@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,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useEffect, useRef } from 'react';
|
|
4
|
+
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
5
|
+
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
6
|
+
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
7
|
+
import styles from './Sheet.module.css';
|
|
8
|
+
|
|
9
|
+
export interface SheetProps {
|
|
10
|
+
/** Whether the sheet is shown. When false, nothing renders. */
|
|
11
|
+
open: boolean;
|
|
12
|
+
/** Called on Esc, or the built-in `OverlayHeader`'s close button. There is
|
|
13
|
+
* no scrim to tap — a Sheet replaces the content area it fills, not a
|
|
14
|
+
* layer floating above it (see the component doc comment). */
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
/** Accessible name for the sheet. Defaults to `title` when omitted. */
|
|
17
|
+
'aria-label'?: string;
|
|
18
|
+
/** Renders a built-in `OverlayHeader` (title + close, optional back button
|
|
19
|
+
* and trailing action). Omit when the content supplies its own header —
|
|
20
|
+
* e.g. a form whose title doubles as an editable field. */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Forwarded to `OverlayHeader` — see its own `onBack` doc. Has no effect
|
|
23
|
+
* without `title`. */
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
/** Forwarded to `OverlayHeader`'s trailing-action slot. Has no effect
|
|
26
|
+
* without `title`. */
|
|
27
|
+
headerAction?: ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Which edge the panel slides in from. Defaults to `'bottom'` (detail
|
|
30
|
+
* views, forms — content the user is reaching down into). `'top'` suits a
|
|
31
|
+
* short options/actions menu opened from a header button near the top of
|
|
32
|
+
* the screen, where sliding up from the bottom would travel past empty
|
|
33
|
+
* space before reaching content that visually belongs near the trigger.
|
|
34
|
+
*/
|
|
35
|
+
slideFrom?: 'bottom' | 'top';
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FOCUSABLE =
|
|
40
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
+
|
|
42
|
+
// Matches --sv-motion-duration-base (Sheet.module.css) — see Dialog.tsx's
|
|
43
|
+
// identical constant for why this stays a plain JS number.
|
|
44
|
+
const MOTION_DURATION_MS = 250;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sheet — a full-page overlay that replaces a plugin's content area between
|
|
48
|
+
* the shell's fixed header and footer (RFC 0013's mobile chrome stays visible
|
|
49
|
+
* and interactive around it), sliding in from an edge instead of centering
|
|
50
|
+
* like `Dialog`. Promoted from the tasks plugin's `MobileFullPageOverlay`
|
|
51
|
+
* (now the reference pattern for this component). Desktop has no equivalent
|
|
52
|
+
* presentation — a desktop layout shows the same content inline (columns,
|
|
53
|
+
* panes) instead of mounting a `Sheet` at all; this component is for the
|
|
54
|
+
* mobile case specifically.
|
|
55
|
+
*
|
|
56
|
+
* Unlike `Dialog`/`Drawer` there is no scrim: a Sheet visually *replaces* the
|
|
57
|
+
* region it covers rather than floating a layer above it, so there is no
|
|
58
|
+
* "outside" to tap-dismiss. Behaviour: Esc dismisses; focus moves into the
|
|
59
|
+
* panel on open and is restored on close; Tab is trapped within the panel;
|
|
60
|
+
* animated open/close via the same two-phase mount as `Dialog`/`Drawer`,
|
|
61
|
+
* respecting `prefers-reduced-motion`.
|
|
62
|
+
*/
|
|
63
|
+
export function Sheet({
|
|
64
|
+
open,
|
|
65
|
+
onClose,
|
|
66
|
+
'aria-label': ariaLabel,
|
|
67
|
+
title,
|
|
68
|
+
onBack,
|
|
69
|
+
headerAction,
|
|
70
|
+
slideFrom = 'bottom',
|
|
71
|
+
children,
|
|
72
|
+
}: SheetProps) {
|
|
73
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
75
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
76
|
+
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!mounted) return;
|
|
80
|
+
lockBodyScroll();
|
|
81
|
+
return unlockBodyScroll;
|
|
82
|
+
}, [mounted]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!mounted) return;
|
|
86
|
+
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
87
|
+
const panel = panelRef.current;
|
|
88
|
+
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
89
|
+
(first ?? panel)?.focus();
|
|
90
|
+
return () => previouslyFocused.current?.focus();
|
|
91
|
+
}, [mounted]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) return;
|
|
95
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
96
|
+
if (e.key === 'Escape') {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
onClose();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (e.key !== 'Tab') return;
|
|
102
|
+
const panel = panelRef.current;
|
|
103
|
+
if (!panel) return;
|
|
104
|
+
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
105
|
+
const first = focusable[0];
|
|
106
|
+
const last = focusable.at(-1);
|
|
107
|
+
if (!first || !last) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const active = document.activeElement;
|
|
112
|
+
if (e.shiftKey && active === first) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
last.focus();
|
|
115
|
+
} else if (!e.shiftKey && active === last) {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
first.focus();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
121
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
122
|
+
}, [open, onClose]);
|
|
123
|
+
|
|
124
|
+
if (!mounted) return null;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={panelRef}
|
|
129
|
+
role="dialog"
|
|
130
|
+
aria-modal="true"
|
|
131
|
+
aria-label={ariaLabel ?? title}
|
|
132
|
+
tabIndex={-1}
|
|
133
|
+
className={[
|
|
134
|
+
styles.panel,
|
|
135
|
+
slideFrom === 'top' ? styles.panelFromTop : '',
|
|
136
|
+
phase === 'open' ? styles.panelOpen : '',
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(' ')}
|
|
140
|
+
>
|
|
141
|
+
{title && (
|
|
142
|
+
<OverlayHeader title={title} onClose={onClose} onBack={onBack} action={headerAction} />
|
|
143
|
+
)}
|
|
144
|
+
{/* Content is always the scroll container — with or without a header —
|
|
145
|
+
so a header's sibling stays fixed and any content-owned internal
|
|
146
|
+
sticky positioning (e.g. a form's own sub-header) resolves against
|
|
147
|
+
this same scrolling ancestor either way. */}
|
|
148
|
+
<div className={styles.content}>{children}</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Sheet } from '../Sheet';
|
|
5
|
+
|
|
6
|
+
// Sheet's exit animation reads prefers-reduced-motion via matchMedia, which
|
|
7
|
+
// jsdom does not implement — see Dialog.test.tsx's identical setup.
|
|
8
|
+
function installMatchMedia() {
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
'matchMedia',
|
|
11
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
12
|
+
matches: false,
|
|
13
|
+
media: query,
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Sheet', () => {
|
|
24
|
+
beforeEach(installMatchMedia);
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders nothing when closed', () => {
|
|
31
|
+
render(
|
|
32
|
+
<Sheet open={false} onClose={() => {}}>
|
|
33
|
+
Body
|
|
34
|
+
</Sheet>,
|
|
35
|
+
);
|
|
36
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders the panel with its accessible name when open', () => {
|
|
40
|
+
render(
|
|
41
|
+
<Sheet open onClose={() => {}} aria-label="Task detail">
|
|
42
|
+
Body
|
|
43
|
+
</Sheet>,
|
|
44
|
+
);
|
|
45
|
+
const panel = screen.getByRole('dialog', { name: 'Task detail' });
|
|
46
|
+
expect(panel.getAttribute('aria-modal')).toBe('true');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('falls back to title for the accessible name when aria-label is omitted', () => {
|
|
50
|
+
render(
|
|
51
|
+
<Sheet open onClose={() => {}} title="Edit list">
|
|
52
|
+
Body
|
|
53
|
+
</Sheet>,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByRole('dialog', { name: 'Edit list' })).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does not render OverlayHeader when title is omitted (headerless escape hatch)', () => {
|
|
59
|
+
render(
|
|
60
|
+
<Sheet open onClose={() => {}} aria-label="Custom">
|
|
61
|
+
<button type="button">Own close button</button>
|
|
62
|
+
</Sheet>,
|
|
63
|
+
);
|
|
64
|
+
// Only the content's own button — no OverlayHeader close button.
|
|
65
|
+
expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders OverlayHeader (title + close) when title is provided', () => {
|
|
69
|
+
const onClose = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<Sheet open onClose={onClose} title="Edit list">
|
|
72
|
+
Body
|
|
73
|
+
</Sheet>,
|
|
74
|
+
);
|
|
75
|
+
expect(screen.getByText('Edit list')).toBeTruthy();
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
77
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls onClose on Escape', () => {
|
|
81
|
+
const onClose = vi.fn();
|
|
82
|
+
render(
|
|
83
|
+
<Sheet open onClose={onClose} aria-label="Panel">
|
|
84
|
+
Body
|
|
85
|
+
</Sheet>,
|
|
86
|
+
);
|
|
87
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
88
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('applies the top slide-from variant class', () => {
|
|
92
|
+
render(
|
|
93
|
+
<Sheet open onClose={() => {}} aria-label="Menu" slideFrom="top">
|
|
94
|
+
Body
|
|
95
|
+
</Sheet>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByRole('dialog').className).toMatch(/panelFromTop/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
.splitPane {
|
|
2
|
+
display: grid;
|
|
3
|
+
grid-template-columns:
|
|
4
|
+
minmax(0, var(--split-pane-primary-size, 50%)) 12px
|
|
5
|
+
minmax(0, calc(100% - var(--split-pane-primary-size, 50%)));
|
|
6
|
+
width: 100%;
|
|
7
|
+
min-height: 320px;
|
|
8
|
+
border: 1px solid var(--sv-color-border);
|
|
9
|
+
border-radius: var(--sv-radius-lg);
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
background: var(--sv-color-surface);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.splitPane:not(:has(.handle)) {
|
|
15
|
+
grid-template-columns:
|
|
16
|
+
minmax(0, var(--split-pane-primary-size, 50%))
|
|
17
|
+
minmax(0, calc(100% - var(--split-pane-primary-size, 50%)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.pane {
|
|
21
|
+
min-width: 0;
|
|
22
|
+
min-height: 0;
|
|
23
|
+
overflow: auto;
|
|
24
|
+
background: var(--sv-color-surface);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.pane + .pane {
|
|
28
|
+
border-left: 1px solid var(--sv-color-border);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.handle {
|
|
32
|
+
position: relative;
|
|
33
|
+
width: 12px;
|
|
34
|
+
min-width: 12px;
|
|
35
|
+
padding: 0;
|
|
36
|
+
border: 0;
|
|
37
|
+
border-inline: 1px solid var(--sv-color-border);
|
|
38
|
+
cursor: col-resize;
|
|
39
|
+
background: var(--sv-color-surface-sunken);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.handle::before {
|
|
43
|
+
position: absolute;
|
|
44
|
+
inset-block: 50%;
|
|
45
|
+
inset-inline: 50%;
|
|
46
|
+
width: 2px;
|
|
47
|
+
height: 40px;
|
|
48
|
+
content: '';
|
|
49
|
+
background: var(--sv-color-border-strong);
|
|
50
|
+
border-radius: var(--sv-radius-full);
|
|
51
|
+
transform: translate(-50%, -50%);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@media (hover: hover) {
|
|
55
|
+
.handle:hover {
|
|
56
|
+
background: var(--sv-color-accent-subtle);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.handle:focus-visible {
|
|
61
|
+
background: var(--sv-color-accent-subtle);
|
|
62
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
63
|
+
outline-offset: -2px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (max-width: 720px) {
|
|
67
|
+
.splitPane,
|
|
68
|
+
.splitPane:not(:has(.handle)) {
|
|
69
|
+
grid-template-columns: 1fr;
|
|
70
|
+
min-height: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.handle {
|
|
74
|
+
display: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.pane {
|
|
78
|
+
min-height: 220px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.pane + .pane {
|
|
82
|
+
border-top: 1px solid var(--sv-color-border);
|
|
83
|
+
border-left: 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { SplitPane } from './SplitPane';
|
|
4
|
+
import { CodeTextarea } from '../CodeTextarea/CodeTextarea';
|
|
5
|
+
import { StatusBadge } from '../StatusBadge/StatusBadge';
|
|
6
|
+
|
|
7
|
+
const editorText = `# Release plan
|
|
8
|
+
|
|
9
|
+
- Confirm copy
|
|
10
|
+
- Preview page
|
|
11
|
+
- Commit changes
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
function PaneShell({ title, children }: { title: string; children: ReactNode }) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
style={{
|
|
18
|
+
display: 'flex',
|
|
19
|
+
minHeight: '100%',
|
|
20
|
+
flexDirection: 'column',
|
|
21
|
+
gap: 'var(--sv-space-3)',
|
|
22
|
+
padding: 'var(--sv-space-4)',
|
|
23
|
+
fontFamily: 'var(--sv-font-family)',
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
<h3
|
|
27
|
+
style={{
|
|
28
|
+
margin: 0,
|
|
29
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
30
|
+
color: 'var(--sv-color-text-primary)',
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{title}
|
|
34
|
+
</h3>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
title: 'Components/SplitPane',
|
|
42
|
+
component: SplitPane,
|
|
43
|
+
parameters: {
|
|
44
|
+
layout: 'padded',
|
|
45
|
+
docs: {
|
|
46
|
+
description: {
|
|
47
|
+
component:
|
|
48
|
+
'Responsive two-pane layout for editor/preview and list/detail workflows. Drag or use arrow keys on the separator.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
args: {
|
|
53
|
+
primary: <PaneShell title="Primary">Primary pane</PaneShell>,
|
|
54
|
+
secondary: <PaneShell title="Secondary">Secondary pane</PaneShell>,
|
|
55
|
+
},
|
|
56
|
+
} satisfies Meta<typeof SplitPane>;
|
|
57
|
+
|
|
58
|
+
export default meta;
|
|
59
|
+
type Story = StoryObj<typeof meta>;
|
|
60
|
+
|
|
61
|
+
export const Default: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<SplitPane
|
|
64
|
+
primary={
|
|
65
|
+
<PaneShell title="Editor">
|
|
66
|
+
<CodeTextarea aria-label="Markdown editor" defaultValue={editorText} />
|
|
67
|
+
</PaneShell>
|
|
68
|
+
}
|
|
69
|
+
secondary={
|
|
70
|
+
<PaneShell title="Preview">
|
|
71
|
+
<article style={{ lineHeight: 1.6, color: 'var(--sv-color-text-primary)' }}>
|
|
72
|
+
<h2 style={{ marginTop: 0 }}>Release plan</h2>
|
|
73
|
+
<ul>
|
|
74
|
+
<li>Confirm copy</li>
|
|
75
|
+
<li>Preview page</li>
|
|
76
|
+
<li>Commit changes</li>
|
|
77
|
+
</ul>
|
|
78
|
+
</article>
|
|
79
|
+
</PaneShell>
|
|
80
|
+
}
|
|
81
|
+
/>
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const FixedPanes: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<SplitPane
|
|
88
|
+
resizable={false}
|
|
89
|
+
defaultPrimarySize={38}
|
|
90
|
+
primary={
|
|
91
|
+
<PaneShell title="Files">
|
|
92
|
+
{['index.md', 'release-notes.md', 'about.md'].map((file) => (
|
|
93
|
+
<div
|
|
94
|
+
key={file}
|
|
95
|
+
style={{
|
|
96
|
+
display: 'flex',
|
|
97
|
+
justifyContent: 'space-between',
|
|
98
|
+
gap: 'var(--sv-space-3)',
|
|
99
|
+
padding: 'var(--sv-space-2) 0',
|
|
100
|
+
borderBottom: '1px solid var(--sv-color-border)',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<span>{file}</span>
|
|
104
|
+
<StatusBadge status={file === 'release-notes.md' ? 'draft' : 'synced'} />
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</PaneShell>
|
|
108
|
+
}
|
|
109
|
+
secondary={
|
|
110
|
+
<PaneShell title="Details">
|
|
111
|
+
<p style={{ margin: 0, color: 'var(--sv-color-text-muted)' }}>
|
|
112
|
+
Select a file to inspect frontmatter, sync state, and publish history.
|
|
113
|
+
</p>
|
|
114
|
+
</PaneShell>
|
|
115
|
+
}
|
|
116
|
+
/>
|
|
117
|
+
),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const KeyboardResizable: Story = {
|
|
121
|
+
render: () => (
|
|
122
|
+
<SplitPane
|
|
123
|
+
defaultPrimarySize={45}
|
|
124
|
+
minPrimarySize={25}
|
|
125
|
+
maxPrimarySize={75}
|
|
126
|
+
resizeLabel="Resize editor and preview panes with arrow keys"
|
|
127
|
+
primary={<PaneShell title="Keyboard">Focus the separator and press arrow keys.</PaneShell>}
|
|
128
|
+
secondary={<PaneShell title="Preview">Shift+Arrow resizes in larger steps.</PaneShell>}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const LongContent: Story = {
|
|
134
|
+
render: () => (
|
|
135
|
+
<SplitPane
|
|
136
|
+
primary={
|
|
137
|
+
<PaneShell title="Long list">
|
|
138
|
+
{Array.from({ length: 28 }, (_, index) => (
|
|
139
|
+
<div key={index} style={{ paddingBlock: 'var(--sv-space-2)' }}>
|
|
140
|
+
Content item {index + 1}
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
</PaneShell>
|
|
144
|
+
}
|
|
145
|
+
secondary={<PaneShell title="Preview">Each pane scrolls independently.</PaneShell>}
|
|
146
|
+
/>
|
|
147
|
+
),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const Mobile: Story = {
|
|
151
|
+
parameters: {
|
|
152
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
153
|
+
},
|
|
154
|
+
render: () => (
|
|
155
|
+
<SplitPane
|
|
156
|
+
primary={<PaneShell title="Editor">This pane appears first on narrow screens.</PaneShell>}
|
|
157
|
+
secondary={<PaneShell title="Preview">The second pane stacks below it.</PaneShell>}
|
|
158
|
+
/>
|
|
159
|
+
),
|
|
160
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CSSProperties,
|
|
5
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
6
|
+
PointerEvent as ReactPointerEvent,
|
|
7
|
+
ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import styles from './SplitPane.module.css';
|
|
11
|
+
|
|
12
|
+
export interface SplitPaneProps {
|
|
13
|
+
primary: ReactNode;
|
|
14
|
+
secondary: ReactNode;
|
|
15
|
+
defaultPrimarySize?: number;
|
|
16
|
+
minPrimarySize?: number;
|
|
17
|
+
maxPrimarySize?: number;
|
|
18
|
+
resizable?: boolean;
|
|
19
|
+
primaryLabel?: string;
|
|
20
|
+
secondaryLabel?: string;
|
|
21
|
+
resizeLabel?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clampSize = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SplitPane — responsive two-pane layout for editor/preview and list/detail
|
|
29
|
+
* workflows. Desktop panes can be resized; narrow viewports fall back to a
|
|
30
|
+
* stable single-column stack.
|
|
31
|
+
*/
|
|
32
|
+
export function SplitPane({
|
|
33
|
+
primary,
|
|
34
|
+
secondary,
|
|
35
|
+
defaultPrimarySize = 50,
|
|
36
|
+
minPrimarySize = 30,
|
|
37
|
+
maxPrimarySize = 70,
|
|
38
|
+
resizable = true,
|
|
39
|
+
primaryLabel = 'Primary pane',
|
|
40
|
+
secondaryLabel = 'Secondary pane',
|
|
41
|
+
resizeLabel = 'Resize panes',
|
|
42
|
+
className,
|
|
43
|
+
}: SplitPaneProps) {
|
|
44
|
+
const initialSize = clampSize(defaultPrimarySize, minPrimarySize, maxPrimarySize);
|
|
45
|
+
const [primarySize, setPrimarySize] = useState(initialSize);
|
|
46
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
const dragState = useRef<{ pointerId: number } | null>(null);
|
|
48
|
+
|
|
49
|
+
const updateFromClientX = useCallback(
|
|
50
|
+
(clientX: number) => {
|
|
51
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
52
|
+
if (!rect || rect.width === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const next = ((clientX - rect.left) / rect.width) * 100;
|
|
57
|
+
setPrimarySize(clampSize(next, minPrimarySize, maxPrimarySize));
|
|
58
|
+
},
|
|
59
|
+
[maxPrimarySize, minPrimarySize],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
|
64
|
+
if (!dragState.current || event.pointerId !== dragState.current.pointerId) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
updateFromClientX(event.clientX);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const stopDragging = (event: globalThis.PointerEvent) => {
|
|
71
|
+
if (!dragState.current || event.pointerId !== dragState.current.pointerId) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
dragState.current = null;
|
|
75
|
+
document.body.style.cursor = '';
|
|
76
|
+
document.body.style.userSelect = '';
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
80
|
+
window.addEventListener('pointerup', stopDragging);
|
|
81
|
+
window.addEventListener('pointercancel', stopDragging);
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
85
|
+
window.removeEventListener('pointerup', stopDragging);
|
|
86
|
+
window.removeEventListener('pointercancel', stopDragging);
|
|
87
|
+
document.body.style.cursor = '';
|
|
88
|
+
document.body.style.userSelect = '';
|
|
89
|
+
};
|
|
90
|
+
}, [updateFromClientX]);
|
|
91
|
+
|
|
92
|
+
const startDragging = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
93
|
+
if (!resizable) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
dragState.current = { pointerId: event.pointerId };
|
|
97
|
+
document.body.style.cursor = 'col-resize';
|
|
98
|
+
document.body.style.userSelect = 'none';
|
|
99
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resizeWithKeyboard = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
103
|
+
const step = event.shiftKey ? 10 : 5;
|
|
104
|
+
if (event.key === 'ArrowLeft') {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
setPrimarySize((size) => clampSize(size - step, minPrimarySize, maxPrimarySize));
|
|
107
|
+
}
|
|
108
|
+
if (event.key === 'ArrowRight') {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
setPrimarySize((size) => clampSize(size + step, minPrimarySize, maxPrimarySize));
|
|
111
|
+
}
|
|
112
|
+
if (event.key === 'Home') {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
setPrimarySize(minPrimarySize);
|
|
115
|
+
}
|
|
116
|
+
if (event.key === 'End') {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
setPrimarySize(maxPrimarySize);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const style = {
|
|
123
|
+
'--split-pane-primary-size': `${primarySize}%`,
|
|
124
|
+
} as CSSProperties;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={containerRef}
|
|
129
|
+
className={[styles.splitPane, className].filter(Boolean).join(' ')}
|
|
130
|
+
style={style}
|
|
131
|
+
>
|
|
132
|
+
<section className={styles.pane} aria-label={primaryLabel}>
|
|
133
|
+
{primary}
|
|
134
|
+
</section>
|
|
135
|
+
{resizable && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={styles.handle}
|
|
139
|
+
aria-label={`${resizeLabel}, primary pane ${Math.round(primarySize)} percent`}
|
|
140
|
+
onPointerDown={startDragging}
|
|
141
|
+
onKeyDown={resizeWithKeyboard}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
<section className={styles.pane} aria-label={secondaryLabel}>
|
|
145
|
+
{secondary}
|
|
146
|
+
</section>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { SplitPane } from '../SplitPane';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('SplitPane', () => {
|
|
9
|
+
it('renders labeled panes and a resize button', () => {
|
|
10
|
+
render(
|
|
11
|
+
<SplitPane
|
|
12
|
+
primary={<p>Editor</p>}
|
|
13
|
+
secondary={<p>Preview</p>}
|
|
14
|
+
primaryLabel="Editor pane"
|
|
15
|
+
secondaryLabel="Preview pane"
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(screen.getByRole('region', { name: 'Editor pane' })).toBeDefined();
|
|
20
|
+
expect(screen.getByRole('region', { name: 'Preview pane' })).toBeDefined();
|
|
21
|
+
expect(screen.getByRole('button', { name: /Resize panes/ })).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('omits the resize button when not resizable', () => {
|
|
25
|
+
render(<SplitPane primary={<p>List</p>} secondary={<p>Detail</p>} resizable={false} />);
|
|
26
|
+
|
|
27
|
+
expect(screen.queryByRole('button', { name: /Resize panes/ })).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('resizes with keyboard controls', () => {
|
|
31
|
+
render(
|
|
32
|
+
<SplitPane
|
|
33
|
+
primary={<p>Editor</p>}
|
|
34
|
+
secondary={<p>Preview</p>}
|
|
35
|
+
defaultPrimarySize={50}
|
|
36
|
+
minPrimarySize={30}
|
|
37
|
+
maxPrimarySize={70}
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const separator = screen.getByRole('button', { name: /Resize panes/ });
|
|
42
|
+
fireEvent.keyDown(separator, { key: 'ArrowRight' });
|
|
43
|
+
expect(separator.getAttribute('aria-label')).toContain('55 percent');
|
|
44
|
+
|
|
45
|
+
fireEvent.keyDown(separator, { key: 'Home' });
|
|
46
|
+
expect(separator.getAttribute('aria-label')).toContain('30 percent');
|
|
47
|
+
|
|
48
|
+
fireEvent.keyDown(separator, { key: 'End' });
|
|
49
|
+
expect(separator.getAttribute('aria-label')).toContain('70 percent');
|
|
50
|
+
});
|
|
51
|
+
});
|