@eifi1/ui-kit 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/chart.d.ts +2 -4
- package/dist/components/amount-input.d.ts +16 -9
- package/dist/components/amount-input.js +13 -11
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +5 -0
- package/dist/components/autocomplete.js +39 -37
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +2 -4
- package/dist/components/calculator.d.ts +2 -4
- package/dist/components/column-mapper.d.ts +62 -9
- package/dist/components/column-mapper.js +85 -49
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox.d.ts +33 -1
- package/dist/components/combobox.js +8 -2
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +2 -4
- package/dist/components/copy-button.d.ts +2 -4
- package/dist/components/danger-confirm.d.ts +2 -4
- package/dist/components/danger-confirm.js +52 -5
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +13 -1
- package/dist/components/entity-combobox.js +13 -4
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -4
- package/dist/components/file-button.d.ts +2 -4
- package/dist/components/file-dropzone.d.ts +2 -4
- package/dist/components/form-actions.d.ts +1 -3
- package/dist/components/form-actions.js +15 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +2 -4
- package/dist/components/language-select.d.ts +2 -4
- package/dist/components/money-field.d.ts +2 -4
- package/dist/components/multi-entity-combobox.d.ts +5 -1
- package/dist/components/multi-entity-combobox.js +11 -3
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +2 -4
- package/dist/components/number-input.d.ts +2 -4
- package/dist/components/numpad-sheet.d.ts +2 -4
- package/dist/components/phone-input.d.ts +2 -4
- package/dist/components/series-chart.d.ts +2 -4
- package/dist/components/settings-fields.d.ts +2 -4
- package/dist/components/share-card.d.ts +1 -3
- package/dist/components/text-link.d.ts +1 -3
- package/dist/components/time-input.d.ts +2 -4
- package/dist/components/ui.d.ts +2 -4
- package/dist/components/ui.js +55 -17
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +66 -1
- package/dist/feedback/feedback-attachment.js +32 -12
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +66 -1
- package/dist/feedback/feedback-inbox.d.ts +67 -253
- package/dist/feedback/feedback-thread.d.ts +1 -3
- package/dist/feedback/feedback-thread.js +8 -2
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{feedback-BxeQVzwq.d.ts → feedback-DOwPu-Il.d.ts} +878 -18
- package/dist/feedback.d.ts +2 -4
- package/dist/hooks/use-file-drop.d.ts +2 -4
- package/dist/i18n/defaults.d.ts +2 -4
- package/dist/i18n/german.d.ts +2 -4
- package/dist/i18n/kit-labels.d.ts +1 -3
- package/dist/i18n/languages.d.ts +2 -4
- package/dist/i18n/locales/de-CH.d.ts +2 -4
- package/dist/i18n/locales/en.d.ts +2 -4
- package/dist/i18n/locales/es.d.ts +2 -4
- package/dist/i18n/locales/fr.d.ts +2 -4
- package/dist/i18n/locales/hu.d.ts +2 -4
- package/dist/i18n/locales/it.d.ts +2 -4
- package/dist/i18n/locales/zh.d.ts +2 -4
- package/dist/i18n/review.d.ts +2 -4
- package/dist/index.d.ts +2 -4
- package/dist/lib/strip-fade.d.ts +2 -2
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/rhf/fields.d.ts +59 -8
- package/dist/rhf/fields.js +61 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +2 -4
- package/dist/rhf.d.ts +3 -5
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +1 -3
- package/dist/shell/top-bar-brand.d.ts +2 -4
- package/dist/shell.d.ts +1 -3
- package/dist/wizard/stepper-nav.d.ts +2 -4
- package/dist/wizard.d.ts +2 -4
- package/package.json +2 -2
- package/src/components/amount-input.tsx +37 -20
- package/src/components/autocomplete.tsx +16 -5
- package/src/components/column-mapper.tsx +181 -79
- package/src/components/combobox.tsx +41 -3
- package/src/components/danger-confirm.tsx +193 -22
- package/src/components/entity-combobox.tsx +28 -5
- package/src/components/form-actions.tsx +20 -1
- package/src/components/multi-entity-combobox.tsx +16 -3
- package/src/components/ui.tsx +107 -0
- package/src/feedback/feedback-attachment.tsx +65 -12
- package/src/feedback/feedback-thread.tsx +66 -6
- package/src/lib/strip-fade.ts +2 -2
- package/src/rhf/fields.tsx +167 -6
- package/src/rhf.ts +1 -1
- 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
|
|
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
|
-
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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,
|
|
499
|
-
*
|
|
500
|
-
* until every guard is
|
|
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
|
|
509
|
-
* confirm is plainly disabled, its spinner
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
758
|
-
// the confirm says why the way Button
|
|
759
|
-
// reason in its tooltip and
|
|
760
|
-
//
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
package/src/components/ui.tsx
CHANGED
|
@@ -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
|
);
|