@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.
- package/dist/Button.module.css +75 -8
- package/dist/Calendar.module.css +126 -0
- package/dist/Card.module.css +4 -2
- package/dist/Checkbox.module.css +109 -0
- package/dist/CodeTextarea.module.css +53 -0
- package/dist/ConfirmDialog.module.css +109 -0
- package/dist/DatePicker.module.css +57 -0
- package/dist/Dialog.module.css +73 -46
- package/dist/DragHandleRow.module.css +57 -0
- package/dist/Drawer.module.css +61 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Input.module.css +10 -0
- package/dist/Menu.module.css +82 -0
- package/dist/NavTabs.module.css +4 -2
- package/dist/OverlayHeader.module.css +98 -0
- package/dist/Popover.module.css +31 -1
- package/dist/SegmentedControl.module.css +4 -2
- package/dist/Select.module.css +9 -0
- package/dist/Sheet.module.css +55 -0
- package/dist/SplitPane.module.css +85 -0
- package/dist/StatusBadge.module.css +59 -0
- package/dist/SystemBanner.module.css +4 -2
- package/dist/Tabs.module.css +4 -2
- package/dist/TagInput.module.css +141 -0
- package/dist/Textarea.module.css +39 -0
- package/dist/Toast.module.css +7 -5
- package/dist/Toggle.module.css +1 -1
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +549 -20
- package/dist/index.js +1496 -193
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +19 -0
- package/package.json +1 -1
- package/src/__tests__/motion.test.tsx +105 -0
- package/src/components/Button/Button.module.css +75 -8
- package/src/components/Button/Button.stories.tsx +6 -0
- package/src/components/Button/Button.tsx +17 -1
- package/src/components/Button/__tests__/Button.test.tsx +19 -0
- package/src/components/Calendar/Calendar.module.css +126 -0
- package/src/components/Calendar/Calendar.tsx +213 -0
- package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
- package/src/components/Calendar/dateUtils.ts +97 -0
- package/src/components/Card/Card.module.css +4 -2
- package/src/components/Checkbox/Checkbox.module.css +109 -0
- package/src/components/Checkbox/Checkbox.tsx +78 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
- package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
- package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
- package/src/components/DatePicker/DatePicker.module.css +57 -0
- package/src/components/DatePicker/DatePicker.tsx +103 -0
- package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
- package/src/components/Dialog/Dialog.module.css +73 -46
- package/src/components/Dialog/Dialog.stories.tsx +51 -1
- package/src/components/Dialog/Dialog.tsx +86 -22
- package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
- package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- package/src/components/Drawer/Drawer.module.css +61 -0
- package/src/components/Drawer/Drawer.tsx +117 -13
- package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
- package/src/components/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- package/src/components/Icon/Icon.module.css +5 -0
- package/src/components/Icon/Icon.stories.tsx +4 -3
- package/src/components/Icon/Icon.tsx +1 -1
- package/src/components/Icon/icons/calendar.tsx +23 -0
- package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -4
- package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
- package/src/components/Input/Input.module.css +10 -0
- package/src/components/Menu/Menu.module.css +82 -0
- package/src/components/Menu/Menu.tsx +137 -0
- package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
- package/src/components/NavTabs/NavTabs.module.css +4 -2
- package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
- package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
- package/src/components/Popover/Popover.module.css +31 -1
- package/src/components/Popover/Popover.stories.tsx +126 -1
- package/src/components/Popover/Popover.tsx +140 -7
- package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
- package/src/components/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -2
- package/src/components/Sheet/Sheet.module.css +55 -0
- package/src/components/Sheet/Sheet.tsx +151 -0
- package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
- package/src/components/SplitPane/SplitPane.module.css +85 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
- package/src/components/SplitPane/SplitPane.tsx +149 -0
- package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
- package/src/components/StatusBadge/StatusBadge.module.css +59 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.tsx +65 -0
- package/src/components/SystemBanner/SystemBanner.module.css +4 -2
- package/src/components/Tabs/Tabs.module.css +4 -2
- package/src/components/TagInput/TagInput.module.css +141 -0
- package/src/components/TagInput/TagInput.stories.tsx +109 -0
- package/src/components/TagInput/TagInput.tsx +174 -0
- package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
- package/src/components/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +7 -5
- package/src/components/Toggle/Toggle.module.css +1 -1
- package/src/components/Tooltip/Tooltip.module.css +6 -1
- package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
- package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
- package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useDoubleTap.ts +91 -0
- package/src/hooks/useIsMobile.ts +33 -0
- package/src/hooks/useLongPress.ts +185 -0
- package/src/index.ts +37 -2
- package/src/motion.ts +78 -0
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +354 -8
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +87 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +19 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import styles from './DragHandleRow.module.css';
|
|
3
|
+
|
|
4
|
+
export interface DragHandleRowProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Pass drag-listener and drag-control props from dnd-kit here. */
|
|
7
|
+
handleProps?: HTMLAttributes<HTMLButtonElement>;
|
|
8
|
+
isDragging?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A row wrapper that surfaces a drag handle on the left.
|
|
13
|
+
* Consumers attach dnd-kit's useSortable listeners to `handleProps`.
|
|
14
|
+
*/
|
|
15
|
+
export function DragHandleRow({
|
|
16
|
+
children,
|
|
17
|
+
handleProps,
|
|
18
|
+
isDragging,
|
|
19
|
+
className,
|
|
20
|
+
...rest
|
|
21
|
+
}: DragHandleRowProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
{...rest}
|
|
25
|
+
className={[styles.row, isDragging ? styles.dragging : '', className]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' ')}
|
|
28
|
+
>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
aria-label="Drag to reorder"
|
|
32
|
+
className={styles.handle}
|
|
33
|
+
tabIndex={-1}
|
|
34
|
+
{...handleProps}
|
|
35
|
+
>
|
|
36
|
+
<DragIcon />
|
|
37
|
+
</button>
|
|
38
|
+
<div className={styles.content}>{children}</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function DragIcon() {
|
|
44
|
+
return (
|
|
45
|
+
<svg
|
|
46
|
+
width="14"
|
|
47
|
+
height="14"
|
|
48
|
+
viewBox="0 0 14 14"
|
|
49
|
+
fill="none"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
className={styles.icon}
|
|
52
|
+
>
|
|
53
|
+
{/* Two columns of three dots */}
|
|
54
|
+
{[3, 7, 11].map((cy) =>
|
|
55
|
+
[4, 10].map((cx) => (
|
|
56
|
+
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.2} fill="currentColor" />
|
|
57
|
+
)),
|
|
58
|
+
)}
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
display: flex;
|
|
15
15
|
align-items: flex-end;
|
|
16
16
|
background: var(--sv-color-scrim);
|
|
17
|
+
/* Entrance/exit fade — same two-phase-mount pattern as Dialog (see motion.ts
|
|
18
|
+
and Drawer.tsx). */
|
|
19
|
+
opacity: 0;
|
|
20
|
+
transition: opacity var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.scrimOpen {
|
|
24
|
+
opacity: 1;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
.panel {
|
|
@@ -28,4 +36,57 @@
|
|
|
28
36
|
/* Pull content above the home indicator / bottom bar in standalone mode. */
|
|
29
37
|
padding-bottom: env(safe-area-inset-bottom);
|
|
30
38
|
outline: none;
|
|
39
|
+
/* Slide up from below the viewport. Always a bottom sheet, so unlike Dialog
|
|
40
|
+
there's no desktop-vs-mobile fork here. */
|
|
41
|
+
transform: translateY(100%);
|
|
42
|
+
transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.panelOpen {
|
|
46
|
+
transform: translateY(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 'half' snapHeight — a fixed height instead of the default content-sized cap
|
|
50
|
+
* above. Per the design-system's "half screen or less" convention for
|
|
51
|
+
* Drawer; content taller than this belongs in Sheet instead. */
|
|
52
|
+
.panelHalf {
|
|
53
|
+
height: 50dvh;
|
|
54
|
+
max-height: 50dvh;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* See Dialog.module.css's identical rule for why 0.01ms rather than 0. */
|
|
58
|
+
@media (prefers-reduced-motion: reduce) {
|
|
59
|
+
.scrim,
|
|
60
|
+
.panel {
|
|
61
|
+
transition-duration: 0.01ms;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Grab handle — the sole drag-initiation region for swipe-down-to-dismiss
|
|
66
|
+
* (see Drawer.tsx's doc comment for why not the whole panel). The visual bar
|
|
67
|
+
* (::before) stays the small, established 36x4px size; only the hit area
|
|
68
|
+
* grows to a comfortable touch target. */
|
|
69
|
+
.handle {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: var(--sv-touch-target-min, 44px);
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
cursor: grab;
|
|
77
|
+
/* JS-driven drag — the browser must not treat a press here as a
|
|
78
|
+
scroll/pan gesture that would fight it. */
|
|
79
|
+
touch-action: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.handle:active {
|
|
83
|
+
cursor: grabbing;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.handle::before {
|
|
87
|
+
content: '';
|
|
88
|
+
width: 36px;
|
|
89
|
+
height: 4px;
|
|
90
|
+
border-radius: var(--sv-radius-full);
|
|
91
|
+
background: var(--sv-color-border-strong);
|
|
31
92
|
}
|
|
@@ -1,41 +1,84 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, useEffect, useRef } from 'react';
|
|
3
|
+
import { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef } from 'react';
|
|
4
4
|
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
5
|
+
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
6
|
import styles from './Drawer.module.css';
|
|
6
7
|
|
|
8
|
+
// Matches --sv-motion-duration-base (Drawer.module.css) — see Dialog.tsx's
|
|
9
|
+
// identical constant for why this stays a plain JS number instead of being
|
|
10
|
+
// read from the CSS custom property.
|
|
11
|
+
const MOTION_DURATION_MS = 250;
|
|
12
|
+
|
|
7
13
|
export interface DrawerProps {
|
|
8
14
|
/** Whether the drawer is shown. When false, nothing renders. */
|
|
9
15
|
open: boolean;
|
|
10
|
-
/** Called on Esc or
|
|
16
|
+
/** Called on Esc, scrim click, or a swipe-down past the dismiss threshold. */
|
|
11
17
|
onClose: () => void;
|
|
12
18
|
/** Accessible name for the drawer panel (sets `aria-label`). */
|
|
13
19
|
'aria-label'?: string;
|
|
20
|
+
/** `'content'` (default) sizes to content, capped at 80dvh. `'half'` fixes
|
|
21
|
+
* the panel to 50dvh regardless of content — per the design-system's
|
|
22
|
+
* "half screen or less" convention for Drawer, this is the only other
|
|
23
|
+
* supported size; taller content should use `Sheet` instead. */
|
|
24
|
+
snapHeight?: 'content' | 'half';
|
|
14
25
|
children: ReactNode;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
const FOCUSABLE =
|
|
18
29
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
19
30
|
|
|
31
|
+
// Downward drag distance (px) past which releasing dismisses the drawer
|
|
32
|
+
// instead of snapping back open. Distance-only, no velocity tracking — matches
|
|
33
|
+
// the swipe-to-reveal gesture already established in consuming plugins (see
|
|
34
|
+
// the mobile design-system plan), not a separate technique invented here.
|
|
35
|
+
const SWIPE_DISMISS_THRESHOLD_PX = 100;
|
|
36
|
+
|
|
20
37
|
/**
|
|
21
38
|
* Drawer — a dismissable bottom-sheet panel. Used by the mobile shell for
|
|
22
39
|
* plugin navigation; also available to plugins for any bottom-up surface.
|
|
23
40
|
*
|
|
24
|
-
* Behaviour: Esc
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
41
|
+
* Behaviour: Esc, scrim click, or dragging the grab handle down past
|
|
42
|
+
* `SWIPE_DISMISS_THRESHOLD_PX` all dismiss; focus moves to the first
|
|
43
|
+
* focusable element on open and is restored on close; Tab is trapped within
|
|
44
|
+
* the panel. The panel respects `env(safe-area-inset-bottom)` so it clears
|
|
45
|
+
* the home indicator in standalone/fullscreen mode. Shares dismissal
|
|
46
|
+
* conventions and tokens (`--sv-color-scrim`, `--sv-shadow-overlay`) with
|
|
47
|
+
* `Dialog`.
|
|
48
|
+
*
|
|
49
|
+
* Animated open/close: slides up from the bottom, scrim fades. The `open`/
|
|
50
|
+
* `onClose` API is unchanged — closing stays mounted internally for the exit
|
|
51
|
+
* transition before actually unmounting; `prefers-reduced-motion: reduce`
|
|
52
|
+
* collapses it to near-instant.
|
|
53
|
+
*
|
|
54
|
+
* The grab handle is the *only* drag-initiation region (not the whole panel)
|
|
55
|
+
* — dragging from body content would fight that content's own internal
|
|
56
|
+
* scroll, the same reasoning behind the tasks plugin's edge-zone-only
|
|
57
|
+
* swipe-to-reveal technique.
|
|
29
58
|
*/
|
|
30
|
-
export function Drawer({
|
|
59
|
+
export function Drawer({
|
|
60
|
+
open,
|
|
61
|
+
onClose,
|
|
62
|
+
'aria-label': ariaLabel,
|
|
63
|
+
snapHeight = 'content',
|
|
64
|
+
children,
|
|
65
|
+
}: DrawerProps) {
|
|
31
66
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
32
67
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
68
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
69
|
+
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
70
|
+
// Tracks an in-progress handle drag. Kept in a ref (not state) so pointermove
|
|
71
|
+
// updates the DOM directly at 60fps instead of re-rendering on every event —
|
|
72
|
+
// same technique as the tasks plugin's swipe-to-reveal rows.
|
|
73
|
+
const dragStartY = useRef<number | null>(null);
|
|
33
74
|
|
|
75
|
+
// Locked for the whole mounted lifetime (including the exit animation), not
|
|
76
|
+
// just while `open` — see Dialog.tsx's identical comment for why.
|
|
34
77
|
useEffect(() => {
|
|
35
|
-
if (!
|
|
78
|
+
if (!mounted) return;
|
|
36
79
|
lockBodyScroll();
|
|
37
80
|
return unlockBodyScroll;
|
|
38
|
-
}, [
|
|
81
|
+
}, [mounted]);
|
|
39
82
|
|
|
40
83
|
useEffect(() => {
|
|
41
84
|
if (!open) return;
|
|
@@ -80,13 +123,56 @@ export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: Dra
|
|
|
80
123
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
81
124
|
}, [open, onClose]);
|
|
82
125
|
|
|
83
|
-
|
|
126
|
+
// Swipe-down-to-dismiss, initiated only from the grab handle (see the
|
|
127
|
+
// component doc comment for why not the whole panel). Drags the panel
|
|
128
|
+
// directly via inline style while active (transition: none, so it tracks
|
|
129
|
+
// the finger 1:1 with no lag) and lets go on release: clearing the inline
|
|
130
|
+
// transform in the same commit that re-enables the CSS transition makes
|
|
131
|
+
// the panel animate from wherever the drag ended to its resolved position
|
|
132
|
+
// — open (snap back) or closed (the exit transition, same as a
|
|
133
|
+
// programmatic close) — rather than jump-cutting there. Same technique as
|
|
134
|
+
// the tasks plugin's row swipe-to-reveal.
|
|
135
|
+
function handleHandlePointerDown(e: ReactPointerEvent) {
|
|
136
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
137
|
+
dragStartY.current = e.clientY;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleHandlePointerMove(e: ReactPointerEvent) {
|
|
141
|
+
const startY = dragStartY.current;
|
|
142
|
+
if (startY === null) return;
|
|
143
|
+
const dy = Math.max(0, e.clientY - startY); // upward drag has no effect — already fully open
|
|
144
|
+
const panel = panelRef.current;
|
|
145
|
+
if (!panel) return;
|
|
146
|
+
panel.style.transition = 'none';
|
|
147
|
+
panel.style.transform = `translateY(${dy}px)`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Releases the drag and resolves it to open (snap back) or closed, based on
|
|
151
|
+
// distance. Shared by pointerup (a completed gesture) and pointercancel
|
|
152
|
+
// (the browser took the gesture away, e.g. for a system gesture) — cancel
|
|
153
|
+
// always snaps back rather than honoring the distance, since it wasn't a
|
|
154
|
+
// deliberate release.
|
|
155
|
+
function releaseDrag(e: ReactPointerEvent, honorDistance: boolean) {
|
|
156
|
+
const startY = dragStartY.current;
|
|
157
|
+
dragStartY.current = null;
|
|
158
|
+
if (startY === null) return;
|
|
159
|
+
const dy = Math.max(0, e.clientY - startY);
|
|
160
|
+
const panel = panelRef.current;
|
|
161
|
+
if (panel) {
|
|
162
|
+
panel.style.transform = '';
|
|
163
|
+
panel.style.transition = '';
|
|
164
|
+
}
|
|
165
|
+
if (honorDistance && dy > SWIPE_DISMISS_THRESHOLD_PX) onClose();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!mounted) return null;
|
|
169
|
+
const isOpenPhase = phase === 'open';
|
|
84
170
|
|
|
85
171
|
return (
|
|
86
172
|
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
87
173
|
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
88
174
|
<div
|
|
89
|
-
className={styles.scrim}
|
|
175
|
+
className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
|
|
90
176
|
role="presentation"
|
|
91
177
|
onClick={(e) => {
|
|
92
178
|
if (e.target === e.currentTarget) onClose();
|
|
@@ -101,8 +187,26 @@ export function Drawer({ open, onClose, 'aria-label': ariaLabel, children }: Dra
|
|
|
101
187
|
aria-modal="true"
|
|
102
188
|
aria-label={ariaLabel}
|
|
103
189
|
tabIndex={-1}
|
|
104
|
-
className={
|
|
190
|
+
className={[
|
|
191
|
+
styles.panel,
|
|
192
|
+
snapHeight === 'half' ? styles.panelHalf : '',
|
|
193
|
+
isOpenPhase ? styles.panelOpen : '',
|
|
194
|
+
]
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.join(' ')}
|
|
105
197
|
>
|
|
198
|
+
{/* The only drag-initiation region for swipe-down-to-dismiss — see the
|
|
199
|
+
component doc comment for why not the whole panel. Decorative +
|
|
200
|
+
functional, so no aria-label of its own; Esc and the scrim remain
|
|
201
|
+
the accessible dismiss paths. */}
|
|
202
|
+
<div
|
|
203
|
+
className={styles.handle}
|
|
204
|
+
aria-hidden="true"
|
|
205
|
+
onPointerDown={handleHandlePointerDown}
|
|
206
|
+
onPointerMove={handleHandlePointerMove}
|
|
207
|
+
onPointerUp={(e) => releaseDrag(e, true)}
|
|
208
|
+
onPointerCancel={(e) => releaseDrag(e, false)}
|
|
209
|
+
/>
|
|
106
210
|
{children}
|
|
107
211
|
</div>
|
|
108
212
|
</div>
|
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
4
|
import { Drawer } from '../Drawer';
|
|
5
5
|
|
|
6
|
+
// Drawer's exit animation reads prefers-reduced-motion via matchMedia, which
|
|
7
|
+
// jsdom does not implement. `matches: false` (motion enabled) exercises the
|
|
8
|
+
// normal animated path; the actual reduced-motion behaviour is covered by
|
|
9
|
+
// motion.ts's own tests.
|
|
10
|
+
function installMatchMedia() {
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'matchMedia',
|
|
13
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
14
|
+
matches: false,
|
|
15
|
+
media: query,
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
addListener: vi.fn(),
|
|
19
|
+
removeListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// jsdom does not implement Element.setPointerCapture/releasePointerCapture
|
|
26
|
+
// (another documented gap, alongside matchMedia and HTMLDialogElement) — the
|
|
27
|
+
// grab handle's pointerdown handler calls setPointerCapture unconditionally.
|
|
28
|
+
function installPointerCapture() {
|
|
29
|
+
if (!Element.prototype.setPointerCapture) {
|
|
30
|
+
Element.prototype.setPointerCapture = () => {};
|
|
31
|
+
}
|
|
32
|
+
if (!Element.prototype.releasePointerCapture) {
|
|
33
|
+
Element.prototype.releasePointerCapture = () => {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
describe('Drawer', () => {
|
|
7
|
-
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
installMatchMedia();
|
|
40
|
+
installPointerCapture();
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
cleanup();
|
|
45
|
+
});
|
|
8
46
|
|
|
9
47
|
it('renders nothing when closed', () => {
|
|
10
48
|
render(
|
|
@@ -53,4 +91,87 @@ describe('Drawer', () => {
|
|
|
53
91
|
fireEvent.click(scrim);
|
|
54
92
|
expect(onClose).toHaveBeenCalledOnce();
|
|
55
93
|
});
|
|
94
|
+
|
|
95
|
+
it('applies the half snapHeight class', () => {
|
|
96
|
+
render(
|
|
97
|
+
<Drawer open onClose={() => {}} aria-label="Nav" snapHeight="half">
|
|
98
|
+
Body
|
|
99
|
+
</Drawer>,
|
|
100
|
+
);
|
|
101
|
+
expect(screen.getByRole('dialog').className).toMatch(/panelHalf/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not apply the half snapHeight class by default (content-sized)', () => {
|
|
105
|
+
render(
|
|
106
|
+
<Drawer open onClose={() => {}} aria-label="Nav">
|
|
107
|
+
Body
|
|
108
|
+
</Drawer>,
|
|
109
|
+
);
|
|
110
|
+
expect(screen.getByRole('dialog').className).not.toMatch(/panelHalf/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('swipe-down-to-dismiss (grab handle)', () => {
|
|
114
|
+
// The handle is the panel's first child, aria-hidden and unlabeled —
|
|
115
|
+
// same "select via DOM position" approach the scrim-click test above
|
|
116
|
+
// already uses for a similarly unlabeled element.
|
|
117
|
+
function getHandle() {
|
|
118
|
+
return screen.getByRole('dialog').firstElementChild as HTMLElement;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
it('dismisses when dragged past the threshold', () => {
|
|
122
|
+
const onClose = vi.fn();
|
|
123
|
+
render(
|
|
124
|
+
<Drawer open onClose={onClose} aria-label="Nav">
|
|
125
|
+
Body
|
|
126
|
+
</Drawer>,
|
|
127
|
+
);
|
|
128
|
+
const handle = getHandle();
|
|
129
|
+
fireEvent.pointerDown(handle, { clientY: 0 });
|
|
130
|
+
fireEvent.pointerMove(handle, { clientY: 150 });
|
|
131
|
+
fireEvent.pointerUp(handle, { clientY: 150 }); // 150px > 100px threshold
|
|
132
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('snaps back without dismissing when released under the threshold', () => {
|
|
136
|
+
const onClose = vi.fn();
|
|
137
|
+
render(
|
|
138
|
+
<Drawer open onClose={onClose} aria-label="Nav">
|
|
139
|
+
Body
|
|
140
|
+
</Drawer>,
|
|
141
|
+
);
|
|
142
|
+
const handle = getHandle();
|
|
143
|
+
fireEvent.pointerDown(handle, { clientY: 0 });
|
|
144
|
+
fireEvent.pointerMove(handle, { clientY: 40 });
|
|
145
|
+
fireEvent.pointerUp(handle, { clientY: 40 }); // 40px < 100px threshold
|
|
146
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('snaps back without dismissing on pointercancel, regardless of distance', () => {
|
|
150
|
+
const onClose = vi.fn();
|
|
151
|
+
render(
|
|
152
|
+
<Drawer open onClose={onClose} aria-label="Nav">
|
|
153
|
+
Body
|
|
154
|
+
</Drawer>,
|
|
155
|
+
);
|
|
156
|
+
const handle = getHandle();
|
|
157
|
+
fireEvent.pointerDown(handle, { clientY: 0 });
|
|
158
|
+
fireEvent.pointerMove(handle, { clientY: 200 }); // well past the threshold
|
|
159
|
+
fireEvent.pointerCancel(handle, { clientY: 200 });
|
|
160
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('ignores upward drags (already fully open)', () => {
|
|
164
|
+
const onClose = vi.fn();
|
|
165
|
+
render(
|
|
166
|
+
<Drawer open onClose={onClose} aria-label="Nav">
|
|
167
|
+
Body
|
|
168
|
+
</Drawer>,
|
|
169
|
+
);
|
|
170
|
+
const handle = getHandle();
|
|
171
|
+
fireEvent.pointerDown(handle, { clientY: 100 });
|
|
172
|
+
fireEvent.pointerMove(handle, { clientY: 0 }); // upward
|
|
173
|
+
fireEvent.pointerUp(handle, { clientY: 0 });
|
|
174
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
56
177
|
});
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-direction: column;
|
|
4
4
|
gap: var(--sv-space-1);
|
|
5
|
+
/* Every local .field/.fieldGroup class this component replaced set this
|
|
6
|
+
explicitly. Without it, a parent with align-items: flex-start (needed so
|
|
7
|
+
submit buttons don't stretch full-width) shrink-wraps this wrapper to its
|
|
8
|
+
content's intrinsic width instead of filling the available space. */
|
|
9
|
+
width: 100%;
|
|
5
10
|
}
|
|
6
11
|
|
|
7
12
|
.label {
|
|
@@ -2,32 +2,59 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import styles from './FormField.module.css';
|
|
4
4
|
|
|
5
|
+
/** Props to spread onto the field's actual form control. */
|
|
6
|
+
export interface FormFieldRenderProps {
|
|
7
|
+
id: string;
|
|
8
|
+
'aria-describedby'?: string;
|
|
9
|
+
'aria-invalid'?: boolean;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface FormFieldProps {
|
|
6
14
|
label: string;
|
|
7
15
|
hint?: string;
|
|
8
16
|
error?: string;
|
|
9
|
-
|
|
17
|
+
/** Explicit control id. Auto-generated via `useId()` when omitted. */
|
|
18
|
+
id?: string;
|
|
10
19
|
required?: boolean;
|
|
11
|
-
children: ReactNode;
|
|
12
20
|
className?: string;
|
|
21
|
+
/** Render prop — receives the props that must be spread onto the control
|
|
22
|
+
* so the label, hint, and error stay associated with it. */
|
|
23
|
+
children: (field: FormFieldRenderProps) => ReactNode;
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
* FormField — accessible label + hint/error wrapper for a single form control.
|
|
28
|
+
* Wires `htmlFor`/`id` and `aria-describedby`/`aria-invalid` onto the control
|
|
29
|
+
* itself (via the render-prop `field` object), not a surrounding element, so
|
|
30
|
+
* screen readers reliably announce the hint or error as part of the control.
|
|
31
|
+
*/
|
|
15
32
|
export function FormField({
|
|
16
33
|
label,
|
|
17
34
|
hint,
|
|
18
35
|
error,
|
|
19
|
-
|
|
36
|
+
id,
|
|
20
37
|
required = false,
|
|
21
|
-
children,
|
|
22
38
|
className,
|
|
39
|
+
children,
|
|
23
40
|
}: FormFieldProps) {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
41
|
+
const generatedId = useId();
|
|
42
|
+
const fieldId = id ?? generatedId;
|
|
43
|
+
const hintId = `${fieldId}-hint`;
|
|
44
|
+
const errorId = `${fieldId}-error`;
|
|
45
|
+
const describedBy =
|
|
46
|
+
[hint && !error && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
|
|
47
|
+
|
|
48
|
+
const field: FormFieldRenderProps = {
|
|
49
|
+
id: fieldId,
|
|
50
|
+
'aria-describedby': describedBy,
|
|
51
|
+
'aria-invalid': error ? true : undefined,
|
|
52
|
+
required: required || undefined,
|
|
53
|
+
};
|
|
27
54
|
|
|
28
55
|
return (
|
|
29
56
|
<div className={[styles.field, className].filter(Boolean).join(' ')}>
|
|
30
|
-
<label className={styles.label} htmlFor={
|
|
57
|
+
<label className={styles.label} htmlFor={fieldId}>
|
|
31
58
|
{label}
|
|
32
59
|
{required && (
|
|
33
60
|
<span className={styles.required} aria-hidden="true">
|
|
@@ -35,9 +62,7 @@ export function FormField({
|
|
|
35
62
|
</span>
|
|
36
63
|
)}
|
|
37
64
|
</label>
|
|
38
|
-
{
|
|
39
|
-
and let the consumer wire htmlFor/id themselves */}
|
|
40
|
-
<div aria-describedby={describedBy}>{children}</div>
|
|
65
|
+
{children(field)}
|
|
41
66
|
{hint && !error && (
|
|
42
67
|
<p id={hintId} className={styles.hint}>
|
|
43
68
|
{hint}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { FormField } from '../FormField';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('FormField', () => {
|
|
9
|
+
it('associates the label with the control via htmlFor/id', () => {
|
|
10
|
+
render(
|
|
11
|
+
<FormField label="Email" id="email">
|
|
12
|
+
{(field) => <input {...field} />}
|
|
13
|
+
</FormField>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByLabelText('Email')).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('generates an id when none is provided', () => {
|
|
19
|
+
render(<FormField label="Email">{(field) => <input {...field} />}</FormField>);
|
|
20
|
+
const input = screen.getByLabelText('Email') as HTMLInputElement;
|
|
21
|
+
expect(input.id).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('wires the hint to the control via aria-describedby', () => {
|
|
25
|
+
render(
|
|
26
|
+
<FormField label="Username" hint="Letters and numbers only." id="username">
|
|
27
|
+
{(field) => <input {...field} />}
|
|
28
|
+
</FormField>,
|
|
29
|
+
);
|
|
30
|
+
const input = screen.getByLabelText('Username');
|
|
31
|
+
const hint = screen.getByText('Letters and numbers only.');
|
|
32
|
+
expect(input.getAttribute('aria-describedby')).toBe(hint.id);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('wires the error to the control via aria-describedby and marks it invalid', () => {
|
|
36
|
+
render(
|
|
37
|
+
<FormField label="Password" error="Too short." id="password">
|
|
38
|
+
{(field) => <input {...field} />}
|
|
39
|
+
</FormField>,
|
|
40
|
+
);
|
|
41
|
+
const input = screen.getByLabelText('Password');
|
|
42
|
+
const error = screen.getByText('Too short.');
|
|
43
|
+
expect(input.getAttribute('aria-describedby')).toBe(error.id);
|
|
44
|
+
expect(input.getAttribute('aria-invalid')).toBe('true');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the error with role="alert"', () => {
|
|
48
|
+
render(
|
|
49
|
+
<FormField label="Password" error="Too short." id="password">
|
|
50
|
+
{(field) => <input {...field} />}
|
|
51
|
+
</FormField>,
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByRole('alert').textContent).toBe('Too short.');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('hides the hint when an error is present', () => {
|
|
57
|
+
render(
|
|
58
|
+
<FormField label="Password" hint="At least 8 characters." error="Too short." id="password">
|
|
59
|
+
{(field) => <input {...field} />}
|
|
60
|
+
</FormField>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.queryByText('At least 8 characters.')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows a required indicator and sets required on the control', () => {
|
|
66
|
+
render(
|
|
67
|
+
<FormField label="Full name" required id="name">
|
|
68
|
+
{(field) => <input {...field} />}
|
|
69
|
+
</FormField>,
|
|
70
|
+
);
|
|
71
|
+
const input = screen.getByLabelText(/Full name/) as HTMLInputElement;
|
|
72
|
+
expect(input.required).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not set aria-describedby when there is no hint or error', () => {
|
|
76
|
+
render(
|
|
77
|
+
<FormField label="Nickname" id="nickname">
|
|
78
|
+
{(field) => <input {...field} />}
|
|
79
|
+
</FormField>,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByLabelText('Nickname').hasAttribute('aria-describedby')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -18,7 +18,7 @@ const meta = {
|
|
|
18
18
|
},
|
|
19
19
|
argTypes: {
|
|
20
20
|
name: { control: 'select', options: ALL_NAMES },
|
|
21
|
-
size: { control: 'select', options: ['sm', 'md', 'lg'] },
|
|
21
|
+
size: { control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
|
|
22
22
|
},
|
|
23
23
|
} satisfies Meta<typeof Icon>;
|
|
24
24
|
|
|
@@ -35,17 +35,18 @@ export const Meaningful: Story = {
|
|
|
35
35
|
args: { name: 'settings', size: 'md', 'aria-label': 'Open settings' },
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
export const SizeExtraSmall: Story = { args: { name: 'bell', size: 'xs', 'aria-hidden': true } };
|
|
38
39
|
export const SizeSmall: Story = { args: { name: 'bell', size: 'sm', 'aria-hidden': true } };
|
|
39
40
|
export const SizeMedium: Story = { args: { name: 'bell', size: 'md', 'aria-hidden': true } };
|
|
40
41
|
export const SizeLarge: Story = { args: { name: 'bell', size: 'lg', 'aria-hidden': true } };
|
|
41
42
|
|
|
42
|
-
/** Full icon grid — every name across all
|
|
43
|
+
/** Full icon grid — every name across all four sizes. */
|
|
43
44
|
export const AllIcons: Story = {
|
|
44
45
|
parameters: { layout: 'padded' },
|
|
45
46
|
args: { name: 'house', 'aria-hidden': true },
|
|
46
47
|
render: (_args) => (
|
|
47
48
|
<div>
|
|
48
|
-
{(['sm', 'md', 'lg'] as const).map((sz) => (
|
|
49
|
+
{(['xs', 'sm', 'md', 'lg'] as const).map((sz) => (
|
|
49
50
|
<section key={sz} style={{ marginBottom: 32 }}>
|
|
50
51
|
<h3
|
|
51
52
|
style={{
|
|
@@ -12,7 +12,7 @@ export interface IconProps {
|
|
|
12
12
|
/** Name of the icon from the curated Sovereign set. */
|
|
13
13
|
name: IconName;
|
|
14
14
|
/** Visual size. Defaults to `"md"` (20 px). */
|
|
15
|
-
size?: 'sm' | 'md' | 'lg';
|
|
15
|
+
size?: 'xs' | 'sm' | 'md' | 'lg';
|
|
16
16
|
/** Additional CSS class applied to the SVG element. */
|
|
17
17
|
className?: string;
|
|
18
18
|
}
|