@eifi1/ui-kit 0.15.5 → 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 (140) hide show
  1. package/README.md +17 -12
  2. package/dist/chart.d.ts +3 -3
  3. package/dist/components/amount-input.d.ts +10 -3
  4. package/dist/components/amount-input.js +165 -156
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/authed-image.d.ts +14 -3
  7. package/dist/components/authed-image.js +32 -23
  8. package/dist/components/authed-image.js.map +1 -1
  9. package/dist/components/button-group.d.ts +3 -3
  10. package/dist/components/calculator.d.ts +3 -3
  11. package/dist/components/copy-button.d.ts +3 -3
  12. package/dist/components/facing-pair.d.ts +3 -3
  13. package/dist/components/file-button.d.ts +3 -3
  14. package/dist/components/file-button.js +64 -31
  15. package/dist/components/file-button.js.map +1 -1
  16. package/dist/components/file-dropzone.d.ts +3 -3
  17. package/dist/components/form-actions.d.ts +4 -4
  18. package/dist/components/form-actions.js +18 -6
  19. package/dist/components/form-actions.js.map +1 -1
  20. package/dist/components/line-items.d.ts +53 -2
  21. package/dist/components/line-items.js +73 -7
  22. package/dist/components/line-items.js.map +1 -1
  23. package/dist/components/list.d.ts +99 -5
  24. package/dist/components/list.js +103 -38
  25. package/dist/components/list.js.map +1 -1
  26. package/dist/components/loading-state.d.ts +19 -3
  27. package/dist/components/loading-state.js +14 -3
  28. package/dist/components/loading-state.js.map +1 -1
  29. package/dist/components/money-field.d.ts +3 -3
  30. package/dist/components/number-field.d.ts +3 -3
  31. package/dist/components/number-input.d.ts +3 -3
  32. package/dist/components/number-input.js +4 -2
  33. package/dist/components/number-input.js.map +1 -1
  34. package/dist/components/numpad-sheet.d.ts +3 -3
  35. package/dist/components/passkeys-setting.d.ts +30 -10
  36. package/dist/components/passkeys-setting.js +19 -11
  37. package/dist/components/passkeys-setting.js.map +1 -1
  38. package/dist/components/progress-bar.d.ts +12 -3
  39. package/dist/components/progress-bar.js +21 -11
  40. package/dist/components/progress-bar.js.map +1 -1
  41. package/dist/components/series-chart.d.ts +3 -3
  42. package/dist/components/settings-fields.d.ts +3 -3
  43. package/dist/components/signed-amount.d.ts +28 -4
  44. package/dist/components/signed-amount.js +17 -8
  45. package/dist/components/signed-amount.js.map +1 -1
  46. package/dist/components/stat-tile.d.ts +31 -2
  47. package/dist/components/stat-tile.js +37 -6
  48. package/dist/components/stat-tile.js.map +1 -1
  49. package/dist/components/status-dot.d.ts +4 -1
  50. package/dist/components/status-dot.js +20 -1
  51. package/dist/components/status-dot.js.map +1 -1
  52. package/dist/components/swatch-picker.d.ts +3 -1
  53. package/dist/components/swatch-picker.js.map +1 -1
  54. package/dist/components/table.d.ts +18 -13
  55. package/dist/components/table.js +20 -5
  56. package/dist/components/table.js.map +1 -1
  57. package/dist/components/text-link.d.ts +3 -3
  58. package/dist/components/time-input.d.ts +3 -3
  59. package/dist/components/toggle-legend.d.ts +12 -1
  60. package/dist/components/toggle-legend.js +6 -2
  61. package/dist/components/toggle-legend.js.map +1 -1
  62. package/dist/components/ui.d.ts +3 -3
  63. package/dist/components/ui.js +149 -45
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/feedback/feedback-attachment.d.ts +1 -1
  66. package/dist/feedback/feedback-attachment.js +24 -11
  67. package/dist/feedback/feedback-attachment.js.map +1 -1
  68. package/dist/feedback/feedback-dialog.d.ts +1 -1
  69. package/dist/feedback/feedback-dialog.js +6 -5
  70. package/dist/feedback/feedback-dialog.js.map +1 -1
  71. package/dist/feedback/feedback-inbox.d.ts +1 -1
  72. package/dist/feedback/feedback-thread.d.ts +1 -1
  73. package/dist/{feedback-attachment-BYAHJMOr.d.ts → feedback-attachment-WFiXJ8We.d.ts} +21 -8
  74. package/dist/feedback.d.ts +1 -1
  75. package/dist/hooks/use-file-drop.d.ts +3 -3
  76. package/dist/i18n/defaults.d.ts +3 -3
  77. package/dist/i18n/kit-labels.d.ts +2 -2
  78. package/dist/i18n/locales/de-CH-informal.d.ts +3 -3
  79. package/dist/i18n/locales/de-CH.d.ts +3 -3
  80. package/dist/i18n/locales/de-informal.d.ts +3 -3
  81. package/dist/i18n/locales/de.d.ts +3 -3
  82. package/dist/i18n/locales/de.js +1 -1
  83. package/dist/i18n/locales/de.js.map +1 -1
  84. package/dist/i18n/locales/es.d.ts +3 -3
  85. package/dist/i18n/locales/es.js +1 -1
  86. package/dist/i18n/locales/es.js.map +1 -1
  87. package/dist/i18n/locales/fr.d.ts +3 -3
  88. package/dist/i18n/locales/fr.js +1 -1
  89. package/dist/i18n/locales/fr.js.map +1 -1
  90. package/dist/i18n/locales/hu.d.ts +3 -3
  91. package/dist/i18n/locales/hu.js +1 -1
  92. package/dist/i18n/locales/hu.js.map +1 -1
  93. package/dist/i18n/locales/it.d.ts +3 -3
  94. package/dist/i18n/locales/it.js +1 -1
  95. package/dist/i18n/locales/it.js.map +1 -1
  96. package/dist/i18n/locales/zh.d.ts +3 -3
  97. package/dist/i18n/locales/zh.js +1 -1
  98. package/dist/i18n/locales/zh.js.map +1 -1
  99. package/dist/index.d.ts +10 -10
  100. package/dist/{kit-labels-BD2F7sOz.d.ts → kit-labels-DE--LmCk.d.ts} +153 -12
  101. package/dist/lib/format.d.ts +18 -3
  102. package/dist/lib/format.js +2 -1
  103. package/dist/lib/format.js.map +1 -1
  104. package/dist/rhf/fields.d.ts +3 -3
  105. package/dist/rhf/form.d.ts +3 -3
  106. package/dist/rhf/line-items.js.map +1 -1
  107. package/dist/rhf.d.ts +3 -3
  108. package/dist/shell/app-shell.d.ts +3 -3
  109. package/dist/shell/top-bar-brand.d.ts +3 -3
  110. package/dist/shell.d.ts +3 -3
  111. package/dist/wizard/stepper-nav.d.ts +3 -3
  112. package/dist/wizard.d.ts +3 -3
  113. package/package.json +1 -1
  114. package/src/components/amount-input.tsx +39 -15
  115. package/src/components/authed-image.tsx +20 -2
  116. package/src/components/file-button.tsx +77 -27
  117. package/src/components/form-actions.tsx +86 -8
  118. package/src/components/line-items.tsx +131 -14
  119. package/src/components/list.tsx +240 -50
  120. package/src/components/loading-state.tsx +35 -4
  121. package/src/components/number-input.tsx +18 -2
  122. package/src/components/passkeys-setting.tsx +45 -18
  123. package/src/components/progress-bar.tsx +46 -21
  124. package/src/components/signed-amount.tsx +47 -8
  125. package/src/components/stat-tile.tsx +76 -6
  126. package/src/components/status-dot.tsx +31 -0
  127. package/src/components/swatch-picker.tsx +3 -1
  128. package/src/components/table.tsx +42 -15
  129. package/src/components/toggle-legend.tsx +21 -4
  130. package/src/components/ui.tsx +273 -63
  131. package/src/feedback/feedback-attachment.tsx +45 -14
  132. package/src/feedback/feedback-dialog.tsx +31 -16
  133. package/src/i18n/locales/de.ts +1 -1
  134. package/src/i18n/locales/es.ts +1 -1
  135. package/src/i18n/locales/fr.ts +1 -1
  136. package/src/i18n/locales/hu.ts +1 -1
  137. package/src/i18n/locales/it.ts +1 -1
  138. package/src/i18n/locales/zh.ts +1 -1
  139. package/src/lib/format.ts +24 -3
  140. package/src/rhf/line-items.tsx +6 -0
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { aB as KitLinkProps, aA as KitLinkComponent } from '../kit-labels-BD2F7sOz.js';
3
+ import { aE as KitLinkProps, aD as KitLinkComponent } from '../kit-labels-DE--LmCk.js';
4
4
  import '../components/data-table-labels.js';
