@eifi1/ui-kit 0.15.4 → 0.16.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 (160) hide show
  1. package/README.md +20 -15
  2. package/dist/chart.d.ts +3 -4
  3. package/dist/components/alert-banner.js +1 -1
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +13 -4
  6. package/dist/components/amount-input.js +166 -157
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/authed-image.d.ts +14 -3
  9. package/dist/components/authed-image.js +32 -23
  10. package/dist/components/authed-image.js.map +1 -1
  11. package/dist/components/button-group.d.ts +3 -4
  12. package/dist/components/calculator.d.ts +3 -4
  13. package/dist/components/checkbox.js +1 -1
  14. package/dist/components/checkbox.js.map +1 -1
  15. package/dist/components/choice-card.js +1 -1
  16. package/dist/components/choice-card.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +3 -4
  18. package/dist/components/error-boundary.js +1 -1
  19. package/dist/components/error-boundary.js.map +1 -1
  20. package/dist/components/facing-pair.d.ts +3 -4
  21. package/dist/components/file-button.d.ts +3 -4
  22. package/dist/components/file-button.js +64 -31
  23. package/dist/components/file-button.js.map +1 -1
  24. package/dist/components/file-dropzone.d.ts +3 -4
  25. package/dist/components/file-dropzone.js +1 -1
  26. package/dist/components/file-dropzone.js.map +1 -1
  27. package/dist/components/form-actions.d.ts +4 -5
  28. package/dist/components/form-actions.js +18 -6
  29. package/dist/components/form-actions.js.map +1 -1
  30. package/dist/components/line-items.d.ts +53 -2
  31. package/dist/components/line-items.js +73 -7
  32. package/dist/components/line-items.js.map +1 -1
  33. package/dist/components/list.d.ts +99 -5
  34. package/dist/components/list.js +103 -38
  35. package/dist/components/list.js.map +1 -1
  36. package/dist/components/loading-state.d.ts +19 -3
  37. package/dist/components/loading-state.js +14 -3
  38. package/dist/components/loading-state.js.map +1 -1
  39. package/dist/components/money-field.d.ts +3 -4
  40. package/dist/components/number-field.d.ts +3 -4
  41. package/dist/components/number-input.d.ts +3 -4
  42. package/dist/components/number-input.js +4 -2
  43. package/dist/components/number-input.js.map +1 -1
  44. package/dist/components/numpad-sheet.d.ts +3 -4
  45. package/dist/components/passkeys-setting.d.ts +30 -10
  46. package/dist/components/passkeys-setting.js +19 -11
  47. package/dist/components/passkeys-setting.js.map +1 -1
  48. package/dist/components/progress-bar.d.ts +12 -3
  49. package/dist/components/progress-bar.js +21 -11
  50. package/dist/components/progress-bar.js.map +1 -1
  51. package/dist/components/series-chart.d.ts +3 -4
  52. package/dist/components/settings-fields.d.ts +3 -4
  53. package/dist/components/signed-amount.d.ts +28 -4
  54. package/dist/components/signed-amount.js +17 -8
  55. package/dist/components/signed-amount.js.map +1 -1
  56. package/dist/components/stat-tile.d.ts +31 -2
  57. package/dist/components/stat-tile.js +37 -6
  58. package/dist/components/stat-tile.js.map +1 -1
  59. package/dist/components/status-dot.d.ts +4 -1
  60. package/dist/components/status-dot.js +20 -1
  61. package/dist/components/status-dot.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +3 -1
  63. package/dist/components/swatch-picker.js.map +1 -1
  64. package/dist/components/table.d.ts +18 -13
  65. package/dist/components/table.js +20 -5
  66. package/dist/components/table.js.map +1 -1
  67. package/dist/components/text-link.d.ts +3 -4
  68. package/dist/components/time-input.d.ts +3 -4
  69. package/dist/components/toggle-legend.d.ts +12 -1
  70. package/dist/components/toggle-legend.js +6 -2
  71. package/dist/components/toggle-legend.js.map +1 -1
  72. package/dist/components/ui.d.ts +3 -4
  73. package/dist/components/ui.js +156 -52
  74. package/dist/components/ui.js.map +1 -1
  75. package/dist/feedback/feedback-attachment.d.ts +2 -82
  76. package/dist/feedback/feedback-attachment.js +285 -55
  77. package/dist/feedback/feedback-attachment.js.map +1 -1
  78. package/dist/feedback/feedback-dialog.d.ts +2 -105
  79. package/dist/feedback/feedback-dialog.js +57 -25
  80. package/dist/feedback/feedback-dialog.js.map +1 -1
  81. package/dist/feedback/feedback-inbox.d.ts +1 -1
  82. package/dist/feedback/feedback-thread.d.ts +2 -2
  83. package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
  84. package/dist/feedback.d.ts +1 -2
  85. package/dist/hooks/use-file-drop.d.ts +3 -4
  86. package/dist/i18n/defaults.d.ts +3 -4
  87. package/dist/i18n/kit-labels.d.ts +2 -3
  88. package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
  89. package/dist/i18n/locales/de-CH.d.ts +3 -4
  90. package/dist/i18n/locales/de-informal.d.ts +3 -4
  91. package/dist/i18n/locales/de.d.ts +3 -4
  92. package/dist/i18n/locales/de.js +5 -1
  93. package/dist/i18n/locales/de.js.map +1 -1
  94. package/dist/i18n/locales/es.d.ts +3 -4
  95. package/dist/i18n/locales/es.js +5 -1
  96. package/dist/i18n/locales/es.js.map +1 -1
  97. package/dist/i18n/locales/fr.d.ts +3 -4
  98. package/dist/i18n/locales/fr.js +5 -1
  99. package/dist/i18n/locales/fr.js.map +1 -1
  100. package/dist/i18n/locales/hu.d.ts +3 -4
  101. package/dist/i18n/locales/hu.js +5 -1
  102. package/dist/i18n/locales/hu.js.map +1 -1
  103. package/dist/i18n/locales/it.d.ts +3 -4
  104. package/dist/i18n/locales/it.js +5 -1
  105. package/dist/i18n/locales/it.js.map +1 -1
  106. package/dist/i18n/locales/zh.d.ts +3 -4
  107. package/dist/i18n/locales/zh.js +5 -1
  108. package/dist/i18n/locales/zh.js.map +1 -1
  109. package/dist/index.d.ts +10 -11
  110. package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
  111. package/dist/lib/format.d.ts +18 -3
  112. package/dist/lib/format.js +2 -1
  113. package/dist/lib/format.js.map +1 -1
  114. package/dist/rhf/fields.d.ts +3 -4
  115. package/dist/rhf/form.d.ts +3 -4
  116. package/dist/rhf/line-items.js.map +1 -1
  117. package/dist/rhf.d.ts +3 -4
  118. package/dist/shell/app-shell.d.ts +3 -4
  119. package/dist/shell/top-bar-brand.d.ts +3 -4
  120. package/dist/shell.d.ts +3 -4
  121. package/dist/theme/palette-derive.d.ts +3 -0
  122. package/dist/theme/palette-derive.js +7 -0
  123. package/dist/theme/palette-derive.js.map +1 -1
  124. package/dist/wizard/stepper-nav.d.ts +3 -4
  125. package/dist/wizard.d.ts +3 -4
  126. package/package.json +4 -2
  127. package/src/components/alert-banner.tsx +1 -1
  128. package/src/components/amount-input.tsx +47 -16
  129. package/src/components/authed-image.tsx +20 -2
  130. package/src/components/checkbox.tsx +1 -1
  131. package/src/components/choice-card.tsx +1 -1
  132. package/src/components/error-boundary.tsx +1 -1
  133. package/src/components/file-button.tsx +77 -27
  134. package/src/components/file-dropzone.tsx +1 -1
  135. package/src/components/form-actions.tsx +86 -8
  136. package/src/components/line-items.tsx +131 -14
  137. package/src/components/list.tsx +240 -50
  138. package/src/components/loading-state.tsx +35 -4
  139. package/src/components/number-input.tsx +18 -2
  140. package/src/components/passkeys-setting.tsx +45 -18
  141. package/src/components/progress-bar.tsx +46 -21
  142. package/src/components/signed-amount.tsx +47 -8
  143. package/src/components/stat-tile.tsx +76 -6
  144. package/src/components/status-dot.tsx +31 -0
  145. package/src/components/swatch-picker.tsx +3 -1
  146. package/src/components/table.tsx +42 -15
  147. package/src/components/toggle-legend.tsx +21 -4
  148. package/src/components/ui.tsx +280 -70
  149. package/src/feedback/feedback-attachment.tsx +472 -96
  150. package/src/feedback/feedback-dialog.tsx +162 -50
  151. package/src/i18n/locales/de.ts +4 -0
  152. package/src/i18n/locales/es.ts +4 -0
  153. package/src/i18n/locales/fr.ts +4 -0
  154. package/src/i18n/locales/hu.ts +4 -0
  155. package/src/i18n/locales/it.ts +4 -0
  156. package/src/i18n/locales/zh.ts +4 -0
  157. package/src/lib/format.ts +24 -3
  158. package/src/rhf/line-items.tsx +6 -0
  159. package/src/theme/palette-derive.ts +10 -0
  160. package/tokens.css +9 -1
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { f as ButtonVariant } from '../kit-labels-CnnBd-2N.js';
3
+ import { f as ButtonVariant } from '../kit-labels-DE--LmCk.js';
4
4
  import { UseWizardReturn, WizardLabels } from './types.js';
