@sovereignfs/ui 0.21.3 → 0.33.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.
Files changed (130) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +109 -0
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. package/src/components/Icon/icons/terminal.tsx +0 -19
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ // The platform's canonical mobile breakpoint — matches the shell chrome and
6
+ // Dialog's own full-screen-sheet switch (`runtime/app/globals.css`,
7
+ // `Dialog.module.css`). A plugin whose own layout genuinely needs a different
8
+ // threshold may still hardcode its own value (e.g. a three-column layout that
9
+ // only needs to fork earlier than 768px) — this constant is the *default* every
10
+ // consumer should reach for first, so the platform's idea of "mobile" doesn't
11
+ // silently drift across components again.
12
+ export const MOBILE_BREAKPOINT_PX = 768;
13
+
14
+ /**
15
+ * useIsMobile — whether the viewport is at or below the platform's mobile
16
+ * breakpoint. SSR-safe: defaults to `false` (desktop) until the client mounts
17
+ * and reads the real viewport, avoiding a hydration mismatch on first paint.
18
+ */
19
+ export function useIsMobile(breakpointPx: number = MOBILE_BREAKPOINT_PX): boolean {
20
+ const [isMobile, setIsMobile] = useState(false);
21
+
22
+ useEffect(() => {
23
+ const mql = window.matchMedia(`(max-width: ${breakpointPx}px)`);
24
+ setIsMobile(mql.matches);
25
+ function handleChange(e: MediaQueryListEvent) {
26
+ setIsMobile(e.matches);
27
+ }
28
+ mql.addEventListener('change', handleChange);
29
+ return () => mql.removeEventListener('change', handleChange);
30
+ }, [breakpointPx]);
31
+
32
+ return isMobile;
33
+ }
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import type {
5
+ CSSProperties,
6
+ MouseEvent as ReactMouseEvent,
7
+ PointerEvent as ReactPointerEvent,
8
+ } from 'react';
9
+
10
+ export interface UseLongPressOptions {
11
+ /** Called once the press has been held for `delay` without exceeding `moveTolerance`. */
12
+ onLongPress: () => void;
13
+ /** Hold duration in ms before `onLongPress` fires. Default 500. */
14
+ delay?: number;
15
+ /** Accumulated pointer movement (px) that cancels a pending press. Default 10 —
16
+ * a real finger jitters a couple of px on a still hold; this must be forgiving
17
+ * of that or the gesture never fires. */
18
+ moveTolerance?: number;
19
+ /** How long (ms) after firing to swallow the click that may or may not follow.
20
+ * Default 700. Time-boxed rather than "clear on next click" — iOS frequently
21
+ * sends no click at all after a long hold, so a flag that only clears on the
22
+ * next click stays armed forever and silently eats the user's next real tap. */
23
+ suppressClickMs?: number;
24
+ /** Fire `navigator.vibrate(10)` when the press triggers, where supported. Default true. */
25
+ vibrate?: boolean;
26
+ /** Skip entirely (hooks can't be called conditionally, so this is the escape hatch
27
+ * for a caller whose long-press action isn't available in the current state). */
28
+ disabled?: boolean;
29
+ }
30
+
31
+ export interface LongPressHandlers {
32
+ onPointerDown: (e: ReactPointerEvent) => void;
33
+ onPointerMove: (e: ReactPointerEvent) => void;
34
+ onPointerUp: (e: ReactPointerEvent) => void;
35
+ onPointerCancel: (e: ReactPointerEvent) => void;
36
+ onPointerLeave: (e: ReactPointerEvent) => void;
37
+ onContextMenu: (e: ReactMouseEvent) => void;
38
+ onClick: (e: ReactMouseEvent) => void;
39
+ /** Spread onto the target element. Empty on devices whose primary pointer
40
+ * isn't coarse (desktop mouse/trackpad) — see the coarse-pointer gate below. */
41
+ style: CSSProperties;
42
+ }
43
+
44
+ const DEFAULT_DELAY_MS = 500;
45
+ const DEFAULT_MOVE_TOLERANCE_PX = 10;
46
+ const DEFAULT_SUPPRESS_CLICK_MS = 700;
47
+
48
+ // SSR-safe, same defer-to-client-mount pattern as useIsMobile: defaults to
49
+ // false (no suppression styles) until the client can actually read the
50
+ // device's primary pointer, avoiding a hydration mismatch.
51
+ function usePrefersCoarsePointer(): boolean {
52
+ const [coarse, setCoarse] = useState(false);
53
+
54
+ useEffect(() => {
55
+ const mql = window.matchMedia('(pointer: coarse)');
56
+ setCoarse(mql.matches);
57
+ function handleChange(e: MediaQueryListEvent) {
58
+ setCoarse(e.matches);
59
+ }
60
+ mql.addEventListener('change', handleChange);
61
+ return () => mql.removeEventListener('change', handleChange);
62
+ }, []);
63
+
64
+ return coarse;
65
+ }
66
+
67
+ /**
68
+ * useLongPress — touch-and-hold gesture with the full suppression recipe a
69
+ * long-press needs to behave, not just a bare timer.
70
+ *
71
+ * A naive `setTimeout` started on `pointerdown` and cleared on `pointermove`
72
+ * fails in three specific ways this hook exists to fix:
73
+ * 1. Any movement at all cancels it — but a real finger jitters a few px even
74
+ * on a "still" hold, so the gesture misfires constantly. `moveTolerance`
75
+ * requires deliberate movement before cancelling.
76
+ * 2. `pointercancel` (the browser converting the touch into a scroll) isn't
77
+ * handled — the timer survives and fires mid-scroll. Handled here.
78
+ * 3. Nothing suppresses the OS's own reaction to a long touch-hold: iOS shows
79
+ * its link-preview callout, Android starts text selection or opens a
80
+ * context menu, and either can fire *alongside* the gesture this hook
81
+ * triggers. `onContextMenu` + the returned `style` (touch-callout,
82
+ * user-select, touch-action) close all three, gated to coarse-pointer
83
+ * devices only — unconditionally disabling `user-select` would break
84
+ * mouse text selection on desktop, which never held this long-press to
85
+ * begin with (see `onPointerDown`'s own `pointerType` check).
86
+ *
87
+ * The click that may or may not follow a fired long-press (iOS often sends
88
+ * none at all) is swallowed for `suppressClickMs`, not indefinitely — an
89
+ * unbounded suppression flag left armed by a browser that never sends that
90
+ * click silently eats the user's *next* unrelated tap on the same element.
91
+ */
92
+ export function useLongPress({
93
+ onLongPress,
94
+ delay = DEFAULT_DELAY_MS,
95
+ moveTolerance = DEFAULT_MOVE_TOLERANCE_PX,
96
+ suppressClickMs = DEFAULT_SUPPRESS_CLICK_MS,
97
+ vibrate = true,
98
+ disabled = false,
99
+ }: UseLongPressOptions): LongPressHandlers {
100
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+ const startRef = useRef<{ x: number; y: number } | null>(null);
102
+ const suppressUntilRef = useRef(0);
103
+
104
+ const clearTimer = useCallback(() => {
105
+ if (timerRef.current) {
106
+ clearTimeout(timerRef.current);
107
+ timerRef.current = null;
108
+ }
109
+ }, []);
110
+
111
+ // A press pending when the component unmounts (its target left the view
112
+ // mid-hold — e.g. the row it's on was deleted by a sync elsewhere) must not
113
+ // fire onLongPress against state that's no longer there.
114
+ useEffect(() => clearTimer, [clearTimer]);
115
+
116
+ const onPointerDown = useCallback(
117
+ (e: ReactPointerEvent) => {
118
+ if (disabled || e.pointerType !== 'touch') return;
119
+ startRef.current = { x: e.clientX, y: e.clientY };
120
+ clearTimer();
121
+ timerRef.current = setTimeout(() => {
122
+ timerRef.current = null;
123
+ suppressUntilRef.current = Date.now() + suppressClickMs;
124
+ if (vibrate && typeof navigator !== 'undefined' && navigator.vibrate) {
125
+ navigator.vibrate(10);
126
+ }
127
+ onLongPress();
128
+ }, delay);
129
+ },
130
+ [disabled, delay, suppressClickMs, vibrate, onLongPress, clearTimer],
131
+ );
132
+
133
+ const onPointerMove = useCallback(
134
+ (e: ReactPointerEvent) => {
135
+ const start = startRef.current;
136
+ if (!start || !timerRef.current) return;
137
+ const dx = e.clientX - start.x;
138
+ const dy = e.clientY - start.y;
139
+ if (Math.hypot(dx, dy) > moveTolerance) clearTimer();
140
+ },
141
+ [moveTolerance, clearTimer],
142
+ );
143
+
144
+ const cancelPress = useCallback(() => {
145
+ clearTimer();
146
+ startRef.current = null;
147
+ }, [clearTimer]);
148
+
149
+ // Preempts the OS's own long-press menu (Android's link/image context menu)
150
+ // whenever our gesture is pending or has just fired — either way, this
151
+ // hook's action is the one that should win, not a native menu appearing on
152
+ // top of (or instead of) it. iOS's equivalent (the touch-callout link
153
+ // preview) isn't a JS event at all — that one is closed by `style` below.
154
+ const onContextMenu = useCallback((e: ReactMouseEvent) => {
155
+ if (timerRef.current || Date.now() < suppressUntilRef.current) e.preventDefault();
156
+ }, []);
157
+
158
+ const onClick = useCallback((e: ReactMouseEvent) => {
159
+ if (Date.now() < suppressUntilRef.current) {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ }
163
+ }, []);
164
+
165
+ const isCoarsePointer = usePrefersCoarsePointer();
166
+ const style: CSSProperties = isCoarsePointer
167
+ ? {
168
+ WebkitTouchCallout: 'none',
169
+ WebkitUserSelect: 'none',
170
+ userSelect: 'none',
171
+ touchAction: 'manipulation',
172
+ }
173
+ : {};
174
+
175
+ return {
176
+ onPointerDown,
177
+ onPointerMove,
178
+ onPointerUp: cancelPress,
179
+ onPointerCancel: cancelPress,
180
+ onPointerLeave: cancelPress,
181
+ onContextMenu,
182
+ onClick,
183
+ style,
184
+ };
185
+ }
package/src/index.ts CHANGED
@@ -21,16 +21,40 @@ export { Button } from './components/Button/Button';
21
21
  export type { ButtonProps, ButtonVariant, ButtonSize } from './components/Button/Button';
