@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,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,159 @@
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
+ export type ToasterProps = {
95
+ /** Wording of the status labels and controls (e.g. Chinese). */
96
+ labels?: Partial<ToastLabels> | undefined;
97
+ /** px from the bottom — keep clear of the action bar and the zoom pill. @default 24 */
98
+ offset?: number | undefined;
99
+ };
100
+
101
+ /**
102
+ * Toaster — mount once. Ink notices, bottom right, three at most. Polite
103
+ * announcements (errors assertive); a toast never takes focus, its action
104
+ * is reachable by keyboard (F8 jumps to the region). Radix Toast.
105
+ */
106
+ export function Toaster({ labels, offset = 24 }: ToasterProps) {
107
+ const list = useSyncExternalStore(subscribe, () => entries, () => entries);
108
+ const l = { ...DEFAULT_LABELS, ...labels };
109
+
110
+ return (
111
+ <ToastPrimitive.Provider swipeDirection="right" label={l.region}>
112
+ {list.map((t) => {
113
+ const status = t.status ?? 'neutral';
114
+ return (
115
+ <ToastPrimitive.Root
116
+ key={t.id}
117
+ open
118
+ onOpenChange={(open) => {
119
+ if (!open) set(entries.filter((e) => e.id !== t.id));
120
+ }}
121
+ duration={t.duration ?? (t.action ? 8000 : 4000)}
122
+ type={status === 'error' ? 'foreground' : 'background'}
123
+ className={cn(
124
+ 'flex w-80 max-w-full items-start gap-3 rounded-[12px] bg-ink px-4 py-3.5 font-sans text-linen',
125
+ 'ink:border ink:border-inv-rule ink:bg-ink-raised',
126
+ 'data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=end]:hidden',
127
+ )}
128
+ >
129
+ <span aria-hidden className={cn('mt-[5px] size-2 shrink-0 rounded-pill', DOT[status])} />
130
+ <div className="min-w-0 flex-1">
131
+ <div className={cn('mb-0.5 text-meta font-medium', status === 'attention' ? 'text-yellow-accent' : 'text-grey-inverse')}>{l[status]}</div>
132
+ <ToastPrimitive.Title className="text-ui leading-[1.4] font-medium">{t.title}</ToastPrimitive.Title>
133
+ {t.description && <ToastPrimitive.Description className="mt-0.5 text-[13px] leading-normal text-grey-inverse">{t.description}</ToastPrimitive.Description>}
134
+ {t.action && (
135
+ <ToastPrimitive.Action
136
+ altText={t.action.altText ?? t.action.label}
137
+ onClick={t.action.onClick}
138
+ className="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)]"
139
+ >
140
+ {t.action.label}
141
+ </ToastPrimitive.Action>
142
+ )}
143
+ </div>
144
+ <ToastPrimitive.Close
145
+ aria-label={l.dismiss}
146
+ className="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)]"
147
+ >
148
+ <X size={14} strokeWidth={2} aria-hidden />
149
+ </ToastPrimitive.Close>
150
+ </ToastPrimitive.Root>
151
+ );
152
+ })}
153
+ <ToastPrimitive.Viewport
154
+ 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"
155
+ style={{ bottom: offset }}
156
+ />
157
+ </ToastPrimitive.Provider>
158
+ );
159
+ }
@@ -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
+ }