@eifi1/ui-kit 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +6 -6
  2. package/dist/chart.d.ts +2 -4
  3. package/dist/components/amount-input.d.ts +16 -9
  4. package/dist/components/amount-input.js +13 -11
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +5 -0
  7. package/dist/components/autocomplete.js +39 -37
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +2 -4
  10. package/dist/components/calculator.d.ts +2 -4
  11. package/dist/components/column-mapper.d.ts +62 -9
  12. package/dist/components/column-mapper.js +85 -49
  13. package/dist/components/column-mapper.js.map +1 -1
  14. package/dist/components/combobox.d.ts +33 -1
  15. package/dist/components/combobox.js +8 -2
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +2 -4
  18. package/dist/components/copy-button.d.ts +2 -4
  19. package/dist/components/danger-confirm.d.ts +2 -4
  20. package/dist/components/danger-confirm.js +52 -5
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/entity-combobox.d.ts +13 -1
  23. package/dist/components/entity-combobox.js +13 -4
  24. package/dist/components/entity-combobox.js.map +1 -1
  25. package/dist/components/facing-pair.d.ts +2 -4
  26. package/dist/components/file-button.d.ts +2 -4
  27. package/dist/components/file-dropzone.d.ts +2 -4
  28. package/dist/components/form-actions.d.ts +1 -3
  29. package/dist/components/form-actions.js +15 -1
  30. package/dist/components/form-actions.js.map +1 -1
  31. package/dist/components/iban-input.d.ts +2 -4
  32. package/dist/components/language-select.d.ts +2 -4
  33. package/dist/components/money-field.d.ts +2 -4
  34. package/dist/components/multi-entity-combobox.d.ts +5 -1
  35. package/dist/components/multi-entity-combobox.js +11 -3
  36. package/dist/components/multi-entity-combobox.js.map +1 -1
  37. package/dist/components/number-field.d.ts +2 -4
  38. package/dist/components/number-input.d.ts +2 -4
  39. package/dist/components/numpad-sheet.d.ts +2 -4
  40. package/dist/components/phone-input.d.ts +2 -4
  41. package/dist/components/series-chart.d.ts +2 -4
  42. package/dist/components/settings-fields.d.ts +2 -4
  43. package/dist/components/share-card.d.ts +1 -3
  44. package/dist/components/text-link.d.ts +1 -3
  45. package/dist/components/time-input.d.ts +2 -4
  46. package/dist/components/ui.d.ts +2 -4
  47. package/dist/components/ui.js +55 -17
  48. package/dist/components/ui.js.map +1 -1
  49. package/dist/feedback/feedback-attachment.d.ts +66 -1
  50. package/dist/feedback/feedback-attachment.js +32 -12
  51. package/dist/feedback/feedback-attachment.js.map +1 -1
  52. package/dist/feedback/feedback-dialog.d.ts +66 -1
  53. package/dist/feedback/feedback-inbox.d.ts +67 -253
  54. package/dist/feedback/feedback-thread.d.ts +1 -3
  55. package/dist/feedback/feedback-thread.js +8 -2
  56. package/dist/feedback/feedback-thread.js.map +1 -1
  57. package/dist/{feedback-BxeQVzwq.d.ts → feedback-DOwPu-Il.d.ts} +878 -18
  58. package/dist/feedback.d.ts +2 -4
  59. package/dist/hooks/use-file-drop.d.ts +2 -4
  60. package/dist/i18n/defaults.d.ts +2 -4
  61. package/dist/i18n/german.d.ts +2 -4
  62. package/dist/i18n/kit-labels.d.ts +1 -3
  63. package/dist/i18n/languages.d.ts +2 -4
  64. package/dist/i18n/locales/de-CH.d.ts +2 -4
  65. package/dist/i18n/locales/en.d.ts +2 -4
  66. package/dist/i18n/locales/es.d.ts +2 -4
  67. package/dist/i18n/locales/fr.d.ts +2 -4
  68. package/dist/i18n/locales/hu.d.ts +2 -4
  69. package/dist/i18n/locales/it.d.ts +2 -4
  70. package/dist/i18n/locales/zh.d.ts +2 -4
  71. package/dist/i18n/review.d.ts +2 -4
  72. package/dist/index.d.ts +2 -4
  73. package/dist/lib/strip-fade.d.ts +2 -2
  74. package/dist/lib/strip-fade.js.map +1 -1
  75. package/dist/rhf/fields.d.ts +59 -8
  76. package/dist/rhf/fields.js +61 -3
  77. package/dist/rhf/fields.js.map +1 -1
  78. package/dist/rhf/form.d.ts +2 -4
  79. package/dist/rhf.d.ts +3 -5
  80. package/dist/rhf.js.map +1 -1
  81. package/dist/shell/app-shell.d.ts +1 -3
  82. package/dist/shell/top-bar-brand.d.ts +2 -4
  83. package/dist/shell.d.ts +1 -3
  84. package/dist/wizard/stepper-nav.d.ts +2 -4
  85. package/dist/wizard.d.ts +2 -4
  86. package/package.json +1 -1
  87. package/src/components/amount-input.tsx +37 -20
  88. package/src/components/autocomplete.tsx +16 -5
  89. package/src/components/column-mapper.tsx +181 -79
  90. package/src/components/combobox.tsx +41 -3
  91. package/src/components/danger-confirm.tsx +193 -22
  92. package/src/components/entity-combobox.tsx +28 -5
  93. package/src/components/form-actions.tsx +20 -1
  94. package/src/components/multi-entity-combobox.tsx +16 -3
  95. package/src/components/ui.tsx +107 -0
  96. package/src/feedback/feedback-attachment.tsx +65 -12
  97. package/src/feedback/feedback-thread.tsx +66 -6
  98. package/src/lib/strip-fade.ts +2 -2
  99. package/src/rhf/fields.tsx +167 -6
  100. package/src/rhf.ts +1 -1
  101. package/dist/feedback-attachment-fGAzZPf0.d.ts +0 -469