5
5
  import '../components/data-table-labels.js';
6
6
  import '../components/mini-calendar.js';
@@ -32,8 +32,7 @@ import '../components/dialog-frame.js';
32
32
  import '../components/modal.js';
33
33
  import '../hooks/use-search-param-state.js';
34
34
  import '../components/measured-grid.js';
35
- import '../feedback/feedback-attachment.js';
36
- import '../feedback/feedback-dialog.js';
35
+ import '../feedback-attachment-WFiXJ8We.js';
37
36
  import '../feedback/feedback-thread.js';
38
37
  import '../feedback/feedback-inbox.js';
39
38
  import '../components/account-settings-labels.js';
@@ -41,12 +40,12 @@ import '../components/confirm-dialog.js';
41
40
  import '../components/floating-panel.js';
42
41
  import '../components/bulk-action-bar.js';
43
42
  import '../components/list.js';
43
+ import '../components/signed-amount.js';
44
44
  import '../components/breadcrumbs.js';
45
45
  import '../components/toast.js';
46
46
  import '../components/description-list.js';
47
47
  import '../components/line-items.js';
48
48
  import '../components/progress-bar.js';
49
- import '../components/signed-amount.js';
50
49
  import '../components/error-boundary.js';
51
50
  import '../components/authed-image.js';
