@sovereignfs/ui 0.40.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 (186) 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/BalanceChip.module.css +32 -0
  6. package/dist/Breadcrumb.module.css +49 -0
  7. package/dist/Button.module.css +12 -3
  8. package/dist/ButtonGroup.module.css +29 -0
  9. package/dist/Card.module.css +6 -0
  10. package/dist/Checkbox.module.css +3 -1
  11. package/dist/Collapsible.module.css +70 -0
  12. package/dist/Combobox.module.css +122 -0
  13. package/dist/Command.module.css +80 -0
  14. package/dist/ConfirmDialog.module.css +5 -1
  15. package/dist/DataTable.module.css +28 -0
  16. package/dist/DatePicker.module.css +6 -2
  17. package/dist/Dialog.module.css +3 -0
  18. package/dist/HoverCard.module.css +3 -0
  19. package/dist/Input.module.css +6 -1
  20. package/dist/Item.module.css +70 -0
  21. package/dist/Kbd.module.css +14 -0
  22. package/dist/Label.module.css +11 -0
  23. package/dist/Marker.module.css +33 -0
  24. package/dist/MemberMultiSelect.module.css +27 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/NavigationMenu.module.css +53 -0
  29. package/dist/Pagination.module.css +98 -0
  30. package/dist/Progress.module.css +20 -0
  31. package/dist/RadioGroup.module.css +88 -0
  32. package/dist/Resizable.module.css +70 -0
  33. package/dist/ScrollArea.module.css +20 -0
  34. package/dist/Select.module.css +7 -3
  35. package/dist/Slider.module.css +95 -0
  36. package/dist/Table.module.css +45 -0
  37. package/dist/Tabs.module.css +6 -1
  38. package/dist/Toggle.module.css +6 -1
  39. package/dist/Typography.module.css +47 -0
  40. package/dist/index.d.ts +698 -5
  41. package/dist/index.js +1938 -482
  42. package/dist/tokens/primitives.css +22 -8
  43. package/package.json +1 -1
  44. package/src/components/Accordion/Accordion.module.css +3 -0
  45. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  46. package/src/components/Accordion/Accordion.tsx +53 -0
  47. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  48. package/src/components/Alert/Alert.module.css +62 -0
  49. package/src/components/Alert/Alert.stories.tsx +96 -0
  50. package/src/components/Alert/Alert.tsx +58 -0
  51. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  52. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  53. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  54. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  55. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  56. package/src/components/Badge/Badge.module.css +75 -9
  57. package/src/components/Badge/Badge.stories.tsx +109 -1
  58. package/src/components/Badge/Badge.tsx +30 -2
  59. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  60. package/src/components/BalanceChip/BalanceChip.module.css +32 -0
  61. package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
  62. package/src/components/BalanceChip/BalanceChip.tsx +33 -0
  63. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  64. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  65. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  66. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  67. package/src/components/Button/Button.module.css +12 -3
  68. package/src/components/Button/Button.stories.tsx +3 -2
  69. package/src/components/Button/Button.tsx +1 -1
  70. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  71. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  72. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  73. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  74. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  75. package/src/components/Card/Card.module.css +6 -0
  76. package/src/components/Checkbox/Checkbox.module.css +3 -1
  77. package/src/components/Collapsible/Collapsible.module.css +70 -0
  78. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  79. package/src/components/Collapsible/Collapsible.tsx +66 -0
  80. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  81. package/src/components/Combobox/Combobox.module.css +122 -0
  82. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  83. package/src/components/Combobox/Combobox.tsx +186 -0
  84. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  85. package/src/components/Command/Command.module.css +80 -0
  86. package/src/components/Command/Command.stories.tsx +104 -0
  87. package/src/components/Command/Command.tsx +168 -0
  88. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  89. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  90. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  91. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  92. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  93. package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
  94. package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
  95. package/src/components/DataTable/DataTable.module.css +28 -0
  96. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  97. package/src/components/DataTable/DataTable.tsx +154 -0
  98. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  99. package/src/components/DatePicker/DatePicker.module.css +6 -2
  100. package/src/components/Dialog/Dialog.module.css +3 -0
  101. package/src/components/HoverCard/HoverCard.module.css +3 -0
  102. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  103. package/src/components/HoverCard/HoverCard.tsx +111 -0
  104. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  105. package/src/components/Icon/icons/circle-check.tsx +21 -0
  106. package/src/components/Icon/icons/circle-x.tsx +22 -0
  107. package/src/components/Icon/icons/index.ts +8 -0
  108. package/src/components/Input/Input.module.css +6 -1
  109. package/src/components/Item/Item.module.css +70 -0
  110. package/src/components/Item/Item.stories.tsx +62 -0
  111. package/src/components/Item/Item.tsx +54 -0
  112. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  113. package/src/components/Kbd/Kbd.module.css +14 -0
  114. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  115. package/src/components/Kbd/Kbd.tsx +12 -0
  116. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  117. package/src/components/Label/Label.module.css +11 -0
  118. package/src/components/Label/Label.stories.tsx +40 -0
  119. package/src/components/Label/Label.tsx +24 -0
  120. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  121. package/src/components/Marker/Marker.module.css +33 -0
  122. package/src/components/Marker/Marker.stories.tsx +39 -0
  123. package/src/components/Marker/Marker.tsx +34 -0
  124. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  125. package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
  126. package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
  127. package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
  128. package/src/components/Menu/Menu.module.css +8 -1
  129. package/src/components/Menu/Menu.tsx +30 -8
  130. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  131. package/src/components/Menubar/Menubar.tsx +42 -0
  132. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  133. package/src/components/Message/Message.module.css +99 -0
  134. package/src/components/Message/Message.stories.tsx +79 -0
  135. package/src/components/Message/Message.tsx +41 -0
  136. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  137. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  138. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  139. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  140. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  141. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  142. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  143. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  144. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  145. package/src/components/Pagination/Pagination.module.css +98 -0
  146. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  147. package/src/components/Pagination/Pagination.tsx +87 -0
  148. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  149. package/src/components/Progress/Progress.module.css +20 -0
  150. package/src/components/Progress/Progress.stories.tsx +40 -0
  151. package/src/components/Progress/Progress.tsx +35 -0
  152. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  153. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  154. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  155. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  156. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  157. package/src/components/Resizable/Resizable.module.css +70 -0
  158. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  159. package/src/components/Resizable/Resizable.tsx +313 -0
  160. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  161. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  162. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  163. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  164. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  165. package/src/components/Select/Select.module.css +7 -3
  166. package/src/components/Slider/Slider.module.css +95 -0
  167. package/src/components/Slider/Slider.stories.tsx +64 -0
  168. package/src/components/Slider/Slider.tsx +77 -0
  169. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  170. package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
  171. package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
  172. package/src/components/Table/Table.module.css +45 -0
  173. package/src/components/Table/Table.stories.tsx +125 -0
  174. package/src/components/Table/Table.tsx +51 -0
  175. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  176. package/src/components/Tabs/Tabs.module.css +6 -1
  177. package/src/components/Toggle/Toggle.module.css +6 -1
  178. package/src/components/Typography/Typography.module.css +47 -0
  179. package/src/components/Typography/Typography.stories.tsx +62 -0
  180. package/src/components/Typography/Typography.tsx +30 -0
  181. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  182. package/src/index.ts +92 -1
  183. package/src/stories/DesignSystemOverview.stories.tsx +706 -2
  184. package/src/stories/MobilePatterns.stories.tsx +8 -2
  185. package/src/stories/TokenGallery.stories.tsx +112 -16
  186. package/src/tokens/primitives.css +22 -8
