@sovereignfs/ui 0.41.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/Marker.module.css +33 -0
  23. package/dist/Menu.module.css +8 -1
  24. package/dist/Message.module.css +99 -0
  25. package/dist/MessageScroller.module.css +54 -0
  26. package/dist/NavigationMenu.module.css +53 -0
  27. package/dist/Pagination.module.css +98 -0
  28. package/dist/Progress.module.css +20 -0
  29. package/dist/RadioGroup.module.css +88 -0
  30. package/dist/Resizable.module.css +70 -0
  31. package/dist/ScrollArea.module.css +20 -0
  32. package/dist/Select.module.css +7 -3
  33. package/dist/Slider.module.css +95 -0
  34. package/dist/Table.module.css +45 -0
  35. package/dist/Tabs.module.css +6 -1
  36. package/dist/Toggle.module.css +6 -1
  37. package/dist/Typography.module.css +47 -0
  38. package/dist/index.d.ts +617 -5
  39. package/dist/index.js +1847 -499
  40. package/dist/tokens/primitives.css +22 -8
  41. package/package.json +1 -1
  42. package/src/components/Accordion/Accordion.module.css +3 -0
  43. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  44. package/src/components/Accordion/Accordion.tsx +53 -0
  45. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  46. package/src/components/Alert/Alert.module.css +62 -0
  47. package/src/components/Alert/Alert.stories.tsx +96 -0
  48. package/src/components/Alert/Alert.tsx +58 -0
  49. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  50. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  51. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  52. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  53. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  54. package/src/components/Badge/Badge.module.css +75 -9
  55. package/src/components/Badge/Badge.stories.tsx +109 -1
  56. package/src/components/Badge/Badge.tsx +30 -2
  57. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  58. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  59. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  60. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  61. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  62. package/src/components/Button/Button.module.css +12 -3
  63. package/src/components/Button/Button.stories.tsx +3 -2
  64. package/src/components/Button/Button.tsx +1 -1
  65. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  66. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  67. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  68. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  69. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  70. package/src/components/Card/Card.module.css +6 -0
  71. package/src/components/Checkbox/Checkbox.module.css +3 -1
  72. package/src/components/Collapsible/Collapsible.module.css +70 -0
  73. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  74. package/src/components/Collapsible/Collapsible.tsx +66 -0
  75. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  76. package/src/components/Combobox/Combobox.module.css +122 -0
  77. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  78. package/src/components/Combobox/Combobox.tsx +186 -0
  79. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  80. package/src/components/Command/Command.module.css +80 -0
  81. package/src/components/Command/Command.stories.tsx +104 -0
  82. package/src/components/Command/Command.tsx +168 -0
  83. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  84. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  85. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  86. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  87. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  88. package/src/components/DataTable/DataTable.module.css +28 -0
  89. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  90. package/src/components/DataTable/DataTable.tsx +154 -0
  91. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  92. package/src/components/DatePicker/DatePicker.module.css +6 -2
  93. package/src/components/Dialog/Dialog.module.css +3 -0
  94. package/src/components/HoverCard/HoverCard.module.css +3 -0
  95. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  96. package/src/components/HoverCard/HoverCard.tsx +111 -0
  97. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  98. package/src/components/Icon/icons/circle-check.tsx +21 -0
  99. package/src/components/Icon/icons/circle-x.tsx +22 -0
  100. package/src/components/Icon/icons/index.ts +8 -0
  101. package/src/components/Input/Input.module.css +6 -1
  102. package/src/components/Item/Item.module.css +70 -0
  103. package/src/components/Item/Item.stories.tsx +62 -0
  104. package/src/components/Item/Item.tsx +54 -0
  105. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  106. package/src/components/Kbd/Kbd.module.css +14 -0
  107. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  108. package/src/components/Kbd/Kbd.tsx +12 -0
  109. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  110. package/src/components/Label/Label.module.css +11 -0
  111. package/src/components/Label/Label.stories.tsx +40 -0
  112. package/src/components/Label/Label.tsx +24 -0
  113. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  114. package/src/components/Marker/Marker.module.css +33 -0
  115. package/src/components/Marker/Marker.stories.tsx +39 -0
  116. package/src/components/Marker/Marker.tsx +34 -0
  117. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  118. package/src/components/Menu/Menu.module.css +8 -1
  119. package/src/components/Menu/Menu.tsx +30 -8
  120. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  121. package/src/components/Menubar/Menubar.tsx +42 -0
  122. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  123. package/src/components/Message/Message.module.css +99 -0
  124. package/src/components/Message/Message.stories.tsx +79 -0
  125. package/src/components/Message/Message.tsx +41 -0
  126. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  127. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  128. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  129. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  130. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  131. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  132. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  133. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  134. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  135. package/src/components/Pagination/Pagination.module.css +98 -0
  136. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  137. package/src/components/Pagination/Pagination.tsx +87 -0
  138. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  139. package/src/components/Progress/Progress.module.css +20 -0
  140. package/src/components/Progress/Progress.stories.tsx +40 -0
  141. package/src/components/Progress/Progress.tsx +35 -0
  142. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  143. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  144. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  145. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  146. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  147. package/src/components/Resizable/Resizable.module.css +70 -0
  148. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  149. package/src/components/Resizable/Resizable.tsx +313 -0
  150. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  151. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  152. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  153. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  154. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  155. package/src/components/Select/Select.module.css +7 -3
  156. package/src/components/Slider/Slider.module.css +95 -0
  157. package/src/components/Slider/Slider.stories.tsx +64 -0
  158. package/src/components/Slider/Slider.tsx +77 -0
  159. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  160. package/src/components/Table/Table.module.css +45 -0
  161. package/src/components/Table/Table.stories.tsx +125 -0
  162. package/src/components/Table/Table.tsx +51 -0
  163. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  164. package/src/components/Tabs/Tabs.module.css +6 -1
  165. package/src/components/Toggle/Toggle.module.css +6 -1
  166. package/src/components/Typography/Typography.module.css +47 -0
  167. package/src/components/Typography/Typography.stories.tsx +62 -0
  168. package/src/components/Typography/Typography.tsx +30 -0
  169. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  170. package/src/index.ts +78 -1
  171. package/src/stories/DesignSystemOverview.stories.tsx +614 -2
  172. package/src/stories/MobilePatterns.stories.tsx +8 -2
  173. package/src/stories/TokenGallery.stories.tsx +112 -16
  174. package/src/tokens/primitives.css +22 -8
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { Command, type CommandItem } from '../Command';
5
+
6
+ // Command renders inside Dialog, whose exit animation reads
7
+ // prefers-reduced-motion via matchMedia, which jsdom does not implement —
8
+ // see Dialog.test.tsx's identical setup.
9
+ function installMatchMedia() {
10
+ vi.stubGlobal(
11
+ 'matchMedia',
12
+ vi.fn().mockImplementation((query: string) => ({
13
+ matches: false,
14
+ media: query,
15
+ addEventListener: vi.fn(),
16
+ removeEventListener: vi.fn(),
17
+ addListener: vi.fn(),
18
+ removeListener: vi.fn(),
19
+ dispatchEvent: vi.fn(),
20
+ })),
21
+ );
22
+ }
23
+
24
+ beforeEach(installMatchMedia);
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ cleanup();
28
+ });
29
+
30
+ const items: CommandItem[] = [
31
+ { id: 'new', label: 'New conversation', onSelect: vi.fn(), group: 'Actions' },
32
+ {
33
+ id: 'export',
34
+ label: 'Export chat',
35
+ onSelect: vi.fn(),
36
+ group: 'Actions',
37
+ keywords: 'download save',
38
+ },
39
+ { id: 'profile', label: 'Go to profile', onSelect: vi.fn(), group: 'Navigation' },
40
+ ];
41
+
42
+ describe('Command', () => {
43
+ it('renders nothing when closed', () => {
44
+ render(<Command open={false} onClose={vi.fn()} items={items} />);
45
+ expect(screen.queryByRole('combobox')).toBeNull();
46
+ });
47
+
48
+ it('lists all items when the query is empty', () => {
49
+ render(<Command open onClose={vi.fn()} items={items} />);
50
+ expect(screen.getAllByRole('option')).toHaveLength(3);
51
+ });
52
+
53
+ it('filters by label', () => {
54
+ render(<Command open onClose={vi.fn()} items={items} />);
55
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
56
+ expect(screen.getAllByRole('option')).toHaveLength(1);
57
+ expect(screen.getByRole('option', { name: 'Go to profile' })).toBeDefined();
58
+ });
59
+
60
+ it('filters by keywords in addition to the label', () => {
61
+ render(<Command open onClose={vi.fn()} items={items} />);
62
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'download' } });
63
+ expect(screen.getAllByRole('option')).toHaveLength(1);
64
+ expect(screen.getByRole('option', { name: 'Export chat' })).toBeDefined();
65
+ });
66
+
67
+ it('shows an empty state when nothing matches', () => {
68
+ render(<Command open onClose={vi.fn()} items={items} />);
69
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'zzz' } });
70
+ expect(screen.queryAllByRole('option')).toHaveLength(0);
71
+ expect(screen.getByText('No matching commands.')).toBeDefined();
72
+ });
73
+
74
+ it('renders group headers', () => {
75
+ render(<Command open onClose={vi.fn()} items={items} />);
76
+ expect(screen.getByText('Actions')).toBeDefined();
77
+ expect(screen.getByText('Navigation')).toBeDefined();
78
+ });
79
+
80
+ it('highlights the first item by default', () => {
81
+ render(<Command open onClose={vi.fn()} items={items} />);
82
+ expect(
83
+ screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
84
+ ).toBe('true');
85
+ });
86
+
87
+ it('ArrowDown/ArrowUp move the highlighted item', () => {
88
+ render(<Command open onClose={vi.fn()} items={items} />);
89
+ const input = screen.getByRole('combobox');
90
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
91
+ expect(screen.getByRole('option', { name: 'Export chat' }).getAttribute('aria-selected')).toBe(
92
+ 'true',
93
+ );
94
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
95
+ expect(
96
+ screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
97
+ ).toBe('true');
98
+ });
99
+
100
+ it('ArrowDown does not move past the last item', () => {
101
+ render(<Command open onClose={vi.fn()} items={items} />);
102
+ const input = screen.getByRole('combobox');
103
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
104
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
105
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
106
+ expect(
107
+ screen.getByRole('option', { name: 'Go to profile' }).getAttribute('aria-selected'),
108
+ ).toBe('true');
109
+ });
110
+
111
+ it('resets the highlighted index when the query changes', () => {
112
+ render(<Command open onClose={vi.fn()} items={items} />);
113
+ const input = screen.getByRole('combobox');
114
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
115
+ fireEvent.change(input, { target: { value: 'e' } });
116
+ const [firstMatch] = screen.getAllByRole('option');
117
+ expect(firstMatch?.getAttribute('aria-selected')).toBe('true');
118
+ });
119
+
120
+ it('Enter selects the highlighted item and closes', () => {
121
+ const onClose = vi.fn();
122
+ const onSelect = vi.fn();
123
+ const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
124
+ render(<Command open onClose={onClose} items={localItems} />);
125
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
126
+ expect(onSelect).toHaveBeenCalled();
127
+ expect(onClose).toHaveBeenCalled();
128
+ });
129
+
130
+ it('clicking an item selects it and closes', () => {
131
+ const onClose = vi.fn();
132
+ const onSelect = vi.fn();
133
+ const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
134
+ render(<Command open onClose={onClose} items={localItems} />);
135
+ fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
136
+ expect(onSelect).toHaveBeenCalled();
137
+ expect(onClose).toHaveBeenCalled();
138
+ });
139
+
140
+ it('sets aria-activedescendant to the highlighted option', () => {
141
+ render(<Command open onClose={vi.fn()} items={items} />);
142
+ const input = screen.getByRole('combobox');
143
+ const firstOption = screen.getByRole('option', { name: 'New conversation' });
144
+ expect(input.getAttribute('aria-activedescendant')).toBe(firstOption.id);
145
+ });
146
+
147
+ it('resets the query each time it opens', () => {
148
+ const { rerender } = render(<Command open onClose={vi.fn()} items={items} />);
149
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
150
+ rerender(<Command open={false} onClose={vi.fn()} items={items} />);
151
+ rerender(<Command open onClose={vi.fn()} items={items} />);
152
+ expect(screen.getByRole('combobox').getAttribute('value')).toBe('');
153
+ });
154
+
155
+ it('focuses the input on open', async () => {
156
+ render(<Command open onClose={vi.fn()} items={items} />);
157
+ await act(async () => {
158
+ await new Promise((resolve) => requestAnimationFrame(resolve));
159
+ });
160
+ expect(document.activeElement).toBe(screen.getByRole('combobox'));
161
+ });
162
+ });
@@ -65,7 +65,11 @@
65
65
  align-items: center;