@@ -1,9 +1,9 @@
1
- import { forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
1
+ import { Children, forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
2
2
  import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { Button, Input, Label, Spinner } from "./ui";
6
- import type { InputProps } from "./ui";
6
+ import type { ButtonVariant, InputProps } from "./ui";
7
7
  import { Checkbox } from "./checkbox";
8
8
  import { Tooltip } from "./tooltip";
9
9
  import { useCommitReason } from "./write-lock";
@@ -451,15 +451,99 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
451
451
  /** The warning above the fields. Defaults to `labels.prompt`. */
452
452
  prompt?: ReactNode;
453
453
  /** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
454
- * cost (loading demo data over your own). Colours the prompt and the confirm. */
454
+ * cost (loading demo data over your own). Colours the prompt, and picks the arm
455
+ * button's variant and — unless {@link DangerConfirmProps.confirmVariant} is
456
+ * given — the confirm's. */
455
457
  tone?: "danger" | "warning";
458
+ /**
459
+ * The confirm button's variant, apart from `tone` (0.24, keksdose). Default: what
460
+ * `tone` picks, as before — `"danger"` for `"danger"`, `"primary"` for `"warning"`.
461
+ *
462
+ * `tone` says how bad the outcome is; this says how loud the last press is, and the two
463
+ * need not agree. keksdose's `UserActionConfirm` (an admin resets somebody else's
464
+ * password) asks an AMBER question — what it guards against is mis-targeting, not data
465
+ * loss, so the danger colour stays its one `severe` consequence's — and ends in a RED
466
+ * Go, as its hand-built panel did before it moved onto this tile in 0.23; with only
467
+ * `tone`, the amber question made Go the neutral primary fill. `tone="warning"
468
+ * confirmVariant="danger"` is that panel again.
469
+ */
470
+ confirmVariant?: ButtonVariant;
471
+ /**
472
+ * Hold the armed confirm for a reason of the CALLER's own (0.24, keksdose) — the
473
+ * tile's counterpart of FormActions' `submitDisabledReason`. While it has content the
474
+ * confirm is held exactly as by the built-in guards: `aria-disabled` but focusable,
475
+ * the reason in the kit {@link Tooltip} and its description, a press — or Enter in a
476
+ * field — does nothing. Nothing else changes: the arm button stays usable, and no
477
+ * line is printed under the buttons.
478
+ *
479
+ * keksdose's `UserPlanEditor` ("move this account onto another plan") must not confirm
480
+ * the plan the account already holds — the request would only write FREE → FREE into
481
+ * the audit trail. With no guard of its own to give, it passed "Pick a different plan"
482
+ * as `lockedReason`, which is a LOCK: documented as "the action is not available",
483
+ * spelled out under the buttons as well as in the tooltip, and meant for a write the
484
+ * user cannot make land. A guard the user lifts by changing a field is this prop.
485
+ *
486
+ * Which reason the held confirm names: a write lock's first (`lockedReason`, `commit`
487
+ * — nothing in the tile lifts it), then this one, then the built-in guards in the
488
+ * order they are drawn (the tick, the phrase, the password). This one before the
489
+ * built-ins because it is usually about a field in
490
+ * {@link DangerConfirmProps.children}, which is drawn ABOVE them — the rule since 0.23
491
+ * is "the first open guard in the order the user meets it" — and because it is about
492
+ * WHAT is being confirmed (which plan), which has to be settled before acknowledging
493
+ * it means anything. It is also the order keksdose's plan editor had, its
494
+ * `lockedReason` winning over the tick.
495
+ */
496
+ confirmDisabledReason?: ReactNode;
497
+ /**
498
+ * Fields of the caller's own, drawn INSIDE the armed tile (0.24, keksdose): after the
499
+ * prompt and the consequences, before the tick, the typed phrase and the password —
500
+ * the order is "what this does, what to do it with, then prove you mean it". Nothing
501
+ * is drawn for them while the tile is disarmed, and nothing at all without them, so a
502
+ * tile without children is laid out exactly as before.
503
+ *
504
+ * keksdose's `UserPlanEditor` asks WHICH plan before it asks for the tick; with no
505
+ * place inside the tile for the picker, the `Select` sat above it, and the tile's
506
+ * prompt — the panel's title by rights — became the "FREE → PRO" line under the
507
+ * picker. With the picker here, the prompt is the title again.
508
+ *
509
+ * The fields are the caller's: their values are not wiped on disarm (an uncontrolled
510
+ * field starts over anyway — the slot unmounts), not passed to `onConfirm`, and a
511
+ * guard on them is {@link DangerConfirmProps.confirmDisabledReason}. Arming moves
512
+ * focus to their first form field (an input, a select, a combobox, a radio…), else to
513
+ * the tile's own first field as before; Tab then goes on through the tick, the phrase
514
+ * and the password to Cancel and the confirm. They sit inside the tile's `<form>`:
515
+ * Enter in a text field confirms once every guard allows, and a {@link Button} among
516
+ * them needs `type="button"` — without one it SUBMITS the form, as a native button
517
+ * does.
518
+ */
519
+ children?: ReactNode;
456
520
  /** Visible text of the arm button; defaults to `labels.arm`. */
457
521
  armLabel?: ReactNode;
458
522
  /** Visible text of the confirm button; defaults to `labels.confirm`. */
459
523
  confirmLabel?: ReactNode;
460
- /** The action is running: confirm shows a spinner and nothing can be pressed. For a
461
- * caller that tracks the mutation itself (a `useMutation`'s `isPending`); a
462
- * promise returned from `onConfirm` does the same on its own. */
524
+ /**
525
+ * The action is running: confirm shows a spinner and nothing can be pressed — Cancel
526
+ * included. For a caller that tracks the mutation itself (a `useMutation`'s
527
+ * `isPending`); a promise returned from `onConfirm` does the same on its own.
528
+ *
529
+ * Cancel stays disabled ON PURPOSE, unlike FormActions' (keksdose asked in 0.24 why the
530
+ * two differ). Once the request has left, nothing on the client can call it back —
531
+ * not even an `AbortSignal`, which only stops the waiting, not the server — so a usable
532
+ * Cancel could only collapse the tile while the action went on: "Cancel" pressed, and
533
+ * the account reset anyway. Every way of settling what comes back is wrong somewhere: a
534
+ * resolve after the Cancel did what the user called off; a reject finds the fields
535
+ * wiped (a collapsed tile holds no password), so the 0.23 "stay armed and retry" is
536
+ * gone; and re-arming while the first request runs is either a busy tile with empty
537
+ * fields or — if the busy state went with the Cancel — a second destructive request
538
+ * beside the first. FormActions' Cancel leaves an editor whose save is the user's own
539
+ * edit landing, and a form can be edited again; the tile's action is the one that
540
+ * cannot be undone, so it says nothing it cannot keep and waits — the wait is one
541
+ * request long.
542
+ *
543
+ * When the action settles still armed (it failed) and the press on the confirm had
544
+ * dropped the focus — a natively disabled button loses it — focus comes back to the
545
+ * confirm, so a keyboard user can retry or Shift+Tab to the field to correct (0.24).
546
+ */
463
547
  busy?: boolean;
464
548
  /** The arm button is disabled. */
465
549
  disabled?: boolean;
@@ -493,11 +577,44 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
493
577
  labels?: Partial<DangerConfirmLabels>;
494
578
  }
495
579
 
580
+ /** What counts as a FIELD among the caller's `children` — what arming focuses. Not
581
+ * any tabbable element: a "see the plans" link or a disclosure button before the
582
+ * picker is not where the user starts. `[tabindex="-1"]` is a roving radio group's
583
+ * unselected option; the selected one (`0`) is the group's stop. */
584
+ const SLOT_FIELD = [
585
+ 'input:not([type="hidden"])',
586
+ "select",
587
+ "textarea",
588
+ ...["combobox", "listbox", "radio", "checkbox", "switch", "slider", "spinbutton", "textbox"].map(
589
+ (role) => `[role="${role}"]`,
590
+ ),
591
+ ]
592
+ .map((selector) => `${selector}:not(:disabled):not([tabindex="-1"]):not([aria-hidden="true"])`)
593
+ .join(", ");
594
+
595
+ /** The first field among the caller's `children`, skipping a native radio that is not
596
+ * the checked one of its group (Tab lands on the checked one). */
597
+ function firstSlotField(slot: HTMLElement | null): HTMLElement | null {
598
+ if (!slot) return null;
599
+ for (const el of slot.querySelectorAll<HTMLElement>(SLOT_FIELD)) {
600
+ if (el.closest("[hidden], [inert]")) continue;
601
+ if (el instanceof HTMLInputElement && el.type === "radio" && !el.checked && el.name) {
602
+ const group = el.form?.elements.namedItem(el.name);
603
+ const checked =
604
+ group instanceof RadioNodeList && Array.from(group).some((r) => r instanceof HTMLInputElement && r.checked);
605
+ if (checked) continue;
606
+ }
607
+ return el;
608
+ }
609
+ return null;
610
+ }
611
+
496
612
  /**
497
613
  * An "arm → confirm" tile for destructive actions: one button, which expands into a
498
- * warning, an optional list of consequences, an optional "I understand" tick, an
499
- * optional type-to-confirm field, an optional password field and a confirm that is held
500
- * until every guard is satisfied.
614
+ * warning, an optional list of consequences, optional fields of the caller's own
615
+ * (`children`, 0.24), an optional "I understand" tick, an optional type-to-confirm
616
+ * field, an optional password field and a confirm that is held until every guard is
617
+ * satisfied — the built-in ones, and the caller's `confirmDisabledReason` (0.24).
501
618
  *
502
619
  * A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
503
620
  * `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
@@ -505,8 +622,10 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
505
622
  * “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
506
623
  * `labels.needs*`), the first open guard in the order they are drawn. FormActions'
507
624
  * `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
508
- * field, does nothing. A lock's reason wins over a guard's; while the action runs the
509
- * confirm is plainly disabled, its spinner saying why.
625
+ * field, does nothing. A lock's reason wins over a guard's, the caller's guard over the
626
+ * built-in ones; while the action runs the confirm is plainly disabled, its spinner
627
+ * saying why — and so is Cancel, which cannot call back a request that has left (see
628
+ * `busy`).
510
629
  *
511
630
  * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
512
631
  * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
@@ -527,6 +646,9 @@ export function DangerConfirm({
527
646
  phraseMatch = "trim",
528
647
  prompt,
529
648
  tone = "danger",
649
+ confirmVariant,
650
+ confirmDisabledReason,
651
+ children,
530
652
  armLabel,
531
653
  confirmLabel,
532
654
  busy: busyProp,
@@ -554,13 +676,18 @@ export function DangerConfirm({
554
676
  const consequencesId = useId();
555
677
  const reasonId = useId();
556
678
 
557
- // The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
679
+ // The buttons focus is moved to are found by id.
558
680
  const armId = useId();
559
681
  const cancelId = useId();
682
+ const confirmId = useId();
560
683
  const firstFieldRef = useRef<HTMLInputElement>(null);
684
+ // The caller's fields (`children`), searched for their first form field on arm.
685
+ const slotRef = useRef<HTMLDivElement>(null);
561
686
  // Focus moves only after a transition — never on mount, so a tile that renders armed
562
687
  // (controlled) does not take the page's focus merely by existing.
563
688
  const moveFocus = useRef(false);
689
+ // A confirm from this tile is under way — see the focus return after a failure below.
690
+ const confirmed = useRef(false);
564
691
 
565
692
  // A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
566
693
  // During render, like NumberField's draft, so no frame shows a collapsed tile that
@@ -583,15 +710,38 @@ export function DangerConfirm({
583
710
  moveFocus.current = false;
584
711
  if (!changed) return;
585
712
  if (armed) {
586
- if (byUser) (firstFieldRef.current ?? document.getElementById(cancelId))?.focus();
713
+ if (byUser) {
714
+ // The caller's fields come first on the page, so first in focus too.
715
+ (firstSlotField(slotRef.current) ?? firstFieldRef.current ?? document.getElementById(cancelId))?.focus();
716
+ }
587
717
  return;
588
718
  }
719
+ confirmed.current = false;
589
720
  // A controlled parent collapsing the tile from its own `onSuccess` did not go
590
721
  // through `setArmed`; if focus went down with the form, bring it back too.
591
722
  const lost = document.activeElement === null || document.activeElement === document.body;
592
723
  if (byUser || lost) document.getElementById(armId)?.focus();
593
724
  }, [armed, armId, cancelId]);
594
725
 
726
+ // The action settled and the tile is still armed — it failed, and the user retries.
727
+ // The confirm was natively disabled while it ran, and a focused button that turns
728
+ // disabled drops the focus to <body>: bring it back to the button that was pressed.
729
+ // Only after a confirm from THIS tile (`confirmed`), so a `busy` that comes and goes
730
+ // on its own never pulls the page's focus in. (A resolve disarms, and the effect
731
+ // above takes the focus to the arm button.)
732
+ const wasBusy = useRef(busy);
733
+ useEffect(() => {
734
+ const settled = wasBusy.current && !busy;
735
+ wasBusy.current = busy;
736
+ if (!settled) return;
737
+ const ours = confirmed.current;
738
+ confirmed.current = false;
739
+ if (!ours || !armed) return;
740
+ if (document.activeElement === null || document.activeElement === document.body) {
741
+ document.getElementById(confirmId)?.focus();
742
+ }
743
+ }, [busy, armed, confirmId]);
744
+
595
745
  const setArmed = (next: boolean) => {
596
746
  moveFocus.current = true;
597
747
  if (armedProp === undefined) setArmedState(next);
@@ -607,7 +757,10 @@ export function DangerConfirm({
607
757
  const passwordOk = !requirePassword || password !== "";
608
758
  const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
609
759
  const acknowledgeOk = !asksAcknowledge || acknowledged;
610
- const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
760
+ const callerHeld = hasContent(confirmDisabledReason);
761
+ // `{cond && <Select />}` and `[null, false]` are no fields: no wrapper, no gap.
762
+ const hasFields = Children.toArray(children).some(hasContent);
763
+ const canConfirm = passwordOk && phraseOk && acknowledgeOk && !callerHeld && !busy;
611
764
  // The first guard still open, in the order the fields are drawn — the one the user
612
765
  // meets next, so the sentence points at it.
613
766
  const guardReason: string | undefined = !acknowledgeOk
@@ -620,18 +773,27 @@ export function DangerConfirm({
620
773
  ? labels.needsPassword
621
774
  : undefined;
622
775
  // The lock first (no guard can lift it); none while busy, when the guards were met and
623
- // the spinner is the state.
624
- const confirmReason: ReactNode = locked ? lockedReason : busy ? undefined : guardReason;
776
+ // the spinner is the state; then the caller's guard — about the fields drawn above the
777
+ // built-in ones, and about WHAT is confirmed — then the first built-in guard still open.
778
+ const confirmReason: ReactNode = locked
779
+ ? lockedReason
780
+ : busy
781
+ ? undefined
782
+ : callerHeld
783
+ ? confirmDisabledReason
784
+ : guardReason;
625
785
 
626
786
  const submit = (e: FormEvent) => {
627
787
  e.preventDefault();
628
- // `locked` too: Enter in a field submits the form without the button's say.
788
+ // `locked` too: Enter in a field submits the form without the button's say (and
789
+ // `canConfirm` holds the caller's guard the same way).
629
790
  if (!canConfirm || locked) return;
630
791
  const values: DangerConfirmValues = {
631
792
  ...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
632
793
  ...(requirePassword && { password }),
633
794
  ...(asksAcknowledge && { acknowledged: true as const }),
634
795
  };
796
+ confirmed.current = true;
635
797
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
636
798
  // shows why, the user retries.
637
799
  run(onConfirm(values.password, values), () => setArmed(false));
@@ -712,6 +874,13 @@ export function DangerConfirm({
712
874
  ))}
713
875
  </ul>
714
876
  )}
877
+ {/* The caller's fields: after what the action does, before the proof that it is
878
+ meant. No wrapper without them — a 0.23 tile's markup is unchanged. */}
879
+ {hasFields && (
880
+ <div ref={slotRef} className="space-y-2">
881
+ {children}
882
+ </div>
883
+ )}
715
884
  {asksAcknowledge && (
716
885
  <Checkbox
717
886
  ref={firstField === "acknowledge" ? firstFieldRef : undefined}
@@ -751,13 +920,15 @@ export function DangerConfirm({
751
920
  {labels.cancel}
752
921
  </Button>
753
922
  <Button
923
+ id={confirmId}
754
924
  type="submit"
755
- variant={tone === "warning" ? "primary" : "danger"}
925
+ variant={confirmVariant ?? (tone === "warning" ? "primary" : "danger")}
756
926
  disabled={!canConfirm}
757
- // Held by a guard, or armed and then locked (or rendered armed under a lock):
758
- // the confirm says why the way Button does — focusable, `aria-disabled`, the
759
- // reason in its tooltip and description, a press swallowed (the submit with
760
- // it) — and the fields stay as typed. Only busy is the native `disabled`.
927
+ // Held by a guard (the caller's or a built-in one), or armed and then locked
928
+ // (or rendered armed under a lock): the confirm says why the way Button
929
+ // does — focusable, `aria-disabled`, the reason in its tooltip and
930
+ // description, a press swallowed (the submit with it) — and the fields stay
931
+ // as typed. Only busy is the native `disabled`.
761
932
  disabledReason={confirmReason}
762
933
  aria-busy={busy || undefined}
763
934
  >
@@ -1,5 +1,5 @@
1
- import { useId } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { useCallback, useId } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import { X } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
@@ -22,7 +22,7 @@ import {
22
22
  useFieldHint,
23
23
  useLockReason,
24
24
  } from "./field-parts";
25
- import { mergeDescribedBy } from "./choice-parts";
25
+ import { assignRef, mergeDescribedBy } from "./choice-parts";
26
26
  import { useCommitReason } from "./write-lock";
27
27
 
28
28
  export type { ComboClearValue, ComboOption } from "./combobox-core";
@@ -69,6 +69,10 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
69
69
  * actions, and on a full-screen sheet the close button is the only way out —
70
70
  * so it is the one control here that MUST be in the reader's language. */
71
71
  closeLabel?: string;
72
+ /** The phone sheet's heading (and, as a string, its accessible name) for a picker
73
+ * whose label is drawn by someone else — a form's `FormLabel`; `RhfCombobox`
74
+ * hands its label over here (0.24). Default: `label`, then `placeholder`. */
75
+ sheetTitle?: ReactNode;
72
76
  disabled?: boolean;
73
77
  /**
74
78
  * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a picker
@@ -125,6 +129,14 @@ export interface EntityComboboxProps<V extends string | number, C extends ComboC
125
129
  debounceMs?: number;
126
130
  /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
127
131
  loadErrorLabel?: string;
132
+ /**
133
+ * The TRIGGER — the focusable `<button>` a reader meets — as a React 19 ref prop, as
134
+ * on {@link CountrySelect}, which is built on the same core (0.24, kastlan: react-hook-
135
+ * form's focus-on-error calls `focus()` on whatever `field.ref` is handed, and a
136
+ * picker with no ref gave it nothing to focus). Not the wrapper: focus belongs on the
137
+ * trigger, which is also where the panel hands it back after a pick.
138
+ */
139
+ ref?: Ref<HTMLButtonElement>;
128
140
  }
129
141
 
130
142
  /**
@@ -151,6 +163,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
151
163
  clearable,
152
164
  clearLabel,
153
165
  closeLabel,
166
+ sheetTitle,
154
167
  disabled,
155
168
  disabledReason,
156
169
  commit,
@@ -166,6 +179,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
166
179
  minChars,
167
180
  debounceMs,
168
181
  loadErrorLabel,
182
+ ref,
169
183
  "aria-label": ariaLabel,
170
184
  // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
171
185
  // the wrapper — see MultiEntityCombobox: `Field`'s render-prop spreads `{ id,
@@ -201,6 +215,15 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
201
215
  });
202
216
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
203
217
  const { open, results, resolve, setOpen, rememberOption, query, triggerRef } = core;
218
+ // The core anchors the panel to the trigger and hands focus back to it; the caller's
219
+ // `ref` wants the same element. One callback feeds both — CountrySelect's.
220
+ const setTrigger = useCallback(
221
+ (el: HTMLButtonElement | null) => {
222
+ triggerRef.current = el;
223
+ assignRef(ref, el);
224
+ },
225
+ [ref, triggerRef],
226
+ );
204
227
  // The lock: this picker's own reason, or the provider's under `commit`.
205
228
  const lock = useLockReason(commit, disabledReason);
206
229
  const inert = lock.locked || Boolean(disabled);
@@ -260,7 +283,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
260
283
  <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
261
284
  {withLock(
262
285
  <button
263
- ref={triggerRef}
286
+ ref={setTrigger}
264
287
  id={id}
265
288
  type="button"
266
289
  // A combobox, not a button. The distinction is not pedantry: this control
@@ -369,7 +392,7 @@ export function EntityCombobox<V extends string | number, C extends ComboClearVa
369
392
  listboxId={listboxId}
370
393
  // On a phone the panel becomes a full-screen sheet, which needs the field's
371
394
  // own label to say what it is asking for (live #200).
372
- sheetTitle={label ?? placeholder}
395
+ sheetTitle={sheetTitle ?? label ?? placeholder}
373
396
  searchPlaceholder={labels.search}
374
397
  emptyLabel={labels.noResults}
375
398
  closeLabel={closeLabel}
@@ -378,6 +378,22 @@ export function FormActions({
378
378
  },
379
379
  [callerSaveRef],
380
380
  );
381
+ // The save settled and the form is still here — it failed, and the user retries. The
382
+ // button was natively disabled while it ran, and a focused button that turns disabled
383
+ // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press
384
+ // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and
385
+ // goes on its own never pulls the page's focus in.
386
+ const pressed = useRef(false);
387
+ const wasPending = useRef(pending);
388
+ useEffect(() => {
389
+ const settled = wasPending.current && !pending;
390
+ wasPending.current = pending;
391
+ if (!settled) return;
392
+ const ours = pressed.current;
393
+ pressed.current = false;
394
+ if (!ours) return;
395
+ if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();
396
+ }, [pending]);
381
397
  // Read by the shortcut's listener at the moment of the key press, so the listener
382
398
  // itself is attached once and never sees a stale `pending`.
383
399
  const canSave = !pending && !submitDisabled && !(commit && lock.locked);
@@ -474,7 +490,10 @@ export function FormActions({
474
490
  }
475
491
  type={onSubmit ? "button" : "submit"}
476
492
  form={form}
477
- onClick={onSubmit}
493
+ onClick={() => {
494
+ pressed.current = true;
495
+ onSubmit?.();
496
+ }}
478
497
  variant={submitVariant}
479
498
  size={saveSize}
480
499
  disabled={pending || submitDisabled}
@@ -1,4 +1,4 @@
1
- import { useId, useMemo, type ComponentPropsWithoutRef, type ReactNode } from "react";
1
+ import { useCallback, useId, useMemo, type ComponentPropsWithoutRef, type ReactNode, type Ref } from "react";
2
2
  import { X } from "lucide-react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui";
@@ -25,7 +25,7 @@ import {
25
25
  useFieldHint,
26
26
  useLockReason,
27
27
  } from "./field-parts";
28
- import { mergeDescribedBy } from "./choice-parts";
28
+ import { assignRef, mergeDescribedBy } from "./choice-parts";
29
29
  import { useCommitReason } from "./write-lock";
30
30
 
31
31
  /** `onChange` is the kit's — "a selection was made", carrying values — rather
@@ -100,6 +100,10 @@ export interface MultiEntityComboboxProps<V extends string | number>
100
100
  debounceMs?: number;
101
101
  /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
102
102
  loadErrorLabel?: string;
103
+ /** The TRIGGER — the focusable `<button>` — as a React 19 ref prop, as on
104
+ * {@link EntityCombobox} and {@link CountrySelect} (0.24, kastlan: react-hook-form's
105
+ * focus-on-error focuses whatever `field.ref` is handed). Not the wrapper. */
106
+ ref?: Ref<HTMLButtonElement>;
103
107
  }
104
108
 
105
109
  /**
@@ -137,6 +141,7 @@ export function MultiEntityCombobox<V extends string | number>(props: MultiEntit
137
141
  minChars,
138
142
  debounceMs,
139
143
  loadErrorLabel,
144
+ ref,
140
145
  "aria-label": ariaLabel,
141
146
  // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
142
147
  // the wrapper: `Field`'s render-prop spreads `{ id, aria-describedby, aria-invalid,
@@ -173,6 +178,14 @@ export function MultiEntityCombobox<V extends string | number>(props: MultiEntit
173
178
  });
174
179
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
175
180
  const { open, results, resolve, setOpen, query, triggerRef } = core;
181
+ // The core's trigger ref and the caller's, fed by one callback — see EntityCombobox.
182
+ const setTrigger = useCallback(
183
+ (el: HTMLButtonElement | null) => {
184
+ triggerRef.current = el;
185
+ assignRef(ref, el);
186
+ },
187
+ [ref, triggerRef],
188
+ );
176
189
  // The lock — see EntityCombobox: the trigger stays reachable and says why.
177
190
  const lock = useLockReason(commit, disabledReason);
178
191
  const inert = lock.locked || Boolean(disabled);
@@ -241,7 +254,7 @@ export function MultiEntityCombobox<V extends string | number>(props: MultiEntit
241
254
  <EndHintRow {...endHintRowProps("hint" in props, label !== undefined, hintParts.labelHint)}>
242
255
  {withLock(
243
256
  <button
244
- ref={triggerRef}
257
+ ref={setTrigger}
245
258
  id={id}
246
259
  type="button"
247
260
  // A combobox, not a button. The distinction is not pedantry: this control
@@ -2501,6 +2501,109 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2501
2501
  });
2502
2502
  Select.displayName = "Select";
2503
2503
 
2504
+ /**
2505
+ * The backdrop of a labelled {@link Textarea}'s label strip (Kurvenschmiede, 0.24).
2506
+ *
2507
+ * The report: paste a long table into ColumnMapper, the field scrolls, and the floated
2508
+ * "Paste a table" / "Tabelle einfügen" sits ON the first visible line of text — on
2509
+ * desktop and on a phone. Not a ColumnMapper bug: every labelled textarea that scrolls
2510
+ * did it. An `<input>` has one line that never moves, so the strip FIELD_FLOATING_PAD
2511
+ * leaves for the label is always empty there; a textarea's top padding is part of its
2512
+ * SCROLLING area, and once the content scrolls the lines travel up through the strip
2513
+ * and under the label.
2514
+ *
2515
+ * The fix makes that padding stay put: this layer is a later sibling of the textarea
2516
+ * (so `peer-*` reaches it, and as a positioned box it paints above the textarea's
2517
+ * text), the label paints above it in turn, and it covers exactly the top padding —
2518
+ * `top-px` / `inset-x-px` inside the 1px border, `h-4` = `pt-4`. At rest it covers
2519
+ * padding and nothing else, so no pixel of an unscrolled line is ever hidden; scrolled
2520
+ * text slides out of sight under it instead of through the label.
2521
+ *
2522
+ * Why a layer and not "start the scrolling area below the strip" (a wrapper that draws
2523
+ * the border and background, the textarea transparent inside it): the box would stop
2524
+ * being the textarea. Its focus outline, its brand border on focus, the invalid ring,
2525
+ * the disabled and read-only greys and every caller's `[&_textarea]:…` would all have
2526
+ * to be rebuilt on the wrapper with `focus-within:` / `has-[…]:`, and a caller's
2527
+ * `style={{ height }}` would size the scroller rather than the field. The layer leaves
2528
+ * all of that exactly where it was.
2529
+ *
2530
+ * What it has to match, it matches from the same tokens the field uses: the surface is
2531
+ * FIELD_BASE's `--bg-surface`, and `--bg-surface-2` for `disabled` and `[readonly]` —
2532
+ * on the same ATTRIBUTE selector FIELD_BASE uses, for the reason given there. Focus and
2533
+ * `invalid` change only the border and the ring, which are outside the layer, so they
2534
+ * need nothing. Both themes come with the variables. The inner corners are the field's
2535
+ * radius (`rounded-md`, 6px) less the 1px border, so the rounded border is never cut
2536
+ * into — a literal 5px rather than `var(--radius-md)`, which Tailwind emits only where
2537
+ * a utility uses it and the kit's token check refuses (token-vars-declared).
2538
+ *
2539
+ * Hidden (`display: none`) until the label floats: an empty, unfocused field has
2540
+ * nothing that could scroll, and it renders exactly as it did before 0.24. Pointer
2541
+ * events pass through, so a click on the strip still lands in the field.
2542
+ *
2543
+ * The one thing CSS cannot know is a classic scrollbar's width, and a strip across the
2544
+ * whole inner width would hide the scrollbar's top arrow — see {@link TextareaLabelStrip}.
2545
+ */
2546
+ const TEXTAREA_LABEL_STRIP = cn(
2547
+ "pointer-events-none absolute inset-x-px top-px hidden h-4 rounded-t-[5px] bg-[var(--bg-surface)]",
2548
+ "peer-focus:block peer-[:not(:placeholder-shown)]:block",
2549
+ "peer-disabled:bg-[var(--bg-surface-2)] peer-[[readonly]]:bg-[var(--bg-surface-2)]",
2550
+ );
2551
+
2552
+ /**
2553
+ * {@link TEXTAREA_LABEL_STRIP}, stopped short of a classic scrollbar.
2554
+ *
2555
+ * A Windows / Linux desktop draws a textarea's vertical scrollbar INSIDE the border, in
2556
+ * a gutter it takes from the content box (15px in Chromium); a strip across the full
2557
+ * inner width would sit on top of the scrollbar's up arrow. So the gutter is measured —
2558
+ * border box less client width less the borders — and the strip ends where it begins.
2559
+ * On whichever side it is: `clientLeft` includes a gutter on the LEFT, which is where
2560
+ * Chromium and Firefox put it in a right-to-left field. The side is applied as an
2561
+ * inline-start / inline-end inset, so when `dir` flips afterwards and the scrollbar
2562
+ * follows it to the other edge, the strip does too without a re-measure. That corner
2563
+ * also loses its rounding, since it no longer meets the border.
2564
+ *
2565
+ * Overlay scrollbars (macOS, phones) take no gutter: the measurement is zero and the
2566
+ * strip spans the field. A ResizeObserver on the textarea re-measures when the
2567
+ * scrollbar comes or goes (it takes the content box with it) and when the field is
2568
+ * resized by hand. Not laid out — hidden in a closed panel, or jsdom — the CSS insets
2569
+ * stand until it is.
2570
+ */
2571
+ function TextareaLabelStrip() {
2572
+ const ref = useRef<HTMLSpanElement>(null);
2573
+ useLayoutEffect(() => {
2574
+ const strip = ref.current;
2575
+ const field = strip?.previousElementSibling;
2576
+ if (!strip || !(field instanceof HTMLTextAreaElement)) return;
2577
+ const fit = () => {
2578
+ if (field.offsetWidth === 0) return;
2579
+ const style = getComputedStyle(field);
2580
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
2581
+ const borderRight = parseFloat(style.borderRightWidth) || 0;
2582
+ const gutter = field.offsetWidth - field.clientWidth - borderLeft - borderRight;
2583
+ // offsetWidth and clientWidth are whole pixels, so a field with no scrollbar can
2584
+ // read ±1 here; no scrollbar is that thin.
2585
+ const bar = gutter > 1.5;
2586
+ const onLeft = field.clientLeft - borderLeft > 1.5;
2587
+ // Kept as a LOGICAL side: a `dir` flipped later (a language switch) moves the
2588
+ // scrollbar across without resizing anything, so nothing would re-measure —
2589
+ // but it stays at the inline end, and so does an inline-end inset.
2590
+ const rtl = style.direction === "rtl";
2591
+ const atEnd = onLeft === rtl;
2592
+ const border = (end: boolean) => (end === rtl ? borderLeft : borderRight);
2593
+ strip.style.insetInlineEnd = bar && atEnd ? `${border(true) + gutter}px` : "";
2594
+ strip.style.insetInlineStart = bar && !atEnd ? `${border(false) + gutter}px` : "";
2595
+ strip.style.borderStartEndRadius = bar && atEnd ? "0" : "";
2596
+ strip.style.borderStartStartRadius = bar && !atEnd ? "0" : "";
2597
+ };
2598
+ fit();
2599
+ if (typeof ResizeObserver === "undefined") return;
2600
+ const observer = new ResizeObserver(fit);
2601
+ observer.observe(field);
2602
+ return () => observer.disconnect();
2603
+ }, []);
2604
+ return <span ref={ref} aria-hidden data-slot="label-strip" className={TEXTAREA_LABEL_STRIP} />;
2605
+ }
2606
+
2504
2607
  export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
2505
2608
  label?: ReactNode;
2506
2609
  /** See {@link Input}'s `invalid`. */
@@ -2591,6 +2694,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
2591
2694
  isInvalid && FIELD_INVALID,
2592
2695
  )}
2593
2696
  />
2697
+ {/* Between the textarea and the label, on purpose: after the textarea so it
2698
+ is its `peer` and paints over its text, before the label so the label
2699
+ paints over it. See TEXTAREA_LABEL_STRIP. */}
2700
+ <TextareaLabelStrip />
2594
2701
  </FloatingField>
2595
2702
  </FieldGroup>
2596
2703
  );