@attrus-ds/core 0.2.0 → 0.3.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.
Files changed (80) hide show
  1. package/LICENSE +27 -27
  2. package/dist/assets/flags/Brazil-rounded.svg +15 -15
  3. package/dist/assets/flags/Brazil-square.svg +16 -16
  4. package/dist/assets/flags/Chile-rounded.svg +10 -10
  5. package/dist/assets/flags/Chile-square.svg +10 -10
  6. package/dist/assets/flags/Colombia-rounded.svg +9 -9
  7. package/dist/assets/flags/Colombia-square.svg +9 -9
  8. package/dist/assets/flags/EUA-rounded.svg +10 -10
  9. package/dist/assets/flags/EUA-square.svg +10 -10
  10. package/dist/assets/flags/EUR-rounded.svg +8 -8
  11. package/dist/assets/flags/EUR-square.svg +8 -8
  12. package/dist/assets/flags/Mexico-rounded.svg +18 -18
  13. package/dist/assets/flags/Mexico-square.svg +18 -18
  14. package/dist/assets/icon-attrus-dark.svg +2 -2
  15. package/dist/assets/icon-attrus-light.svg +2 -2
  16. package/dist/assets/logo-attrus-dark.svg +7 -7
  17. package/dist/assets/logo-attrus-light.svg +7 -7
  18. package/dist/css/components/buttons.css +301 -301
  19. package/dist/css/components/callout.css +195 -195
  20. package/dist/css/components/cards.css +272 -272
  21. package/dist/css/components/combobox.css +319 -319
  22. package/dist/css/components/empty-states.css +61 -61
  23. package/dist/css/components/inputs.css +975 -975
  24. package/dist/css/components/number-input.css +138 -138
  25. package/dist/css/components/overlays.css +374 -374
  26. package/dist/css/components/primitives.css +314 -314
  27. package/dist/css/components/sidebar.css +179 -179
  28. package/dist/css/components/smart-search.css +91 -91
  29. package/dist/css/components/status-pills.css +207 -207
  30. package/dist/css/components/stepper.css +309 -309
  31. package/dist/css/components/table.css +602 -602
  32. package/dist/css/components/tabs.css +319 -319
  33. package/dist/css/components/toast.css +243 -243
  34. package/dist/css/components/tooltip.css +231 -231
  35. package/dist/css/components/topbar.css +148 -148
  36. package/dist/css/tokens.css +666 -666
  37. package/dist/react/AppSidebar/AppSidebar.d.ts +50 -50
  38. package/dist/react/AppSidebar/AppSidebar.tsx +197 -197
  39. package/dist/react/AppTopbar/AppTopbar.d.ts +58 -58
  40. package/dist/react/AppTopbar/AppTopbar.tsx +203 -203
  41. package/dist/react/Atoms/Atoms.d.ts +149 -149
  42. package/dist/react/Atoms/Atoms.tsx +407 -407
  43. package/dist/react/Button/Button.d.ts +25 -25
  44. package/dist/react/Button/Button.tsx +83 -83
  45. package/dist/react/Callout/Callout.d.ts +29 -29
  46. package/dist/react/Callout/Callout.tsx +98 -98
  47. package/dist/react/Card/Card.d.ts +38 -38
  48. package/dist/react/Card/Card.tsx +93 -93
  49. package/dist/react/Combobox/Combobox.d.ts +52 -52
  50. package/dist/react/Combobox/Combobox.tsx +254 -254
  51. package/dist/react/DataTable/DataTable.d.ts +81 -81
  52. package/dist/react/DataTable/DataTable.tsx +308 -308
  53. package/dist/react/Drawer/Drawer.d.ts +35 -35
  54. package/dist/react/Drawer/Drawer.tsx +226 -226
  55. package/dist/react/Input/Input.d.ts +155 -155
  56. package/dist/react/Input/Input.tsx +478 -478
  57. package/dist/react/Modal/Modal.d.ts +27 -27
  58. package/dist/react/Modal/Modal.tsx +173 -173
  59. package/dist/react/NumberInput/NumberInput.d.ts +25 -25
  60. package/dist/react/NumberInput/NumberInput.tsx +117 -117
  61. package/dist/react/Pill/Pill.d.ts +31 -31
  62. package/dist/react/Pill/Pill.tsx +104 -104
  63. package/dist/react/SearchPill/SearchPill.d.ts +14 -14
  64. package/dist/react/SearchPill/SearchPill.tsx +40 -40
  65. package/dist/react/SmartSearch/SmartSearch.d.ts +65 -65
  66. package/dist/react/SmartSearch/SmartSearch.tsx +271 -271
  67. package/dist/react/Stepper/Stepper.d.ts +25 -25
  68. package/dist/react/Stepper/Stepper.tsx +94 -94
  69. package/dist/react/Tabs/Tabs.d.ts +32 -32
  70. package/dist/react/Tabs/Tabs.tsx +147 -147
  71. package/dist/react/Toast/Toast.d.ts +30 -30
  72. package/dist/react/Toast/Toast.tsx +94 -94
  73. package/dist/react/Tooltip/Tooltip.d.ts +23 -23
  74. package/dist/react/Tooltip/Tooltip.tsx +80 -80
  75. package/dist/react/ZeroState/ZeroState.d.ts +17 -17
  76. package/dist/react/ZeroState/ZeroState.tsx +45 -45
  77. package/dist/react/index.ts +35 -35
  78. package/dist/tokens/attrus-tokens.flat.json +1234 -1234
  79. package/dist/tokens/attrus-tokens.json +1089 -1089
  80. package/package.json +64 -64
