@sovereignfs/ui 0.23.0 → 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 +14 -2
- 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 +9 -3
- package/dist/Drawer.module.css +61 -0
- package/dist/Icon.module.css +5 -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/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/Toast.module.css +4 -2
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +493 -17
- package/dist/index.js +1390 -209
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +13 -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 +14 -2
- package/src/components/Checkbox/Checkbox.tsx +22 -12
- 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 +9 -3
- 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/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/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/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/Toast/Toast.module.css +4 -2
- 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 +30 -1
- package/src/motion.ts +78 -0
- package/src/stories/DesignSystemOverview.stories.tsx +292 -4
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/TokenGallery.stories.tsx +85 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +13 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
max-width: 100%;
|
|
5
|
+
min-height: 24px;
|
|
6
|
+
gap: var(--sv-space-1);
|
|
7
|
+
padding: 2px var(--sv-space-2);
|
|
8
|
+
border: 1px solid var(--sv-color-border);
|
|
9
|
+
border-radius: var(--sv-radius-full);
|
|
10
|
+
font-family: var(--sv-font-family);
|
|
11
|
+
font-size: var(--sv-font-size-xs);
|
|
12
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
13
|
+
line-height: 1.2;
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dot {
|
|
18
|
+
width: 7px;
|
|
19
|
+
height: 7px;
|
|
20
|
+
flex: 0 0 auto;
|
|
21
|
+
border-radius: var(--sv-radius-full);
|
|
22
|
+
background: currentColor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.label {
|
|
26
|
+
min-width: 0;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
text-overflow: ellipsis;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.neutral {
|
|
32
|
+
color: var(--sv-color-text-muted);
|
|
33
|
+
background: var(--sv-color-surface-sunken);
|
|
34
|
+
border-color: var(--sv-color-border);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.info {
|
|
38
|
+
color: var(--sv-color-info-text);
|
|
39
|
+
background: var(--sv-color-info-surface);
|
|
40
|
+
border-color: var(--sv-color-info-border);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.success {
|
|
44
|
+
color: var(--sv-color-success-text);
|
|
45
|
+
background: var(--sv-color-success-surface);
|
|
46
|
+
border-color: var(--sv-color-success-border);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.warning {
|
|
50
|
+
color: var(--sv-color-warning-text);
|
|
51
|
+
background: var(--sv-color-warning-surface);
|
|
52
|
+
border-color: var(--sv-color-warning-border);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.error {
|
|
56
|
+
color: var(--sv-color-error-text);
|
|
57
|
+
background: var(--sv-color-error-surface);
|
|
58
|
+
border-color: var(--sv-color-error-border);
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { StatusBadge } from './StatusBadge';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/StatusBadge',
|
|
6
|
+
component: StatusBadge,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Compact inline status indicator for draft, sync, conflict, and delete-pending editor workflows.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
status: 'draft',
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof StatusBadge>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)' }}>
|
|
27
|
+
<StatusBadge status="unmodified" />
|
|
28
|
+
<StatusBadge status="draft" />
|
|
29
|
+
<StatusBadge status="committed" />
|
|
30
|
+
<StatusBadge status="synced" />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ErrorStates: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)' }}>
|
|
38
|
+
<StatusBadge status="conflict" />
|
|
39
|
+
<StatusBadge status="pending-delete" />
|
|
40
|
+
<StatusBadge status="warning" />
|
|
41
|
+
<StatusBadge status="error" />
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const AccessibleLabel: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<StatusBadge status="draft" aria-label="Draft with unpublished changes">
|
|
49
|
+
D
|
|
50
|
+
</StatusBadge>
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const LongContent: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<div style={{ width: 180 }}>
|
|
57
|
+
<StatusBadge status="warning">Preview requires frontmatter migration</StatusBadge>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const MobileWrap: Story = {
|
|
63
|
+
parameters: {
|
|
64
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
65
|
+
},
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)', maxWidth: 320 }}>
|
|
68
|
+
<StatusBadge status="draft" />
|
|
69
|
+
<StatusBadge status="synced" />
|
|
70
|
+
<StatusBadge status="conflict" />
|
|
71
|
+
<StatusBadge status="pending-delete" />
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './StatusBadge.module.css';
|
|
3
|
+
|
|
4
|
+
export type StatusBadgeStatus =
|
|
5
|
+
| 'unmodified'
|
|
6
|
+
| 'draft'
|
|
7
|
+
| 'committed'
|
|
8
|
+
| 'conflict'
|
|
9
|
+
| 'pending-delete'
|
|
10
|
+
| 'synced'
|
|
11
|
+
| 'warning'
|
|
12
|
+
| 'error';
|
|
13
|
+
|
|
14
|
+
export interface StatusBadgeProps {
|
|
15
|
+
status: StatusBadgeStatus;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
/** Accessible label when the visible text is abbreviated or contextual. */
|
|
18
|
+
'aria-label'?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const STATUS_CLASS: Record<StatusBadgeStatus, string> = {
|
|
23
|
+
unmodified: styles.neutral as string,
|
|
24
|
+
draft: styles.info as string,
|
|
25
|
+
committed: styles.success as string,
|
|
26
|
+
conflict: styles.error as string,
|
|
27
|
+
'pending-delete': styles.warning as string,
|
|
28
|
+
synced: styles.success as string,
|
|
29
|
+
warning: styles.warning as string,
|
|
30
|
+
error: styles.error as string,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STATUS_LABEL: Record<StatusBadgeStatus, string> = {
|
|
34
|
+
unmodified: 'Unmodified',
|
|
35
|
+
draft: 'Draft',
|
|
36
|
+
committed: 'Committed',
|
|
37
|
+
conflict: 'Conflict',
|
|
38
|
+
'pending-delete': 'Pending delete',
|
|
39
|
+
synced: 'Synced',
|
|
40
|
+
warning: 'Warning',
|
|
41
|
+
error: 'Error',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* StatusBadge — compact inline status indicator for editor lists, draft
|
|
46
|
+
* workflows, and sync states.
|
|
47
|
+
*/
|
|
48
|
+
export function StatusBadge({
|
|
49
|
+
status,
|
|
50
|
+
children,
|
|
51
|
+
className,
|
|
52
|
+
'aria-label': ariaLabel,
|
|
53
|
+
}: StatusBadgeProps) {
|
|
54
|
+
const label = children ?? STATUS_LABEL[status];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
className={[styles.badge, STATUS_CLASS[status], className].filter(Boolean).join(' ')}
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
>
|
|
61
|
+
<span className={styles.dot} aria-hidden="true" />
|
|
62
|
+
<span className={styles.label}>{label}</span>
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
}
|