@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.
- 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/BalanceChip.module.css +32 -0
- 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/MemberMultiSelect.module.css +27 -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 +698 -5
- package/dist/index.js +1938 -482
- 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/BalanceChip/BalanceChip.module.css +32 -0
- package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
- package/src/components/BalanceChip/BalanceChip.tsx +33 -0
- 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/CurrencyInput/CurrencyInput.stories.tsx +50 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +60 -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/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -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/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -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 +92 -1
- package/src/stories/DesignSystemOverview.stories.tsx +706 -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,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
|
-
|
|
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
|
+
};
|