52
51
  import '../hooks/use-authed-src.js';
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './kit-labels-CnnBd-2N.js';
9
+ import './kit-labels-DE--LmCk.js';
10
10
  import './components/data-table-labels.js';
11
11
  import './components/mini-calendar.js';
12
12
  import './components/calendar-heatmap.js';
@@ -37,8 +37,7 @@ import './components/dialog-frame.js';
37
37
  import './components/modal.js';
38
38
  import './hooks/use-search-param-state.js';
39
39
  import './components/measured-grid.js';
40
- import './feedback/feedback-attachment.js';
41
- import './feedback/feedback-dialog.js';
40
+ import './feedback-attachment-WFiXJ8We.js';
42
41
  import './feedback/feedback-thread.js';
43
42
  import './feedback/feedback-inbox.js';
44
43
  import './components/account-settings-labels.js';
@@ -46,12 +45,12 @@ import './components/confirm-dialog.js';
46
45
  import './components/floating-panel.js';
47
46
  import './components/bulk-action-bar.js';
48
47
  import './components/list.js';
48
+ import './components/signed-amount.js';
49
49
  import './components/breadcrumbs.js';
50
50
  import './components/toast.js';
51
51
  import './components/description-list.js';
52
52
  import './components/line-items.js';
53
53
  import './components/progress-bar.js';
