@attrus-ds/core 0.1.1 → 0.2.0

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.
Files changed (55) hide show
  1. package/README.md +63 -9
  2. package/dist/css/components/buttons.css +1 -1
  3. package/dist/css/components/combobox.css +17 -1
  4. package/dist/css/components/inputs.css +3 -55
  5. package/dist/css/components/overlays.css +28 -59
  6. package/dist/css/components/primitives.css +10 -1
  7. package/dist/css/components/sidebar.css +3 -3
  8. package/dist/css/components/smart-search.css +91 -0
  9. package/dist/css/components/table.css +10 -8
  10. package/dist/css/components/tabs.css +2 -2
  11. package/dist/css/components/toast.css +4 -0
  12. package/dist/css/components/tooltip.css +5 -0
  13. package/dist/css/index.css +1 -0
  14. package/dist/react/AppSidebar/AppSidebar.d.ts +50 -0
  15. package/dist/react/AppSidebar/AppSidebar.tsx +197 -0
  16. package/dist/react/AppTopbar/AppTopbar.d.ts +58 -0
  17. package/dist/react/AppTopbar/AppTopbar.tsx +203 -0
  18. package/dist/react/Atoms/Atoms.d.ts +149 -0
  19. package/dist/react/Atoms/Atoms.tsx +407 -0
  20. package/dist/react/Button/Button.d.ts +25 -0
  21. package/dist/react/Button/Button.tsx +83 -0
  22. package/dist/react/Callout/Callout.d.ts +29 -0
  23. package/dist/react/Callout/Callout.tsx +98 -0
  24. package/dist/react/Card/Card.d.ts +38 -0
  25. package/dist/react/Card/Card.tsx +93 -0
  26. package/dist/react/Combobox/Combobox.d.ts +52 -0
  27. package/dist/react/Combobox/Combobox.tsx +254 -0
  28. package/dist/react/DataTable/DataTable.d.ts +81 -0
  29. package/dist/react/DataTable/DataTable.tsx +308 -0
  30. package/dist/react/Drawer/Drawer.d.ts +35 -0
  31. package/dist/react/Drawer/Drawer.tsx +226 -0
  32. package/dist/react/Input/Input.d.ts +155 -0
  33. package/dist/react/Input/Input.tsx +478 -0
  34. package/dist/react/Modal/Modal.d.ts +27 -0
  35. package/dist/react/Modal/Modal.tsx +173 -0
  36. package/dist/react/NumberInput/NumberInput.d.ts +25 -0
  37. package/dist/react/NumberInput/NumberInput.tsx +117 -0
  38. package/dist/react/Pill/Pill.d.ts +31 -0
  39. package/dist/react/Pill/Pill.tsx +104 -0
  40. package/dist/react/SearchPill/SearchPill.d.ts +14 -0
  41. package/dist/react/SearchPill/SearchPill.tsx +40 -0
  42. package/dist/react/SmartSearch/SmartSearch.d.ts +65 -0
  43. package/dist/react/SmartSearch/SmartSearch.tsx +271 -0
  44. package/dist/react/Stepper/Stepper.d.ts +25 -0
  45. package/dist/react/Stepper/Stepper.tsx +94 -0
  46. package/dist/react/Tabs/Tabs.d.ts +32 -0
  47. package/dist/react/Tabs/Tabs.tsx +147 -0
  48. package/dist/react/Toast/Toast.d.ts +30 -0
  49. package/dist/react/Toast/Toast.tsx +94 -0
  50. package/dist/react/Tooltip/Tooltip.d.ts +23 -0
  51. package/dist/react/Tooltip/Tooltip.tsx +80 -0
  52. package/dist/react/ZeroState/ZeroState.d.ts +17 -0
  53. package/dist/react/ZeroState/ZeroState.tsx +45 -0
  54. package/dist/react/index.ts +35 -0
  55. package/package.json +12 -2
