@cosxai/ui 0.25.0 → 1.0.0-alpha.1
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/CHANGELOG.md +597 -0
- package/NOTICE +11 -0
- package/README.md +52 -0
- package/package.json +57 -7
- package/src/brand.css +13 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Button.tsx +178 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/Dialog.tsx +111 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/Toast.tsx +159 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/core.test.tsx +242 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/forms.test.tsx +280 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/overlays.test.tsx +245 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/fonts.css +20 -0
- package/src/index.ts +34 -24
- package/src/lib/cn.ts +23 -6
- package/src/modes.css +10 -0
- package/src/styles.css +19 -0
- package/src/theme.css +143 -0
- package/src/tokens/colors.css +112 -0
- package/src/tokens/compat.css +36 -0
- package/src/tokens/elevation.css +14 -0
- package/src/tokens/motion.css +42 -0
- package/src/tokens/spacing.css +55 -0
- package/src/tokens/typography-cjk.css +42 -0
- package/src/tokens/typography.css +81 -0
- package/src/actionbar/ActionBar.tsx +0 -837
- package/src/actionbar/ActionBarButton.tsx +0 -111
- package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
- package/src/actionbar/ActionBarModeHandle.tsx +0 -171
- package/src/actionbar/ActionBarProvider.tsx +0 -91
- package/src/actionbar/actionbar-context.ts +0 -45
- package/src/actionbar/index.ts +0 -28
- package/src/actionbar/types.ts +0 -147
- package/src/actionbar/useActionBarItems.ts +0 -60
- package/src/actionbar/useActionBarMode.ts +0 -144
- package/src/actionbar/useActionBarPanel.ts +0 -56
- package/src/actionbar/useActionBarStatusDot.ts +0 -49
- package/src/actionbar/useActionBarToast.ts +0 -33
- package/src/ambient/AmbientBackdrop.tsx +0 -74
- package/src/ambient/CommandInput.tsx +0 -107
- package/src/ambient/SuperbarStrip.tsx +0 -36
- package/src/ambient/index.ts +0 -6
- package/src/bento/BentoCell.tsx +0 -66
- package/src/bento/BentoGrid.tsx +0 -42
- package/src/bento/index.ts +0 -2
- package/src/command/CommandPalette.tsx +0 -329
- package/src/command/CommandProvider.tsx +0 -57
- package/src/command/command-context.ts +0 -12
- package/src/command/index.ts +0 -6
- package/src/command/rank.ts +0 -50
- package/src/command/types.ts +0 -37
- package/src/command/useCommandSource.ts +0 -37
- package/src/dialogs/DialogsProvider.tsx +0 -216
- package/src/dialogs/Modal.tsx +0 -260
- package/src/dialogs/Toast.tsx +0 -85
- package/src/dialogs/dialogs-context.ts +0 -6
- package/src/dialogs/index.ts +0 -10
- package/src/dialogs/types.ts +0 -37
- package/src/dialogs/useDialogs.ts +0 -8
- package/src/editorial/EditorialSpotlight.tsx +0 -63
- package/src/editorial/Folio.tsx +0 -52
- package/src/editorial/PlateMarker.tsx +0 -33
- package/src/editorial/RomanSection.tsx +0 -65
- package/src/editorial/RunningMarginalia.tsx +0 -65
- package/src/editorial/index.ts +0 -10
- package/src/hooks/index.ts +0 -5
- package/src/hooks/useKeyboardHotkey.ts +0 -93
- package/src/hooks/useReducedMotion.ts +0 -20
- package/src/hooks/useViewport.ts +0 -61
- package/src/layout/Breadcrumb.tsx +0 -74
- package/src/layout/LeftNavRail.tsx +0 -139
- package/src/layout/MobileTabBar.tsx +0 -101
- package/src/layout/NavItem.tsx +0 -128
- package/src/layout/NavSearchTrigger.tsx +0 -88
- package/src/layout/NavSection.tsx +0 -40
- package/src/layout/RightSidebarPanel.tsx +0 -111
- package/src/layout/Shell.tsx +0 -91
- package/src/layout/SidePanel.tsx +0 -316
- package/src/layout/StickyBanner.tsx +0 -83
- package/src/layout/Topbar.tsx +0 -68
- package/src/layout/index.ts +0 -24
- package/src/layout/useNavRailState.ts +0 -69
- package/src/lib/time-utils.ts +0 -44
- package/src/neobrutalism/Marquee.tsx +0 -81
- package/src/neobrutalism/Sticker.tsx +0 -71
- package/src/neobrutalism/index.ts +0 -4
- package/src/primitives/Avatar.tsx +0 -53
- package/src/primitives/Button.tsx +0 -67
- package/src/primitives/Card.tsx +0 -41
- package/src/primitives/Checkbox.tsx +0 -87
- package/src/primitives/Chip.tsx +0 -191
- package/src/primitives/Combobox.tsx +0 -507
- package/src/primitives/CopyField.tsx +0 -139
- package/src/primitives/CountBadge.tsx +0 -50
- package/src/primitives/Input.tsx +0 -204
- package/src/primitives/Kbd.tsx +0 -45
- package/src/primitives/MentionCombobox.tsx +0 -612
- package/src/primitives/MultiCombobox.tsx +0 -327
- package/src/primitives/PageHeader.tsx +0 -77
- package/src/primitives/Radio.tsx +0 -131
- package/src/primitives/SegmentedControl.tsx +0 -135
- package/src/primitives/Select.tsx +0 -779
- package/src/primitives/SignaturePad.tsx +0 -461
- package/src/primitives/Tag.tsx +0 -56
- package/src/primitives/Textarea.tsx +0 -62
- package/src/primitives/ToggleSwitch.tsx +0 -79
- package/src/primitives/Tooltip.tsx +0 -213
- package/src/primitives/index.ts +0 -53
- package/src/pwa/InstallPromptBanner.tsx +0 -132
- package/src/pwa/index.ts +0 -4
- package/src/pwa/manifest.template.json +0 -20
- package/src/pwa/registerSW.ts +0 -55
- package/src/riso/Halftone.tsx +0 -85
- package/src/riso/Misregister.tsx +0 -63
- package/src/riso/RisoStamp.tsx +0 -76
- package/src/riso/index.ts +0 -3
- package/src/sketch/HandUnderline.tsx +0 -53
- package/src/sketch/RoughArrow.tsx +0 -91
- package/src/sketch/RoughBox.tsx +0 -73
- package/src/sketch/StickyNote.tsx +0 -56
- package/src/sketch/index.ts +0 -4
- package/src/styles/base.css +0 -186
- package/src/styles/chrome-ambient.css +0 -235
- package/src/styles/chrome-bento.css +0 -192
- package/src/styles/chrome-editorial.css +0 -168
- package/src/styles/chrome-neobrutalism.css +0 -326
- package/src/styles/chrome-riso.css +0 -339
- package/src/styles/chrome-sketch.css +0 -362
- package/src/styles/chrome-swiss.css +0 -265
- package/src/styles/chrome-terminal.css +0 -272
- package/src/styles/fonts.css +0 -1034
- package/src/styles/index.css +0 -343
- package/src/styles/tokens.css +0 -941
- package/src/terminal/AsciiBox.tsx +0 -65
- package/src/terminal/BrailleSpinner.tsx +0 -46
- package/src/terminal/index.ts +0 -4
- package/src/theme/ThemeProvider.tsx +0 -99
- package/src/theme/index.ts +0 -6
- package/src/theme/inline-script.ts +0 -40
- package/src/theme/theme-context.ts +0 -7
- package/src/theme/types.ts +0 -39
- package/src/theme/useTheme.ts +0 -8
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { act, render, screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { a11y } from '../../test/a11y';
|
|
7
|
+
import { Button } from './Button';
|
|
8
|
+
import { Dialog, DialogClose, DialogContent, DialogTrigger } from './Dialog';
|
|
9
|
+
import { Table, type TableSort } from './Table';
|
|
10
|
+
import { TabPanel, Tabs } from './Tabs';
|
|
11
|
+
import { Toaster, toast } from './Toast';
|
|
12
|
+
import { Tooltip } from './Tooltip';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
describe('Tabs', () => {
|
|
16
|
+
const items = [
|
|
17
|
+
{ value: 'overview', label: 'Overview' },
|
|
18
|
+
{ value: 'documents', label: 'Documents', count: 3 },
|
|
19
|
+
{ value: 'parties', label: 'Parties', count: 0 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
it('arrow keys move and aria-selected follows; onChange reports the value', async () => {
|
|
23
|
+
const onChange = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<Tabs items={items} defaultValue="overview" onChange={onChange} label="Project">
|
|
26
|
+
<TabPanel value="overview">Overview panel</TabPanel>
|
|
27
|
+
<TabPanel value="documents">Documents panel</TabPanel>
|
|
28
|
+
<TabPanel value="parties">Parties panel</TabPanel>
|
|
29
|
+
</Tabs>,
|
|
30
|
+
);
|
|
31
|
+
const tabs = screen.getAllByRole('tab');
|
|
32
|
+
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
|
33
|
+
await userEvent.click(tabs[0]!);
|
|
34
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
35
|
+
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
|
|
36
|
+
expect(onChange).toHaveBeenLastCalledWith('documents');
|
|
37
|
+
expect(screen.getByRole('tabpanel')).toHaveTextContent('Documents panel');
|
|
38
|
+
await userEvent.keyboard('{End}');
|
|
39
|
+
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('count shows as a badge, hidden at zero', () => {
|
|
43
|
+
render(<Tabs items={items} defaultValue="overview" label="Project" />);
|
|
44
|
+
expect(screen.getByRole('tab', { name: 'Documents 3' })).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByRole('tab', { name: 'Parties' })).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('passes axe (both variants)', async () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<div>
|
|
51
|
+
<Tabs items={items} defaultValue="overview" label="Underline">
|
|
52
|
+
<TabPanel value="overview">a</TabPanel>
|
|
53
|
+
</Tabs>
|
|
54
|
+
<Tabs variant="pills" items={items} defaultValue="documents" label="Pills">
|
|
55
|
+
<TabPanel value="documents">b</TabPanel>
|
|
56
|
+
</Tabs>
|
|
57
|
+
</div>,
|
|
58
|
+
);
|
|
59
|
+
expect(await a11y(container)).toHaveNoViolations();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('Dialog', () => {
|
|
64
|
+
function Revoke() {
|
|
65
|
+
return (
|
|
66
|
+
<Dialog>
|
|
67
|
+
<DialogTrigger asChild>
|
|
68
|
+
<Button variant="secondary">Revoke share</Button>
|
|
69
|
+
</DialogTrigger>
|
|
70
|
+
<DialogContent
|
|
71
|
+
size="sm"
|
|
72
|
+
title="Revoke this share?"
|
|
73
|
+
description="Anna loses access now."
|
|
74
|
+
footer={
|
|
75
|
+
<>
|
|
76
|
+
<DialogClose asChild>
|
|
77
|
+
<Button variant="secondary">Cancel</Button>
|
|
78
|
+
</DialogClose>
|
|
79
|
+
<Button variant="danger">Revoke share</Button>
|
|
80
|
+
</>
|
|
81
|
+
}
|
|
82
|
+
/>
|
|
83
|
+
</Dialog>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it('is labelled by its title and described by its consequence', async () => {
|
|
88
|
+
render(<Revoke />);
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: 'Revoke share' }));
|
|
90
|
+
const dialog = screen.getByRole('dialog', { name: 'Revoke this share?' });
|
|
91
|
+
expect(dialog).toHaveAccessibleDescription('Anna loses access now.');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('traps focus, closes on Esc and returns focus to the trigger', async () => {
|
|
95
|
+
render(<Revoke />);
|
|
96
|
+
const trigger = screen.getByRole('button', { name: 'Revoke share' });
|
|
97
|
+
await userEvent.click(trigger);
|
|
98
|
+
const dialog = screen.getByRole('dialog');
|
|
99
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
100
|
+
for (let i = 0; i < 6; i++) {
|
|
101
|
+
await userEvent.tab();
|
|
102
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
103
|
+
}
|
|
104
|
+
await userEvent.keyboard('{Escape}');
|
|
105
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
106
|
+
expect(trigger).toHaveFocus();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('passes axe while open', async () => {
|
|
110
|
+
render(<Revoke />);
|
|
111
|
+
await userEvent.click(screen.getByRole('button', { name: 'Revoke share' }));
|
|
112
|
+
expect(await a11y(document.body)).toHaveNoViolations();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('Toast', () => {
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
act(() => toast.reset());
|
|
119
|
+
vi.useRealTimers();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('is announced in a live region and leaves after 4s (8s with an action)', () => {
|
|
123
|
+
vi.useFakeTimers();
|
|
124
|
+
render(<Toaster />);
|
|
125
|
+
act(() => {
|
|
126
|
+
toast({ title: 'Link copied.' });
|
|
127
|
+
toast({ title: '3 files moved to Archive.', status: 'complete', action: { label: 'Undo', onClick: () => {} } });
|
|
128
|
+
});
|
|
129
|
+
// Radix announces toasts through a status live region.
|
|
130
|
+
expect(document.querySelector('[role="status"][aria-live]')).not.toBeNull();
|
|
131
|
+
expect(screen.getByText('Link copied.')).toBeInTheDocument();
|
|
132
|
+
act(() => vi.advanceTimersByTime(4100));
|
|
133
|
+
expect(screen.queryByText('Link copied.')).not.toBeInTheDocument();
|
|
134
|
+
expect(screen.getByText('3 files moved to Archive.')).toBeInTheDocument();
|
|
135
|
+
act(() => vi.advanceTimersByTime(4000));
|
|
136
|
+
expect(screen.queryByText('3 files moved to Archive.')).not.toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('shows the same error once per 30 seconds and at most three at once', () => {
|
|
140
|
+
render(<Toaster />);
|
|
141
|
+
act(() => {
|
|
142
|
+
expect(toast({ title: "Couldn't rename the folder.", status: 'error' })).not.toBeNull();
|
|
143
|
+
expect(toast({ title: "Couldn't rename the folder.", status: 'error' })).toBeNull();
|
|
144
|
+
toast({ title: 'One.' });
|
|
145
|
+
toast({ title: 'Two.' });
|
|
146
|
+
toast({ title: 'Three.' });
|
|
147
|
+
});
|
|
148
|
+
expect(screen.queryByText("Couldn't rename the folder.")).not.toBeInTheDocument();
|
|
149
|
+
expect(screen.getByText('Three.')).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('never takes focus', () => {
|
|
153
|
+
render(
|
|
154
|
+
<>
|
|
155
|
+
<button>Work</button>
|
|
156
|
+
<Toaster />
|
|
157
|
+
</>,
|
|
158
|
+
);
|
|
159
|
+
screen.getByRole('button', { name: 'Work' }).focus();
|
|
160
|
+
act(() => void toast({ title: 'Link copied.' }));
|
|
161
|
+
expect(screen.getByRole('button', { name: 'Work' })).toHaveFocus();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('Tooltip', () => {
|
|
166
|
+
it('opens on keyboard focus as well as hover', async () => {
|
|
167
|
+
render(
|
|
168
|
+
<Tooltip content="Downloads are off for this share">
|
|
169
|
+
<button>Share</button>
|
|
170
|
+
</Tooltip>,
|
|
171
|
+
);
|
|
172
|
+
await userEvent.tab();
|
|
173
|
+
expect(screen.getByRole('button', { name: 'Share' })).toHaveFocus();
|
|
174
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent('Downloads are off for this share');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('opens on hover', async () => {
|
|
178
|
+
render(
|
|
179
|
+
<Tooltip content="Star" delay={0}>
|
|
180
|
+
<button>☆</button>
|
|
181
|
+
</Tooltip>,
|
|
182
|
+
);
|
|
183
|
+
await userEvent.hover(screen.getByRole('button'));
|
|
184
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent('Star');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('Table', () => {
|
|
189
|
+
type Row = { id: string; status?: 'attention' | 'error'; task: string; docs: number };
|
|
190
|
+
const rows: Row[] = [
|
|
191
|
+
{ id: 'T-119', task: 'Translate the term sheet', docs: 1 },
|
|
192
|
+
{ id: 'T-122', status: 'error', task: 'Prepare the data room index', docs: 20 },
|
|
193
|
+
{ id: 'T-131', status: 'attention', task: 'Sign the engagement letter', docs: 2 },
|
|
194
|
+
];
|
|
195
|
+
const columns = [
|
|
196
|
+
{ key: 'task', label: 'Task', sortable: true },
|
|
197
|
+
{ key: 'docs', label: 'Docs', align: 'right' as const, sortable: true },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
function Sorted({ onRowClick }: { onRowClick: (r: Row) => void }) {
|
|
201
|
+
const [sort, setSort] = useState<TableSort>({ key: 'task', direction: 'asc' });
|
|
202
|
+
return <Table caption="Review queue" columns={columns} rows={rows} sort={sort} onSortChange={setSort} onRowClick={onRowClick} />;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it('sorting sets aria-sort on the header', async () => {
|
|
206
|
+
render(<Sorted onRowClick={() => {}} />);
|
|
207
|
+
const [task, docs] = screen.getAllByRole('columnheader');
|
|
208
|
+
expect(task).toHaveAttribute('aria-sort', 'ascending');
|
|
209
|
+
expect(docs).toHaveAttribute('aria-sort', 'none');
|
|
210
|
+
await userEvent.click(within(docs!).getByRole('button', { name: 'Docs' }));
|
|
211
|
+
expect(docs).toHaveAttribute('aria-sort', 'ascending');
|
|
212
|
+
await userEvent.click(within(docs!).getByRole('button', { name: 'Docs' }));
|
|
213
|
+
expect(docs).toHaveAttribute('aria-sort', 'descending');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('rows open on click and on Enter from the keyboard', async () => {
|
|
217
|
+
const onRowClick = vi.fn();
|
|
218
|
+
render(<Sorted onRowClick={onRowClick} />);
|
|
219
|
+
const row = screen.getByRole('row', { name: /Prepare the data room index/ });
|
|
220
|
+
await userEvent.click(row);
|
|
221
|
+
expect(onRowClick).toHaveBeenLastCalledWith(rows[1]);
|
|
222
|
+
row.focus();
|
|
223
|
+
await userEvent.keyboard('{Enter}');
|
|
224
|
+
expect(onRowClick).toHaveBeenCalledTimes(2);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rows needing a person take a wash; the title can be a real link', () => {
|
|
228
|
+
render(<Table columns={columns} rows={rows} rowHref={(r) => `/tasks/${r.id}`} />);
|
|
229
|
+
expect(screen.getByRole('row', { name: /data room/ }).className).toMatch(/bg-error-wash/);
|
|
230
|
+
expect(screen.getByRole('row', { name: /engagement letter/ }).className).toMatch(/bg-attention-wash/);
|
|
231
|
+
expect(screen.getByRole('link', { name: 'Sign the engagement letter' })).toHaveAttribute('href', '/tasks/T-131');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('empty and loading', () => {
|
|
235
|
+
const { rerender } = render(<Table columns={columns} rows={[]} empty="No tasks yet." />);
|
|
236
|
+
expect(screen.getByText('No tasks yet.')).toBeInTheDocument();
|
|
237
|
+
rerender(<Table columns={columns} rows={[]} loading />);
|
|
238
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('passes axe', async () => {
|
|
242
|
+
const { container } = render(<Sorted onRowClick={() => {}} />);
|
|
243
|
+
expect(await a11y(container)).toHaveNoViolations();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ButtonState } from './Button';
|
|
4
|
+
|
|
5
|
+
export type ButtonAction<A extends unknown[]> = {
|
|
6
|
+
/** Pass to <Button state>. */
|
|
7
|
+
state: ButtonState;
|
|
8
|
+
/** The last failure, to explain beside the button (with a Retry). Cleared on the next run. */
|
|
9
|
+
error: unknown;
|
|
10
|
+
/** Run the action: busy → done for `doneFor` ms → idle; on failure → idle with `error` set. */
|
|
11
|
+
run: (...args: A) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* useButtonAction — the async button of the spec: busy while the action
|
|
16
|
+
* runs, the result confirmed in place for 1.6s, a failure kept for the
|
|
17
|
+
* caller to explain in place. Repeat runs while busy are ignored.
|
|
18
|
+
*/
|
|
19
|
+
export function useButtonAction<A extends unknown[]>(action: (...args: A) => Promise<unknown>, { doneFor = 1600 }: { doneFor?: number } = {}): ButtonAction<A> {
|
|
20
|
+
const [state, setState] = useState<ButtonState>('idle');
|
|
21
|
+
const [error, setError] = useState<unknown>(null);
|
|
22
|
+
const busy = useRef(false);
|
|
23
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
24
|
+
const alive = useRef(true);
|
|
25
|
+
|
|
26
|
+
useEffect(
|
|
27
|
+
() => () => {
|
|
28
|
+
alive.current = false;
|
|
29
|
+
if (timer.current) clearTimeout(timer.current);
|
|
30
|
+
},
|
|
31
|
+
[],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const run = useCallback(
|
|
35
|
+
async (...args: A) => {
|
|
36
|
+
if (busy.current) return;
|
|
37
|
+
busy.current = true;
|
|
38
|
+
setError(null);
|
|
39
|
+
setState('busy');
|
|
40
|
+
try {
|
|
41
|
+
await action(...args);
|
|
42
|
+
if (!alive.current) return;
|
|
43
|
+
setState('done');
|
|
44
|
+
timer.current = setTimeout(() => alive.current && setState('idle'), doneFor);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
if (!alive.current) return;
|
|
47
|
+
setError(e);
|
|
48
|
+
setState('idle');
|
|
49
|
+
} finally {
|
|
50
|
+
busy.current = false;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[action, doneFor],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return { state, error, run };
|
|
57
|
+
}
|
package/src/fonts.css
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* @cosxai/ui — self-hosted fonts.
|
|
2
|
+
*
|
|
3
|
+
* The 3.0 tokens load Geist and Noto Sans SC from Google Fonts
|
|
4
|
+
* (tokens/fonts.css in the design system, not shipped here). The kit
|
|
5
|
+
* serves them from the consumer's own bundle instead: Fontsource
|
|
6
|
+
* variable builds, OFL-1.1, split by unicode range so a page downloads
|
|
7
|
+
* only the glyphs it shows.
|
|
8
|
+
*
|
|
9
|
+
* Fontsource names the families "… Variable"; the stacks below put those
|
|
10
|
+
* first and keep the design system's own names after them. Loaded after
|
|
11
|
+
* the tokens (styles.css), so these stacks are the ones in effect.
|
|
12
|
+
*/
|
|
13
|
+
@import "@fontsource-variable/geist";
|
|
14
|
+
@import "@fontsource-variable/noto-sans-sc";
|
|
15
|
+
|
|
16
|
+
:root {
|
|
17
|
+
--font-sans: "Geist Variable", "Geist", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
18
|
+
--font-cjk: "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
19
|
+
--font-sans-cjk: "Geist Variable", "Geist", "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
20
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @cosxai/ui 1.x — the COSX Design System 3.0 in React.
|
|
3
|
+
*
|
|
4
|
+
* Foundations: import "@cosxai/ui/styles.css" (tokens + fonts) and, in a
|
|
5
|
+
* Tailwind v4 app, "@cosxai/ui/theme.css". Plan and status:
|
|
6
|
+
* docs/workdocs/2026-09-29_feature-ui-1.0.
|
|
7
|
+
*/
|
|
8
|
+
export { cn } from './lib/cn';
|
|
8
9
|
|
|
9
|
-
export
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
export
|
|
13
|
-
export
|
|
14
|
-
export
|
|
15
|
-
export
|
|
16
|
-
export
|
|
17
|
-
export
|
|
18
|
-
export
|
|
19
|
-
export
|
|
20
|
-
export
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
export
|
|
24
|
-
export
|
|
25
|
-
export
|
|
10
|
+
export { Badge, type BadgeAppearance, type BadgeProps, type BadgeStatus } from './components/Badge';
|
|
11
|
+
export { Button, formatCount, type ButtonGround, type ButtonProps, type ButtonState, type ButtonVariant } from './components/Button';
|
|
12
|
+
export { Card, type CardProps } from './components/Card';
|
|
13
|
+
export { Figure, type FigureProps } from './components/Figure';
|
|
14
|
+
export { Icon, type IconProps } from './components/Icon';
|
|
15
|
+
export { IconButton, type IconButtonProps } from './components/IconButton';
|
|
16
|
+
export { Logo, type LogoProps } from './components/Logo';
|
|
17
|
+
export { Marker, type MarkerProps } from './components/Marker';
|
|
18
|
+
export { MetaLabel, type MetaLabelProps } from './components/MetaLabel';
|
|
19
|
+
export { Spinner, type SpinnerProps } from './components/Spinner';
|
|
20
|
+
export { Tag, type TagProps } from './components/Tag';
|
|
21
|
+
export { ThemeProvider, useTheme, type ThemeMode, type ThemeProviderProps } from './components/ThemeProvider';
|
|
22
|
+
export { useButtonAction, type ButtonAction } from './components/useButtonAction';
|
|
23
|
+
|
|
24
|
+
export { Checkbox, type CheckboxProps } from './components/Checkbox';
|
|
25
|
+
export { Dialog, DialogClose, DialogContent, DialogTrigger, type DialogContentProps } from './components/Dialog';
|
|
26
|
+
export { Field, useField, type FieldContextValue, type FieldProps } from './components/Field';
|
|
27
|
+
export { Input, type InputProps } from './components/Input';
|
|
28
|
+
export { RadioGroup, type RadioGroupProps, type RadioOption } from './components/Radio';
|
|
29
|
+
export { Select, type SelectOption, type SelectProps } from './components/Select';
|
|
30
|
+
export { Switch, type SwitchProps } from './components/Switch';
|
|
31
|
+
export { Table, type TableColumn, type TableProps, type TableRowBase, type TableSort, type TableSortDirection } from './components/Table';
|
|
32
|
+
export { Tab, TabList, TabPanel, Tabs, type TabItem, type TabListProps, type TabPanelProps, type TabProps, type TabsProps, type TabsVariant } from './components/Tabs';
|
|
33
|
+
export { Textarea, type TextareaProps } from './components/Textarea';
|
|
34
|
+
export { Toaster, toast, type ToasterProps, type ToastLabels, type ToastOptions, type ToastStatus } from './components/Toast';
|
|
35
|
+
export { Tooltip, TooltipProvider, type TooltipProps } from './components/Tooltip';
|
package/src/lib/cn.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* cn — join class names and let the later Tailwind class win on conflict
|
|
6
|
+
* (`cn('p-2', cond && 'p-4')` → `p-4`).
|
|
7
|
+
*
|
|
8
|
+
* tailwind-merge only knows Tailwind's default scales; the kit's theme
|
|
9
|
+
* (theme.css) replaces font sizes with its own names, taught here so
|
|
10
|
+
* `text-ui` and `text-fg` are not mistaken for each other (one is a
|
|
11
|
+
* size, one a colour).
|
|
12
|
+
*/
|
|
13
|
+
const merge = extendTailwindMerge({
|
|
14
|
+
extend: {
|
|
15
|
+
theme: {
|
|
16
|
+
text: ['meta', 'small', 'ui', 'body', 'h3', 'lede', 'title', 'figure', 'h2', 'h1', 'display'],
|
|
17
|
+
radius: ['xs', 'sm', 'md', 'lg', 'xl', 'pill'],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
23
|
+
return merge(clsx(inputs));
|
|
7
24
|
}
|
package/src/modes.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* @cosxai/ui — ink mode, completed.
|
|
2
|
+
*
|
|
3
|
+
* 3.0 switches its colours on either data-mode="ink" (root) or .ink-mode
|
|
4
|
+
* (subtree) — colors.css — but its marker rules (typography.css,
|
|
5
|
+
* typography-cjk.css) only know .ink-mode. Same rules for data-mode, so a
|
|
6
|
+
* page in ink mode gets the ink marker: no band, the finding in accent
|
|
7
|
+
* yellow. Reported to the design system; drop this when 3.0 covers it.
|
|
8
|
+
*/
|
|
9
|
+
[data-mode="ink"] .marker { color: var(--yellow-accent); background: none; padding: 0; margin: 0; border-radius: 0; }
|
|
10
|
+
[data-mode="ink"] :lang(zh) .marker, :lang(zh)[data-mode="ink"] .marker { background: none; }
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* @cosxai/ui — the COSX Design System 3.0 foundations.
|
|
2
|
+
*
|
|
3
|
+
* tokens/ are the design system's own files, copied unchanged from the
|
|
4
|
+
* "COSX Design System 3.0" project. Edit them there and copy again; the
|
|
5
|
+
* kit's additions live in fonts.css and theme.css.
|
|
6
|
+
*
|
|
7
|
+
* Ink (dark) mode: set data-mode="ink" on <html>, or put .ink-mode on a
|
|
8
|
+
* subtree (colors.css).
|
|
9
|
+
*/
|
|
10
|
+
@import "./tokens/colors.css";
|
|
11
|
+
@import "./tokens/typography.css";
|
|
12
|
+
@import "./tokens/typography-cjk.css";
|
|
13
|
+
@import "./tokens/spacing.css";
|
|
14
|
+
@import "./tokens/elevation.css";
|
|
15
|
+
@import "./tokens/motion.css";
|
|
16
|
+
@import "./tokens/compat.css";
|
|
17
|
+
@import "./brand.css";
|
|
18
|
+
@import "./modes.css";
|
|
19
|
+
@import "./fonts.css";
|
package/src/theme.css
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/* @cosxai/ui — Tailwind v4 theme for the COSX Design System 3.0.
|
|
2
|
+
*
|
|
3
|
+
* @import "tailwindcss";
|
|
4
|
+
* @import "@cosxai/ui/theme.css";
|
|
5
|
+
* @import "@cosxai/ui/styles.css";
|
|
6
|
+
* @source "../node_modules/@cosxai/ui/src"; (so the kit's classes are generated)
|
|
7
|
+
*
|
|
8
|
+
* Utilities point at the 3.0 variables ("inline"), so ink mode —
|
|
9
|
+
* data-mode="ink" / .ink-mode — switches them with no dark: variants.
|
|
10
|
+
*
|
|
11
|
+
* Tailwind's own palette, radii, shadows, fonts and easings are cleared:
|
|
12
|
+
* only what the design system defines exists (no bg-blue-500, no
|
|
13
|
+
* shadow-lg — 3.0 has no shadows).
|
|
14
|
+
*
|
|
15
|
+
* Names never collide with a 3.0 variable unless the value is the same
|
|
16
|
+
* (theme.test.ts). In particular font sizes avoid --text-primary /
|
|
17
|
+
* --text-secondary / --text-label, which are colours in 3.0.
|
|
18
|
+
*/
|
|
19
|
+
/* ink: — styles for ink mode (data-mode="ink" on <html>, or inside
|
|
20
|
+
.ink-mode). The semantic colours switch by themselves; this is for the
|
|
21
|
+
rules that flip on ink (the primary becomes the yellow). */
|
|
22
|
+
@custom-variant ink (&:where([data-mode="ink"], [data-mode="ink"] *, .ink-mode, .ink-mode *));
|
|
23
|
+
|
|
24
|
+
@theme inline {
|
|
25
|
+
--color-*: initial;
|
|
26
|
+
--radius-*: initial;
|
|
27
|
+
--shadow-*: initial;
|
|
28
|
+
--inset-shadow-*: initial;
|
|
29
|
+
--drop-shadow-*: initial;
|
|
30
|
+
--font-*: initial;
|
|
31
|
+
--ease-*: initial;
|
|
32
|
+
--text-*: initial;
|
|
33
|
+
--font-weight-*: initial;
|
|
34
|
+
--tracking-*: initial;
|
|
35
|
+
|
|
36
|
+
/* Materials — fixed, do not switch with ink mode */
|
|
37
|
+
--color-paper: var(--paper);
|
|
38
|
+
--color-linen: var(--linen);
|
|
39
|
+
--color-sunk-2: var(--sunk-2);
|
|
40
|
+
--color-sunk-3: var(--sunk-3);
|
|
41
|
+
--color-ink: var(--ink);
|
|
42
|
+
--color-ink-raised: var(--ink-raised);
|
|
43
|
+
--color-grey: var(--grey);
|
|
44
|
+
--color-grey-inverse: var(--grey-inverse);
|
|
45
|
+
|
|
46
|
+
/* The yellow — one hue, by area */
|
|
47
|
+
--color-yellow: var(--yellow);
|
|
48
|
+
--color-yellow-accent: var(--yellow-accent);
|
|
49
|
+
--color-yellow-accent-hover: var(--yellow-accent-hover);
|
|
50
|
+
--color-yellow-light: var(--yellow-light);
|
|
51
|
+
--color-yellow-hover: var(--yellow-hover);
|
|
52
|
+
--color-yellow-1: var(--yellow-1);
|
|
53
|
+
--color-yellow-2: var(--yellow-2);
|
|
54
|
+
--color-yellow-3: var(--yellow-3);
|
|
55
|
+
--color-yellow-4: var(--yellow-4);
|
|
56
|
+
--color-yellow-5: var(--yellow-5);
|
|
57
|
+
--color-yellow-wash-12: var(--yellow-12);
|
|
58
|
+
--color-yellow-wash-20: var(--yellow-20);
|
|
59
|
+
--color-yellow-wash-32: var(--yellow-32);
|
|
60
|
+
--color-yellow-wash-50: var(--yellow-50);
|
|
61
|
+
--color-marker: var(--marker);
|
|
62
|
+
|
|
63
|
+
/* Semantic — switch with ink mode. Product code uses these. */
|
|
64
|
+
--color-page: var(--bg-page);
|
|
65
|
+
--color-sunk: var(--bg-sunk);
|
|
66
|
+
--color-well: var(--bg-well);
|
|
67
|
+
--color-chrome: var(--bg-chrome);
|
|
68
|
+
--color-field: var(--bg-field);
|
|
69
|
+
--color-fg: var(--text-primary);
|
|
70
|
+
--color-fg-secondary: var(--text-secondary);
|
|
71
|
+
--color-fg-label: var(--text-label);
|
|
72
|
+
--color-fg-on-yellow-secondary: var(--text-on-yellow-secondary);
|
|
73
|
+
--color-rule: var(--rule);
|
|
74
|
+
--color-rule-soft: var(--rule-soft);
|
|
75
|
+
--color-hover: var(--hover);
|
|
76
|
+
--color-scrim: var(--scrim);
|
|
77
|
+
|
|
78
|
+
/* Workspace brand (brand.css): the COSX yellow unless a customer's
|
|
79
|
+
colour is injected. Selection and grounds use field; dots and
|
|
80
|
+
progress use mark. Never for text. */
|
|
81
|
+
--color-brand-field: var(--brand-field);
|
|
82
|
+
--color-brand-mark: var(--brand-mark);
|
|
83
|
+
|
|
84
|
+
/* On an ink surface inside a paper page */
|
|
85
|
+
--color-inv: var(--inv-bg);
|
|
86
|
+
--color-inv-raised: var(--inv-bg-raised);
|
|
87
|
+
--color-inv-fg: var(--inv-text);
|
|
88
|
+
--color-inv-fg-secondary: var(--inv-text-secondary);
|
|
89
|
+
--color-inv-rule: var(--inv-rule);
|
|
90
|
+
|
|
91
|
+
/* Product status: yellow, ink and one red. Progress is an ink outline and
|
|
92
|
+
complete a grey dot — they take no colour of their own. */
|
|
93
|
+
--color-attention: var(--status-attention);
|
|
94
|
+
--color-attention-wash: var(--status-attention-wash);
|
|
95
|
+
--color-error: var(--status-error);
|
|
96
|
+
--color-error-text: var(--status-error-text);
|
|
97
|
+
--color-error-wash: var(--status-error-wash);
|
|
98
|
+
|
|
99
|
+
--color-transparent: transparent;
|
|
100
|
+
--color-current: currentColor;
|
|
101
|
+
--color-white: #fff;
|
|
102
|
+
|
|
103
|
+
/* Radius ladder — same names and values as 3.0 spacing.css */
|
|
104
|
+
--radius-xs: 4px;
|
|
105
|
+
--radius-sm: 6px;
|
|
106
|
+
--radius-md: 8px;
|
|
107
|
+
--radius-lg: 16px;
|
|
108
|
+
--radius-xl: 24px;
|
|
109
|
+
--radius-pill: 999px;
|
|
110
|
+
|
|
111
|
+
/* One family. Same stacks as fonts.css (the ones in effect): font-sans
|
|
112
|
+
for Latin, font-sans-cjk for Chinese pages (Geist for Latin and
|
|
113
|
+
figures, Noto Sans SC for hanzi). */
|
|
114
|
+
--font-sans: "Geist Variable", "Geist", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
115
|
+
--font-sans-cjk: "Geist Variable", "Geist", "Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
116
|
+
|
|
117
|
+
--font-weight-normal: 400;
|
|
118
|
+
--font-weight-medium: 500;
|
|
119
|
+
--font-weight-semibold: 600;
|
|
120
|
+
--font-weight-bold: 700;
|
|
121
|
+
|
|
122
|
+
/* Type scale (typography.css). Product UI body is 14px, content 15px. */
|
|
123
|
+
--text-meta: 12px;
|
|
124
|
+
--text-small: 13.5px;
|
|
125
|
+
--text-ui: 14px;
|
|
126
|
+
--text-body: 15px;
|
|
127
|
+
--text-h3: 18px;
|
|
128
|
+
--text-lede: 20px;
|
|
129
|
+
--text-title: 22px;
|
|
130
|
+
--text-figure: 32px;
|
|
131
|
+
--text-h2: var(--size-h2);
|
|
132
|
+
--text-h1: var(--size-h1);
|
|
133
|
+
--text-display: var(--size-display);
|
|
134
|
+
|
|
135
|
+
--tracking-display: -0.025em;
|
|
136
|
+
--tracking-heading: -0.015em;
|
|
137
|
+
--tracking-figure: -0.03em;
|
|
138
|
+
|
|
139
|
+
/* Motion — flat for state, ease-out for entry; nothing bounces */
|
|
140
|
+
--ease-standard: cubic-bezier(.2,0,.2,1);
|
|
141
|
+
--ease-out: cubic-bezier(.16,1,.3,1);
|
|
142
|
+
--ease-in: cubic-bezier(.4,0,1,1);
|
|
143
|
+
}
|