Small refinements across dashboard components (#10188)

This PR:

1. Improves typography across the components
2. Refactors Terms of service modal to `<Field />` component
3. Changes the way we render background for modals (we no longer need ::before and relative hacks)
4. Adds ability to nest `<Text />` components and remove paddings of the nested text elements. this is helpful if we want to change styling of a particular word inside the text or we want to display 2 text nodes closer to each other
5. Remove timeot before showing a tooltip on button elements.
This commit is contained in:
Sergei Garin 2024-06-06 11:59:48 +03:00 committed by GitHub
parent 1bc14252df
commit d21140e422
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 239 additions and 89 deletions

View File

@ -7,34 +7,62 @@ import * as twv from 'tailwind-variants'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as text from '../Text'
/**
* Props for the Alert component.
*/
export interface AlertProps extends React.PropsWithChildren, twv.VariantProps<typeof ALERT_STYLES> {
readonly className?: string
}
export interface AlertProps
extends React.PropsWithChildren,
twv.VariantProps<typeof ALERT_STYLES>,
React.HTMLAttributes<HTMLDivElement> {}
export const ALERT_STYLES = twv.tv({
base: 'w-full rounded-md border',
base: 'flex flex-col items-stretch',
variants: {
fullWidth: { true: 'w-full' },
variant: {
custom: '',
neutral: 'bg-gray-200 border-gray-800 text-gray-800',
error: 'bg-red-200 border-red-800 text-red-800',
info: 'bg-blue-200 border-blue-800 text-blue-800',
success: 'bg-green-200 border-green-800 text-green-800',
warning: 'bg-yellow-200 border-yellow-800 text-yellow-800',
outline: 'border border-2 bg-transparent border-primary/30 text-primary',
neutral: 'border border-2 bg-gray-100 border-gray-800 text-primary',
error: 'border border-2 bg-red-100 border-danger text-primary',
info: 'border border-2 bg-blue-100 border-blue-800 text-blue-800',
success: 'border border-2 bg-green-100 border-green-800 text-green-800',
warning: 'border border-2 bg-yellow-100 border-yellow-800 text-yellow-800',
},
rounded: {
none: 'rounded-none',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
},
size: {
custom: '',
small: 'p-1.5 text-xs',
medium: 'p-2.5 text-sm',
large: 'p-4.5 text-lg',
small: text.TEXT_STYLE({
color: 'custom',
variant: 'body',
class: 'px-1.5 pt-1 pb-1',
}),
medium: text.TEXT_STYLE({
color: 'custom',
variant: 'body',
class: 'px-3 pt-1 pb-1',
}),
large: text.TEXT_STYLE({
color: 'custom',
variant: 'subtitle',
class: 'px-4 pt-2 pb-2',
}),
},
},
defaultVariants: {
fullWidth: true,
variant: 'error',
size: 'medium',
rounded: 'large',
},
})
@ -45,14 +73,22 @@ export const Alert = React.forwardRef(function Alert(
props: AlertProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { children, className, variant, size } = props
const { children, className, variant, size, rounded, fullWidth, ...containerProps } = props
if (variant === 'error') {
containerProps.tabIndex = -1
containerProps.role = 'alert'
}
return (
<div
role="alert"
className={ALERT_STYLES({ variant, size, className })}
tabIndex={-1}
ref={mergeRefs.mergeRefs(ref, e => e?.focus())}
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
ref={mergeRefs.mergeRefs(ref, e => {
if (variant === 'error') {
e?.focus()
}
})}
{...containerProps}
>
{children}
</div>

View File

@ -48,12 +48,12 @@ export const POPOVER_STYLES = twv.tv({
},
rounded: {
none: '',
small: 'rounded-sm before:rounded-sm',
medium: 'rounded-md before:rounded-md',
large: 'rounded-lg before:rounded-lg',
xlarge: 'rounded-xl before:rounded-xl',
xxlarge: 'rounded-2xl before:rounded-2xl',
xxxlarge: 'rounded-3xl before:rounded-3xl',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
},
},
slots: {

View File

@ -6,12 +6,12 @@
import * as twv from 'tailwind-variants'
export const DIALOG_BACKGROUND = twv.tv({
base: 'bg-clip-padding relative before:absolute before:inset before:h-full before:w-full before:bg-selected-frame before:backdrop-blur-default [:where(&>*)]:relative',
base: 'bg-white/80 backdrop-blur-md',
})
export const DIALOG_STYLES = twv.tv({
extend: DIALOG_BACKGROUND,
base: 'flex flex-col overflow-hidden text-left align-middle shadow-sm border border-primary/10',
base: 'flex flex-col overflow-hidden text-left align-middle shadow-xl',
variants: {
rounded: {
none: '',

View File

@ -19,13 +19,25 @@ import * as formContext from './useFormContext'
*/
export interface FieldComponentProps
extends twv.VariantProps<typeof FIELD_STYLES>,
types.FieldProps,
React.PropsWithChildren {
types.FieldProps {
readonly name: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any, any>
readonly isInvalid?: boolean
readonly className?: string
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
}
/**
* Props for Field children
*/
export interface FieldChildrenRenderProps {
readonly isInvalid: boolean
readonly isDirty: boolean
readonly isTouched: boolean
readonly isValidating: boolean
// eslint-disable-next-line no-restricted-syntax
readonly error?: string | undefined
}
export const FIELD_STYLES = twv.tv({
@ -37,6 +49,7 @@ export const FIELD_STYLES = twv.tv({
},
},
slots: {
labelContainer: 'contents',
label: text.TEXT_STYLE({ variant: 'subtitle' }),
content: 'flex flex-col items-start w-full',
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
@ -91,13 +104,25 @@ export const Field = React.forwardRef(function Field(
aria-errormessage={hasError ? errorId : ''}
aria-required={isRequired}
>
<aria.Label id={labelId} className={classes.labelContainer()}>
{label != null && (
<aria.Label id={labelId} className={classes.label()}>
<span id={labelId} className={classes.label()}>
{label}
</aria.Label>
</span>
)}
<div className={classes.content()}>{children}</div>
<div className={classes.content()}>
{typeof children === 'function'
? children({
isInvalid: invalid,
isDirty: fieldState.isDirty,
isTouched: fieldState.isTouched,
isValidating: fieldState.isValidating,
error: fieldState.error?.message,
})
: children}
</div>
</aria.Label>
{description != null && (
<span id={descriptionId} className={classes.description()}>

View File

@ -27,8 +27,9 @@ export interface FormErrorProps extends Omit<reactAriaComponents.AlertProps, 'ch
export function FormError(props: FormErrorProps) {
const {
form = formContext.useFormContext(),
size = 'medium',
size = 'large',
variant = 'error',
rounded = 'large',
...alertProps
} = props
@ -61,8 +62,10 @@ export function FormError(props: FormErrorProps) {
const errorMessage = getSubmitError()
return errorMessage != null ? (
<reactAriaComponents.Alert size={size} variant={variant} {...alertProps}>
<reactAriaComponents.Alert size={size} variant={variant} rounded={rounded} {...alertProps}>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
{errorMessage}
</reactAriaComponents.Text>
</reactAriaComponents.Alert>
) : null
}

View File

@ -56,6 +56,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
formnovalidate,
loading = false,
children,
rounded = 'large',
...buttonProps
} = props
@ -68,6 +69,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
rounded={rounded}
type={formnovalidate === true ? 'button' : 'submit'}
variant={variant}
size={size}

View File

@ -29,8 +29,8 @@ export const SEPARATOR_STYLES = twv.tv({
vertical: 'border-l',
},
variant: {
primary: 'border-primary',
secondary: 'border-gray-500',
primary: 'border-primary/30',
inverted: 'border-white/30',
},
},
})

View File

@ -9,6 +9,7 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as textProvider from './TextProvider'
import * as visualTooltip from './useVisualTooltip'
/**
@ -18,12 +19,15 @@ export interface TextProps
extends Omit<aria.TextProps, 'color'>,
twv.VariantProps<typeof TEXT_STYLE> {
readonly lineClamp?: number
readonly tooltip?: React.ReactElement | string | false | null
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
}
export const TEXT_STYLE = twv.tv({
base: 'inline-block',
variants: {
color: {
custom: '',
primary: 'text-primary/60',
danger: 'text-danger',
success: 'text-share',
@ -35,15 +39,16 @@ export const TEXT_STYLE = twv.tv({
// leading should always be after the text size to make sure it is not stripped by twMerge
variant: {
custom: '',
body: 'pt-[1px] pb-[3px] text-xs leading-[20px]',
h1: 'pt-0.5 pb-[7px] text-xl leading-[29px]',
subtitle: 'pt-0.5 pb-[3px] text-[13px] leading-[19px]',
body: 'text-xs leading-[20px] pt-[1px] pb-[3px]',
h1: 'text-xl leading-[29px] pt-[2px] pb-[5px]',
subtitle: 'text-[13.5px] leading-[20px] pt-[1px] pb-[3px]',
},
weight: {
custom: '',
bold: 'font-bold',
semibold: 'font-semibold',
extraBold: 'font-extrabold',
medium: 'font-medium',
normal: 'font-normal',
thin: 'font-thin',
},
@ -73,17 +78,43 @@ export const TEXT_STYLE = twv.tv({
monospace: { true: 'font-mono' },
italic: { true: 'italic' },
nowrap: { true: 'whitespace-nowrap' },
textSelection: {
auto: '',
none: 'select-none',
word: 'select-text',
all: 'select-all',
},
disableLineHeightCompensation: { true: '' },
},
defaultVariants: {
variant: 'body',
weight: 'normal',
weight: 'medium',
transform: 'none',
color: 'primary',
italic: false,
nowrap: false,
monospace: false,
disableLineHeightCompensation: false,
textSelection: 'auto',
},
compoundVariants: [{ variant: 'h1', class: 'font-bold text-balance' }],
compoundVariants: [
{ variant: 'h1', class: 'font-bold' },
{
variant: 'h1',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
},
{
variant: 'body',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
},
{
variant: 'subtitle',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
},
],
})
/**
@ -98,7 +129,7 @@ export const Text = React.forwardRef(function Text(
className,
variant = 'body',
italic = false,
weight = 'normal',
weight = 'medium',
nowrap = false,
monospace = false,
transform = 'none',
@ -108,10 +139,15 @@ export const Text = React.forwardRef(function Text(
color = 'primary',
balance,
elementType: ElementType = 'span',
tooltip: tooltipElement = children,
tooltipDisplay = 'whenOverflowing',
textSelection,
disableLineHeightCompensation = false,
...ariaProps
} = props
const textElementRef = React.useRef<HTMLElement>(null)
const textContext = textProvider.useTextContext()
const textClasses = TEXT_STYLE({
variant,
@ -122,19 +158,32 @@ export const Text = React.forwardRef(function Text(
nowrap,
truncate,
color,
className,
balance,
textSelection,
disableLineHeightCompensation:
disableLineHeightCompensation || textContext.isInsideTextComponent,
className,
})
const isToolipDisabled = () => {
if (tooltipDisplay === 'whenOverflowing') {
return !truncate
} else if (tooltipDisplay === 'always') {
return tooltipElement === false || tooltipElement == null
} else {
return false
}
}
const { tooltip, targetProps } = visualTooltip.useVisualTooltip({
isDisabled: !truncate,
isDisabled: isToolipDisabled(),
targetRef: textElementRef,
display: 'whenOverflowing',
children,
display: tooltipDisplay,
children: tooltipElement,
})
return (
<>
<textProvider.TextProvider value={{ isInsideTextComponent: true }}>
{/* @ts-expect-error We suppose that elementType is a valid HTML element */}
<ElementType
ref={mergeRefs.mergeRefs(ref, textElementRef)}
@ -150,8 +199,9 @@ export const Text = React.forwardRef(function Text(
>
{children}
</ElementType>
{tooltip}
</>
</textProvider.TextProvider>
)
// eslint-disable-next-line no-restricted-syntax
}) as unknown as React.FC<React.RefAttributes<HTMLSpanElement> & TextProps> & {

View File

@ -0,0 +1,30 @@
/**
* @file
*
* Context for the Text component.
*/
import * as React from 'react'
/**
* Context for the Text component.
*/
export interface TextContextType {
/**
* Flag indicating whether the component is inside a Text component.
*/
readonly isInsideTextComponent: boolean
}
const TextContext = React.createContext<TextContextType>({
isInsideTextComponent: false,
})
/**
* Hook to get the Text context.
*/
export function useTextContext(): TextContextType {
return React.useContext(TextContext)
}
// eslint-disable-next-line no-restricted-syntax
export const TextProvider = TextContext.Provider

View File

@ -0,0 +1,7 @@
/**
* @file
*
* Barrel file for the Tooltip component.
*/
export * from './Tooltip'

View File

@ -3,7 +3,7 @@
* Index file for Aria Components
*/
export * from './Button'
export * from './Tooltip/Tooltip'
export * from './Tooltip'
export * from './Dialog'
export * from './Alert'
export * from './CopyBlock'
@ -12,3 +12,4 @@ export * from './Form'
export * from './Text'
export * from './Separator'
export * from './VisuallyHidden'
export * from './Radio'

View File

@ -26,7 +26,7 @@ export interface SvgMaskProps {
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) {
const { invert = false, alt, src, style, color, className } = props
const { invert = false, alt = '', src, style, color, className } = props
const urlSrc = `url(${JSON.stringify(src)})`
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc

View File

@ -51,7 +51,7 @@ function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTML
return tooltipElement == null ? (
button
) : (
<ariaComponents.TooltipTrigger>
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
{button}
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>

View File

@ -98,7 +98,7 @@ export function TermsOfServiceModal() {
isKeyboardDismissDisabled
isDismissable={false}
hideCloseButton
modalProps={{ isOpen: true }}
modalProps={{ defaultOpen: true }}
testId="terms-of-service-modal"
id="terms-of-service-modal"
>
@ -106,40 +106,33 @@ export function TermsOfServiceModal() {
schema={formSchema}
defaultValues={{ agree: false, hash: latestVersionHash }}
testId="terms-of-service-form"
method="dialog"
onSubmit={({ hash }) => {
localStorage.set('termsOfService', { versionHash: hash })
}}
>
{({ register, formState }) => {
const agreeError = formState.errors.agree
const hasError = agreeError != null
return (
{({ register }) => (
<>
<div>
<div className="mb-1">
<div className="flex items-center gap-1.5 text-sm">
<ariaComponents.Form.Field name="agree">
{({ isInvalid }) => (
<>
<div className="flex w-full items-center gap-1">
<aria.Input
type="checkbox"
className={twMerge.twMerge(
`flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2 ${hasError ? 'border-red-700 text-red-500 outline-red-500' : ''}`
`flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2`,
isInvalid && 'border-red-700 text-red-500 outline-red-500'
)}
id={checkboxId}
aria-invalid={hasError}
data-testid="terms-of-service-checkbox"
{...register('agree')}
/>
<aria.Label htmlFor={checkboxId} className="text-sm">
<label htmlFor={checkboxId}>
<ariaComponents.Text>
{getText('licenseAgreementCheckbox')}
</aria.Label>
</div>
{agreeError && (
<p className="m-0 text-xs text-red-700" role="alert">
{agreeError.message}
</p>
)}
</ariaComponents.Text>
</label>
</div>
<ariaComponents.Button
@ -149,14 +142,17 @@ export function TermsOfServiceModal() {
>
{getText('viewLicenseAgreement')}
</ariaComponents.Button>
</div>
</>
)}
</ariaComponents.Form.Field>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit>{getText('accept')}</ariaComponents.Form.Submit>
<ariaComponents.Form.Submit fullWidth>
{getText('accept')}
</ariaComponents.Form.Submit>
</>
)
}}
)}
</ariaComponents.Form>
</ariaComponents.Dialog>
</>

View File

@ -465,7 +465,7 @@
"licenseAgreementTitle": "Enso Terms of Service",
"licenseAgreementCheckbox": "I agree to the Enso Terms of Service",
"licenseAgreementCheckboxError": "You must agree to the Enso Terms of Service",
"licenseAgreementCheckboxError": "Please agree to the Enso Terms of Service",
"viewLicenseAgreement": "View Terms of Service",
"metaModifier": "Meta",