54
- import './components/signed-amount.js';
55
54
  import './components/error-boundary.js';
56
55
  import './components/authed-image.js';
57
56
  import './hooks/use-authed-src.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -77,7 +77,9 @@
77
77
  "check:tailwind": "node scripts/check-tailwind-scan.mjs",
78
78
  "check:security": "npm audit --audit-level=high",
79
79
  "check:commits": "node scripts/check-commit-range.cjs",
80
- "check:static": "npm run typecheck && npm run lint && npm run check:tokens && npm run check:security && npm run check:commits",
80
+ "check:commit-rules": "node scripts/check-commit-rules.mjs",
81
+ "sync:commit-rules": "node scripts/sync-commit-rules.mjs",
82
+ "check:static": "npm run typecheck && npm run lint && npm run check:tokens && npm run check:security && npm run check:commit-rules && npm run check:commits",
81
83
  "check:test": "npm run test:coverage",
82
84
  "check:build": "npm run build && npm run check:graph && npm run check:package && npm run build:showcase && npm run check:tailwind",
83
85
  "check": "node scripts/check.mjs",
@@ -28,7 +28,7 @@ export type AlertSize = "sm" | "md";
28
28
  * at any offset. Callers compensate the extra pixel in their own padding so
29
29
  * switching tones never shifts the layout. */
30
30
  const TONE_FRAME: Record<AlertTone, string> = {
31
- danger: "border-2 border-[var(--danger-border)] bg-[var(--danger-bg)]",
31
+ danger: "border-2 border-[var(--danger-border-strong)] bg-[var(--danger-bg)]",
32
32
  warning: "border-2 border-[var(--warning-border)] bg-[var(--warning-bg)]",
33
33
  info: "border-2 border-[var(--info-border)] bg-[var(--info-bg)]",
34
34
  success: "border-2 border-[var(--success-border)] bg-[var(--success-bg)]",
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, useId, useMemo, useRef, useState } from "react
2
2
  import { ChevronDown } from "lucide-react";
3
3
  import type { ReactNode } from "react";
4
4
  import { CURRENCIES, CurrencyFlag, currencyName, getCurrency } from "./currency-select";
5
- import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FLOATING_LABEL_CLASS, PHONE_QUERY } from "./ui";
5
+ import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui";
6
6
  import { cn } from "../lib/cn";
7
7
  import { currencyMinorDigits } from "../lib/format";
8
8
  import { useMediaQuery } from "../hooks/use-media-query";