@@ -0,0 +1,271 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * ATTRUS SmartSearch — typed shell over the canonical `.ss` system
5
+ * (preview/components/smart-search.css). The global ⌘K command palette.
6
+ *
7
+ * The 8 canonical states (combobox.html §03) COMPOSE from props — the
8
+ * component renders whatever slices it receives, in the doc's order:
9
+ * input → chips → sections/list → hint → typeButtons → empty/loading/
10
+ * error illo → footer (kbd hints).
11
+ *
12
+ * inactive → no popover content (flat input)
13
+ * active → chips + sections (recents/suggestions)
14
+ * word → resultLabel + sections
15
+ * similar → hint + sections
16
+ * id → hint + typeButtons
17
+ * partial id → hint only
18
+ * no results → empty
19
+ * loading → loading
20
+ * error → error (retry)
21
+ *
22
+ * Keyboard: ↑/↓ move `.is-active` across items, Enter selects, Esc → onClose.
23
+ */
24
+
25
+ export interface SSItem {
26
+ key: string;
27
+ /** Leading 28px tile glyph. */
28
+ icon?: React.ReactNode;
29
+ title: React.ReactNode;
30
+ /** Muted second line. */
31
+ sub?: React.ReactNode;
32
+ /** Right-aligned mono hint (id, shortcut). */
33
+ trailing?: React.ReactNode;
34
+ }
35
+
36
+ export interface SSSection {
37
+ /** Uppercase section label (e.g. "Counterparties"). */
38
+ label?: React.ReactNode;
39
+ items: SSItem[];
40
+ }
41
+
42
+ export interface SSTypeButton {
43
+ key: string;
44
+ icon?: React.ReactNode;
45
+ label: React.ReactNode;
46
+ /** Muted right hint (e.g. "transaction"). */
47
+ hint?: React.ReactNode;
48
+ }
49
+
50
+ export interface SmartSearchProps {
51
+ value: string;
52
+ onChange?: (value: string) => void;
53
+ placeholder?: string;
54
+ /** Leading input glyph (search icon). */
55
+ icon?: React.ReactNode;
56
+ /** Shows the × clear button when value is non-empty. */
57
+ onClear?: () => void;
58
+ /** Esc inside the input. */
59
+ onClose?: () => void;
60
+ /** Suggestion chips row (active state). */
61
+ chips?: Array<{ key: string; icon?: React.ReactNode; label: React.ReactNode }>;
62
+ onChipClick?: (key: string) => void;
63
+ /** Grouped result rows. */
64
+ sections?: SSSection[];
65
+ onSelect?: (key: string) => void;
66
+ /** "RESULTS (N)" line above the list. */
67
+ resultLabel?: React.ReactNode;
68
+ /** Tip card (similar match, ID format hint). */
69
+ hint?: React.ReactNode;
70
+ /** ID-disambiguation buttons. */
71
+ typeButtons?: SSTypeButton[];
72
+ onTypeSelect?: (key: string) => void;
73
+ /** No-results state. */
74
+ empty?: { title: React.ReactNode; sub?: React.ReactNode; illo?: React.ReactNode };
75
+ /** Skeleton rows while fetching. */
76
+ loading?: boolean;
77
+ /** Fetch failed — retry. */
78
+ error?: { title: React.ReactNode; sub?: React.ReactNode; retryLabel?: React.ReactNode; onRetry?: () => void; illo?: React.ReactNode };
79
+ /** Kbd hint footer (↑↓ navigate · ↵ open · esc close). Default true when the popover has content. */
80
+ footer?: boolean;
81
+ className?: string;
82
+ style?: React.CSSProperties;
83
+ 'aria-label'?: string;
84
+ }
85
+
86
+ const SearchGlyph: React.FC = () => (
87
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
88
+ <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
89
+ </svg>
90
+ );
91
+
92
+ export const SmartSearch: React.FC<SmartSearchProps> = ({
93
+ value,
94
+ onChange,
95
+ placeholder = 'Search or jump to…',
96
+ icon,
97
+ onClear,
98
+ onClose,
99
+ chips,
100
+ onChipClick,
101
+ sections,
102
+ onSelect,
103
+ resultLabel,
104
+ hint,
105
+ typeButtons,
106
+ onTypeSelect,
107
+ empty,
108
+ loading = false,
109
+ error,
110
+ footer,
111
+ className,
112
+ style,
113
+ ...rest
114
+ }) => {
115
+ const [active, setActive] = React.useState<string | null>(null);
116
+ const flat = React.useMemo(() => (sections || []).reduce<SSItem[]>((a, s) => a.concat(s.items), []), [sections]);
117
+
118
+ const hasBody = Boolean(
119
+ (chips && chips.length) || (sections && sections.length) || hint != null ||
120
+ (typeButtons && typeButtons.length) || empty != null || loading || error != null
121
+ );
122
+ const showFooter = footer != null ? footer : hasBody;
123
+
124
+ const onKeyDown = (e: React.KeyboardEvent) => {
125
+ if (e.key === 'Escape') { if (onClose) onClose(); return; }
126
+ if (!flat.length) return;
127
+ const idx = flat.findIndex((i) => i.key === active);
128
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
129
+ e.preventDefault();
130
+ const next = e.key === 'ArrowDown'
131
+ ? flat[Math.min(idx + 1, flat.length - 1)]
132
+ : flat[Math.max(idx - 1, 0)];
133
+ setActive(next.key);
134
+ } else if (e.key === 'Enter') {
135
+ const hit = flat.find((i) => i.key === active);
136
+ if (hit && onSelect) { e.preventDefault(); onSelect(hit.key); }
137
+ }
138
+ };
139
+
140
+ const renderItem = (it: SSItem) => (
141
+ <li
142
+ key={it.key}
143
+ className={['ss-list-item', it.key === active ? 'is-active' : ''].filter(Boolean).join(' ')}
144
+ onMouseEnter={() => setActive(it.key)}
145
+ onClick={() => onSelect && onSelect(it.key)}
146
+ >
147
+ {it.icon != null ? <span className="icon-leading">{it.icon}</span> : null}
148
+ <span className="text">
149
+ <span className="label">{it.title}</span>
150
+ {it.sub != null ? <span className="meta">{it.sub}</span> : null}
151
+ </span>
152
+ {it.trailing != null ? (
153
+ <span style={{ marginLeft: 'auto', flexShrink: 0, fontFamily: 'var(--font-mono)', fontSize: 'var(--fs-xxs)', color: 'var(--color-foreground-muted)' }}>{it.trailing}</span>
154
+ ) : null}
155
+ </li>
156
+ );
157
+
158
+ return (
159
+ <div className={['ss', className || ''].filter(Boolean).join(' ')} style={style} role="search" aria-label={rest['aria-label'] || 'Smart search'}>
160
+ <div className={['ss-input', hasBody ? '' : 'ss-input-flat'].filter(Boolean).join(' ')}>
161
+ {icon != null ? icon : <SearchGlyph />}
162
+ <input
163
+ value={value}
164
+ placeholder={placeholder}
165
+ onChange={(e) => onChange && onChange(e.target.value)}
166
+ onKeyDown={onKeyDown}
167
+ role="combobox"
168
+ aria-expanded={hasBody}
169
+ />
170
+ {onClear && value ? (
171
+ <button type="button" className="ss-clear" aria-label="Clear" onClick={onClear}>
172
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
173
+ </button>
174
+ ) : null}
175
+ </div>
176
+
177
+ {chips && chips.length ? (
178
+ <div className="ss-section">
179
+ <div className="ss-chips">
180
+ {chips.map((c) => (
181
+ <button key={c.key} type="button" className="ss-chip" onClick={() => onChipClick && onChipClick(c.key)}>
182
+ {c.icon}
183
+ {c.label}
184
+ </button>
185
+ ))}
186
+ </div>
187
+ </div>
188
+ ) : null}
189
+
190
+ {resultLabel != null ? (
191
+ <div className="ss-section ss-result-label" style={{ borderBottom: 0, paddingBottom: 0 }}><span className="l">{resultLabel}</span></div>
192
+ ) : null}
193
+
194
+ {sections && sections.length ? sections.map((s, i) => (
195
+ <div key={i} className="ss-section">
196
+ {s.label != null ? <div className="ss-section-label">{s.label}</div> : null}
197
+ <ul className="ss-list">{s.items.map(renderItem)}</ul>
198
+ </div>
199
+ )) : null}
200
+
201
+ {hint != null ? (
202
+ <div className="ss-section"><div className="ss-hint">{hint}</div></div>
203
+ ) : null}
204
+
205
+ {typeButtons && typeButtons.length ? (
206
+ <div className="ss-section">
207
+ <div className="ss-typebtns">
208
+ {typeButtons.map((b) => (
209
+ <button key={b.key} type="button" className="ss-typebtn" onClick={() => onTypeSelect && onTypeSelect(b.key)}>
210
+ {b.icon}
211
+ <span className="grow">{b.label}</span>
212
+ {b.hint != null ? <span style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--fs-xxs)', color: 'var(--color-foreground-muted)' }}>{b.hint}</span> : null}
213
+ </button>
214
+ ))}
215
+ </div>
216
+ </div>
217
+ ) : null}
218
+
219
+ {empty != null && !loading && !error ? (
220
+ <div className="ss-section">
221
+ <div className="ss-illo">
222
+ {empty.illo}
223
+ <span className="title">{empty.title}</span>
224
+ {empty.sub != null ? <span className="body">{empty.sub}</span> : null}
225
+ </div>
226
+ </div>
227
+ ) : null}
228
+
229
+ {loading ? (
230
+ <div className="ss-skeleton" aria-busy="true" aria-hidden="true">
231
+ {[0, 1, 2].map((i) => (
232
+ <div key={i} className="ss-skel-row">
233
+ <span className="ss-skel-circle ss-skel-shimmer" />
234
+ <span className="ss-skel-bars">
235
+ <span className="ss-skel-bar long ss-skel-shimmer" />
236
+ <span className="ss-skel-bar short ss-skel-shimmer" />
237
+ </span>
238
+ </div>
239
+ ))}
240
+ </div>
241
+ ) : null}
242
+
243
+ {error != null ? (
244
+ <div className="ss-section">
245
+ <div className="ss-illo" role="alert">
246
+ {error.illo}
247
+ <span className="title">{error.title}</span>
248
+ {error.sub != null ? <span className="body">{error.sub}</span> : null}
249
+ {error.onRetry ? (
250
+ <button type="button" className="ss-typebtn" style={{ marginTop: 'var(--space-2)' }} onClick={error.onRetry}>
251
+ {error.retryLabel != null ? error.retryLabel : 'Try again'}
252
+ </button>
253
+ ) : null}
254
+ </div>
255
+ </div>
256
+ ) : null}
257
+
258
+ {showFooter ? (
259
+ <div className="ss-foot">
260
+ <span className="kbd-group"><kbd className="ss-kbd">↑</kbd><kbd className="ss-kbd">↓</kbd> navigate</span>
261
+ <span className="sep" />
262
+ <span className="kbd-group"><kbd className="ss-kbd">↵</kbd> open</span>
263
+ <span className="sep" />
264
+ <span className="kbd-group"><kbd className="ss-kbd">esc</kbd> close</span>
265
+ </div>
266
+ ) : null}
267
+ </div>
268
+ );
269
+ };
270
+
271
+ export default SmartSearch;
@@ -0,0 +1,25 @@
1
+ import * as React from 'react';
2
+
3
+ export type StepState = 'todo' | 'current' | 'done' | 'error' | 'skipped';
4
+
5
+ export interface StepItem {
6
+ key: string;
7
+ label: React.ReactNode;
8
+ help?: React.ReactNode;
9
+ state?: StepState;
10
+ }
11
+
12
+ export interface StepperProps extends React.OlHTMLAttributes<HTMLOListElement> {
13
+ steps: StepItem[];
14
+ vertical?: boolean;
15
+ compact?: boolean;
16
+ /** Minimal capsule+label variant (.is-rule) for tight headers. */
17
+ rule?: boolean;
18
+ /** ≤640px: hide labels, keep markers (.is-collapse-sm). */
19
+ collapseOnMobile?: boolean;
20
+ /** Step click (e.g. jump back to a done step). */
21
+ onStepClick?: (key: string) => void;
22
+ }
23
+
24
+ export declare const Stepper: React.FC<StepperProps>;
25
+ export default Stepper;
@@ -0,0 +1,94 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * ATTRUS Stepper — typed wrapper over the canonical `.stepper` flow
5
+ * indicator (preview/components/stepper.css). States: todo / current /
6
+ * done / error / skipped. Orientations: horizontal (default), vertical.
7
+ * Density: compact. Minimal: rule (4px capsule, mono labels).
8
+ */
9
+
10
+ export type StepState = 'todo' | 'current' | 'done' | 'error' | 'skipped';
11
+
12
+ export interface StepItem {
13
+ key: string;
14
+ label: React.ReactNode;
15
+ help?: React.ReactNode;
16
+ state?: StepState;
17
+ }
18
+
19
+ export interface StepperProps extends React.OlHTMLAttributes<HTMLOListElement> {
20
+ steps: StepItem[];
21
+ vertical?: boolean;
22
+ compact?: boolean;
23
+ /** Minimal capsule+label variant (.is-rule) for tight headers. */
24
+ rule?: boolean;
25
+ /** ≤640px: hide labels, keep markers (.is-collapse-sm). */
26
+ collapseOnMobile?: boolean;
27
+ /** Step click (e.g. jump back to a done step). */
28
+ onStepClick?: (key: string) => void;
29
+ }
30
+
31
+ const CheckGlyph: React.FC = () => (
32
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
33
+ <path d="M20 6 9 17l-5-5" />
34
+ </svg>
35
+ );
36
+ const XGlyph: React.FC = () => (
37
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
38
+ <line x1="18" y1="6" x2="6" y2="18" />
39
+ <line x1="6" y1="6" x2="18" y2="18" />
40
+ </svg>
41
+ );
42
+
43
+ export const Stepper: React.FC<StepperProps> = ({
44
+ steps,
45
+ vertical = false,
46
+ compact = false,
47
+ rule = false,
48
+ collapseOnMobile = false,
49
+ onStepClick,
50
+ className,
51
+ ...rest
52
+ }) => {
53
+ const cls = [
54
+ 'stepper',
55
+ vertical ? 'is-vertical' : '',
56
+ compact ? 'is-compact' : '',
57
+ rule ? 'is-rule' : '',
58
+ collapseOnMobile ? 'is-collapse-sm' : '',
59
+ className || '',
60
+ ]
61
+ .filter(Boolean)
62
+ .join(' ');
63
+
64
+ return (
65
+ <ol className={cls} {...rest}>
66
+ {steps.map((s, i) => {
67
+ const state = s.state || 'todo';
68
+ const stepCls = ['step', state !== 'todo' ? `is-${state}` : ''].filter(Boolean).join(' ');
69
+ const marker =
70
+ state === 'done' ? <CheckGlyph /> : state === 'error' ? <XGlyph /> : <span>{i + 1}</span>;
71
+ const clickable = onStepClick && (state === 'done' || state === 'error');
72
+ return (
73
+ <li key={s.key} className={stepCls} aria-current={state === 'current' ? 'step' : undefined}>
74
+ <span
75
+ className="step-marker"
76
+ role={clickable ? 'button' : undefined}
77
+ tabIndex={clickable ? 0 : undefined}
78
+ style={clickable ? { cursor: 'pointer' } : undefined}
79
+ onClick={clickable ? () => onStepClick!(s.key) : undefined}
80
+ >
81
+ {marker}
82
+ </span>
83
+ <span className="step-body">
84
+ <span className="step-label">{s.label}</span>
85
+ {s.help != null ? <span className="step-help">{s.help}</span> : null}
86
+ </span>
87
+ </li>
88
+ );
89
+ })}
90
+ </ol>
91
+ );
92
+ };
93
+
94
+ export default Stepper;
@@ -0,0 +1,32 @@
1
+ import * as React from 'react';
2
+
3
+ export interface TabItem {
4
+ /** Stable identity — also what onChange receives. */
5
+ key: string;
6
+ /** Label. Strings get the bold-width reservation (no reflow on select). */
7
+ label: React.ReactNode;
8
+ /** Optional count badge (.tab-count). */
9
+ count?: number | string;
10
+ /** Icon-only segment (`.tab.is-icon`, pill variant) — label becomes aria-label; pass the glyph here. */
11
+ icon?: React.ReactNode;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export type TabsVariant = 'underline' | 'pill';
16
+ export type TabsSize = 'sm' | 'md' | 'lg';
17
+ export type TabsLayout = 'auto' | 'block' | 'equal' | 'scroll';
18
+
19
+ export interface TabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
20
+ items: TabItem[];
21
+ /** Key of the selected tab (controlled). */
22
+ value: string;
23
+ onChange?: (key: string) => void;
24
+ variant?: TabsVariant;
25
+ size?: TabsSize;
26
+ layout?: TabsLayout;
27
+ /** Sliding indicator (`.is-animated` + `.tab-indicator`), ON by default; false = static indicator. */
28
+ animated?: boolean;
29
+ }
30
+
31
+ export declare const Tabs: React.FC<TabsProps>;
32
+ export default Tabs;
@@ -0,0 +1,147 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * ATTRUS Tabs — typed wrapper over the canonical `.tabs` system
5
+ * (preview/components/tabs.css). CSS stays the visual source of truth.
6
+ *
7
+ * Variants: underline (default) | pill (segmented control).
8
+ * Sizes: sm | md (default) | lg (lg steps down to md on mobile via CSS).
9
+ * Layout: auto (content-sized) | block (full-width, equal columns) |
10
+ * equal (equal columns, content width) | scroll (force h-scroll).
11
+ * Animated BY DEFAULT: sliding indicator (underline → bar, pill → lifted
12
+ * surface) wired via `.is-animated` + `--ind-x/--ind-w`; pass
13
+ * animated={false} to opt out (static per-tab indicator).
14
+ * Mobile: underline rows scroll horizontally by default; the pill shell
15
+ * fills the row and scrolls internally — both handled by the CSS.
16
+ */
17
+
18
+ export interface TabItem {
19
+ /** Stable identity — also what onChange receives. */
20
+ key: string;
21
+ /** Label. Strings get the bold-width reservation (no reflow on select). */
22
+ label: React.ReactNode;
23
+ /** Optional count badge (.tab-count). */
24
+ count?: number | string;
25
+ /** Icon-only segment (`.tab.is-icon`, pill variant) — label becomes aria-label; pass the glyph here. */
26
+ icon?: React.ReactNode;
27
+ disabled?: boolean;
28
+ }
29
+
30
+ export type TabsVariant = 'underline' | 'pill';
31
+ export type TabsSize = 'sm' | 'md' | 'lg';
32
+ export type TabsLayout = 'auto' | 'block' | 'equal' | 'scroll';
33
+
34
+ export interface TabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
35
+ items: TabItem[];
36
+ /** Key of the selected tab (controlled). */
37
+ value: string;
38
+ onChange?: (key: string) => void;
39
+ variant?: TabsVariant;
40
+ size?: TabsSize;
41
+ layout?: TabsLayout;
42
+ /** Sliding indicator (`.is-animated` + `.tab-indicator`): underline → sliding bar,
43
+ * pill → sliding lifted surface. ON by default; pass false for the static indicator.
44
+ * Positioning JS (--ind-x/--ind-w + resize) is wired here. */
45
+ animated?: boolean;
46
+ }
47
+
48
+ const LAYOUT_CLASS: Record<TabsLayout, string> = {
49
+ auto: '',
50
+ block: 'is-block',
51
+ equal: 'is-equal',
52
+ scroll: 'is-scroll',
53
+ };
54
+
55
+ export const Tabs: React.FC<TabsProps> = ({
56
+ items,
57
+ value,
58
+ onChange,
59
+ variant = 'underline',
60
+ size = 'md',
61
+ layout = 'auto',
62
+ animated = true,
63
+ className,
64
+ ...rest
65
+ }) => {
66
+ const listRef = React.useRef<HTMLDivElement>(null);
67
+
68
+ // .is-animated contract: set --ind-x/--ind-w from the active tab; recompute on resize.
69
+ React.useLayoutEffect(() => {
70
+ if (!animated) return;
71
+ const list = listRef.current;
72
+ if (!list) return;
73
+ const position = () => {
74
+ const active = list.querySelector<HTMLElement>('.tab[aria-selected="true"]');
75
+ if (!active) return;
76
+ list.style.setProperty('--ind-x', active.offsetLeft + 'px');
77
+ list.style.setProperty('--ind-w', active.offsetWidth + 'px');
78
+ };
79
+ position();
80
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(position) : null;
81
+ ro?.observe(list);
82
+ // Late font load changes tab widths without resizing a fixed-width list —
83
+ // reposition once webfonts settle so the indicator never sits misaligned.
84
+ if (typeof document !== 'undefined' && (document as Document & { fonts?: { ready: Promise<unknown> } }).fonts) {
85
+ (document as Document & { fonts: { ready: Promise<unknown> } }).fonts.ready.then(position).catch(() => undefined);
86
+ }
87
+ return () => ro?.disconnect();
88
+ }, [animated, value, items]);
89
+
90
+ const cls = [
91
+ 'tabs',
92
+ variant === 'pill' ? 'tabs-pill' : '',
93
+ size !== 'md' ? `tabs-${size}` : '',
94
+ LAYOUT_CLASS[layout],
95
+ animated ? 'is-animated' : '',
96
+ className || '',
97
+ ]
98
+ .filter(Boolean)
99
+ .join(' ');
100
+
101
+ return (
102
+ <div ref={listRef} className={cls} role="tablist" {...rest}>
103
+ {items.map((it) => (
104
+ <button
105
+ key={it.key}
106
+ type="button"
107
+ role="tab"
108
+ className={it.icon != null ? 'tab is-icon' : 'tab'}
109
+ aria-selected={value === it.key}
110
+ aria-label={it.icon != null && typeof it.label === 'string' ? it.label : undefined}
111
+ tabIndex={value === it.key ? 0 : -1}
112
+ disabled={it.disabled}
113
+ onClick={() => {
114
+ if (!it.disabled && onChange) onChange(it.key);
115
+ }}
116
+ onKeyDown={(e) => {
117
+ // Roving focus — ←/→ move between tabs, wrapping; Home/End jump.
118
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;
119
+ e.preventDefault();
120
+ const enabled = items.filter((x) => !x.disabled);
121
+ if (!enabled.length) return;
122
+ const idx = enabled.findIndex((x) => x.key === it.key);
123
+ let next = idx;
124
+ if (e.key === 'ArrowRight') next = (idx + 1) % enabled.length;
125
+ else if (e.key === 'ArrowLeft') next = (idx - 1 + enabled.length) % enabled.length;
126
+ else if (e.key === 'Home') next = 0;
127
+ else next = enabled.length - 1;
128
+ const btns = listRef.current?.querySelectorAll<HTMLButtonElement>('.tab:not([disabled])');
129
+ btns?.[next]?.focus();
130
+ }}
131
+ >
132
+ {it.icon != null ? (
133
+ it.icon
134
+ ) : (
135
+ <span className="tab-label" data-label={typeof it.label === 'string' ? it.label : undefined}>
136
+ {it.label}
137
+ </span>
138
+ )}
139
+ {it.icon == null && it.count != null ? <span className="tab-count">{it.count}</span> : null}
140
+ </button>
141
+ ))}
142
+ {animated ? <span className="tab-indicator" aria-hidden="true" /> : null}
143
+ </div>
144
+ );
145
+ };
146
+
147
+ export default Tabs;
@@ -0,0 +1,30 @@
1
+ import * as React from 'react';
2
+
3
+ export type ToastTone = 'success' | 'warning' | 'danger' | 'info' | 'brand' | 'inverse';
4
+ export type ToastStackPosition = 'tr' | 'tl' | 'tc' | 'br' | 'bl' | 'bc';
5
+
6
+ export interface ToastProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ tone?: ToastTone;
8
+ /** Leading icon slot (.toast-icon). */
9
+ icon?: React.ReactNode;
10
+ /** Bold first line. */
11
+ title?: React.ReactNode;
12
+ /** Inline action link (.toast-action). */
13
+ action?: React.ReactNode;
14
+ onActionClick?: () => void;
15
+ /** Renders the × and calls back. */
16
+ onClose?: () => void;
17
+ /** Auto-dismiss countdown hairline (.toast-progress). */
18
+ progress?: boolean;
19
+ /** Exit animation hook (.is-leaving) — set just before removal. */
20
+ leaving?: boolean;
21
+ }
22
+
23
+ export interface ToastStackProps extends React.HTMLAttributes<HTMLDivElement> {
24
+ /** Corner: tr (default) · tl · tc · br · bl · bc. */
25
+ position?: ToastStackPosition;
26
+ }
27
+
28
+ export declare const Toast: React.FC<ToastProps>;
29
+ export declare const ToastStack: React.FC<ToastStackProps>;
30
+ export default Toast;