5
5
  import '../components/mini-calendar.js';
6
6
  import '../components/calendar-heatmap.js';
@@ -32,7 +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-attachment-BYAHJMOr.js';
35
+ import '../feedback-attachment-WFiXJ8We.js';
36
36
  import '../feedback/feedback-thread.js';
37
37
  import '../feedback/feedback-inbox.js';
38
38
  import '../components/account-settings-labels.js';
@@ -40,12 +40,12 @@ import '../components/confirm-dialog.js';
40
40
  import '../components/floating-panel.js';
41
41
  import '../components/bulk-action-bar.js';
42
42
  import '../components/list.js';
43
+ import '../components/signed-amount.js';
43
44
  import '../components/breadcrumbs.js';
44
45
  import '../components/toast.js';
45
46
  import '../components/description-list.js';
46
47
  import '../components/line-items.js';
47
48
  import '../components/progress-bar.js';
48
- import '../components/signed-amount.js';
49
49
  import '../components/error-boundary.js';
50
50
  import '../components/authed-image.js';
51
51
  import '../hooks/use-authed-src.js';
package/dist/shell.d.ts CHANGED
@@ -13,7 +13,7 @@ import './theme/theme-store.js';
13
13
  import 'zustand/middleware';
14
14
  import 'zustand';
15
15
  import 'lucide-react';