66
66
  justify-content: center;
67
67
  gap: var(--sv-space-2);
68
- padding: 13px var(--sv-space-4);
68
+ /* Kept in sync with Button's .md rule by hand (see design-system.md
69
+ "Button and Checkbox touch ergonomics" — not a literal reuse of
70
+ Button). Matches Button's md padding, updated alongside it (10px,
71
+ 36px rendered height). */
72
+ padding: 10px var(--sv-space-4);
69
73
  border: 1px solid transparent;
70
74
  border-radius: var(--sv-radius-md);
71
75
  font-family: var(--sv-font-family);
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ContextMenu } from './ContextMenu';
3
+
4
+ const meta = {
5
+ title: 'Components/ContextMenu',
6
+ component: ContextMenu,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Right-click menu on desktop, long-press on touch. Desktop positioning reuses Popover's existing collision detection anchored to the click point; touch opens the same items in a Drawer bottom sheet — a floating panel at a touch point isn't a pattern touch interfaces have.",
13
+ },
14
+ },
15
+ },
16
+ args: {
17
+ children: <span />,
18
+ items: [
19
+ { label: 'Rename', onSelect: () => {} },
20
+ { label: 'Duplicate', onSelect: () => {} },
21
+ { type: 'separator' },
22
+ { label: 'Delete', destructive: true, onSelect: () => {} },
23
+ ],
24
+ 'aria-label': 'Row actions',
25
+ },
26
+ } satisfies Meta<typeof ContextMenu>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const Default: Story = {
34
+ render: (args) => (
35
+ <ContextMenu {...args}>
36
+ <div
37
+ style={{
38
+ width: 240,
39
+ padding: 'var(--sv-space-4)',
40
+ border: '1px dashed var(--sv-color-border-strong)',
41
+ borderRadius: 'var(--sv-radius-md)',
42
+ textAlign: 'center',
43
+ fontSize: 14,
44
+ color: 'var(--sv-color-text-muted)',
45
+ }}
46
+ >
47
+ Right-click here (or long-press on touch)
48
+ </div>
49
+ </ContextMenu>
50
+ ),
51
+ };
52
+
53
+ export const WithIconsAndChecked: Story = {
54
+ args: {
55
+ items: [
56
+ { type: 'label', label: 'Sort by' },
57
+ { label: 'Name', checked: true, onSelect: () => {} },
58
+ { label: 'Date modified', checked: false, onSelect: () => {} },
59
+ { type: 'separator' },
60
+ { label: 'Rename', icon: 'pencil', onSelect: () => {} },
61
+ { label: 'Delete', icon: 'trash-2', destructive: true, onSelect: () => {} },
62
+ ],
63
+ },
64
+ render: (args) => (
65
+ <ContextMenu {...args}>
66
+ <div
67
+ style={{
68
+ width: 240,
69
+ padding: 'var(--sv-space-4)',
70
+ border: '1px dashed var(--sv-color-border-strong)',
71
+ borderRadius: 'var(--sv-radius-md)',
72
+ textAlign: 'center',
73
+ fontSize: 14,
74
+ color: 'var(--sv-color-text-muted)',
75
+ }}
76
+ >
77
+ Right-click here
78
+ </div>
79
+ </ContextMenu>
80
+ ),
81
+ };
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { cloneElement, useState } from 'react';
4
+ import type { ReactElement } from 'react';
5
+ import type { MouseEvent as ReactMouseEvent } from 'react';
6
+ import { useIsMobile, useLongPress } from '../../hooks';
7
+ import { Popover } from '../Popover/Popover';
8
+ import { Drawer } from '../Drawer/Drawer';
9
+ import { MenuEntries, type MenuEntry, type MenuItem } from '../Menu/Menu';
10
+
11
+ export interface ContextMenuProps {
12
+ /** The element that gets right-click (desktop) / long-press (touch)
13
+ * behavior — cloned with the trigger handlers, rendered as-is otherwise.
14
+ * Typed with `<any>` props (not a bare `ReactElement`, whose implicit
15
+ * `unknown` props type makes `cloneElement` reject *both* branches of
16
+ * the long-press/onContextMenu union below — an interface value like
17
+ * `LongPressHandlers` isn't assignable to an indexed props type without
18
+ * its own index signature) — the standard, narrowly-scoped escape hatch
19
+ * for "clone an arbitrary child with props not known ahead of time". */
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
21
+ children: ReactElement<any>;
22
+ items: MenuEntry[];
23
+ 'aria-label': string;
24
+ }
25
+
26
+ /**
27
+ * ContextMenu — right-click menu on desktop, long-press on touch.
28
+ *
29
+ * Desktop positioning reuses `Popover`'s existing collision-detection logic
30
+ * rather than reimplementing it: the panel is wrapped in a zero-size,
31
+ * `position: fixed` anchor placed at the click coordinates, so `Popover`
32
+ * measures and flips against *that* point exactly as it would a normal
33
+ * trigger element.
34
+ *
35
+ * Touch uses `useLongPress` (which already handles the OS's own long-press
36
+ * callout/context-menu suppression) and opens the same items in a `Drawer`
37
+ * bottom sheet — the same adaptive-surface split `Menu` already uses,
38
+ * because a floating panel positioned at a touch point isn't a pattern
39
+ * touch interfaces have in the first place.
40
+ */
41
+ export function ContextMenu({ children, items, 'aria-label': ariaLabel }: ContextMenuProps) {
42
+ const isMobile = useIsMobile();
43
+ const [open, setOpen] = useState(false);
44
+ const [point, setPoint] = useState({ x: 0, y: 0 });
45
+
46
+ const longPressHandlers = useLongPress({
47
+ onLongPress: () => setOpen(true),
48
+ disabled: !isMobile,
49
+ });
50
+
51
+ function handleContextMenu(e: ReactMouseEvent) {
52
+ e.preventDefault();
53
+ setPoint({ x: e.clientX, y: e.clientY });
54
+ setOpen(true);
55
+ }
56
+
57
+ const wrappedChild = cloneElement(
58
+ children,
59
+ isMobile ? longPressHandlers : { onContextMenu: handleContextMenu },
60
+ );
61
+
62
+ const list = (
63
+ <MenuEntries
64
+ items={items}
65
+ onSelect={(entry: MenuItem) => {
66
+ setOpen(false);
67
+ entry.onSelect();
68
+ }}
69
+ />
70
+ );
71
+
72
+ if (isMobile) {
73
+ return (
74
+ <>
75
+ {wrappedChild}
76
+ <Drawer open={open} onClose={() => setOpen(false)} aria-label={ariaLabel}>
77
+ {list}
78
+ </Drawer>
79
+ </>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <>
85
+ {wrappedChild}
86
+ {open && (
87
+ <div style={{ position: 'fixed', left: point.x, top: point.y, width: 0, height: 0 }}>
88
+ <Popover
89
+ trigger={<span />}
90
+ open={open}
91
+ onClose={() => setOpen(false)}
92
+ aria-label={ariaLabel}
93
+ >
94
+ {list}
95
+ </Popover>
96
+ </div>
97
+ )}
98
+ </>
99
+ );
100
+ }
@@ -0,0 +1,87 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { ContextMenu } from '../ContextMenu';
5
+
6
+ let isMobileMatches = false;
7
+
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches: isMobileMatches,
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
+ const items = [{ label: 'Delete', onSelect: vi.fn() }];
24
+
25
+ describe('ContextMenu', () => {
26
+ beforeEach(() => {
27
+ isMobileMatches = false;
28
+ installMatchMedia();
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllGlobals();
33
+ cleanup();
34
+ });
35
+
36
+ it('does not show the menu before any trigger', () => {
37
+ render(
38
+ <ContextMenu items={items} aria-label="Row actions">
39
+ <div>Row</div>
40
+ </ContextMenu>,
41
+ );
42
+ expect(screen.queryByRole('menu')).toBeNull();
43
+ });
44
+
45
+ it('opens on right-click (desktop) and prevents the native context menu', () => {
46
+ render(
47
+ <ContextMenu items={items} aria-label="Row actions">
48
+ <div>Row</div>
49
+ </ContextMenu>,
50
+ );
51
+ const target = screen.getByText('Row');
52
+ const event = fireEvent.contextMenu(target, { clientX: 100, clientY: 150 });
53
+ expect(event).toBe(false); // fireEvent returns false when preventDefault was called
54
+ expect(screen.getByRole('menu')).toBeDefined();
55
+ });
56
+
57
+ it('selecting an item calls onSelect and closes the menu', () => {
58
+ const onSelect = vi.fn();
59
+ render(
60
+ <ContextMenu items={[{ label: 'Delete', onSelect }]} aria-label="Row actions">
61
+ <div>Row</div>
62
+ </ContextMenu>,
63
+ );
64
+ fireEvent.contextMenu(screen.getByText('Row'), { clientX: 10, clientY: 10 });
65
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
66
+ expect(onSelect).toHaveBeenCalled();
67
+ expect(screen.queryByRole('menu')).toBeNull();
68
+ });
69
+
70
+ it('opens via long-press on touch instead of right-click', () => {
71
+ vi.useFakeTimers();
72
+ isMobileMatches = true;
73
+ installMatchMedia();
74
+ render(
75
+ <ContextMenu items={items} aria-label="Row actions">
76
+ <div>Row</div>
77
+ </ContextMenu>,
78
+ );
79
+ const target = screen.getByText('Row');
80
+ fireEvent.pointerDown(target, { pointerType: 'touch', clientX: 10, clientY: 10 });
81
+ act(() => {
82
+ vi.advanceTimersByTime(600);
83
+ });
84
+ expect(screen.getByRole('menu')).toBeDefined();
85
+ vi.useRealTimers();
86
+ });
87
+ });
@@ -0,0 +1,28 @@
1
+ .sortButton {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-1);
5
+ border: none;
6
+ background: transparent;
7
+ padding: 0;
8
+ margin: 0;
9
+ color: inherit;
10
+ font: inherit;
11
+ font-weight: inherit;
12
+ cursor: pointer;
13
+ }
14
+
15
+ .sortButton:focus-visible {
16
+ outline: 2px solid var(--sv-color-focus-ring);
17
+ outline-offset: 2px;
18
+ border-radius: var(--sv-radius-sm);
19
+ }
20
+
21
+ .sortIcon {
22
+ display: inline-flex;
23
+ flex-shrink: 0;
24
+ width: var(--sv-icon-size-sm);
25
+ height: var(--sv-icon-size-sm);
26
+ align-items: center;
27
+ justify-content: center;
28
+ }
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { DataTable, type DataTableColumn } from './DataTable';
3
+ import { StatusBadge } from '../StatusBadge/StatusBadge';
4
+
5
+ interface PluginRow {
6
+ id: string;
7
+ name: string;
8
+ installs: number;
9
+ status: 'synced' | 'draft';
10
+ }
11
+
12
+ const ROWS: PluginRow[] = [
13
+ { id: 'tasks', name: 'sovereign-tasks', installs: 482, status: 'synced' },
14
+ { id: 'ledger', name: 'sovereign-ledger', installs: 219, status: 'synced' },
15
+ { id: 'shopper', name: 'sovereign-shopper', installs: 37, status: 'draft' },
16
+ { id: 'healthlog', name: 'sovereign-healthlog', installs: 1204, status: 'synced' },
17
+ ];
18
+
19
+ const columns: DataTableColumn<PluginRow>[] = [
20
+ { key: 'name', header: 'Plugin', sortable: true },
21
+ { key: 'installs', header: 'Installs', sortable: true, align: 'end' },
22
+ {
23
+ key: 'status',
24
+ header: 'Status',
25
+ render: (row) => <StatusBadge status={row.status} />,
26
+ },
27
+ ];
28
+
29
+ const meta = {
30
+ title: 'Components/DataTable',
31
+ component: DataTable,
32
+ parameters: {
33
+ layout: 'padded',
34
+ docs: {
35
+ description: {
36
+ component:
37
+ 'Sortable table built on Table. Column-driven: pass columns + data instead of composing TableRow/TableCell by hand. Clicking a sortable header cycles ascending → descending → unsorted.',
38
+ },
39
+ },
40
+ },
41
+ args: { columns: [], data: [], getRowKey: (row: PluginRow) => row.id },
42
+ } satisfies Meta<typeof DataTable<PluginRow>>;
43
+
44
+ export default meta;
45
+ type Story = StoryObj<typeof meta>;
46
+
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export const Default: Story = {
50
+ render: () => <DataTable columns={columns} data={ROWS} getRowKey={(row) => row.id} />,
51
+ };
52
+
53
+ export const DefaultSorted: Story = {
54
+ render: () => (
55
+ <DataTable
56
+ columns={columns}
57
+ data={ROWS}
58
+ getRowKey={(row) => row.id}
59
+ defaultSortKey="installs"
60
+ defaultSortDirection="desc"
61
+ />
62
+ ),
63
+ };
64
+
65
+ export const Empty: Story = {
66
+ render: () => (
67
+ <DataTable
68
+ columns={columns}
69
+ data={[]}
70
+ getRowKey={(row) => row.id}
71
+ emptyMessage="No plugins installed"
72
+ />
73
+ ),
74
+ };