@@ -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,50 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { CurrencyInput } from './CurrencyInput';
4
+
5
+ const meta = {
6
+ title: 'Components/CurrencyInput',
7
+ component: CurrencyInput,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Decimal amount entry that reports its value as integer cents, matching the "amounts are always smallest-unit integers" data-model convention. Preserves in-progress typing (e.g. a trailing decimal point) instead of reformatting on every keystroke.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ valueCents: null,
19
+ onValueChange: () => {},
20
+ },
21
+ } satisfies Meta<typeof CurrencyInput>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ function ControlledCurrencyInput({ initialCents }: { initialCents: number | null }) {
27
+ const [cents, setCents] = useState<number | null>(initialCents);
28
+ return (
29
+ <CurrencyInput
30
+ valueCents={cents}
31
+ onValueChange={setCents}
32
+ placeholder="0.00"
33
+ aria-label="Amount"
34
+ />
35
+ );
36
+ }
37
+
38
+ export const Empty: Story = {
39
+ render: () => <ControlledCurrencyInput initialCents={null} />,
40
+ };
41
+
42
+ export const Prefilled: Story = {
43
+ render: () => <ControlledCurrencyInput initialCents={4250} />,
44
+ };
45
+
46
+ export const Disabled: Story = {
47
+ render: () => (
48
+ <CurrencyInput valueCents={1000} onValueChange={() => {}} aria-label="Amount" disabled />
49
+ ),
50
+ };
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Input, type InputProps } from '../Input/Input';
5
+
6
+ export interface CurrencyInputProps extends Omit<
7
+ InputProps,
8
+ 'value' | 'onChange' | 'type' | 'inputMode'
9
+ > {
10
+ /** Smallest currency unit (cents) — never a float. Null when the field is empty or unparsable. */
11
+ valueCents: number | null;
12
+ onValueChange: (cents: number | null) => void;
13
+ }
14
+
15
+ function centsToText(cents: number): string {
16
+ return (cents / 100).toFixed(2);
17
+ }
18
+
19
+ function textToCents(text: string): number | null {
20
+ const trimmed = text.trim();
21
+ if (!trimmed) return null;
22
+ const parsed = Number(trimmed);
23
+ if (!Number.isFinite(parsed)) return null;
24
+ return Math.round(parsed * 100);
25
+ }
26
+
27
+ /**
28
+ * CurrencyInput — decimal amount entry that reports its value as integer
29
+ * cents, matching Sovereign's "amounts are always smallest-unit integers"
30
+ * data-model convention (never a float in application state).
31
+ *
32
+ * Keeps its own text buffer rather than deriving display text from
33
+ * `valueCents` on every keystroke — reformatting mid-type (e.g. "12." → the
34
+ * trailing decimal point) would fight the user's cursor. It only
35
+ * re-syncs from `valueCents` when the prop changes to something the current
36
+ * text doesn't already represent (an external reset/prefill), so typing
37
+ * remains uninterrupted while the parsed amount stays in lockstep with the
38
+ * parent's state.
39
+ */
40
+ export function CurrencyInput({ valueCents, onValueChange, ...rest }: CurrencyInputProps) {
41
+ const [text, setText] = useState(() => (valueCents != null ? centsToText(valueCents) : ''));
42
+
43
+ if (textToCents(text) !== valueCents) {
44
+ setText(valueCents != null ? centsToText(valueCents) : '');
45
+ }
46
+
47
+ return (
48
+ <Input
49
+ {...rest}
50
+ type="text"
51
+ inputMode="decimal"
52
+ value={text}
53
+ onChange={(e) => {
54
+ const next = e.currentTarget.value;
55
+ setText(next);
56
+ onValueChange(textToCents(next));
57
+ }}
58
+ />
59
+ );
60
+ }
@@ -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
+ };
@@ -0,0 +1,154 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../Table/Table';
6
+ import { Icon } from '../Icon/Icon';
7
+ import { EmptyState } from '../EmptyState/EmptyState';
8
+ import styles from './DataTable.module.css';
9
+
10
+ export type SortDirection = 'asc' | 'desc';
11
+
12
+ export interface DataTableColumn<T> {
13
+ key: string;
14
+ header: string;
15
+ sortable?: boolean;
16
+ /** Cell content. Defaults to String(row[key]) when omitted. */
17
+ render?: (row: T) => ReactNode;
18
+ /** Value compared when sorting by this column. Defaults to row[key].
19
+ * Required if `key` isn't a property on T (a computed/joined column). */
20
+ sortValue?: (row: T) => string | number;
21
+ align?: 'start' | 'end';
22
+ }
23
+
24
+ export interface DataTableProps<T> {
25
+ columns: DataTableColumn<T>[];
26
+ data: T[];
27
+ getRowKey: (row: T) => string;
28
+ /** Initial sort — uncontrolled after mount, like Table itself has no
29
+ * externally-controlled state. */
30
+ defaultSortKey?: string;
31
+ defaultSortDirection?: SortDirection;
32
+ emptyMessage?: string;
33
+ className?: string;
34
+ }
35
+
36
+ function compareValues(a: string | number, b: string | number): number {
37
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
38
+ return String(a).localeCompare(String(b));
39
+ }
40
+
41
+ /**
42
+ * DataTable — sortable table built on Table's own primitives (no
43
+ * virtualization or filtering — those stay out of scope, matching Table's
44
+ * own "not a data grid" boundary). Column-driven: pass `columns` + `data`
45
+ * rather than composing `<TableRow>`/`<TableCell>` by hand.
46
+ *
47
+ * Sort state is internal (uncontrolled) and click-cycles
48
+ * ascending → descending → unsorted for a sortable column, mirroring the
49
+ * "toggle → toggle → clear" pattern most sortable tables use rather than
50
+ * being permanently stuck in whichever direction was clicked last.
51
+ */
52
+ export function DataTable<T>({
53
+ columns,
54
+ data,
55
+ getRowKey,
56
+ defaultSortKey,
57
+ defaultSortDirection = 'asc',
58
+ emptyMessage = 'No results.',
59
+ className,
60
+ }: DataTableProps<T>) {
61
+ const [sortKey, setSortKey] = useState<string | undefined>(defaultSortKey);
62
+ const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSortDirection);
63
+
64
+ const sortColumn = columns.find((c) => c.key === sortKey);
65
+
66
+ const sortedData = useMemo(() => {
67
+ if (!sortColumn) return data;
68
+ const getValue =
69
+ sortColumn.sortValue ??
70
+ ((row: T) => (row as Record<string, unknown>)[sortColumn.key] as string | number);
71
+ const sorted = [...data].sort((a, b) => compareValues(getValue(a), getValue(b)));
72
+ return sortDirection === 'asc' ? sorted : sorted.reverse();
73
+ }, [data, sortColumn, sortDirection]);
74
+
75
+ function handleSort(column: DataTableColumn<T>) {
76
+ if (!column.sortable) return;
77
+ if (sortKey !== column.key) {
78
+ setSortKey(column.key);
79
+ setSortDirection('asc');
80
+ } else if (sortDirection === 'asc') {
81
+ setSortDirection('desc');
82
+ } else {
83
+ setSortKey(undefined);
84
+ setSortDirection('asc');
85
+ }
86
+ }
87
+
88
+ if (data.length === 0) {
89
+ return <EmptyState heading={emptyMessage} />;
90
+ }
91
+
92
+ return (
93
+ <Table className={className}>
94
+ <TableHead>
95
+ <TableRow>
96
+ {columns.map((column) => {
97
+ const isSorted = sortKey === column.key;
98
+ const ariaSort = !column.sortable
99
+ ? undefined
100
+ : isSorted
101
+ ? sortDirection === 'asc'
102
+ ? 'ascending'
103
+ : 'descending'
104
+ : 'none';
105
+ return (
106
+ <TableHeaderCell
107
+ key={column.key}
108
+ aria-sort={ariaSort}
109
+ style={column.align === 'end' ? { textAlign: 'end' } : undefined}
110
+ >
111
+ {column.sortable ? (
112
+ <button
113
+ type="button"
114
+ className={styles.sortButton}
115
+ onClick={() => handleSort(column)}
116
+ >
117
+ {column.header}
118
+ <span className={styles.sortIcon}>
119
+ {isSorted && (
120
+ <Icon
121
+ name={sortDirection === 'asc' ? 'chevron-up' : 'chevron-down'}
122
+ size="sm"
123
+ aria-hidden
124
+ />
125
+ )}
126
+ </span>
127
+ </button>
128
+ ) : (
129
+ column.header
130
+ )}
131
+ </TableHeaderCell>
132
+ );
133
+ })}
134
+ </TableRow>
135
+ </TableHead>
136
+ <TableBody>
137
+ {sortedData.map((row) => (
138
+ <TableRow key={getRowKey(row)}>
139
+ {columns.map((column) => (
140
+ <TableCell
141
+ key={column.key}
142
+ style={column.align === 'end' ? { textAlign: 'end' } : undefined}
143
+ >
144
+ {column.render
145
+ ? column.render(row)
146
+ : String((row as Record<string, unknown>)[column.key] ?? '')}
147
+ </TableCell>
148
+ ))}
149
+ </TableRow>
150
+ ))}
151
+ </TableBody>
152
+ </Table>
153
+ );
154
+ }
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
4
+ import { DataTable, type DataTableColumn } from '../DataTable';
5
+
6
+ afterEach(cleanup);
7
+
8
+ interface Plugin {
9
+ id: string;
10
+ name: string;
11
+ installs: number;
12
+ }
13
+
14
+ const data: Plugin[] = [
15
+ { id: 'a', name: 'Charlie', installs: 12 },
16
+ { id: 'b', name: 'Alpha', installs: 30 },
17
+ { id: 'c', name: 'Bravo', installs: 5 },
18
+ ];
19
+
20
+ const columns: DataTableColumn<Plugin>[] = [
21
+ { key: 'name', header: 'Name', sortable: true },
22
+ { key: 'installs', header: 'Installs', sortable: true, align: 'end' },
23
+ ];
24
+
25
+ function rowsText() {
26
+ return screen
27
+ .getAllByRole('row')
28
+ .slice(1)
29
+ .map((row) => within(row).getAllByRole('cell')[0]?.textContent);
30
+ }
31
+
32
+ describe('DataTable', () => {
33
+ it('renders a row per data item and a header per column', () => {
34
+ render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
35
+ expect(screen.getAllByRole('row')).toHaveLength(4); // 1 header + 3 body rows
36
+ expect(screen.getByRole('columnheader', { name: 'Name' })).toBeDefined();
37
+ expect(screen.getByRole('columnheader', { name: 'Installs' })).toBeDefined();
38
+ });
39
+
40
+ it('renders cell values from row[key] by default', () => {
41
+ render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
42
+ expect(screen.getByText('Charlie')).toBeDefined();
43
+ expect(screen.getByText('30')).toBeDefined();
44
+ });
45
+
46
+ it('uses a custom render function when provided', () => {
47
+ const withRender: DataTableColumn<Plugin>[] = [
48
+ { key: 'name', header: 'Name' },
49
+ { key: 'installs', header: 'Installs', render: (row) => `${row.installs} installs` },
50
+ ];
51
+ render(<DataTable columns={withRender} data={data} getRowKey={(row) => row.id} />);
52
+ expect(screen.getByText('30 installs')).toBeDefined();
53
+ });
54
+
55
+ it('shows an empty state instead of a table when data is empty', () => {
56
+ render(
57
+ <DataTable
58
+ columns={columns}
59
+ data={[]}
60
+ getRowKey={(row) => row.id}
61
+ emptyMessage="No plugins"
62
+ />,
63
+ );
64
+ expect(screen.queryByRole('table')).toBeNull();
65
+ expect(screen.getByText('No plugins')).toBeDefined();
66
+ });
67
+
68
+ it('non-sortable columns render as plain text, not a button', () => {
69
+ const withPlain: DataTableColumn<Plugin>[] = [{ key: 'name', header: 'Name' }];
70
+ render(<DataTable columns={withPlain} data={data} getRowKey={(row) => row.id} />);
71
+ expect(screen.queryByRole('button', { name: 'Name' })).toBeNull();
72
+ expect(screen.getByRole('columnheader', { name: 'Name' }).getAttribute('aria-sort')).toBeNull();
73
+ });
74
+
75
+ it('clicking a sortable header sorts ascending, then descending, then clears', () => {
76
+ render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
77
+ const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
78
+ const button = within(nameHeader).getByRole('button');
79
+
80
+ fireEvent.click(button);
81
+ expect(rowsText()).toEqual(['Alpha', 'Bravo', 'Charlie']);
82
+ expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
83
+
84
+ fireEvent.click(button);
85
+ expect(rowsText()).toEqual(['Charlie', 'Bravo', 'Alpha']);
86
+ expect(nameHeader.getAttribute('aria-sort')).toBe('descending');
87
+
88
+ fireEvent.click(button);
89
+ expect(rowsText()).toEqual(['Charlie', 'Alpha', 'Bravo']);
90
+ expect(nameHeader.getAttribute('aria-sort')).toBe('none');
91
+ });
92
+
93
+ it('sorts numeric columns numerically, not lexicographically', () => {
94
+ render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
95
+ const installsHeader = screen.getByRole('columnheader', { name: 'Installs' });
96
+ fireEvent.click(within(installsHeader).getByRole('button'));
97
+ expect(rowsText()).toEqual(['Bravo', 'Charlie', 'Alpha']); // 5, 12, 30
98
+ });
99
+
100
+ it('switching the sort column resets to ascending', () => {
101
+ render(<DataTable columns={columns} data={data} getRowKey={(row) => row.id} />);
102
+ const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
103
+ const installsHeader = screen.getByRole('columnheader', { name: 'Installs' });
104
+ fireEvent.click(within(nameHeader).getByRole('button'));
105
+ fireEvent.click(within(nameHeader).getByRole('button')); // now descending
106
+ fireEvent.click(within(installsHeader).getByRole('button'));
107
+ expect(installsHeader.getAttribute('aria-sort')).toBe('ascending');
108
+ expect(nameHeader.getAttribute('aria-sort')).toBe('none');
109
+ });
110
+
111
+ it('respects defaultSortKey/defaultSortDirection', () => {
112
+ render(
113
+ <DataTable
114
+ columns={columns}
115
+ data={data}
116
+ getRowKey={(row) => row.id}
117
+ defaultSortKey="installs"
118
+ defaultSortDirection="desc"
119
+ />,
120
+ );
121
+ expect(rowsText()).toEqual(['Alpha', 'Charlie', 'Bravo']); // 30, 12, 5
122
+ });
123
+ });
@@ -1,5 +1,7 @@
1
1
  /* DatePicker — trigger styled to match Input's height/border/radius/focus
2
- * ring for visual consistency as a form field. */
2
+ * ring for visual consistency as a form field. Kept in sync with Input's
3
+ * own sizing by hand (36px rendered height via explicit height +
4
+ * border-box, not a literal reuse of Input). */
3
5
 