16
- import './kit-labels-BD2F7sOz.js';
16
+ import './kit-labels-DE--LmCk.js';
17
17
  import './components/data-table-labels.js';
18
18
  import './components/mini-calendar.js';
19
19
  import './components/calendar-heatmap.js';
@@ -44,7 +44,7 @@ import './components/dialog-frame.js';
44
44
  import './components/modal.js';
45
45
  import './hooks/use-search-param-state.js';
46
46
  import './components/measured-grid.js';
47
- import './feedback-attachment-BYAHJMOr.js';
47
+ import './feedback-attachment-WFiXJ8We.js';
48
48
  import './feedback/feedback-thread.js';
49
49
  import './feedback/feedback-inbox.js';
50
50
  import './components/account-settings-labels.js';
@@ -52,12 +52,12 @@ import './components/confirm-dialog.js';
52
52
  import './components/floating-panel.js';
53
53
  import './components/bulk-action-bar.js';
54
54
  import './components/list.js';
55
+ import './components/signed-amount.js';
55
56
  import './components/breadcrumbs.js';
56
57
  import './components/toast.js';
57
58
  import './components/description-list.js';
58
59
  import './components/line-items.js';
59
60
  import './components/progress-bar.js';
60
- import './components/signed-amount.js';
61
61
  import './components/error-boundary.js';
62
62
  import './components/authed-image.js';
63
63
  import './hooks/use-authed-src.js';
@@ -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-BD2F7sOz.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,7 +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-attachment-BYAHJMOr.js';
35
+ import '../feedback-attachment-WFiXJ8We.js';
36
36
  import '../feedback/feedback-thread.js';
