diff --git a/apps/docs/components/IconGallery.tsx b/apps/docs/components/IconGallery.tsx index af90380e..267d5829 100644 --- a/apps/docs/components/IconGallery.tsx +++ b/apps/docs/components/IconGallery.tsx @@ -51,7 +51,7 @@ const IconCell = React.memo( @@ -266,8 +268,10 @@ export function TokenReference({ setActiveSection('primitive'); setSelectedCategory(null); }} - variant={activeSection === 'primitive' ? 'solid' : 'ghost'} - color={activeSection === 'primitive' ? 'primary' : 'secondary'} + {...(activeSection === 'primitive' + ? { variant: 'solid', color: 'primary' } + : { variant: 'ghost', color: 'secondary' })} + size="md" > Primitive Colors @@ -297,16 +301,20 @@ export function TokenReference({ diff --git a/apps/docs/components/TokenReference/TokenSearch.tsx b/apps/docs/components/TokenReference/TokenSearch.tsx index 713adaf6..86826f0a 100644 --- a/apps/docs/components/TokenReference/TokenSearch.tsx +++ b/apps/docs/components/TokenReference/TokenSearch.tsx @@ -44,8 +44,10 @@ export function TokenSearch({ @@ -54,8 +56,10 @@ export function TokenSearch({ key={category} type="button" onClick={() => onCategoryFilter(category)} - variant={selectedCategory === category ? 'solid' : 'outlined'} - color={selectedCategory === category ? 'primary' : 'secondary'} + {...(selectedCategory === category + ? { variant: 'solid', color: 'primary' } + : { variant: 'outlined', color: 'secondary' })} + size="md" > {category} diff --git a/apps/docs/stories/alert-dialog.stories.tsx b/apps/docs/stories/alert-dialog.stories.tsx index 0f190d0f..4dd102ed 100644 --- a/apps/docs/stories/alert-dialog.stories.tsx +++ b/apps/docs/stories/alert-dialog.stories.tsx @@ -113,7 +113,12 @@ export const Default: Story = { checkboxChecked={checked} onCheckboxChange={setChecked} trigger={ - } @@ -124,14 +129,16 @@ export const Default: Story = { color="secondary" prefix={} onClick={() => setOpen(false)} + size="md" > Cancel diff --git a/apps/docs/stories/button-group.mdx b/apps/docs/stories/button-group.mdx new file mode 100644 index 00000000..a9d66fa7 --- /dev/null +++ b/apps/docs/stories/button-group.mdx @@ -0,0 +1,79 @@ +import { Meta, Controls, Primary, Stories } from '@storybook/addon-docs/blocks'; +import * as ButtonGroupStories from './button-group.stories'; + + + +# ButtonGroup + +A segmented cluster of related buttons. It renders `
` with deduped internal +borders and only the outer corners rounded. + +## How to use + +The group lays its children out in a row and squares off the borders between them. Each button +carries its own `variant`, `color` and `size`, and the group takes a `variant` and a `color` of +its own: + +```tsx +import { Button, ButtonGroup } from '@signozhq/ui'; + +export default function MyComponent() { + return ( + + + + + + ); +} +``` + +`variant`, `color` and `size` on the group are mirrored as `data-variant`, `data-color` and +`data-size`, ready for your own selectors and assertions. + + + +## Mixed appearances + +Each member reads its own appearance, so one can differ from the rest: + +```tsx + + + + + +``` + +Icon-only members work the same way, each with its own `aria-label`: + +```tsx +import { ChevronLeft, ChevronRight } from '@signozhq/icons'; + + + + +; +``` + +## Props + + + + diff --git a/apps/docs/stories/button-group.stories.tsx b/apps/docs/stories/button-group.stories.tsx index 2152b78a..340581df 100644 --- a/apps/docs/stories/button-group.stories.tsx +++ b/apps/docs/stories/button-group.stories.tsx @@ -1,11 +1,18 @@ import { ChevronLeft, ChevronRight, Code } from '@signozhq/icons'; -import { Button, ButtonColor, ButtonGroup, ButtonSize, ButtonVariant } from '@signozhq/ui'; +import { + Button, + ButtonColor, + ButtonGroup, + ButtonSize, + ButtonVariant, + type VariantColorType, +} from '@signozhq/ui'; import type { Meta, StoryObj } from '@storybook/react-vite'; import styles from './button-group.stories.module.css'; import { COLORS, VARIANTS } from './shared/button-arg-types.js'; const meta: Meta = { - title: 'Primitive Components/Button/ButtonGroup', + title: 'Primitive Components/ButtonGroup', component: ButtonGroup, parameters: { layout: 'fullscreen', @@ -15,22 +22,17 @@ const meta: Meta = { variant: { control: 'select', options: VARIANTS, - description: - 'Default `variant` applied to descendant Buttons that do not set their own `variant`.', - table: { defaultValue: { summary: 'solid' } }, + description: 'Mirrored on the group element as `data-variant`.', }, size: { control: 'select', - options: ['sm', 'md', 'icon'], - description: 'Default `size` applied to descendant Buttons that do not set their own `size`.', - table: { defaultValue: { summary: 'md' } }, + options: ['sm', 'md'], + description: 'Mirrored on the group element as `data-size`.', }, color: { control: 'select', options: COLORS, - description: - 'Default `color` applied to descendant Buttons that do not set their own `color`.', - table: { defaultValue: { summary: 'primary' } }, + description: 'Mirrored on the group element as `data-color`.', }, testId: { control: 'text', @@ -49,13 +51,28 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: (args) => ( - - - - - - ), + render: ({ variant, size, color, ...args }) => { + // The controls pick `variant` and `color` independently, so the pair has to be + // re-asserted before it reaches the group. + const appearance = { + variant: variant ?? ButtonVariant.Outlined, + color: color ?? ButtonColor.Secondary, + } as VariantColorType; + + return ( + + + + + + ); + }, }; export const Variants: Story = { @@ -63,19 +80,37 @@ export const Variants: Story = { render: () => (
- - - + + + - - - - + + + + - - - - + + + +
), @@ -92,8 +127,12 @@ export const Sizes: Story = { variant={ButtonVariant.Outlined} color={ButtonColor.Secondary} > - - + + ))}
@@ -104,10 +143,34 @@ export const IconCluster: Story = { parameters: { controls: { disable: true } }, render: () => (
- - + +
), @@ -118,9 +181,15 @@ export const PerButtonOverride: Story = { render: () => (
- - - + + +
), diff --git a/apps/docs/stories/button.mdx b/apps/docs/stories/button.mdx index 20b0bd31..9f7d1462 100644 --- a/apps/docs/stories/button.mdx +++ b/apps/docs/stories/button.mdx @@ -1,22 +1,27 @@ import { Meta, Controls, Primary } from '@storybook/addon-docs/blocks'; import * as ButtonStories from './button.stories'; -import * as ButtonGroupStories from './button-group.stories'; -import { Button } from '@signozhq/ui'; # Button -A versatile button that maps to a native `; + return ( + + ); } ``` @@ -24,119 +29,169 @@ export default function MyComponent() { ## Variants -`variant` controls the visual treatment. `solid` is the default; `outlined` + `dashed` work best paired with `color="secondary"`; `ghost` is a borderless text-only button; `link` styles the button as inline text; `action` reads its background from the surrounding surface (see [Action buttons](#action-buttons)). +`variant` picks the visual treatment. `solid` is filled, `outlined` and `dashed` are bordered, +`ghost` is borderless, and `link` styles the button as inline text. + +`solid` and `link` take every `color`. The other three only exist in the secondary treatment, so +`color="secondary"` is the only value they accept. Anything else is a type error. ```tsx - - - - - + + + + + ``` ## Colors -`color` swaps the palette behind any variant. Pair with `variant` to get the right combination. +`color` swaps the palette behind `solid` and `link`: `primary`, `secondary`, `danger`, `warning`, +`success`, `info`, `archive` and `highlight-danger`. ```tsx - - - - + + + + ``` ## Sizes -`size="sm"` and `size="md"` (default) cover the common cases. `size="icon"` produces a square button sized for a single icon child. +`size="sm"` is 24px tall, `size="md"` is 32px. In `icon` mode both are square. ```tsx - - - + + ``` -## Icons (prefix / suffix) +## Icons -Place an icon before or after the label with `prefix` / `suffix`. For an icon-only button, combine `prefix` with `size="icon"`. +`prefix` and `suffix` place an element before or after the label. For an icon-only button, set +`icon` and pass the icon as the only child: `prefix` and `suffix` are not allowed in that mode, +and `aria-label` is required because there is no text to announce. ```tsx - - - + + ``` ## Loading -`loading` disables the button and replaces the leading icon with a spinner. The `suffix` is hidden while loading. +`loading` stops the button from responding to clicks and keyboard activation, and cross-fades a +spinner over the `prefix` slot. The label and `suffix` stay visible. ```tsx - ``` -## Action buttons - -`variant="action"` reads its background from the surface it's placed on via the `background` prop (`ink-500`, `ink-400`, `vanilla-100`, `vanilla-200`). +`loadingTooltip` is optional and says what the button is busy with, on hover or focus, for as long +as `loading` is true. The spinner alone is already a valid busy state, so reach for it when the +wait is long enough that the user deserves to know what is happening. ```tsx -
- -
+ ``` -## `asChild` +## Text overflow -Render the button as the immediate child element (via Radix `Slot`) instead of a native ` + ``` -`loading`, `prefix`, and `suffix` are not supported in this mode. +The tooltip belongs to `ellipsis` alone, and icon buttons (`icon`) have no label, so they never +get one. -## Native attributes +## Disabled -`ButtonProps` extends `HTMLButtonElement`'s attribute surface. All standard event handlers (`onClick`, `onMouseEnter`/`Leave`, `onFocus`/`Blur`, `onKeyDown`/`Up`, etc.) plus `aria-*`, `data-*`, `tabIndex`, `id`, `title`, and `type="submit" | "button" | "reset"` are typed and forwarded. +`disabledTooltip` is mandatory whenever `disabled` is set, and it only opens while the button is +disabled. The two travel together in both directions: a `disabledTooltip` without `disabled` is a +type error too. ```tsx -
- -
+ ``` -## ButtonGroup +A native `disabled` button receives no hover or focus events, so the tooltip would be unreachable +on it, and clicking a button that goes native-disabled mid-interaction throws the focus away. The +button therefore never uses the native attribute: it carries `aria-disabled` instead, which keeps +clicks and keyboard activation blocked but leaves the button hoverable and in the tab order. -`ButtonGroup` is a segmented cluster of related buttons. It renders `
` with deduped internal borders and only the outer corners rounded. `size`, `variant`, and `color` set on the group are inherited by descendant `Button`s through context — per-button props still take precedence. +## Stacked tooltips -```tsx -import { Button, ButtonGroup } from '@signozhq/ui'; +The button owns up to two tooltips at a time: a reason, and the full text of a truncated label. +Two tooltips anchored to the same element would open on the same hover and render on top of each +other, so both go into one popup, the reason first and the label under it, with a divider between +them. - - - - -; -``` +The reason slot holds `loadingTooltip` while the button is loading, and `disabledTooltip` while it +is disabled and idle. Only one of them can ever show. -Override the color of a single member without touching the rest of the group: +Wrapping the button in a tooltip of your own adds your title to that same popup, above both: ```tsx - - - - - + + + ``` -## Button Props +``` +Removes every rule, cannot be undone +──────────────────────────────────── +You need write access to edit alerts +──────────────────────────────────── +Delete every alert rule in this workspace +``` - +While `loading`, `disabledTooltip` drops out because the spinner already says the button is busy, +and `loadingTooltip` takes its place. A truncated label keeps its entry either way. -## ButtonGroup Props +## Props - + diff --git a/apps/docs/stories/button.stories.module.css b/apps/docs/stories/button.stories.module.css index 3df91c53..e5e96347 100644 --- a/apps/docs/stories/button.stories.module.css +++ b/apps/docs/stories/button.stories.module.css @@ -4,17 +4,6 @@ gap: 3rem; } -.capitalizeText { - display: block; - text-transform: capitalize; -} - -.buttonVariantGrid { - display: grid; - grid-template-columns: 1fr; - gap: 1rem; -} - .sectionGapLarge { display: flex; flex-direction: column; @@ -25,53 +14,24 @@ margin-top: 1rem; } -.marginBottomMedium { - display: block; - margin-bottom: 1rem; -} - -.twoColumnGrid { +.matrix { display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; -} - -.inkBackground500 { - padding: 1.5rem; - background-color: var(--bg-ink-500); - border-radius: 0.5rem; + grid-template-columns: max-content repeat(var(--matrix-columns), max-content); + gap: 0.75rem 1.25rem; + align-items: center; + justify-items: start; + overflow-x: auto; + padding-bottom: 4px; } -.inkBackground400 { - padding: 1.5rem; - background-color: var(--bg-ink-400); - border-radius: 0.5rem; +.matrixLabel { + white-space: nowrap; } -.vanillaBackground100 { - padding: 1.5rem; - background-color: var(--bg-vanilla-100); - border-radius: 0.5rem; -} - -.vanillaBackground200 { - padding: 1.5rem; - background-color: var(--bg-vanilla-200); - border-radius: 0.5rem; -} - -.lightText { - display: block; - color: var(--text-vanilla-100); - margin-bottom: 1rem; -} - -.mutedMarginBottom { - display: block; - margin-bottom: 1rem; -} - -.marginBottomSmall { - display: block; - margin-bottom: 0.75rem; +.overflowGrid { + display: grid; + grid-template-columns: max-content 12rem minmax(0, 1fr); + gap: 8rem 1.5rem; + padding-block-start: 4rem; + align-items: center; } diff --git a/apps/docs/stories/button.stories.tsx b/apps/docs/stories/button.stories.tsx index 13614d3d..29a25b3e 100644 --- a/apps/docs/stories/button.stories.tsx +++ b/apps/docs/stories/button.stories.tsx @@ -1,16 +1,29 @@ import { Check, ChevronLeft, ChevronRight, Code, Star, Trash } from '@signozhq/icons'; import { Button, - ButtonBackground, ButtonColor, + type ButtonProps, ButtonSize, + ButtonTextOverflow, ButtonVariant, + type SizeType, + Tooltip, + TooltipProvider, + type VariantColorType, Typography, } from '@signozhq/ui'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { type CSSProperties, Fragment, type ReactElement, useEffect, useState } from 'react'; +import { expect, fireEvent, fn, waitFor, within } from 'storybook/test'; import styles from './button.stories.module.css'; -import { buttonArgTypes, COLORS, VARIANTS } from './shared/button-arg-types.js'; +import { + buttonArgTypes, + COLORS, + MULTI_COLOR_VARIANTS, + resolveVariantColor, + SECONDARY_ONLY_VARIANTS, + VARIANTS, +} from './shared/button-arg-types.js'; const meta: Meta = { title: 'Primitive Components/Button', @@ -28,10 +41,6 @@ const meta: Meta = { argTypes: buttonArgTypes, parameters: { layout: 'fullscreen', - design: { - type: 'figma', - url: 'https://www.figma.com/file/...', - }, backgrounds: { disable: true, }, @@ -41,6 +50,10 @@ const meta: Meta = { type: 'code', }, }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/eyORbfrXMWCz9w0xEFdgWe/Periscope-%E2%80%93-Primitives-v2?node-id=12-739&p=f&m=dev', + }, test: { dangerouslyIgnoreUnhandledErrors: true }, }, }; @@ -51,6 +64,8 @@ type Story = StoryObj; export const Default: Story = { parameters: { docs: { story: { autoplay: true } }, + // Playground: every state it can be driven into is covered by `ButtonShowcase`. + chromatic: { disableSnapshot: true }, }, argTypes: { prefix: { @@ -59,6 +74,7 @@ export const Default: Story = { description: 'The prefix for the button, will be displayed before the button text, can be anything such as an icon or a text. For this playground, the only options are icons.', table: { + category: 'Content', type: { summary: 'React.ReactElement' }, }, }, @@ -68,23 +84,12 @@ export const Default: Story = { description: 'The suffix for the button, will be displayed after the button text, can be anything such as an icon or a text. For this playground, the only options are icons.', table: { + category: 'Content', type: { summary: 'React.ReactElement' }, }, }, }, render: ({ prefix, suffix, ...args }) => { - if (args.asChild) { - return ( - - ); - } - switch (prefix?.toString()) { case 'chevron-left': prefix = ; @@ -121,305 +126,549 @@ export const Default: Story = { break; } + // outlined/dashed/ghost only accept `secondary`, so the free color control is clamped here. + // `icon` is dropped because this playground drives the prefix/suffix slots instead. + const { variant, color, icon: _icon, ...rest } = args; + return ( - ); }, }; -// Main showcase of all button styles -export const ButtonShowcase: Story = { - parameters: { - docs: { story: { autoplay: true } }, - }, - render: () => ( -
-
- {COLORS.map((color) => ( -
- - {color} - -
- {/* Filter variants based on color */} - {VARIANTS.filter( - (variant) => - // Only show outlined and dashed for secondary - color === 'secondary' || !(variant === 'outlined' || variant === 'dashed'), - ).map((variant) => ( -
- - - - -
- ))} -
-
- ))} -
-
- ), +const DISABLED_REASON = 'You need write access to edit alerts'; +const LOADING_REASON = 'Deleting the rules, this can take a minute'; +const NESTED_TOOLTIP_TITLE = 'Removes every rule, cannot be undone'; +const LONG_LABEL = 'Delete every alert rule in this workspace'; +const CONSTRAINED_WIDTH = '12rem'; + +/** + * The columns of both matrices below. `hover`, `focus` and `active` cannot be reached by a + * snapshot on their own, `storybook-addon-pseudo-states` forces them through the + * `[data-pseudo]` selectors in the story parameters. + */ +const STATES = ['default', 'hover', 'focus', 'active', 'disabled', 'loading'] as const; + +type State = (typeof STATES)[number]; + +type StateProps = { + disabled?: boolean; + disabledTooltip?: string; + loading?: boolean; + 'data-pseudo'?: State; }; -// Size Variations -export const Sizes: Story = { - parameters: { - controls: { disable: false }, - }, - args: { - variant: ButtonVariant.Solid, - color: ButtonColor.Primary, - }, - argTypes: { - variant: { - control: 'select', - options: VARIANTS, - }, - color: { - control: 'select', - options: COLORS, - }, - }, - render: (args) => ( -
-
- - Size Variations - -
- {[ButtonSize.SM, ButtonSize.MD].map((size) => ( -
- - {size} - - -
- ))} -
-
-
+function stateProps(state: State): StateProps { + switch (state) { + case 'default': + return {}; + case 'disabled': + return { disabled: true, disabledTooltip: DISABLED_REASON }; + case 'loading': + return { loading: true }; + default: + return { 'data-pseudo': state }; + } +} + +/** + * Every pair the types allow: any color for solid/link, `secondary` only for the other three. + */ +const VARIANT_COLORS: VariantColorType[] = [ + ...MULTI_COLOR_VARIANTS.flatMap((variant) => + COLORS.map((color) => resolveVariantColor(variant, color)), ), + ...SECONDARY_ONLY_VARIANTS.map((variant) => resolveVariantColor(variant)), +]; + +type Composition = { + id: string; + label: string; + prefix?: ReactElement; + suffix?: ReactElement; + icon?: true; }; -// Icon Only Buttons -export const IconButtons: Story = { - parameters: { - controls: { disable: false }, - }, - args: { - variant: ButtonVariant.Solid, - color: ButtonColor.Primary, - }, - argTypes: { - variant: { - control: 'select', - options: VARIANTS, - }, - color: { - control: 'select', - options: COLORS, - }, - }, - render: (args) => ( -
-
- - Icon Only Buttons - - - Icon only buttons are buttons that only have an icon as their content. These buttons are - useful when you need to display an icon in a button without any text. You can just specify - the button as: -
<Button suffix={<Code />} size="icon"/>
-
-
- {VARIANTS.map((variant) => ( -
-
-
- - Icon Button Sizes - - - By default, the icon will be displayed at the size of the button. You can also specify the - size of the icon by passing the "size" prop to the icon. +const COMPOSITIONS: Composition[] = [ + { id: 'label', label: 'label only' }, + { id: 'prefix', label: 'prefix', prefix: }, + { id: 'suffix', label: 'suffix', suffix: }, + { id: 'affixes', label: 'prefix + suffix', prefix: , suffix: }, + { id: 'icon', label: 'icon only', icon: true }, +]; + +const COMPOSITION_STATES: { label: string; size: SizeType; state: State }[] = [ + { label: 'sm', size: ButtonSize.SM, state: 'default' }, + { label: 'md', size: ButtonSize.MD, state: 'default' }, + { label: 'sm hover', size: ButtonSize.SM, state: 'hover' }, + { label: 'md loading', size: ButtonSize.MD, state: 'loading' }, + { label: 'md disabled', size: ButtonSize.MD, state: 'disabled' }, +]; + +function MatrixHeader({ columns }: { columns: string[] }): ReactElement { + return ( + <> + + {columns.map((column) => ( + + {column} -
- {[ButtonSize.SM, ButtonSize.MD, ButtonSize.Icon].map((size) => ( -
-
-
- ), -}; + ))} + + ); +} + +function matrixStyle(columns: number): CSSProperties { + return { '--matrix-columns': columns } as CSSProperties; +} -// Add Action Button Story -export const ActionButtons: Story = { +/** + * One button per variant/color pair in a single state. + */ +function StateCell({ + variantColor, + state, +}: { + variantColor: VariantColorType; + state: State; +}): ReactElement { + return ( + + ); +} + +/** + * One button per prefix/suffix/icon composition in a single size + state. + */ +function CompositionCell({ + composition, + size, + state, +}: { + composition: Composition; + size: SizeType; + state: State; +}): ReactElement { + const props = stateProps(state); + + if (composition.icon === true) { + return ( + + ); + } + + return ( + + ); +} + +/** + * The buttons whose tooltip the `play` function opens, in the order they appear. + */ +const FORCED_TOOLTIPS = [ + 'overflow-truncated', + 'overflow-disabled', + 'overflow-nested', + 'overflow-nested-disabled', +]; + +/** + * Every variant, color, size, composition and state in one snapshot, with the four tooltip + * combinations held open and every animation frozen. + */ +export const ButtonShowcase: Story = { parameters: { - controls: { disable: false }, - }, - argTypes: { - background: { - control: 'select', - options: [ - ButtonBackground.Ink500, - ButtonBackground.Ink400, - ButtonBackground.Vanilla100, - ButtonBackground.Vanilla200, - ], - description: 'The background context for the action button', + chromatic: { disableSnapshot: false, disableAnimations: true }, + pseudo: { + hover: '[data-pseudo="hover"]', + focusVisible: '[data-pseudo="focus"]', + active: '[data-pseudo="active"]', }, }, - args: { - variant: ButtonVariant.Action, - background: ButtonBackground.Ink500, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + for (const testId of FORCED_TOOLTIPS) { + const trigger = canvas.getByTestId(testId); + + // Base UI opens the tooltip when the pointer enters the trigger. `userEvent.hover` + // moves one pointer around, so each hover would close the tooltip before it, while + // synthetic events leave all four open in the same snapshot. + fireEvent.pointerEnter(trigger); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + } + + await waitFor(() => + expect(document.querySelectorAll('[data-slot="tooltip-content"]')).toHaveLength( + FORCED_TOOLTIPS.length, + ), + ); }, + // `story-freeze-animations`: the dashed border marches on hover, the ghost glow shimmers + // while active and the spinner never stops, so the snapshot would catch each one mid-frame. render: () => ( -
-
- - Action Buttons - - - Action buttons adapt their style based on the background they are placed on. - - -
- {/* ink-500 background */} -
- On ink-500 background +
+
+
+ + States + + + One row per variant/color pair the types allow, one column per state. hover + , focus and active are forced by{' '} + storybook-addon-pseudo-states. Only ghost styles{' '} + active, and only outlined restyles disabled (the + stripes), the rest carry the shared opacity. + +
+ + {VARIANT_COLORS.map(({ variant, color }) => ( + + + {variant} / {color} + + {STATES.map((state) => ( + + ))} + + ))} +
+
+
+ + Composition + + + The prefix/suffix/icon slots against both sizes. While loading, the spinner cross-fades + over the prefix slot, which opens up on buttons that have no prefix, and the suffix + stays where it is. + +
+ label)} /> + {COMPOSITIONS.map((composition) => ( + + + {composition.label} + + {COMPOSITION_STATES.map(({ label, size, state }) => ( + + ))} + + ))} +
+
+
+ + Overflow and tooltips + + + Every button below is capped at {CONSTRAINED_WIDTH}. The four rows that + have something to say are held open by the story's play function, so the + snapshot carries the popups themselves, including the stacked ones. + +
+ + ellipsis + -
+ + The default: truncates and hands the full label to a tooltip, only while it is + actually truncated. + - {/* ink-400 background */} -
- On ink-400 background + + ellipsis, label fits + -
+ + Same mode, short label. Nothing is truncated, so there is no tooltip to open. + - {/* vanilla-100 background */} -
- - On vanilla-100 background + + none -
+ + Clips the label at the button's edge, with no marker and no tooltip. + - {/* vanilla-200 background */} -
- - On vanilla-200 background + + disabled + truncated -
-
-
+ + Two entries in one popup, the reason first and the full label under it. + -
- - Disabled Action Buttons - -
- {/* Disabled examples */} -
+ + tooltip + truncated + + + + + + + + A button that is already a tooltip trigger stacks into that popup instead of opening a + second one on the same hover: your title, then the label. + + + + tooltip + disabled + truncated + + + + + + + + All three entries stack, dividers between them: title, reason, label. + + + + loading + disabled + truncated + -
-
+ + The spinner already says the button is busy, so the reason is dropped. The truncated + label still has its own tooltip. + + + + loadingTooltip + disabled + truncated + + + loadingTooltip takes the place of the disabled reason for as long as the + button is loading, above the truncated label. +
), }; + +const LOADING_DURATION_MS = 5_000; + +/** + * Flips to `loading` for 5s on click, then back, the usual "fire a request and wait" pattern. + */ +function LoadingOnClickButton(props: ButtonProps): ReactElement { + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!loading) { + return; + } + + const timeout = setTimeout(() => setLoading(false), LOADING_DURATION_MS); + + return () => clearTimeout(timeout); + }, [loading]); + + return
diff --git a/apps/docs/stories/command.stories.tsx b/apps/docs/stories/command.stories.tsx index ff36d37b..cca0b177 100644 --- a/apps/docs/stories/command.stories.tsx +++ b/apps/docs/stories/command.stories.tsx @@ -180,7 +180,13 @@ export const Dialog: Story = { return ( <>
-
diff --git a/apps/docs/stories/confirm-dialog-url.stories.tsx b/apps/docs/stories/confirm-dialog-url.stories.tsx index 509d9acc..ba01eaf0 100644 --- a/apps/docs/stories/confirm-dialog-url.stories.tsx +++ b/apps/docs/stories/confirm-dialog-url.stories.tsx @@ -22,7 +22,7 @@ export const Default: Story = { title: 'Delete from URL param', confirmText: 'Delete', cancelText: 'Cancel', - confirmColor: 'destructive', + confirmColor: 'danger', children: 'This confirm dialog is controlled via a URL query parameter using nuqs.', width: 'narrow', }, @@ -42,6 +42,7 @@ Default.decorators = [ variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(true)} + size="md" > Open confirm dialog (URL) diff --git a/apps/docs/stories/confirm-dialog.stories.tsx b/apps/docs/stories/confirm-dialog.stories.tsx index 6c770bd0..365bc6ff 100644 --- a/apps/docs/stories/confirm-dialog.stories.tsx +++ b/apps/docs/stories/confirm-dialog.stories.tsx @@ -22,7 +22,7 @@ export const Default: Story = { children: 'Are you sure you want to proceed? This action cannot be undone.', cancelText: 'Cancel', confirmText: 'Confirm', - confirmColor: 'destructive', + confirmColor: 'danger', width: 'narrow', }, render: (args) => { @@ -34,6 +34,7 @@ export const Default: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(true)} + size="md" > Open confirm dialog diff --git a/apps/docs/stories/confirm-drawer-url.stories.tsx b/apps/docs/stories/confirm-drawer-url.stories.tsx index 14a04dc0..e81214f7 100644 --- a/apps/docs/stories/confirm-drawer-url.stories.tsx +++ b/apps/docs/stories/confirm-drawer-url.stories.tsx @@ -37,7 +37,7 @@ export const Default: Story = { title: 'Delete from URL param', confirmText: 'Delete', cancelText: 'Cancel', - confirmColor: 'destructive', + confirmColor: 'danger', children: 'This confirm drawer is controlled via a URL query parameter using nuqs.', direction: 'right', width: 'narrow', @@ -58,6 +58,7 @@ Default.decorators = [ variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(true)} + size="md" > Open confirm drawer (URL) diff --git a/apps/docs/stories/confirm-drawer.stories.tsx b/apps/docs/stories/confirm-drawer.stories.tsx index 2fcd5a9b..a71d3777 100644 --- a/apps/docs/stories/confirm-drawer.stories.tsx +++ b/apps/docs/stories/confirm-drawer.stories.tsx @@ -36,7 +36,7 @@ export const Default: Story = { children: 'Are you sure you want to proceed? This action cannot be undone.', cancelText: 'Cancel', confirmText: 'Confirm', - confirmColor: 'destructive', + confirmColor: 'danger', direction: 'right', }, render: (args) => { @@ -48,6 +48,7 @@ export const Default: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(true)} + size="md" > Open confirm drawer diff --git a/apps/docs/stories/data-table.stories.tsx b/apps/docs/stories/data-table.stories.tsx index 93ba7d71..6f2959e3 100644 --- a/apps/docs/stories/data-table.stories.tsx +++ b/apps/docs/stories/data-table.stories.tsx @@ -12,7 +12,6 @@ import { Badge, DataTable as BaseDataTable, Button, - ButtonColor, type ColumnDef, type Row, Typography, @@ -425,28 +424,13 @@ const enhancedColumns: ColumnDef[] = [ cell: () => { return (
- - -
@@ -1025,28 +1009,13 @@ export const AllFeatures: StoryObj> = { cell: () => { return (
- - -
@@ -1305,28 +1274,13 @@ export const VirtualizationWithFeatures: StoryObj> = { cell: () => { return (
- - -
@@ -1866,7 +1820,7 @@ export const ScrollToIndex: StoryObj> = { -
diff --git a/apps/docs/stories/date-picker.stories.tsx b/apps/docs/stories/date-picker.stories.tsx index 918877a8..b35fdf36 100644 --- a/apps/docs/stories/date-picker.stories.tsx +++ b/apps/docs/stories/date-picker.stories.tsx @@ -118,7 +118,7 @@ const meta: Meta = { }, buttonColor: { control: 'select', - options: ['primary', 'destructive', 'warning', 'secondary', 'none'], + options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info', 'decorative'], description: 'Button color for the trigger.', table: { category: 'Appearance', diff --git a/apps/docs/stories/dialog-close.stories.tsx b/apps/docs/stories/dialog-close.stories.tsx index 2d6a4db9..9f5d5305 100644 --- a/apps/docs/stories/dialog-close.stories.tsx +++ b/apps/docs/stories/dialog-close.stories.tsx @@ -22,19 +22,19 @@ export const Default: Story = { render: (args) => ( + } footer={ <> - - diff --git a/apps/docs/stories/dialog-content.stories.tsx b/apps/docs/stories/dialog-content.stories.tsx index 842302fc..2c2b6797 100644 --- a/apps/docs/stories/dialog-content.stories.tsx +++ b/apps/docs/stories/dialog-content.stories.tsx @@ -35,7 +35,7 @@ export const Default: Story = { render: (args) => ( - @@ -49,10 +49,10 @@ export const Default: Story = { - - diff --git a/apps/docs/stories/dialog-description.stories.tsx b/apps/docs/stories/dialog-description.stories.tsx index be60f8ab..9d09025c 100644 --- a/apps/docs/stories/dialog-description.stories.tsx +++ b/apps/docs/stories/dialog-description.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-footer.stories.tsx b/apps/docs/stories/dialog-footer.stories.tsx index 246adc19..5a477dd4 100644 --- a/apps/docs/stories/dialog-footer.stories.tsx +++ b/apps/docs/stories/dialog-footer.stories.tsx @@ -31,7 +31,7 @@ export const Default: Story = { render: (args) => ( - @@ -45,10 +45,10 @@ export const Default: Story = { - - diff --git a/apps/docs/stories/dialog-header.stories.tsx b/apps/docs/stories/dialog-header.stories.tsx index d6b7d942..ff696aa6 100644 --- a/apps/docs/stories/dialog-header.stories.tsx +++ b/apps/docs/stories/dialog-header.stories.tsx @@ -30,7 +30,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-overlay.stories.tsx b/apps/docs/stories/dialog-overlay.stories.tsx index 7ea0d48d..5081daf9 100644 --- a/apps/docs/stories/dialog-overlay.stories.tsx +++ b/apps/docs/stories/dialog-overlay.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-portal.stories.tsx b/apps/docs/stories/dialog-portal.stories.tsx index 18b6213b..317e45a4 100644 --- a/apps/docs/stories/dialog-portal.stories.tsx +++ b/apps/docs/stories/dialog-portal.stories.tsx @@ -31,7 +31,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-primitive.stories.tsx b/apps/docs/stories/dialog-primitive.stories.tsx index 6e2fd9cb..9c2ce49b 100644 --- a/apps/docs/stories/dialog-primitive.stories.tsx +++ b/apps/docs/stories/dialog-primitive.stories.tsx @@ -47,7 +47,7 @@ export const Default: Story = { }} > - @@ -67,6 +67,7 @@ export const Default: Story = { variant={ButtonVariant.Ghost} color="secondary" onClick={() => setOpen(false)} + size="md" > Cancel @@ -74,6 +75,7 @@ export const Default: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Confirm diff --git a/apps/docs/stories/dialog-subtitle.stories.tsx b/apps/docs/stories/dialog-subtitle.stories.tsx index abd43678..6565d62a 100644 --- a/apps/docs/stories/dialog-subtitle.stories.tsx +++ b/apps/docs/stories/dialog-subtitle.stories.tsx @@ -34,7 +34,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-title.stories.tsx b/apps/docs/stories/dialog-title.stories.tsx index 1b6c8721..dee6a5e4 100644 --- a/apps/docs/stories/dialog-title.stories.tsx +++ b/apps/docs/stories/dialog-title.stories.tsx @@ -34,7 +34,7 @@ export const Default: Story = { render: (args) => ( - @@ -60,7 +60,7 @@ export const WithIcon: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-trigger.stories.tsx b/apps/docs/stories/dialog-trigger.stories.tsx index 84f67b6f..afd0603c 100644 --- a/apps/docs/stories/dialog-trigger.stories.tsx +++ b/apps/docs/stories/dialog-trigger.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dialog-wrapper.stories.tsx b/apps/docs/stories/dialog-wrapper.stories.tsx index 61f0cb27..23aec781 100644 --- a/apps/docs/stories/dialog-wrapper.stories.tsx +++ b/apps/docs/stories/dialog-wrapper.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { + } @@ -41,7 +41,7 @@ export const Default: Story = { Dialog content goes here.
- diff --git a/apps/docs/stories/dialog.stories.tsx b/apps/docs/stories/dialog.stories.tsx index da8d7eb0..21c01a26 100644 --- a/apps/docs/stories/dialog.stories.tsx +++ b/apps/docs/stories/dialog.stories.tsx @@ -49,7 +49,7 @@ export const Default: Story = { }} > - @@ -69,6 +69,7 @@ export const Default: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Save Changes @@ -94,7 +95,7 @@ export const Controlled: Story = { title="Controlled Dialog" titleIcon={} trigger={ - } @@ -103,6 +104,7 @@ export const Controlled: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Close Dialog @@ -133,7 +135,7 @@ export const WidthVariants: Story = { title={`${width.charAt(0).toUpperCase() + width.slice(1)} width`} width={width} trigger={ - } @@ -145,6 +147,7 @@ export const WidthVariants: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(null)} + size="md" > Close @@ -172,7 +175,7 @@ export const PositionVariants: Story = { onOpenChange={(v) => setOpen(v ? position : null)} > - @@ -200,6 +203,7 @@ export const PositionVariants: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(null)} + size="md" > Close @@ -228,7 +232,7 @@ export const WithoutCloseButton: Story = { title="Dialog without close button" showCloseButton={false} trigger={ - } @@ -239,7 +243,7 @@ export const WithoutCloseButton: Story = {
- @@ -256,7 +260,7 @@ export const Primitive: Story = { return ( - @@ -277,6 +281,7 @@ export const Primitive: Story = { variant={ButtonVariant.Ghost} color="secondary" onClick={() => setOpen(false)} + size="md" > Cancel @@ -284,6 +289,7 @@ export const Primitive: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Confirm diff --git a/apps/docs/stories/drawer-close.stories.tsx b/apps/docs/stories/drawer-close.stories.tsx index 5ab31a32..06f3ca6a 100644 --- a/apps/docs/stories/drawer-close.stories.tsx +++ b/apps/docs/stories/drawer-close.stories.tsx @@ -34,7 +34,7 @@ export const Default: Story = { render: (args) => ( - @@ -46,11 +46,11 @@ export const Default: Story = { This drawer uses DrawerClose in the footer for the close action. - - diff --git a/apps/docs/stories/drawer-content.stories.tsx b/apps/docs/stories/drawer-content.stories.tsx index 1868badc..c80395a2 100644 --- a/apps/docs/stories/drawer-content.stories.tsx +++ b/apps/docs/stories/drawer-content.stories.tsx @@ -57,7 +57,7 @@ export const Default: Story = { render: (args) => ( - @@ -71,10 +71,10 @@ export const Default: Story = { - - diff --git a/apps/docs/stories/drawer-description.stories.tsx b/apps/docs/stories/drawer-description.stories.tsx index 2e367230..99e9bf93 100644 --- a/apps/docs/stories/drawer-description.stories.tsx +++ b/apps/docs/stories/drawer-description.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-footer.stories.tsx b/apps/docs/stories/drawer-footer.stories.tsx index 47410867..25d4af22 100644 --- a/apps/docs/stories/drawer-footer.stories.tsx +++ b/apps/docs/stories/drawer-footer.stories.tsx @@ -31,7 +31,7 @@ export const Default: Story = { render: (args) => ( - @@ -45,10 +45,10 @@ export const Default: Story = { - - diff --git a/apps/docs/stories/drawer-header.stories.tsx b/apps/docs/stories/drawer-header.stories.tsx index cfbca74d..b8a62b7d 100644 --- a/apps/docs/stories/drawer-header.stories.tsx +++ b/apps/docs/stories/drawer-header.stories.tsx @@ -30,7 +30,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-overlay.stories.tsx b/apps/docs/stories/drawer-overlay.stories.tsx index 93fcf173..f2b45726 100644 --- a/apps/docs/stories/drawer-overlay.stories.tsx +++ b/apps/docs/stories/drawer-overlay.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-portal.stories.tsx b/apps/docs/stories/drawer-portal.stories.tsx index 56a0e6bf..b884b346 100644 --- a/apps/docs/stories/drawer-portal.stories.tsx +++ b/apps/docs/stories/drawer-portal.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-subtitle.stories.tsx b/apps/docs/stories/drawer-subtitle.stories.tsx index 25218b64..2a195641 100644 --- a/apps/docs/stories/drawer-subtitle.stories.tsx +++ b/apps/docs/stories/drawer-subtitle.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-title.stories.tsx b/apps/docs/stories/drawer-title.stories.tsx index f410b31f..cf494fb0 100644 --- a/apps/docs/stories/drawer-title.stories.tsx +++ b/apps/docs/stories/drawer-title.stories.tsx @@ -33,7 +33,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-trigger.stories.tsx b/apps/docs/stories/drawer-trigger.stories.tsx index 5957abc4..e6494f28 100644 --- a/apps/docs/stories/drawer-trigger.stories.tsx +++ b/apps/docs/stories/drawer-trigger.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/drawer-wrapper.stories.tsx b/apps/docs/stories/drawer-wrapper.stories.tsx index c812ed38..075f0415 100644 --- a/apps/docs/stories/drawer-wrapper.stories.tsx +++ b/apps/docs/stories/drawer-wrapper.stories.tsx @@ -109,8 +109,10 @@ export const Default: Story = { ), footer: (
- - +
@@ -128,19 +130,20 @@ export const Default: Story = { open={open} onOpenChange={setOpen} trigger={ - } footer={
- diff --git a/apps/docs/stories/drawer.stories.tsx b/apps/docs/stories/drawer.stories.tsx index 676b4b67..530c90ab 100644 --- a/apps/docs/stories/drawer.stories.tsx +++ b/apps/docs/stories/drawer.stories.tsx @@ -48,7 +48,7 @@ const DrawerPositionVariant = ({ }} > - @@ -67,6 +67,7 @@ const DrawerPositionVariant = ({ variant={ButtonVariant.Ghost} color="secondary" onClick={() => setOpen(false)} + size="md" > Cancel @@ -74,6 +75,7 @@ const DrawerPositionVariant = ({ variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Confirm @@ -115,7 +117,7 @@ export const WithoutOverlay: Story = { }} > - @@ -135,6 +137,7 @@ export const WithoutOverlay: Story = { variant={ButtonVariant.Ghost} color="secondary" onClick={() => setOpen(false)} + size="md" > Cancel @@ -142,6 +145,7 @@ export const WithoutOverlay: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Primary} onClick={() => setOpen(false)} + size="md" > Confirm diff --git a/apps/docs/stories/dropdown-menu-back.stories.tsx b/apps/docs/stories/dropdown-menu-back.stories.tsx index 52be1a95..f2bc7210 100644 --- a/apps/docs/stories/dropdown-menu-back.stories.tsx +++ b/apps/docs/stories/dropdown-menu-back.stories.tsx @@ -46,7 +46,7 @@ export const Default: Story = {
!open && setStep('main')}> - diff --git a/apps/docs/stories/dropdown-menu-checkbox-item.stories.tsx b/apps/docs/stories/dropdown-menu-checkbox-item.stories.tsx index 315b557d..95485100 100644 --- a/apps/docs/stories/dropdown-menu-checkbox-item.stories.tsx +++ b/apps/docs/stories/dropdown-menu-checkbox-item.stories.tsx @@ -60,7 +60,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-content.stories.tsx b/apps/docs/stories/dropdown-menu-content.stories.tsx index da08849e..617e39c9 100644 --- a/apps/docs/stories/dropdown-menu-content.stories.tsx +++ b/apps/docs/stories/dropdown-menu-content.stories.tsx @@ -97,7 +97,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-group.stories.tsx b/apps/docs/stories/dropdown-menu-group.stories.tsx index 2992e3da..e4aa1526 100644 --- a/apps/docs/stories/dropdown-menu-group.stories.tsx +++ b/apps/docs/stories/dropdown-menu-group.stories.tsx @@ -34,7 +34,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-item.stories.tsx b/apps/docs/stories/dropdown-menu-item.stories.tsx index 43e8c20e..f1853311 100644 --- a/apps/docs/stories/dropdown-menu-item.stories.tsx +++ b/apps/docs/stories/dropdown-menu-item.stories.tsx @@ -58,7 +58,7 @@ export const Default: Story = {
- @@ -88,7 +88,7 @@ export const WithShortcut: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-label.stories.tsx b/apps/docs/stories/dropdown-menu-label.stories.tsx index 899acbc0..79bb30f4 100644 --- a/apps/docs/stories/dropdown-menu-label.stories.tsx +++ b/apps/docs/stories/dropdown-menu-label.stories.tsx @@ -47,7 +47,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-loading.stories.tsx b/apps/docs/stories/dropdown-menu-loading.stories.tsx index 0825b374..07088591 100644 --- a/apps/docs/stories/dropdown-menu-loading.stories.tsx +++ b/apps/docs/stories/dropdown-menu-loading.stories.tsx @@ -39,7 +39,7 @@ export const Default: Story = {
- @@ -59,7 +59,7 @@ export const CustomText: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-multi-step.stories.tsx b/apps/docs/stories/dropdown-menu-multi-step.stories.tsx index d52aca62..99f280a9 100644 --- a/apps/docs/stories/dropdown-menu-multi-step.stories.tsx +++ b/apps/docs/stories/dropdown-menu-multi-step.stories.tsx @@ -47,7 +47,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-portal.stories.tsx b/apps/docs/stories/dropdown-menu-portal.stories.tsx index 17c9173f..1f240b40 100644 --- a/apps/docs/stories/dropdown-menu-portal.stories.tsx +++ b/apps/docs/stories/dropdown-menu-portal.stories.tsx @@ -39,7 +39,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/dropdown-menu-radio-group.stories.tsx b/apps/docs/stories/dropdown-menu-radio-group.stories.tsx index d76307fa..21383460 100644 --- a/apps/docs/stories/dropdown-menu-radio-group.stories.tsx +++ b/apps/docs/stories/dropdown-menu-radio-group.stories.tsx @@ -46,7 +46,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-radio-item.stories.tsx b/apps/docs/stories/dropdown-menu-radio-item.stories.tsx index ff7a0362..7445a6e5 100644 --- a/apps/docs/stories/dropdown-menu-radio-item.stories.tsx +++ b/apps/docs/stories/dropdown-menu-radio-item.stories.tsx @@ -54,7 +54,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-root.stories.tsx b/apps/docs/stories/dropdown-menu-root.stories.tsx index 1ad7f55d..9c782242 100644 --- a/apps/docs/stories/dropdown-menu-root.stories.tsx +++ b/apps/docs/stories/dropdown-menu-root.stories.tsx @@ -67,7 +67,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-search.stories.tsx b/apps/docs/stories/dropdown-menu-search.stories.tsx index 81bf6803..760f59a6 100644 --- a/apps/docs/stories/dropdown-menu-search.stories.tsx +++ b/apps/docs/stories/dropdown-menu-search.stories.tsx @@ -56,7 +56,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-separator.stories.tsx b/apps/docs/stories/dropdown-menu-separator.stories.tsx index f22f7474..b247b707 100644 --- a/apps/docs/stories/dropdown-menu-separator.stories.tsx +++ b/apps/docs/stories/dropdown-menu-separator.stories.tsx @@ -32,7 +32,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-shortcut.stories.tsx b/apps/docs/stories/dropdown-menu-shortcut.stories.tsx index 7782ef73..143d8e1c 100644 --- a/apps/docs/stories/dropdown-menu-shortcut.stories.tsx +++ b/apps/docs/stories/dropdown-menu-shortcut.stories.tsx @@ -40,7 +40,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-simple.stories.tsx b/apps/docs/stories/dropdown-menu-simple.stories.tsx index e578f07b..2765a5eb 100644 --- a/apps/docs/stories/dropdown-menu-simple.stories.tsx +++ b/apps/docs/stories/dropdown-menu-simple.stories.tsx @@ -114,7 +114,7 @@ export const Default: Story = { render: (args) => (
- @@ -152,7 +152,7 @@ export const Basic: Story = { return (
- @@ -231,19 +231,19 @@ export const WithIcons: Story = { return (
- - - @@ -286,7 +286,7 @@ export const Destructive: Story = { return (
- @@ -343,7 +343,7 @@ export const WithSectionLabels: Story = { return (
- @@ -429,13 +429,13 @@ export const Checkable: Story = { return (
- - @@ -526,7 +526,7 @@ export const NestedMenus: Story = { return (
- @@ -556,7 +556,7 @@ export const Loading: Story = { render: () => (
- @@ -622,7 +622,7 @@ export const WithSearch: Story = { }, }} > - @@ -728,7 +728,7 @@ export const AllStates: Story = {
- @@ -741,7 +741,7 @@ export const AllStates: Story = {
- @@ -754,7 +754,7 @@ export const AllStates: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-sub-content.stories.tsx b/apps/docs/stories/dropdown-menu-sub-content.stories.tsx index 9c9107d0..21921c67 100644 --- a/apps/docs/stories/dropdown-menu-sub-content.stories.tsx +++ b/apps/docs/stories/dropdown-menu-sub-content.stories.tsx @@ -63,7 +63,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-sub-trigger.stories.tsx b/apps/docs/stories/dropdown-menu-sub-trigger.stories.tsx index 29aafb46..a4f5c67b 100644 --- a/apps/docs/stories/dropdown-menu-sub-trigger.stories.tsx +++ b/apps/docs/stories/dropdown-menu-sub-trigger.stories.tsx @@ -52,7 +52,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/dropdown-menu-sub.stories.tsx b/apps/docs/stories/dropdown-menu-sub.stories.tsx index ea76f98c..269e05d3 100644 --- a/apps/docs/stories/dropdown-menu-sub.stories.tsx +++ b/apps/docs/stories/dropdown-menu-sub.stories.tsx @@ -23,7 +23,7 @@ function SubMenuFrame({
- diff --git a/apps/docs/stories/dropdown-menu-trigger.stories.tsx b/apps/docs/stories/dropdown-menu-trigger.stories.tsx index 22ec1c84..69d6d7e6 100644 --- a/apps/docs/stories/dropdown-menu-trigger.stories.tsx +++ b/apps/docs/stories/dropdown-menu-trigger.stories.tsx @@ -47,7 +47,7 @@ export const Default: Story = {
- diff --git a/apps/docs/stories/popover-content.stories.tsx b/apps/docs/stories/popover-content.stories.tsx index 63646138..34f1140a 100644 --- a/apps/docs/stories/popover-content.stories.tsx +++ b/apps/docs/stories/popover-content.stories.tsx @@ -33,7 +33,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/popover-primitive.stories.tsx b/apps/docs/stories/popover-primitive.stories.tsx index 86104e8b..44690c58 100644 --- a/apps/docs/stories/popover-primitive.stories.tsx +++ b/apps/docs/stories/popover-primitive.stories.tsx @@ -43,7 +43,7 @@ export const Default: Story = { }} > - diff --git a/apps/docs/stories/popover-simple.stories.tsx b/apps/docs/stories/popover-simple.stories.tsx index 989987a3..b1ee9e0a 100644 --- a/apps/docs/stories/popover-simple.stories.tsx +++ b/apps/docs/stories/popover-simple.stories.tsx @@ -27,7 +27,7 @@ export const Default: Story = { + } diff --git a/apps/docs/stories/popover-trigger.stories.tsx b/apps/docs/stories/popover-trigger.stories.tsx index 9597e6f3..3470bd54 100644 --- a/apps/docs/stories/popover-trigger.stories.tsx +++ b/apps/docs/stories/popover-trigger.stories.tsx @@ -30,7 +30,7 @@ export const Default: Story = { render: (args) => ( - diff --git a/apps/docs/stories/popover.stories.tsx b/apps/docs/stories/popover.stories.tsx index 3104f223..8a5cb0a3 100644 --- a/apps/docs/stories/popover.stories.tsx +++ b/apps/docs/stories/popover.stories.tsx @@ -38,7 +38,7 @@ export const Default: Story = {
- @@ -106,6 +106,7 @@ export const DateAndTimePicker: Story = { color={ButtonColor.Primary} id="date-picker" className={styles.datePickerTrigger} + size="md" > {date ? `${date.toLocaleDateString()} : ${time}` : 'Select date'} @@ -161,6 +162,7 @@ export const PopoverShowcase: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Secondary} className={styles.capitalizedButton} + size="md" > {side} @@ -185,6 +187,7 @@ export const PopoverShowcase: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Secondary} className={styles.capitalizedButton} + size="md" > {align} @@ -204,7 +207,7 @@ export const PopoverShowcase: Story = {
- @@ -214,7 +217,7 @@ export const PopoverShowcase: Story = { - @@ -231,7 +234,7 @@ export const PopoverShowcase: Story = { - @@ -247,7 +250,7 @@ export const PopoverShowcase: Story = { - diff --git a/apps/docs/stories/select-content.stories.tsx b/apps/docs/stories/select-content.stories.tsx index fbbad68a..e5c35708 100644 --- a/apps/docs/stories/select-content.stories.tsx +++ b/apps/docs/stories/select-content.stories.tsx @@ -161,7 +161,9 @@ export const InsidePopover: Story = {
- +
diff --git a/apps/docs/stories/shared/button-arg-types.ts b/apps/docs/stories/shared/button-arg-types.ts index f8182206..5d5c08f6 100644 --- a/apps/docs/stories/shared/button-arg-types.ts +++ b/apps/docs/stories/shared/button-arg-types.ts @@ -1,83 +1,254 @@ -import { type Button, ButtonColor, ButtonVariant } from '@signozhq/ui'; +import { + type Button, + ButtonColor, + ButtonTextOverflow, + ButtonVariant, + type ColorType, + type VariantColorType, + type VariantType, +} from '@signozhq/ui'; import type { Meta } from '@storybook/react-vite'; export const VARIANTS = Object.values(ButtonVariant); export const COLORS = Object.values(ButtonColor); +/** + * Variants that accept any `ButtonColor`. + */ +export const MULTI_COLOR_VARIANTS = [ButtonVariant.Solid, ButtonVariant.Link] as VariantType[]; + +/** + * Variants restricted to `secondary`. + */ +export const SECONDARY_ONLY_VARIANTS = [ + ButtonVariant.Outlined, + ButtonVariant.Dashed, + ButtonVariant.Ghost, +] as VariantType[]; + +/** + * The colors a variant accepts: everything for solid/link, only `secondary` for + * outlined/dashed/ghost. + */ +export function colorsForVariant(variant: VariantType): ColorType[] { + return SECONDARY_ONLY_VARIANTS.includes(variant) ? [ButtonColor.Secondary] : COLORS; +} + +/** + * Clamps a variant/color pair to a combination the `Button` types allow, so stories with a free + * color control keep rendering something valid when the variant only supports `secondary`. + */ +export function resolveVariantColor( + variant: VariantType = ButtonVariant.Solid, + color: ColorType = ButtonColor.Primary, +): VariantColorType { + if (SECONDARY_ONLY_VARIANTS.includes(variant)) { + return { variant: variant as 'outlined' | 'dashed' | 'ghost', color: ButtonColor.Secondary }; + } + + return { variant: variant as 'solid' | 'link', color }; +} + export const buttonArgTypes: Meta['argTypes'] = { + // Content + children: { + control: 'text', + description: + 'The label of the button. Required, an empty button is not allowed. In `icon` mode this is the icon itself and must be a single element.', + table: { category: 'Content', type: { summary: 'React.ReactNode' } }, + }, + prefix: { + control: false, + description: + 'Element rendered before the label. The sizing class is merged into its own `className`. Not allowed in `icon` mode.', + table: { category: 'Content', type: { summary: 'React.ReactElement' } }, + }, + suffix: { + control: false, + description: + 'Element rendered after the label. The sizing class is merged into its own `className`. Not allowed in `icon` mode.', + table: { category: 'Content', type: { summary: 'React.ReactElement' } }, + }, + disabledTooltip: { + control: 'text', + description: + 'Why the button cannot be used. Required whenever `disabled` is set, and only allowed alongside it. Opens only while `disabled` is true and `loading` is not. Stacks above the `ellipsis` overflow tooltip.', + table: { category: 'Content', type: { summary: 'React.ReactNode' } }, + }, + loadingTooltip: { + control: 'text', + description: + 'What the button is busy with. Optional, and opens only while `loading` is true. Takes the place of `disabledTooltip`, which is suppressed while loading. Stacks above the `ellipsis` overflow tooltip.', + table: { category: 'Content', type: { summary: 'React.ReactNode' } }, + }, + + // Appearance variant: { control: 'select', options: VARIANTS, - description: 'The visual style of the button', - table: { - defaultValue: { summary: 'solid' }, - }, - }, - size: { - control: 'select', - options: ['sm', 'md', 'icon'], - description: 'The size of the button', - table: { - defaultValue: { summary: 'md' }, - }, + description: + 'The visual treatment. Required. `solid` and `link` accept every `color`, `outlined`, `dashed` and `ghost` only exist with `color="secondary"`.', + table: { category: 'Appearance', type: { summary: 'VariantType' } }, }, color: { control: 'select', options: COLORS, - description: 'The color scheme of the button', + description: + 'The palette behind the variant. Required. Only `solid` and `link` accept every color, `outlined`, `dashed` and `ghost` are restricted to `secondary`.', + table: { category: 'Appearance', type: { summary: 'ColorType' } }, }, - disabled: { - control: 'boolean', - description: 'Whether the button is disabled', - table: { - defaultValue: { summary: 'false' }, - type: { summary: 'boolean' }, - }, + size: { + control: 'select', + options: ['sm', 'md'], + description: 'Height and padding token. Required. `sm` is 24px, `md` is 32px.', + table: { category: 'Appearance', type: { summary: 'SizeType' } }, }, - asChild: { + icon: { control: 'boolean', - description: 'Whether to render as a child component', - table: { - type: { summary: 'boolean' }, - }, + description: + 'Renders the button square, optimized for a single icon. The children are the icon, so `prefix` and `suffix` are not allowed and `aria-label` is required.', + table: { category: 'Appearance', type: { summary: 'true' } }, }, - background: { + textOverflow: { control: 'select', - options: ['ink-500', 'ink-400', 'vanilla-100', 'vanilla-200'], + options: Object.values(ButtonTextOverflow), description: - 'The background context for the action button. Only applicable to *Action* buttons.', + 'What happens to the label when it does not fit. `ellipsis` truncates and shows the full text in a tooltip, only while it is actually truncated. `none` clips at the edge with no marker and no tooltip.', table: { - type: { summary: 'string' }, + category: 'Appearance', + type: { summary: 'TextOverflowType' }, + defaultValue: { summary: 'ellipsis' }, }, }, + width: { + control: 'text', + description: + 'Width of the button, written as the `--button-internal-width` custom property so it composes with the tokens. Numbers are written as `px`.', + table: { category: 'Appearance', type: { summary: 'CSSProperties["width"]' } }, + }, + maxWidth: { + control: 'text', + description: + 'Max width of the button, written as the `--button-internal-max-width` custom property so it composes with the tokens. Numbers are written as `px`.', + table: { category: 'Appearance', type: { summary: 'CSSProperties["maxWidth"]' } }, + }, + + // State + disabled: { + control: 'boolean', + description: + 'Stops `onClick`, `onDoubleClick` and keyboard activation. Requires `disabledTooltip`. The button carries `aria-disabled` rather than the native attribute, so it stays hoverable and tabbable and the tooltip stays reachable.', + table: { category: 'State', type: { summary: 'boolean' } }, + }, loading: { control: 'boolean', - description: 'Whether the button is loading', + description: + 'Cross-fades a spinner over the `prefix` slot and stops the button from responding to clicks and keyboard activation. The label and `suffix` stay visible. Suppresses `disabledTooltip` even when `disabled` is true, `loadingTooltip` takes its place.', table: { - defaultValue: { summary: 'false' }, + category: 'State', type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, }, }, + + // Behavior type: { control: 'select', - options: ['button', 'submit'], - description: 'The type of the button', + options: ['button', 'submit', 'reset'], + description: + 'The native button type. Base UI defaults it to `button`, so a button inside a form does not submit unless asked to.', table: { - defaultValue: { summary: 'submit' }, + category: 'Behavior', + type: { summary: "'button' | 'submit' | 'reset'" }, + defaultValue: { summary: 'button' }, }, }, + tabIndex: { + control: 'number', + description: 'Forwarded to the rendered element.', + table: { category: 'Behavior', type: { summary: 'number' } }, + }, + autoFocus: { + control: 'boolean', + description: 'Focuses the button on mount.', + table: { category: 'Behavior', type: { summary: 'boolean' } }, + }, + + // Accessibility + 'aria-label': { + control: 'text', + description: + 'The accessible name. Required in `icon` mode, where the children are an icon and there is no text to announce. Every other `aria-*` attribute is forwarded as well.', + table: { category: 'Accessibility', type: { summary: 'string' } }, + }, + + // Events onClick: { + control: false, action: 'onClick', - description: 'The function to call when the button is clicked', - table: { - type: { summary: 'function' }, - }, + description: 'Called on click. Swallowed while `disabled` or `loading`.', + table: { category: 'Events', type: { summary: 'MouseEventHandler' } }, }, onDoubleClick: { + control: false, action: 'onDoubleClick', - description: 'The function to call when the button is double clicked', - table: { - type: { summary: 'function' }, - }, + description: 'Called on double click. Swallowed while `disabled` or `loading`.', + table: { category: 'Events', type: { summary: 'MouseEventHandler' } }, + }, + onKeyDown: { + control: false, + description: 'Forwarded to the rendered element.', + table: { category: 'Events', type: { summary: 'KeyboardEventHandler' } }, + }, + onKeyUp: { + control: false, + description: 'Forwarded to the rendered element.', + table: { category: 'Events', type: { summary: 'KeyboardEventHandler' } }, + }, + onFocus: { + control: false, + description: 'Forwarded to the rendered element. Still fires while `disabled` or `loading`.', + table: { category: 'Events', type: { summary: 'FocusEventHandler' } }, + }, + onBlur: { + control: false, + description: 'Forwarded to the rendered element.', + table: { category: 'Events', type: { summary: 'FocusEventHandler' } }, + }, + onMouseEnter: { + control: false, + description: 'Forwarded to the rendered element. Still fires while `disabled` or `loading`.', + table: { category: 'Events', type: { summary: 'MouseEventHandler' } }, + }, + onMouseLeave: { + control: false, + description: 'Forwarded to the rendered element.', + table: { category: 'Events', type: { summary: 'MouseEventHandler' } }, + }, + + // Testing + testId: { + control: 'text', + description: + 'Forwarded as `data-testid`. Survives the tooltip trigger cloning the button, which a raw `data-testid` prop does not, so that prop is a type error.', + table: { category: 'Testing', type: { summary: 'string' } }, + }, + + // Styling + id: { + control: 'text', + description: 'Forwarded to the rendered element.', + table: { category: 'Styling', type: { summary: 'string' } }, + }, + className: { + control: 'text', + description: 'Merged after the component class, never replaces it.', + table: { category: 'Styling', type: { summary: 'string' } }, + }, + style: { + control: false, + description: + 'Merged with the `width` / `maxWidth` custom properties. The place to override `--button-*` tokens per call site.', + table: { category: 'Styling', type: { summary: 'React.CSSProperties' } }, }, }; diff --git a/apps/docs/stories/sonner.stories.tsx b/apps/docs/stories/sonner.stories.tsx index 96ac327b..8664a19d 100644 --- a/apps/docs/stories/sonner.stories.tsx +++ b/apps/docs/stories/sonner.stories.tsx @@ -1,4 +1,4 @@ -import { Button, ButtonColor, Toaster, toast, Typography } from '@signozhq/ui'; +import { Button, Toaster, toast, Typography } from '@signozhq/ui'; import type { Meta, StoryObj } from '@storybook/react-vite'; import styles from './sonner.stories.module.css'; @@ -18,20 +18,22 @@ export const BasicToasts: Story = { Basic Toast Examples
- @@ -39,6 +41,7 @@ export const BasicToasts: Story = { onClick={() => toast.warning('Warning! Please check your input.')} variant="solid" color="warning" + size="md" > Warning Toast @@ -46,6 +49,7 @@ export const BasicToasts: Story = { onClick={() => toast.info('Info: Here is some information.')} variant="solid" color="secondary" + size="md" > Info Toast @@ -71,6 +75,7 @@ export const ToastWithDescriptions: Story = { } variant="solid" color="primary" + size="md" > With Description @@ -81,7 +86,8 @@ export const ToastWithDescriptions: Story = { }) } variant="solid" - color="destructive" + color="danger" + size="md" > Error with Description @@ -93,6 +99,7 @@ export const ToastWithDescriptions: Story = { } variant="solid" color="primary" + size="md" > Success with Description @@ -121,6 +128,7 @@ export const ToastWithActions: Story = { } variant="solid" color="primary" + size="md" > With Action Button @@ -135,7 +143,8 @@ export const ToastWithActions: Story = { }) } variant="solid" - color="destructive" + color="danger" + size="md" > Error with Action @@ -151,6 +160,7 @@ export const ToastWithActions: Story = { } variant="solid" color="primary" + size="md" > Success with Action @@ -177,7 +187,7 @@ export const ToastPositions: Story = { onClick={() => toast('Top left', { position: 'top-left' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Top Left @@ -185,7 +195,7 @@ export const ToastPositions: Story = { onClick={() => toast('Top center', { position: 'top-center' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Top Center @@ -193,7 +203,7 @@ export const ToastPositions: Story = { onClick={() => toast('Top right', { position: 'top-right' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Top Right @@ -208,7 +218,7 @@ export const ToastPositions: Story = { onClick={() => toast('Bottom left', { position: 'bottom-left' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Bottom Left @@ -216,7 +226,7 @@ export const ToastPositions: Story = { onClick={() => toast('Bottom center', { position: 'bottom-center' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Bottom Center @@ -224,7 +234,7 @@ export const ToastPositions: Story = { onClick={() => toast('Bottom right', { position: 'bottom-right' })} variant="outlined" size="sm" - color={ButtonColor.None} + color="secondary" > Bottom Right @@ -248,6 +258,7 @@ export const ToastDurations: Story = { onClick={() => toast('Quick message', { duration: 1000 })} variant="solid" color="primary" + size="md" > 1 Second @@ -255,6 +266,7 @@ export const ToastDurations: Story = { onClick={() => toast('Standard message', { duration: 4000 })} variant="solid" color="primary" + size="md" > 4 Seconds @@ -262,6 +274,7 @@ export const ToastDurations: Story = { onClick={() => toast('Long message', { duration: 8000 })} variant="solid" color="primary" + size="md" > 8 Seconds @@ -269,6 +282,7 @@ export const ToastDurations: Story = { onClick={() => toast('Persistent message', { duration: Infinity })} variant="solid" color="warning" + size="md" > Persistent @@ -297,6 +311,7 @@ export const CustomStyledToasts: Story = { } variant="solid" color="primary" + size="md" > Custom Styled @@ -311,6 +326,7 @@ export const CustomStyledToasts: Story = { } variant="solid" color="primary" + size="md" > Gradient Toast @@ -325,6 +341,7 @@ export const CustomStyledToasts: Story = { } variant="solid" color="warning" + size="md" > Custom Warning @@ -353,6 +370,7 @@ export const ToastWithPromises: Story = { }} variant="solid" color="primary" + size="md" > Promise Toast @@ -375,6 +393,7 @@ export const ToastWithPromises: Story = { }} variant="solid" color="primary" + size="md" > Random Promise @@ -389,6 +408,7 @@ export const ToastWithPromises: Story = { }} variant="solid" color="primary" + size="md" > File Upload @@ -415,6 +435,7 @@ export const MultipleToasts: Story = { }} variant="solid" color="primary" + size="md" > Show Multiple @@ -427,6 +448,7 @@ export const MultipleToasts: Story = { }} variant="solid" color="primary" + size="md" > Different Types @@ -439,6 +461,7 @@ export const MultipleToasts: Story = { }} variant="solid" color="primary" + size="md" > Different Positions @@ -459,7 +482,7 @@ export const Default: Story = { Click the buttons below to see different types of toasts in action.
-
diff --git a/apps/docs/stories/table.stories.tsx b/apps/docs/stories/table.stories.tsx index 09245e0a..040df388 100644 --- a/apps/docs/stories/table.stories.tsx +++ b/apps/docs/stories/table.stories.tsx @@ -11,7 +11,6 @@ import { import { Badge, Button, - ButtonColor, Table, TableBody, TableCaption, @@ -267,25 +266,15 @@ export const Enhanced: Story = {
- - @@ -312,7 +312,7 @@ export const TooltipShowcase: Story = { variant={ButtonVariant.Solid} color={ButtonColor.Secondary} size={ButtonSize.MD} - style={{ width: TRIGGER_WIDTH }} + width={TRIGGER_WIDTH} > {label} @@ -470,7 +470,8 @@ export const TooltipShowcase: Story = { @@ -116,10 +117,12 @@ export const AnnouncementBanner = React.forwardRef} + size="md" + icon onClick={onClose} - /> + > + + )}
); diff --git a/packages/ui/src/button-group/__tests__/button-group.test.tsx b/packages/ui/src/button-group/__tests__/button-group.test.tsx new file mode 100644 index 00000000..6e50c79d --- /dev/null +++ b/packages/ui/src/button-group/__tests__/button-group.test.tsx @@ -0,0 +1,115 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Button } from '../../button/index.js'; +import { ButtonGroup } from '../button-group.js'; + +describe('ButtonGroup rendering', () => { + it('renders a div with role=group', () => { + render( + + + , + ); + + const group = screen.getByRole('group'); + expect(group.tagName).toBe('DIV'); + expect(group).toBe(screen.getByTestId('group')); + }); + + it('renders every child button', () => { + render( + + + + , + ); + + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + + it('exposes size, variant and color on the group element', () => { + render( + + + , + ); + + const group = screen.getByTestId('group'); + expect(group).toHaveAttribute('data-size', 'sm'); + expect(group).toHaveAttribute('data-variant', 'outlined'); + expect(group).toHaveAttribute('data-color', 'secondary'); + }); + + it('keeps the component class next to a custom className', () => { + render( + + + , + ); + + const group = screen.getByTestId('group'); + expect(group).toHaveClass('custom-class'); + expect(group.className.split(' ').length).toBeGreaterThan(1); + }); + + it('forwards arbitrary div attributes', () => { + render( + + + , + ); + + const group = screen.getByRole('group', { name: 'Time range' }); + expect(group).toHaveAttribute('id', 'ranges'); + }); + + it('forwards ref to the group element', () => { + const ref = createRef(); + render( + + + , + ); + + expect(ref.current).toBe(screen.getByRole('group')); + }); +}); + +describe('ButtonGroup child styling', () => { + it('styles the cluster only, children keep their own tokens', () => { + render( + + + + , + ); + + const a = screen.getByTestId('a'); + expect(a).toHaveAttribute('data-size', 'md'); + expect(a).toHaveAttribute('data-variant', 'solid'); + expect(a).toHaveAttribute('data-color', 'primary'); + + const b = screen.getByTestId('b'); + expect(b).toHaveAttribute('data-size', 'sm'); + expect(b).toHaveAttribute('data-color', 'danger'); + }); +}); diff --git a/packages/ui/src/button-group/button-group.module.scss b/packages/ui/src/button-group/button-group.module.scss index d5968327..38604c34 100644 --- a/packages/ui/src/button-group/button-group.module.scss +++ b/packages/ui/src/button-group/button-group.module.scss @@ -7,6 +7,10 @@ .button-group > * { border-radius: 0; position: relative; + /* The dashed variant draws its border as an SVG rect, and a single `rx` cannot + express the per-corner rounding a grouped button has. Squaring it off leaves + only the group's two outer corners uncurved, instead of rounding all four. */ + --button-border-radius: 0; } .button-group > * + * { diff --git a/packages/ui/src/button-group/button-group.tsx b/packages/ui/src/button-group/button-group.tsx index 31f54f2e..735eccda 100644 --- a/packages/ui/src/button-group/button-group.tsx +++ b/packages/ui/src/button-group/button-group.tsx @@ -1,91 +1,76 @@ -import type React from 'react'; -import { forwardRef, useMemo } from 'react'; +import { forwardRef, type HTMLAttributes } from 'react'; import { cn } from '../lib/utils'; -import { - type ButtonColorValue, - ButtonGroupContext, - type ButtonSizeValue, - type ButtonVariantValue, -} from '../button'; +import { type SizeType, type VariantColorType } from '../button'; import styles from './button-group.module.scss'; export type ButtonGroupProps = { /** - * Default `size` applied to descendant `Button`s that do not set their own `size`. - * Individual buttons can still override this locally. + * Mirrored on the group element as `data-size`. Not inherited by the buttons: set `size` + * on every child too. */ - size?: ButtonSizeValue; - /** - * Default `variant` applied to descendant `Button`s that do not set their own `variant`. - * Individual buttons can still override this locally. - */ - variant?: ButtonVariantValue; - /** - * Default `color` applied to descendant `Button`s that do not set their own `color`. - * Individual buttons can still override this locally (e.g. to mark one action destructive). - */ - color?: ButtonColorValue; + size?: SizeType; /** * Forwarded to the rendered group element as `data-testid`. */ testId?: string; -} & Omit, 'color'>; +} & Omit, 'color'> & + VariantColorType; /** * Segmented cluster of related buttons. Renders as `
` with * inline-flex children, deduped internal borders, and only the outer corners - * rounded. `size` / `variant` / `color` set on the group propagate to descendant - * `Button`s through context — per-button props still take precedence. + * rounded. `size` / `variant` / `color` set on the group style the cluster itself, + * they are not inherited by the buttons: set them on every child too. * * @example * ```tsx * // Time-range segmented control — all three buttons share the group's variant + color * - * - * - * + * + * + * * * ``` * * @example * ```tsx - * // Per-button override — last button opts into a destructive color + * // Per-button override — last button opts into a danger color * - * - * - * + * + * + * * * ``` * * @example * ```tsx * // Icon-only navigation cluster - * - * + * * * ``` */ const ButtonGroup = forwardRef( ({ size, variant, color, className, children, testId, ...props }, ref) => { - const value = useMemo(() => ({ size, variant, color, inGroup: true }), [size, variant, color]); - return ( - - {/* biome-ignore lint/a11y/useSemanticElements:
is the standard ButtonGroup pattern; alternatives (fieldset/menu) carry unwanted semantics. */} -
- {children} -
- + // biome-ignore lint/a11y/useSemanticElements:
is the standard ButtonGroup pattern; alternatives (fieldset/menu) carry unwanted semantics. +
+ {children} +
); }, ); diff --git a/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap b/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap new file mode 100644 index 00000000..51495af2 --- /dev/null +++ b/packages/ui/src/button/__tests__/__snapshots__/button.types.messages.test.ts.snap @@ -0,0 +1,49 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Button type error messages > prints a message that names the constraint that failed 1`] = ` +{ + "disabled and disabledTooltip > a possibly undefined disabled still needs a reason > \`disabled\` typed \`boolean | undefined\` is still \`disabled\`": "TS2741: Property ''\`disabled\` needs \`disabledTooltip\`, a disabled control has to tell the user why it cannot be used'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabled: boolean | undefined; }' but required in type 'ADisabledButtonMustSayWhy'.", + "disabled and disabledTooltip > a reason without disabled is rejected > \`disabledTooltip\` never renders unless \`disabled\` is set": "TS2741: Property ''\`disabledTooltip\` only renders while \`disabled\` is set, add \`disabled\` or drop the tooltip'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabledTooltip: string; }' but required in type 'ADisabledReasonNeedsADisabledButton'.", + "disabled and disabledTooltip > disabled without a reason is rejected > a disabled button must explain itself through \`disabledTooltip\`": "TS2741: Property ''\`disabled\` needs \`disabledTooltip\`, a disabled control has to tell the user why it cannot be used'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; disabled: true; }' but required in type 'ADisabledButtonMustSayWhy'.", + "icon mode > an icon without an accessible name is rejected > an icon button has no visible text, so \`aria-label\` is required": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Property ''aria-label'' is missing in type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; }' but required in type 'IconButtonProps'.", + "icon mode > children are required > a button with nothing inside it is not allowed": "TS2322: Type '{ size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Property 'children' is missing in type '{ size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' but required in type 'ButtonBaseProps'.", + "icon mode > icon only accepts true > drop the prop for a text button, \`icon={false}\` is not a mode": "TS2322: Type 'false' is not assignable to type 'true'.", + "icon mode > prefix and suffix are rejected, the children are the icon > \`prefix\` has no slot to render into in icon mode": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; prefix: Element; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'prefix' are incompatible. + Type 'Element' is not assignable to type 'undefined'.", + "icon mode > prefix and suffix are rejected, the children are the icon > \`suffix\` has no slot to render into in icon mode": "TS2322: Type '{ children: Element; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; suffix: Element; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'suffix' are incompatible. + Type 'Element' is not assignable to type 'undefined'.", + "icon mode > text children are rejected > icon mode renders an element, not a label": "TS2322: Type '{ children: string; size: "md"; variant: "solid"; color: "primary"; icon: true; "aria-label": string; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'children' are incompatible. + Type 'string' is not assignable to type 'ReactNode & ReactElement>'.", + "prefix, suffix and children > children are required > a button with nothing inside it is not allowed": "TS2322: Type '{ size: "md"; variant: "solid"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Type '{ size: "md"; variant: "solid"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'. + Property 'children' is missing in type '{ size: "md"; variant: "solid"; color: "primary"; }' but required in type 'ButtonBaseProps'.", + "remaining props > rejects a textOverflow outside the set > \`clip\` is not a ButtonTextOverflow": "TS2322: Type '"clip"' is not assignable to type 'TextOverflowType | undefined'.", + "size > is required > \`size\` has no default, it must be picked explicitly": "TS2322: Type '{ children: string; variant: "solid"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Type '{ children: string; variant: "solid"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'. + Property 'size' is missing in type '{ children: string; variant: "solid"; color: "primary"; }' but required in type 'ButtonBaseProps'.", + "size > rejects a size outside the scale > \`lg\` is not a ButtonSize": "TS2322: Type '"lg"' is not assignable to type 'SizeType'.", + "test ids > rejects a raw data-testid > use \`testId\`, it survives the tooltip trigger cloning the button": "TS2741: Property ''\`data-testid\` is written as the \`testId\` prop, which survives the tooltip trigger cloning the button'' is missing in type '{ children: string; size: "md"; variant: "solid"; color: "primary"; "data-testid": string; }' but required in type 'TheTestIdPropIsCalledTestId'.", + "unknown props > rejects a misspelled prop > \`onClik\` is not a prop, a generic \`T extends ButtonProps\` alone would let it through": "TS2322: Type '() => void' is not assignable to type 'never'.", + "variant and color > both are required > \`color\` has no default": "TS2322: Type '{ children: string; size: "md"; variant: "solid"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Type '{ children: string; size: "md"; variant: "solid"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'. + Property 'color' is missing in type '{ children: string; size: "md"; variant: "solid"; }' but required in type 'ColoredVariantProps'.", + "variant and color > both are required > \`variant\` has no default": "TS2322: Type '{ children: string; size: "md"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Type '{ children: string; size: "md"; color: "primary"; }' is not assignable to type '(IntrinsicAttributes & ButtonBaseProps & ColoredVariantProps & TextButtonProps & Record & RefAttributes<...>) | (IntrinsicAttributes & ... 4 more ... & RefAttributes<...>)'. + Property 'variant' is missing in type '{ children: string; size: "md"; color: "primary"; }' but required in type 'ColoredVariantProps'.", + "variant and color > outlined, ghost and dashed reject every other color > \`dashed\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "dashed"; color: "warning"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'color' are incompatible. + Type '"warning"' is not assignable to type '"secondary"'.", + "variant and color > outlined, ghost and dashed reject every other color > \`ghost\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "ghost"; color: "danger"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'color' are incompatible. + Type '"danger"' is not assignable to type '"secondary"'.", + "variant and color > outlined, ghost and dashed reject every other color > \`outlined\` only has a secondary treatment": "TS2322: Type '{ children: string; size: "md"; variant: "outlined"; color: "primary"; }' is not assignable to type 'IntrinsicAttributes & (ButtonProps & Record & RefAttributes)'. + Types of property 'color' are incompatible. + Type '"primary"' is not assignable to type '"secondary"'.", + "variant and color > rejects a variant outside the set > \`elevated\` is not a ButtonVariant": "TS2322: Type '"elevated"' is not assignable to type '"link" | "solid" | "outlined" | "ghost" | "dashed"'.", +} +`; diff --git a/packages/ui/src/button/__tests__/button.affixes.test.tsx b/packages/ui/src/button/__tests__/button.affixes.test.tsx new file mode 100644 index 00000000..c9c9d3f4 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.affixes.test.tsx @@ -0,0 +1,165 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button prefix', () => { + it('clones the element into the prefix slot with the sizing class', () => { + render( + , + ); + + const prefix = screen.getByTestId('prefix'); + expect(document.querySelector('[data-slot="button-prefix-slot"]')).toContainElement(prefix); + expect(prefix.className).not.toBe(''); + }); + + it('keeps the class the caller put on the element', () => { + render( + , + ); + + const prefix = screen.getByTestId('prefix'); + expect(prefix).toHaveClass('user-class'); + expect(prefix.classList.length).toBe(2); + }); + + it('marks the wrapper empty when there is no prefix, so it can collapse', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute( + 'data-empty', + 'true', + ); + }); + + it('marks the wrapper filled when a prefix is given', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute( + 'data-empty', + 'false', + ); + }); + + it('keeps the wrapper mounted without a prefix, it also holds the spinner', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toBeInTheDocument(); + expect(document.querySelector('[data-slot="button-prefix-loading"]')).toBeInTheDocument(); + }); + + it('drops the prefix when the prop is removed', () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId('prefix')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByTestId('prefix')).not.toBeInTheDocument(); + }); +}); + +describe('Button suffix', () => { + it('clones the element into the suffix slot with the sizing class', () => { + render( + , + ); + + const suffix = screen.getByTestId('suffix'); + expect(document.querySelector('[data-slot="button-suffix-slot"]')).toContainElement(suffix); + expect(suffix.className).not.toBe(''); + }); + + it('keeps the class the caller put on the element', () => { + render( + , + ); + + const suffix = screen.getByTestId('suffix'); + expect(suffix).toHaveClass('user-class'); + expect(suffix.classList.length).toBe(2); + }); + + it('renders no suffix slot at all without a suffix', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-suffix-slot"]')).toBeNull(); + }); + + it('keeps the affixes out of the accessible name', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAccessibleName('Create alert'); + }); + + it('orders the prefix, the label and the suffix in the DOM', () => { + render( + , + ); + + // @ts-ignore + const slots = [...screen.getByRole('button').children].map((child) => + child.getAttribute('data-slot'), + ); + expect(slots).toEqual(['button-prefix-wrapper', 'button-label', 'button-suffix-slot']); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx new file mode 100644 index 00000000..7f94a63c --- /dev/null +++ b/packages/ui/src/button/__tests__/button.disabled-tooltip.test.tsx @@ -0,0 +1,312 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { queryOpenTooltip } from './test-utils.js'; + +const REASON = 'You need write access to edit alerts'; + +describe('Button disabledTooltip', () => { + it('shows the reason on hover', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('shows the reason on keyboard focus', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('hides the reason again when the pointer leaves', async () => { + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.unhover(button); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('hides the reason again on blur', async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + + await user.tab(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.tab(); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('renders a rich reason, not only text', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByTestId('reason')).toBeInTheDocument(); + }); + + it('ties the button to the popup with aria-describedby', async () => { + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip.id).toBeTruthy(); + expect(button).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('points aria-describedby at nothing while there is no popup', () => { + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby'); + }); +}); + +describe('Button disabledTooltip while usable', () => { + it('says nothing while the button is enabled', async () => { + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(button).toHaveAttribute('aria-disabled', 'false'); + }); + + it('stops saying it the moment the button becomes usable', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.hover(screen.getByRole('button')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('is never mounted when there is no reason to give', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); + +describe('Button disabledTooltip while loading', () => { + it('says nothing while loading, busy is not unavailable', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('is still not natively disabled while loading and disabled at once', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveAttribute('aria-busy', 'true'); + }); + + it('shows the reason again once loading ends', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('does not pop back open when the reason disappears and returns with the pointer away', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + rerender( + , + ); + await user.unhover(button); + + rerender( + , + ); + + await waitFor(() => expect(queryOpenTooltip()).toBeNull()); + }); +}); + +describe('Button disabledTooltip element identity', () => { + it('keeps the same element as the tooltip content is mounted and unmounted', () => { + const { rerender } = render( + , + ); + const button = screen.getByTestId('btn'); + + rerender( + , + ); + expect(screen.getByTestId('btn')).toBe(button); + + rerender( + , + ); + expect(screen.getByTestId('btn')).toBe(button); + }); + + it('keeps forwarding testId and data attributes through the tooltip trigger', () => { + render( + , + ); + + expect(screen.getByTestId('btn')).toHaveAttribute('data-foo', 'bar'); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.disabled.test.tsx b/packages/ui/src/button/__tests__/button.disabled.test.tsx new file mode 100644 index 00000000..74ff5e80 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.disabled.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button disabled state', () => { + it('marks itself unavailable through aria-disabled instead of the native attribute', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toBeEnabled(); + }); + + it('stays focusable and tabbable, so the reason for it stays reachable', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + + expect(screen.getByRole('button')).toHaveFocus(); + }); + + it('swallows clicks and double clicks', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onDoubleClick = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button'); + await user.click(button); + await user.dblClick(button); + + expect(onClick).not.toHaveBeenCalled(); + expect(onDoubleClick).not.toHaveBeenCalled(); + }); + + it.each(['{Enter}', '[Space]'])('swallows %s', async (key) => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button').focus(); + await user.keyboard(key); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not claim to be busy', () => { + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy'); + }); + + it('accepts clicks again once it is enabled', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + + rerender( + , + ); + + await user.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.form.test.tsx b/packages/ui/src/button/__tests__/button.form.test.tsx new file mode 100644 index 00000000..8915a31b --- /dev/null +++ b/packages/ui/src/button/__tests__/button.form.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +function renderForm(button: React.ReactNode, onSubmit = vi.fn()) { + render( +
{ + event.preventDefault(); + onSubmit(); + }} + > + + {button} +
, + ); + + return onSubmit; +} + +describe('Button inside a form', () => { + it('submits the form with type="submit"', async () => { + const user = userEvent.setup(); + const onSubmit = renderForm( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('does not submit the form with the default type', async () => { + const user = userEvent.setup(); + const onSubmit = renderForm( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('resets the form fields with type="reset"', async () => { + const user = userEvent.setup(); + renderForm( + , + ); + const input = screen.getByTestId('input'); + + await user.type(input, 'typed'); + expect(input).toHaveValue('typed'); + + await user.click(screen.getByRole('button')); + + expect(input).toHaveValue(''); + }); + + it('does not submit while disabled', async () => { + const user = userEvent.setup(); + const onSubmit = renderForm( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit twice while loading', async () => { + const user = userEvent.setup(); + const onSubmit = renderForm( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.forward-ref.test.tsx b/packages/ui/src/button/__tests__/button.forward-ref.test.tsx new file mode 100644 index 00000000..41ea01dd --- /dev/null +++ b/packages/ui/src/button/__tests__/button.forward-ref.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Button } from '../index.js'; +import { Tooltip, TooltipProvider } from '../../tooltip/index.js'; + +describe('Button forwardRef', () => { + it('forwards the ref to the rendered button element', () => { + const ref = createRef(); + render( + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toBe(screen.getByRole('button')); + expect(ref.current).toHaveAttribute('data-slot', 'button'); + }); + + it('forwards the ref through the tooltip trigger the ellipsis mode mounts', () => { + const ref = createRef(); + render( + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('forwards the ref without a tooltip trigger in the way', () => { + const ref = createRef(); + render( + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('forwards the ref from inside a wrapping tooltip', () => { + const ref = createRef(); + render( + + + + + , + ); + + expect(ref.current).toBe(screen.getByRole('button')); + }); + + it('calls a callback ref with the button and with null on unmount', () => { + const seen: Array = []; + const { unmount } = render( + , + ); + const button = screen.getByRole('button'); + + unmount(); + + expect(seen[0]).toBe(button); + expect(seen.at(-1)).toBeNull(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.icon.test.tsx b/packages/ui/src/button/__tests__/button.icon.test.tsx new file mode 100644 index 00000000..2e8dcf9a --- /dev/null +++ b/packages/ui/src/button/__tests__/button.icon.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button icon mode', () => { + it('marks itself with data-icon so the square padding tokens apply', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-icon', 'true'); + }); + + it('renders the children into the prefix slot, so the loading swap works on them', () => { + render( + , + ); + + const slot = document.querySelector('[data-slot="button-prefix-slot"]'); + expect(slot).toContainElement(screen.getByTestId('icon')); + }); + + it('renders neither a label slot nor a suffix slot', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-label"]')).toBeNull(); + expect(document.querySelector('[data-slot="button-suffix-slot"]')).toBeNull(); + }); + + it('keeps the prefix wrapper open, the icon is the only content', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute( + 'data-empty', + 'false', + ); + }); + + it('takes its accessible name from aria-label, having no text of its own', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAccessibleName('Star this dashboard'); + }); + + it('still clicks like any other button', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('swaps to the spinner and stops responding while loading', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button')); + + expect(onClick).not.toHaveBeenCalled(); + expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); + expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.interaction.test.tsx b/packages/ui/src/button/__tests__/button.interaction.test.tsx new file mode 100644 index 00000000..2687f69e --- /dev/null +++ b/packages/ui/src/button/__tests__/button.interaction.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button pointer interaction', () => { + it('calls onClick with the button as the event target', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button'); + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0]?.[0].target).toBe(button); + }); + + it('calls onClick once per click', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button'); + await user.click(button); + await user.click(button); + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(3); + }); + + it('calls onDoubleClick once and onClick twice on a double click', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onDoubleClick = vi.fn(); + render( + , + ); + + await user.dblClick(screen.getByRole('button')); + + expect(onDoubleClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('does not activate on hover alone', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(onClick).not.toHaveBeenCalled(); + }); +}); + +describe('Button keyboard interaction', () => { + it('activates on Enter', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button').focus(); + await user.keyboard('{Enter}'); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('activates on Space', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button').focus(); + await user.keyboard('[Space]'); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('forwards onKeyDown and onKeyUp with the pressed key', async () => { + const user = userEvent.setup(); + const onKeyDown = vi.fn(); + const onKeyUp = vi.fn(); + render( + , + ); + + screen.getByRole('button').focus(); + await user.keyboard('{Escape}'); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onKeyDown.mock.calls[0]?.[0].key).toBe('Escape'); + expect(onKeyUp).toHaveBeenCalledTimes(1); + }); +}); + +describe('Button focus', () => { + it('is reachable by Tab', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.tab(); + + expect(screen.getByRole('button')).toHaveFocus(); + }); + + it('is skipped by Tab when tabIndex is -1', async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + + await user.tab(); + + expect(screen.getByTestId('input')).toHaveFocus(); + }); + + it('takes focus on mount with autoFocus', () => { + render( + // eslint-disable-next-line jsx-a11y/no-autofocus -- the prop is what this test covers + , + ); + + expect(screen.getByRole('button')).toHaveFocus(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx new file mode 100644 index 00000000..9fd8a6de --- /dev/null +++ b/packages/ui/src/button/__tests__/button.loading-tooltip.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { mockLabelMeasurement, resetLabelMeasurement, truncate } from './test-utils.js'; + +const BUSY = 'Deleting the rules, this can take a minute'; +const REASON = 'You need write access to edit alerts'; +const LABEL = 'Delete every alert rule in this workspace'; + +beforeAll(mockLabelMeasurement); +afterEach(resetLabelMeasurement); + +describe('Button loadingTooltip', () => { + it('says what the button is busy with on hover', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(BUSY); + }); + + it('says nothing while the button is idle', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('takes the place of the disabled reason while loading', async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(BUSY); + expect(tooltip).not.toHaveTextContent(REASON); + + rerender( + , + ); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('stacks above the truncated label, busy first', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(`${BUSY}${LABEL}`); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.loading.test.tsx b/packages/ui/src/button/__tests__/button.loading.test.tsx new file mode 100644 index 00000000..520dddc7 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.loading.test.tsx @@ -0,0 +1,212 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button loading state', () => { + it('announces itself as busy and unavailable', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + it('never sets the native disabled attribute, so the click does not throw focus away', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + button.focus(); + expect(button).toHaveFocus(); + }); + + it('keeps aria-busy off while idle', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).not.toHaveAttribute('aria-busy'); + expect(button).toHaveAttribute('aria-disabled', 'false'); + }); + + it('keeps the label as the accessible name while busy', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAccessibleName('Saving…'); + }); +}); + +describe('Button loading spinner', () => { + it('hides the spinner from screen readers, aria-busy already says it', () => { + render( + , + ); + + const slot = document.querySelector('[data-slot="button-prefix-loading"]'); + expect(slot).toHaveAttribute('aria-hidden', 'true'); + expect(slot).toContainElement(document.querySelector('[data-slot="spinner"]')); + }); + + it('keeps the spinner mounted while idle, the swap is a css cross-fade', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument(); + }); + + it('keeps the prefix and suffix mounted next to the spinner', () => { + render( + , + ); + + expect(screen.getByTestId('prefix')).toBeInTheDocument(); + expect(screen.getByTestId('suffix')).toBeInTheDocument(); + expect(document.querySelector('[data-slot="spinner"]')).toBeInTheDocument(); + }); + + it('leaves the prefix wrapper collapsed when there is no prefix to make room for', () => { + const { rerender } = render( + , + ); + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute( + 'data-empty', + 'true', + ); + + rerender( + , + ); + expect(document.querySelector('[data-slot="button-prefix-wrapper"]')).toHaveAttribute( + 'data-empty', + 'false', + ); + }); +}); + +describe('Button loading activation', () => { + it('swallows clicks and double clicks', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const onDoubleClick = vi.fn(); + render( + , + ); + + const button = screen.getByRole('button'); + await user.click(button); + await user.dblClick(button); + + expect(onClick).not.toHaveBeenCalled(); + expect(onDoubleClick).not.toHaveBeenCalled(); + }); + + it.each(['{Enter}', '[Space]'])('swallows %s', async (key) => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + , + ); + + screen.getByRole('button').focus(); + await user.keyboard(key); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('accepts clicks again once loading ends', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + + rerender( + , + ); + + await user.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('keeps the focus it already had when loading starts and ends', () => { + const { rerender } = render( + , + ); + const button = screen.getByRole('button'); + button.focus(); + + rerender( + , + ); + expect(button).toHaveFocus(); + + rerender( + , + ); + expect(button).toHaveFocus(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx b/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx new file mode 100644 index 00000000..d8e3966d --- /dev/null +++ b/packages/ui/src/button/__tests__/button.overflow-tooltip.test.tsx @@ -0,0 +1,218 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js'; + +const LABEL = 'A very long destructive label'; + +beforeAll(mockLabelMeasurement); +afterEach(resetLabelMeasurement); + +describe('Button overflow tooltip', () => { + it('shows the full label on hover once the text is ellipsed', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(LABEL); + }); + + it('shows the full label on keyboard focus too', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.tab(); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(LABEL); + }); + + it('ties the button to the popup with aria-describedby', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + + const tooltip = await screen.findByRole('tooltip'); + expect(button).toHaveAttribute('aria-describedby', tooltip.id); + }); + + it('closes again on blur', async () => { + const user = userEvent.setup(); + truncate(); + render( + <> + + + , + ); + + await user.tab(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + await user.tab(); + + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()); + }); +}); + +describe('Button overflow tooltip while the label fits', () => { + it('puts nothing in the DOM at all', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull(); + expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby'); + }); + + it('closes an open popup when a resize makes the label fit', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + + resize(300, 300); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('stops opening on later hovers', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + const button = screen.getByRole('button'); + + await user.hover(button); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + await user.unhover(button); + + resize(300, 300); + await user.hover(button); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); + +describe('Button overflow tooltip opt-outs', () => { + it('is never mounted for textOverflow=none', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('does not even make the button a trigger for textOverflow=none', () => { + truncate(); + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-slot', 'tooltip-trigger'); + }); + + it('is never mounted for an icon button', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); + +describe('Button overflow tooltip element identity', () => { + it('keeps the same element, and its focus, across a truncation flip', () => { + render( + , + ); + const button = screen.getByTestId('btn'); + button.focus(); + + resize(300, 100); + expect(screen.getByTestId('btn')).toBe(button); + expect(button).toHaveFocus(); + + resize(300, 300); + expect(screen.getByTestId('btn')).toBe(button); + expect(button).toHaveFocus(); + }); + + it('leaves focus where it was when the button was not focused', () => { + render( + <> + + + , + ); + const input = screen.getByTestId('input'); + input.focus(); + + resize(300, 100); + + expect(input).toHaveFocus(); + }); + + it('keeps rendering a native button through the tooltip trigger', () => { + render( + , + ); + + expect(screen.getByTestId('btn').tagName).toBe('BUTTON'); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.rendering.test.tsx b/packages/ui/src/button/__tests__/button.rendering.test.tsx new file mode 100644 index 00000000..4ae48aa3 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.rendering.test.tsx @@ -0,0 +1,218 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button.js'; + +describe('Button rendering', () => { + it('renders a native button that is named by its children', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Label' }); + expect(button.tagName).toBe('BUTTON'); + }); + + it('defaults to type="button", so a button inside a form never submits by accident', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it.each(['submit', 'reset', 'button'] as const)('honours type="%s"', (type) => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('type', type); + }); + + it('exposes testId as data-testid', () => { + render( + , + ); + + expect(screen.getByTestId('my-button').tagName).toBe('BUTTON'); + }); + + it('leaves data-testid off when no testId is given', () => { + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-testid'); + }); + + it('keeps the component class next to a custom className', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('custom-class'); + expect(button.className.split(' ').length).toBeGreaterThan(1); + }); + + it('forwards id, tabIndex and aria attributes', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Toolbar action' }); + expect(button).toHaveAttribute('id', 'save'); + expect(button).toHaveAttribute('tabindex', '-1'); + expect(button).toHaveAttribute('aria-haspopup', 'menu'); + }); + + it('forwards arbitrary data-* attributes', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('data-foo', 'bar'); + expect(button).toHaveAttribute('data-analytics-id', 'save'); + }); + + it('wraps the label in its own slot, so the affixes are not measured with it', () => { + render( + , + ); + + const label = document.querySelector('[data-slot="button-label"]'); + expect(label).toHaveTextContent('Label'); + expect(label?.querySelector('[data-testid="prefix"]')).toBeNull(); + }); + + it('accepts a key without reading it as a prop, which React warns about', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + , + ); + + expect(error).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('renders element children inside the label slot', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-label"]')).toContainElement( + screen.getByTestId('strong'), + ); + }); +}); + +describe('Button width', () => { + it('maps width to the width custom property', () => { + render( + , + ); + + expect(screen.getByRole('button').style.getPropertyValue('--button-internal-width')).toBe( + '10rem', + ); + }); + + it('maps maxWidth to the max-width custom property', () => { + render( + , + ); + + expect(screen.getByRole('button').style.getPropertyValue('--button-internal-max-width')).toBe( + '20rem', + ); + }); + + it('writes a number as px, custom properties get no unit from React', () => { + render( + , + ); + + const { style } = screen.getByRole('button'); + expect(style.getPropertyValue('--button-internal-width')).toBe('200px'); + expect(style.getPropertyValue('--button-internal-max-width')).toBe('320px'); + }); + + it('keeps a zero, which is a real length', () => { + render( + , + ); + + expect(screen.getByRole('button').style.getPropertyValue('--button-internal-max-width')).toBe( + '0px', + ); + }); + + it('leaves both custom properties unset by default, so the size tokens win', () => { + render( + , + ); + + const { style } = screen.getByRole('button'); + expect(style.getPropertyValue('--button-internal-width')).toBe(''); + expect(style.getPropertyValue('--button-internal-max-width')).toBe(''); + }); + + it('keeps the caller style alongside the custom properties', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveStyle({ color: 'rgb(255, 0, 0)', marginTop: '4px' }); + expect(button.style.getPropertyValue('--button-internal-width')).toBe('10rem'); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.text-overflow.test.tsx b/packages/ui/src/button/__tests__/button.text-overflow.test.tsx new file mode 100644 index 00000000..c52258d8 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.text-overflow.test.tsx @@ -0,0 +1,170 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js'; + +beforeAll(mockLabelMeasurement); +afterEach(resetLabelMeasurement); + +describe('Button textOverflow', () => { + it('defaults to ellipsis', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-text-overflow', 'ellipsis'); + }); + + it.each(['none', 'ellipsis'] as const)( + 'exposes textOverflow=%s as data-text-overflow', + (textOverflow) => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-text-overflow', textOverflow); + }, + ); + + it('gives the label its own slot to measure', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-label"]')).toHaveTextContent('Label'); + }); + + it('has no label slot to measure on an icon button', () => { + render( + , + ); + + expect(document.querySelector('[data-slot="button-label"]')).toBeNull(); + }); +}); + +describe('Button truncation flag', () => { + it('flags a label that does not fit', () => { + truncate(); + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-truncated'); + }); + + it('leaves a label that fits unflagged', () => { + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + }); + + it('ignores a one pixel overflow, which rounded measurements produce on their own', () => { + resize(101, 100); + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + }); + + it('picks the flag up when the button is resized down', () => { + render( + , + ); + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + + resize(300, 100); + + expect(screen.getByRole('button')).toHaveAttribute('data-truncated'); + }); + + it('drops the flag when the button is resized back up', () => { + truncate(); + render( + , + ); + expect(screen.getByRole('button')).toHaveAttribute('data-truncated'); + + resize(300, 300); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + }); + + it('keeps measuring across repeated flips', () => { + render( + , + ); + + resize(300, 100); + expect(screen.getByRole('button')).toHaveAttribute('data-truncated'); + + resize(300, 300); + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + + resize(300, 100); + expect(screen.getByRole('button')).toHaveAttribute('data-truncated'); + }); + + it('re-measures when the label text changes at the same width', async () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + + truncate(); + rerender( + , + ); + + await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('data-truncated')); + }); + + it('never flags textOverflow=none, which clips instead of truncating', () => { + truncate(); + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + }); + + it('never flags an icon button, which has no label to truncate', () => { + truncate(); + render( + , + ); + + expect(screen.getByRole('button')).not.toHaveAttribute('data-truncated'); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx b/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx new file mode 100644 index 00000000..843dbce7 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.tooltip-stacking.test.tsx @@ -0,0 +1,224 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { Tooltip } from '../../tooltip/index.js'; +import { mockLabelMeasurement, resetLabelMeasurement, resize, truncate } from './test-utils.js'; + +const REASON = 'You need write access to edit alerts'; +const LABEL = 'A very long destructive label'; + +beforeAll(mockLabelMeasurement); +afterEach(resetLabelMeasurement); + +describe('Button reason stacked over the truncated label', () => { + it('shows both, reason first, separated by a divider', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(`${REASON}${LABEL}`); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeInTheDocument(); + }); + + it('shows the reason alone when the label is not truncated', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(REASON); + expect(tooltip.querySelector('[data-slot="tooltip-divider"]')).toBeNull(); + }); + + it('hands the popup back to the label once the button is usable', async () => { + const user = userEvent.setup(); + truncate(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent(LABEL); + expect(tooltip).not.toHaveTextContent(REASON); + }); + + it('shows the reason for textOverflow=none, which has no label popup of its own', async () => { + const user = userEvent.setup(); + truncate(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('shows the reason for an icon button, which has no label popup either', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('keeps the same element across a truncation flip', () => { + render( + , + ); + const button = screen.getByTestId('btn'); + + resize(300, 100); + + expect(screen.getByTestId('btn')).toBe(button); + }); +}); + +describe('Button inside another tooltip', () => { + it('adds the truncated label to the wrapping popup instead of opening a second one', async () => { + const user = userEvent.setup(); + truncate(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]).toHaveTextContent(`Outer reason${LABEL}`); + }); + + it('adds the disabled reason under the outer title', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]).toHaveTextContent(`Outer reason${REASON}`); + }); + + it('shows the disabled reason when the wrapping tooltip has no title of its own', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + const tooltips = await screen.findAllByRole('tooltip'); + expect(tooltips).toHaveLength(1); + expect(tooltips[0]).toHaveTextContent(REASON); + }); + + it('does not become a trigger of its own', () => { + truncate(); + render( + + + , + ); + + expect(document.querySelectorAll('[data-slot="tooltip-trigger"]')).toHaveLength(1); + expect(screen.getByTestId('btn')).toHaveAttribute('data-truncated'); + }); + + it('keeps its own testId, which the cloning trigger would otherwise drop', () => { + render( + + + , + ); + + expect(screen.getByTestId('btn')).toBeInTheDocument(); + }); + + it('stays hoverable while disabled, so the wrapping tooltip still opens', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.hover(screen.getByRole('button')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Outer reason'); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.types.messages.test.ts b/packages/ui/src/button/__tests__/button.types.messages.test.ts new file mode 100644 index 00000000..1147b478 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.types.messages.test.ts @@ -0,0 +1,140 @@ +/** + * Message assertions for the type tests in `button.types.test-d.tsx`. + * + * `@ts-expect-error` only asserts that *an* error happens on the next line. It says nothing about + * which constraint fired or what the compiler printed, so a case can keep passing for entirely the + * wrong reason, and a rewrite of the prop types can quietly turn a readable message into an + * unreadable one. + * + * This test closes that gap. It compiles the same fixture with the `@ts-expect-error` directives + * blanked out, then snapshots the diagnostic each case actually produces, keyed by + * `describe > test > reason`. Blanking (rather than deleting) keeps every line number intact, so a + * diagnostic on the line below a directive belongs to that directive. + * + * Two failure modes are covered beyond the snapshot itself: a case that stops erroring, and an error + * on a line no directive covers, meaning a combination we accept has started to fail. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const fixturePath = resolve(currentDir, 'button.types.test-d.tsx'); +const tsconfigPath = resolve(currentDir, '../../../tsconfig.json'); + +const DESCRIBE = /^\s*describe\('([^']+)'/; +const TEST = /^\s*test\('([^']+)'/; +const EXPECT_ERROR = /^\s*\/\/\s*@ts-expect-error\s*-?\s*(.*)$/; + +interface ExpectedError { + /** 1-based line the directive suppresses, i.e. the line holding the opening ` { + const describeMatch = DESCRIBE.exec(line); + if (describeMatch?.[1] !== undefined) { + describeName = describeMatch[1]; + return; + } + + const testMatch = TEST.exec(line); + if (testMatch?.[1] !== undefined) { + testName = testMatch[1]; + return; + } + + const expectErrorMatch = EXPECT_ERROR.exec(line); + if (expectErrorMatch?.[1] === undefined) { + return; + } + + expected.push({ + line: index + 2, + label: `${describeName} > ${testName} > ${expectErrorMatch[1].trim()}`, + }); + lines[index] = ''; + }); + + return { source: lines.join('\n'), expected }; +} + +/** + * Compiles the package exactly as `tsc` would, with the fixture's contents swapped for `source`. + */ +function compileFixture(source: string): ts.Diagnostic[] { + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(tsconfigPath)); + + const host = ts.createCompilerHost(config.options, true); + const getSourceFile = host.getSourceFile.bind(host); + const readFile = host.readFile.bind(host); + + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + if (resolve(fileName) !== fixturePath) { + return getSourceFile(fileName, languageVersion, onError, shouldCreate); + } + + return ts.createSourceFile(fileName, source, languageVersion, true, ts.ScriptKind.TSX); + }; + host.readFile = (fileName) => (resolve(fileName) === fixturePath ? source : readFile(fileName)); + + const program = ts.createProgram(config.fileNames, config.options, host); + const fixture = program.getSourceFile(fixturePath); + + return [...program.getSyntacticDiagnostics(fixture), ...program.getSemanticDiagnostics(fixture)]; +} + +const { source, expected } = readFixture(); +const diagnostics = compileFixture(source); + +/** Diagnostics grouped by the 1-based line they were reported on. */ +const byLine = new Map(); +for (const diagnostic of diagnostics) { + if (diagnostic.file === undefined || diagnostic.start === undefined) { + continue; + } + + const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + const message = `TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n ')}`; + + byLine.set(line + 1, [...(byLine.get(line + 1) ?? []), message]); +} + +describe('Button type error messages', () => { + it('reports an error for every rejected combination', () => { + const silent = expected.filter(({ line }) => !byLine.has(line)).map(({ label }) => label); + + expect(silent).toEqual([]); + }); + + it('reports nothing for the accepted combinations', () => { + const covered = new Set(expected.map(({ line }) => line)); + const unexpected = [...byLine.entries()] + .filter(([line]) => !covered.has(line)) + .map(([line, messages]) => `${line}: ${messages[0]}`); + + expect(unexpected).toEqual([]); + }); + + it('prints a message that names the constraint that failed', () => { + const messages = Object.fromEntries( + expected.map(({ line, label }) => [label, byLine.get(line)?.join('\n') ?? null]), + ); + + expect(messages).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.types.test-d.tsx b/packages/ui/src/button/__tests__/button.types.test-d.tsx new file mode 100644 index 00000000..d9d90af2 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.types.test-d.tsx @@ -0,0 +1,369 @@ +/** + * Type-level tests for the props of {@link Button}. + * + * Run by `vitest --typecheck` (see `typecheck` in `vitest.config.ts`) and, because the file lives + * under `src` and is not excluded by `tsconfig.json`, also by `pnpm type-check`. + * + * Nothing here executes. Each case is a JSX element written the way a consumer writes it: it either + * compiles, or it is marked `@ts-expect-error` because we refuse that combination. TypeScript + * reports an unused `@ts-expect-error` as an error of its own, so loosening a constraint by accident + * fails the build instead of passing silently. + * + * The comment suppresses the line directly below it, which is why it sits inside `assertType(...)` + * right above the opening `; +const noop = (): void => {}; +const buttonRef = createRef(); +declare const maybeDisabled: boolean | undefined; + +describe('size', () => { + test('accepts every ButtonSize', () => { + assertType( + , + ); + assertType( + , + ); + }); + + test('is required', () => { + assertType( + // @ts-expect-error - `size` has no default, it must be picked explicitly + , + ); + }); + + test('rejects a size outside the scale', () => { + assertType( + // @ts-expect-error - `lg` is not a ButtonSize + , + ); + }); +}); + +describe('variant and color', () => { + test('solid and link accept every color', () => { + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + }); + + test('outlined, ghost and dashed accept secondary', () => { + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + }); + + test('outlined, ghost and dashed reject every other color', () => { + assertType( + // @ts-expect-error - `outlined` only has a secondary treatment + , + ); + assertType( + // @ts-expect-error - `ghost` only has a secondary treatment + , + ); + assertType( + // @ts-expect-error - `dashed` only has a secondary treatment + , + ); + }); + + test('both are required', () => { + assertType( + // @ts-expect-error - `variant` has no default + , + ); + assertType( + // @ts-expect-error - `color` has no default + , + ); + }); + + test('rejects a variant outside the set', () => { + assertType( + // @ts-expect-error - `elevated` is not a ButtonVariant + , + ); + }); +}); + +describe('disabled and disabledTooltip', () => { + test('accepts the pair', () => { + assertType( + , + ); + assertType( + , + ); + }); + + test('accepts an explicit undefined reason as the opt-out', () => { + assertType( + , + ); + }); + + test('a possibly undefined disabled still needs a reason', () => { + assertType( + // @ts-expect-error - `disabled` typed `boolean | undefined` is still `disabled` + , + ); + }); + + test('disabled without a reason is rejected', () => { + assertType( + // @ts-expect-error - a disabled button must explain itself through `disabledTooltip` + , + ); + }); + + test('a reason without disabled is rejected', () => { + assertType( + // @ts-expect-error - `disabledTooltip` never renders unless `disabled` is set + , + ); + }); +}); + +describe('icon mode', () => { + test('accepts an icon with an accessible name', () => { + assertType( + , + ); + }); + + test('an icon without an accessible name is rejected', () => { + assertType( + // @ts-expect-error - an icon button has no visible text, so `aria-label` is required + , + ); + }); + + test('prefix and suffix are rejected, the children are the icon', () => { + assertType( + // @ts-expect-error - `prefix` has no slot to render into in icon mode + , + ); + assertType( + // @ts-expect-error - `suffix` has no slot to render into in icon mode + , + ); + }); + + test('text children are rejected', () => { + assertType( + // @ts-expect-error - icon mode renders an element, not a label + , + ); + }); + + test('children are required', () => { + assertType( + // @ts-expect-error - a button with nothing inside it is not allowed + , + ); + }); +}); + +describe('prefix, suffix and children', () => { + test('accepts either affix', () => { + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + }); + + test('children are required', () => { + assertType( + // @ts-expect-error - a button with nothing inside it is not allowed + , + ); + }); + + test('rejects a raw data-testid', () => { + assertType( + // @ts-expect-error - use `testId`, it survives the tooltip trigger cloning the button + , + ); + }); +}); + +describe('unknown props', () => { + test('accepts key and ref', () => { + assertType( + , + ); + }); + + test('accepts the forwarded focus and pointer handlers', () => { + assertType( + , + ); + }); + + test('rejects a misspelled prop', () => { + assertType( + // @ts-expect-error - `onClik` is not a prop, a generic `T extends ButtonProps` alone would let it through + , + ); + }); +}); + +describe('remaining props', () => { + test('accepts the presentational and native ones', () => { + assertType( + , + ); + assertType( + , + ); + assertType( + , + ); + }); + + test('rejects a textOverflow outside the set', () => { + assertType( + // @ts-expect-error - `clip` is not a ButtonTextOverflow + , + ); + }); +}); diff --git a/packages/ui/src/button/__tests__/button.variants.test.tsx b/packages/ui/src/button/__tests__/button.variants.test.tsx new file mode 100644 index 00000000..f3dbf330 --- /dev/null +++ b/packages/ui/src/button/__tests__/button.variants.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Button } from '../button.js'; +import { ButtonColor, ButtonSize } from '../constants.js'; +import type { VariantColorType } from '../types.js'; + +const VARIANTS = [ + { variant: 'solid', color: 'primary' }, + { variant: 'link', color: 'primary' }, + { variant: 'outlined', color: 'secondary' }, + { variant: 'ghost', color: 'secondary' }, + { variant: 'dashed', color: 'secondary' }, +] as const satisfies readonly VariantColorType[]; + +describe('Button style tokens', () => { + it.each(VARIANTS)('exposes variant=$variant as data-variant', ({ variant, color }) => { + render( + // @ts-expect-error For some reason, it's complaining about disabled + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-variant', variant); + }); + + it.each(Object.values(ButtonSize))('exposes size=%s as data-size', (size) => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-size', size); + }); + + it.each(Object.values(ButtonColor))('exposes color=%s as data-color', (color) => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-color', color); + }); + + it('marks a non-icon button with data-icon="false"', () => { + render( + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-icon', 'false'); + }); +}); + +describe('Button dashed border', () => { + it('draws the dashes as an svg overlay, so they can be animated', () => { + const { container } = render( + , + ); + + const overlay = container.querySelector('[data-slot="button-dashed-border"]'); + expect(overlay?.tagName).toBe('svg'); + expect(overlay?.querySelector('rect')).toBeInTheDocument(); + }); + + it('keeps the overlay out of the accessibility tree and out of the tab order', () => { + const { container } = render( + , + ); + + const overlay = container.querySelector('[data-slot="button-dashed-border"]'); + expect(overlay).toHaveAttribute('aria-hidden', 'true'); + expect(overlay).toHaveAttribute('focusable', 'false'); + expect(screen.getByRole('button')).toHaveAccessibleName('Add step'); + }); + + it.each(VARIANTS.filter(({ variant }) => variant !== 'dashed'))( + 'leaves variant=$variant without an overlay', + ({ variant, color }) => { + const { container } = render( + // @ts-expect-error For some reason, it's complaining about disabled + , + ); + + expect(container.querySelector('[data-slot="button-dashed-border"]')).toBeNull(); + }, + ); +}); diff --git a/packages/ui/src/button/__tests__/test-utils.tsx b/packages/ui/src/button/__tests__/test-utils.tsx new file mode 100644 index 00000000..6da14705 --- /dev/null +++ b/packages/ui/src/button/__tests__/test-utils.tsx @@ -0,0 +1,88 @@ +import { act } from '@testing-library/react'; + +/** + * jsdom has no layout, so every element measures 0x0 and no label ever reads as + * truncated. These fake the two properties the truncation check looks at, for + * the label slot only, so the rest of the tree keeps its real (zero) sizes. + */ +let labelScrollWidth = 0; +let labelClientWidth = 0; + +function isLabel(element: HTMLElement): boolean { + return element.dataset.slot === 'button-label'; +} + +/** Callbacks of every live ResizeObserver, so a resize can be replayed by hand. */ +const resizeCallbacks = new Set(); + +/** Label wider than its box: the text is cut off. */ +export function truncate(): void { + labelScrollWidth = 300; + labelClientWidth = 100; +} + +/** Sets the label measurements and replays them to every live observer. */ +export function resize(scrollWidth: number, clientWidth: number): void { + labelScrollWidth = scrollWidth; + labelClientWidth = clientWidth; + + act(() => { + for (const callback of resizeCallbacks) { + callback([], {} as ResizeObserver); + } + }); +} + +/** Call from `beforeAll`: installs the label measurement and ResizeObserver fakes. */ +export function mockLabelMeasurement(): void { + const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth'); + const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get(this: HTMLElement) { + return isLabel(this) ? labelScrollWidth : (scrollWidth?.get?.call(this) ?? 0); + }, + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return isLabel(this) ? labelClientWidth : (clientWidth?.get?.call(this) ?? 0); + }, + }); + + globalThis.ResizeObserver = class ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(): void { + resizeCallbacks.add(this.callback); + // A real ResizeObserver reports the initial size once observation starts, + // on the next frame. Delivering it inline keeps the tests synchronous. + this.callback([], this); + } + + unobserve(): void { + resizeCallbacks.delete(this.callback); + } + + disconnect(): void { + resizeCallbacks.delete(this.callback); + } + }; +} + +/** Call from `afterEach`: drops the measurements and observers of the last render. */ +export function resetLabelMeasurement(): void { + labelScrollWidth = 0; + labelClientWidth = 0; + resizeCallbacks.clear(); +} + +/** + * The tooltip popup, but only while it is open. Base UI keeps a closed popup + * mounted until its exit transition ends, and jsdom never runs one, so a closed + * popup can linger in the DOM long after the tooltip is gone on screen. + */ +export function queryOpenTooltip(): HTMLElement | null { + return document.querySelector('[data-slot="tooltip-content"][data-open]'); +} diff --git a/packages/ui/src/button/button.forward-ref.test.tsx b/packages/ui/src/button/button.forward-ref.test.tsx deleted file mode 100644 index a05252bc..00000000 --- a/packages/ui/src/button/button.forward-ref.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { render } from '@testing-library/react'; -import { createRef } from 'react'; -import { describe, expect, it } from 'vitest'; - -import { Button } from './index.js'; - -describe('Button forwardRef', () => { - it('forwards ref', () => { - const ref = createRef(); - render(); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - }); -}); diff --git a/packages/ui/src/button/button.module.scss b/packages/ui/src/button/button.module.scss index 6d9c339d..da2e4623 100644 --- a/packages/ui/src/button/button.module.scss +++ b/packages/ui/src/button/button.module.scss @@ -1,9 +1,12 @@ .button { display: var(--button-display, inline-flex); + flex-direction: var(--button-flex-direction, row); align-items: var(--button-align-items, center); justify-content: var(--button-justify-content, center); + gap: var(--button-gap, var(--button-internal-gap)); white-space: var(--button-white-space, nowrap); - border-radius: var(--button-border-radius, calc(var(--radius) - 2px)); + --button-internal-border-radius: var(--button-border-radius, var(--radius-1)); + border-radius: var(--button-internal-border-radius); transition: var(--button-transition, background-color 150ms ease, color 150ms ease, @@ -11,233 +14,456 @@ cursor: var(--button-cursor, pointer); background-color: var(--button-internal-background); color: var(--button-internal-solid-foreground); - line-height: var(--button-line-height, 100%); font-variant-numeric: var(--button-font-variant-numeric, slashed-zero); + font-weight: var(--button-font-weight, var(--font-weight-medium)); + // TODO: Convert this hardcoded value to semantic token + letter-spacing: var(--button-text-spacing, -0.005em); border-color: var(--button-base-border-color, transparent); border-width: var(--button-base-border-width, 0px); + width: var(--button-width, var(--button-internal-width, auto)); + max-width: var(--button-max-width, var(--button-internal-max-width, 100%)); + + --button-internal-loading-duration: var(--button-loading-duration, 120ms); + --button-internal-loading-delay: var(--button-loading-delay, var(--button-internal-loading-duration)); + --button-internal-loading-easing: var(--button-loading-easing, cubic-bezier(0.65, 0, 0.35, 1)); + --button-internal-loading-travel: var(--button-loading-travel, 6px); &[data-color='primary'] { --button-internal-background: var(--button-primary-background, var(--primary-background)); - --button-internal-border-color: var(--button-primary-border-color, var(--primary-background)); --button-internal-solid-foreground: var(--button-primary-solid-foreground, var(--primary-foreground)); - --button-internal-outlined-foreground: var(--button-primary-outlined-foreground, var(--primary)); --button-internal-hover-background: var(--button-primary-hover-background, var(--primary-background-hover)); - --button-internal-link-hover-foreground: var(--button-primary-link-hover-foreground, var(--primary-background-hover)); - --button-internal-ghost-hover-foreground: var(--button-primary-ghost-hover-foreground, var(--primary-foreground-hover)); + --button-internal-link-foreground: var(--button-primary-link-foreground, var(--primary-link)); + --button-internal-link-hover-foreground: var(--button-primary-link-hover-foreground, var(--primary-hover)); + } + + &[data-color='secondary'] { + --button-internal-background: var(--button-secondary-background, var(--secondary-background)); + --button-internal-solid-foreground: var(--button-secondary-solid-foreground, var(--secondary-foreground)); + --button-internal-hover-background: var(--button-secondary-hover-background, var(--secondary-background-hover)); + --button-internal-link-foreground: var(--button-secondary-link-foreground, var(--secondary-link)); + --button-internal-link-hover-foreground: var(--button-secondary-link-hover-foreground, var(--secondary-link-hover)); } - &[data-color='destructive'] { - --button-internal-background: var(--button-destructive-background, var(--danger-background)); - --button-internal-border-color: var(--button-destructive-border-color, var(--danger-background)); - --button-internal-solid-foreground: var(--button-destructive-solid-foreground, var(--danger-foreground)); - --button-internal-outlined-foreground: var(--button-destructive-outlined-foreground, var(--danger-background)); - --button-internal-hover-background: var(--button-destructive-hover-background, var(--danger-background-hover)); - --button-internal-link-hover-foreground: var(--button-destructive-link-hover-foreground, var(--danger-background-hover)); - --button-internal-ghost-hover-foreground: var(--button-destructive-ghost-hover-foreground, var(--danger-foreground-hover)); + &[data-color='success'] { + --button-internal-background: var(--button-success-background, var(--success-background)); + --button-internal-solid-foreground: var(--button-success-solid-foreground, var(--success-foreground)); + --button-internal-hover-background: var(--button-success-hover-background, var(--success-background-hover)); + --button-internal-link-foreground: var(--button-success-link-foreground, var(--success-link)); + --button-internal-link-hover-foreground: var(--button-success-link-hover-foreground, var(--success-link-hover)); + } + + &[data-color='danger'] { + --button-internal-background: var(--button-danger-background, var(--danger-background)); + --button-internal-solid-foreground: var(--button-danger-solid-foreground, var(--danger-foreground)); + --button-internal-hover-background: var(--button-danger-hover-background, var(--danger-background-hover)); + --button-internal-link-foreground: var(--button-danger-link-foreground, var(--danger-link)); + --button-internal-link-hover-foreground: var(--button-danger-link-hover-foreground, var(--danger-link-hover)); } &[data-color='warning'] { --button-internal-background: var(--button-warning-background, var(--warning-background)); - --button-internal-border-color: var(--button-warning-border-color, var(--warning-background)); --button-internal-solid-foreground: var(--button-warning-solid-foreground, var(--warning-foreground)); - --button-internal-outlined-foreground: var(--button-warning-outlined-foreground, var(--warning-background)); --button-internal-hover-background: var(--button-warning-hover-background, var(--warning-background-hover)); - --button-internal-link-hover-foreground: var(--button-warning-link-hover-foreground, var(--warning-background-hover)); - --button-internal-ghost-hover-foreground: var(--button-warning-ghost-hover-foreground, var(--warning-foreground-hover)); + --button-internal-link-foreground: var(--button-warning-link-foreground, var(--warning-link)); + --button-internal-link-hover-foreground: var(--button-warning-link-hover-foreground, var(--warning-link-hover)); } - &[data-color='secondary'] { - --button-internal-background: var(--button-secondary-background, var(--secondary-background)); - --button-internal-border-color: var(--button-secondary-border-color, var(--secondary-border)); - --button-internal-solid-foreground: var(--button-secondary-solid-foreground, var(--secondary-foreground)); - --button-internal-outlined-foreground: var(--button-secondary-outlined-foreground, var(--secondary-foreground)); - --button-internal-hover-background: var(--button-secondary-hover-background, var(--secondary-background-hover)); - --button-internal-link-hover-foreground: var(--button-secondary-link-hover-foreground, var(--secondary-foreground-hover)); - --button-internal-ghost-hover-foreground: var(--button-secondary-ghost-hover-foreground, var(--secondary-foreground-hover)); + &[data-color='info'] { + --button-internal-background: var(--button-info-background, var(--bg-aqua-500)); + --button-internal-solid-foreground: var(--button-info-solid-foreground, var(--text-ink-500)); + --button-internal-hover-background: var(--button-info-hover-background, var(--bg-aqua-400)); + --button-internal-link-foreground: var(--button-info-link-foreground, var(--bg-aqua-400)); + --button-internal-link-hover-foreground: var(--button-info-link-hover-foreground, var(--bg-aqua-500)); } - &[data-color='none'] { - --button-internal-background: var(--button-none-background, var(--ghost-background)); - --button-internal-border-color: var(--button-none-border-color, var(--ghost-border)); - --button-internal-solid-foreground: var(--button-none-solid-foreground, var(--ghost-foreground)); - --button-internal-outlined-foreground: var(--button-none-outlined-foreground, var(--ghost-foreground)); - --button-internal-hover-background: var(--button-none-hover-background, var(--ghost-background-hover)); - --button-internal-link-hover-foreground: var(--button-none-link-hover-foreground, var(--ghost-foreground-hover)); - --button-internal-ghost-hover-foreground: var(--button-none-ghost-hover-foreground, var(--ghost-foreground-hover)); + &[data-color='archive'] { + --button-internal-background: var(--button-archive-background, var(--bg-sienna-500)); + --button-internal-solid-foreground: var(--button-archive-solid-foreground, var(--text-ink-500)); + --button-internal-hover-background: var(--button-archive-hover-background, var(--bg-sienna-400)); + --button-internal-link-foreground: var(--button-archive-link-foreground, var(--bg-sienna-400)); + --button-internal-link-hover-foreground: var(--button-archive-link-hover-foreground, var(--bg-sienna-500)); } - &[data-color='primary'], - &[data-color='destructive'], - &[data-color='warning'], - &[data-color='none'], - &[data-color='secondary'] { - --button-internal-action-border: var(--button-action-border, var(--action-border)); - --button-internal-action-hover-border: var(--button-action-hover-border, var(--action-border-hover)); - --button-internal-action-text: var(--button-action-text, var(--action-foreground)); - --button-internal-action-hover-text: var(--button-action-hover-text, var(--action-foreground-hover)); - } - - &[data-background='ink-500'] { - --button-internal-action-background: var(--button-action-ink-500-background, var(--action-background)); - --button-internal-action-border: var(--button-action-ink-500-border, var(--action-border)); - --button-internal-action-text: var(--button-action-ink-500-text, var(--action-foreground)); - --button-internal-action-hover-background: var(--button-action-ink-500-hover-background, var(--action-background-hover)); - --button-internal-action-hover-border: var(--button-action-ink-500-hover-border, var(--action-border-hover)); - --button-internal-action-hover-text: var(--button-action-ink-500-hover-text, var(--action-foreground-hover)); - } - - &[data-background='ink-400'] { - --button-internal-action-background: var(--button-action-ink-400-background, var(--action-background)); - --button-internal-action-border: var(--button-action-ink-400-border, var(--action-border)); - --button-internal-action-text: var(--button-action-ink-400-text, var(--action-foreground)); - --button-internal-action-hover-background: var(--button-action-ink-400-hover-background, var(--action-background-hover)); - --button-internal-action-hover-border: var(--button-action-ink-400-hover-border, var(--action-border-hover)); - --button-internal-action-hover-text: var(--button-action-ink-400-hover-text, var(--action-foreground-hover)); - } - - &[data-background='vanilla-100'] { - --button-internal-action-background: var(--button-action-vanilla-100-background, var(--action-background)); - --button-internal-action-border: var(--button-action-vanilla-100-border, var(--action-border)); - --button-internal-action-text: var(--button-action-vanilla-100-text, var(--action-foreground)); - --button-internal-action-hover-background: var(--button-action-vanilla-100-hover-background, var(--action-background-hover)); - --button-internal-action-hover-border: var(--button-action-vanilla-100-hover-border, var(--action-border-hover)); - --button-internal-action-hover-text: var(--button-action-vanilla-100-hover-text, var(--action-foreground-hover)); - } - - &[data-background='vanilla-200'] { - --button-internal-action-background: var(--button-action-vanilla-200-background, var(--action-background)); - --button-internal-action-border: var(--button-action-vanilla-200-border, var(--action-border)); - --button-internal-action-text: var(--button-action-vanilla-200-text, var(--action-foreground)); - --button-internal-action-hover-background: var(--button-action-vanilla-200-hover-background, var(--action-background-hover)); - --button-internal-action-hover-border: var(--button-action-vanilla-200-hover-border, var(--action-border-hover)); - --button-internal-action-hover-text: var(--button-action-vanilla-200-hover-text, var(--action-foreground-hover)); + &[data-color='highlight-danger'] { + --button-internal-background: var(--button-highlight-danger-background, var(--bg-sakura-500)); + --button-internal-solid-foreground: var(--button-highlight-danger-solid-foreground, var(--text-ink-500)); + --button-internal-hover-background: var(--button-highlight-danger-hover-background, var(--bg-sakura-400)); + --button-internal-link-foreground: var(--button-highlight-danger-link-foreground, var(--bg-sakura-400)); + --button-internal-link-hover-foreground: var(--button-highlight-danger-link-hover-foreground, var(--bg-sakura-500)); } -} -.button:focus-visible { - outline-offset: var(--button-focus-visible-outline-offset, 2px); - outline: var(--button-focus-visible-outline, var(--ring) solid 2px); -} + &:focus-visible { + outline-offset: var(--button-focus-visible-outline-offset, 1px); + outline: var(--button-focus-visible-outline, var(--ring) solid 1px); + } -.button:hover { - background-color: var(--button-internal-hover-state-background-color, var(--button-internal-hover-background)); -} + &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) { + background-color: var(--button-hover-state-background-color, var(--button-internal-hover-background)); + } -.button:disabled { - pointer-events: var(--button-disabled-pointer-events, none); - opacity: var(--button-disabled-opacity, 0.6); -} + &:disabled:not([aria-busy="true"]), + &[aria-disabled="true"]:not([aria-busy="true"]) { + opacity: var(--button-disabled-opacity, 0.6); + cursor: var(--button-disabled-cursor, not-allowed); + } -.button--loading { - cursor: var(--button-loading-cursor, wait); -} + &[aria-busy="true"] { + opacity: var(--button-loading-opacity, 1); + cursor: var(--button-loading-cursor, wait); + } -.button[data-variant='outlined'] { - border: var(--button-variant-outlined-border, 1px solid var(--button-internal-border-color)); - background-color: var(--button-variant-outlined-background-color, transparent); - color: var(--button-variant-outlined-color, var(--button-internal-outlined-foreground)); -} + &__suffix-slot { + display: contents; + } + + &__label { + display: var(--button-label-display, block); + min-width: var(--button-label-min-width, 0); + overflow: var(--button-label-overflow, hidden); + white-space: var(--button-label-white-space, nowrap); + + &-tooltip { + max-width: var(--button-label-tooltip-max-width, 20rem); + } + } + + &[data-text-overflow='ellipsis'] .button__label { + text-overflow: var(--button-label-text-overflow, ellipsis); + } + + &[data-variant='outlined'] { + border: var(--button-variant-outlined-border, 1px solid var(--secondary-border)); + background-color: var(--button-variant-outlined-background-color, var(--secondary-background)); + color: var(--button-variant-outlined-color, var(--secondary-foreground)); + + &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) { + background-color: var(--button-variant-outlined-hover-background-color, var(--secondary-background-hover)); + color: var(--button-variant-outlined-hover-color, var(--secondary-foreground-hover)); + } + + &[aria-disabled="true"]:not([aria-busy="true"]) { + position: var(--button-variant-outlined-disabled-position, relative); + // Keeps the stripe bands inside the border radius. + overflow: var(--button-variant-outlined-disabled-overflow, hidden); + + .button__prefix-wrapper, + .button__label, + .button__suffix { + position: var(--button-variant-outlined-disabled-content-position, relative); + z-index: var(--button-variant-outlined-disabled-content-z-index, 2); + } + + &::before, + &::after { + content: var(--button-variant-outlined-disabled-stripe-content, ""); + position: var(--button-variant-outlined-disabled-stripe-position, absolute); + top: var(--button-variant-outlined-disabled-stripe-top, 0); + bottom: var(--button-variant-outlined-disabled-stripe-bottom, 0); + width: var(--button-variant-outlined-disabled-stripe-width, 16px); + background-image: var(--button-variant-outlined-disabled-stripe-background-image, + repeating-linear-gradient(-45deg, + transparent, + transparent 3px, + var(--button-variant-outlined-disabled-stripe-color, var(--secondary-border)) 2px, + var(--button-variant-outlined-disabled-stripe-color, var(--secondary-border)) 5px)); + z-index: var(--button-variant-outlined-disabled-stripe-z-index, 1); + pointer-events: var(--button-variant-outlined-disabled-stripe-pointer-events, none); + } + + &::before { + left: var(--button-variant-outlined-disabled-stripe-before-left, 0); + mask-image: var(--button-variant-outlined-disabled-stripe-before-mask-image, + linear-gradient(to left, transparent 5%, black 100%)); + } + + &::after { + right: var(--button-variant-outlined-disabled-stripe-after-right, 0); + mask-image: var(--button-variant-outlined-disabled-stripe-after-mask-image, + linear-gradient(to right, transparent 5%, black 100%)); + } + } + } + + &[data-variant='dashed'] { + border: var(--button-variant-dashed-border, 1px solid transparent); + background-color: var(--button-variant-dashed-background-color, transparent); + color: var(--button-variant-dashed-color, var(--secondary-foreground)); + position: var(--button-variant-dashed-position, relative); + + --button-internal-dash-border-width: var(--button-dash-border-width, 1px); + --button-internal-dash-stroke-width: var(--button-dash-stroke-width, 1px); + --button-internal-dash-length: var(--button-dash-length, 3px); + --button-internal-dash-gap: var(--button-dash-gap, 3px); + --button-internal-dash-duration: var(--button-dash-duration, 200ms); + --button-internal-dash-stroke: var(--button-dash-stroke, var(--secondary-border)); + + &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) { + background-color: var(--button-variant-dashed-hover-background-color, transparent); + color: var(--button-variant-dashed-hover-color, var(--secondary-foreground-hover)); + } + + &:hover:not([aria-disabled="true"]), + &[aria-busy="true"] { + --button-internal-dash-stroke: var(--button-dash-hover-stroke, + var(--secondary-background-hover)); + } + } + + &__dash-border { + --button-internal-dash-offset: calc(var(--button-internal-dash-stroke-width) / 2 - var(--button-internal-dash-border-width)); + position: absolute; + inset-inline-start: var(--button-internal-dash-offset); + inset-block-start: var(--button-internal-dash-offset); + inline-size: calc(100% - 2 * var(--button-internal-dash-offset)); + block-size: calc(100% - 2 * var(--button-internal-dash-offset)); + overflow: visible; + pointer-events: none; + + rect { + fill: none; + stroke: var(--button-internal-dash-stroke); + stroke-width: var(--button-internal-dash-stroke-width); + stroke-dasharray: var(--button-internal-dash-length) var(--button-internal-dash-gap); + rx: var(--button-internal-border-radius); + ry: var(--button-internal-border-radius); + transition: stroke 150ms ease; + animation: button-dash-march var(--button-internal-dash-duration) linear infinite; + animation-play-state: paused; + } + } -.button[data-variant='outlined']:hover { - background-color: var(--button-variant-outlined-hover-background-color, - var(--button-internal-border-color)); - color: var(--button-variant-outlined-hover-color, var(--button-internal-solid-foreground)); + &[data-variant='ghost'] { + background-color: var(--button-variant-ghost-background-color, transparent); + color: var(--button-variant-ghost-color, var(--secondary-foreground)); + position: var(--button-variant-ghost-position, relative); + overflow: var(--button-variant-ghost-overflow, hidden); + + // on light, this used to be 12%, but since this is not controlled by semantic token + // I will keep as smooth as possible for any theme + --button-internal-ghost-glow-opacity: var(--button-ghost-glow-opacity, 22%); + + .button__prefix-wrapper, + .button__label, + .button__suffix { + position: relative; + z-index: var(--button-ghost-content-z-index, 1); + } + + &::before { + content: ''; + position: absolute; + inset: var(--button-ghost-glow-inset, -4px); + z-index: var(--button-ghost-glow-z-index, 0); + pointer-events: none; + opacity: 0; + filter: blur(var(--button-ghost-glow-blur, 6px)); + background: var(--button-ghost-glow-background, + linear-gradient(90deg, + color-mix(in srgb, var(--bg-cherry-400) var(--button-internal-ghost-glow-opacity), transparent), + color-mix(in srgb, var(--bg-amber-500) var(--button-internal-ghost-glow-opacity), transparent), + color-mix(in srgb, var(--bg-forest-400) var(--button-internal-ghost-glow-opacity), transparent), + color-mix(in srgb, var(--bg-aqua-400) var(--button-internal-ghost-glow-opacity), transparent), + color-mix(in srgb, var(--bg-robin-400) var(--button-internal-ghost-glow-opacity), transparent), + color-mix(in srgb, var(--bg-cherry-400) var(--button-internal-ghost-glow-opacity), transparent))); + // Twice the width so the gradient can slide a full period and wrap on itself. + background-size: var(--button-ghost-glow-background-size, 200% 100%); + transition: opacity var(--button-ghost-glow-fade, 600ms ease); + animation: button-ghost-shimmer var(--button-ghost-glow-duration, 2.5s) linear infinite; + animation-play-state: paused; + } + + &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) { + background-color: var(--button-variant-ghost-hover-background-color, var(--secondary-background-hover)); + color: var(--button-variant-ghost-hover-color, var(--secondary-foreground-hover)); + } + + &:active:not([aria-disabled="true"])::before, + &[aria-busy="true"]::before { + opacity: var(--button-ghost-glow-active-opacity, 1); + animation-play-state: running; + } + } + + &[data-variant='link'] { + background-color: var(--button-variant-link-background-color, transparent); + font-weight: var(--button-variant-link-font-weight, var(--font-weight-medium)); + color: var(--button-variant-link-color, var(--button-internal-link-foreground)); + + &:hover:not([aria-busy="true"]):not([aria-disabled="true"]) { + background-color: var(--button-variant-link-hover-background-color, transparent); + color: var(--button-variant-link-hover-color, var(--button-internal-link-hover-foreground)); + } + } + + &[data-size='sm'] { + height: var(--button-height, 24px); + font-size: var(--button-font-size, var(--periscope-font-size-small)); + line-height: var(--button-size-line-height, 14px); + + --button-internal-icon-size: var(--button-icon-size, 12px); + --button-internal-gap: var(--spacing-3); + + &:not([data-variant="link"]) { + &[data-icon="false"] { + padding: var(--button-padding, var(--spacing-2) var(--spacing-4)); + } + + &[data-icon="true"] { + padding: var(--button-padding, var(--spacing-2)); + --button-internal-icon-size: var(--button-icon-size, 14px); + --button-internal-width: 24px; + --button-internal-max-width: 24px; + } + } + } + + &[data-size='md'] { + height: var(--button-height, 32px); + font-size: var(--button-font-size, var(--periscope-font-size-base)); + line-height: var(--button-size-line-height, 16px); + + --button-internal-icon-size: var(--button-icon-size, 14px); + --button-internal-gap: var(--spacing-3); + + &:not([data-variant="link"]) { + &[data-icon="false"] { + padding: var(--button-padding, var(--spacing-4) var(--spacing-6)); + } + + &[data-icon="true"] { + padding: var(--button-padding, var(--spacing-4)); + --button-internal-icon-size: var(--button-icon-size, 16px); + --button-internal-width: 32px; + --button-internal-max-width: 32px; + } + } + } } -.button[data-variant='dashed'] { - border: var(--button-variant-dashed-border, 1px dashed var(--button-internal-border-color)); - background-color: var(--button-variant-dashed-background-color, transparent); - color: var(--button-variant-dashed-color, var(--button-internal-outlined-foreground)); +.button[data-variant='dashed']:hover:not([aria-disabled="true"]) .button__dash-border rect, +.button[data-variant='dashed'][aria-busy="true"] .button__dash-border rect { + animation-play-state: running; } -.button[data-variant='dashed']:hover { - background-color: var(--button-variant-dashed-hover-background-color, - var(--button-internal-border-color)); - color: var(--button-variant-dashed-hover-color, var(--button-internal-solid-foreground)); +.button__prefix, +.button__prefix > svg, +.button__suffix, +.button__suffix > svg { + flex-shrink: var(--button-affix-flex-shrink, 0); + inline-size: var(--button-internal-icon-size); + block-size: var(--button-internal-icon-size); } -.button[data-variant='ghost'] { - background-color: var(--button-variant-ghost-background-color, transparent); - color: var(--button-variant-ghost-color, var(--button-internal-outlined-foreground)); +.button__prefix-wrapper { + display: var(--button-prefix-wrapper-display, grid); + grid-template-columns: var(--button-prefix-wrapper-grid-template-columns, 1fr); + block-size: var(--button-prefix-wrapper-block-size, var(--button-internal-icon-size)); + align-items: var(--button-prefix-wrapper-align-items, center); + justify-items: var(--button-prefix-wrapper-justify-items, center); + flex-shrink: var(--button-prefix-wrapper-flex-shrink, 0); + overflow: var(--button-prefix-wrapper-overflow, hidden); + transition: var(--button-prefix-wrapper-transition, + grid-template-columns var(--button-internal-loading-duration) var(--button-internal-loading-easing), + margin-inline-end var(--button-internal-loading-duration) var(--button-internal-loading-easing)); + // Opening up for the spinner is the first beat of the sequence. + transition-delay: 0s; } -.button[data-variant='ghost']:hover { - background-color: var(--button-variant-ghost-hover-background-color, var(--button-internal-hover-background)); - color: var(--button-variant-ghost-hover-color, var(--button-internal-solid-foreground)); +.button:not([aria-busy="true"]) .button__prefix-wrapper[data-empty='true'] { + grid-template-columns: var(--button-prefix-wrapper-collapsed-grid-template-columns, 0fr); + margin-inline-end: var(--button-prefix-wrapper-collapsed-margin-inline-end, calc(-1 * var(--button-internal-gap))); + // Closing back up is the last beat: wait for the spinner to leave first. + transition-delay: var(--button-internal-loading-delay); } -.button[data-variant='link'] { - background-color: var(--button-variant-link-background-color, transparent); - font-weight: var(--button-variant-link-font-weight, 500); - color: var(--button-variant-link-color, var(--button-internal-outlined-foreground)); +.button__prefix-slot, +.button__loader-slot { + grid-area: 1 / 1; + display: var(--button-slot-display, flex); + align-items: var(--button-slot-align-items, center); + justify-content: var(--button-slot-justify-content, center); + min-width: var(--button-slot-min-width, 0); + transition: var(--button-slot-transition, + opacity var(--button-internal-loading-duration) var(--button-internal-loading-easing), + transform var(--button-internal-loading-duration) var(--button-internal-loading-easing)); } -.button[data-variant='link']:hover { - background-color: var(--button-variant-link-hover-background-color, transparent); - color: var(--button-variant-link-hover-color, var(--button-internal-link-hover-foreground)); +.button__prefix-slot { + opacity: var(--button-prefix-slot-visible-opacity, 1); + transform: var(--button-prefix-slot-visible-transform, translateY(0)); + transition-delay: var(--button-internal-loading-delay); } -.button[data-variant='action'] { - border: var(--button-variant-action-border, 1px solid var(--button-internal-action-border)); - background-color: var(--button-variant-action-background-color, var(--button-internal-action-background)); - color: var(--button-variant-action-color, var(--button-internal-action-text)); +.button__loader-slot { + // The ring sizes itself off the button's icon size, no shared stylesheet. + --spinner-size: var(--button-internal-icon-size); + opacity: var(--button-loader-slot-hidden-opacity, 0); + transform: var(--button-loader-slot-hidden-transform, + translateY(var(--button-internal-loading-travel))); + transition-delay: 0s; } -.button[data-variant='action']:hover { - background-color: var(--button-variant-action-hover-background-color, - var(--button-internal-action-hover-background)); - border-color: var(--button-variant-action-hover-border-color, var(--button-internal-action-hover-border)); - color: var(--button-variant-action-hover-color, var(--button-internal-action-hover-text)); +// The spinner stays mounted for the cross-fade, so the ring is frozen while the +// button is idle instead of turning behind an invisible slot. +.button:not([aria-busy="true"]) .button__loader-slot [data-slot='spinner'] { + animation-play-state: var(--button-loader-slot-idle-animation-play-state, paused); } -.button[data-size='sm'] { - height: var(--button-height, 1.5rem); - font-size: var(--button-font-size, var(--periscope-font-size-small, 11px)); - line-height: var(--button-size-sm-line-height, 1.5rem); - gap: var(--button-gap, var(--spacing-3, 0.375rem)); +.button[aria-busy="true"] { + .button__prefix-slot { + opacity: var(--button-prefix-slot-hidden-opacity, 0); + transform: var(--button-prefix-slot-hidden-transform, + translateY(calc(-1 * var(--button-internal-loading-travel)))); + transition-delay: 0s; + } - &:not([data-variant="link"]) { - padding: var(--button-padding, var(--spacing-3, 0.375rem) var(--spacing-4, 0.5rem)); + .button__loader-slot { + opacity: var(--button-loader-slot-visible-opacity, 1); + transform: var(--button-loader-slot-visible-transform, translateY(0)); + transition-delay: var(--button-internal-loading-delay); } } -.button[data-size='md'] { - height: var(--button-height, 2rem); - font-size: var(--button-font-size, var(--periscope-font-size-small, 11px)); - gap: var(--button-gap, var(--spacing-4, 0.5rem)); - - &:not([data-variant="link"]) { - padding: var(--button-padding, var(--spacing-5, 0.625rem) var(--spacing-8, 1rem)); +@media (prefers-reduced-motion: reduce) { + .button__prefix-wrapper, + .button:not([aria-busy="true"]) .button__prefix-wrapper[data-empty='true'], + .button__prefix-slot, + .button__loader-slot { + transition-duration: 0s; + transition-delay: 0s; } -} -.button[data-size='icon'] { - height: var(--button-height, 2rem); - width: var(--button-width, 2rem); - font-size: var(--button-font-size, var(--periscope-font-size-small, 11px)); - gap: var(--button-gap, var(--spacing-4, 0.5rem)); + .button[data-variant='dashed']:hover:not([aria-disabled="true"]) .button__dash-border rect, + .button[data-variant='dashed'][aria-busy="true"] .button__dash-border rect, + .button[data-variant='ghost']:active:not([aria-disabled="true"])::before, + .button[data-variant='ghost'][aria-busy="true"]::before { + animation-play-state: paused; + } - &:not([data-variant="link"]) { - padding: var(--button-padding, var(--spacing-4, 0.5rem)); + .button[data-variant='ghost']::before { + transition-duration: 0s; } } -.button__loader { - flex-shrink: var(--button-loader-flex-shrink, 0); -} +// Travels exactly one dash + gap, so the end state is pixel-identical to the +// start and the loop never shows a seam. Counts down to 0 rather than into the +// negatives, which SVG 1.1 called an error. +@keyframes button-dash-march { + from { + stroke-dashoffset: calc(var(--button-internal-dash-length) + var(--button-internal-dash-gap)); + } -.button__prefix, -.button__suffix { - flex-shrink: var(--button-affix-flex-shrink, 0); + to { + stroke-dashoffset: 0; + } } -.animate-fast-spin { - --button-internal-animation-name: spin; - animation: var(--button-animate-fast-spin, var(--button-internal-animation-name) 0.7s linear infinite); -} +@keyframes button-ghost-shimmer { + from { + background-position: 0% 50%; + } -@keyframes spin { to { - transform: var(--button-spin-transform, rotate(360deg)); + background-position: 200% 50%; } } diff --git a/packages/ui/src/button/button.test.tsx b/packages/ui/src/button/button.test.tsx deleted file mode 100644 index 57a64fa5..00000000 --- a/packages/ui/src/button/button.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { createRef } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { Button, ButtonBackground, ButtonColor, ButtonSize, ButtonVariant } from './button.js'; -import { ButtonGroup } from '../button-group/button-group'; - -describe('Button', () => { - it('renders as button with default props and children', () => { - render(); - const button = screen.getByRole('button', { name: 'Label' }); - expect(button).toBeInTheDocument(); - expect(button.tagName).toBe('BUTTON'); - expect(button).toHaveAttribute('data-color', 'primary'); - }); - - it('applies testId to data-testid', () => { - render(); - expect(screen.getByTestId('my-button')).toBeInTheDocument(); - }); - - it('disables the button when disabled is true', () => { - const onClick = vi.fn(); - - render( - , - ); - expect(screen.getByRole('button')).toBeDisabled(); - - fireEvent.click(screen.getByRole('button')); - expect(onClick).toHaveBeenCalledTimes(0); - }); - - it('disables the button and shows spinner when loading', () => { - const onClick = vi.fn(); - - render( - , - ); - const button = screen.getByRole('button'); - expect(button).toBeDisabled(); - - fireEvent.click(button); - expect(onClick).toHaveBeenCalledTimes(0); - - expect(screen.getByTestId('loader-circle')).toBeInTheDocument(); - }); - - it('hides prefix and suffix when loading', () => { - render( - , - ); - expect(screen.queryByTestId('prefix')).not.toBeInTheDocument(); - expect(screen.queryByTestId('suffix')).not.toBeInTheDocument(); - }); - - it('renders prefix and suffix when not loading', () => { - render( - , - ); - expect(screen.getByTestId('prefix')).toBeInTheDocument(); - expect(screen.getByTestId('suffix')).toBeInTheDocument(); - }); - - it('renders as child element when asChild is true', () => { - render( - , - ); - const link = screen.getByTestId('link-btn'); - expect(link.tagName).toBe('A'); - expect(link).toHaveAttribute('href', '#'); - expect(link).toHaveAttribute('data-color', 'primary'); - }); - - it('calls onClick when clicked', () => { - const onClick = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button')); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClick).toHaveBeenCalledWith(expect.any(Object)); - }); - - it('calls onDoubleClick when double-clicked', () => { - const onDoubleClick = vi.fn(); - render(); - fireEvent.doubleClick(screen.getByRole('button')); - expect(onDoubleClick).toHaveBeenCalledTimes(1); - }); - - it('forwards ref to the button element', () => { - const ref = createRef(); - render(); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current).toBe(screen.getByRole('button')); - }); - - it('applies data-color for color prop', () => { - render(); - expect(screen.getByRole('button')).toHaveAttribute('data-color', 'destructive'); - }); - - it('applies data-background for Action variant with background', () => { - render( - , - ); - expect(screen.getByRole('button')).toHaveAttribute('data-background', 'vanilla-100'); - }); - - it('merges custom className', () => { - render(); - const button = screen.getByRole('button'); - expect(button).toHaveClass('custom-class'); - }); - - it('applies type="submit" when specified', () => { - render(); - expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); - }); - - it('forwards native event handlers (onMouseEnter, onFocus, onKeyDown)', () => { - const onMouseEnter = vi.fn(); - const onFocus = vi.fn(); - const onKeyDown = vi.fn(); - render( - , - ); - const btn = screen.getByRole('button'); - fireEvent.mouseEnter(btn); - fireEvent.focus(btn); - fireEvent.keyDown(btn, { key: 'Enter' }); - expect(onMouseEnter).toHaveBeenCalledTimes(1); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onKeyDown).toHaveBeenCalledTimes(1); - }); - - it('forwards arbitrary aria-* and data-* attributes to the button element', () => { - render( - , - ); - const btn = screen.getByRole('button'); - expect(btn).toHaveAttribute('aria-label', 'Toolbar action'); - expect(btn).toHaveAttribute('data-foo', 'bar'); - expect(btn).toHaveAttribute('tabindex', '-1'); - }); -}); - -describe('ButtonGroup', () => { - it('renders a group with role=group', () => { - render( - - - - , - ); - const group = screen.getByTestId('g'); - expect(group).toHaveAttribute('role', 'group'); - }); - - it('propagates size to child buttons that do not override it', () => { - render( - - - - , - ); - expect(screen.getByTestId('a')).toHaveAttribute('data-size', 'sm'); - expect(screen.getByTestId('b')).toHaveAttribute('data-size', 'md'); - }); - - it('propagates variant and color to child buttons', () => { - render( - - - - , - ); - const a = screen.getByTestId('a'); - const b = screen.getByTestId('b'); - expect(a).toHaveAttribute('data-variant', 'outlined'); - expect(a).toHaveAttribute('data-color', 'secondary'); - expect(b).toHaveAttribute('data-color', 'destructive'); - }); - - it('forwards ref to the group element', () => { - const ref = createRef(); - render( - - - , - ); - expect(ref.current).toBeInstanceOf(HTMLDivElement); - }); -}); diff --git a/packages/ui/src/button/button.tsx b/packages/ui/src/button/button.tsx index 25bccb2d..3105ed29 100644 --- a/packages/ui/src/button/button.tsx +++ b/packages/ui/src/button/button.tsx @@ -1,280 +1,380 @@ -import { Slot } from '@radix-ui/react-slot'; -import { LoaderCircle } from '@signozhq/icons'; -import type React from 'react'; -import { cloneElement, createContext, forwardRef, useContext } from 'react'; +import { Button as BaseUiButton } from '@base-ui/react/button'; +import { + cloneElement, + forwardRef, + isValidElement, + type MouseEventHandler, + type ReactElement, + type RefAttributes, + useId, + useMemo, +} from 'react'; import { cn } from '../lib/utils.js'; +import { Spinner } from '../spinner/spinner.js'; +import { TooltipProviderIfMissing } from '../tooltip/subcomponents/tooltip-provider.js'; +import { TooltipContent } from '../tooltip/subcomponents/tooltip-content.js'; +import { TooltipRoot } from '../tooltip/subcomponents/tooltip-root.js'; +import { TooltipStack } from '../tooltip/subcomponents/tooltip-stack.js'; +import { TooltipTrigger } from '../tooltip/subcomponents/tooltip-trigger.js'; +import { + hasTooltipContent, + type TooltipContentStackEntry, +} from '../tooltip/tooltip-content-stack-context.js'; +import { useTooltipHandle } from '../tooltip/tooltip-handle.js'; import styles from './button.module.scss'; - -export const ButtonVariant = { - Solid: 'solid', - Outlined: 'outlined', - Dashed: 'dashed', - Ghost: 'ghost', - Link: 'link', - Action: 'action', -} as const; - -export const ButtonSize = { - SM: 'sm', - MD: 'md', - Icon: 'icon', -} as const; - -export const ButtonBackground = { - Ink500: 'ink-500', - Ink400: 'ink-400', - Vanilla100: 'vanilla-100', - Vanilla200: 'vanilla-200', -} as const; - -export const ButtonColor = { - Primary: 'primary', - Destructive: 'destructive', - Warning: 'warning', - Secondary: 'secondary', - None: 'none', -} as const; - -export type ButtonVariantValue = (typeof ButtonVariant)[keyof typeof ButtonVariant]; -export type ButtonSizeValue = (typeof ButtonSize)[keyof typeof ButtonSize]; -export type ButtonBackgroundValue = (typeof ButtonBackground)[keyof typeof ButtonBackground]; -export type ButtonColorValue = (typeof ButtonColor)[keyof typeof ButtonColor] | (string & {}); - -/** - * Context used by `ButtonGroup` to propagate `size`, `variant`, and `color` to - * descendant `Button`s. Children may still override any of these locally. - */ -export interface ButtonGroupContextValue { - size?: ButtonSizeValue; - variant?: ButtonVariantValue; - color?: ButtonColorValue; - inGroup: boolean; -} - -export const ButtonGroupContext = createContext(null); +import { ButtonTextOverflow, ButtonVariant } from './constants.js'; +import type { ButtonProps, SizeType, ValidateButtonProps, VariantType } from './types.js'; +import { useIsLabelTruncated } from '../lib/useIsLabelTruncated.js'; +import { toCssLength } from '../lib/css-length'; /** * Helper function to generate button class names for use in other components * This replaces the old CVA-based buttonVariants function + * + * @deprecated */ export function buttonVariants({ variant: _variant = 'outlined', size: _size = 'md', className, }: { - variant?: ButtonVariantValue; - size?: ButtonSizeValue; + variant?: VariantType; + size?: SizeType; className?: string; } = {}) { return cn(styles['button'], className); } -export type ButtonProps = { - /** - * Visual style of the button. - * @default 'solid' - */ - variant?: ButtonVariantValue; - /** - * Height + padding token. `'icon'` produces a square button suitable for a single icon child. - * @default 'md' - */ - size?: ButtonSizeValue; - /** - * When `true`, render as the immediate child element (via Radix `Slot`) instead of a native - * ` - * ``` + * Visual values are `--button-*` custom properties, defaults in the `css-tokens` region of + * [./index.ts](./index.ts). * - * @example - * ```tsx - * // Outlined secondary with a leading icon - * - * ``` + * ### Icon mode + * + * `icon` puts `children` in the prefix slot. There is no label and no `suffix`. + * + * Nothing is left to read, so give it an `aria-label`. + * + * ### Disabled and loading + * + * Both set `aria-disabled`, never the native `disabled` attribute (Base UI + * `focusableWhenDisabled`). + * + * So the button stays tabbable and hoverable, and its tooltip stays reachable. + * + * They swallow `onClick`, `onDoubleClick` and Enter/Space. Hover and focus events still fire. + * + * In tests `toBeDisabled()` fails. Assert `aria-disabled`. + * + * ### Loading + * + * Adds `aria-busy` and cross-fades the spinner over the prefix slot. + * + * Label and `suffix` stay visible. + * + * It suppresses `disabledTooltip`, even while `disabled` is true. `loadingTooltip` takes that + * place, and only while loading. + * + * ### Truncation + * + * `textOverflow="ellipsis"` (the default) measures the label and re-measures on resize. + * + * While it does not fit: `data-truncated`, plus a tooltip with the full text. The visible text + * is only clipped, so the accessible name is already the full label. + * + * `none` clips with no tooltip. Icon buttons have no label to measure. + * + * ### Its tooltips + * + * Three of them, and only one reason can apply at a time: `loadingTooltip` while loading, + * `disabledTooltip` while disabled and idle, plus the truncated label. + * + * When both apply, one popup holds both: reason first, then the label, split by a + * `tooltip-divider`. + * + * Inside a wrapping `` the button adds its entries to that popup instead of opening a + * second one. + * + * The trigger is always mounted, so the element never remounts when a tooltip appears. The + * tooltip root and popup mount only while there is something to show. + * + * ### Width + * + * `width` and `maxWidth` are written as inline `--button-internal-width` and + * `--button-internal-max-width`. + * + * So they compose with the tokens instead of overwriting `style.width`. Numbers are written as + * `px`, and any `style` you pass is kept. + * + * ### Asserting on it + * + * `testId` is `data-testid` and survives the tooltip trigger cloning the button. Otherwise use + * the data attributes, never the hashed class names. + * + * | root attribute | value | + * |---|---| + * | `data-slot` | `"button"` | + * | `data-variant`, `data-color`, `data-size` | mirrors the prop | + * | `data-icon` | `"true"` in icon mode, else `"false"` | + * | `data-text-overflow` | `ellipsis` (default) or `none` | + * | `data-truncated` | present only while the label does not fit | + * + * | `data-slot` | rendered | + * |---|---| + * | `button-prefix-wrapper` | always, `data-empty="true"` without a prefix or icon | + * | `button-prefix-slot` | always, holds the cloned `prefix` (or the icon `children`) | + * | `button-prefix-loading` | always, holds the `Spinner`, `aria-hidden`. The ring is paused while idle | + * | `button-label` | except in icon mode, this is the measured element | + * | `button-suffix-slot` | only when `suffix` is a valid element | + * | `button-dashed-border` | only for `variant="dashed"` | * * @example * ```tsx - * // Destructive ghost with a trailing icon - * * ``` * * @example * ```tsx - * // Icon-only square button - * * ``` * * @example * ```tsx - * // Loading state — disables the button and replaces the prefix with a spinner - * - * ``` - * - * @example - * ```tsx - * // `asChild` — render as a link with button styling - * * ``` * * @example * ```tsx - * // Native HTML submit button inside a form - *
- * - *
+ * // Says what it is waiting on while the request is in flight + * * ``` */ -const Button = forwardRef( - ( - { - className, - variant, - color, - size, - prefix, - suffix, - asChild = false, - disabled, - loading = false, - background, - children, - testId, - ...props - }, - ref, - ) => { - const Comp = asChild ? Slot : 'button'; - const group = useContext(ButtonGroupContext); - - variant ??= group?.variant ?? ButtonVariant.Solid; - size ??= group?.size ?? ButtonSize.MD; - color ??= group?.color ?? ButtonColor.Primary; - - const iconSizes: Record = { - [ButtonSize.SM]: 12, - [ButtonSize.MD]: 14, - [ButtonSize.Icon]: 16, - }; - - if (asChild) { - if (loading || prefix || suffix) { - console.warn('Loading, prefix, and suffix are not supported when using asChild'); - } - - return ( - - {children} - - ); - } - - return ( - - {loading ? ( - - ) : ( - (prefix && - cloneElement(prefix, { - ...(!prefix.props.size && { - size: iconSizes[size], - className: styles['button__prefix'], - }), - })) || - null - )} - {children} - {(!loading && - suffix && - cloneElement(suffix, { - ...(!suffix.props.size && { - size: iconSizes[size], - className: styles['button__suffix'], - }), - })) || - null} - - ); - }, -); -Button.displayName = 'Button'; - -export { Button }; +export const Button = ButtonImpl as ( + props: T & + ValidateButtonProps & + // `T` is inferred from the call site, so `T extends ButtonProps` alone never runs excess + // property checks. Every key outside the props (a typo, a native attribute the button does + // not forward on purpose) is pinned to `never` instead. + Record>, never> & + RefAttributes, +) => ReactElement; diff --git a/packages/ui/src/button/constants.tsx b/packages/ui/src/button/constants.tsx new file mode 100644 index 00000000..e061a089 --- /dev/null +++ b/packages/ui/src/button/constants.tsx @@ -0,0 +1,28 @@ +export const ButtonVariant = { + Solid: 'solid', + Outlined: 'outlined', + Dashed: 'dashed', + Ghost: 'ghost', + Link: 'link', +} as const; + +export const ButtonSize = { + SM: 'sm', + MD: 'md', +} as const; + +export const ButtonColor = { + Primary: 'primary', + Secondary: 'secondary', + Danger: 'danger', + Warning: 'warning', + Success: 'success', + Info: 'info', + Archive: 'archive', + HighlightDanger: 'highlight-danger', +} as const; + +export const ButtonTextOverflow = { + None: 'none', + Ellipsis: 'ellipsis', +} as const; diff --git a/packages/ui/src/button/index.ts b/packages/ui/src/button/index.ts index 28ba5fa4..a4220370 100644 --- a/packages/ui/src/button/index.ts +++ b/packages/ui/src/button/index.ts @@ -5,130 +5,183 @@ * * | Token | Default | * |-------|---------| - * | `--button-action-border` | `var(--action-border)` | - * | `--button-action-hover-border` | `var(--action-border-hover)` | - * | `--button-action-hover-text` | `var(--action-foreground-hover)` | - * | `--button-action-ink-400-background` | `var(--action-background)` | - * | `--button-action-ink-400-border` | `var(--action-border)` | - * | `--button-action-ink-400-hover-background` | `var(--action-background-hover)` | - * | `--button-action-ink-400-hover-border` | `var(--action-border-hover)` | - * | `--button-action-ink-400-hover-text` | `var(--action-foreground-hover)` | - * | `--button-action-ink-400-text` | `var(--action-foreground)` | - * | `--button-action-ink-500-background` | `var(--action-background)` | - * | `--button-action-ink-500-border` | `var(--action-border)` | - * | `--button-action-ink-500-hover-background` | `var(--action-background-hover)` | - * | `--button-action-ink-500-hover-border` | `var(--action-border-hover)` | - * | `--button-action-ink-500-hover-text` | `var(--action-foreground-hover)` | - * | `--button-action-ink-500-text` | `var(--action-foreground)` | - * | `--button-action-text` | `var(--action-foreground)` | - * | `--button-action-vanilla-100-background` | `var(--action-background)` | - * | `--button-action-vanilla-100-border` | `var(--action-border)` | - * | `--button-action-vanilla-100-hover-background` | `var(--action-background-hover)` | - * | `--button-action-vanilla-100-hover-border` | `var(--action-border-hover)` | - * | `--button-action-vanilla-100-hover-text` | `var(--action-foreground-hover)` | - * | `--button-action-vanilla-100-text` | `var(--action-foreground)` | - * | `--button-action-vanilla-200-background` | `var(--action-background)` | - * | `--button-action-vanilla-200-border` | `var(--action-border)` | - * | `--button-action-vanilla-200-hover-background` | `var(--action-background-hover)` | - * | `--button-action-vanilla-200-hover-border` | `var(--action-border-hover)` | - * | `--button-action-vanilla-200-hover-text` | `var(--action-foreground-hover)` | - * | `--button-action-vanilla-200-text` | `var(--action-foreground)` | * | `--button-affix-flex-shrink` | `0` | * | `--button-align-items` | `center` | - * | `--button-animate-fast-spin` | `var(--button-internal-animation-name) 0.7s line...` | + * | `--button-archive-background` | `var(--bg-sienna-500)` | + * | `--button-archive-hover-background` | `var(--bg-sienna-400)` | + * | `--button-archive-link-foreground` | `var(--bg-sienna-400)` | + * | `--button-archive-link-hover-foreground` | `var(--bg-sienna-500)` | + * | `--button-archive-solid-foreground` | `var(--text-ink-500)` | * | `--button-base-border-color` | `transparent` | * | `--button-base-border-width` | `0px` | - * | `--button-border-radius` | `calc(var(--radius) - 2px)` | + * | `--button-border-radius` | `var(--radius-1)` | * | `--button-cursor` | `pointer` | - * | `--button-destructive-background` | `var(--danger-background)` | - * | `--button-destructive-border-color` | `var(--danger-background)` | - * | `--button-destructive-ghost-hover-foreground` | `var(--danger-foreground-hover)` | - * | `--button-destructive-hover-background` | `var(--danger-background-hover)` | - * | `--button-destructive-link-hover-foreground` | `var(--danger-background-hover)` | - * | `--button-destructive-outlined-foreground` | `var(--danger-background)` | - * | `--button-destructive-solid-foreground` | `var(--danger-foreground)` | + * | `--button-danger-background` | `var(--danger-background)` | + * | `--button-danger-hover-background` | `var(--danger-background-hover)` | + * | `--button-danger-link-foreground` | `var(--danger-link)` | + * | `--button-danger-link-hover-foreground` | `var(--danger-link-hover)` | + * | `--button-danger-solid-foreground` | `var(--danger-foreground)` | + * | `--button-dash-border-width` | `1px` | + * | `--button-dash-duration` | `200ms` | + * | `--button-dash-gap` | `3px` | + * | `--button-dash-hover-stroke` | `var(--secondary-background-hover)` | + * | `--button-dash-length` | `3px` | + * | `--button-dash-stroke` | `var(--secondary-border)` | + * | `--button-dash-stroke-width` | `1px` | + * | `--button-disabled-cursor` | `not-allowed` | * | `--button-disabled-opacity` | `0.6` | - * | `--button-disabled-pointer-events` | `none` | * | `--button-display` | `inline-flex` | - * | `--button-focus-visible-outline` | `var(--ring) solid 2px` | - * | `--button-focus-visible-outline-offset` | `2px` | - * | `--button-font-size` | `var(--periscope-font-size-small, 11px)` | + * | `--button-flex-direction` | `row` | + * | `--button-focus-visible-outline` | `var(--ring) solid 1px` | + * | `--button-focus-visible-outline-offset` | `1px` | + * | `--button-font-size` | `var(--periscope-font-size-small)` | * | `--button-font-variant-numeric` | `slashed-zero` | - * | `--button-gap` | `var(--spacing-3, 0.375rem)` | - * | `--button-height` | `1.5rem` | + * | `--button-font-weight` | `var(--font-weight-medium)` | + * | `--button-gap` | `var(--button-internal-gap)` | + * | `--button-ghost-content-z-index` | `1` | + * | `--button-ghost-glow-active-opacity` | `1` | + * | `--button-ghost-glow-background` | `linear-gradient(90deg, color-mix(in srgb...` | + * | `--button-ghost-glow-background-size` | `200% 100%` | + * | `--button-ghost-glow-blur` | `6px` | + * | `--button-ghost-glow-duration` | `2.5s` | + * | `--button-ghost-glow-fade` | `600ms ease` | + * | `--button-ghost-glow-inset` | `-4px` | + * | `--button-ghost-glow-opacity` | `22%` | + * | `--button-ghost-glow-z-index` | `0` | + * | `--button-height` | `24px` | + * | `--button-highlight-danger-background` | `var(--bg-sakura-500)` | + * | `--button-highlight-danger-hover-background` | `var(--bg-sakura-400)` | + * | `--button-highlight-danger-link-foreground` | `var(--bg-sakura-400)` | + * | `--button-highlight-danger-link-hover-foreground` | `var(--bg-sakura-500)` | + * | `--button-highlight-danger-solid-foreground` | `var(--text-ink-500)` | + * | `--button-hover-state-background-color` | `var(--button-internal-hover-background)` | + * | `--button-icon-size` | `12px` | + * | `--button-info-background` | `var(--bg-aqua-500)` | + * | `--button-info-hover-background` | `var(--bg-aqua-400)` | + * | `--button-info-link-foreground` | `var(--bg-aqua-400)` | + * | `--button-info-link-hover-foreground` | `var(--bg-aqua-500)` | + * | `--button-info-solid-foreground` | `var(--text-ink-500)` | * | `--button-justify-content` | `center` | - * | `--button-line-height` | `100%` | - * | `--button-loader-flex-shrink` | `0` | + * | `--button-label-display` | `block` | + * | `--button-label-min-width` | `0` | + * | `--button-label-overflow` | `hidden` | + * | `--button-label-text-overflow` | `ellipsis` | + * | `--button-label-tooltip-max-width` | `20rem` | + * | `--button-label-white-space` | `nowrap` | + * | `--button-loader-slot-hidden-opacity` | `0` | + * | `--button-loader-slot-hidden-transform` | `translateY(var(--button-internal-loading-travel))` | + * | `--button-loader-slot-idle-animation-play-state` | `paused` | + * | `--button-loader-slot-visible-opacity` | `1` | + * | `--button-loader-slot-visible-transform` | `translateY(0)` | * | `--button-loading-cursor` | `wait` | - * | `--button-none-background` | `var(--ghost-background)` | - * | `--button-none-border-color` | `var(--ghost-border)` | - * | `--button-none-ghost-hover-foreground` | `var(--ghost-foreground-hover)` | - * | `--button-none-hover-background` | `var(--ghost-background-hover)` | - * | `--button-none-link-hover-foreground` | `var(--ghost-foreground-hover)` | - * | `--button-none-outlined-foreground` | `var(--ghost-foreground)` | - * | `--button-none-solid-foreground` | `var(--ghost-foreground)` | - * | `--button-padding` | `var(--spacing-3, 0.375rem) var(--spacing-4, 0.5...` | + * | `--button-loading-delay` | `var(--button-internal-loading-duration)` | + * | `--button-loading-duration` | `120ms` | + * | `--button-loading-easing` | `cubic-bezier(0.65, 0, 0.35, 1)` | + * | `--button-loading-opacity` | `1` | + * | `--button-loading-travel` | `6px` | + * | `--button-max-width` | `var(--button-internal-max-width, 100%)` | + * | `--button-padding` | `var(--spacing-2) var(--spacing-4)` | + * | `--button-prefix-slot-hidden-opacity` | `0` | + * | `--button-prefix-slot-hidden-transform` | `translateY(calc(-1 * var(--button-internal-load...` | + * | `--button-prefix-slot-visible-opacity` | `1` | + * | `--button-prefix-slot-visible-transform` | `translateY(0)` | + * | `--button-prefix-wrapper-align-items` | `center` | + * | `--button-prefix-wrapper-block-size` | `var(--button-internal-icon-size)` | + * | `--button-prefix-wrapper-collapsed-grid-template-columns` | `0fr` | + * | `--button-prefix-wrapper-collapsed-margin-inline-end` | `calc(-1 * var(--button-internal-gap))` | + * | `--button-prefix-wrapper-display` | `grid` | + * | `--button-prefix-wrapper-flex-shrink` | `0` | + * | `--button-prefix-wrapper-grid-template-columns` | `1fr` | + * | `--button-prefix-wrapper-justify-items` | `center` | + * | `--button-prefix-wrapper-overflow` | `hidden` | + * | `--button-prefix-wrapper-transition` | `grid-template-columns var(--button-internal-loa...` | * | `--button-primary-background` | `var(--primary-background)` | - * | `--button-primary-border-color` | `var(--primary-background)` | - * | `--button-primary-ghost-hover-foreground` | `var(--primary-foreground-hover)` | * | `--button-primary-hover-background` | `var(--primary-background-hover)` | - * | `--button-primary-link-hover-foreground` | `var(--primary-background-hover)` | - * | `--button-primary-outlined-foreground` | `var(--primary)` | + * | `--button-primary-link-foreground` | `var(--primary-link)` | + * | `--button-primary-link-hover-foreground` | `var(--primary-hover)` | * | `--button-primary-solid-foreground` | `var(--primary-foreground)` | * | `--button-secondary-background` | `var(--secondary-background)` | - * | `--button-secondary-border-color` | `var(--secondary-border)` | - * | `--button-secondary-ghost-hover-foreground` | `var(--secondary-foreground-hover)` | * | `--button-secondary-hover-background` | `var(--secondary-background-hover)` | - * | `--button-secondary-link-hover-foreground` | `var(--secondary-foreground-hover)` | - * | `--button-secondary-outlined-foreground` | `var(--secondary-foreground)` | + * | `--button-secondary-link-foreground` | `var(--secondary-link)` | + * | `--button-secondary-link-hover-foreground` | `var(--secondary-link-hover)` | * | `--button-secondary-solid-foreground` | `var(--secondary-foreground)` | - * | `--button-size-sm-line-height` | `1.5rem` | - * | `--button-spin-transform` | `rotate(360deg)` | + * | `--button-size-line-height` | `14px` | + * | `--button-slot-align-items` | `center` | + * | `--button-slot-display` | `flex` | + * | `--button-slot-justify-content` | `center` | + * | `--button-slot-min-width` | `0` | + * | `--button-slot-transition` | `opacity var(--button-internal-loading-duration)...` | + * | `--button-success-background` | `var(--success-background)` | + * | `--button-success-hover-background` | `var(--success-background-hover)` | + * | `--button-success-link-foreground` | `var(--success-link)` | + * | `--button-success-link-hover-foreground` | `var(--success-link-hover)` | + * | `--button-success-solid-foreground` | `var(--success-foreground)` | + * | `--button-text-spacing` | `-0.005em` | * | `--button-transition` | `background-color 150ms ease, color 150ms eas...` | - * | `--button-variant-action-background-color` | `var(--button-internal-action-background)` | - * | `--button-variant-action-border` | `1px solid var(--button-internal-action-border)` | - * | `--button-variant-action-color` | `var(--button-internal-action-text)` | - * | `--button-variant-action-hover-background-color` | `var(--button-internal-action-hover-background)` | - * | `--button-variant-action-hover-border-color` | `var(--button-internal-action-hover-border)` | - * | `--button-variant-action-hover-color` | `var(--button-internal-action-hover-text)` | * | `--button-variant-dashed-background-color` | `transparent` | - * | `--button-variant-dashed-border` | `1px dashed var(--button-internal-border-color)` | - * | `--button-variant-dashed-color` | `var(--button-internal-outlined-foreground)` | - * | `--button-variant-dashed-hover-background-color` | `var(--button-internal-border-color)` | - * | `--button-variant-dashed-hover-color` | `var(--button-internal-solid-foreground)` | + * | `--button-variant-dashed-border` | `1px solid transparent` | + * | `--button-variant-dashed-color` | `var(--secondary-foreground)` | + * | `--button-variant-dashed-hover-background-color` | `transparent` | + * | `--button-variant-dashed-hover-color` | `var(--secondary-foreground-hover)` | + * | `--button-variant-dashed-position` | `relative` | * | `--button-variant-ghost-background-color` | `transparent` | - * | `--button-variant-ghost-color` | `var(--button-internal-outlined-foreground)` | - * | `--button-variant-ghost-hover-background-color` | `var(--button-internal-hover-background)` | - * | `--button-variant-ghost-hover-color` | `var(--button-internal-solid-foreground)` | + * | `--button-variant-ghost-color` | `var(--secondary-foreground)` | + * | `--button-variant-ghost-hover-background-color` | `var(--secondary-background-hover)` | + * | `--button-variant-ghost-hover-color` | `var(--secondary-foreground-hover)` | + * | `--button-variant-ghost-overflow` | `hidden` | + * | `--button-variant-ghost-position` | `relative` | * | `--button-variant-link-background-color` | `transparent` | - * | `--button-variant-link-color` | `var(--button-internal-outlined-foreground)` | - * | `--button-variant-link-font-weight` | `500` | + * | `--button-variant-link-color` | `var(--button-internal-link-foreground)` | + * | `--button-variant-link-font-weight` | `var(--font-weight-medium)` | * | `--button-variant-link-hover-background-color` | `transparent` | * | `--button-variant-link-hover-color` | `var(--button-internal-link-hover-foreground)` | - * | `--button-variant-outlined-background-color` | `transparent` | - * | `--button-variant-outlined-border` | `1px solid var(--button-internal-border-color)` | - * | `--button-variant-outlined-color` | `var(--button-internal-outlined-foreground)` | - * | `--button-variant-outlined-hover-background-color` | `var(--button-internal-border-color)` | - * | `--button-variant-outlined-hover-color` | `var(--button-internal-solid-foreground)` | + * | `--button-variant-outlined-background-color` | `var(--secondary-background)` | + * | `--button-variant-outlined-border` | `1px solid var(--secondary-border)` | + * | `--button-variant-outlined-color` | `var(--secondary-foreground)` | + * | `--button-variant-outlined-disabled-content-position` | `relative` | + * | `--button-variant-outlined-disabled-content-z-index` | `2` | + * | `--button-variant-outlined-disabled-overflow` | `hidden` | + * | `--button-variant-outlined-disabled-position` | `relative` | + * | `--button-variant-outlined-disabled-stripe-after-mask-image` | `linear-gradient(to right, transparent 5%, black...` | + * | `--button-variant-outlined-disabled-stripe-after-right` | `0` | + * | `--button-variant-outlined-disabled-stripe-background-image` | `repeating-linear-gradient(-45deg, trans...` | + * | `--button-variant-outlined-disabled-stripe-before-left` | `0` | + * | `--button-variant-outlined-disabled-stripe-before-mask-image` | `linear-gradient(to left, transparent 5%, black ...` | + * | `--button-variant-outlined-disabled-stripe-bottom` | `0` | + * | `--button-variant-outlined-disabled-stripe-color` | `var(--secondary-border)` | + * | `--button-variant-outlined-disabled-stripe-content` | `""` | + * | `--button-variant-outlined-disabled-stripe-pointer-events` | `none` | + * | `--button-variant-outlined-disabled-stripe-position` | `absolute` | + * | `--button-variant-outlined-disabled-stripe-top` | `0` | + * | `--button-variant-outlined-disabled-stripe-width` | `16px` | + * | `--button-variant-outlined-disabled-stripe-z-index` | `1` | + * | `--button-variant-outlined-hover-background-color` | `var(--secondary-background-hover)` | + * | `--button-variant-outlined-hover-color` | `var(--secondary-foreground-hover)` | * | `--button-warning-background` | `var(--warning-background)` | - * | `--button-warning-border-color` | `var(--warning-background)` | - * | `--button-warning-ghost-hover-foreground` | `var(--warning-foreground-hover)` | * | `--button-warning-hover-background` | `var(--warning-background-hover)` | - * | `--button-warning-link-hover-foreground` | `var(--warning-background-hover)` | - * | `--button-warning-outlined-foreground` | `var(--warning-background)` | + * | `--button-warning-link-foreground` | `var(--warning-link)` | + * | `--button-warning-link-hover-foreground` | `var(--warning-link-hover)` | * | `--button-warning-solid-foreground` | `var(--warning-foreground)` | * | `--button-white-space` | `nowrap` | - * | `--button-width` | `2rem` | + * | `--button-width` | `var(--button-internal-width, auto)` | */ // #endregion css-tokens -export type * from './button.js'; -export { - Button, - ButtonBackground, - ButtonColor, - ButtonGroupContext, - ButtonSize, - ButtonVariant, - buttonVariants, -} from './button.js'; +export { Button, buttonVariants } from './button.js'; +export { ButtonTextOverflow, ButtonColor, ButtonSize, ButtonVariant } from './constants.js'; +export type { + ButtonBaseProps, + ButtonProps, + ColoredVariantProps, + ColorType, + DisableType, + IconButtonProps, + IconPrefixSuffixType, + SecondaryOnlyVariantProps, + TextButtonProps, + TextOverflowType, + SizeType, + ValidateButtonProps, + VariantColorType, + VariantType, +} from './types.js'; diff --git a/packages/ui/src/button/types.tsx b/packages/ui/src/button/types.tsx new file mode 100644 index 00000000..2df01834 --- /dev/null +++ b/packages/ui/src/button/types.tsx @@ -0,0 +1,283 @@ +import type { ButtonColor, ButtonSize, ButtonTextOverflow, ButtonVariant } from './constants.js'; +import type { + AriaAttributes, + ButtonHTMLAttributes, + CSSProperties, + ReactElement, + ReactNode, +} from 'react'; + +export type SizeType = (typeof ButtonSize)[keyof typeof ButtonSize]; +export type VariantType = (typeof ButtonVariant)[keyof typeof ButtonVariant]; +export type ColorType = (typeof ButtonColor)[keyof typeof ButtonColor]; +export type TextOverflowType = (typeof ButtonTextOverflow)[keyof typeof ButtonTextOverflow]; + +/** + * The variants that carry a colour of their own. + */ +export interface ColoredVariantProps { + /** + * The variants of solid/link are the only ones that accept custom colors. + */ + variant: 'solid' | 'link'; + /** + * The possible colors for this variant. + */ + color: ColorType; +} + +/** + * The variants that only exist in the secondary treatment. + */ +export interface SecondaryOnlyVariantProps { + /** + * The variant of outlined/ghost/dashed only support one type of color. + */ + variant: 'outlined' | 'ghost' | 'dashed'; + /** + * The possible colors for this variant. + */ + color: 'secondary'; +} + +export type VariantColorType = ColoredVariantProps | SecondaryOnlyVariantProps; + +/** + * `disabled` and `disabledTooltip` travel together, but the pairing is enforced by + * {@link ValidateButtonProps} rather than by a union of the two shapes. + * + * A union here would be multiplied by the {@link VariantColorType} and + * {@link IconPrefixSuffixType} unions into an eight member cross product. TypeScript can only + * narrow such a union through a discriminant the call site actually writes, and `disabled` is + * absent from most call sites, so it would give up and blame whichever member came first. That is + * how a missing `aria-label` used to be reported as missing `disabled` and `disabledTooltip`. + */ +export type DisableType = { + /** + * When true, this will disable the button (will not trigger onClick and onDoubleClick), but will not prevent other events to be triggered. + * + * @note Requires `disabledTooltip`. + * + * @note While the tooltip is rendered, the button carries `aria-disabled` instead of the + * native `disabled` attribute, so it keeps receiving hover/focus and stays tabbable. A + * native `disabled` button gets no events at all, which would make the tooltip unreachable. + */ + disabled?: boolean; + /** + * When disable is defined, you must define the possible reason of the button is disabled, only render the tooltip when disabled is true. + * + * @note Only allowed alongside `disabled`. + * + * @note This does not render when loading={true} and disabled={true} is defined at same time. + * + * @note Stacks above the `ellipsis` overflow tooltip: a disabled button with a truncated + * label shows the reason first, then the full label. + * + * @note Pass `undefined` explicitly when there is no reason to give, for example in a + * wrapper that only forwards `disabled`. Leaving the prop out is the type error. + */ + disabledTooltip?: ReactNode; +}; + +/** + * The rules below are the ones a union cannot express without blowing up {@link ButtonProps} into a + * cross product. Each is an object whose single required key is the sentence the compiler should + * print, so a violation reads as `Property '' is missing ... but required in type + * ''` instead of pointing at an unrelated prop. + */ +interface ADisabledButtonMustSayWhy { + '`disabled` needs `disabledTooltip`, a disabled control has to tell the user why it cannot be used': never; +} + +interface TheTestIdPropIsCalledTestId { + '`data-testid` is written as the `testId` prop, which survives the tooltip trigger cloning the button': never; +} + +interface ADisabledReasonNeedsADisabledButton { + '`disabledTooltip` only renders while `disabled` is set, add `disabled` or drop the tooltip': never; +} + +/** + * Extra constraints layered on top of {@link ButtonProps} at the call site. + * + * Resolves to `unknown` (which disappears from an intersection) while the props are valid, and to a + * rule object when they are not. + * + * The pairing rules look at which props the call site writes, not at their values: `disabled` + * typed `boolean | undefined` is still `disabled`, and `disabledTooltip={undefined}` is the + * explicit opt-out for a call site that has no reason to give. + * + * @note A wrapper that forwards the whole `ButtonProps` union is not checked: `T` is then the union + * itself, every branch of the conditional is taken at once, and `unknown` from the passing branches + * absorbs the rest. Such a wrapper is checked at its own call sites instead. + */ +export type ValidateButtonProps = (T extends { disabled: boolean | undefined } + ? T extends { disabledTooltip: ReactNode } + ? unknown + : ADisabledButtonMustSayWhy + : unknown) & + (T extends { disabledTooltip: ReactNode } + ? T extends { disabled: boolean | undefined } + ? unknown + : ADisabledReasonNeedsADisabledButton + : unknown) & + (T extends { 'data-testid': unknown } ? TheTestIdPropIsCalledTestId : unknown); + +/** + * A button that renders a text label, optionally flanked by a `prefix` and a `suffix`. + */ +export interface TextButtonProps { + /** + * When icon is not defined, you can use prefix/suffix/children. + */ + icon?: never; + /** + * Element rendered before the button label. The sizing class is merged into the element's + * own `className`. + */ + prefix?: ReactElement; + /** + * Element rendered after the button label. The sizing class is merged into the element's + * own `className`. + */ + suffix?: ReactElement; +} + +/** + * A button whose children are the icon itself. + * + * Named rather than inlined so that a missing `aria-label` is reported against + * `IconButtonProps`, which says what the call site was taken to be, instead of against the + * structural dump of the branch. + */ +export interface IconButtonProps { + /** + * Define this button should be rendered optimized for an icon + * + * @note In icon mode the children are the icon, so `prefix` and `suffix` are not allowed. + */ + icon: true; + /** + * The prefix is not allowed when using icon + */ + prefix?: never; + /** + * The suffix is not allowed when using icon + */ + suffix?: never; + /** + * The text/children of this button. + * + * This field is mandatory, it's not allowed to have a empty button. + */ + children: ReactElement; + /** + * The accessible name of the button. Required: the children are an icon, so there is no text + * for a screen reader to announce. + */ + 'aria-label': string; +} + +export type IconPrefixSuffixType = TextButtonProps | IconButtonProps; + +/** + * Everything that does not depend on which kind of button is being rendered. + * + * Named, and holding `size` and `children` itself, so that a call site missing one of them is told + * which type wanted it rather than being handed the structural dump of an anonymous object. + */ +export interface ButtonBaseProps + extends + Pick< + ButtonHTMLAttributes, + | 'id' + | 'className' + | 'style' + | 'tabIndex' + | 'autoFocus' + | 'type' + | 'onClick' + | 'onDoubleClick' + | 'onKeyDown' + | 'onKeyUp' + | 'onFocus' + | 'onBlur' + | 'onMouseEnter' + | 'onMouseLeave' + >, + AriaAttributes, + DisableType { + /** + * Height + padding token. + */ + size: SizeType; + /** + * The text/children of this button. + * + * This field is mandatory, it's not allowed to have a empty button. `IconButtonProps` narrows + * it to a single element. + */ + children: ReactNode; + /** + * Controls how the text inside the button will behave when it does not fit. + * + * `ellipsis` truncates the label and shows the full text in a tooltip on + * hover/focus, and only while the label is actually truncated. `none` clips the + * label at the button's edge, no marker and no tooltip. + * + * @note The tooltip is exclusive to `ellipsis`. + * + * @note A `disabled` button stacks its `disabledTooltip` above this one, reason first. + * `loading` buttons stay focusable and do show this one. + * + * @default ellipsis + */ + textOverflow?: TextOverflowType; + /** + * When `true`, cross-fades a spinner over the `prefix` slot and stops the button from + * responding to clicks and keyboard activation. The label and `suffix` stay visible. + * + * @note Unlike `disabled`, the button stays focusable and carries `aria-disabled`/`aria-busy` + * instead of the native `disabled` attribute, so clicking it does not throw focus away. + * + * @note This does not cause the disabledTooltip to be shown to the user, we intentionally hide the tooltip even if you pass disabled={true} + * + * @default false + */ + loading?: boolean; + /** + * What the button is busy with, shown in a tooltip while `loading` is true. Optional, a + * spinner alone is already a valid busy state. + * + * @note Only renders while `loading` is true, and it takes the place of `disabledTooltip`, + * which is suppressed for the whole time the button is loading. + * + * @note Stacks above the `ellipsis` overflow tooltip, same as `disabledTooltip`: a loading + * button with a truncated label shows what it is doing first, then the full label. + */ + loadingTooltip?: ReactNode; + /** + * The width of this button. Written as the `--button-internal-width` custom property, so it + * composes with the tokens instead of overwriting `style.width`. Numbers are written as `px`. + * Without it the button sizes to its content. + */ + width?: CSSProperties['width']; + /** + * The max-width of this button. Written as the `--button-internal-max-width` custom property, + * so it composes with the tokens. Numbers are written as `px`. Without it the button is capped + * at `100%` of its container. + */ + maxWidth?: CSSProperties['maxWidth']; + /** + * Forwarded to the rendered element as `data-testid`. Survives the tooltip trigger cloning the + * button, which a raw `data-testid` prop does not. + */ + testId?: string; + /** + * Any `data-*` attribute is forwarded to the rendered element, so consumers can hook + * styling/selectors on top of the button without wrapping it. + */ + [dataAttribute: `data-${string}`]: unknown; +} + +export type ButtonProps = ButtonBaseProps & VariantColorType & IconPrefixSuffixType; diff --git a/packages/ui/src/calendar/calendar.tsx b/packages/ui/src/calendar/calendar.tsx index 97b4bbae..7ac0b422 100644 --- a/packages/ui/src/calendar/calendar.tsx +++ b/packages/ui/src/calendar/calendar.tsx @@ -8,7 +8,13 @@ import { type Formatters, getDefaultClassNames, } from 'react-day-picker'; -import { Button, ButtonColor, type ButtonColorValue, buttonVariants } from '../button/index.js'; +import { + Button, + ButtonColor, + type ColorType, + buttonVariants, + type VariantColorType, +} from '../button/index.js'; import { cn } from '../lib/utils.js'; import styles from './calendar.module.scss'; @@ -224,10 +230,33 @@ export function Calendar({ ); } -export type CalendarDayButtonProps = React.ComponentProps< - Exclude +/** + * The `DayButton` props react-day-picker actually passes, which are also the ones `Button` + * forwards. Picking them keeps the spread into `Button` type-checked. + */ +export type CalendarDayButtonProps = Pick< + React.ComponentProps, + | 'day' + | 'modifiers' + | 'children' + | 'className' + | 'style' + | 'type' + | 'disabled' + | 'tabIndex' + | 'aria-label' + | 'aria-disabled' + | 'onClick' + | 'onBlur' + | 'onFocus' + | 'onKeyDown' + | 'onMouseEnter' + | 'onMouseLeave' > & { - color?: ButtonColorValue; + /** + * The ghost variant of the day button only renders correctly with the secondary color. + */ + color?: ColorType | (string & {}); suffix?: string; prefix?: string; }; @@ -242,6 +271,9 @@ export function CalendarDayButton({ modifiers, suffix, prefix, + color = ButtonColor.Secondary, + disabled, + children, ...props }: CalendarDayButtonProps) { const ref = React.useRef(null); @@ -256,8 +288,10 @@ export function CalendarDayButton({ return ( ); } diff --git a/packages/ui/src/date-picker/date-picker.tsx b/packages/ui/src/date-picker/date-picker.tsx index 936de35f..82ed5fc9 100644 --- a/packages/ui/src/date-picker/date-picker.tsx +++ b/packages/ui/src/date-picker/date-picker.tsx @@ -3,12 +3,7 @@ import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import * as React from 'react'; -import { - Button, - type ButtonColorValue, - type ButtonSizeValue, - type ButtonVariantValue, -} from '../button/index.js'; +import { Button, type ColorType, type SizeType, type VariantColorType } from '../button/index.js'; import { Calendar } from '../calendar/index.js'; import { ComboboxSimple, type ComboboxSimpleItem } from '../combobox/index.js'; import { Input } from '../input/index.js'; @@ -88,21 +83,11 @@ export type DatePickerProps = { * Additional CSS classes for the popover content. */ popoverContentClassName?: string; - /** - * Button variant. - * @default 'outlined' - */ - buttonVariant?: ButtonVariantValue; - /** - * Button color. - * @default 'secondary' - */ - buttonColor?: ButtonColorValue; /** * Button size. * @default 'md' */ - buttonSize?: ButtonSizeValue; + buttonSize?: SizeType; /** * Calendar props. */ @@ -129,7 +114,37 @@ export type DatePickerProps = { * Test ID for the date picker. */ testId?: string; -}; +} & DatePickerTriggerAppearance; + +/** + * Variant/color of the trigger button. Follows the same pairing rules as `Button`: + * `solid` and `link` accept every color, the other variants are secondary only. + */ +export type DatePickerTriggerAppearance = + | { + /** + * Button variant. + * @default 'outlined' + */ + buttonVariant?: 'solid' | 'link'; + /** + * Button color. + * @default 'secondary' + */ + buttonColor?: ColorType; + } + | { + /** + * Button variant. + * @default 'outlined' + */ + buttonVariant?: 'outlined' | 'ghost' | 'dashed'; + /** + * Button color. + * @default 'secondary' + */ + buttonColor?: 'secondary'; + }; export const TIMEZONES = ALL_TIMEZONES.map((tz) => ({ value: tz, @@ -325,9 +340,11 @@ export const DatePicker = React.forwardRef( const defaultTrigger = ( ); diff --git a/packages/ui/src/dialog/subcomponents/dialog-footer.tsx b/packages/ui/src/dialog/subcomponents/dialog-footer.tsx index e90eebe3..a59b7f64 100644 --- a/packages/ui/src/dialog/subcomponents/dialog-footer.tsx +++ b/packages/ui/src/dialog/subcomponents/dialog-footer.tsx @@ -29,7 +29,7 @@ export type DialogFooterProps = Pick< * - * * diff --git a/packages/ui/src/drawer/subcomponents/drawer-footer.tsx b/packages/ui/src/drawer/subcomponents/drawer-footer.tsx index c6e571b0..c1b9c7f7 100644 --- a/packages/ui/src/drawer/subcomponents/drawer-footer.tsx +++ b/packages/ui/src/drawer/subcomponents/drawer-footer.tsx @@ -22,7 +22,7 @@ export type DrawerFooterProps = DialogFooterProps; * * * - * + * * * * diff --git a/packages/ui/src/input/input.tsx b/packages/ui/src/input/input.tsx index b7739230..db49c2a6 100644 --- a/packages/ui/src/input/input.tsx +++ b/packages/ui/src/input/input.tsx @@ -230,21 +230,22 @@ const InputPassword = React.forwardRef( suffix={ } /> ); diff --git a/packages/ui/src/lib/css-length.ts b/packages/ui/src/lib/css-length.ts new file mode 100644 index 00000000..0f38e099 --- /dev/null +++ b/packages/ui/src/lib/css-length.ts @@ -0,0 +1,3 @@ +export function toCssLength(value: string | number): string { + return typeof value === 'number' ? `${value}px` : value; +} diff --git a/packages/ui/src/lib/useIsLabelTruncated.tsx b/packages/ui/src/lib/useIsLabelTruncated.tsx new file mode 100644 index 00000000..b315ef26 --- /dev/null +++ b/packages/ui/src/lib/useIsLabelTruncated.tsx @@ -0,0 +1,47 @@ +import { type RefCallback, useCallback, useRef, useState } from 'react'; + +// `scrollWidth`/`clientWidth` are rounded to integers, so a label that fits can +// still report a one pixel overflow on fractional layouts. Anything above that +// is a real truncation. +const LABEL_OVERFLOW_TOLERANCE_PX = 1; + +function isLabelTruncated(label: HTMLElement): boolean { + return label.scrollWidth - label.clientWidth > LABEL_OVERFLOW_TOLERANCE_PX; +} + +export function useIsLabelTruncated(enabled: boolean): [boolean, RefCallback] { + const [truncated, setTruncated] = useState(false); + const observers = useRef>([]); + + const labelRef = useCallback( + (node: HTMLSpanElement | null): void => { + for (const observer of observers.current) { + observer.disconnect(); + } + + observers.current = []; + + if ( + !node || + !enabled || + typeof ResizeObserver === 'undefined' || + typeof MutationObserver === 'undefined' + ) { + return; + } + + const measure = (): void => setTruncated(isLabelTruncated(node)); + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(node); + + const mutationObserver = new MutationObserver(measure); + mutationObserver.observe(node, { characterData: true, childList: true, subtree: true }); + + observers.current = [resizeObserver, mutationObserver]; + }, + [enabled], + ); + + return [enabled && truncated, labelRef]; +} diff --git a/packages/ui/src/pagination/pagination.tsx b/packages/ui/src/pagination/pagination.tsx index 43805868..576cdb8e 100644 --- a/packages/ui/src/pagination/pagination.tsx +++ b/packages/ui/src/pagination/pagination.tsx @@ -1,7 +1,14 @@ import { ChevronLeft, ChevronRight, Minus } from '@signozhq/icons'; import * as React from 'react'; import { type MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, type ButtonProps, ButtonSize } from '../button/index.js'; +import { + Button, + type ButtonProps, + ButtonSize, + type ColorType, + type VariantColorType, + type VariantType, +} from '../button/index.js'; import { cn } from '../lib/utils.js'; import { SelectSimple } from '../select/index.js'; import styles from './pagination.module.scss'; @@ -145,26 +152,74 @@ export type PaginationLinkProps = { * If the link is not active, the button will be styled as a ghost button. */ isActive?: boolean; -} & ButtonProps; + /** + * Visual style of the button. Defaults to `solid` when active, `ghost` otherwise. + */ + variant?: VariantType; + /** + * Height + padding token. + * + * @default md + */ + size?: ButtonProps['size']; + /** + * Color scheme applied to the variant. Defaults to `primary` when active, `secondary` otherwise. + */ + color?: ColorType; + /** + * Content of the button, usually the page number. + */ + children?: React.ReactNode; + /** + * When true, the page cannot be selected. + */ + disabled?: boolean; + /** + * Reason shown in a tooltip while the button is disabled. + */ + disabledTooltip?: React.ReactNode; +} & Omit< + ButtonProps, + 'variant' | 'size' | 'color' | 'children' | 'disabled' | 'disabledTooltip' | 'icon' +>; /** * Button for a specific page number. Set `isActive` when it represents the * current page. Accepts all `Button` props. */ export const PaginationLink = React.forwardRef( - ({ className, testId, isActive, size = ButtonSize.Icon, disabled, children, ...props }, ref) => { + ( + { + className, + testId, + isActive, + size = ButtonSize.MD, + variant, + color, + disabled, + disabledTooltip, + children, + ...props + }, + ref, + ) => { return ( ) } /> @@ -1189,10 +1192,14 @@ export function DataTable({ ) : null } />