mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(design-system): segmented controls (#50738)
## Problem We don't have any one defined way of displaying segmented controls. This PR looks at unifying and using underlying primtives to put something together. Open to comment. ## Solution Improves upon the shadcn toggle group, which has a specific use case. ## Review instructions Run the design system and find "Segmented Controls" in the Fragment Components. Test [here](https://design-system-git-feat-button-group-component-draft-supabase.vercel.app/design-system/docs/components/toggle-group#segmented). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary * **New Features** * Added segmented toggle groups with default, text, outline, and primary tones. * Single-select groups display a sliding selection indicator; multi-select groups highlight selected items individually. * Groups can keep the selected option active; deselection is enabled by default. * Added examples for tone variations and a status filter. * **Documentation** * Updated segmented-control guidance and props tables, including usage recommendations, accessibility labeling, and filter options. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
1 parent
9b12165400
commit
0e6fff6760
8 files changed
+518
-30
No files matched your search
@@ -89,3 +89,37 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
### Disabled
|
||||
|
||||
<ComponentPreview name="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".
|
||||
|
||||
<ComponentPreview name="toggle-group-segmented" />
|
||||
|
||||
<ComponentPreview name="toggle-group-segmented-filter" peekCode />
|
||||
|
||||
## 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.
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{SIZES.map((size) => (
|
||||
<div key={size} className="flex items-center gap-4">
|
||||
<span className="w-16 shrink-0 text-xs text-foreground-lighter">{size}</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="segmented"
|
||||
size={size}
|
||||
value={status}
|
||||
onValueChange={setStatus}
|
||||
allowDeselect={false}
|
||||
aria-label={`Filter keys by status (${size})`}
|
||||
>
|
||||
<ToggleGroupItem value="all">All</ToggleGroupItem>
|
||||
<ToggleGroupItem value="active">Active</ToggleGroupItem>
|
||||
<ToggleGroupItem value="revoked">Revoked</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{TONES.map((tone) => (
|
||||
<div key={tone} className="flex items-center gap-4">
|
||||
<span className="w-16 shrink-0 text-xs text-foreground-lighter">{tone}</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="segmented"
|
||||
tone={tone}
|
||||
value={view}
|
||||
onValueChange={setView}
|
||||
allowDeselect={false}
|
||||
aria-label={`Table view (${tone})`}
|
||||
>
|
||||
<ToggleGroupItem value="data">Data</ToggleGroupItem>
|
||||
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}) => (
|
||||
<ToggleGroup variant="segmented" size="tiny" type="single" {...props}>
|
||||
<ToggleGroupItem value="data">Data</ToggleGroupItem>
|
||||
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
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 (
|
||||
<ToggleGroup
|
||||
variant="segmented"
|
||||
type="single"
|
||||
allowDeselect={false}
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
onValueChange(next)
|
||||
setValue(next)
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="data">Data</ToggleGroupItem>
|
||||
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
render(<Controlled />)
|
||||
|
||||
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(
|
||||
<ToggleGroup
|
||||
variant="segmented"
|
||||
size="tiny"
|
||||
type="multiple"
|
||||
allowDeselect={false}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
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(
|
||||
<ToggleGroup variant="segmented" size="tiny" type="multiple" defaultValue={['a']}>
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-segment-indicator]')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'A' })).toHaveClass(
|
||||
'data-[state=on]:bg-overlay-hover'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<VariantProps<typeof toggleVariants>>({
|
||||
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<typeof toggleVariants> & { hasIndicator?: boolean }
|
||||
>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
type ToggleGroupProps = React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
allowDeselect?: boolean
|
||||
tone?: VariantProps<typeof segmentedToneVariants>['tone']
|
||||
}
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
ToggleGroupProps
|
||||
>(
|
||||
(
|
||||
{ className, variant, size, tone, children, allowDeselect = true, onValueChange, ...props },
|
||||
ref
|
||||
) => {
|
||||
const rootRef = React.useRef<HTMLDivElement>(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<typeof ToggleGroupPrimitive.Root>
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={(node) => {
|
||||
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 && <ToggleGroupIndicator tone={tone} />}
|
||||
<ToggleGroupContext.Provider value={{ variant, size, hasIndicator }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupIndicator = ({
|
||||
className,
|
||||
tone,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<'span'> & VariantProps<typeof segmentedIndicatorToneVariants>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
data-segment-indicator
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-0 left-0 inset-y-px rounded-sm',
|
||||
segmentedIndicatorToneVariants({ tone }),
|
||||
'w-[var(--active-segment-width,0)] translate-x-[var(--active-segment-left,0)]',
|
||||
'transition-none opacity-0',
|
||||
'group-data-[segment-indicator-ready]/segmented:opacity-100',
|
||||
'group-data-[segment-indicator-ready]/segmented:transition-[translate,width]',
|
||||
'group-data-[segment-indicator-ready]/segmented:duration-[250ms]',
|
||||
'group-data-[segment-indicator-ready]/segmented:ease-move',
|
||||
'motion-reduce:transition-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
const resolvedVariant = context.variant || variant
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
variant: resolvedVariant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
resolvedVariant === 'segmented' && !context.hasIndicator && segmentedSelfPaint,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -54,4 +174,12 @@ const ToggleGroupItem = React.forwardRef<
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
export {
|
||||
ToggleGroup,
|
||||
ToggleGroupIndicator,
|
||||
ToggleGroupItem,
|
||||
segmentedIndicatorToneVariants,
|
||||
segmentedToneVariants,
|
||||
toggleGroupVariants,
|
||||
type ToggleGroupProps,
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const toggleVariants = cva(
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'bg-transparent border border-control hover:bg-accent hover:text-accent-foreground',
|
||||
segmented:
|
||||
'relative z-10 cursor-pointer rounded-sm bg-transparent hover:bg-transparent text-foreground-light hover:text-foreground data-[state=on]:bg-transparent data-[state=on]:text-foreground aria-checked:bg-transparent aria-checked:text-foreground',
|
||||
},
|
||||
size: {
|
||||
tiny: 'h-[26px] px-2.5 text-xs',
|
||||
|
||||
@@ -2,34 +2,61 @@
|
||||
|
||||
import { useLayoutEffect, type RefObject } from 'react'
|
||||
|
||||
export const useTabIndicator = (listRef: RefObject<HTMLElement | null>) => {
|
||||
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<IndicatorOptions>
|
||||
|
||||
export const useTabIndicator = (
|
||||
listRef: RefObject<HTMLElement | null>,
|
||||
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<HTMLElement>(':scope > [role="tab"][data-state="active"]')
|
||||
const trigger = list.querySelector<HTMLElement>(`: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<HTMLElement | null>) => {
|
||||
cancelled = true
|
||||
mutations?.disconnect()
|
||||
resizes?.disconnect()
|
||||
delete list.dataset.tabIndicatorReady
|
||||
delete list.dataset[readyFlag]
|
||||
}
|
||||
}, [listRef])
|
||||
}, [
|
||||
listRef,
|
||||
activeItemSelector,
|
||||
indicatorSelector,
|
||||
readyFlag,
|
||||
leftProperty,
|
||||
widthProperty,
|
||||
insetByPadding,
|
||||
])
|
||||
}
|
||||
Reference in new issue
Block a user