22
22
  export { Input } from './components/Input/Input';
23
23
  export type { InputProps } from './components/Input/Input';
24
- export { Dialog } from './components/Dialog/Dialog';
24
+ export { Textarea } from './components/Textarea/Textarea';
25
+ export type { TextareaProps } from './components/Textarea/Textarea';
26
+ export { CodeTextarea } from './components/CodeTextarea/CodeTextarea';
27
+ export type { CodeTextareaProps } from './components/CodeTextarea/CodeTextarea';
28
+ export { StatusBadge } from './components/StatusBadge/StatusBadge';
29
+ export type { StatusBadgeProps, StatusBadgeStatus } from './components/StatusBadge/StatusBadge';
30
+ export { SplitPane } from './components/SplitPane/SplitPane';
31
+ export type { SplitPaneProps } from './components/SplitPane/SplitPane';
32
+ export { TagInput } from './components/TagInput/TagInput';
33
+ export type { TagInputProps } from './components/TagInput/TagInput';
34
+ export { Dialog, useOverlaySecondRow } from './components/Dialog/Dialog';
25
35
  export type { DialogProps, DialogSize } from './components/Dialog/Dialog';
26
36
  export { Drawer } from './components/Drawer/Drawer';
27
37
  export type { DrawerProps } from './components/Drawer/Drawer';
