@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,99 @@
|
|
|
1
|
+
import { type Dispatch, type Reducer, startTransition, useCallback, useEffect, useReducer } from "react";
|
|
2
|
+
|
|
3
|
+
type Id = string | number;
|
|
4
|
+
type Transition = {
|
|
5
|
+
duration: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type StackItem = {
|
|
9
|
+
id: Id;
|
|
10
|
+
transition?: Transition;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Action<T extends StackItem> =
|
|
14
|
+
| { type: "push"; item: T }
|
|
15
|
+
| { type: "pop"; transition?: Transition }
|
|
16
|
+
| { type: "remove"; id: Id; transition?: Transition }
|
|
17
|
+
| { type: "update"; item: Partial<T> & StackItem }
|
|
18
|
+
| { type: "clear"; transition?: Transition };
|
|
19
|
+
|
|
20
|
+
export type StackDispatch<T extends StackItem> = Dispatch<Action<T>>;
|
|
21
|
+
|
|
22
|
+
function createReducer<T extends StackItem>(): Reducer<T[], Action<T>> {
|
|
23
|
+
return (state: T[], action: Action<T>) => {
|
|
24
|
+
switch (action.type) {
|
|
25
|
+
case "push": {
|
|
26
|
+
if (state.some((item) => item.id === action.item.id)) {
|
|
27
|
+
return state;
|
|
28
|
+
} else {
|
|
29
|
+
return [...state, action.item];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
case "pop": {
|
|
33
|
+
const firstItems = state.slice(0, -1);
|
|
34
|
+
if (action.transition) {
|
|
35
|
+
const lastItem = {
|
|
36
|
+
...state.at(-1),
|
|
37
|
+
transition: action.transition,
|
|
38
|
+
} as T;
|
|
39
|
+
return [...firstItems, lastItem];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return firstItems;
|
|
43
|
+
}
|
|
44
|
+
case "remove": {
|
|
45
|
+
if (action.transition) {
|
|
46
|
+
return state.map((s) => (s.id !== action.id ? s : { ...s, transition: action.transition }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return state.filter((s) => s.id !== action.id);
|
|
50
|
+
}
|
|
51
|
+
case "update": {
|
|
52
|
+
return state.map((s) => (s.id !== action.item.id ? s : { ...s, ...action.item }));
|
|
53
|
+
}
|
|
54
|
+
case "clear": {
|
|
55
|
+
if (action.transition) {
|
|
56
|
+
// Mark all items for transition-based removal
|
|
57
|
+
return state.map((s) => ({ ...s, transition: action.transition }));
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
default: {
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useStack<T extends StackItem>(initialStack: T[] = []): [T[], StackDispatch<T>] {
|
|
69
|
+
const reducer = createReducer<T>();
|
|
70
|
+
|
|
71
|
+
const [state, dispatch] = useReducer(reducer, initialStack);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const itemsToRemove = state.filter((item) => item.transition);
|
|
75
|
+
|
|
76
|
+
if (itemsToRemove.length === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
itemsToRemove.reverse().forEach((itemToRemove) => {
|
|
81
|
+
setTimeout(
|
|
82
|
+
() => {
|
|
83
|
+
dispatch({ type: "remove", id: itemToRemove.id });
|
|
84
|
+
}, // We found the item by the `transition` property, so we can be sure it exists.
|
|
85
|
+
(itemToRemove.transition as Transition).duration
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}, [state]);
|
|
89
|
+
|
|
90
|
+
// Memoize the dispatch wrapper to prevent unnecessary re-renders
|
|
91
|
+
// dispatch from useReducer is stable, so this only creates once
|
|
92
|
+
const wrappedDispatch = useCallback((params: Action<T>) => {
|
|
93
|
+
startTransition(() => {
|
|
94
|
+
dispatch(params);
|
|
95
|
+
});
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
return [state, wrappedDispatch];
|
|
99
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
export type Theme = "light" | "dark" | "system";
|
|
4
|
+
export type ResolvedTheme = "light" | "dark";
|
|
5
|
+
|
|
6
|
+
const STORAGE_KEY = "theme";
|
|
7
|
+
const listeners = new Set<() => void>();
|
|
8
|
+
|
|
9
|
+
function emit(): void {
|
|
10
|
+
for (const fn of listeners) fn();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getSystemTheme(): ResolvedTheme {
|
|
14
|
+
if (typeof window === "undefined") return "light";
|
|
15
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readTheme(): Theme {
|
|
19
|
+
try {
|
|
20
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
21
|
+
if (!raw) return "system";
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (parsed === "light" || parsed === "dark" || parsed === "system") return parsed;
|
|
24
|
+
return "system";
|
|
25
|
+
} catch {
|
|
26
|
+
return "system";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeTheme(theme: Theme): void {
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme));
|
|
33
|
+
} catch {
|
|
34
|
+
// Storage full or unavailable
|
|
35
|
+
}
|
|
36
|
+
paint(theme);
|
|
37
|
+
emit();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function paint(theme: Theme): void {
|
|
41
|
+
if (typeof document === "undefined") return;
|
|
42
|
+
document.documentElement.dataset.theme = theme === "system" ? getSystemTheme() : theme;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function subscribe(callback: () => void): () => void {
|
|
46
|
+
listeners.add(callback);
|
|
47
|
+
return () => listeners.delete(callback);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSnapshot(): Theme {
|
|
51
|
+
return readTheme();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getServerSnapshot(): Theme {
|
|
55
|
+
return "system";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply the stored theme now, and subscribe to OS color-scheme changes and
|
|
60
|
+
* cross-tab `storage` events. If nothing is stored yet, seeds `"system"` so
|
|
61
|
+
* the source of truth always exists. Call once at app bootstrap.
|
|
62
|
+
*/
|
|
63
|
+
export function bootstrapTheme(): () => void {
|
|
64
|
+
const theme = readTheme();
|
|
65
|
+
if (!localStorage.getItem(STORAGE_KEY)) {
|
|
66
|
+
writeTheme("system");
|
|
67
|
+
}
|
|
68
|
+
paint(theme);
|
|
69
|
+
|
|
70
|
+
if (typeof window === "undefined") return () => {};
|
|
71
|
+
|
|
72
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
73
|
+
const onSystemChange = () => {
|
|
74
|
+
if (readTheme() === "system") {
|
|
75
|
+
paint("system");
|
|
76
|
+
emit();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
mq.addEventListener("change", onSystemChange);
|
|
80
|
+
|
|
81
|
+
const onStorage = (e: StorageEvent) => {
|
|
82
|
+
if (e.key !== STORAGE_KEY) return;
|
|
83
|
+
paint(readTheme());
|
|
84
|
+
emit();
|
|
85
|
+
};
|
|
86
|
+
window.addEventListener("storage", onStorage);
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
mq.removeEventListener("change", onSystemChange);
|
|
90
|
+
window.removeEventListener("storage", onStorage);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read/write theme hook. Reads from localStorage via useSyncExternalStore; no effects. */
|
|
95
|
+
export function useTheme() {
|
|
96
|
+
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
97
|
+
const resolvedTheme: ResolvedTheme = theme === "system" ? getSystemTheme() : theme;
|
|
98
|
+
|
|
99
|
+
const setTheme = useCallback((next: Theme) => writeTheme(next), []);
|
|
100
|
+
const toggleTheme = useCallback(() => {
|
|
101
|
+
writeTheme(readTheme() === "dark" ? "light" : "dark");
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
return { theme, setTheme, resolvedTheme, toggleTheme };
|
|
105
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { WritableRefObject } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Merges multiple refs into a single callback ref.
|
|
6
|
+
*/
|
|
7
|
+
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
|
|
8
|
+
return (node) => {
|
|
9
|
+
refs.forEach((ref) => {
|
|
10
|
+
if (typeof ref === "function") {
|
|
11
|
+
ref(node);
|
|
12
|
+
} else if (ref != null) {
|
|
13
|
+
(ref as WritableRefObject<T | null>).current = node;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Merges two props objects, composing event handlers and merging classNames.
|
|
21
|
+
*/
|
|
22
|
+
function mergeProps(parentProps: Record<string, unknown>, childProps: Record<string, unknown>): Record<string, unknown> {
|
|
23
|
+
const merged: Record<string, unknown> = { ...parentProps };
|
|
24
|
+
|
|
25
|
+
for (const key of Object.keys(childProps)) {
|
|
26
|
+
const parentValue = parentProps[key];
|
|
27
|
+
const childValue = childProps[key];
|
|
28
|
+
|
|
29
|
+
// Compose event handlers
|
|
30
|
+
if (key.startsWith("on") && typeof parentValue === "function" && typeof childValue === "function") {
|
|
31
|
+
merged[key] = (...args: unknown[]) => {
|
|
32
|
+
childValue(...args);
|
|
33
|
+
parentValue(...args);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Merge classNames
|
|
37
|
+
else if (key === "className" && parentValue && childValue) {
|
|
38
|
+
merged[key] = `${parentValue} ${childValue}`;
|
|
39
|
+
}
|
|
40
|
+
// Merge styles
|
|
41
|
+
else if (key === "style" && parentValue && childValue) {
|
|
42
|
+
merged[key] = { ...(parentValue as object), ...(childValue as object) };
|
|
43
|
+
}
|
|
44
|
+
// Child value takes precedence for other props
|
|
45
|
+
else if (childValue !== undefined) {
|
|
46
|
+
merged[key] = childValue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SlotProps = React.HTMLAttributes<HTMLElement> & {
|
|
54
|
+
children?: React.ReactNode;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Slot merges its props onto its immediate child element.
|
|
59
|
+
* Used for the "asChild" pattern - allows replacing the rendered element
|
|
60
|
+
* while keeping all component behavior.
|
|
61
|
+
*/
|
|
62
|
+
export const Slot = React.forwardRef<HTMLElement, SlotProps>(({ children, ...props }, ref) => {
|
|
63
|
+
if (!React.isValidElement(children)) {
|
|
64
|
+
console.warn("Slot requires a valid React element as its child");
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// React 19 moved `ref` into props and warns when the legacy `element.ref`
|
|
69
|
+
// getter is read; React 18 only has the getter. Check props first so neither
|
|
70
|
+
// version complains.
|
|
71
|
+
const childProps = children.props as Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
|
|
72
|
+
const childRef = childProps.ref ?? (children as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref;
|
|
73
|
+
|
|
74
|
+
return React.cloneElement(children, {
|
|
75
|
+
...mergeProps(props, childProps),
|
|
76
|
+
ref: ref ? mergeRefs(ref, childRef) : childRef,
|
|
77
|
+
} as React.HTMLAttributes<HTMLElement>);
|
|
78
|
+
});
|
|
79
|
+
Slot.displayName = "Slot";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roving-focus helpers for composite widgets.
|
|
3
|
+
*
|
|
4
|
+
* A `radiogroup` is a single tab stop: exactly one option carries
|
|
5
|
+
* `tabIndex={0}` and the arrow keys move between options. Leaving every option
|
|
6
|
+
* separately tabbable — the browser default for a row of buttons — breaks the
|
|
7
|
+
* WAI-ARIA radio pattern.
|
|
8
|
+
*
|
|
9
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/radio/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Given the index of the currently focused option, return the index the key
|
|
14
|
+
* should move to, or `null` if the key is not a navigation key.
|
|
15
|
+
*
|
|
16
|
+
* Callers should pass an already-filtered list of *enabled* options, so
|
|
17
|
+
* disabled ones are skipped for free. Navigation wraps at both ends.
|
|
18
|
+
*
|
|
19
|
+
* `currentIndex` may be -1 (nothing focused yet), in which case forward keys
|
|
20
|
+
* land on the first option and backward keys on the last.
|
|
21
|
+
*/
|
|
22
|
+
export function getRovingTargetIndex(key: string, currentIndex: number, count: number): number | null {
|
|
23
|
+
if (count === 0) return null;
|
|
24
|
+
|
|
25
|
+
switch (key) {
|
|
26
|
+
case "Home":
|
|
27
|
+
return 0;
|
|
28
|
+
case "End":
|
|
29
|
+
return count - 1;
|
|
30
|
+
case "ArrowRight":
|
|
31
|
+
case "ArrowDown":
|
|
32
|
+
return currentIndex < 0 ? 0 : (currentIndex + 1) % count;
|
|
33
|
+
case "ArrowLeft":
|
|
34
|
+
case "ArrowUp":
|
|
35
|
+
return currentIndex < 0 ? count - 1 : (currentIndex - 1 + count) % count;
|
|
36
|
+
default:
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ref object whose `current` this library writes to.
|
|
3
|
+
*
|
|
4
|
+
* React 18's types spell this `MutableRefObject<T>`; React 19 removed that alias
|
|
5
|
+
* and made `RefObject<T>` itself mutable. Neither name type-checks under both
|
|
6
|
+
* versions, so the structural shape is written out instead — it is what both
|
|
7
|
+
* aliases resolve to, and it keeps the source valid across the whole
|
|
8
|
+
* `react@^18.2.0 || ^19.0.0` peer range.
|
|
9
|
+
*/
|
|
10
|
+
export type WritableRefObject<T> = { current: T };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type EmptyBoundaryProps = {
|
|
4
|
+
isEmpty: boolean;
|
|
5
|
+
fallback: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const EmptyBoundary = ({ isEmpty, fallback, children }: EmptyBoundaryProps) => {
|
|
10
|
+
if (isEmpty) return <>{fallback}</>;
|
|
11
|
+
return <>{children}</>;
|
|
12
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type ErrorFallbackProps = {
|
|
4
|
+
error: Error;
|
|
5
|
+
reset: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
fallback: ReactNode | ((props: ErrorFallbackProps) => ReactNode);
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type State = {
|
|
14
|
+
error: Error | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
18
|
+
constructor(props: Props) {
|
|
19
|
+
super(props);
|
|
20
|
+
this.state = { error: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static getDerivedStateFromError(error: Error): State {
|
|
24
|
+
return { error };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
28
|
+
console.error("ErrorBoundary caught:", error, errorInfo);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset = () => {
|
|
32
|
+
this.setState({ error: null });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
override render() {
|
|
36
|
+
const { error } = this.state;
|
|
37
|
+
const { fallback, children } = this.props;
|
|
38
|
+
|
|
39
|
+
if (error) {
|
|
40
|
+
if (typeof fallback === "function") {
|
|
41
|
+
return fallback({ error, reset: this.reset });
|
|
42
|
+
}
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return children;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createContext, type PropsWithChildren, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
type LinkProps = {
|
|
4
|
+
href: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type LinkComponent = React.ComponentType<LinkProps>;
|
|
11
|
+
|
|
12
|
+
type Components = {
|
|
13
|
+
Link: LinkComponent;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DefaultLink = ({ href, children, ...props }: LinkProps) => (
|
|
17
|
+
<a href={href} {...props}>
|
|
18
|
+
{children}
|
|
19
|
+
</a>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const defaultComponents: Components = { Link: DefaultLink };
|
|
23
|
+
|
|
24
|
+
const ComponentsContext = createContext<Components>(defaultComponents);
|
|
25
|
+
|
|
26
|
+
export type ComponentsProviderProps = PropsWithChildren<{
|
|
27
|
+
components: Partial<Components>;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
export const ComponentsProvider = ({ components, children }: ComponentsProviderProps) => (
|
|
31
|
+
<ComponentsContext.Provider value={{ ...defaultComponents, ...components }}>{children}</ComponentsContext.Provider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const useComponents = () => useContext(ComponentsContext);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentsProvider, type ComponentsProviderProps, useComponents } from "./ComponentsContext";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { FloatingPortal, type FloatingPortalProps } from "@floating-ui/react";
|
|
2
|
+
import { type StackItem, useStack } from "@sixthshift/design-system/hooks";
|
|
3
|
+
import { createContext, type FunctionComponent, type PropsWithChildren, useContext, useEffect, useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
type OverlayContextProps = {
|
|
6
|
+
modal?: FloatingPortalProps["root"];
|
|
7
|
+
toast?: FloatingPortalProps["root"];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Stack item for overlays - component is a self-contained wrapper */
|
|
11
|
+
type OverlayStackItem = StackItem & {
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: Component type needs to be flexible for different overlay types (Toast, Modal, etc.)
|
|
13
|
+
component: FunctionComponent<any>;
|
|
14
|
+
/** Live data passed to the component via update() */
|
|
15
|
+
data?: unknown;
|
|
16
|
+
/** Close handler for escape key handling */
|
|
17
|
+
onClose?: (() => void) | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type OverlayContextType = {
|
|
21
|
+
modalRoot: FloatingPortalProps["root"];
|
|
22
|
+
modalStack: ReturnType<typeof useStack<OverlayStackItem>>;
|
|
23
|
+
toastRoot: FloatingPortalProps["root"];
|
|
24
|
+
toastStack: ReturnType<typeof useStack<OverlayStackItem>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const OverlayContext = createContext<OverlayContextType>(undefined as unknown as OverlayContextType);
|
|
28
|
+
export const useOverlayContext = () => useContext(OverlayContext);
|
|
29
|
+
|
|
30
|
+
export const OverlayProvider = ({ modal: modalRoot, toast: toastRoot, children }: PropsWithChildren<OverlayContextProps>) => {
|
|
31
|
+
const resolvedModalRoot = modalRoot ?? document.body;
|
|
32
|
+
const resolvedToastRoot = toastRoot ?? document.body;
|
|
33
|
+
|
|
34
|
+
const modalStack = useStack<OverlayStackItem>([]);
|
|
35
|
+
const toastStack = useStack<OverlayStackItem>([]);
|
|
36
|
+
|
|
37
|
+
const [modals] = modalStack;
|
|
38
|
+
const [toasts] = toastStack;
|
|
39
|
+
|
|
40
|
+
// Global escape key handler - closes topmost modal
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (modals.length === 0) return;
|
|
43
|
+
|
|
44
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
45
|
+
if (event.key === "Escape") {
|
|
46
|
+
const topModal = modals.at(-1);
|
|
47
|
+
topModal?.onClose?.();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
document.addEventListener("keydown", handleEscape);
|
|
52
|
+
return () => document.removeEventListener("keydown", handleEscape);
|
|
53
|
+
}, [modals]);
|
|
54
|
+
|
|
55
|
+
// Memoize context value to prevent unnecessary re-renders in consumers
|
|
56
|
+
const contextValue = useMemo(
|
|
57
|
+
() => ({
|
|
58
|
+
modalRoot: resolvedModalRoot,
|
|
59
|
+
modalStack,
|
|
60
|
+
toastRoot: resolvedToastRoot,
|
|
61
|
+
toastStack,
|
|
62
|
+
}),
|
|
63
|
+
[resolvedModalRoot, modalStack, resolvedToastRoot, toastStack]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<OverlayContext.Provider value={contextValue}>
|
|
68
|
+
{children}
|
|
69
|
+
|
|
70
|
+
{/* Modal stack - rendered in portal */}
|
|
71
|
+
{modals.length > 0 && (
|
|
72
|
+
<FloatingPortal root={resolvedModalRoot}>
|
|
73
|
+
{modals.map(({ id, component: Component, data }) => (
|
|
74
|
+
<Component key={id} data={data} />
|
|
75
|
+
))}
|
|
76
|
+
</FloatingPortal>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{/* Toast stack - rendered in portal */}
|
|
80
|
+
{toasts.length > 0 && (
|
|
81
|
+
<FloatingPortal root={resolvedToastRoot}>
|
|
82
|
+
<div className="pointer-events-none fixed bottom-6 left-1/2 z-toast flex -translate-x-1/2 flex-col-reverse items-center gap-3">
|
|
83
|
+
{toasts.map(({ id, component: Component, onClose, ...props }) => (
|
|
84
|
+
<div key={id} className="pointer-events-auto">
|
|
85
|
+
<Component onClose={onClose} {...props} />
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</FloatingPortal>
|
|
90
|
+
)}
|
|
91
|
+
</OverlayContext.Provider>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ModalContextProvider, type ModalProps, type OnClose } from "@sixthshift/design-system/modal";
|
|
2
|
+
import { type ComponentType, type ReactNode, Suspense, useCallback, useId, useRef } from "react";
|
|
3
|
+
import { useOverlayContext } from "../OverlayContext";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
/** Props passed to modal components */
|
|
10
|
+
export type ModalComponentProps<TData = undefined> = {
|
|
11
|
+
/** Close this modal */
|
|
12
|
+
onClose: OnClose;
|
|
13
|
+
/** Close all open modals */
|
|
14
|
+
closeAll: () => void;
|
|
15
|
+
/** Live data passed via update() */
|
|
16
|
+
data: TData;
|
|
17
|
+
} & Omit<ModalProps, "onOpenChange" | "children">;
|
|
18
|
+
|
|
19
|
+
/** Modal options passed to openModal */
|
|
20
|
+
export type OpenModalOptions<TData = undefined> = Omit<ModalProps, "onOpenChange" | "children"> & {
|
|
21
|
+
/** Fallback to show while lazy content is loading */
|
|
22
|
+
suspenseFallback?: ReactNode;
|
|
23
|
+
/** Initial data passed to the modal component */
|
|
24
|
+
data?: TData;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Hook
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export const useModal = () => {
|
|
32
|
+
const {
|
|
33
|
+
modalStack: [, dispatch],
|
|
34
|
+
} = useOverlayContext();
|
|
35
|
+
|
|
36
|
+
const baseId = useId();
|
|
37
|
+
const counterRef = useRef(0);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Close all open modals.
|
|
41
|
+
*/
|
|
42
|
+
const closeAllModals = useCallback(() => {
|
|
43
|
+
dispatch({ type: "clear", transition: { duration: 300 } });
|
|
44
|
+
}, [dispatch]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Open a modal by passing a component that renders a Modal.
|
|
48
|
+
*
|
|
49
|
+
* The component receives `onClose`, `closeAll`, and any options passed as props.
|
|
50
|
+
* It should render a `<Modal>` with its content.
|
|
51
|
+
*
|
|
52
|
+
* Supports lazy-loaded components via React.lazy().
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```tsx
|
|
56
|
+
* const { openModal } = useModal();
|
|
57
|
+
*
|
|
58
|
+
* // Inline component — Modal picks up the close handler from context, no prop needed.
|
|
59
|
+
* openModal(({ onClose, closeAll }) => (
|
|
60
|
+
* <Modal>
|
|
61
|
+
* <Modal.Header>My Modal</Modal.Header>
|
|
62
|
+
* <Modal.Body>Content</Modal.Body>
|
|
63
|
+
* <Modal.Footer>
|
|
64
|
+
* <Button onClick={onClose}>Close</Button>
|
|
65
|
+
* <Button onClick={closeAll}>Close All</Button>
|
|
66
|
+
* </Modal.Footer>
|
|
67
|
+
* </Modal>
|
|
68
|
+
* ));
|
|
69
|
+
*
|
|
70
|
+
* // Named component
|
|
71
|
+
* function ConfirmModal({ onClose, closeAll }: ModalComponentProps) {
|
|
72
|
+
* return (
|
|
73
|
+
* <Modal>
|
|
74
|
+
* <Modal.Header>Confirm</Modal.Header>
|
|
75
|
+
* <Modal.Body>Are you sure?</Modal.Body>
|
|
76
|
+
* <Modal.Footer>
|
|
77
|
+
* <Button onClick={closeAll}>Close All</Button>
|
|
78
|
+
* </Modal.Footer>
|
|
79
|
+
* </Modal>
|
|
80
|
+
* );
|
|
81
|
+
* }
|
|
82
|
+
* openModal(ConfirmModal);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
const openModal = useCallback(
|
|
86
|
+
<TData = undefined>(Component: ComponentType<ModalComponentProps<TData>>, options: OpenModalOptions<TData> = {} as OpenModalOptions<TData>) => {
|
|
87
|
+
const id = `${baseId}-${counterRef.current++}`;
|
|
88
|
+
const { suspenseFallback = null, data: initialData, ...componentProps } = options;
|
|
89
|
+
|
|
90
|
+
const closeModal: OnClose = () => {
|
|
91
|
+
dispatch({ type: "remove", id, transition: { duration: 300 } });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const ModalWrapper = ({ data }: { data?: unknown }) => (
|
|
95
|
+
<ModalContextProvider onClose={closeModal}>
|
|
96
|
+
<Suspense fallback={suspenseFallback}>
|
|
97
|
+
<Component onClose={closeModal} closeAll={closeAllModals} data={(data ?? initialData) as TData} {...componentProps} />
|
|
98
|
+
</Suspense>
|
|
99
|
+
</ModalContextProvider>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
dispatch({
|
|
103
|
+
type: "push",
|
|
104
|
+
item: {
|
|
105
|
+
id,
|
|
106
|
+
component: ModalWrapper,
|
|
107
|
+
onClose: closeModal,
|
|
108
|
+
data: initialData,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const update = (newData: TData) => {
|
|
113
|
+
dispatch({ type: "update", item: { id, data: newData } });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return { id, closeModal, update };
|
|
117
|
+
},
|
|
118
|
+
[dispatch, baseId, closeAllModals]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
openModal,
|
|
123
|
+
closeAllModals,
|
|
124
|
+
};
|
|
125
|
+
};
|