@@ -48,6 +48,13 @@ interface AmountInputProps {
48
48
  "aria-describedby"?: string;
49
49
  "aria-invalid"?: boolean | "true" | "false";
50
50
  "aria-required"?: boolean | "true" | "false";
51
+ /** {@link NumberInput}'s `hint`, the same rule: a {@link FieldHint} "?" rides the
52
+ * label's own line, beside the label (not at the far end, where the calculator and
53
+ * the currency chip live); plain TEXT (a string or a number) is a caption UNDER
54
+ * the field, attached through `aria-describedby` after the caller's own. A phone's
55
+ * `variant="display"` has no visible label, so a FieldHint is dropped there; a
56
+ * caption still shows. */
57
+ hint?: ReactNode;
51
58
  /** Every user-facing string this component and the two it composes own, so a
52
59
  * translating host can supply its own. Each key optional, and each a per-field
53
60
  * override of the kit-wide translation in `<UiKitProvider labels>`: `currency`
@@ -100,6 +107,9 @@ interface AmountInputProps {
100
107
  * Because it is rendered rather than stored it cannot be deleted (the next render
101
108
  * puts it back), a select-all-and-retype keeps the direction, and the caret needs
102
109
  * no minding.
110
+ *
111
+ * Since 0.15.5 it is drawn on its own too, for a read-only or disabled signed figure
112
+ * (an "Items total" of −5.18): no `onNegativeChange` needed just to show the minus.
103
113
  */
104
114
  negative?: boolean;
105
115
  /**
@@ -293,9 +303,13 @@ function isResultOf(previous: string, text: string, settle: (text: string) => st
293
303
  }
294
304
 
295
305
  export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
296
- ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max, onCommit }, ref) => {
306
+ ({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, hint, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max, onCommit }, ref) => {
297
307
  const generatedId = useId();
298
308
  const fieldId = id ?? generatedId;
309
+ const hintId = useId();
310
+ // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
311
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
312
+ const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;
299
313
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
300
314
  const editable = !!onCurrencyChange;
301
315
  // On phones we suppress the OS keyboard (inputMode="none" below) and show our
@@ -330,7 +344,11 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
330
344
  // front of a bare amount: an expression being typed ("12-30") owns its own
331
345
  // minus and has to survive keystroke-for-keystroke.
332
346
  const signOwned = onNegativeChange !== undefined;
333
- const shown = signOwned && negative && isBareAmount(value) ? `-${value}` : value;
347
+ // The sign is DRAWN whenever `negative` says so, handler or not (keksdose live #366):
348
+ // a read-only or disabled total ("−5.18") used to need a dummy `onNegativeChange`
349
+ // just to show its minus. Without the handler there is nothing to toggle — a typed
350
+ // minus simply stays in the text, as in any field without a direction control.
351
+ const shown = negative && isBareAmount(value) ? `-${value}` : value;
334
352
  // What the user SEES is `shown` with the locale's decimal mark (kastlan 40: a
335
353
  // fr-CH user typed "1,5" and watched it turn into "1.5" under their fingers). Display only — `value`, `onChange`, the sign split and the settle all
336
354
  // stay dot-decimal, because that string is the contract: RhfMoneyField's
@@ -447,8 +465,17 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
447
465
  <div ref={wrapperRef} className={cn("w-full", className)}>
448
466
  {/* Inner relative box holds the input + its absolutely-positioned
449
467
  trailing controls (calculator / currency), so those stay anchored to
450
- the INPUT even when the mobile math bar is rendered below (#334). */}
451
- <div className="relative w-full">
468
+ the INPUT even when the mobile math bar is rendered below (#334). It is
469
+ the kit's FloatingField, which draws the label — floating, or sr-only in
470
+ the display shape — and puts a FieldHint on the label's line, as it does
471
+ for NumberInput. */}
472
+ <FloatingField
473
+ className="w-full"
474
+ htmlFor={fieldId}
475
+ label={label}
476
+ srOnlyLabel={asDisplay}
477
+ hint={textHint ? undefined : hint}
478
+ >
452
479
  <input
453
480
  ref={setRefs}
454
481
  id={fieldId}
@@ -506,18 +533,15 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
506
533
  invalid && FIELD_INVALID,
507
534
  )}
508
535
  aria-invalid={invalid || undefined}
509
- aria-describedby={ariaDescribedBy}
536
+ aria-describedby={describedBy}
510
537
  aria-required={ariaRequired}
511
538
  />
512
- {label !== undefined && (
513
- // The display shape drops the label VISUALLY, not from the accessibility
514
- // tree: a 36px tinted figure at the top of a transaction form is legibly
515
- // the amount, but a screen reader still needs the name, and callers that
516
- // pass `label` without `ariaLabel` would otherwise be left with none.
517
- <label htmlFor={fieldId} className={asDisplay ? "sr-only" : FLOATING_LABEL_CLASS}>
518
- {label}
519
- </label>
520
- )}
539
+ {/* The label is FloatingField's, after these children (the floating trick
540
+ reads the input through `peer-*`). The display shape drops it VISUALLY,
541
+ not from the accessibility tree: a 36px tinted figure at the top of a
542
+ transaction form is legibly the amount, but a screen reader still needs
543
+ the name, and callers that pass `label` without `ariaLabel` would
544
+ otherwise be left with none. */}
521
545
  {/* Trailing controls share one flex track so the calculator icon and the
522
546
  currency suffix/picker sit side by side without overlapping. */}
