@sixthshift/design-system 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/styles.css +2 -0
- package/dist/theme.generated.css +438 -0
- package/package.json +238 -0
- package/src/components/Avatar/Avatar.tsx +19 -0
- package/src/components/Avatar/index.ts +1 -0
- package/src/components/Badge/Badge.tsx +59 -0
- package/src/components/Badge/index.ts +1 -0
- package/src/components/BarChart/BarChart.tsx +51 -0
- package/src/components/BarChart/index.ts +1 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +47 -0
- package/src/components/Breadcrumb/index.ts +5 -0
- package/src/components/Button/Button.tsx +157 -0
- package/src/components/Button/index.ts +1 -0
- package/src/components/Calendar/Calendar.tsx +433 -0
- package/src/components/Calendar/calendar.hooks.ts +329 -0
- package/src/components/Calendar/calendar.types.ts +117 -0
- package/src/components/Calendar/index.ts +28 -0
- package/src/components/Card/Card.tsx +47 -0
- package/src/components/Card/index.ts +1 -0
- package/src/components/Checkbox/Checkbox.tsx +144 -0
- package/src/components/Checkbox/index.ts +1 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +173 -0
- package/src/components/CheckboxGroup/index.ts +5 -0
- package/src/components/Code/Editor/Editor.tsx +161 -0
- package/src/components/Code/Editor/index.ts +2 -0
- package/src/components/Code/Editor/semanticTokenProvider.ts +69 -0
- package/src/components/Code/Editor/themes.ts +112 -0
- package/src/components/Code/ValidationStatus/ValidationStatus.tsx +81 -0
- package/src/components/Code/ValidationStatus/index.ts +1 -0
- package/src/components/Code/Workspace/StatusBar.tsx +47 -0
- package/src/components/Code/Workspace/Toolbar.tsx +47 -0
- package/src/components/Code/Workspace/Workspace.tsx +109 -0
- package/src/components/Code/Workspace/index.ts +3 -0
- package/src/components/ColorDot/ColorDot.tsx +50 -0
- package/src/components/ColorDot/index.ts +1 -0
- package/src/components/DatePicker/DatePicker.tsx +236 -0
- package/src/components/DatePicker/datepicker.hooks.ts +381 -0
- package/src/components/DatePicker/datepicker.types.ts +140 -0
- package/src/components/DatePicker/index.ts +12 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +127 -0
- package/src/components/DateRangePicker/daterangepicker.types.ts +53 -0
- package/src/components/DateRangePicker/index.ts +2 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +399 -0
- package/src/components/DateTimePicker/datetimepicker.types.ts +48 -0
- package/src/components/DateTimePicker/index.ts +2 -0
- package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +679 -0
- package/src/components/DateTimeRangePicker/datetimerangepicker.types.ts +62 -0
- package/src/components/DateTimeRangePicker/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.tsx +29 -0
- package/src/components/EmptyState/index.ts +1 -0
- package/src/components/Field/Field.tsx +60 -0
- package/src/components/Field/index.ts +1 -0
- package/src/components/FormField/FormField.tsx +55 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/HeatMap/HeatMapCalendar.tsx +127 -0
- package/src/components/HeatMap/HeatMapMatrix.tsx +75 -0
- package/src/components/HeatMap/heat-map.utils.ts +15 -0
- package/src/components/HeatMap/index.ts +3 -0
- package/src/components/HoverCard/HoverCard.tsx +83 -0
- package/src/components/HoverCard/components/HoverCardContent.tsx +30 -0
- package/src/components/HoverCard/components/HoverCardContext.tsx +22 -0
- package/src/components/HoverCard/components/HoverCardTrigger.tsx +32 -0
- package/src/components/HoverCard/index.ts +3 -0
- package/src/components/Input/Input.tsx +48 -0
- package/src/components/Input/index.ts +1 -0
- package/src/components/LineChart/LineChart.tsx +251 -0
- package/src/components/LineChart/index.ts +1 -0
- package/src/components/Markdown/Markdown.tsx +40 -0
- package/src/components/Markdown/index.ts +1 -0
- package/src/components/Message/Message.tsx +59 -0
- package/src/components/Message/components/MessageBody.tsx +9 -0
- package/src/components/Message/components/MessageDescription.tsx +9 -0
- package/src/components/Message/components/MessageIcon.tsx +13 -0
- package/src/components/Message/components/MessageTitle.tsx +9 -0
- package/src/components/Message/index.ts +10 -0
- package/src/components/MetricList/MetricList.tsx +13 -0
- package/src/components/MetricList/MetricRow.tsx +30 -0
- package/src/components/MetricList/index.ts +2 -0
- package/src/components/Modal/Modal.tsx +146 -0
- package/src/components/Modal/components/ModalBody.tsx +9 -0
- package/src/components/Modal/components/ModalContext.tsx +27 -0
- package/src/components/Modal/components/ModalFooter.tsx +9 -0
- package/src/components/Modal/components/ModalHeader.tsx +29 -0
- package/src/components/Modal/components/index.ts +9 -0
- package/src/components/Modal/index.ts +13 -0
- package/src/components/NavBottom/NavBottom.tsx +53 -0
- package/src/components/NavBottom/index.ts +3 -0
- package/src/components/NavSide/NavItem.tsx +39 -0
- package/src/components/NavSide/NavSide.tsx +40 -0
- package/src/components/NavSide/index.ts +2 -0
- package/src/components/NavSide/types.ts +36 -0
- package/src/components/Pagination/Pagination.tsx +86 -0
- package/src/components/Pagination/index.ts +2 -0
- package/src/components/Popover/Popover.tsx +68 -0
- package/src/components/Popover/components/PopoverBody.tsx +32 -0
- package/src/components/Popover/components/PopoverClose.tsx +26 -0
- package/src/components/Popover/components/PopoverContext.tsx +24 -0
- package/src/components/Popover/components/PopoverTrigger.tsx +31 -0
- package/src/components/Popover/index.ts +10 -0
- package/src/components/ProgressBar/ProgressBar.tsx +42 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/RadioButton/RadioButton.tsx +89 -0
- package/src/components/RadioButton/index.ts +1 -0
- package/src/components/RadioButtonGroup/RadioButtonGroup.tsx +193 -0
- package/src/components/RadioButtonGroup/index.ts +5 -0
- package/src/components/SearchInput/SearchInput.tsx +45 -0
- package/src/components/SearchInput/index.ts +2 -0
- package/src/components/Select/Select.tsx +289 -0
- package/src/components/Select/SelectDropdown.tsx +90 -0
- package/src/components/Select/SelectTrigger.tsx +130 -0
- package/src/components/Select/index.ts +3 -0
- package/src/components/Select/useSelectKeyboard.ts +111 -0
- package/src/components/Separator/Separator.tsx +20 -0
- package/src/components/Separator/index.ts +1 -0
- package/src/components/Sheet/Sheet.tsx +136 -0
- package/src/components/Sheet/components/SheetBody.tsx +9 -0
- package/src/components/Sheet/components/SheetContext.tsx +23 -0
- package/src/components/Sheet/components/SheetFooter.tsx +9 -0
- package/src/components/Sheet/components/SheetHeader.tsx +29 -0
- package/src/components/Sheet/components/index.ts +9 -0
- package/src/components/Sheet/index.ts +13 -0
- package/src/components/Skeleton/Skeleton.tsx +7 -0
- package/src/components/Skeleton/index.ts +1 -0
- package/src/components/Sparkline/Sparkline.tsx +93 -0
- package/src/components/Sparkline/index.ts +1 -0
- package/src/components/Spinner/Spinner.tsx +41 -0
- package/src/components/Spinner/index.ts +1 -0
- package/src/components/StatsCard/StatsCard.tsx +40 -0
- package/src/components/StatsCard/index.ts +1 -0
- package/src/components/Switch/Switch.tsx +128 -0
- package/src/components/Switch/index.ts +1 -0
- package/src/components/Tabs/Tabs.tsx +49 -0
- package/src/components/Tabs/components/TabsContext.tsx +35 -0
- package/src/components/Tabs/components/TabsList.tsx +98 -0
- package/src/components/Tabs/components/TabsPanels.tsx +26 -0
- package/src/components/Tabs/components/TabsTrigger.tsx +69 -0
- package/src/components/Tabs/index.ts +3 -0
- package/src/components/TagChip/TagChip.tsx +46 -0
- package/src/components/TagChip/index.ts +1 -0
- package/src/components/TagInput/TagInput.tsx +60 -0
- package/src/components/TagInput/index.ts +1 -0
- package/src/components/Text/Text.tsx +35 -0
- package/src/components/Text/index.ts +1 -0
- package/src/components/TextInline/TextInline.tsx +35 -0
- package/src/components/TextInline/index.ts +1 -0
- package/src/components/Textarea/Textarea.tsx +20 -0
- package/src/components/Textarea/index.ts +1 -0
- package/src/components/TimePicker/PeriodSelector.tsx +28 -0
- package/src/components/TimePicker/TimeColumn.tsx +62 -0
- package/src/components/TimePicker/TimePicker.tsx +323 -0
- package/src/components/TimePicker/index.ts +7 -0
- package/src/components/TimePicker/timepicker.hooks.ts +173 -0
- package/src/components/TimePicker/timepicker.types.ts +101 -0
- package/src/components/Toast/Toast.tsx +111 -0
- package/src/components/Toast/index.ts +1 -0
- package/src/components/Toggle/Toggle.tsx +72 -0
- package/src/components/Toggle/index.ts +1 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +14 -0
- package/src/components/ToggleGroup/ToggleGroupContainer.tsx +19 -0
- package/src/components/ToggleGroup/ToggleGroupItem.tsx +51 -0
- package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +61 -0
- package/src/components/ToggleGroup/ToggleGroupSingle.tsx +105 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/ToggleGroup/toggleGroup.types.ts +55 -0
- package/src/components/Tooltip/Tooltip.tsx +66 -0
- package/src/components/Tooltip/components/TooltipBody.tsx +30 -0
- package/src/components/Tooltip/components/TooltipContext.tsx +22 -0
- package/src/components/Tooltip/components/TooltipTrigger.tsx +27 -0
- package/src/components/Tooltip/index.ts +9 -0
- package/src/date-time/arithmetic.ts +101 -0
- package/src/date-time/boundaries.ts +91 -0
- package/src/date-time/compare.ts +85 -0
- package/src/date-time/duration.ts +63 -0
- package/src/date-time/elapsed.ts +18 -0
- package/src/date-time/format.ts +722 -0
- package/src/date-time/index.ts +279 -0
- package/src/date-time/now.ts +70 -0
- package/src/date-time/parse.ts +170 -0
- package/src/date-time/serialize.ts +90 -0
- package/src/date-time/timezone.ts +47 -0
- package/src/date-time/validate.ts +278 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useCollapsible.ts +16 -0
- package/src/hooks/useControllableState.ts +54 -0
- package/src/hooks/useDebouncedCallback.ts +21 -0
- package/src/hooks/useDebouncedState.ts +20 -0
- package/src/hooks/useDeviceFormFactor.ts +49 -0
- package/src/hooks/useLocalStorage.ts +67 -0
- package/src/hooks/useMergedFloatingRef.ts +29 -0
- package/src/hooks/usePresence.ts +127 -0
- package/src/hooks/useStack.ts +99 -0
- package/src/hooks/useTheme.ts +105 -0
- package/src/internal/Slot.tsx +79 -0
- package/src/internal/index.ts +2 -0
- package/src/internal/rovingFocus.ts +39 -0
- package/src/internal/types.ts +10 -0
- package/src/lib/EmptyBoundary.tsx +12 -0
- package/src/lib/ErrorBoundary.tsx +48 -0
- package/src/lib/contexts/ComponentsContext/ComponentsContext.tsx +34 -0
- package/src/lib/contexts/ComponentsContext/index.ts +1 -0
- package/src/lib/contexts/OverlayContext/OverlayContext.tsx +93 -0
- package/src/lib/contexts/OverlayContext/hooks/useModal.tsx +125 -0
- package/src/lib/contexts/OverlayContext/hooks/useToast.ts +80 -0
- package/src/lib/contexts/OverlayContext/index.ts +6 -0
- package/src/lib/format.ts +51 -0
- package/src/lib/utils.ts +10 -0
- package/src/lib/withEmpty.tsx +14 -0
- package/src/lib/withErrorBoundary.tsx +16 -0
- package/src/lib/withSuspense.tsx +16 -0
- package/src/lib/withSuspenseAndErrorBoundary.tsx +26 -0
- package/src/styles/base.css +41 -0
- package/src/theme/index.ts +37 -0
- package/src/theme/palette.json +100 -0
- package/src/theme/palette.schema.json +40 -0
- package/src/theme/schema.ts +122 -0
- package/src/theme/theme.json +349 -0
- package/src/theme/theme.schema.json +876 -0
- package/src/theme/typography.json +7 -0
- package/src/theme/typography.schema.json +36 -0
- package/src/typography/Body.tsx +8 -0
- package/src/typography/Caption.tsx +8 -0
- package/src/typography/Code.tsx +8 -0
- package/src/typography/Display.tsx +8 -0
- package/src/typography/Emphasis.tsx +8 -0
- package/src/typography/Heading.tsx +23 -0
- package/src/typography/Label.tsx +10 -0
- package/src/typography/Lead.tsx +8 -0
- package/src/typography/Mono.tsx +8 -0
- package/src/typography/Muted.tsx +8 -0
- package/src/typography/SectionTitle.tsx +8 -0
- package/src/typography/Subtitle.tsx +8 -0
- package/src/typography/Timestamp.tsx +8 -0
- package/src/typography/index.ts +10 -0
- package/tailwind.config.ts +261 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useControllableState } from "@sixthshift/design-system/hooks";
|
|
2
|
+
import { RadioButton } from "@sixthshift/design-system/radio-button";
|
|
3
|
+
import { cn } from "@sixthshift/design-system/utils";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { getRovingTargetIndex } from "../../internal/rovingFocus";
|
|
6
|
+
import type { WritableRefObject } from "../../internal/types";
|
|
7
|
+
|
|
8
|
+
export type RadioButtonGroupOption = {
|
|
9
|
+
/** Unique value for the option */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Display label */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Whether this option is disabled */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RadioButtonGroupProps = Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "onBlur"> & {
|
|
18
|
+
/** Controlled selected value */
|
|
19
|
+
value?: string;
|
|
20
|
+
/** Default selected value for uncontrolled mode */
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
/** Available options */
|
|
23
|
+
options: readonly RadioButtonGroupOption[];
|
|
24
|
+
/** Called when selection changes */
|
|
25
|
+
onValueChange?: (value: string) => void;
|
|
26
|
+
/** Layout orientation */
|
|
27
|
+
orientation?: "vertical" | "horizontal";
|
|
28
|
+
/** Visual variant */
|
|
29
|
+
variant?: "default" | "button";
|
|
30
|
+
/** Button appearance (only applies when variant="button") */
|
|
31
|
+
appearance?: "segmented" | "separate";
|
|
32
|
+
/** Input name for form submission */
|
|
33
|
+
name?: string;
|
|
34
|
+
/** Disable all options */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Called when focus leaves the group entirely */
|
|
37
|
+
onBlur?: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const RadioButtonGroup = React.forwardRef<HTMLDivElement, RadioButtonGroupProps>(
|
|
41
|
+
(
|
|
42
|
+
{
|
|
43
|
+
value: controlledValue,
|
|
44
|
+
defaultValue = "",
|
|
45
|
+
options,
|
|
46
|
+
onValueChange,
|
|
47
|
+
orientation = "vertical",
|
|
48
|
+
variant = "default",
|
|
49
|
+
appearance = "segmented",
|
|
50
|
+
name,
|
|
51
|
+
disabled,
|
|
52
|
+
onBlur,
|
|
53
|
+
className,
|
|
54
|
+
...props
|
|
55
|
+
},
|
|
56
|
+
ref
|
|
57
|
+
) => {
|
|
58
|
+
const [value, setValue] = useControllableState({
|
|
59
|
+
value: controlledValue,
|
|
60
|
+
defaultValue,
|
|
61
|
+
onChange: onValueChange,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const groupRef = React.useRef<HTMLDivElement | null>(null);
|
|
65
|
+
const mergedRef = React.useCallback(
|
|
66
|
+
(node: HTMLDivElement | null) => {
|
|
67
|
+
groupRef.current = node;
|
|
68
|
+
if (typeof ref === "function") ref(node);
|
|
69
|
+
else if (ref) (ref as WritableRefObject<HTMLDivElement | null>).current = node;
|
|
70
|
+
},
|
|
71
|
+
[ref]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const handleFocusOut = React.useCallback(
|
|
75
|
+
(e: React.FocusEvent) => {
|
|
76
|
+
if (!groupRef.current?.contains(e.relatedTarget as Node)) {
|
|
77
|
+
onBlur?.();
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[onBlur]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const handleOptionSelect = (optionValue: string) => {
|
|
84
|
+
setValue(optionValue);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// A radiogroup is one tab stop with arrow-key navigation; without this each
|
|
88
|
+
// option is separately tabbable, which breaks the WAI-ARIA radio pattern.
|
|
89
|
+
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
90
|
+
const enabled = Array.from(groupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? []).filter((radio) => !radio.disabled);
|
|
91
|
+
const focused = (event.target as HTMLElement | null)?.closest?.('[role="radio"]') as HTMLButtonElement | null;
|
|
92
|
+
const target = getRovingTargetIndex(event.key, focused ? enabled.indexOf(focused) : -1, enabled.length);
|
|
93
|
+
if (target === null) return;
|
|
94
|
+
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
const next = enabled[target];
|
|
97
|
+
next?.focus();
|
|
98
|
+
// In the radio pattern, moving focus also moves the selection.
|
|
99
|
+
next?.click();
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
// The tab stop is the checked option, or the first enabled one when nothing
|
|
103
|
+
// is checked yet, so the group is always reachable by keyboard.
|
|
104
|
+
const firstEnabledValue = options.find((option) => !(disabled || option.disabled))?.value;
|
|
105
|
+
const hasCheckedOption = options.some((option) => option.value === value);
|
|
106
|
+
const tabStopValue = hasCheckedOption ? value : firstEnabledValue;
|
|
107
|
+
|
|
108
|
+
if (variant === "button") {
|
|
109
|
+
const isVertical = orientation === "vertical";
|
|
110
|
+
const isSegmented = appearance === "segmented";
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={mergedRef}
|
|
115
|
+
role="radiogroup"
|
|
116
|
+
onBlurCapture={handleFocusOut}
|
|
117
|
+
onKeyDown={handleKeyDown}
|
|
118
|
+
className={cn("inline-flex", isVertical ? "flex-col" : "flex-row", !isSegmented && (isVertical ? "gap-2" : "gap-2"), className)}
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
{options.map((option, index) => {
|
|
122
|
+
const isChecked = value === option.value;
|
|
123
|
+
const isDisabled = disabled || option.disabled;
|
|
124
|
+
const isFirst = index === 0;
|
|
125
|
+
const isLast = index === options.length - 1;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
// biome-ignore lint/a11y/useSemanticElements: Custom radio button group with button role="radio" is WAI-ARIA best practice
|
|
129
|
+
<button
|
|
130
|
+
key={option.value}
|
|
131
|
+
type="button"
|
|
132
|
+
role="radio"
|
|
133
|
+
aria-checked={isChecked}
|
|
134
|
+
disabled={isDisabled}
|
|
135
|
+
tabIndex={option.value === tabStopValue ? 0 : -1}
|
|
136
|
+
onClick={() => handleOptionSelect(option.value)}
|
|
137
|
+
className={cn(
|
|
138
|
+
"inline-flex items-center justify-center px-4 py-2 font-medium text-sm transition-colors",
|
|
139
|
+
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2",
|
|
140
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
141
|
+
isSegmented
|
|
142
|
+
? cn(
|
|
143
|
+
"border",
|
|
144
|
+
isVertical
|
|
145
|
+
? cn(isFirst && "rounded-t-md", isLast && "rounded-b-md", !isFirst && "-mt-px")
|
|
146
|
+
: cn(isFirst && "rounded-l-md", isLast && "rounded-r-md", !isFirst && "-ml-px")
|
|
147
|
+
)
|
|
148
|
+
: "rounded-md border",
|
|
149
|
+
isChecked ? "border-border-brand bg-bg-brand text-fg-on-brand" : "border-border-normal bg-bg-normal text-fg-normal hover:bg-bg-subtle"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
{option.label}
|
|
153
|
+
</button>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
{name && <input type="hidden" name={name} value={value} />}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
ref={mergedRef}
|
|
164
|
+
role="radiogroup"
|
|
165
|
+
onBlurCapture={handleFocusOut}
|
|
166
|
+
onKeyDown={handleKeyDown}
|
|
167
|
+
className={cn("flex", orientation === "vertical" ? "flex-col gap-3" : "flex-row gap-4", className)}
|
|
168
|
+
{...props}
|
|
169
|
+
>
|
|
170
|
+
{options.map((option) => {
|
|
171
|
+
const isChecked = value === option.value;
|
|
172
|
+
const isDisabled = disabled || option.disabled;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<RadioButton
|
|
176
|
+
key={option.value}
|
|
177
|
+
{...(name ? { name } : {})}
|
|
178
|
+
value={option.value}
|
|
179
|
+
checked={isChecked}
|
|
180
|
+
onCheckedChange={() => handleOptionSelect(option.value)}
|
|
181
|
+
disabled={isDisabled}
|
|
182
|
+
tabIndex={option.value === tabStopValue ? 0 : -1}
|
|
183
|
+
label={option.label}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
RadioButtonGroup.displayName = "RadioButtonGroup";
|
|
192
|
+
|
|
193
|
+
export { RadioButtonGroup };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Input } from "@sixthshift/design-system/input";
|
|
2
|
+
import { Search, X } from "lucide-react";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
export type SearchInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> & {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
onClear?: () => void;
|
|
9
|
+
/** Accessible name for the clear button — it is icon-only. */
|
|
10
|
+
clearLabel?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
|
14
|
+
({ className, value, onChange, onClear, clearLabel = "Clear search", ...props }, ref) => {
|
|
15
|
+
const handleClear = () => {
|
|
16
|
+
if (onClear) {
|
|
17
|
+
onClear();
|
|
18
|
+
} else {
|
|
19
|
+
onChange("");
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Input
|
|
25
|
+
ref={ref}
|
|
26
|
+
type="text"
|
|
27
|
+
value={value}
|
|
28
|
+
onChange={(e) => onChange(e.target.value)}
|
|
29
|
+
iconLeft={<Search />}
|
|
30
|
+
iconRight={
|
|
31
|
+
value ? (
|
|
32
|
+
<button type="button" onClick={handleClear} aria-label={clearLabel} className="rounded hover:bg-bg-subtle">
|
|
33
|
+
<X />
|
|
34
|
+
</button>
|
|
35
|
+
) : undefined
|
|
36
|
+
}
|
|
37
|
+
className={className}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
SearchInput.displayName = "SearchInput";
|
|
44
|
+
|
|
45
|
+
export { SearchInput };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { autoUpdate, flip, offset, shift, size, useFloating } from "@floating-ui/react";
|
|
2
|
+
import { useControllableState } from "@sixthshift/design-system/hooks";
|
|
3
|
+
import { type HTMLAttributes, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { SelectDropdown } from "./SelectDropdown";
|
|
5
|
+
import { SelectTriggerButton, SelectTriggerSearch } from "./SelectTrigger";
|
|
6
|
+
import { useSelectKeyboard } from "./useSelectKeyboard";
|
|
7
|
+
|
|
8
|
+
export type SelectOption<T extends string = string> = {
|
|
9
|
+
value: T;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type SelectBaseProps<T extends string = string> = Omit<HTMLAttributes<HTMLElement>, "onChange" | "value" | "defaultValue"> & {
|
|
14
|
+
options: readonly SelectOption<T>[];
|
|
15
|
+
onBlur?: () => void;
|
|
16
|
+
onFocus?: () => void;
|
|
17
|
+
collapsed?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
searchable?: boolean;
|
|
21
|
+
clearable?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SelectSingleProps<T extends string = string> = SelectBaseProps<T> & {
|
|
25
|
+
mode?: "single";
|
|
26
|
+
value?: T;
|
|
27
|
+
defaultValue?: T;
|
|
28
|
+
onValueChange?: (value: T) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type SelectMultipleProps<T extends string = string> = SelectBaseProps<T> & {
|
|
32
|
+
mode: "multiple";
|
|
33
|
+
value?: T[];
|
|
34
|
+
defaultValue?: T[];
|
|
35
|
+
onValueChange?: (value: T[]) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SelectProps<T extends string = string> = SelectSingleProps<T> | SelectMultipleProps<T>;
|
|
39
|
+
|
|
40
|
+
export const Select = <T extends string = string>(props: SelectProps<T>) => {
|
|
41
|
+
const {
|
|
42
|
+
options,
|
|
43
|
+
onBlur,
|
|
44
|
+
onFocus,
|
|
45
|
+
collapsed = false,
|
|
46
|
+
className,
|
|
47
|
+
disabled = false,
|
|
48
|
+
placeholder = "Select...",
|
|
49
|
+
searchable = false,
|
|
50
|
+
clearable = false,
|
|
51
|
+
...restProps
|
|
52
|
+
} = props;
|
|
53
|
+
|
|
54
|
+
const isMultiple = props.mode === "multiple";
|
|
55
|
+
|
|
56
|
+
// Single-mode state
|
|
57
|
+
const [singleValue, setSingleValue] = useControllableState({
|
|
58
|
+
value: !isMultiple ? props.value : undefined,
|
|
59
|
+
defaultValue: (!isMultiple ? props.defaultValue : undefined) ?? ("" as T),
|
|
60
|
+
onChange: !isMultiple ? props.onValueChange : undefined,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Multiple-mode state
|
|
64
|
+
const [multiValue, setMultiValue] = useControllableState({
|
|
65
|
+
value: isMultiple ? props.value : undefined,
|
|
66
|
+
defaultValue: (isMultiple ? props.defaultValue : undefined) ?? ([] as T[]),
|
|
67
|
+
onChange: isMultiple ? props.onValueChange : undefined,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const [open, setOpen] = useControllableState({ value: undefined, defaultValue: false });
|
|
71
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
72
|
+
const [searchValue, setSearchValue] = useState("");
|
|
73
|
+
const listboxRef = useRef<HTMLDivElement | null>(null);
|
|
74
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
75
|
+
const optionRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
|
76
|
+
const wasOpenRef = useRef(false);
|
|
77
|
+
|
|
78
|
+
const selectedValues: Set<T> = useMemo(
|
|
79
|
+
() => (isMultiple ? new Set(multiValue) : singleValue ? new Set([singleValue]) : new Set()),
|
|
80
|
+
[isMultiple, multiValue, singleValue]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const displayLabel = useMemo(() => {
|
|
84
|
+
if (isMultiple) {
|
|
85
|
+
if (multiValue.length === 0) return placeholder;
|
|
86
|
+
if (multiValue.length === 1) return options.find((o) => o.value === multiValue[0])?.label ?? placeholder;
|
|
87
|
+
return `${multiValue.length} selected`;
|
|
88
|
+
}
|
|
89
|
+
return options.find((o) => o.value === singleValue)?.label ?? placeholder;
|
|
90
|
+
}, [isMultiple, multiValue, singleValue, options, placeholder]);
|
|
91
|
+
|
|
92
|
+
const filteredOptions = searchable && searchValue ? options.filter((opt) => opt.label.toLowerCase().includes(searchValue.toLowerCase())) : options;
|
|
93
|
+
const hasValue = isMultiple ? multiValue.length > 0 : Boolean(singleValue);
|
|
94
|
+
const showClearButton = clearable && hasValue && !disabled;
|
|
95
|
+
|
|
96
|
+
// Floating UI
|
|
97
|
+
const { refs, floatingStyles } = useFloating({
|
|
98
|
+
open,
|
|
99
|
+
onOpenChange: setOpen,
|
|
100
|
+
placement: "bottom-start",
|
|
101
|
+
middleware: [
|
|
102
|
+
offset(4),
|
|
103
|
+
flip(),
|
|
104
|
+
shift({ padding: 8 }),
|
|
105
|
+
size({
|
|
106
|
+
apply({ rects, elements }) {
|
|
107
|
+
Object.assign(elements.floating.style, { width: `${rects.reference.width}px` });
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
],
|
|
111
|
+
whileElementsMounted: autoUpdate,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const focusTrigger = useCallback(() => {
|
|
115
|
+
(refs.reference.current as HTMLElement | null)?.focus();
|
|
116
|
+
}, [refs.reference]);
|
|
117
|
+
|
|
118
|
+
const handleSelect = useCallback(
|
|
119
|
+
(optionValue: T) => {
|
|
120
|
+
if (isMultiple) {
|
|
121
|
+
setMultiValue(selectedValues.has(optionValue) ? multiValue.filter((v) => v !== optionValue) : [...multiValue, optionValue]);
|
|
122
|
+
} else {
|
|
123
|
+
setSingleValue(optionValue);
|
|
124
|
+
setSearchValue("");
|
|
125
|
+
setOpen(false);
|
|
126
|
+
focusTrigger();
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[isMultiple, selectedValues, multiValue, setMultiValue, setSingleValue, setOpen, focusTrigger]
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handleClear = useCallback(
|
|
133
|
+
(e: React.MouseEvent) => {
|
|
134
|
+
e.stopPropagation();
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
if (isMultiple) {
|
|
137
|
+
setMultiValue([] as T[]);
|
|
138
|
+
} else {
|
|
139
|
+
setSingleValue("" as T);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[isMultiple, setMultiValue, setSingleValue]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const handleTriggerKeyDown = useCallback(
|
|
146
|
+
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
147
|
+
if (disabled || collapsed) return;
|
|
148
|
+
if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ")) {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
setOpen(true);
|
|
151
|
+
}
|
|
152
|
+
if (searchable && !open && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
153
|
+
setOpen(true);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[disabled, collapsed, open, setOpen, searchable]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const handleSearchChange = useCallback((val: string) => {
|
|
160
|
+
setSearchValue(val);
|
|
161
|
+
setHighlightedIndex(0);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
// Keep the latest callbacks in a ref. This effect must run when the open state
|
|
165
|
+
// (and derived option state) changes — not whenever a caller passes a fresh
|
|
166
|
+
// inline handler. Listing onFocus/onBlur as dependencies would re-run it on
|
|
167
|
+
// every parent render, resetting the highlighted option and clearing the
|
|
168
|
+
// search box mid-interaction.
|
|
169
|
+
const focusCallbacksRef = useRef({ onFocus, onBlur });
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
focusCallbacksRef.current = { onFocus, onBlur };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Focus/blur lifecycle
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (open) {
|
|
177
|
+
const firstSelected = filteredOptions.findIndex((opt) => selectedValues.has(opt.value));
|
|
178
|
+
setHighlightedIndex(firstSelected >= 0 ? firstSelected : 0);
|
|
179
|
+
if (!wasOpenRef.current) focusCallbacksRef.current.onFocus?.();
|
|
180
|
+
if (searchable && inputRef.current) inputRef.current.focus();
|
|
181
|
+
} else {
|
|
182
|
+
setHighlightedIndex(-1);
|
|
183
|
+
setSearchValue("");
|
|
184
|
+
if (wasOpenRef.current) focusCallbacksRef.current.onBlur?.();
|
|
185
|
+
}
|
|
186
|
+
wasOpenRef.current = open;
|
|
187
|
+
}, [open, filteredOptions, searchable, selectedValues]);
|
|
188
|
+
|
|
189
|
+
// Scroll highlighted option into view
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (open && highlightedIndex >= 0) {
|
|
192
|
+
const el = optionRefs.current.get(highlightedIndex);
|
|
193
|
+
if (el && listboxRef.current) {
|
|
194
|
+
const listbox = listboxRef.current;
|
|
195
|
+
const top = el.offsetTop;
|
|
196
|
+
const bottom = top + el.offsetHeight;
|
|
197
|
+
if (top < listbox.scrollTop) listbox.scrollTop = top;
|
|
198
|
+
else if (bottom > listbox.scrollTop + listbox.clientHeight) listbox.scrollTop = bottom - listbox.clientHeight;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}, [highlightedIndex, open]);
|
|
202
|
+
|
|
203
|
+
// Close on outside click
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!open) return;
|
|
206
|
+
const handler = (event: MouseEvent) => {
|
|
207
|
+
const target = event.target as Node;
|
|
208
|
+
const reference = refs.reference.current;
|
|
209
|
+
const floating = refs.floating.current;
|
|
210
|
+
if (reference && floating && !(reference as HTMLElement).contains(target) && !floating.contains(target)) {
|
|
211
|
+
setOpen(false);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
document.addEventListener("mousedown", handler);
|
|
215
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
216
|
+
}, [open, setOpen, refs]);
|
|
217
|
+
|
|
218
|
+
useSelectKeyboard({
|
|
219
|
+
open,
|
|
220
|
+
searchable,
|
|
221
|
+
searchValue,
|
|
222
|
+
highlightedIndex,
|
|
223
|
+
displayOptions: filteredOptions,
|
|
224
|
+
setOpen,
|
|
225
|
+
setHighlightedIndex,
|
|
226
|
+
setSearchValue,
|
|
227
|
+
handleSelect,
|
|
228
|
+
focusTrigger,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The listbox id must be unique per instance: it is referenced by
|
|
232
|
+
// aria-controls, and a hardcoded value meant two Selects on one page
|
|
233
|
+
// produced duplicate ids and cross-wired ARIA.
|
|
234
|
+
const listboxId = useId();
|
|
235
|
+
|
|
236
|
+
// Strip mode from DOM props
|
|
237
|
+
const { mode: _, value: _v, defaultValue: _dv, onValueChange: _oc, ...htmlProps } = restProps as Record<string, unknown>;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
{searchable && open && !collapsed ? (
|
|
242
|
+
<SelectTriggerSearch
|
|
243
|
+
setReference={refs.setReference}
|
|
244
|
+
inputRef={inputRef}
|
|
245
|
+
searchValue={searchValue}
|
|
246
|
+
disabled={disabled}
|
|
247
|
+
displayLabel={displayLabel}
|
|
248
|
+
open={open}
|
|
249
|
+
className={className}
|
|
250
|
+
onSearchChange={handleSearchChange}
|
|
251
|
+
listboxId={listboxId}
|
|
252
|
+
props={htmlProps as React.InputHTMLAttributes<HTMLInputElement>}
|
|
253
|
+
/>
|
|
254
|
+
) : (
|
|
255
|
+
<SelectTriggerButton
|
|
256
|
+
setReference={refs.setReference}
|
|
257
|
+
open={open}
|
|
258
|
+
disabled={disabled}
|
|
259
|
+
collapsed={collapsed}
|
|
260
|
+
displayLabel={displayLabel}
|
|
261
|
+
showClearButton={Boolean(showClearButton)}
|
|
262
|
+
className={className}
|
|
263
|
+
onToggle={() => !disabled && !collapsed && setOpen(!open)}
|
|
264
|
+
onKeyDown={handleTriggerKeyDown}
|
|
265
|
+
onClear={handleClear}
|
|
266
|
+
listboxId={listboxId}
|
|
267
|
+
props={htmlProps as HTMLAttributes<HTMLElement>}
|
|
268
|
+
/>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{open && !collapsed && (
|
|
272
|
+
<SelectDropdown
|
|
273
|
+
setFloating={refs.setFloating}
|
|
274
|
+
listboxId={listboxId}
|
|
275
|
+
listboxRef={listboxRef}
|
|
276
|
+
floatingStyles={floatingStyles}
|
|
277
|
+
displayOptions={filteredOptions}
|
|
278
|
+
highlightedIndex={highlightedIndex}
|
|
279
|
+
selectedValues={selectedValues}
|
|
280
|
+
searchValue={searchValue}
|
|
281
|
+
optionRefs={optionRefs}
|
|
282
|
+
multiple={isMultiple}
|
|
283
|
+
onSelect={handleSelect}
|
|
284
|
+
onHighlight={setHighlightedIndex}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { FloatingPortal } from "@floating-ui/react";
|
|
2
|
+
import { cn } from "@sixthshift/design-system/utils";
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
import type React from "react";
|
|
5
|
+
import type { WritableRefObject } from "../../internal/types";
|
|
6
|
+
|
|
7
|
+
interface SelectDropdownProps<T extends string> {
|
|
8
|
+
setFloating: (node: HTMLElement | null) => void;
|
|
9
|
+
listboxId: string;
|
|
10
|
+
listboxRef: WritableRefObject<HTMLDivElement | null>;
|
|
11
|
+
floatingStyles: React.CSSProperties;
|
|
12
|
+
displayOptions: readonly { value: T; label: string }[];
|
|
13
|
+
highlightedIndex: number;
|
|
14
|
+
selectedValues: Set<T>;
|
|
15
|
+
searchValue: string;
|
|
16
|
+
optionRefs: WritableRefObject<Map<number, HTMLButtonElement>>;
|
|
17
|
+
multiple: boolean;
|
|
18
|
+
onSelect: (value: T) => void;
|
|
19
|
+
onHighlight: (index: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const SelectDropdown = <T extends string>({
|
|
23
|
+
setFloating,
|
|
24
|
+
listboxId,
|
|
25
|
+
listboxRef,
|
|
26
|
+
floatingStyles,
|
|
27
|
+
displayOptions,
|
|
28
|
+
highlightedIndex,
|
|
29
|
+
selectedValues,
|
|
30
|
+
searchValue,
|
|
31
|
+
optionRefs,
|
|
32
|
+
multiple,
|
|
33
|
+
onSelect,
|
|
34
|
+
onHighlight,
|
|
35
|
+
}: SelectDropdownProps<T>) => (
|
|
36
|
+
<FloatingPortal>
|
|
37
|
+
<div
|
|
38
|
+
ref={(node) => {
|
|
39
|
+
setFloating(node);
|
|
40
|
+
listboxRef.current = node;
|
|
41
|
+
}}
|
|
42
|
+
id={listboxId}
|
|
43
|
+
role="listbox"
|
|
44
|
+
aria-multiselectable={multiple || undefined}
|
|
45
|
+
tabIndex={-1}
|
|
46
|
+
style={floatingStyles}
|
|
47
|
+
className="z-popover max-h-60 overflow-y-auto rounded-md border border-border-normal bg-bg-normal shadow-lg"
|
|
48
|
+
>
|
|
49
|
+
{displayOptions.length === 0 ? (
|
|
50
|
+
<div className="px-3 py-2 text-center text-fg-subtle text-sm">{searchValue ? `No results for "${searchValue}"` : "No options available"}</div>
|
|
51
|
+
) : (
|
|
52
|
+
displayOptions.map((option, index) => {
|
|
53
|
+
const isSelected = selectedValues.has(option.value);
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
key={option.value}
|
|
57
|
+
ref={(el) => {
|
|
58
|
+
if (el) optionRefs.current.set(index, el);
|
|
59
|
+
else optionRefs.current.delete(index);
|
|
60
|
+
}}
|
|
61
|
+
type="button"
|
|
62
|
+
role="option"
|
|
63
|
+
aria-selected={isSelected}
|
|
64
|
+
onClick={() => onSelect(option.value)}
|
|
65
|
+
onMouseEnter={() => onHighlight(index)}
|
|
66
|
+
className={cn(
|
|
67
|
+
`flex w-full cursor-pointer items-center px-3 py-2 text-fg-normal text-sm transition-colors`,
|
|
68
|
+
multiple && "gap-2",
|
|
69
|
+
highlightedIndex === index && "bg-bg-subtle",
|
|
70
|
+
isSelected ? "font-medium text-fg-normal" : "text-fg-subtle"
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{multiple && (
|
|
74
|
+
<span
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex h-4 w-4 shrink-0 items-center justify-center rounded-xs border",
|
|
77
|
+
isSelected ? "border-border-brand bg-bg-brand text-fg-on-brand" : "border-border-normal"
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{isSelected && <Check className="h-3 w-3" />}
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
{option.label}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
})
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</FloatingPortal>
|
|
90
|
+
);
|