diff --git a/apps/design-system/content/docs/components/toggle-group.mdx b/apps/design-system/content/docs/components/toggle-group.mdx index 5cedba42af4..f06d35cba17 100644 --- a/apps/design-system/content/docs/components/toggle-group.mdx +++ b/apps/design-system/content/docs/components/toggle-group.mdx @@ -89,3 +89,37 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' ### Disabled + +## Segmented + +A Supabase addition, not part of shadcn or Radix. A segmented control is a compact row of two to +four mutually exclusive options sharing one continuous track, for switching view state (Data or +Definition) or filtering a list (All, Active, Revoked). The choice takes effect immediately and is +never saved, so reach for a [Switch](../components/switch) or +[Radio Group](../components/radio-group) when the setting is persisted, and +[Tabs](../components/tabs) when the options swap whole panels of content. Items paint nothing +themselves; a single indicator slides between them, so the group always has exactly one thing +selected. Give a filter an explicit **All** segment rather than letting deselection stand for +"no filter". + + + + + +## Props + +`ToggleGroup` forwards every prop to the underlying +[Radix Toggle Group](https://www.radix-ui.com/docs/primitives/components/toggle-group#api-reference). +These are the ones worth knowing about. + +| Prop | Type | Default | Description | +| --------------- | --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `type` | `'single' \| 'multiple'` | | Required by Radix. Only a `'single'` group gets the segmented indicator; a `'multiple'` group falls back to items painting their own background. | +| `variant` | `'default' \| 'outline' \| 'segmented'` | `'default'` | Styles the root and every item, which inherit it through context. `segmented` drops the gaps and per-item borders in favor of an indicator that slides between segments, respecting `prefers-reduced-motion`. | +| `size` | `'tiny' \| 'sm' \| 'default' \| 'lg'` | `'default'` | Item height and padding, inherited through context. `tiny` suits a dense toolbar or table footer. Segments hug their labels, so add `className="flex-1"` to each item, plus a width on the root, if you need them equal. | +| `tone` | `'text' \| 'outline' \| 'primary'` | `'text'` | Segmented only. `text` is a flat `bg-accent` fill, `outline` adds a container border and a raised bordered indicator, `primary` fills the indicator with brand. | +| `allowDeselect` | `boolean` | `true` | Whether clicking the active item in a `type="single"` group clears it, emitting `''`. Set `false` for a segmented control, which has no empty state. | +| `aria-label` | `string` | | Names what is being switched or filtered. The segments name the options, not the axis. | + +`variant` and `size` are the shadcn variant axes; `segmented`, `tone` and `allowDeselect` are the +Supabase additions. `default` and `outline` are unchanged from shadcn. diff --git a/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx b/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx new file mode 100644 index 00000000000..0a40a293b38 --- /dev/null +++ b/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx @@ -0,0 +1,33 @@ +'use client' + +import * as React from 'react' +import { ToggleGroup, ToggleGroupItem } from 'ui' + +const SIZES = ['tiny', 'sm', 'default', 'lg'] as const + +export default function ToggleGroupSegmentedFilter() { + const [status, setStatus] = React.useState('all') + + return ( +
+ {SIZES.map((size) => ( +
+ {size} + + All + Active + Revoked + +
+ ))} +
+ ) +} diff --git a/apps/design-system/registry/default/example/toggle-group-segmented.tsx b/apps/design-system/registry/default/example/toggle-group-segmented.tsx new file mode 100644 index 00000000000..e3cf3996df6 --- /dev/null +++ b/apps/design-system/registry/default/example/toggle-group-segmented.tsx @@ -0,0 +1,32 @@ +'use client' + +import * as React from 'react' +import { ToggleGroup, ToggleGroupItem } from 'ui' + +const TONES = ['text', 'outline', 'primary'] as const + +export default function ToggleGroupSegmented() { + const [view, setView] = React.useState('data') + + return ( +
+ {TONES.map((tone) => ( +
+ {tone} + + Data + Definition + +
+ ))} +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 13c8741b839..540b6ac2e17 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1069,6 +1069,18 @@ export const examples: Registry = [ registryDependencies: ['toggle-group'], files: ['example/toggle-group-outline.tsx'], }, + { + name: 'toggle-group-segmented', + type: 'components:example', + registryDependencies: ['toggle-group'], + files: ['example/toggle-group-segmented.tsx'], + }, + { + name: 'toggle-group-segmented-filter', + type: 'components:example', + registryDependencies: ['toggle-group'], + files: ['example/toggle-group-segmented-filter.tsx'], + }, { name: 'toggle-group-sm', type: 'components:example', diff --git a/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx b/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx new file mode 100644 index 00000000000..9da88783479 --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx @@ -0,0 +1,212 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { ToggleGroup, ToggleGroupItem } from './toggle-group' + +const renderSegmented = (props: { + defaultValue?: string + onValueChange?: (value: string) => void + allowDeselect?: boolean + tone?: 'outline' | 'text' | 'primary' +}) => ( + + Data + Definition + +) + +describe('ToggleGroup', () => { + it('leaves the segmented container naked — no border on the root or the items', () => { + render(renderSegmented({ defaultValue: 'data' })) + + const root = screen.getByRole('group') + + expect(root).toHaveClass('p-px', 'gap-0', 'w-fit') + expect(root).not.toHaveClass('border', 'border-strong') + expect(screen.getByRole('radio', { name: 'Data' })).not.toHaveClass('border') + }) + + it('leaves the selected item unpainted so only the indicator marks it', () => { + render(renderSegmented({ defaultValue: 'data' })) + + const selected = screen.getByRole('radio', { name: 'Data' }) + + expect(selected).toHaveAttribute('aria-checked', 'true') + expect(selected).toHaveClass('data-[state=on]:bg-transparent', 'aria-checked:bg-transparent') + expect(selected).not.toHaveClass( + 'data-[state=on]:bg-accent', + 'aria-checked:bg-accent', + 'data-[state=on]:bg-overlay-hover', + 'data-[state=on]:border-strong', + 'data-[state=on]:shadow-sm' + ) + }) + + it('adds a container border only for the outline tone', () => { + const { rerender } = render(renderSegmented({ defaultValue: 'data' })) + expect(screen.getByRole('group')).not.toHaveClass('border-strong') + + rerender(renderSegmented({ defaultValue: 'data', tone: 'outline' })) + expect(screen.getByRole('group')).toHaveClass('border', 'border-strong') + }) + + it('tones the indicator rather than the items', () => { + const { container, rerender } = render(renderSegmented({ defaultValue: 'data' })) + expect(container.querySelector('[data-segment-indicator]')).toHaveClass('bg-accent') + + rerender(renderSegmented({ defaultValue: 'data', tone: 'outline' })) + expect(container.querySelector('[data-segment-indicator]')).toHaveClass( + 'bg-overlay-hover', + 'border-strong' + ) + + rerender(renderSegmented({ defaultValue: 'data', tone: 'primary' })) + expect(container.querySelector('[data-segment-indicator]')).toHaveClass('bg-brand-400') + expect(screen.getByRole('radio', { name: 'Data' })).toHaveClass( + 'data-[state=on]:bg-transparent' + ) + }) + + it('gives items a pointer cursor', () => { + render(renderSegmented({ defaultValue: 'data' })) + + expect(screen.getByRole('radio', { name: 'Data' })).toHaveClass('cursor-pointer') + }) + + it('clears the selection on re-click by default', async () => { + const onValueChange = vi.fn() + render(renderSegmented({ defaultValue: 'data', onValueChange })) + + await userEvent.click(screen.getByRole('radio', { name: 'Data' })) + + expect(onValueChange).toHaveBeenCalledWith('') + }) + + it('keeps the active item selected across repeated clicks when allowDeselect is false', async () => { + const onValueChange = vi.fn() + render(renderSegmented({ defaultValue: 'data', onValueChange, allowDeselect: false })) + + const active = screen.getByRole('radio', { name: 'Data' }) + + await userEvent.click(active) + expect(active).toHaveAttribute('aria-checked', 'true') + await userEvent.click(active) + expect(active).toHaveAttribute('aria-checked', 'true') + expect(onValueChange).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('radio', { name: 'Definition' })) + + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('definition') + expect(screen.getByRole('radio', { name: 'Definition' })).toHaveAttribute( + 'aria-checked', + 'true' + ) + expect(active).toHaveAttribute('aria-checked', 'false') + }) + + it('leaves a controlled group to its own value', async () => { + const onValueChange = vi.fn() + const Controlled = () => { + const [value, setValue] = React.useState('data') + return ( + { + onValueChange(next) + setValue(next) + }} + > + Data + Definition + + ) + } + render() + + const active = screen.getByRole('radio', { name: 'Data' }) + await userEvent.click(active) + + expect(active).toHaveAttribute('aria-checked', 'true') + expect(onValueChange).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('radio', { name: 'Definition' })) + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('definition') + }) + + it('still deselects an uncontrolled group when allowDeselect is left alone', async () => { + render(renderSegmented({ defaultValue: 'data' })) + + const active = screen.getByRole('radio', { name: 'Data' }) + await userEvent.click(active) + + expect(active).toHaveAttribute('aria-checked', 'false') + }) + + it('still reports every change for multi-select groups', async () => { + const onValueChange = vi.fn() + render( + + A + + ) + + await userEvent.click(screen.getByRole('button', { name: 'A' })) + expect(onValueChange).toHaveBeenCalledWith(['a']) + + await userEvent.click(screen.getByRole('button', { name: 'A' })) + expect(onValueChange).toHaveBeenCalledWith([]) + }) + + it('moves focus between items with the arrow keys', async () => { + render(renderSegmented({ defaultValue: 'data' })) + + await userEvent.tab() + expect(screen.getByRole('radio', { name: 'Data' })).toHaveFocus() + + await userEvent.keyboard('{ArrowRight}') + expect(screen.getByRole('radio', { name: 'Definition' })).toHaveFocus() + }) + + describe('with a sliding indicator', () => { + it('renders one automatically for a single-select segmented group', () => { + const { container } = render(renderSegmented({ defaultValue: 'data' })) + + expect(container.querySelectorAll('[data-segment-indicator]')).toHaveLength(1) + }) + + it('measures the active item onto the root as custom properties', async () => { + render(renderSegmented({ defaultValue: 'data' })) + + const root = screen.getByRole('group') + + await waitFor(() => expect(root).toHaveAttribute('data-segment-indicator-ready')) + expect(root.style.getPropertyValue('--active-segment-left')).not.toBe('') + expect(root.style.getPropertyValue('--active-segment-width')).not.toBe('') + }) + + it('falls back to per-item paint for multi-select, which an indicator cannot track', () => { + const { container } = render( + + A + B + + ) + + expect(container.querySelector('[data-segment-indicator]')).toBeNull() + expect(screen.getByRole('button', { name: 'A' })).toHaveClass( + 'data-[state=on]:bg-overlay-hover' + ) + }) + }) +}) diff --git a/packages/ui/src/components/shadcn/ui/toggle-group.tsx b/packages/ui/src/components/shadcn/ui/toggle-group.tsx index 4163c0ce249..6664bf8f04c 100644 --- a/packages/ui/src/components/shadcn/ui/toggle-group.tsx +++ b/packages/ui/src/components/shadcn/ui/toggle-group.tsx @@ -1,48 +1,168 @@ 'use client' +import { cva, VariantProps } from 'class-variance-authority' import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui' -import { VariantProps } from 'class-variance-authority' import * as React from 'react' import { cn } from '../../../lib/utils' import { toggleVariants } from './toggle' +import { useTabIndicator } from './useTabIndicator' -const ToggleGroupContext = React.createContext>({ +const segmentIndicatorOptions = { + activeItemSelector: '[data-state="on"]', + indicatorSelector: '[data-segment-indicator]', + readyFlag: 'segmentIndicatorReady', + leftProperty: '--active-segment-left', + widthProperty: '--active-segment-width', + insetByPadding: false, +} + +const segmentedSelfPaint = 'data-[state=on]:bg-overlay-hover aria-checked:bg-overlay-hover' + +const segmentedToneVariants = cva('', { + variants: { + tone: { + outline: 'border border-strong', + text: '', + primary: '', + }, + }, + defaultVariants: { tone: 'text' }, +}) + +const segmentedIndicatorToneVariants = cva('', { + variants: { + tone: { + outline: 'bg-overlay-hover border border-strong shadow-sm', + text: 'bg-accent', + primary: + 'bg-brand-400 dark:bg-brand-500 border border-brand-500/75 dark:border-brand-default/30', + }, + }, + defaultVariants: { tone: 'text' }, +}) + +const toggleGroupVariants = cva('flex items-center justify-center', { + variants: { + variant: { + default: 'gap-1', + outline: 'gap-1', + segmented: 'relative isolate w-fit gap-0 p-px rounded-md group/segmented', + }, + }, + defaultVariants: { + variant: 'default', + }, +}) + +const ToggleGroupContext = React.createContext< + VariantProps & { hasIndicator?: boolean } +>({ size: 'default', variant: 'default', }) +type ToggleGroupProps = React.ComponentPropsWithoutRef & + VariantProps & { + allowDeselect?: boolean + tone?: VariantProps['tone'] + } + const ToggleGroup = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, size, children, ...props }, ref) => ( - - {children} - -)) + ToggleGroupProps +>( + ( + { className, variant, size, tone, children, allowDeselect = true, onValueChange, ...props }, + ref + ) => { + const rootRef = React.useRef(null) + useTabIndicator(rootRef, segmentIndicatorOptions) + + const hasIndicator = variant === 'segmented' && props.type === 'single' + + const ownsValue = !allowDeselect && props.type === 'single' && props.value === undefined + const [internalValue, setInternalValue] = React.useState(() => + typeof props.defaultValue === 'string' ? props.defaultValue : '' + ) + + const handleValueChange = (value: string & string[]) => { + if (!allowDeselect && value === '') return + if (ownsValue && typeof value === 'string') setInternalValue(value) + if (onValueChange) onValueChange(value) + } + + const rootProps = ( + ownsValue ? { ...props, value: internalValue, defaultValue: undefined } : props + ) as React.ComponentPropsWithoutRef + + return ( + { + rootRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) ref.current = node + }} + className={cn( + toggleGroupVariants({ variant }), + variant === 'segmented' && segmentedToneVariants({ tone }), + className + )} + {...rootProps} + onValueChange={handleValueChange} + > + {hasIndicator && } + + {children} + + + ) + } +) ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName +const ToggleGroupIndicator = ({ + className, + tone, + ...props +}: React.ComponentPropsWithRef<'span'> & VariantProps) => ( + +) + const ToggleGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, children, variant, size, ...props }, ref) => { const context = React.useContext(ToggleGroupContext) + const resolvedVariant = context.variant || variant return ( ) => { +type IndicatorOptions = { + activeItemSelector?: string + indicatorSelector?: string + readyFlag?: string + leftProperty?: string + widthProperty?: string + insetByPadding?: boolean +} + +const tabDefaults = { + activeItemSelector: '[role="tab"][data-state="active"]', + indicatorSelector: '[data-tab-indicator]', + readyFlag: 'tabIndicatorReady', + leftProperty: '--active-tab-left', + widthProperty: '--active-tab-width', + insetByPadding: true, +} satisfies Required + +export const useTabIndicator = ( + listRef: RefObject, + options: IndicatorOptions = {} +) => { + const { + activeItemSelector, + indicatorSelector, + readyFlag, + leftProperty, + widthProperty, + insetByPadding, + } = { ...tabDefaults, ...options } + useLayoutEffect(() => { const list = listRef.current - if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return + if (!list || !list.querySelector(`:scope > ${indicatorSelector}`)) return let cancelled = false const measure = () => { - const trigger = list.querySelector(':scope > [role="tab"][data-state="active"]') + const trigger = list.querySelector(`:scope > ${activeItemSelector}`) if (cancelled) return if (!trigger) { - delete list.dataset.tabIndicatorReady + delete list.dataset[readyFlag] return } const styles = getComputedStyle(trigger) - const paddingLeft = parseFloat(styles.paddingLeft) || 0 - const paddingRight = parseFloat(styles.paddingRight) || 0 + const paddingLeft = insetByPadding ? parseFloat(styles.paddingLeft) || 0 : 0 + const paddingRight = insetByPadding ? parseFloat(styles.paddingRight) || 0 : 0 - list.style.setProperty('--active-tab-left', `${trigger.offsetLeft + paddingLeft}px`) - list.style.setProperty( - '--active-tab-width', - `${trigger.offsetWidth - paddingLeft - paddingRight}px` - ) - if (list.dataset.tabIndicatorReady === undefined) { + list.style.setProperty(leftProperty, `${trigger.offsetLeft + paddingLeft}px`) + list.style.setProperty(widthProperty, `${trigger.offsetWidth - paddingLeft - paddingRight}px`) + if (list.dataset[readyFlag] === undefined) { // makes the bar already sitting on the active tab rather than sliding in requestAnimationFrame(() => { - if (!cancelled) list.dataset.tabIndicatorReady = '' + if (!cancelled) list.dataset[readyFlag] = '' }) } } @@ -60,7 +87,15 @@ export const useTabIndicator = (listRef: RefObject) => { cancelled = true mutations?.disconnect() resizes?.disconnect() - delete list.dataset.tabIndicatorReady + delete list.dataset[readyFlag] } - }, [listRef]) + }, [ + listRef, + activeItemSelector, + indicatorSelector, + readyFlag, + leftProperty, + widthProperty, + insetByPadding, + ]) }