@cosxai/ui 1.0.0-alpha.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.
@@ -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/index.ts CHANGED
@@ -2,7 +2,34 @@
2
2
  * @cosxai/ui 1.x — the COSX Design System 3.0 in React.
3
3
  *
4
4
  * Foundations: import "@cosxai/ui/styles.css" (tokens + fonts) and, in a
5
- * Tailwind v4 app, "@cosxai/ui/theme.css". Components arrive in the 1.0
6
- * alphas (docs/workdocs/2026-09-29_feature-ui-1.0).
5
+ * Tailwind v4 app, "@cosxai/ui/theme.css". Plan and status:
6
+ * docs/workdocs/2026-09-29_feature-ui-1.0.
7
7
  */
8
8
  export { cn } from './lib/cn';
9
+
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/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 CHANGED
@@ -14,4 +14,6 @@
14
14
  @import "./tokens/elevation.css";
15
15
  @import "./tokens/motion.css";
16
16
  @import "./tokens/compat.css";
17
+ @import "./brand.css";
18
+ @import "./modes.css";
17
19
  @import "./fonts.css";
package/src/theme.css CHANGED
@@ -16,6 +16,11 @@
16
16
  * (theme.test.ts). In particular font sizes avoid --text-primary /
17
17
  * --text-secondary / --text-label, which are colours in 3.0.
18
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
+
19
24
  @theme inline {
20
25
  --color-*: initial;
21
26
  --radius-*: initial;
@@ -70,6 +75,12 @@
70
75
  --color-hover: var(--hover);
71
76
  --color-scrim: var(--scrim);
72
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
+
73
84
  /* On an ink surface inside a paper page */
74
85
  --color-inv: var(--inv-bg);
75
86
  --color-inv-raised: var(--inv-bg-raised);