@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +109 -0
- package/package.json +36 -6
- package/src/brand.css +13 -0
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/AuthLayout.tsx +127 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/BrandLoader.tsx +122 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +186 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- 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 +135 -0
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -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/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/MetaLabel.tsx +38 -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/Radio.tsx +72 -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/Select.tsx +351 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/SyncStatus.tsx +56 -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/ThemeSwitch.tsx +99 -0
- package/src/components/Toast.tsx +196 -0
- package/src/components/Tooltip.tsx +57 -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/WorkspaceRow.tsx +80 -0
- package/src/components/forms-control.ts +36 -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/logo-art.ts +4 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +73 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme-base.css +123 -0
- package/src/theme-reset.css +14 -0
- package/src/theme.css +2 -114
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
|
2
|
+
import { createContext, useContext, type ComponentProps, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { formatCount } from './Button';
|
|
6
|
+
|
|
7
|
+
export type TabsVariant = 'underline' | 'pills';
|
|
8
|
+
|
|
9
|
+
export type TabItem = {
|
|
10
|
+
value: string;
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
/** An attention count after the label: a small yellow badge, hidden at zero. */
|
|
13
|
+
count?: number | undefined;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const VariantContext = createContext<TabsVariant>('underline');
|
|
18
|
+
|
|
19
|
+
export type TabsProps = Omit<ComponentProps<typeof TabsPrimitive.Root>, 'onValueChange' | 'onChange' | 'children'> & {
|
|
20
|
+
/** underline for product UI · pills for marketing sections (active one on the brand field). @default "underline" */
|
|
21
|
+
variant?: TabsVariant | undefined;
|
|
22
|
+
/** Called with the new tab's value. Keep the active tab in the URL so refresh and sharing keep it. */
|
|
23
|
+
onChange?: ((value: string) => void) | undefined;
|
|
24
|
+
/** Shorthand: renders the tab list from these. Two to seven; beyond that use More or a side menu. */
|
|
25
|
+
items?: TabItem[] | undefined;
|
|
26
|
+
/** Accessible name of the tab list (with `items`). */
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
/** TabPanels (and, without `items`, a TabList). */
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tabs — switch between views of the same object (Overview, Documents,
|
|
34
|
+
* Parties). Switching changes no data; segments change how the same
|
|
35
|
+
* content shows, tabs change the content. Arrow keys move, Home/End jump
|
|
36
|
+
* (Radix Tabs).
|
|
37
|
+
*/
|
|
38
|
+
export function Tabs({ variant = 'underline', onChange, items, label, className, children, ...rest }: TabsProps) {
|
|
39
|
+
return (
|
|
40
|
+
<VariantContext.Provider value={variant}>
|
|
41
|
+
<TabsPrimitive.Root {...(onChange ? { onValueChange: onChange } : {})} className={cn('flex flex-col', className)} {...rest}>
|
|
42
|
+
{items && (
|
|
43
|
+
<TabList aria-label={label}>
|
|
44
|
+
{items.map((it) => (
|
|
45
|
+
<Tab key={it.value} value={it.value} count={it.count} disabled={it.disabled}>
|
|
46
|
+
{it.label}
|
|
47
|
+
</Tab>
|
|
48
|
+
))}
|
|
49
|
+
</TabList>
|
|
50
|
+
)}
|
|
51
|
+
{children}
|
|
52
|
+
</TabsPrimitive.Root>
|
|
53
|
+
</VariantContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TabListProps = ComponentProps<typeof TabsPrimitive.List>;
|
|
58
|
+
|
|
59
|
+
export function TabList({ className, ...rest }: TabListProps) {
|
|
60
|
+
const variant = useContext(VariantContext);
|
|
61
|
+
return (
|
|
62
|
+
<TabsPrimitive.List
|
|
63
|
+
className={cn(variant === 'pills' ? 'flex gap-2.5 [counter-reset:cosx-tab]' : 'flex gap-0.5 border-b border-rule', className)}
|
|
64
|
+
{...rest}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type TabProps = ComponentProps<typeof TabsPrimitive.Trigger> & {
|
|
70
|
+
count?: number | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function Tab({ className, children, count, ...rest }: TabProps) {
|
|
74
|
+
const variant = useContext(VariantContext);
|
|
75
|
+
const shown = count === undefined ? null : formatCount(count);
|
|
76
|
+
const badge = shown && (
|
|
77
|
+
<span className="min-w-[18px] rounded-pill bg-brand-mark px-1.5 text-[11px] leading-[18px] font-semibold text-ink tabular-nums">
|
|
78
|
+
{shown}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (variant === 'pills') {
|
|
83
|
+
return (
|
|
84
|
+
<TabsPrimitive.Trigger
|
|
85
|
+
className={cn(
|
|
86
|
+
'group flex flex-1 cursor-pointer items-center gap-3 rounded-md border-0 bg-sunk px-[18px] py-3.5 text-left font-sans text-ui font-medium text-fg outline-none',
|
|
87
|
+
'transition-colors duration-[120ms] ease-standard focus-visible:shadow-(--focus-ring)',
|
|
88
|
+
'data-[state=active]:bg-brand-field data-[state=active]:text-ink',
|
|
89
|
+
'disabled:cursor-not-allowed disabled:opacity-40',
|
|
90
|
+
'[counter-increment:cosx-tab] before:text-meta before:font-normal before:text-fg-secondary before:content-[counter(cosx-tab,decimal-leading-zero)] data-[state=active]:before:text-ink',
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
{...rest}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
{badge && ' '}
|
|
97
|
+
{badge}
|
|
98
|
+
</TabsPrimitive.Trigger>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<TabsPrimitive.Trigger
|
|
104
|
+
className={cn(
|
|
105
|
+
'-mb-px inline-flex cursor-pointer items-center gap-2 border-0 border-b-2 border-transparent bg-transparent px-3 py-2.5 font-sans text-small text-fg-secondary outline-none',
|
|
106
|
+
'transition-colors duration-[120ms] ease-standard hover:text-fg focus-visible:shadow-(--focus-ring)',
|
|
107
|
+
'data-[state=active]:border-fg data-[state=active]:font-medium data-[state=active]:text-fg',
|
|
108
|
+
'disabled:cursor-not-allowed disabled:opacity-40',
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
{...rest}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
{badge && ' '}
|
|
115
|
+
{badge}
|
|
116
|
+
</TabsPrimitive.Trigger>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TabPanelProps = ComponentProps<typeof TabsPrimitive.Content>;
|
|
121
|
+
|
|
122
|
+
export function TabPanel({ className, ...rest }: TabPanelProps) {
|
|
123
|
+
return <TabsPrimitive.Content className={cn('pt-4 outline-none focus-visible:shadow-(--focus-ring)', className)} {...rest} />;
|
|
124
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { X } from 'lucide-react';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
export type TagProps = Omit<ComponentProps<'span'>, 'children'> & {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Shows a remove button. */
|
|
9
|
+
onRemove?: (() => void) | undefined;
|
|
10
|
+
/** Accessible name of the remove button. @default "Remove" */
|
|
11
|
+
removeLabel?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Tag — quiet metadata on linen: format, origin, category, permission.
|
|
15
|
+
* Attributes are tags, never status colours (that is Badge). */
|
|
16
|
+
export function Tag({ children, onRemove, removeLabel = 'Remove', className, ...rest }: TagProps) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
className={cn('inline-flex items-center gap-1.5 rounded-md border border-rule-soft bg-sunk px-[7px] py-[3px] text-meta leading-[1.3] text-fg-secondary', className)}
|
|
20
|
+
{...rest}
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
{onRemove && (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onRemove}
|
|
27
|
+
aria-label={removeLabel}
|
|
28
|
+
className="-mr-0.5 inline-grid cursor-pointer place-items-center rounded-xs text-fg-secondary outline-none hover:text-fg focus-visible:shadow-(--focus-ring)"
|
|
29
|
+
>
|
|
30
|
+
<X size={12} strokeWidth={2} aria-hidden />
|
|
31
|
+
</button>
|
|
32
|
+
)}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { Field, useField } from './Field';
|
|
5
|
+
import { controlFrame, controlInner } from './forms-control';
|
|
6
|
+
|
|
7
|
+
export type TextareaProps = ComponentProps<'textarea'> & {
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
hint?: ReactNode;
|
|
10
|
+
error?: ReactNode;
|
|
11
|
+
invalid?: boolean | undefined;
|
|
12
|
+
/** Class for the outer frame (className goes to the <textarea>). */
|
|
13
|
+
frameClassName?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function Control({ invalid, frameClassName, className, disabled, readOnly, rows = 4, id, ...rest }: Omit<TextareaProps, 'label' | 'hint' | 'error'>) {
|
|
17
|
+
const field = useField();
|
|
18
|
+
const bad = Boolean(invalid || field?.invalid);
|
|
19
|
+
const off = Boolean(disabled || field?.disabled);
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn(controlFrame({ invalid: bad, disabled: off, readOnly: Boolean(readOnly) && !off }), 'h-auto items-stretch py-2', frameClassName)}>
|
|
22
|
+
<textarea
|
|
23
|
+
{...rest}
|
|
24
|
+
id={id ?? field?.id}
|
|
25
|
+
rows={rows}
|
|
26
|
+
disabled={off}
|
|
27
|
+
readOnly={readOnly}
|
|
28
|
+
aria-invalid={bad || undefined}
|
|
29
|
+
aria-describedby={cn(rest['aria-describedby'], field?.describedBy) || undefined}
|
|
30
|
+
className={cn(controlInner, 'resize-y leading-[1.55]', className)}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Textarea — multi-line text in the same sunk frame as Input. */
|
|
37
|
+
export function Textarea({ label, hint, error, required, ...rest }: TextareaProps) {
|
|
38
|
+
if (label !== undefined || hint !== undefined || error !== undefined) {
|
|
39
|
+
return (
|
|
40
|
+
<Field label={label} hint={hint} error={error} required={required} disabled={rest.disabled} id={rest.id}>
|
|
41
|
+
<Control {...rest} required={required} />
|
|
42
|
+
</Field>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return <Control {...rest} required={required} />;
|
|
46
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** system follows the OS; light is paper; ink is the dark page. */
|
|
4
|
+
export type ThemeMode = 'system' | 'light' | 'ink';
|
|
5
|
+
|
|
6
|
+
type ThemeContextValue = {
|
|
7
|
+
/** What the person chose. */
|
|
8
|
+
mode: ThemeMode;
|
|
9
|
+
/** What is showing now (system resolved). */
|
|
10
|
+
resolved: 'light' | 'ink';
|
|
11
|
+
setMode: (mode: ThemeMode) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
15
|
+
const QUERY = '(prefers-color-scheme: dark)';
|
|
16
|
+
|
|
17
|
+
function readStored(key: string | null): ThemeMode | null {
|
|
18
|
+
if (!key) return null;
|
|
19
|
+
try {
|
|
20
|
+
const v = localStorage.getItem(key);
|
|
21
|
+
return v === 'system' || v === 'light' || v === 'ink' ? v : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ThemeProviderProps = {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** @default "system" */
|
|
30
|
+
defaultMode?: ThemeMode | undefined;
|
|
31
|
+
/** localStorage key for the choice; null keeps it in memory only. @default "cosx-theme" */
|
|
32
|
+
storageKey?: string | null | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ThemeProvider — system (default) / light / ink. Sets data-mode="ink" on
|
|
37
|
+
* <html> when the ink page shows (the tokens switch on it); follows the OS
|
|
38
|
+
* live in system mode.
|
|
39
|
+
*/
|
|
40
|
+
export function ThemeProvider({ children, defaultMode = 'system', storageKey = 'cosx-theme' }: ThemeProviderProps) {
|
|
41
|
+
const [mode, setModeState] = useState<ThemeMode>(() => readStored(storageKey) ?? defaultMode);
|
|
42
|
+
const [systemInk, setSystemInk] = useState(() => typeof window !== 'undefined' && window.matchMedia?.(QUERY).matches === true);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const mq = window.matchMedia?.(QUERY);
|
|
46
|
+
if (!mq) return;
|
|
47
|
+
const onChange = (e: MediaQueryListEvent) => setSystemInk(e.matches);
|
|
48
|
+
mq.addEventListener('change', onChange);
|
|
49
|
+
return () => mq.removeEventListener('change', onChange);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const resolved: 'light' | 'ink' = mode === 'system' ? (systemInk ? 'ink' : 'light') : mode;
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const root = document.documentElement;
|
|
56
|
+
if (resolved === 'ink') root.setAttribute('data-mode', 'ink');
|
|
57
|
+
else root.removeAttribute('data-mode');
|
|
58
|
+
}, [resolved]);
|
|
59
|
+
|
|
60
|
+
const setMode = useCallback(
|
|
61
|
+
(next: ThemeMode) => {
|
|
62
|
+
setModeState(next);
|
|
63
|
+
if (!storageKey) return;
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(storageKey, next);
|
|
66
|
+
} catch {
|
|
67
|
+
/* storage unavailable: the choice lasts for this page */
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[storageKey],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const value = useMemo(() => ({ mode, resolved, setMode }), [mode, resolved, setMode]);
|
|
74
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The current theme and a setter. Outside a ThemeProvider: light, no-op. */
|
|
78
|
+
export function useTheme(): ThemeContextValue {
|
|
79
|
+
return useContext(ThemeContext) ?? { mode: 'light', resolved: 'light', setMode: () => {} };
|
|
80
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Monitor, Moon, Sun, type LucideIcon } from 'lucide-react';
|
|
2
|
+
import { useRef, useState, type ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
import { useTheme, type ThemeMode } from './ThemeProvider';
|
|
6
|
+
|
|
7
|
+
export type ThemeSwitchLabels = {
|
|
8
|
+
/** @default "Theme" */
|
|
9
|
+
group?: string | undefined;
|
|
10
|
+
/** @default "Theme: match system" */
|
|
11
|
+
system?: string | undefined;
|
|
12
|
+
/** @default "Theme: light" */
|
|
13
|
+
light?: string | undefined;
|
|
14
|
+
/** @default "Theme: dark" */
|
|
15
|
+
ink?: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ThemeSwitchProps = Omit<ComponentProps<'div'>, 'onChange' | 'children'> & {
|
|
19
|
+
/** The choice. Unset: the ThemeProvider's. */
|
|
20
|
+
value?: ThemeMode | undefined;
|
|
21
|
+
/** Unset: the ThemeProvider's setter. */
|
|
22
|
+
onChange?: ((mode: ThemeMode) => void) | undefined;
|
|
23
|
+
labels?: ThemeSwitchLabels | undefined;
|
|
24
|
+
/** Start opened (documentation specimens); it folds once the pointer leaves. */
|
|
25
|
+
defaultOpen?: boolean | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A click on an option only picks it once the switch has been open this long. */
|
|
29
|
+
const PICK_AFTER_MS = 800;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ThemeSwitch — match system · light · dark, folded to the current choice.
|
|
33
|
+
* Hovering or focusing opens all three. A click anywhere steps to the next;
|
|
34
|
+
* a click on an option picks it only once the switch has been open a moment,
|
|
35
|
+
* so the first tap on touch (which opens it) steps instead of landing on
|
|
36
|
+
* whatever slid under the finger.
|
|
37
|
+
*/
|
|
38
|
+
export function ThemeSwitch({ value, onChange, labels, defaultOpen = false, className, ...rest }: ThemeSwitchProps) {
|
|
39
|
+
const theme = useTheme();
|
|
40
|
+
const mode = value ?? theme.mode;
|
|
41
|
+
const setMode = onChange ?? theme.setMode;
|
|
42
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
43
|
+
const openedAt = useRef(0);
|
|
44
|
+
const show = () => {
|
|
45
|
+
if (open) return;
|
|
46
|
+
openedAt.current = Date.now();
|
|
47
|
+
setOpen(true);
|
|
48
|
+
};
|
|
49
|
+
const options: Array<[ThemeMode, LucideIcon, string]> = [
|
|
50
|
+
['system', Monitor, labels?.system ?? 'Theme: match system'],
|
|
51
|
+
['light', Sun, labels?.light ?? 'Theme: light'],
|
|
52
|
+
['ink', Moon, labels?.ink ?? 'Theme: dark'],
|
|
53
|
+
];
|
|
54
|
+
const cycle = () => {
|
|
55
|
+
const i = options.findIndex(([v]) => v === mode);
|
|
56
|
+
setMode(options[(i + 1) % options.length]![0]);
|
|
57
|
+
};
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="radiogroup"
|
|
61
|
+
aria-label={labels?.group ?? 'Theme'}
|
|
62
|
+
className={cn('inline-flex cursor-pointer rounded-[10px] bg-sunk p-[3px] transition-[gap] duration-[180ms] ease-standard', open ? 'gap-0.5' : 'gap-0', className)}
|
|
63
|
+
onClick={cycle}
|
|
64
|
+
onMouseEnter={show}
|
|
65
|
+
onMouseLeave={() => setOpen(false)}
|
|
66
|
+
onFocus={show}
|
|
67
|
+
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && setOpen(false)}
|
|
68
|
+
{...rest}
|
|
69
|
+
>
|
|
70
|
+
{options.map(([v, Glyph, label]) => {
|
|
71
|
+
const on = mode === v;
|
|
72
|
+
const shown = open || on;
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
key={v}
|
|
76
|
+
type="button"
|
|
77
|
+
role="radio"
|
|
78
|
+
aria-checked={on}
|
|
79
|
+
aria-label={label}
|
|
80
|
+
title={label}
|
|
81
|
+
tabIndex={shown ? 0 : -1}
|
|
82
|
+
onClick={(e) => {
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
if (open && Date.now() - openedAt.current > PICK_AFTER_MS) setMode(v);
|
|
85
|
+
else cycle();
|
|
86
|
+
}}
|
|
87
|
+
className={cn(
|
|
88
|
+
'grid h-7 cursor-pointer place-items-center overflow-hidden rounded-[8px] border-0 p-0 outline-none transition-[width,opacity] duration-[180ms] ease-standard focus-visible:shadow-(--focus-ring)',
|
|
89
|
+
shown ? 'w-[30px] opacity-100' : 'w-0 opacity-0',
|
|
90
|
+
on ? 'bg-page text-fg' : 'bg-transparent text-fg-secondary',
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<Glyph size={15} strokeWidth={1.75} aria-hidden />
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as ToastPrimitive from '@radix-ui/react-toast';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useSyncExternalStore, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn';
|
|
6
|
+
|
|
7
|
+
export type ToastStatus = 'neutral' | 'complete' | 'attention' | 'error' | 'progress';
|
|
8
|
+
|
|
9
|
+
export type ToastOptions = {
|
|
10
|
+
/** One sentence in the past tense: "3 files moved to Archive." Errors say why and what next. */
|
|
11
|
+
title: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
/** Sets the dot and the label. @default "neutral" */
|
|
14
|
+
status?: ToastStatus | undefined;
|
|
15
|
+
/** At most one: Undo or Retry. With an action the toast stays 8s and pauses on hover. */
|
|
16
|
+
action?: { label: string; onClick: () => void; altText?: string | undefined } | undefined;
|
|
17
|
+
/** ms. @default 4000, 8000 with an action */
|
|
18
|
+
duration?: number | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Entry = ToastOptions & { id: number };
|
|
22
|
+
|
|
23
|
+
/** At most three at once; the oldest leaves first. */
|
|
24
|
+
const MAX_VISIBLE = 3;
|
|
25
|
+
/** The same error shows once per 30 seconds. */
|
|
26
|
+
const REPEAT_WINDOW = 30_000;
|
|
27
|
+
|
|
28
|
+
let entries: Entry[] = [];
|
|
29
|
+
let nextId = 1;
|
|
30
|
+
const lastShown = new Map<string, number>();
|
|
31
|
+
const listeners = new Set<() => void>();
|
|
32
|
+
const emit = () => listeners.forEach((l) => l());
|
|
33
|
+
|
|
34
|
+
function set(next: Entry[]) {
|
|
35
|
+
entries = next;
|
|
36
|
+
emit();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* toast — report a result that happened off the page (a copy, a background
|
|
41
|
+
* move, an upload finishing elsewhere). When the result can show in place
|
|
42
|
+
* (a button, a row, a field), don't toast. Needs a <Toaster/> mounted once.
|
|
43
|
+
* Returns the toast id, or null when an identical error is suppressed.
|
|
44
|
+
*/
|
|
45
|
+
export function toast(options: ToastOptions): number | null {
|
|
46
|
+
const status = options.status ?? 'neutral';
|
|
47
|
+
if (status === 'error' && typeof options.title === 'string') {
|
|
48
|
+
const key = `error:${options.title}`;
|
|
49
|
+
const at = lastShown.get(key);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
if (at !== undefined && now - at < REPEAT_WINDOW) return null;
|
|
52
|
+
lastShown.set(key, now);
|
|
53
|
+
}
|
|
54
|
+
const id = nextId++;
|
|
55
|
+
// The oldest leaves when a fourth arrives.
|
|
56
|
+
set([...entries, { ...options, status, id }].slice(-MAX_VISIBLE));
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Close a toast early. */
|
|
61
|
+
toast.dismiss = (id: number) => set(entries.filter((e) => e.id !== id));
|
|
62
|
+
|
|
63
|
+
/** Test and story helper: forget every toast and the repeat window. */
|
|
64
|
+
toast.reset = () => {
|
|
65
|
+
lastShown.clear();
|
|
66
|
+
set([]);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function subscribe(l: () => void) {
|
|
70
|
+
listeners.add(l);
|
|
71
|
+
return () => listeners.delete(l);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DOT: Record<ToastStatus, string> = {
|
|
75
|
+
neutral: 'bg-grey-inverse',
|
|
76
|
+
progress: 'bg-grey-inverse',
|
|
77
|
+
complete: 'bg-linen',
|
|
78
|
+
attention: 'bg-yellow-accent',
|
|
79
|
+
error: 'bg-error',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ToastLabels = Record<ToastStatus, string> & { dismiss: string; region: string };
|
|
83
|
+
|
|
84
|
+
const DEFAULT_LABELS: ToastLabels = {
|
|
85
|
+
neutral: 'Notice',
|
|
86
|
+
complete: 'Done',
|
|
87
|
+
attention: 'Attention',
|
|
88
|
+
error: 'Failed',
|
|
89
|
+
progress: 'In progress',
|
|
90
|
+
dismiss: 'Dismiss',
|
|
91
|
+
region: 'Notifications',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const CARD = 'flex w-80 max-w-full items-start gap-3 rounded-[12px] bg-ink px-4 py-3.5 font-sans text-linen ink:border ink:border-inv-rule ink:bg-ink-raised';
|
|
95
|
+
const ACTION =
|
|
96
|
+
'mt-2.5 inline-flex h-8 cursor-pointer items-center rounded-md border border-inv-rule bg-transparent px-3 text-[13px] font-semibold text-linen outline-none hover:border-linen focus-visible:shadow-[0_0_0_3px_var(--ink),0_0_0_5px_var(--yellow)]';
|
|
97
|
+
const CLOSE = 'flex cursor-pointer rounded-xs border-0 bg-transparent p-0.5 text-grey-inverse outline-none hover:text-linen focus-visible:shadow-[0_0_0_2px_var(--yellow)]';
|
|
98
|
+
const statusLabelClass = (status: ToastStatus) => cn('mb-0.5 text-meta font-medium', status === 'attention' ? 'text-yellow-accent' : 'text-grey-inverse');
|
|
99
|
+
|
|
100
|
+
export type ToastCardProps = Omit<ToastOptions, 'duration' | 'action'> & {
|
|
101
|
+
/** A label, or the full action (label + onClick). */
|
|
102
|
+
action?: string | ToastOptions['action'];
|
|
103
|
+
onClose?: (() => void) | undefined;
|
|
104
|
+
labels?: Partial<ToastLabels> | undefined;
|
|
105
|
+
className?: string | undefined;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ToastCard — one toast drawn in place, not queued: documentation
|
|
110
|
+
* specimens and previews. In the product, call toast() instead.
|
|
111
|
+
*/
|
|
112
|
+
export function ToastCard({ title, description, status = 'neutral', action, onClose, labels, className }: ToastCardProps) {
|
|
113
|
+
const l = { ...DEFAULT_LABELS, ...labels };
|
|
114
|
+
const act = typeof action === 'string' ? { label: action, onClick: () => {} } : action;
|
|
115
|
+
return (
|
|
116
|
+
<div className={cn(CARD, className)}>
|
|
117
|
+
<span aria-hidden className={cn('mt-[5px] size-2 shrink-0 rounded-pill', DOT[status])} />
|
|
118
|
+
<div className="min-w-0 flex-1">
|
|
119
|
+
<div className={statusLabelClass(status)}>{l[status]}</div>
|
|
120
|
+
<div className="text-ui leading-[1.4] font-medium">{title}</div>
|
|
121
|
+
{description && <div className="mt-0.5 text-[13px] leading-normal text-grey-inverse">{description}</div>}
|
|
122
|
+
{act && (
|
|
123
|
+
<button type="button" onClick={act.onClick} className={ACTION}>
|
|
124
|
+
{act.label}
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
<button type="button" aria-label={l.dismiss} onClick={onClose} className={CLOSE}>
|
|
129
|
+
<X size={14} strokeWidth={2} aria-hidden />
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ToasterProps = {
|
|
136
|
+
/** Wording of the status labels and controls (e.g. Chinese). */
|
|
137
|
+
labels?: Partial<ToastLabels> | undefined;
|
|
138
|
+
/** px from the bottom — keep clear of the action bar and the zoom pill. @default 24 */
|
|
139
|
+
offset?: number | undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Toaster — mount once. Ink notices, bottom right, three at most. Polite
|
|
144
|
+
* announcements (errors assertive); a toast never takes focus, its action
|
|
145
|
+
* is reachable by keyboard (F8 jumps to the region). Radix Toast.
|
|
146
|
+
*/
|
|
147
|
+
export function Toaster({ labels, offset = 24 }: ToasterProps) {
|
|
148
|
+
const list = useSyncExternalStore(subscribe, () => entries, () => entries);
|
|
149
|
+
const l = { ...DEFAULT_LABELS, ...labels };
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<ToastPrimitive.Provider swipeDirection="right" label={l.region}>
|
|
153
|
+
{list.map((t) => {
|
|
154
|
+
const status = t.status ?? 'neutral';
|
|
155
|
+
return (
|
|
156
|
+
<ToastPrimitive.Root
|
|
157
|
+
key={t.id}
|
|
158
|
+
open
|
|
159
|
+
onOpenChange={(open) => {
|
|
160
|
+
if (!open) set(entries.filter((e) => e.id !== t.id));
|
|
161
|
+
}}
|
|
162
|
+
duration={t.duration ?? (t.action ? 8000 : 4000)}
|
|
163
|
+
type={status === 'error' ? 'foreground' : 'background'}
|
|
164
|
+
className={cn(CARD, 'data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=end]:hidden')}
|
|
165
|
+
>
|
|
166
|
+
<span aria-hidden className={cn('mt-[5px] size-2 shrink-0 rounded-pill', DOT[status])} />
|
|
167
|
+
<div className="min-w-0 flex-1">
|
|
168
|
+
<div className={statusLabelClass(status)}>{l[status]}</div>
|
|
169
|
+
<ToastPrimitive.Title className="text-ui leading-[1.4] font-medium">{t.title}</ToastPrimitive.Title>
|
|
170
|
+
{t.description && <ToastPrimitive.Description className="mt-0.5 text-[13px] leading-normal text-grey-inverse">{t.description}</ToastPrimitive.Description>}
|
|
171
|
+
{t.action && (
|
|
172
|
+
<ToastPrimitive.Action
|
|
173
|
+
altText={t.action.altText ?? t.action.label}
|
|
174
|
+
onClick={t.action.onClick}
|
|
175
|
+
className={ACTION}
|
|
176
|
+
>
|
|
177
|
+
{t.action.label}
|
|
178
|
+
</ToastPrimitive.Action>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
<ToastPrimitive.Close
|
|
182
|
+
aria-label={l.dismiss}
|
|
183
|
+
className={CLOSE}
|
|
184
|
+
>
|
|
185
|
+
<X size={14} strokeWidth={2} aria-hidden />
|
|
186
|
+
</ToastPrimitive.Close>
|
|
187
|
+
</ToastPrimitive.Root>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
<ToastPrimitive.Viewport
|
|
191
|
+
className="fixed right-6 z-[60] m-0 flex max-w-[calc(100vw-48px)] list-none flex-col gap-2.5 p-0 outline-none"
|
|
192
|
+
style={{ bottom: offset }}
|
|
193
|
+
/>
|
|
194
|
+
</ToastPrimitive.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/cn';
|
|
5
|
+
|
|
6
|
+
/** Shares the open delay across tooltips (moving between them is instant). Optional: each Tooltip brings one. */
|
|
7
|
+
export const TooltipProvider = TooltipPrimitive.Provider;
|
|
8
|
+
|
|
9
|
+
export type TooltipProps = {
|
|
10
|
+
/** The hint. Never the only way to reach information — a disabled reason also goes in the page or aria. */
|
|
11
|
+
content: ReactNode;
|
|
12
|
+
/** One focusable element (a button, a link). */
|
|
13
|
+
children: ReactElement;
|
|
14
|
+
/** Preferred side; flips to fit (placement auto). @default "top" */
|
|
15
|
+
side?: 'top' | 'bottom' | 'left' | 'right' | undefined;
|
|
16
|
+
align?: 'start' | 'center' | 'end' | undefined;
|
|
17
|
+
/** ms before it opens on hover; on keyboard focus it opens at once. @default 300 */
|
|
18
|
+
delay?: number | undefined;
|
|
19
|
+
open?: boolean | undefined;
|
|
20
|
+
defaultOpen?: boolean | undefined;
|
|
21
|
+
onOpenChange?: ((open: boolean) => void) | undefined;
|
|
22
|
+
className?: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tooltip — an ink chip with linen text; no shadow. Opens on hover and on
|
|
27
|
+
* keyboard focus, closes on Esc; flips to stay in view (Radix Tooltip).
|
|
28
|
+
* On an ink page it inverts to linen.
|
|
29
|
+
*/
|
|
30
|
+
export function Tooltip({ content, children, side = 'top', align = 'center', delay = 300, open, defaultOpen, onOpenChange, className }: TooltipProps) {
|
|
31
|
+
return (
|
|
32
|
+
<TooltipPrimitive.Provider delayDuration={delay}>
|
|
33
|
+
<TooltipPrimitive.Root
|
|
34
|
+
{...(open !== undefined ? { open } : {})}
|
|
35
|
+
{...(defaultOpen !== undefined ? { defaultOpen } : {})}
|
|
36
|
+
{...(onOpenChange ? { onOpenChange } : {})}
|
|
37
|
+
>
|
|
38
|
+
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
|
39
|
+
<TooltipPrimitive.Portal>
|
|
40
|
+
<TooltipPrimitive.Content
|
|
41
|
+
side={side}
|
|
42
|
+
align={align}
|
|
43
|
+
sideOffset={6}
|
|
44
|
+
collisionPadding={8}
|
|
45
|
+
className={cn(
|
|
46
|
+
'z-[70] max-w-[260px] rounded-md bg-ink px-[9px] py-[5px] font-sans text-meta leading-[1.4] text-linen',
|
|
47
|
+
'ink:bg-linen ink:text-ink',
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{content}
|
|
52
|
+
</TooltipPrimitive.Content>
|
|
53
|
+
</TooltipPrimitive.Portal>
|
|
54
|
+
</TooltipPrimitive.Root>
|
|
55
|
+
</TooltipPrimitive.Provider>
|
|
56
|
+
);
|
|
57
|
+
}
|