@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,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
+ };
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import { cloneElement, useEffect, useRef, useState } from 'react';
4
+ import type { ReactElement, ReactNode } from 'react';
5
+ import { Popover } from '../Popover/Popover';
6
+ import styles from './HoverCard.module.css';
7
+
8
+ export interface HoverCardProps {
9
+ /** The element that opens the card — cloned with hover/focus handlers
10
+ * (desktop) or a tap handler (touch). */
11
+ trigger: ReactElement;
12
+ children: ReactNode;
13
+ align?: 'left' | 'right';
14
+ width?: number | 'trigger';
15
+ 'aria-label': string;
16
+ }
17
+
18
+ const OPEN_DELAY_MS = 400;
19
+ const CLOSE_DELAY_MS = 150;
20
+
21
+ /** Whether the primary pointer can hover. Distinct from viewport-width-based
22
+ * mobile detection: a touchscreen laptop with a mouse as its primary
23
+ * pointer should still get real hover behavior, and a large-viewport tablet
24
+ * with only touch should not — viewport width doesn't capture that, `(hover:
25
+ * hover)` does, matching the pointer-based gating this design system already
26
+ * uses in CSS (e.g. Button/Checkbox's touch-target expansion). Defaults to
27
+ * `true`: this only changes which event handlers get attached, not the
28
+ * initial DOM shape, so there's no hydration-mismatch risk to guard against
29
+ * the way `useIsMobile` does. */
30
+ function useHoverCapable(): boolean {
31
+ const [hoverCapable, setHoverCapable] = useState(true);
32
+
33
+ useEffect(() => {
34
+ const mql = window.matchMedia('(hover: hover)');
35
+ setHoverCapable(mql.matches);
36
+ function handleChange(e: MediaQueryListEvent) {
37
+ setHoverCapable(e.matches);
38
+ }
39
+ mql.addEventListener('change', handleChange);
40
+ return () => mql.removeEventListener('change', handleChange);
41
+ }, []);
42
+
43
+ return hoverCapable;
44
+ }
45
+
46
+ /**
47
+ * HoverCard — hover-triggered popover on desktop, tap-to-toggle on touch.
48
+ *
49
+ * Built on `Popover` rather than reimplementing positioning/collision
50
+ * detection: this only adds the hover-intent open/close timing (avoids
51
+ * flicker on an accidental mouse pass-over, and gives the pointer time to
52
+ * travel from the trigger to the card itself across the gap between them)
53
+ * and the touch fallback. Also opens on keyboard focus and closes on blur —
54
+ * a hover-only trigger is a WCAG failure, since keyboard users can't hover.
55
+ */
56
+ export function HoverCard({
57
+ trigger,
58
+ children,
59
+ align,
60
+ width,
61
+ 'aria-label': ariaLabel,
62
+ }: HoverCardProps) {
63
+ const [open, setOpen] = useState(false);
64
+ const hoverCapable = useHoverCapable();
65
+ const openTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
66
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
67
+
68
+ function scheduleOpen() {
69
+ clearTimeout(closeTimer.current);
70
+ openTimer.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);
71
+ }
72
+
73
+ function scheduleClose() {
74
+ clearTimeout(openTimer.current);
75
+ closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
76
+ }
77
+
78
+ function cancelScheduledClose() {
79
+ clearTimeout(closeTimer.current);
80
+ }
81
+
82
+ const triggerHandlers = hoverCapable
83
+ ? {
84
+ onMouseEnter: scheduleOpen,
85
+ onMouseLeave: scheduleClose,
86
+ onFocus: () => setOpen(true),
87
+ onBlur: scheduleClose,
88
+ }
89
+ : { onClick: () => setOpen((o) => !o) };
90
+
91
+ const wrappedTrigger = cloneElement(trigger, triggerHandlers);
92
+
93
+ return (
94
+ <div
95
+ className={styles.root}
96
+ onMouseEnter={hoverCapable ? cancelScheduledClose : undefined}
97
+ onMouseLeave={hoverCapable ? scheduleClose : undefined}
98
+ >
99
+ <Popover
100
+ trigger={wrappedTrigger}
101
+ open={open}
102
+ onClose={() => setOpen(false)}
103
+ align={align}
104
+ width={width}
105
+ aria-label={ariaLabel}
106
+ >
107
+ {children}
108
+ </Popover>
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,99 @@
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 { HoverCard } from '../HoverCard';
5
+
6
+ let matches = true;
7
+
8
+ function installMatchMedia() {
9
+ vi.stubGlobal(
10
+ 'matchMedia',
11
+ vi.fn().mockImplementation((query: string) => ({
12
+ matches,
13
+ media: query,
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ addListener: vi.fn(),
17
+ removeListener: vi.fn(),
18
+ dispatchEvent: vi.fn(),
19
+ })),
20
+ );
21
+ }
22
+
23
+ describe('HoverCard', () => {
24
+ beforeEach(() => {
25
+ matches = true; // hover-capable by default
26
+ installMatchMedia();
27
+ vi.useFakeTimers();
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.runOnlyPendingTimers();
32
+ vi.useRealTimers();
33
+ vi.unstubAllGlobals();
34
+ cleanup();
35
+ });
36
+
37
+ it('does not open immediately on mouse enter (hover-intent delay)', () => {
38
+ render(
39
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
40
+ Card content
41
+ </HoverCard>,
42
+ );
43
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
44
+ expect(screen.queryByText('Card content')).toBeNull();
45
+ });
46
+
47
+ it('opens after the hover-intent delay elapses', () => {
48
+ render(
49
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
50
+ Card content
51
+ </HoverCard>,
52
+ );
53
+ fireEvent.mouseEnter(screen.getByRole('button', { name: 'Profile' }));
54
+ act(() => {
55
+ vi.advanceTimersByTime(500);
56
+ });
57
+ expect(screen.getByText('Card content')).toBeDefined();
58
+ });
59
+
60
+ it('cancels the open timer if the pointer leaves before the delay elapses', () => {
61
+ render(
62
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
63
+ Card content
64
+ </HoverCard>,
65
+ );
66
+ const trigger = screen.getByRole('button', { name: 'Profile' });
67
+ fireEvent.mouseEnter(trigger);
68
+ fireEvent.mouseLeave(trigger);
69
+ act(() => {
70
+ vi.advanceTimersByTime(1000);
71
+ });
72
+ expect(screen.queryByText('Card content')).toBeNull();
73
+ });
74
+
75
+ it('opens immediately on keyboard focus, for keyboard accessibility', () => {
76
+ render(
77
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
78
+ Card content
79
+ </HoverCard>,
80
+ );
81
+ fireEvent.focus(screen.getByRole('button', { name: 'Profile' }));
82
+ expect(screen.getByText('Card content')).toBeDefined();
83
+ });
84
+
85
+ it('falls back to tap-to-toggle when the pointer cannot hover', () => {
86
+ matches = false;
87
+ installMatchMedia();
88
+ render(
89
+ <HoverCard trigger={<button type="button">Profile</button>} aria-label="Profile preview">
90
+ Card content
91
+ </HoverCard>,
92
+ );
93
+ const trigger = screen.getByRole('button', { name: 'Profile' });
94
+ fireEvent.click(trigger);
95
+ expect(screen.getByText('Card content')).toBeDefined();
96
+ fireEvent.click(trigger);
97
+ expect(screen.queryByText('Card content')).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,21 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CircleCheckIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <path d="m9 12 2 2 4-4" />
19
+ </svg>
20
+ );
21
+ }
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function CircleXIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <path d="m15 9-6 6" />
19
+ <path d="m9 9 6 6" />
20
+ </svg>
21
+ );
22
+ }
@@ -26,6 +26,8 @@ import { PackageIcon } from './package';
26
26
  import { Grid2x2Icon } from './grid-2x2';
27
27
  import { InfoIcon } from './info';
28
28
  import { AlertTriangleIcon } from './alert-triangle';
29
+ import { CircleCheckIcon } from './circle-check';
30
+ import { CircleXIcon } from './circle-x';
29
31
  import { CalendarIcon } from './calendar';
30
32
  import { SlidersHorizontalIcon } from './sliders-horizontal';
31
33
  import { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -80,6 +82,8 @@ export type IconName =
80
82
  | 'grid-2x2'
81
83
  | 'info'
82
84
  | 'alert-triangle'
85
+ | 'circle-check'
86
+ | 'circle-x'
83
87
  | 'calendar'
84
88
  | 'sliders-horizontal'
85
89
  | 'ellipsis-vertical'
@@ -136,6 +140,8 @@ export const ICONS: Record<IconName, IconComponent> = {
136
140
  'grid-2x2': Grid2x2Icon,
137
141
  info: InfoIcon,
138
142
  'alert-triangle': AlertTriangleIcon,
143
+ 'circle-check': CircleCheckIcon,
144
+ 'circle-x': CircleXIcon,
139
145
  calendar: CalendarIcon,
140
146
  'sliders-horizontal': SlidersHorizontalIcon,
141
147
  'ellipsis-vertical': EllipsisVerticalIcon,
@@ -190,6 +196,8 @@ export { PackageIcon } from './package';
190
196
  export { Grid2x2Icon } from './grid-2x2';
191
197
  export { InfoIcon } from './info';
192
198
  export { AlertTriangleIcon } from './alert-triangle';
199
+ export { CircleCheckIcon } from './circle-check';
200
+ export { CircleXIcon } from './circle-x';
193
201
  export { CalendarIcon } from './calendar';
194
202
  export { SlidersHorizontalIcon } from './sliders-horizontal';
195
203
  export { EllipsisVerticalIcon } from './ellipsis-vertical';
@@ -1,7 +1,12 @@
1
1
  .input {
2
2
  display: block;
3
3
  width: 100%;
4
- padding: var(--sv-space-2) var(--sv-space-3);
4
+ /* 36px rendered height. Explicit height + border-box (rather than
5
+ deriving height from padding and line-height) gives an exact,
6
+ browser-independent target instead of an approximation. */
7
+ height: 36px;
8
+ box-sizing: border-box;
9
+ padding: 4px var(--sv-space-3);
5
10
  font-family: var(--sv-font-family);
6
11
  font-size: var(--sv-font-size-sm);
7
12
  line-height: 1.5;