@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.3
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 +28 -0
- package/package.json +6 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +48 -40
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +38 -0
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- package/src/components/overlays.test.tsx +0 -245
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
export type SelectionOptions = {
|
|
4
|
+
/** Every selectable id, in display order (ranges follow this order). */
|
|
5
|
+
ids: readonly string[];
|
|
6
|
+
/** Controlled selection. */
|
|
7
|
+
selected?: ReadonlySet<string> | readonly string[] | undefined;
|
|
8
|
+
/** Uncontrolled starting selection. */
|
|
9
|
+
defaultSelected?: readonly string[] | undefined;
|
|
10
|
+
onChange?: ((selected: Set<string>) => void) | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Selection mode: a plain click toggles instead of opening. Unset: on
|
|
13
|
+
* while anything is selected (ticking one box enters it, clearing leaves).
|
|
14
|
+
*/
|
|
15
|
+
selectionMode?: boolean | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Selection = {
|
|
19
|
+
selected: Set<string>;
|
|
20
|
+
count: number;
|
|
21
|
+
isSelected: (id: string) => boolean;
|
|
22
|
+
/** True when a plain click toggles (see SelectionOptions.selectionMode). */
|
|
23
|
+
selectionMode: boolean;
|
|
24
|
+
toggle: (id: string) => void;
|
|
25
|
+
/** Select from the anchor (last clicked) to id, keeping what is selected. */
|
|
26
|
+
selectRange: (id: string) => void;
|
|
27
|
+
selectAll: () => void;
|
|
28
|
+
clear: () => void;
|
|
29
|
+
set: (ids: Iterable<string>) => void;
|
|
30
|
+
/**
|
|
31
|
+
* The click on an item: ⇧ selects a range, ⌘/Ctrl toggles, a plain click
|
|
32
|
+
* toggles in selection mode. Returns true when it handled the click —
|
|
33
|
+
* otherwise the caller opens the item.
|
|
34
|
+
*/
|
|
35
|
+
handleClick: (id: string, event: Pick<MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>) => boolean;
|
|
36
|
+
/** For the list container: ⌘/Ctrl-A selects all, Esc clears. */
|
|
37
|
+
onKeyDown: (event: KeyboardEvent) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function toSet(v: ReadonlySet<string> | readonly string[] | undefined): Set<string> | undefined {
|
|
41
|
+
if (v === undefined) return undefined;
|
|
42
|
+
return new Set(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* useSelection — the selection model every list and grid shares: click,
|
|
47
|
+
* ⇧-click range, ⌘/Ctrl-click toggle, ⌘A, Esc. In selection mode the whole
|
|
48
|
+
* card or row is the checkbox.
|
|
49
|
+
*/
|
|
50
|
+
export function useSelection({ ids, selected, defaultSelected, onChange, selectionMode }: SelectionOptions): Selection {
|
|
51
|
+
const [inner, setInner] = useState<Set<string>>(() => new Set(defaultSelected ?? []));
|
|
52
|
+
const controlled = toSet(selected);
|
|
53
|
+
const current = useMemo(() => controlled ?? inner, [controlled, inner]);
|
|
54
|
+
const anchor = useRef<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const commit = useCallback(
|
|
57
|
+
(next: Set<string>) => {
|
|
58
|
+
if (!controlled) setInner(next);
|
|
59
|
+
onChange?.(next);
|
|
60
|
+
},
|
|
61
|
+
[controlled, onChange],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const toggle = useCallback(
|
|
65
|
+
(id: string) => {
|
|
66
|
+
const next = new Set(current);
|
|
67
|
+
if (next.has(id)) next.delete(id);
|
|
68
|
+
else next.add(id);
|
|
69
|
+
anchor.current = id;
|
|
70
|
+
commit(next);
|
|
71
|
+
},
|
|
72
|
+
[current, commit],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const selectRange = useCallback(
|
|
76
|
+
(id: string) => {
|
|
77
|
+
const from = anchor.current === null ? -1 : ids.indexOf(anchor.current);
|
|
78
|
+
const to = ids.indexOf(id);
|
|
79
|
+
if (to < 0) return;
|
|
80
|
+
const next = new Set(current);
|
|
81
|
+
if (from < 0) next.add(id);
|
|
82
|
+
else for (let i = Math.min(from, to); i <= Math.max(from, to); i++) next.add(ids[i]!);
|
|
83
|
+
anchor.current = id;
|
|
84
|
+
commit(next);
|
|
85
|
+
},
|
|
86
|
+
[ids, current, commit],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const selectAll = useCallback(() => commit(new Set(ids)), [ids, commit]);
|
|
90
|
+
const clear = useCallback(() => {
|
|
91
|
+
anchor.current = null;
|
|
92
|
+
commit(new Set());
|
|
93
|
+
}, [commit]);
|
|
94
|
+
const set = useCallback((next: Iterable<string>) => commit(new Set(next)), [commit]);
|
|
95
|
+
|
|
96
|
+
const mode = selectionMode ?? current.size > 0;
|
|
97
|
+
|
|
98
|
+
const handleClick = useCallback<Selection['handleClick']>(
|
|
99
|
+
(id, e) => {
|
|
100
|
+
if (e.shiftKey) {
|
|
101
|
+
selectRange(id);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (e.metaKey || e.ctrlKey || mode) {
|
|
105
|
+
toggle(id);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
anchor.current = id;
|
|
109
|
+
return false;
|
|
110
|
+
},
|
|
111
|
+
[mode, selectRange, toggle],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const onKeyDown = useCallback(
|
|
115
|
+
(e: KeyboardEvent) => {
|
|
116
|
+
const target = e.target as HTMLElement | null;
|
|
117
|
+
const typing = target?.closest('input, textarea, [contenteditable="true"]');
|
|
118
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a' && !typing) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
selectAll();
|
|
121
|
+
} else if (e.key === 'Escape' && current.size > 0) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
clear();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[selectAll, clear, current.size],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
selected: current,
|
|
131
|
+
count: current.size,
|
|
132
|
+
isSelected: (id) => current.has(id),
|
|
133
|
+
selectionMode: mode,
|
|
134
|
+
toggle,
|
|
135
|
+
selectRange,
|
|
136
|
+
selectAll,
|
|
137
|
+
clear,
|
|
138
|
+
set,
|
|
139
|
+
handleClick,
|
|
140
|
+
onKeyDown,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,3 +33,41 @@ export { Tab, TabList, TabPanel, Tabs, type TabItem, type TabListProps, type Tab
|
|
|
33
33
|
export { Textarea, type TextareaProps } from './components/Textarea';
|
|
34
34
|
export { Toaster, toast, type ToasterProps, type ToastLabels, type ToastOptions, type ToastStatus } from './components/Toast';
|
|
35
35
|
export { Tooltip, TooltipProvider, type TooltipProps } from './components/Tooltip';
|
|
36
|
+
|
|
37
|
+
// Stage 2 — product patterns
|
|
38
|
+
export { ActionBar, ActionBarProvider, arrange, formatShortcut, useActionBarActivity, useActionBarHidden, useActionBarItems, useActionBarMode, useActionBarSelection, type ActionBarAction, type ActionBarLabels, type ActionBarMode, type ActionBarPresentation, type ActionBarProps, type ActionBarProviderProps, type ActionBarSelection, type ActionBarState } from './components/ActionBar';
|
|
39
|
+
export { ActivityTimeline, initialsOf, type ActivityEvent, type ActivityGroup, type ActivityTimelineProps } from './components/ActivityTimeline';
|
|
40
|
+
export { AsyncSelect, type AsyncSelectProps } from './components/AsyncSelect';
|
|
41
|
+
export { Breadcrumb, type BreadcrumbItem, type BreadcrumbProps } from './components/Breadcrumb';
|
|
42
|
+
export { ChoiceCards, type Choice, type ChoiceCardsProps } from './components/ChoiceCards';
|
|
43
|
+
export { CodeInput, type CodeInputProps } from './components/CodeInput';
|
|
44
|
+
export { CommandPalette, useCommandPaletteHotkey, type CommandItem, type CommandPaletteLabels, type CommandPaletteProps, type CommandSource } from './components/CommandPalette';
|
|
45
|
+
export { ConfirmDialog, PromptDialog, StepUpDialog, type ConfirmDialogProps, type PromptDialogProps, type StepUpDialogProps } from './components/ConfirmDialog';
|
|
46
|
+
export { CopyField, type CopyFieldProps } from './components/CopyField';
|
|
47
|
+
export { DateInput, FuzzyDateInput, type DateInputProps, type DatePrecision, type FuzzyDateInputProps } from './components/DateInput';
|
|
48
|
+
export { Dropzone, refusedText, type DropzoneLabels, type DropzoneProps } from './components/Dropzone';
|
|
49
|
+
export { FileCard, type FileCardProps, type FileCardState } from './components/FileCard';
|
|
50
|
+
export { FileList, FileRow, type FileListProps, type FileRowProps } from './components/FileRow';
|
|
51
|
+
export { FolderTree, type FolderTreeProps, type TreeNode } from './components/FolderTree';
|
|
52
|
+
export { type ListItem } from './components/inputs-listbox';
|
|
53
|
+
export { ListToolbar, type ListFilter, type ListToolbarLabels, type ListToolbarProps, type ListView, type SearchScope } from './components/ListToolbar';
|
|
54
|
+
export { MentionInput, mentionAt, type Mention, type MentionInputProps } from './components/MentionInput';
|
|
55
|
+
export { Menu, MenuCheckboxItem, MenuContent, MenuGroup, MenuItem, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger, type MenuCheckboxItemProps, type MenuContentProps, type MenuItemProps, type MenuSubTriggerProps } from './components/Menu';
|
|
56
|
+
export { PageHeader, type PageHeaderFigure, type PageHeaderProps } from './components/PageHeader';
|
|
57
|
+
export { PageNotice, PageState, type PageNoticeProps, type PageStateKind, type PageStateProps } from './components/PageState';
|
|
58
|
+
export { Progress, progressStep, type ProgressProps, type ProgressSegment } from './components/Progress';
|
|
59
|
+
export { PulseDot, type PulseDotProps } from './components/PulseDot';
|
|
60
|
+
export { RecipientsInput, type Recipient, type RecipientStatus, type RecipientsInputProps } from './components/RecipientsInput';
|
|
61
|
+
export { SearchField, type SearchFieldProps } from './components/SearchField';
|
|
62
|
+
export { SegmentedControl, type Segment, type SegmentedControlProps } from './components/SegmentedControl';
|
|
63
|
+
export { SidePanel, SidePanelProvider, SidePanelSlot, useSidePanel, type SidePanelProps } from './components/SidePanel';
|
|
64
|
+
export { Skeleton, SkeletonCards, SkeletonList, SkeletonViewer, type SkeletonProps, type SkeletonViewerProps } from './components/Skeleton';
|
|
65
|
+
export { StageProgress, type Stage, type StageProgressProps } from './components/StageProgress';
|
|
66
|
+
export { checkFiles, formatBytes, hasFiles, readDataTransfer, type DroppedFile, type FileRules, type Rejected, type RejectReason } from './components/status-files';
|
|
67
|
+
export { Steps, type Step, type StepState, type StepsProps } from './components/Steps';
|
|
68
|
+
export { SyncStatus, type SyncState, type SyncStatusProps } from './components/SyncStatus';
|
|
69
|
+
export { UploadCheck, groupByFolder, type UploadCheckLabels, type UploadCheckProps } from './components/UploadCheck';
|
|
70
|
+
export { UploadFolderRow, UploadList, UploadRow, useBeforeUnloadWhile, type UploadItem, type UploadRowLabels, type UploadRowProps, type UploadState } from './components/UploadList';
|
|
71
|
+
export { useSelection, type Selection, type SelectionOptions } from './components/useSelection';
|
|
72
|
+
export { InfiniteLoader, VirtualList, type InfiniteLoaderProps, type VirtualListProps } from './components/VirtualList';
|
|
73
|
+
export { WindowDrop, useWindowDrop, type WindowDropProps } from './components/WindowDrop';
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
2
|
-
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { Share2, Star } from 'lucide-react';
|
|
4
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import { a11y } from '../../test/a11y';
|
|
7
|
-
import { Badge } from './Badge';
|
|
8
|
-
import { Button, formatCount } from './Button';
|
|
9
|
-
import { Card } from './Card';
|
|
10
|
-
import { Figure } from './Figure';
|
|
11
|
-
import { IconButton } from './IconButton';
|
|
12
|
-
import { Logo } from './Logo';
|
|
13
|
-
import { Marker } from './Marker';
|
|
14
|
-
import { MetaLabel } from './MetaLabel';
|
|
15
|
-
import { Tag } from './Tag';
|
|
16
|
-
import { ThemeProvider, useTheme } from './ThemeProvider';
|
|
17
|
-
import { useButtonAction } from './useButtonAction';
|
|
18
|
-
|
|
19
|
-
describe('Button', () => {
|
|
20
|
-
it('is a type=button that clicks', async () => {
|
|
21
|
-
const onClick = vi.fn();
|
|
22
|
-
render(<Button onClick={onClick}>Publish register</Button>);
|
|
23
|
-
const b = screen.getByRole('button', { name: 'Publish register' });
|
|
24
|
-
expect(b).toHaveAttribute('type', 'button');
|
|
25
|
-
await userEvent.click(b);
|
|
26
|
-
expect(onClick).toHaveBeenCalledOnce();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('disabled with a reason stays focusable, explains itself, ignores clicks', async () => {
|
|
30
|
-
const onClick = vi.fn();
|
|
31
|
-
render(
|
|
32
|
-
<Button disabled disabledReason="Downloads are off for this share" onClick={onClick}>
|
|
33
|
-
Share
|
|
34
|
-
</Button>,
|
|
35
|
-
);
|
|
36
|
-
const b = screen.getByRole('button', { name: 'Share' });
|
|
37
|
-
expect(b).not.toBeDisabled();
|
|
38
|
-
expect(b).toHaveAttribute('aria-disabled', 'true');
|
|
39
|
-
expect(b).toHaveAttribute('title', 'Downloads are off for this share');
|
|
40
|
-
await userEvent.click(b);
|
|
41
|
-
expect(onClick).not.toHaveBeenCalled();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('busy: aria-busy, keeps its name, ignores clicks', async () => {
|
|
45
|
-
const onClick = vi.fn();
|
|
46
|
-
render(
|
|
47
|
-
<Button state="busy" onClick={onClick}>
|
|
48
|
-
Publish
|
|
49
|
-
</Button>,
|
|
50
|
-
);
|
|
51
|
-
const b = screen.getByRole('button', { name: 'Publish' });
|
|
52
|
-
expect(b).toHaveAttribute('aria-busy', 'true');
|
|
53
|
-
await userEvent.click(b);
|
|
54
|
-
expect(onClick).not.toHaveBeenCalled();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('done shows the confirmation in place', () => {
|
|
58
|
-
render(
|
|
59
|
-
<Button state="done" doneLabel="Published">
|
|
60
|
-
Publish
|
|
61
|
-
</Button>,
|
|
62
|
-
);
|
|
63
|
-
expect(screen.getByRole('button')).toHaveTextContent('Published');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('count: hidden at zero, 99+ above 99', () => {
|
|
67
|
-
expect(formatCount(0)).toBeNull();
|
|
68
|
-
expect(formatCount(12)).toBe('12');
|
|
69
|
-
expect(formatCount(140)).toBe('99+');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('primary flips to the yellow on an ink ground', () => {
|
|
73
|
-
render(<Button ground="ink">See what we do</Button>);
|
|
74
|
-
expect(screen.getByRole('button').className).toMatch(/bg-yellow-accent/);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('asChild renders the link with the button look', () => {
|
|
78
|
-
render(
|
|
79
|
-
<Button asChild variant="secondary">
|
|
80
|
-
<a href="/x">Open</a>
|
|
81
|
-
</Button>,
|
|
82
|
-
);
|
|
83
|
-
expect(screen.getByRole('link', { name: 'Open' }).className).toMatch(/rounded-md/);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('passes axe', async () => {
|
|
87
|
-
const { container } = render(
|
|
88
|
-
<div>
|
|
89
|
-
<Button>Publish</Button>
|
|
90
|
-
<Button variant="secondary" count={12}>
|
|
91
|
-
Comments
|
|
92
|
-
</Button>
|
|
93
|
-
<Button disabled disabledReason="Still converting to PDF">
|
|
94
|
-
Download
|
|
95
|
-
</Button>
|
|
96
|
-
</div>,
|
|
97
|
-
);
|
|
98
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe('useButtonAction', () => {
|
|
103
|
-
it('busy → done → idle', async () => {
|
|
104
|
-
vi.useFakeTimers();
|
|
105
|
-
let resolve!: () => void;
|
|
106
|
-
const { result } = renderHook(() => useButtonAction(() => new Promise<void>((r) => (resolve = r))));
|
|
107
|
-
let p!: Promise<void>;
|
|
108
|
-
act(() => {
|
|
109
|
-
p = result.current.run();
|
|
110
|
-
});
|
|
111
|
-
expect(result.current.state).toBe('busy');
|
|
112
|
-
await act(async () => {
|
|
113
|
-
resolve();
|
|
114
|
-
await p;
|
|
115
|
-
});
|
|
116
|
-
expect(result.current.state).toBe('done');
|
|
117
|
-
act(() => vi.advanceTimersByTime(1600));
|
|
118
|
-
expect(result.current.state).toBe('idle');
|
|
119
|
-
vi.useRealTimers();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('a failure returns to idle with the error, and runs are not doubled while busy', async () => {
|
|
123
|
-
const action = vi.fn().mockRejectedValue(new Error("Couldn't reach the mail server."));
|
|
124
|
-
const { result } = renderHook(() => useButtonAction(action));
|
|
125
|
-
await act(async () => {
|
|
126
|
-
await Promise.all([result.current.run(), result.current.run()]);
|
|
127
|
-
});
|
|
128
|
-
expect(action).toHaveBeenCalledOnce();
|
|
129
|
-
expect(result.current.state).toBe('idle');
|
|
130
|
-
expect(String(result.current.error)).toMatch(/mail server/);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe('IconButton', () => {
|
|
135
|
-
it('has a label, and the count joins it', () => {
|
|
136
|
-
render(<IconButton icon={Star} label="Comments" count={120} />);
|
|
137
|
-
expect(screen.getByRole('button', { name: 'Comments (99+)' })).toBeInTheDocument();
|
|
138
|
-
});
|
|
139
|
-
it('passes axe', async () => {
|
|
140
|
-
const { container } = render(<IconButton icon={Share2} label="Share" variant="outline" />);
|
|
141
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe('Badge', () => {
|
|
146
|
-
it('defaults the appearance by status', () => {
|
|
147
|
-
const { container } = render(
|
|
148
|
-
<>
|
|
149
|
-
<Badge status="attention">Awaiting you</Badge>
|
|
150
|
-
<Badge status="progress">In progress</Badge>
|
|
151
|
-
<Badge status="complete">Delivered</Badge>
|
|
152
|
-
</>,
|
|
153
|
-
);
|
|
154
|
-
const [a, p, c] = container.querySelectorAll('[data-status]');
|
|
155
|
-
expect(a!.className).toMatch(/bg-brand-mark/);
|
|
156
|
-
expect(p!.className).toMatch(/border-fg/);
|
|
157
|
-
expect(c!.querySelector('[aria-hidden]')!.className).toMatch(/bg-fg\b/);
|
|
158
|
-
});
|
|
159
|
-
it('unknown status falls back to neutral', () => {
|
|
160
|
-
const { container } = render(<Badge status="quarantined-ish">Draft</Badge>);
|
|
161
|
-
expect(container.querySelector('[data-status]')).toHaveAttribute('data-status', 'neutral');
|
|
162
|
-
});
|
|
163
|
-
it('compact keeps the wording for screen readers and on hover', () => {
|
|
164
|
-
render(
|
|
165
|
-
<Badge status="error" compact>
|
|
166
|
-
Overdue
|
|
167
|
-
</Badge>,
|
|
168
|
-
);
|
|
169
|
-
const text = screen.getByText('Overdue');
|
|
170
|
-
expect(text).toHaveClass('sr-only');
|
|
171
|
-
expect(text.parentElement).toHaveAttribute('title', 'Overdue');
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
describe('Tag', () => {
|
|
176
|
-
it('removes', async () => {
|
|
177
|
-
const onRemove = vi.fn();
|
|
178
|
-
render(
|
|
179
|
-
<Tag onRemove={onRemove} removeLabel="Remove PDF">
|
|
180
|
-
PDF
|
|
181
|
-
</Tag>,
|
|
182
|
-
);
|
|
183
|
-
await userEvent.click(screen.getByRole('button', { name: 'Remove PDF' }));
|
|
184
|
-
expect(onRemove).toHaveBeenCalledOnce();
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('Logo, Card, Figure, Marker, MetaLabel', () => {
|
|
189
|
-
it('logo has an accessible name and never paints itself yellow', () => {
|
|
190
|
-
render(<Logo />);
|
|
191
|
-
const svg = screen.getByRole('img', { name: 'COSX' });
|
|
192
|
-
expect(svg).toHaveAttribute('fill', 'currentColor');
|
|
193
|
-
});
|
|
194
|
-
it('card outline defaults by ground', () => {
|
|
195
|
-
const { container } = render(
|
|
196
|
-
<>
|
|
197
|
-
<Card>a</Card>
|
|
198
|
-
<Card ground="sunk">b</Card>
|
|
199
|
-
</>,
|
|
200
|
-
);
|
|
201
|
-
const [page, sunk] = container.querySelectorAll('div');
|
|
202
|
-
expect(page!.className).toMatch(/border-rule/);
|
|
203
|
-
expect(sunk!.className).not.toMatch(/border-rule/);
|
|
204
|
-
});
|
|
205
|
-
it('figure, marker and label render', async () => {
|
|
206
|
-
const { container } = render(
|
|
207
|
-
<div>
|
|
208
|
-
<Figure value="117" label="Organisations" source="Verified September 2026" />
|
|
209
|
-
<h2>
|
|
210
|
-
Of 47 organisations, <Marker>20 name no regulator</Marker>
|
|
211
|
-
</h2>
|
|
212
|
-
<MetaLabel rule="yellow">03 / Who we serve</MetaLabel>
|
|
213
|
-
</div>,
|
|
214
|
-
);
|
|
215
|
-
expect(container.querySelector('.marker')).toHaveTextContent('20 name no regulator');
|
|
216
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
describe('ThemeProvider', () => {
|
|
221
|
-
it('sets data-mode="ink" when ink is chosen and clears it for light', () => {
|
|
222
|
-
function Switch() {
|
|
223
|
-
const { setMode, resolved } = useTheme();
|
|
224
|
-
return (
|
|
225
|
-
<>
|
|
226
|
-
<span>{resolved}</span>
|
|
227
|
-
<button onClick={() => setMode('ink')}>ink</button>
|
|
228
|
-
<button onClick={() => setMode('light')}>light</button>
|
|
229
|
-
</>
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
render(
|
|
233
|
-
<ThemeProvider storageKey={null}>
|
|
234
|
-
<Switch />
|
|
235
|
-
</ThemeProvider>,
|
|
236
|
-
);
|
|
237
|
-
act(() => screen.getByText('ink', { selector: 'button' }).click());
|
|
238
|
-
expect(document.documentElement).toHaveAttribute('data-mode', 'ink');
|
|
239
|
-
act(() => screen.getByText('light', { selector: 'button' }).click());
|
|
240
|
-
expect(document.documentElement).not.toHaveAttribute('data-mode');
|
|
241
|
-
});
|
|
242
|
-
});
|