38
+ export { OverlayHeader } from './components/OverlayHeader/OverlayHeader';
39
+ export type { OverlayHeaderProps } from './components/OverlayHeader/OverlayHeader';
40
+ export { Sheet } from './components/Sheet/Sheet';
41
+ export type { SheetProps } from './components/Sheet/Sheet';
42
+ export { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog';
43
+ export type { ConfirmDialogProps } from './components/ConfirmDialog/ConfirmDialog';
44
+ export { Menu } from './components/Menu/Menu';
45
+ export type {
46
+ MenuProps,
47
+ MenuItem,
48
+ MenuLabel,
49
+ MenuSeparator,
50
+ MenuEntry,
51
+ } from './components/Menu/Menu';
28
52
  export { ToastProvider, useToast } from './components/Toast/Toast';
29
53
  export type { ToastItem, ToastContextValue } from './components/Toast/Toast';
30
54
  export { Card } from './components/Card/Card';
31
55
  export type { CardProps } from './components/Card/Card';
32
56
  export { FormField } from './components/FormField/FormField';
33
- export type { FormFieldProps } from './components/FormField/FormField';
57
+ export type { FormFieldProps, FormFieldRenderProps } from './components/FormField/FormField';
34
58
  export { PageHeader } from './components/PageHeader/PageHeader';
35
59
  export type { PageHeaderProps } from './components/PageHeader/PageHeader';
36
60
  export { EmptyState } from './components/EmptyState/EmptyState';
@@ -43,3 +67,14 @@ export { NavTabs } from './components/NavTabs/NavTabs';
43
67
  export type { NavTabsProps, NavTabItem } from './components/NavTabs/NavTabs';
44
68
  export { Tooltip } from './components/Tooltip/Tooltip';
45
69
  export type { TooltipProps } from './components/Tooltip/Tooltip';
70
+ export { Checkbox } from './components/Checkbox/Checkbox';
71
+ export type { CheckboxProps } from './components/Checkbox/Checkbox';
72
+ export { DragHandleRow } from './components/DragHandleRow/DragHandleRow';
73
+ export type { DragHandleRowProps } from './components/DragHandleRow/DragHandleRow';
74
+ export { Calendar } from './components/Calendar/Calendar';
75
+ export type { CalendarProps } from './components/Calendar/Calendar';
76
+ export { DatePicker } from './components/DatePicker/DatePicker';
77
+ export type { DatePickerProps } from './components/DatePicker/DatePicker';
78
+ export { useLongPress, useDoubleTapHandler, useSingleOrDoubleTap, useIsMobile } from './hooks';
79
+ export { MOBILE_BREAKPOINT_PX } from './hooks';
80
+ export type { UseLongPressOptions, LongPressHandlers } from './hooks';
package/src/motion.ts ADDED
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ export type TransitionPhase = 'entering' | 'open' | 'closing' | 'closed';
6
+
7
+ /**
8
+ * useMountTransition — generic two-phase-mount state machine for animated
9
+ * overlays (Dialog, Drawer, and their Phase-B successors: Sheet,
10
+ * ConfirmDialog, Menu). Internal to the design system — not exported from
11
+ * `index.ts`; each component wires its own CSS classes to the returned phase.
12
+ *
13
+ * The `open` prop controls mounting from the outside, but both directions need
14
+ * two renders to actually animate rather than snap: entering needs a frame at
15
+ * the closed-position styles before flipping to open (so the browser has
16
+ * something to interpolate from), and exiting needs to stay mounted *after*
17
+ * `open` goes false long enough to play in reverse — so `mounted` and `open`
18
+ * diverge for `durationMs` on close:
19
+ *
20
+ * open=true -> phase 'entering' (closed-position styles — this applies
21
+ * equally to a fresh mount that starts open, e.g. a
22
+ * route-driven overlay, and to an existing instance whose
23
+ * `open` prop just flipped true; both should animate in the
24
+ * same way) -> next frame, phase 'open' (open-position styles;
25
+ * the CSS transition between the two animates the entrance).
26
+ * open=false -> phase 'closing' (still mounted, rendered back at the
27
+ * closed-position styles so the transition reverses) -> after
28
+ * durationMs, phase 'closed' (unmounts).
29
+ *
30
+ * A component only needs one conditional class: apply the "open" variant
31
+ * when `phase === 'open'`, and leave the base (closed-position) styles as the
32
+ * unconditional default — entering, closing, and the pre-transition instant
33
+ * all render as that same base state, which is exactly what should animate
34
+ * to/from.
35
+ */
36
+ export function useMountTransition(
37
+ open: boolean,
38
+ durationMs: number,
39
+ ): { mounted: boolean; phase: TransitionPhase } {
40
+ const [phase, setPhase] = useState<TransitionPhase>(open ? 'entering' : 'closed');
41
+
42
+ useEffect(() => {
43
+ if (open) {
44
+ setPhase('entering');
45
+ const raf = requestAnimationFrame(() => setPhase('open'));
46
+ return () => cancelAnimationFrame(raf);
47
+ }
48
+ setPhase((p) => (p === 'closed' ? 'closed' : 'closing'));
49
+ const timer = setTimeout(() => setPhase('closed'), durationMs);
50
+ return () => clearTimeout(timer);
51
+ }, [open, durationMs]);
52
+
53
+ return { mounted: phase !== 'closed', phase };
54
+ }
55
+
56
+ /**
57
+ * usePrefersReducedMotion — SSR-safe (defaults to `false` until the client
58
+ * mounts, matching every other SSR-safe hook in this system). Components pair
59
+ * this with a `durationMs` of ~0 passed to `useMountTransition` and a matching
60
+ * `@media (prefers-reduced-motion: reduce)` CSS rule collapsing the same
61
+ * transition to near-instant — both sides must agree, or the JS unmount timer
62
+ * and the CSS animation duration drift apart.
63
+ */
64
+ export function usePrefersReducedMotion(): boolean {
65
+ const [reduced, setReduced] = useState(false);
66
+
67
+ useEffect(() => {
68
+ const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
69
+ setReduced(mql.matches);
70
+ function handleChange(e: MediaQueryListEvent) {
71
+ setReduced(e.matches);
72
+ }
73
+ mql.addEventListener('change', handleChange);
74
+ return () => mql.removeEventListener('change', handleChange);
75
+ }, []);
76
+
77
+ return reduced;
78
+ }
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { Checkbox } from '../components/Checkbox/Checkbox';
4
+
5
+ const meta: Meta<typeof Checkbox> = {
6
+ title: 'Components/Checkbox',
7
+ component: Checkbox,
8
+ parameters: { layout: 'centered' },
9
+ tags: ['autodocs'],
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof Checkbox>;
14
+
15
+ export const Default: Story = {
16
+ render: () => {
17
+ const [checked, setChecked] = useState(false);
18
+ return <Checkbox checked={checked} onChange={setChecked} label="Task title" />;
19
+ },
20
+ };
21
+
22
+ export const Checked: Story = {
23
+ render: () => {
24
+ const [checked, setChecked] = useState(true);
25
+ return <Checkbox checked={checked} onChange={setChecked} label="Already done" />;
26
+ },
27
+ };
28
+
29
+ export const WithStrikeThrough: Story = {
30
+ name: 'Strike-through on complete',
31
+ render: () => {
32
+ const [checked, setChecked] = useState(false);
33
+ return (
34
+ <Checkbox checked={checked} onChange={setChecked} label="Click to complete" strikeThrough />
35
+ );
36
+ },
37
+ };
38
+
39
+ export const Disabled: Story = {
40
+ render: () => <Checkbox checked={false} onChange={() => {}} label="Cannot interact" disabled />,
41
+ };
42
+
43
+ export const DisabledChecked: Story = {
44
+ render: () => <Checkbox checked onChange={() => {}} label="Locked complete" disabled />,
45
+ };