37
37
  import '../feedback/feedback-inbox.js';
38
38
  import '../components/account-settings-labels.js';
@@ -40,12 +40,12 @@ import '../components/confirm-dialog.js';
40
40
  import '../components/floating-panel.js';
41
41
  import '../components/bulk-action-bar.js';
42
42
  import '../components/list.js';
43
+ import '../components/signed-amount.js';
43
44
  import '../components/breadcrumbs.js';
44
45
  import '../components/toast.js';
45
46
  import '../components/description-list.js';
46
47
  import '../components/line-items.js';
47
48
  import '../components/progress-bar.js';
48
- import '../components/signed-amount.js';
49
49
  import '../components/error-boundary.js';
50
50
  import '../components/authed-image.js';
51
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-BD2F7sOz.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,7 +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-attachment-BYAHJMOr.js';
40
+ import './feedback-attachment-WFiXJ8We.js';
41
41
  import './feedback/feedback-thread.js';
42
42
  import './feedback/feedback-inbox.js';
43
43
  import './components/account-settings-labels.js';
@@ -45,12 +45,12 @@ import './components/confirm-dialog.js';
45
45
  import './components/floating-panel.js';
46
46
  import './components/bulk-action-bar.js';
47
47
  import './components/list.js';
48
+ import './components/signed-amount.js';
48
49
  import './components/breadcrumbs.js';
49
50
  import './components/toast.js';
50
51
  import './components/description-list.js';
51
52
  import './components/line-items.js';
52
53
  import './components/progress-bar.js';
53
- import './components/signed-amount.js';
54
54
  import './components/error-boundary.js';
55
55
  import './components/authed-image.js';