4
6
  .trigger {
5
7
  display: flex;
@@ -7,7 +9,9 @@
7
9
  justify-content: space-between;
8
10
  gap: var(--sv-space-2);
9
11
  width: 100%;
10
- padding: var(--sv-space-2) var(--sv-space-3);
12
+ height: 36px;
13
+ box-sizing: border-box;
14
+ padding: 4px var(--sv-space-3);
11
15
  font-family: var(--sv-font-family);
12
16
  font-size: var(--sv-font-size-sm);
13
17
  line-height: 1.5;
@@ -79,6 +79,9 @@
79
79
  }
80
80
  }
81
81
 
82
+ /* Sizing audit: reviewed padding/radius against this scale's other
83
+ surfaces. --sv-radius-lg (shared with Card via .panel below) left
84
+ unchanged for the same blast-radius reason as Card.module.css. */
82
85
  .content {
83
86
  flex: 1 1 auto;
84
87
  overflow-y: auto;
@@ -0,0 +1,3 @@
1
+ .root {
2
+ display: inline-flex;
3
+ }
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { HoverCard } from './HoverCard';
3
+ import { Avatar } from '../Avatar/Avatar';
4
+
5
+ const meta = {
6
+ title: 'Components/HoverCard',
7
+ component: HoverCard,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Hover-triggered popover on desktop, tap-to-toggle on touch. Built on Popover for positioning/collision detection — this only adds hover-intent timing and the touch fallback. Opens on keyboard focus too.',
14
+ },
15
+ },
16
+ },
17
+ args: { trigger: <span />, children: null, 'aria-label': 'Preview' },
18
+ } satisfies Meta<typeof HoverCard>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const Default: Story = {
26
+ render: () => (
27
+ <HoverCard
28
+ aria-label="User preview"
29
+ trigger={
30
+ <button
31
+ type="button"
32
+ style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
33
+ >
34
+ <Avatar name="Jane Smith" size="sm" />
35
+ </button>
36
+ }
37
+ >
38
+ <div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
39
+ <div style={{ fontWeight: 600 }}>Jane Smith</div>
40
+ <div style={{ color: 'var(--sv-color-text-muted)' }}>jane@example.com</div>
41
+ </div>
42
+ </HoverCard>
43
+ ),
44
+ };
45
+
46
+ export const WithLink: Story = {
47
+ render: () => (
48
+ <p style={{ fontSize: 14 }}>
49
+ Assigned to{' '}
50
+ <HoverCard
51
+ aria-label="Assignee preview"
52
+ trigger={
53
+ <a href="/console/users/jane-smith" style={{ color: 'var(--sv-color-text-primary)' }}>
54
+ Jane Smith
55
+ </a>
56
+ }
57
+ >
58
+ <div style={{ padding: 'var(--sv-space-3)', fontSize: 14 }}>
59
+ <div style={{ fontWeight: 600 }}>Jane Smith</div>
60
+ <div style={{ color: 'var(--sv-color-text-muted)' }}>12 tasks completed this month</div>
61
+ </div>
62
+ </HoverCard>
63
+ .
64
+ </p>
65
+ ),
66
+ };