523
547
  {/* The display shape has no box to inset from, so the controls align to the
@@ -620,7 +644,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
620
644
  })}
621
645
  </DropdownPanel>
622
646
  )}
623
- </div>
647
+ </FloatingField>
648
+ {/* Outside the relative box, as NumberInput's caption is: the trailing
649
+ controls are `inset-y-1` in it and would stretch down over a second line. */}
650
+ {textHint && (
651
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
652
+ {hint}
653
+ </p>
654
+ )}
624
655
  {showNumpad && (
625
656
  <NumberPadSheet
626
657
  // Localised like the field it mirrors. Keys are typing, read by the
@@ -44,8 +44,18 @@ export interface AuthedImageProps
44
44
  link?: boolean;
45
45
  /** Replaces the default skeleton while loading. */
46
46
  loadingFallback?: ReactNode;
47
- /** Replaces the default "couldn't load" tile. */
47
+ /**
48
+ * Replaces the default "couldn't load" tile. `null` renders nothing in its place —
49
+ * an optional photo slot that simply stays empty; only `undefined` (left out) means
50
+ * the default tile.
51
+ */
48
52
  errorFallback?: ReactNode;
53
+ /**
54
+ * Keep a click on the image (and the Enter that follows its `link`) from reaching an
55
+ * ancestor — a thumbnail in a DataTable row or a clickable card opens itself instead
56
+ * of also running the row's action. As TextLink's and IconButton's `stopPropagation`.
57
+ */
58
+ stopPropagation?: boolean;
49
59
  labels?: Partial<AuthedImageLabels>;
50
60
  }
51
61
 
@@ -68,6 +78,7 @@ export function AuthedImage({
68
78
  link,
69
79
  loadingFallback,
70
80
  errorFallback,
81
+ stopPropagation,
71
82
  labels: labelsProp,
72
83
  onLoad,
73
84
  onError,
@@ -85,6 +96,7 @@ export function AuthedImage({
85
96
  link={link}
86
97
  loadingFallback={loadingFallback}
87
98
  errorFallback={errorFallback}
99
+ stopPropagation={stopPropagation}
88
100
  imgProps={rest}
89
101
  onLoad={onLoad}
90
102
  onError={onError}
@@ -106,6 +118,7 @@ export function ImageStates({
106
118
  link,
107
119
  loadingFallback,
108
120
  errorFallback,
121
+ stopPropagation,
109
122
  imgProps,
110
123
  onLoad,
111
124
  onError,
@@ -118,6 +131,7 @@ export function ImageStates({
118
131
  link?: boolean;
119
132
  loadingFallback?: ReactNode;
120
133
  errorFallback?: ReactNode;
134
+ stopPropagation?: boolean;
121
135
  imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "children" | "onLoad" | "onError">;
122
136
  onLoad?: ImgHTMLAttributes<HTMLImageElement>["onLoad"];
123
137
  onError?: ImgHTMLAttributes<HTMLImageElement>["onError"];
@@ -153,7 +167,10 @@ export function ImageStates({
153
167
  );
154
168
 
155
169
  return (
170
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction: only fences the image's own clicks off from an ancestor's handler
156
171
  <span
172
+ onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
173
+ onKeyDown={stopPropagation ? (e) => e.key === "Enter" && e.stopPropagation() : undefined}
157
174
  data-image-state={failed ? "error" : waiting ? "loading" : fetched.status === "idle" ? "idle" : "ready"}
158
175
  className={cn("relative block overflow-hidden", wrapperClassName)}
159
176
  aria-busy={waiting || undefined}
@@ -172,7 +189,8 @@ export function ImageStates({
172
189
  </span>
173
190
  ))}
174
191
  {failed &&
175
- (errorFallback ?? (
192
+ // `=== undefined`, not `??`: `null` is the caller asking for nothing at all.
193
+ (errorFallback !== undefined ? errorFallback : (
176
194
  <span
177
195
  // `role="img"` with the alt as its name: the picture that failed still
178
196
  // occupies its place in the reading order, and "couldn't be loaded" is
@@ -40,7 +40,7 @@ const BOX_MIXED = "border-[var(--brand)] bg-[var(--brand)]";
40
40
  // survives fractional display scaling on every edge (see the note in ui.tsx). The
41
41
  // focus ring turns danger too, so focusing a wrong box does not repaint it as fine.
42
42
  const BOX_INVALID =
43
- "border-[var(--danger-border)] ring-1 ring-[var(--danger-border)] checked:border-[var(--danger-border)] focus-visible:ring-[var(--danger)]";
43
+ "border-[var(--danger-border-strong)] ring-1 ring-[var(--danger-border-strong)] checked:border-[var(--danger-border-strong)] focus-visible:ring-[var(--danger)]";
44
44
 
45
45
  const ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
46
46
 
@@ -102,7 +102,7 @@ const CARD =
102
102
  const CARD_DISABLED = "hover:border-[var(--border)] hover:bg-[var(--bg-surface)]";
103
103
 
104
104
  const CARD_INVALID =
105
- "border-[var(--danger-border)] ring-1 ring-[var(--danger-border)] has-[:checked]:border-[var(--danger-border)] has-[:checked]:ring-[var(--danger-border)]";
105
+ "border-[var(--danger-border-strong)] ring-1 ring-[var(--danger-border-strong)] has-[:checked]:border-[var(--danger-border-strong)] has-[:checked]:ring-[var(--danger-border-strong)]";
106
106
 
107
107
  const BOX =
108
108
  "peer size-4 shrink-0 appearance-none border border-[var(--border-strong)] bg-[var(--bg-surface)] transition-colors " +
@@ -642,7 +642,7 @@ function ErrorFallback({
642
642
  // program's words and can be read out or screenshotted verbatim.
643
643
  <p
644
644
  data-error-message=""
645
- className="-mt-1 mb-1 basis-full whitespace-pre-wrap break-words rounded-md border border-[var(--danger-border)] bg-[var(--danger-bg)] px-3 py-2 text-start font-mono text-xs text-[var(--text-primary)]"
645
+ className="-mt-1 mb-1 basis-full whitespace-pre-wrap break-words rounded-md border border-[var(--danger-border-strong)] bg-[var(--danger-bg)] px-3 py-2 text-start font-mono text-xs text-[var(--text-primary)]"
646
646
  >
647
647
  {errorLine}
648
648
  </p>
@@ -1,4 +1,4 @@
1
- import { forwardRef, useRef, useState } from "react";
1
+ import { forwardRef, useId, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { useAnnounce } from "../hooks/use-announce";
@@ -418,6 +418,15 @@ export interface FileButtonProps
418
418
  * Off by default: a button that silently takes drops is a surprise on a page that
419
419
  * has a real drop target elsewhere. */
420
420
  droppable?: boolean;
421
+ /**
422
+ * Show what was picked beside the button: the file's name, or "3 files selected"
423
+ * (`filePicker.selected`) for several — a native `<input type="file">` says this,
424
+ * and a bare button that takes a file and says nothing leaves the user to trust it.
425
+ * The read-out is a polite live region and describes the button, so it is heard
426
+ * when it changes and again on focus. It holds the last ACCEPTED pick: a refused
427
+ * file leaves it as it was. Off by default, so the button's layout is unchanged.
428
+ */
429
+ showFileName?: boolean;
421
430
  }
422
431
 
423
432
  /**
@@ -447,6 +456,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
447
456
  labels,
448
457
  pending,
449
458
  droppable,
459
+ showFileName = false,
450
460
  disabled,
451
461
  children,
452
462
  className,
@@ -460,6 +470,9 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
460
470
  ref,
461
471
  ) {
462
472
  const inert = Boolean(disabled || pending);
473
+ const [picked, setPicked] = useState<readonly File[]>([]);
474
+ const pickedId = useId();
475
+ const pickerLabels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labels);
463
476
  const picker = useFilePicker({
464
477
  accept,
465
478
  multiple,
@@ -468,12 +481,19 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
468
481
  maxFiles,
469
482
  isValid,
470
483
  invalidMessage,
471
- onFiles,
484
+ onFiles: showFileName
485
+ ? (files) => {
486
+ setPicked(files);
487
+ onFiles?.(files);
488
+ }
489
+ : onFiles,
472
490
  onPick,
473
491
  onReject,
474
492
  labels,
475
493
  disabled: inert,
476
494
  });
495
+ const pickedText =
496
+ picked.length === 0 ? "" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);
477
497
  const [dragOver, setDragOver] = useState(false);
478
498
 
479
499
  const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
@@ -514,34 +534,64 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
514
534
  // declare it, hence the widening here rather than a second button implementation.
515
535
  const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };
516
536
 
537
+ const button = (
538
+ <Button
539
+ {...rest}
540
+ {...refProp}
541
+ {...dropHandlers}
542
+ type="button"
543
+ disabled={inert}
544
+ aria-busy={pending || undefined}
545
+ aria-describedby={
546
+ showFileName && pickedText
547
+ ? [rest["aria-describedby"], pickedId].filter(Boolean).join(" ")
548
+ : rest["aria-describedby"]
549
+ }
550
+ data-drag-over={dragOver || undefined}
551
+ className={cn(
552
+ // A live drag gets the focus ring's colour as a ring: "let go here", in the
553
+ // same vocabulary the button already uses for "you are here".
554
+ dragOver && "ring-2 ring-[var(--brand)]",
555
+ // Beside a long file name it is the NAME that gives way, never the button.
556
+ showFileName && "shrink-0",
557
+ className,
558
+ )}
559
+ onClick={(e) => {
560
+ onClick?.(e);
561
+ if (!e.defaultPrevented) picker.open();
562
+ }}
563
+ >
564
+ {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same
565
+ thing on the button, and a spoken "Loading" would run into its name. */}
566
+ {pending && <Spinner label={null} className="size-4" />}
567
+ {children}
568
+ </Button>
569
+ );
570
+
571
+ if (!showFileName) {
572
+ return (
573
+ <>
574
+ {button}
575
+ {picker.element}
576
+ </>
577
+ );
578
+ }
517
579
  return (
518
- <>
519
- <Button
520
- {...rest}
521
- {...refProp}
522
- {...dropHandlers}
523
- type="button"
524
- disabled={inert}
525
- aria-busy={pending || undefined}
526
- data-drag-over={dragOver || undefined}
527
- className={cn(
528
- // A live drag gets the focus ring's colour as a ring: "let go here", in the
529
- // same vocabulary the button already uses for "you are here".
530
- dragOver && "ring-2 ring-[var(--brand)]",
531
- className,
532
- )}
533
- onClick={(e) => {
534
- onClick?.(e);
535
- if (!e.defaultPrevented) picker.open();
536
- }}
580
+ // One inline row, so the name sits beside the button wherever the button sits; the
581
+ // name truncates (full list in its title) rather than pushing the row wider.
582
+ <span className="inline-flex max-w-full min-w-0 items-center gap-2">
583
+ {button}
584
+ <span
585
+ id={pickedId}
586
+ aria-live="polite"
587
+ data-slot="file-button-name"
588
+ title={picked.map((f) => f.name).join(", ") || undefined}
589
+ className="min-w-0 truncate text-sm text-[var(--text-secondary)]"
537
590
  >
538
- {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same
539
- thing on the button, and a spoken "Loading" would run into its name. */}
540
- {pending && <Spinner label={null} className="size-4" />}
541
- {children}
542
- </Button>
591
+ {pickedText}
592
+ </span>
543
593
  {picker.element}
544
- </>
594
+ </span>
545
595
  );
546
596
  });
547
597
  FileButton.displayName = "FileButton";
@@ -339,7 +339,7 @@ export function FileDropzone({
339
339
  : dragOver
340
340
  ? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]"
341
341
  : showError
342
- ? "cursor-pointer border-[var(--danger-border)]"
342
+ ? "cursor-pointer border-[var(--danger-border-strong)]"
343
343
  : "cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]",
344
344
  className,
345
345
  )}