@@ -1,478 +1,478 @@
1
- import * as React from 'react';
2
-
3
- /**
4
- * ATTRUS Input — typed wrapper over the canonical input system
5
- * (preview/components/inputs.css). CSS stays the visual source of truth.
6
- *
7
- * Composes the canonical anatomy automatically:
8
- * .field > label + control + .help (when label/help given)
9
- * .affix.lead-pad/.trail-pad (when leadIcon/trailIcon given)
10
- * .group > .fix.l + input + .fix.r (when prefix/suffix given)
11
- *
12
- * Note: prefix/suffix (.group) and leadIcon/trailIcon (.affix) are
13
- * mutually exclusive — group wins if both are passed. Sizes apply to
14
- * the plain/affix input; the group row is md-only by design.
15
- */
16
-
17
- export type InputSize = 'sm' | 'md' | 'lg';
18
- export type InputStatus = 'default' | 'error' | 'success' | 'warning';
19
-
20
- export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
21
- /** Field label (renders the .field wrapper). */
22
- label?: React.ReactNode;
23
- /** Helper line under the control; takes the status tone. */
24
- help?: React.ReactNode;
25
- /** Validation state: error/success border + focus ring + help tone. */
26
- status?: InputStatus;
27
- /** sm=32px, md=40px (default), lg=48px. */
28
- size?: InputSize;
29
- /** Leading icon (decorative) — .affix.lead-pad. */
30
- leadIcon?: React.ReactNode;
31
- /** Trailing icon (decorative) — .affix.trail-pad. */
32
- trailIcon?: React.ReactNode;
33
- /** Makes the trailing slot a clickable .trail-btn (clear, reveal…). */
34
- onTrailClick?: React.MouseEventHandler<HTMLButtonElement>;
35
- /** Trailing button accessible label (required with onTrailClick). */
36
- trailLabel?: string;
37
- /** Attached prefix cell — .group .fix.l (e.g. "USD"). */
38
- prefix?: React.ReactNode;
39
- /** Attached suffix cell — .group .fix.r (e.g. ".00"). */
40
- suffix?: React.ReactNode;
41
- /** Dark-surface treatment (.on-inverse). */
42
- onInverse?: boolean;
43
- /** Quiet read-only: value only, no border (.is-readonly). */
44
- readOnlyQuiet?: boolean;
45
- /** Quiet / ghost input — bottom hairline only (`.input.underline`). */
46
- underline?: boolean;
47
- }
48
-
49
- export const Input: React.FC<InputProps> = ({
50
- label,
51
- help,
52
- status = 'default',
53
- size = 'md',
54
- leadIcon,
55
- trailIcon,
56
- onTrailClick,
57
- trailLabel,
58
- prefix,
59
- suffix,
60
- onInverse = false,
61
- readOnlyQuiet = false,
62
- underline = false,
63
- className,
64
- id,
65
- ...rest
66
- }) => {
67
- const autoId = React.useId();
68
- const inputId = id || (label ? autoId : undefined);
69
-
70
- const inputCls = [
71
- 'input',
72
- size !== 'md' ? `input-${size}` : '',
73
- status === 'error' ? 'is-error' : '',
74
- status === 'success' ? 'is-success' : '',
75
- onInverse ? 'on-inverse' : '',
76
- readOnlyQuiet ? 'is-readonly' : '',
77
- underline ? 'underline' : '',
78
- className || '',
79
- ]
80
- .filter(Boolean)
81
- .join(' ');
82
-
83
- let control: React.ReactNode;
84
-
85
- if (prefix != null || suffix != null) {
86
- // Attached group — the inner input is styled by `.group > input`.
87
- control = (
88
- <div className={['group', status === 'error' ? 'is-error' : ''].filter(Boolean).join(' ')}>
89
- {prefix != null ? <span className="fix l">{prefix}</span> : null}
90
- <input id={inputId} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
91
- {suffix != null ? <span className="fix r">{suffix}</span> : null}
92
- </div>
93
- );
94
- } else if (leadIcon != null || trailIcon != null || onTrailClick) {
95
- const affixCls = [
96
- 'affix',
97
- leadIcon != null ? 'lead-pad' : '',
98
- trailIcon != null || onTrailClick ? 'trail-pad' : '',
99
- ]
100
- .filter(Boolean)
101
- .join(' ');
102
- control = (
103
- <div className={affixCls}>
104
- {leadIcon != null ? <span className="lead">{leadIcon}</span> : null}
105
- <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
106
- {onTrailClick ? (
107
- <button type="button" className="trail-btn" aria-label={trailLabel || 'Action'} onClick={onTrailClick}>
108
- {trailIcon}
109
- </button>
110
- ) : trailIcon != null ? (
111
- <span className="trail">{trailIcon}</span>
112
- ) : null}
113
- </div>
114
- );
115
- } else {
116
- control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />;
117
- }
118
-
119
- if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
120
-
121
- const helpCls = [
122
- 'help',
123
- status === 'error' ? 'is-error' : '',
124
- status === 'success' ? 'is-success' : '',
125
- status === 'warning' ? 'is-warning' : '',
126
- ]
127
- .filter(Boolean)
128
- .join(' ');
129
-
130
- return (
131
- <div className="field">
132
- {label != null ? <label htmlFor={inputId}>{label}</label> : null}
133
- {control}
134
- {help != null ? <div className={helpCls}>{help}</div> : null}
135
- </div>
136
- );
137
- };
138
-
139
- export default Input;
140
-
141
- /* ----------------------------------------------------------------
142
- Companion form controls — same inputs.css, same .field anatomy.
143
- ---------------------------------------------------------------- */
144
-
145
- const fieldWrap = (
146
- label: React.ReactNode,
147
- help: React.ReactNode,
148
- status: InputStatus,
149
- id: string | undefined,
150
- control: React.ReactNode,
151
- ) => {
152
- if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
153
- const helpCls = ['help', status === 'error' ? 'is-error' : '', status === 'success' ? 'is-success' : '', status === 'warning' ? 'is-warning' : ''].filter(Boolean).join(' ');
154
- return (
155
- <div className="field">
156
- {label != null ? <label htmlFor={id}>{label}</label> : null}
157
- {control}
158
- {help != null ? <div className={helpCls}>{help}</div> : null}
159
- </div>
160
- );
161
- };
162
-
163
- export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
164
- label?: React.ReactNode;
165
- help?: React.ReactNode;
166
- status?: InputStatus;
167
- /** sm / md (default) / lg — maps to textarea.input.input-{size}. */
168
- inputSize?: InputSize;
169
- }
170
- /** Multi-line text — `textarea.input` (auto height, min-height per size). */
171
- export const Textarea: React.FC<TextareaProps> = ({ label, help, status = 'default', inputSize = 'md', className, id, ...rest }) => {
172
- const autoId = React.useId();
173
- const tid = id || (label ? autoId : undefined);
174
- const cls = ['input', inputSize !== 'md' ? `input-${inputSize}` : '', status === 'error' ? 'is-error' : '', status === 'success' ? 'is-success' : '', className || ''].filter(Boolean).join(' ');
175
- return fieldWrap(label, help, status, tid, <textarea id={tid} className={cls} {...rest} />);
176
- };
177
-
178
- export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
179
- label?: React.ReactNode;
180
- help?: React.ReactNode;
181
- status?: InputStatus;
182
- inputSize?: InputSize;
183
- }
184
- /** Native select — `.select` wrapper + chevron glyph. Children = <option>s. */
185
- export const Select: React.FC<SelectProps> = ({ label, help, status = 'default', inputSize = 'md', className, id, children, ...rest }) => {
186
- const autoId = React.useId();
187
- const sid = id || (label ? autoId : undefined);
188
- const cls = ['input', inputSize !== 'md' ? `input-${inputSize}` : '', status === 'error' ? 'is-error' : '', className || ''].filter(Boolean).join(' ');
189
- const control = (
190
- <div className="select">
191
- <select id={sid} className={cls} {...rest}>{children}</select>
192
- <span className="chev" aria-hidden="true">
193
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
194
- </span>
195
- </div>
196
- );
197
- return fieldWrap(label, help, status, sid, control);
198
- };
199
-
200
- export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
201
- /** Visible label to the right of the track. */
202
- children?: React.ReactNode;
203
- }
204
- /** Toggle — `.switch` (hidden input + track + thumb; royal when checked). */
205
- export const Switch: React.FC<SwitchProps> = ({ children, className, ...rest }) => (
206
- <label className={['switch', className || ''].filter(Boolean).join(' ')}>
207
- <input type="checkbox" {...rest} />
208
- <span className="switch-track"><span className="switch-thumb" /></span>
209
- {children != null ? <span className="switch-label">{children}</span> : null}
210
- </label>
211
- );
212
-
213
- export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
214
- children?: React.ReactNode;
215
- /** Mixed state — sets input.indeterminate (bar instead of check). */
216
- indeterminate?: boolean;
217
- /** Error ring on the box (`.check.is-error`). */
218
- error?: boolean;
219
- }
220
- /** Custom checkbox — `.check` (hidden input + box + check glyph). */
221
- export const Checkbox: React.FC<CheckboxProps> = ({ children, indeterminate = false, error = false, className, ...rest }) => {
222
- const ref = React.useRef<HTMLInputElement>(null);
223
- React.useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
224
- return (
225
- <label className={['check', error ? 'is-error' : '', className || ''].filter(Boolean).join(' ')}>
226
- <input ref={ref} type="checkbox" {...rest} />
227
- <span className="check-box">
228
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
229
- </span>
230
- {children != null ? <span>{children}</span> : null}
231
- </label>
232
- );
233
- };
234
-
235
- export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
236
- children?: React.ReactNode;
237
- }
238
- /** Custom radio — `.radio` (hidden input + dot). Group via shared `name`. */
239
- export const Radio: React.FC<RadioProps> = ({ children, className, ...rest }) => (
240
- <label className={['radio', className || ''].filter(Boolean).join(' ')}>
241
- <input type="radio" {...rest} />
242
- <span className="radio-dot" />
243
- {children != null ? <span>{children}</span> : null}
244
- </label>
245
- );
246
-
247
- /* ---------- Specialised inputs (inputs.html §8–13) ---------- */
248
-
249
- const ChevronGlyph: React.FC = () => (
250
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
251
- <polyline points="6 9 12 15 18 9" />
252
- </svg>
253
- );
254
-
255
- export interface DateTriggerProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
256
- /** Filled label (e.g. "April 17, 2026" or "Apr 1 – Apr 30"). Empty → placeholder. */
257
- value?: React.ReactNode;
258
- placeholder?: React.ReactNode;
259
- /** Leading icon (calendar / calendar-range / clock) — caller's <Icon/>. */
260
- icon?: React.ReactNode;
261
- /** Error ring (`.is-error`). */
262
- error?: boolean;
263
- }
264
- /** Trigger button for calendar/time pickers — `.date-trigger`. The picker itself is not part of the input system. */
265
- export const DateTrigger: React.FC<DateTriggerProps> = ({ value, placeholder = 'Select a date…', icon, error = false, className, ...rest }) => (
266
- <button type="button" className={['date-trigger', error ? 'is-error' : '', className || ''].filter(Boolean).join(' ')} {...rest}>
267
- <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
268
- {icon}
269
- {value != null ? value : <span className="placeholder">{placeholder}</span>}
270
- </span>
271
- <ChevronGlyph />
272
- </button>
273
- );
274
-
275
- export interface ChipsInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
276
- /** Current chips (controlled). */
277
- values: string[];
278
- /** Enter/comma in the embedded input adds a chip. */
279
- onAdd?: (value: string) => void;
280
- /** × on a chip removes it (also Backspace on empty input removes the last). */
281
- onRemove?: (value: string) => void;
282
- placeholder?: string;
283
- disabled?: boolean;
284
- }
285
- /** Token input — `.chips` shell with removable `.chip`s + embedded text input. */
286
- export const ChipsInput: React.FC<ChipsInputProps> = ({ values, onAdd, onRemove, placeholder = 'Add…', disabled = false, className, ...rest }) => {
287
- const [draft, setDraft] = React.useState('');
288
- const commit = () => {
289
- const v = draft.trim();
290
- if (v && onAdd) onAdd(v);
291
- setDraft('');
292
- };
293
- return (
294
- <div className={['chips', className || ''].filter(Boolean).join(' ')} {...rest}>
295
- {values.map((v) => (
296
- <span key={v} className="chip">
297
- {v}
298
- {onRemove ? (
299
- <button type="button" className="chip-x" aria-label={`Remove ${v}`} disabled={disabled} onClick={() => onRemove(v)}>
300
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" aria-hidden="true">
301
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
302
- </svg>
303
- </button>
304
- ) : null}
305
- </span>
306
- ))}
307
- <input
308
- value={draft}
309
- placeholder={placeholder}
310
- disabled={disabled}
311
- onChange={(e) => setDraft(e.target.value)}
312
- onKeyDown={(e) => {
313
- if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit(); }
314
- if (e.key === 'Backspace' && !draft && values.length && onRemove) onRemove(values[values.length - 1]);
315
- }}
316
- onBlur={commit}
317
- />
318
- </div>
319
- );
320
- };
321
-
322
- export interface OtpProps {
323
- /** Number of digit cells. Default 6. */
324
- length?: number;
325
- /** Current code (controlled) — up to `length` chars. */
326
- value: string;
327
- onChange?: (code: string) => void;
328
- /** Error ring on every cell (`.is-error`). */
329
- error?: boolean;
330
- disabled?: boolean;
331
- 'aria-label'?: string;
332
- className?: string;
333
- style?: React.CSSProperties;
334
- }
335
- /** One-time-password cells — `.otp`. Auto-advances focus; paste fills all cells. */
336
- export const Otp: React.FC<OtpProps> = ({ length = 6, value, onChange, error = false, disabled = false, className, style, ...rest }) => {
337
- const refs = React.useRef<Array<HTMLInputElement | null>>([]);
338
- const set = (code: string) => { if (onChange) onChange(code.slice(0, length)); };
339
- return (
340
- <div className={['otp', className || ''].filter(Boolean).join(' ')} style={style} role="group" aria-label={rest['aria-label'] || 'Verification code'}>
341
- {Array.from({ length }, (_, i) => (
342
- <input
343
- key={i}
344
- ref={(el) => { refs.current[i] = el; }}
345
- maxLength={1}
346
- inputMode="numeric"
347
- autoComplete={i === 0 ? 'one-time-code' : 'off'}
348
- className={error ? 'is-error' : undefined}
349
- value={value[i] || ''}
350
- disabled={disabled}
351
- onChange={(e) => {
352
- const ch = e.target.value.replace(/\D/g, '').slice(-1);
353
- const next = value.slice(0, i) + (ch || '') + value.slice(i + 1);
354
- set(next);
355
- if (ch && i < length - 1) refs.current[i + 1]?.focus();
356
- }}
357
- onKeyDown={(e) => {
358
- if (e.key === 'Backspace' && !value[i] && i > 0) refs.current[i - 1]?.focus();
359
- }}
360
- onPaste={(e) => {
361
- const digits = e.clipboardData.getData('text').replace(/\D/g, '');
362
- if (digits) { e.preventDefault(); set(digits); refs.current[Math.min(digits.length, length) - 1]?.focus(); }
363
- }}
364
- />
365
- ))}
366
- </div>
367
- );
368
- };
369
-
370
- export interface FileDropProps extends React.HTMLAttributes<HTMLDivElement> {
371
- /** Called with the dropped/browsed files. */
372
- onFiles?: (files: File[]) => void;
373
- multiple?: boolean;
374
- /** input[type=file] accept. */
375
- accept?: string;
376
- title?: React.ReactNode;
377
- /** Sub line — the `.anchor` span is the click-to-browse affordance. */
378
- sub?: React.ReactNode;
379
- /** Leading glyph (e.g. upload-cloud icon). */
380
- icon?: React.ReactNode;
381
- disabled?: boolean;
382
- }
383
- /** Dashed dropzone — `.file-drop` with drag (`.is-dragging`) + click-to-browse. */
384
- export const FileDrop: React.FC<FileDropProps> = ({ onFiles, multiple = true, accept, title = 'Drop files here', sub, icon, disabled = false, className, ...rest }) => {
385
- const [dragging, setDragging] = React.useState(false);
386
- const inputRef = React.useRef<HTMLInputElement>(null);
387
- const emit = (list: FileList | null) => { if (list && list.length && onFiles) onFiles(Array.from(list)); };
388
- return (
389
- <div
390
- className={['file-drop', dragging ? 'is-dragging' : '', className || ''].filter(Boolean).join(' ')}
391
- role="button"
392
- tabIndex={disabled ? undefined : 0}
393
- aria-disabled={disabled || undefined}
394
- onClick={() => { if (!disabled) inputRef.current?.click(); }}
395
- onKeyDown={(e) => { if (!disabled && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); inputRef.current?.click(); } }}
396
- onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragging(true); }}
397
- onDragLeave={() => setDragging(false)}
398
- onDrop={(e) => { e.preventDefault(); setDragging(false); if (!disabled) emit(e.dataTransfer.files); }}
399
- {...rest}
400
- >
401
- {icon}
402
- <span className="ttl">{dragging ? 'Release to upload' : title}</span>
403
- <span className="sub">{sub != null ? sub : <React.Fragment>or <span className="anchor">browse files</span></React.Fragment>}</span>
404
- <input ref={inputRef} type="file" hidden multiple={multiple} accept={accept} disabled={disabled} onChange={(e) => { emit(e.target.files); e.target.value = ''; }} />
405
- </div>
406
- );
407
- };
408
-
409
- export type FileRowStatus = 'ok' | 'pending' | 'err';
410
-
411
- export interface FileRowProps extends React.HTMLAttributes<HTMLLIElement> {
412
- /** Icon tile kind — tints via `data-kind` (img / sheet / doc / pdf…). */
413
- kind?: string;
414
- /** Tile glyph (caller's <Icon/>) + optional corner tag text (e.g. "PDF"). */
415
- icon?: React.ReactNode;
416
- tag?: React.ReactNode;
417
- name: React.ReactNode;
418
- /** Mono sub line (size · date). Status err tints it. */
419
- sub?: React.ReactNode;
420
- /** ok (done) · pending (uploading) · err (failed — clickable to retry). */
421
- status?: FileRowStatus;
422
- statusLabel?: React.ReactNode;
423
- /** 0–100 — renders the `.file-progress` hairline while uploading. */
424
- progress?: number;
425
- onRemove?: () => void;
426
- }
427
- /** Uploaded-file row — `.file-row` (icon tile · meta · status · remove). Wrap rows in <FileListBox>. */
428
- export const FileRow: React.FC<FileRowProps> = ({ kind, icon, tag, name, sub, status, statusLabel, progress, onRemove, className, ...rest }) => (
429
- <li className={['file-row', className || ''].filter(Boolean).join(' ')} {...rest}>
430
- <span className="file-icon" data-kind={kind}>
431
- {icon}
432
- {tag != null ? <span className="file-icon-tag">{tag}</span> : null}
433
- </span>
434
- <span className="file-meta">
435
- <span className="file-name">{name}</span>
436
- {sub != null ? <span className={['file-sub', status === 'err' ? 'err' : ''].filter(Boolean).join(' ')}>{sub}</span> : null}
437
- {progress != null ? (
438
- <span className="file-progress"><span style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} /></span>
439
- ) : null}
440
- </span>
441
- {status != null ? <span className={`file-status ${status}`}>{statusLabel != null ? statusLabel : status === 'ok' ? 'Uploaded' : status === 'pending' ? 'Uploading…' : 'Retry'}</span> : null}
442
- {onRemove ? (
443
- <button type="button" className="file-remove" aria-label="Remove file" onClick={onRemove}>
444
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
445
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
446
- </svg>
447
- </button>
448
- ) : null}
449
- </li>
450
- );
451
-
452
- export interface FileListBoxProps extends React.HTMLAttributes<HTMLDivElement> {
453
- /** Uppercase head label (e.g. "3 files · 4.2 MB"). */
454
- head?: React.ReactNode;
455
- /** Head-right action (e.g. "Clear all") — `.file-list-action`. */
456
- action?: React.ReactNode;
457
- onActionClick?: () => void;
458
- }
459
- /** Post-upload list shell — `.file-list` with head + `.file-rows`. Children: <FileRow/>s. */
460
- export const FileListBox: React.FC<FileListBoxProps> = ({ head, action, onActionClick, className, children, ...rest }) => (
461
- <div className={['file-list', className || ''].filter(Boolean).join(' ')} {...rest}>
462
- {head != null || action != null ? (
463
- <div className="file-list-head">
464
- <span>{head}</span>
465
- {action != null ? (
466
- <button type="button" className="file-list-action" onClick={onActionClick}>{action}</button>
467
- ) : null}
468
- </div>
469
- ) : null}
470
- <ul className="file-rows">{children}</ul>
471
- </div>
472
- );
473
-
474
- export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {}
475
- /** Range input — `.slider` (canonical track + thumb). Pair with a label that reflects the value. */
476
- export const Slider: React.FC<SliderProps> = ({ className, ...rest }) => (
477
- <input type="range" className={['slider', className || ''].filter(Boolean).join(' ')} {...rest} />
478
- );
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * ATTRUS Input — typed wrapper over the canonical input system
5
+ * (preview/components/inputs.css). CSS stays the visual source of truth.
6
+ *
7
+ * Composes the canonical anatomy automatically:
8
+ * .field > label + control + .help (when label/help given)
9
+ * .affix.lead-pad/.trail-pad (when leadIcon/trailIcon given)
10
+ * .group > .fix.l + input + .fix.r (when prefix/suffix given)
11
+ *
12
+ * Note: prefix/suffix (.group) and leadIcon/trailIcon (.affix) are
13
+ * mutually exclusive — group wins if both are passed. Sizes apply to
14
+ * the plain/affix input; the group row is md-only by design.
15
+ */
16
+
17
+ export type InputSize = 'sm' | 'md' | 'lg';
18
+ export type InputStatus = 'default' | 'error' | 'success' | 'warning';
19
+
20
+ export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
21
+ /** Field label (renders the .field wrapper). */
22
+ label?: React.ReactNode;
23
+ /** Helper line under the control; takes the status tone. */
24
+ help?: React.ReactNode;
25
+ /** Validation state: error/success border + focus ring + help tone. */
26
+ status?: InputStatus;
27
+ /** sm=32px, md=40px (default), lg=48px. */
28
+ size?: InputSize;
29
+ /** Leading icon (decorative) — .affix.lead-pad. */
30
+ leadIcon?: React.ReactNode;
31
+ /** Trailing icon (decorative) — .affix.trail-pad. */
32
+ trailIcon?: React.ReactNode;
33
+ /** Makes the trailing slot a clickable .trail-btn (clear, reveal…). */
34
+ onTrailClick?: React.MouseEventHandler<HTMLButtonElement>;
35
+ /** Trailing button accessible label (required with onTrailClick). */
36
+ trailLabel?: string;
37
+ /** Attached prefix cell — .group .fix.l (e.g. "USD"). */
38
+ prefix?: React.ReactNode;
39
+ /** Attached suffix cell — .group .fix.r (e.g. ".00"). */
40
+ suffix?: React.ReactNode;
41
+ /** Dark-surface treatment (.on-inverse). */
42
+ onInverse?: boolean;
43
+ /** Quiet read-only: value only, no border (.is-readonly). */
44
+ readOnlyQuiet?: boolean;
45
+ /** Quiet / ghost input — bottom hairline only (`.input.underline`). */
46
+ underline?: boolean;
47
+ }
48
+
49
+ export const Input: React.FC<InputProps> = ({
50
+ label,
51
+ help,
52
+ status = 'default',
53
+ size = 'md',
54
+ leadIcon,
55
+ trailIcon,
56
+ onTrailClick,
57
+ trailLabel,
58
+ prefix,
59
+ suffix,
60
+ onInverse = false,
61
+ readOnlyQuiet = false,
62
+ underline = false,
63
+ className,
64
+ id,
65
+ ...rest
66
+ }) => {
67
+ const autoId = React.useId();
68
+ const inputId = id || (label ? autoId : undefined);
69
+
70
+ const inputCls = [
71
+ 'input',
72
+ size !== 'md' ? `input-${size}` : '',
73
+ status === 'error' ? 'is-error' : '',
74
+ status === 'success' ? 'is-success' : '',
75
+ onInverse ? 'on-inverse' : '',
76
+ readOnlyQuiet ? 'is-readonly' : '',
77
+ underline ? 'underline' : '',
78
+ className || '',
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+
83
+ let control: React.ReactNode;
84
+
85
+ if (prefix != null || suffix != null) {
86
+ // Attached group — the inner input is styled by `.group > input`.
87
+ control = (
88
+ <div className={['group', status === 'error' ? 'is-error' : ''].filter(Boolean).join(' ')}>
89
+ {prefix != null ? <span className="fix l">{prefix}</span> : null}
90
+ <input id={inputId} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
91
+ {suffix != null ? <span className="fix r">{suffix}</span> : null}
92
+ </div>
93
+ );
94
+ } else if (leadIcon != null || trailIcon != null || onTrailClick) {
95
+ const affixCls = [
96
+ 'affix',
97
+ leadIcon != null ? 'lead-pad' : '',
98
+ trailIcon != null || onTrailClick ? 'trail-pad' : '',
99
+ ]
100
+ .filter(Boolean)
101
+ .join(' ');
102
+ control = (
103
+ <div className={affixCls}>
104
+ {leadIcon != null ? <span className="lead">{leadIcon}</span> : null}
105
+ <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />
106
+ {onTrailClick ? (
107
+ <button type="button" className="trail-btn" aria-label={trailLabel || 'Action'} onClick={onTrailClick}>
108
+ {trailIcon}
109
+ </button>
110
+ ) : trailIcon != null ? (
111
+ <span className="trail">{trailIcon}</span>
112
+ ) : null}
113
+ </div>
114
+ );
115
+ } else {
116
+ control = <input id={inputId} className={inputCls} readOnly={readOnlyQuiet || rest.readOnly} {...rest} />;
117
+ }
118
+
119
+ if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
120
+
121
+ const helpCls = [
122
+ 'help',
123
+ status === 'error' ? 'is-error' : '',
124
+ status === 'success' ? 'is-success' : '',
125
+ status === 'warning' ? 'is-warning' : '',
126
+ ]
127
+ .filter(Boolean)
128
+ .join(' ');
129
+
130
+ return (
131
+ <div className="field">
132
+ {label != null ? <label htmlFor={inputId}>{label}</label> : null}
133
+ {control}
134
+ {help != null ? <div className={helpCls}>{help}</div> : null}
135
+ </div>
136
+ );
137
+ };
138
+
139
+ export default Input;
140
+
141
+ /* ----------------------------------------------------------------
142
+ Companion form controls — same inputs.css, same .field anatomy.
143
+ ---------------------------------------------------------------- */
144
+
145
+ const fieldWrap = (
146
+ label: React.ReactNode,
147
+ help: React.ReactNode,
148
+ status: InputStatus,
149
+ id: string | undefined,
150
+ control: React.ReactNode,
151
+ ) => {
152
+ if (label == null && help == null) return <React.Fragment>{control}</React.Fragment>;
153
+ const helpCls = ['help', status === 'error' ? 'is-error' : '', status === 'success' ? 'is-success' : '', status === 'warning' ? 'is-warning' : ''].filter(Boolean).join(' ');
154
+ return (
155
+ <div className="field">
156
+ {label != null ? <label htmlFor={id}>{label}</label> : null}
157
+ {control}
158
+ {help != null ? <div className={helpCls}>{help}</div> : null}
159
+ </div>
160
+ );
161
+ };
162
+
163
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
164
+ label?: React.ReactNode;
165
+ help?: React.ReactNode;
166
+ status?: InputStatus;
167
+ /** sm / md (default) / lg — maps to textarea.input.input-{size}. */
168
+ inputSize?: InputSize;
169
+ }
170
+ /** Multi-line text — `textarea.input` (auto height, min-height per size). */
171
+ export const Textarea: React.FC<TextareaProps> = ({ label, help, status = 'default', inputSize = 'md', className, id, ...rest }) => {
172
+ const autoId = React.useId();
173
+ const tid = id || (label ? autoId : undefined);
174
+ const cls = ['input', inputSize !== 'md' ? `input-${inputSize}` : '', status === 'error' ? 'is-error' : '', status === 'success' ? 'is-success' : '', className || ''].filter(Boolean).join(' ');
175
+ return fieldWrap(label, help, status, tid, <textarea id={tid} className={cls} {...rest} />);
176
+ };
177
+
178
+ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
179
+ label?: React.ReactNode;
180
+ help?: React.ReactNode;
181
+ status?: InputStatus;
182
+ inputSize?: InputSize;
183
+ }
184
+ /** Native select — `.select` wrapper + chevron glyph. Children = <option>s. */
185
+ export const Select: React.FC<SelectProps> = ({ label, help, status = 'default', inputSize = 'md', className, id, children, ...rest }) => {
186
+ const autoId = React.useId();
187
+ const sid = id || (label ? autoId : undefined);
188
+ const cls = ['input', inputSize !== 'md' ? `input-${inputSize}` : '', status === 'error' ? 'is-error' : '', className || ''].filter(Boolean).join(' ');
189
+ const control = (
190
+ <div className="select">
191
+ <select id={sid} className={cls} {...rest}>{children}</select>
192
+ <span className="chev" aria-hidden="true">
193
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
194
+ </span>
195
+ </div>
196
+ );
197
+ return fieldWrap(label, help, status, sid, control);
198
+ };
199
+
200
+ export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
201
+ /** Visible label to the right of the track. */
202
+ children?: React.ReactNode;
203
+ }
204
+ /** Toggle — `.switch` (hidden input + track + thumb; royal when checked). */
205
+ export const Switch: React.FC<SwitchProps> = ({ children, className, ...rest }) => (
206
+ <label className={['switch', className || ''].filter(Boolean).join(' ')}>
207
+ <input type="checkbox" {...rest} />
208
+ <span className="switch-track"><span className="switch-thumb" /></span>
209
+ {children != null ? <span className="switch-label">{children}</span> : null}
210
+ </label>
211
+ );
212
+
213
+ export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
214
+ children?: React.ReactNode;
215
+ /** Mixed state — sets input.indeterminate (bar instead of check). */
216
+ indeterminate?: boolean;
217
+ /** Error ring on the box (`.check.is-error`). */
218
+ error?: boolean;
219
+ }
220
+ /** Custom checkbox — `.check` (hidden input + box + check glyph). */
221
+ export const Checkbox: React.FC<CheckboxProps> = ({ children, indeterminate = false, error = false, className, ...rest }) => {
222
+ const ref = React.useRef<HTMLInputElement>(null);
223
+ React.useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
224
+ return (
225
+ <label className={['check', error ? 'is-error' : '', className || ''].filter(Boolean).join(' ')}>
226
+ <input ref={ref} type="checkbox" {...rest} />
227
+ <span className="check-box">
228
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
229
+ </span>
230
+ {children != null ? <span>{children}</span> : null}
231
+ </label>
232
+ );
233
+ };
234
+
235
+ export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
236
+ children?: React.ReactNode;
237
+ }
238
+ /** Custom radio — `.radio` (hidden input + dot). Group via shared `name`. */
239
+ export const Radio: React.FC<RadioProps> = ({ children, className, ...rest }) => (
240
+ <label className={['radio', className || ''].filter(Boolean).join(' ')}>
241
+ <input type="radio" {...rest} />
242
+ <span className="radio-dot" />
243
+ {children != null ? <span>{children}</span> : null}
244
+ </label>
245
+ );
246
+
247
+ /* ---------- Specialised inputs (inputs.html §8–13) ---------- */
248
+
249
+ const ChevronGlyph: React.FC = () => (
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
251
+ <polyline points="6 9 12 15 18 9" />
252
+ </svg>
253
+ );
254
+
255
+ export interface DateTriggerProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
256
+ /** Filled label (e.g. "April 17, 2026" or "Apr 1 – Apr 30"). Empty → placeholder. */
257
+ value?: React.ReactNode;
258
+ placeholder?: React.ReactNode;
259
+ /** Leading icon (calendar / calendar-range / clock) — caller's <Icon/>. */
260
+ icon?: React.ReactNode;
261
+ /** Error ring (`.is-error`). */
262
+ error?: boolean;
263
+ }
264
+ /** Trigger button for calendar/time pickers — `.date-trigger`. The picker itself is not part of the input system. */
265
+ export const DateTrigger: React.FC<DateTriggerProps> = ({ value, placeholder = 'Select a date…', icon, error = false, className, ...rest }) => (
266
+ <button type="button" className={['date-trigger', error ? 'is-error' : '', className || ''].filter(Boolean).join(' ')} {...rest}>
267
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
268
+ {icon}
269
+ {value != null ? value : <span className="placeholder">{placeholder}</span>}
270
+ </span>
271
+ <ChevronGlyph />
272
+ </button>
273
+ );
274
+
275
+ export interface ChipsInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
276
+ /** Current chips (controlled). */
277
+ values: string[];
278
+ /** Enter/comma in the embedded input adds a chip. */
279
+ onAdd?: (value: string) => void;
280
+ /** × on a chip removes it (also Backspace on empty input removes the last). */
281
+ onRemove?: (value: string) => void;
282
+ placeholder?: string;
283
+ disabled?: boolean;
284
+ }
285
+ /** Token input — `.chips` shell with removable `.chip`s + embedded text input. */
286
+ export const ChipsInput: React.FC<ChipsInputProps> = ({ values, onAdd, onRemove, placeholder = 'Add…', disabled = false, className, ...rest }) => {
287
+ const [draft, setDraft] = React.useState('');
288
+ const commit = () => {
289
+ const v = draft.trim();
290
+ if (v && onAdd) onAdd(v);
291
+ setDraft('');
292
+ };
293
+ return (
294
+ <div className={['chips', className || ''].filter(Boolean).join(' ')} {...rest}>
295
+ {values.map((v) => (
296
+ <span key={v} className="chip">
297
+ {v}
298
+ {onRemove ? (
299
+ <button type="button" className="chip-x" aria-label={`Remove ${v}`} disabled={disabled} onClick={() => onRemove(v)}>
300
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" aria-hidden="true">
301
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
302
+ </svg>
303
+ </button>
304
+ ) : null}
305
+ </span>
306
+ ))}
307
+ <input
308
+ value={draft}
309
+ placeholder={placeholder}
310
+ disabled={disabled}
311
+ onChange={(e) => setDraft(e.target.value)}
312
+ onKeyDown={(e) => {
313
+ if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit(); }
314
+ if (e.key === 'Backspace' && !draft && values.length && onRemove) onRemove(values[values.length - 1]);
315
+ }}
316
+ onBlur={commit}
317
+ />
318
+ </div>
319
+ );
320
+ };
321
+
322
+ export interface OtpProps {
323
+ /** Number of digit cells. Default 6. */
324
+ length?: number;
325
+ /** Current code (controlled) — up to `length` chars. */
326
+ value: string;
327
+ onChange?: (code: string) => void;
328
+ /** Error ring on every cell (`.is-error`). */
329
+ error?: boolean;
330
+ disabled?: boolean;
331
+ 'aria-label'?: string;
332
+ className?: string;
333
+ style?: React.CSSProperties;
334
+ }
335
+ /** One-time-password cells — `.otp`. Auto-advances focus; paste fills all cells. */
336
+ export const Otp: React.FC<OtpProps> = ({ length = 6, value, onChange, error = false, disabled = false, className, style, ...rest }) => {
337
+ const refs = React.useRef<Array<HTMLInputElement | null>>([]);
338
+ const set = (code: string) => { if (onChange) onChange(code.slice(0, length)); };
339
+ return (
340
+ <div className={['otp', className || ''].filter(Boolean).join(' ')} style={style} role="group" aria-label={rest['aria-label'] || 'Verification code'}>
341
+ {Array.from({ length }, (_, i) => (
342
+ <input
343
+ key={i}
344
+ ref={(el) => { refs.current[i] = el; }}
345
+ maxLength={1}
346
+ inputMode="numeric"
347
+ autoComplete={i === 0 ? 'one-time-code' : 'off'}
348
+ className={error ? 'is-error' : undefined}
349
+ value={value[i] || ''}
350
+ disabled={disabled}
351
+ onChange={(e) => {
352
+ const ch = e.target.value.replace(/\D/g, '').slice(-1);
353
+ const next = value.slice(0, i) + (ch || '') + value.slice(i + 1);
354
+ set(next);
355
+ if (ch && i < length - 1) refs.current[i + 1]?.focus();
356
+ }}
357
+ onKeyDown={(e) => {
358
+ if (e.key === 'Backspace' && !value[i] && i > 0) refs.current[i - 1]?.focus();
359
+ }}
360
+ onPaste={(e) => {
361
+ const digits = e.clipboardData.getData('text').replace(/\D/g, '');
362
+ if (digits) { e.preventDefault(); set(digits); refs.current[Math.min(digits.length, length) - 1]?.focus(); }
363
+ }}
364
+ />
365
+ ))}
366
+ </div>
367
+ );
368
+ };
369
+
370
+ export interface FileDropProps extends React.HTMLAttributes<HTMLDivElement> {
371
+ /** Called with the dropped/browsed files. */
372
+ onFiles?: (files: File[]) => void;
373
+ multiple?: boolean;
374
+ /** input[type=file] accept. */
375
+ accept?: string;
376
+ title?: React.ReactNode;
377
+ /** Sub line — the `.anchor` span is the click-to-browse affordance. */
378
+ sub?: React.ReactNode;
379
+ /** Leading glyph (e.g. upload-cloud icon). */
380
+ icon?: React.ReactNode;
381
+ disabled?: boolean;
382
+ }
383
+ /** Dashed dropzone — `.file-drop` with drag (`.is-dragging`) + click-to-browse. */
384
+ export const FileDrop: React.FC<FileDropProps> = ({ onFiles, multiple = true, accept, title = 'Drop files here', sub, icon, disabled = false, className, ...rest }) => {
385
+ const [dragging, setDragging] = React.useState(false);
386
+ const inputRef = React.useRef<HTMLInputElement>(null);
387
+ const emit = (list: FileList | null) => { if (list && list.length && onFiles) onFiles(Array.from(list)); };
388
+ return (
389
+ <div
390
+ className={['file-drop', dragging ? 'is-dragging' : '', className || ''].filter(Boolean).join(' ')}
391
+ role="button"
392
+ tabIndex={disabled ? undefined : 0}
393
+ aria-disabled={disabled || undefined}
394
+ onClick={() => { if (!disabled) inputRef.current?.click(); }}
395
+ onKeyDown={(e) => { if (!disabled && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); inputRef.current?.click(); } }}
396
+ onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragging(true); }}
397
+ onDragLeave={() => setDragging(false)}
398
+ onDrop={(e) => { e.preventDefault(); setDragging(false); if (!disabled) emit(e.dataTransfer.files); }}
399
+ {...rest}
400
+ >
401
+ {icon}
402
+ <span className="ttl">{dragging ? 'Release to upload' : title}</span>
403
+ <span className="sub">{sub != null ? sub : <React.Fragment>or <span className="anchor">browse files</span></React.Fragment>}</span>
404
+ <input ref={inputRef} type="file" hidden multiple={multiple} accept={accept} disabled={disabled} onChange={(e) => { emit(e.target.files); e.target.value = ''; }} />
405
+ </div>
406
+ );
407
+ };
408
+
409
+ export type FileRowStatus = 'ok' | 'pending' | 'err';
410
+
411
+ export interface FileRowProps extends React.HTMLAttributes<HTMLLIElement> {
412
+ /** Icon tile kind — tints via `data-kind` (img / sheet / doc / pdf…). */
413
+ kind?: string;
414
+ /** Tile glyph (caller's <Icon/>) + optional corner tag text (e.g. "PDF"). */
415
+ icon?: React.ReactNode;
416
+ tag?: React.ReactNode;
417
+ name: React.ReactNode;
418
+ /** Mono sub line (size · date). Status err tints it. */
419
+ sub?: React.ReactNode;
420
+ /** ok (done) · pending (uploading) · err (failed — clickable to retry). */
421
+ status?: FileRowStatus;
422
+ statusLabel?: React.ReactNode;
423
+ /** 0–100 — renders the `.file-progress` hairline while uploading. */
424
+ progress?: number;
425
+ onRemove?: () => void;
426
+ }
427
+ /** Uploaded-file row — `.file-row` (icon tile · meta · status · remove). Wrap rows in <FileListBox>. */
428
+ export const FileRow: React.FC<FileRowProps> = ({ kind, icon, tag, name, sub, status, statusLabel, progress, onRemove, className, ...rest }) => (
429
+ <li className={['file-row', className || ''].filter(Boolean).join(' ')} {...rest}>
430
+ <span className="file-icon" data-kind={kind}>
431
+ {icon}
432
+ {tag != null ? <span className="file-icon-tag">{tag}</span> : null}
433
+ </span>
434
+ <span className="file-meta">
435
+ <span className="file-name">{name}</span>
436
+ {sub != null ? <span className={['file-sub', status === 'err' ? 'err' : ''].filter(Boolean).join(' ')}>{sub}</span> : null}
437
+ {progress != null ? (
438
+ <span className="file-progress"><span style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} /></span>
439
+ ) : null}
440
+ </span>
441
+ {status != null ? <span className={`file-status ${status}`}>{statusLabel != null ? statusLabel : status === 'ok' ? 'Uploaded' : status === 'pending' ? 'Uploading…' : 'Retry'}</span> : null}
442
+ {onRemove ? (
443
+ <button type="button" className="file-remove" aria-label="Remove file" onClick={onRemove}>
444
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
445
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
446
+ </svg>
447
+ </button>
448
+ ) : null}
449
+ </li>
450
+ );
451
+
452
+ export interface FileListBoxProps extends React.HTMLAttributes<HTMLDivElement> {
453
+ /** Uppercase head label (e.g. "3 files · 4.2 MB"). */
454
+ head?: React.ReactNode;
455
+ /** Head-right action (e.g. "Clear all") — `.file-list-action`. */
456
+ action?: React.ReactNode;
457
+ onActionClick?: () => void;
458
+ }
459
+ /** Post-upload list shell — `.file-list` with head + `.file-rows`. Children: <FileRow/>s. */
460
+ export const FileListBox: React.FC<FileListBoxProps> = ({ head, action, onActionClick, className, children, ...rest }) => (
461
+ <div className={['file-list', className || ''].filter(Boolean).join(' ')} {...rest}>
462
+ {head != null || action != null ? (
463
+ <div className="file-list-head">
464
+ <span>{head}</span>
465
+ {action != null ? (
466
+ <button type="button" className="file-list-action" onClick={onActionClick}>{action}</button>
467
+ ) : null}
468
+ </div>
469
+ ) : null}
470
+ <ul className="file-rows">{children}</ul>
471
+ </div>
472
+ );
473
+
474
+ export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {}
475
+ /** Range input — `.slider` (canonical track + thumb). Pair with a label that reflects the value. */
476
+ export const Slider: React.FC<SliderProps> = ({ className, ...rest }) => (
477
+ <input type="range" className={['slider', className || ''].filter(Boolean).join(' ')} {...rest} />
478
+ );