@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.
- package/dist/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +617 -5
- package/dist/index.js +1847 -499
- package/dist/tokens/primitives.css +22 -8
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +30 -2
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/index.ts +78 -1
- package/src/stories/DesignSystemOverview.stories.tsx +614 -2
- package/src/stories/MobilePatterns.stories.tsx +8 -2
- package/src/stories/TokenGallery.stories.tsx +112 -16
- 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
|
-
|
|
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,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
|
-
|
|
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;
|