{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "segmented-control", "type": "registry:ui", "title": "SegmentedControl", "description": "Compact pick-one control: one bordered strip, divided into segments.", "categories": [ "forms" ], "registryDependencies": [ "https://whiskeyjack.net/r/compact-control.json", "https://whiskeyjack.net/r/merge-refs.json", "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/segmented-control.tsx", "type": "registry:ui", "target": "components/ui/segmented-control.tsx", "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useMergedRefs } from '@/lib/merge-refs'\nimport { COMPACT_CONTROL_HEIGHT, compactControlClass } from '@/lib/compact-control'\n\nexport interface SegmentedControlOption {\n value: T\n label: string\n /**\n * Optional element rendered BEFORE the label, on the same line. The DS bundles\n * no icon library, so the caller passes the node: usually a Phosphor glyph,\n * but anything works -- Icon Stack passes a thumbnail of the image each\n * segment selects, because \"Main\" and \"Alternate\" say nothing about which\n * artwork you are choosing.\n */\n icon?: React.ReactNode\n}\n\nexport interface SegmentedControlProps {\n options: SegmentedControlOption[]\n value: T\n onChange: (value: T) => void\n /** Accessible name for the group. */\n 'aria-label'?: string\n /**\n * Render the icons alone, with each option's `label` becoming its accessible\n * name and its tooltip rather than visible text.\n *\n * For the case where a pair of glyphs IS the vocabulary -- a sun and a moon, a\n * contain and a cover arrow -- and spelling it out costs more room than it\n * earns. It is all-or-nothing on purpose: a strip mixing labelled and bare\n * segments reads as one of them having failed to render.\n *\n * `label` stays required, because an icon-only control still has to say what it\n * is. Each segment gets both `aria-label` and `title` from it, per the\n * accessibility rule for icon-only controls.\n */\n hideLabels?: boolean\n className?: string\n}\n\n/**\n * Compact pick-one control: one bordered strip, divided into segments.\n *\n * ## Why this is not a size variant of `ToggleGroup`\n *\n * `ToggleGroup` is a grid of separate tiles, each bordered, icon stacked above\n * the label, sized to be the primary choice on a settings screen. This is a\n * single bordered strip with internal dividers, icon beside the label, sized to\n * sit ON A LINE with body text as the trailing control of a labelled row.\n *\n * Those are two constructions, not two sizes. A `size=\"sm\"` on `ToggleGroup`\n * would have to change the border from per-tile to per-group, the icon from\n * stacked to inline, and the layout from a gap-separated grid to a joined strip\n * -- at which point the only thing the two share is the word \"toggle\". Keeping\n * them separate means each stays legible on its own terms.\n *\n * What they DO share is behaviour, and that is deliberate: same `radiogroup`\n * role, same roving `tabIndex`, same arrow-key wrap-around, same accent fill on\n * the selected option. A control that looks different should not also *act*\n * different, so the keyboard model here is `ToggleGroup`'s, and a test pins the\n * two against each other.\n *\n * Reach for `ToggleGroup` when the choice is the point of the screen, and this\n * when the choice is one row among many.\n *\n * ```tsx\n * },\n * { value: 'cover', label: t('source.cover'), icon: },\n * ]}\n * value={fit}\n * onChange={setFit}\n * />\n * ```\n */\nfunction SegmentedControlInner(\n { options, value, onChange, hideLabels, className, ...rest }: SegmentedControlProps,\n ref: React.ForwardedRef,\n) {\n const containerRef = React.useRef(null)\n const mergedRef = useMergedRefs(containerRef, ref)\n\n // Deliberately identical to ToggleGroup's: both are single-select radiogroups,\n // and a user who learns one should not have to relearn the other.\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (!options.length) return\n const currentIndex = options.findIndex((o) => o.value === value)\n\n let nextIndex: number | null = null\n if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n nextIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1\n }\n\n if (nextIndex !== null) {\n e.preventDefault()\n const next = options[nextIndex]\n onChange(next.value as T)\n const btn = containerRef.current?.querySelectorAll('[role=\"radio\"]')[nextIndex]\n btn?.focus()\n }\n },\n [options, value, onChange],\n )\n\n return (\n \n {options.map((option, index) => {\n const isSelected = value === option.value\n return (\n onChange(option.value as T)}\n // Both, per the icon-only rule: aria-label names it for assistive\n // tech, title for a sighted user who cannot read the glyph.\n {...(hideLabels ? { 'aria-label': option.label, title: option.label } : null)}\n className={cn(\n compactControlClass({ inset: true }),\n // Square-ish when there is no text to give the segment width.\n hideLabels && '!px-2',\n // Dividers belong to the segments rather than the container, so a\n // one-option control has no stray rule down its side.\n index > 0 && 'border-s border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n isSelected\n ? 'bg-[var(--color-accent-500)] text-[var(--color-accent-foreground)]'\n : [\n 'bg-[var(--color-surface-light)] text-[var(--color-text-secondary-light)]',\n 'hover:bg-[var(--color-warm-100)]',\n 'dark:bg-[var(--color-surface-dark)] dark:text-[var(--color-text-secondary-dark)]',\n 'dark:hover:bg-[var(--color-surface-muted-dark)]',\n ],\n )}\n >\n {option.icon && (\n \n {option.icon}\n \n )}\n {!hideLabels && option.label}\n \n )\n })}\n \n )\n}\n\nexport const SegmentedControl = React.forwardRef(SegmentedControlInner) as <\n T extends string = string,\n>(\n props: SegmentedControlProps & { ref?: React.ForwardedRef },\n) => React.ReactElement\n" } ], "docs": "Use when the choice is one row among many rather than the point of the screen -- the trailing control of a labelled row, sized to sit on a line with body text. ToggleGroup is the other shape: a grid of separate bordered tiles with the icon stacked above the label, sized to be the primary choice on a settings screen. They are two constructions rather than two sizes, since a size variant would have to move the border from per-tile to per-group, the icon from stacked to inline, and the layout from a gap-separated grid to a joined strip. Behaviour is deliberately identical -- same radiogroup role, same roving tabIndex, same arrow-key wrap-around -- and a test pins the two against each other, because a control that looks different should not also act different. The optional icon is any node: a Phosphor glyph usually, or a thumbnail when the label alone cannot say what is being chosen. Pass hideLabels when a pair of glyphs IS the vocabulary -- a sun and a moon, a contain and a cover arrow -- and spelling it out costs more room than it earns; label stays required and becomes each segment aria-label and title, per the icon-only rule. It is all-or-nothing on purpose, since a strip mixing labelled and bare segments reads as one of them having failed to render. Presentational and i18n-free: pass translated labels and an aria-label.", "meta": { "group": "forms", "related": [ "toggle-group", "toggle", "option-tile" ], "exports": [ "SegmentedControl" ], "siteSlug": "segmented-control" } }