@sovereignfs/ui 0.23.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +14 -2
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +77 -50
  9. package/dist/DragHandleRow.module.css +9 -3
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/Icon.module.css +5 -0
  12. package/dist/Menu.module.css +82 -0
  13. package/dist/NavTabs.module.css +4 -2
  14. package/dist/OverlayHeader.module.css +98 -0
  15. package/dist/Popover.module.css +31 -1
  16. package/dist/SegmentedControl.module.css +4 -2
  17. package/dist/Sheet.module.css +55 -0
  18. package/dist/SplitPane.module.css +85 -0
  19. package/dist/StatusBadge.module.css +59 -0
  20. package/dist/SystemBanner.module.css +4 -2
  21. package/dist/Tabs.module.css +4 -2
  22. package/dist/TagInput.module.css +141 -0
  23. package/dist/Toast.module.css +4 -2
  24. package/dist/Tooltip.module.css +6 -1
  25. package/dist/index.d.ts +493 -17
  26. package/dist/index.js +1390 -209
  27. package/dist/tokens/primitives.css +19 -0
  28. package/dist/tokens/semantic.css +13 -0
  29. package/package.json +1 -1
  30. package/src/__tests__/motion.test.tsx +105 -0
  31. package/src/components/Button/Button.module.css +75 -8
  32. package/src/components/Button/Button.stories.tsx +6 -0
  33. package/src/components/Button/Button.tsx +17 -1
  34. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  35. package/src/components/Calendar/Calendar.module.css +126 -0
  36. package/src/components/Calendar/Calendar.tsx +213 -0
  37. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  38. package/src/components/Calendar/dateUtils.ts +97 -0
  39. package/src/components/Card/Card.module.css +4 -2
  40. package/src/components/Checkbox/Checkbox.module.css +14 -2
  41. package/src/components/Checkbox/Checkbox.tsx +22 -12
  42. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  43. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  44. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  45. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  46. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  47. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  48. package/src/components/DatePicker/DatePicker.module.css +57 -0
  49. package/src/components/DatePicker/DatePicker.tsx +103 -0
  50. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  51. package/src/components/Dialog/Dialog.module.css +77 -50
  52. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  53. package/src/components/Dialog/Dialog.tsx +86 -22
  54. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  55. package/src/components/DragHandleRow/DragHandleRow.module.css +9 -3
  56. package/src/components/Drawer/Drawer.module.css +61 -0
  57. package/src/components/Drawer/Drawer.tsx +117 -13
  58. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  59. package/src/components/Icon/Icon.module.css +5 -0
  60. package/src/components/Icon/Icon.stories.tsx +4 -3
  61. package/src/components/Icon/Icon.tsx +1 -1
  62. package/src/components/Icon/icons/calendar.tsx +23 -0
  63. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  64. package/src/components/Icon/icons/index.ts +8 -4
  65. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  66. package/src/components/Menu/Menu.module.css +82 -0
  67. package/src/components/Menu/Menu.tsx +137 -0
  68. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  69. package/src/components/NavTabs/NavTabs.module.css +4 -2
  70. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  71. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  72. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  73. package/src/components/Popover/Popover.module.css +31 -1
  74. package/src/components/Popover/Popover.stories.tsx +126 -1
  75. package/src/components/Popover/Popover.tsx +140 -7
  76. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  77. package/src/components/Sheet/Sheet.module.css +55 -0
  78. package/src/components/Sheet/Sheet.tsx +151 -0
  79. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  80. package/src/components/SplitPane/SplitPane.module.css +85 -0
  81. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  82. package/src/components/SplitPane/SplitPane.tsx +149 -0
  83. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  84. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  85. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  86. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  87. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  88. package/src/components/Tabs/Tabs.module.css +4 -2
  89. package/src/components/TagInput/TagInput.module.css +141 -0
  90. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  91. package/src/components/TagInput/TagInput.tsx +174 -0
  92. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  93. package/src/components/Toast/Toast.module.css +4 -2
  94. package/src/components/Tooltip/Tooltip.module.css +6 -1
  95. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  96. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  97. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  98. package/src/hooks/index.ts +4 -0
  99. package/src/hooks/useDoubleTap.ts +91 -0
  100. package/src/hooks/useIsMobile.ts +33 -0
  101. package/src/hooks/useLongPress.ts +185 -0
  102. package/src/index.ts +30 -1
  103. package/src/motion.ts +78 -0
  104. package/src/stories/DesignSystemOverview.stories.tsx +292 -4
  105. package/src/stories/InteractionHooks.stories.tsx +388 -0
  106. package/src/stories/TokenGallery.stories.tsx +85 -1
  107. package/src/tokens/primitives.css +19 -0
  108. package/src/tokens/semantic.css +13 -0
  109. 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
+ }
@@ -38,8 +38,10 @@
38
38
  transition: opacity 0.12s ease;
39
39
  }
40
40
 
41
- .dismiss:hover {
42
- opacity: 1;
41
+ @media (hover: hover) {
42
+ .dismiss:hover {
43
+ opacity: 1;
44
+ }
43
45
  }
44
46
 
45
47
  .dismiss:focus-visible {
@@ -44,6 +44,8 @@
44
44
  font-weight: var(--sv-font-weight-medium);
45
45
  }
46
46
 
47
- .inactive:hover {
48
- color: var(--sv-color-text-primary);
47
+ @media (hover: hover) {
48
+ .inactive:hover {
49
+ color: var(--sv-color-text-primary);
50
+ }
49
51
  }