56
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.5",
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.",
@@ -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`
@@ -296,9 +303,13 @@ function isResultOf(previous: string, text: string, settle: (text: string) => st
296
303
  }
297
304
 
298
305
  export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
299
- ({ 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) => {
300
307
  const generatedId = useId();
301
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;
302
313
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
303
314
  const editable = !!onCurrencyChange;
304
315
  // On phones we suppress the OS keyboard (inputMode="none" below) and show our
@@ -454,8 +465,17 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
454
465
  <div ref={wrapperRef} className={cn("w-full", className)}>
455
466
  {/* Inner relative box holds the input + its absolutely-positioned
456
467
  trailing controls (calculator / currency), so those stay anchored to
457
- the INPUT even when the mobile math bar is rendered below (#334). */}
458
- <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
+ >
459
479
  <input
460
480
  ref={setRefs}
461
481
  id={fieldId}
@@ -513,18 +533,15 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
513
533
  invalid && FIELD_INVALID,
514
534
  )}
515
535
  aria-invalid={invalid || undefined}
516
- aria-describedby={ariaDescribedBy}
536
+ aria-describedby={describedBy}
517
537
  aria-required={ariaRequired}
518
538
  />
519
- {label !== undefined && (
520
- // The display shape drops the label VISUALLY, not from the accessibility
521
- // tree: a 36px tinted figure at the top of a transaction form is legibly
522
- // the amount, but a screen reader still needs the name, and callers that
523
- // pass `label` without `ariaLabel` would otherwise be left with none.
524
- <label htmlFor={fieldId} className={asDisplay ? "sr-only" : FLOATING_LABEL_CLASS}>
525
- {label}
526
- </label>
527
- )}
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. */}
528
545
  {/* Trailing controls share one flex track so the calculator icon and the
529
546
  currency suffix/picker sit side by side without overlapping. */}
530
547
  {/* The display shape has no box to inset from, so the controls align to the
@@ -627,7 +644,14 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
627
644
  })}
628
645
  </DropdownPanel>
629
646
  )}
630
- </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
+ )}
631
655
  {showNumpad && (
632
656
  <NumberPadSheet
633
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
@@ -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";
@@ -1,8 +1,9 @@
1
1
  import { isValidElement } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import type { LucideIcon } from "lucide-react";
3
4
  import { cn } from "../lib/cn";
4
5
  import { useKitLabels } from "../i18n/kit-labels";
5
- import { Button, Spinner, type ButtonVariant } from "./ui";
6
+ import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
6
7
 
7
8
  /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
8
9
  export interface FormActionsLabels {
@@ -28,6 +29,25 @@ export type FormActionsAlign = "start" | "center" | "end" | "between";
28
29
  */
29
30
  export type FormActionsPlacement = "inline" | "sticky" | "dialog";
30
31
 
32
+ /**
33
+ * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
34
+ * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
35
+ * the phone's bottom nav by `--app-nav-h`.
36
+ * - `container`: the bottom of whatever scroll container it is in — a dialog body, a
37
+ * side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.
38
+ */
39
+ export type FormActionsStickyWithin = "viewport" | "container";
40
+
41
+ /**
42
+ * Extra attributes for the save button — an `id`, `data-*` hooks for tests or
43
+ * analytics, an `aria-describedby`. The props FormActions drives itself (type, click,
44
+ * disabled, variant, form, children, the busy state) are not among them.
45
+ */
46
+ export type FormActionsSubmitProps = Omit<
47
+ ButtonProps,
48
+ "type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"
49
+ > & { [key: `data-${string}`]: string | number | boolean | undefined };
50
+
31
51
  /** The start-side action of {@link FormActionsProps.destructive}, as data. */
32
52
  export interface FormActionsDestructive {
33
53
  label: ReactNode;
@@ -65,10 +85,21 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
65
85
  cancelLabel?: ReactNode;
66
86
  /** The save button's text while `pending` ("Saving…"). Default: `submitLabel`. */
67
87
  pendingLabel?: ReactNode;
88
+ /**
89
+ * A Lucide icon before the save button's text. While `pending` the spinner takes
90
+ * ITS place rather than being added in front of it — an icon written into
91
+ * `submitLabel` itself stays, so the button would show the spinner AND the glyph,
92
+ * two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of
93
+ * its text only to drop the check mark).
94
+ */
95
+ submitIcon?: LucideIcon;
96
+ /** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a
97
+ * `className`. See {@link FormActionsSubmitProps}. */
98
+ submitProps?: FormActionsSubmitProps;
68
99
  /**
69
100
  * The save is running: a spinner in the save button, `aria-busy` on it, and the
70
101
  * button disabled, so a second click cannot send the form twice. Cancel stays
71
- * usable.
102
+ * usable. The spinner goes in front of the text, or in place of {@link submitIcon}.
72
103
  */
73
104
  pending?: boolean;
74
105
  /** Disable save for a reason of the form's own (an unbalanced entry). */
@@ -85,10 +116,39 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
85
116
  * is `between`-aligned unless `align` says otherwise.
86
117
  */
87
118
  destructive?: ReactNode | FormActionsDestructive;
119
+ /**
120
+ * Something neutral at the START of the row — a caption ("Last saved 2 min ago",
121
+ * "Drag the corners to crop"), a "View activity" link. After {@link destructive}
122
+ * when both are given; like it, makes the row `between`-aligned unless `align` says
123
+ * otherwise. `children` are for further ACTIONS, beside Cancel.
124
+ */
125
+ start?: ReactNode;
88
126
  /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
89
127
  align?: FormActionsAlign;
90
128
  /** See {@link FormActionsPlacement}. Default `inline`. */
91
129
  placement?: FormActionsPlacement;
130
+ /**
131
+ * With `placement="sticky"`: what the row sticks to. Default `viewport`.
132
+ *
133
+ * CSS sticky always sticks to the NEAREST scroll container, so the default row
134
+ * already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,
135
+ * which AppShell publishes on the document: inside a pane or a portalled dialog
136
+ * body on a phone it then floats a nav's height above that container's bottom,
137
+ * with the content scrolling through the gap. `container` drops that offset.
138
+ *
139
+ * What neither value can fix, because it is the layout, not the row:
140
+ * - sticky moves only within its PARENT box. Inside a DataTable expansion row the
141
+ * parent is the expansion cell, so the row pins to the table's own scroller
142
+ * (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans
143
+ * that scroller's bottom edge — not to the page.
144
+ * - any `overflow` other than `visible`/`clip` between the row and the scroller
145
+ * you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the
146
+ * scroll container; if THAT box does not scroll, the row never sticks.
147
+ * - the offset is resolved against the scroller's content box, so a scroller with
148
+ * bottom padding leaves that strip uncovered; give the row a negative `bottom`
149
+ * through `style` and the same amount back as padding.
150
+ */
151
+ stickyWithin?: FormActionsStickyWithin;
92
152
  /** The `id` of the `<form>` the save button submits, when the row is rendered
93
153
  * outside it (a dialog's footer slot). */
94
154
  form?: string;
@@ -121,13 +181,17 @@ export function FormActions({
121
181
  submitLabel,
122
182
  cancelLabel,
123
183
  pendingLabel,
184
+ submitIcon: SubmitIcon,
185
+ submitProps,
124
186
  pending = false,
125
187
  submitDisabled = false,
126
188
  submitDisabledReason,
127
189
  submitVariant = "brand",
128
190
  destructive,
191
+ start: startSlot,
129
192
  align,
130
193
  placement = "inline",
194
+ stickyWithin = "viewport",
131
195
  form,
132
196
  children,
133
197
  className,
@@ -136,8 +200,9 @@ export function FormActions({
136
200
  }: FormActionsProps) {
137
201
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
138
202
  const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
139
- const justify = align ?? (hasDestructive ? "between" : "end");
140
- const start = !hasDestructive ? null : isDestructiveData(destructive) ? (
203
+ const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
204
+ const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
205
+ const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (
141
206
  <Button
142
207
  type="button"
143
208
  variant="ghost"
@@ -157,6 +222,7 @@ export function FormActions({
157
222
  <div
158
223
  data-slot="form-actions"
159
224
  data-placement={placement}
225
+ data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
160
226
  {...rest}
161
227
  style={
162
228
  placement === "sticky"
@@ -165,15 +231,22 @@ export function FormActions({
165
231
  // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`
166
232
  // pinned the row under AppShell's nav, which covers the page's last ~56px.
167
233
  // `--app-nav-h` is the nav's measured height (0px from md up, and outside an
168
- // AppShell); the 1px overlaps the two top borders into one line.
169
- bottom: "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
234
+ // AppShell); the 1px overlaps the two top borders into one line. In a
235
+ // container the nav is not underneath: `--app-nav-h` is the document's,
236
+ // and would hold the row that far above the container's own edge.
237
+ bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
170
238
  ...style,
171
239
  }
172
240
  : style
173
241
  }
174
242
  className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
175
243
  >
176
- {start && <div className="me-auto flex items-center gap-2">{start}</div>}
244
+ {(hasDestructive || hasStart) && (
245
+ <div className="me-auto flex min-w-0 flex-wrap items-center gap-2">
246
+ {destructiveNode}
247
+ {hasStart && startSlot}
248
+ </div>
249
+ )}
177
250
  <div className="flex flex-wrap items-center gap-2">
178
251
  {children}
179
252
  {onCancel && (
@@ -182,6 +255,7 @@ export function FormActions({
182
255
  </Button>
183
256
  )}
184
257
  <Button
258
+ {...submitProps}
185
259
  type={onSubmit ? "button" : "submit"}
186
260
  form={form}
187
261
  onClick={onSubmit}
@@ -190,7 +264,11 @@ export function FormActions({
190
264
  disabledReason={pending ? undefined : submitDisabledReason}
191
265
  aria-busy={pending || undefined}
192
266
  >
193
- {pending && <Spinner label={null} className="size-4" />}
267
+ {pending ? (
268
+ <Spinner label={null} className="size-4" />
269
+ ) : (
270
+ SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
271
+ )}
194
272
  {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
195
273
  </Button>
196
274
  </div>