@eifi1/ui-kit 0.22.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.
- package/README.md +42 -25
- package/dist/chart.d.ts +8 -7
- package/dist/components/amount-input.d.ts +34 -7
- package/dist/components/amount-input.js +14 -10
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +26 -0
- package/dist/components/autocomplete.js +164 -137
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +8 -7
- package/dist/components/calculator.d.ts +8 -7
- package/dist/components/column-mapper.d.ts +235 -0
- package/dist/components/column-mapper.js +411 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +113 -3
- package/dist/components/combobox.js +456 -313
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +8 -7
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -4
- package/dist/components/country-select.d.ts +50 -7
- package/dist/components/country-select.js +65 -9
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +8 -7
- package/dist/components/danger-confirm.js +64 -7
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/date-picker.d.ts +41 -0
- package/dist/components/date-picker.js +269 -8
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +48 -2
- package/dist/components/entity-combobox.js +159 -114
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +8 -7
- package/dist/components/field-parts.d.ts +116 -6
- package/dist/components/field-parts.js +84 -0
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +8 -7
- package/dist/components/file-dropzone.d.ts +8 -7
- package/dist/components/form-actions.d.ts +8 -7
- package/dist/components/form-actions.js +23 -3
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +8 -7
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +12 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/language-select.d.ts +8 -7
- package/dist/components/money-field.d.ts +11 -7
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +2 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +31 -2
- package/dist/components/multi-entity-combobox.js +152 -103
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +8 -7
- package/dist/components/number-input.d.ts +8 -7
- package/dist/components/numpad-sheet.d.ts +8 -7
- package/dist/components/phone-input.d.ts +8 -7
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +8 -7
- package/dist/components/settings-fields.d.ts +8 -7
- package/dist/components/share-card.d.ts +8 -7
- package/dist/components/swatch-picker.d.ts +17 -1
- package/dist/components/swatch-picker.js +10 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.d.ts +8 -7
- package/dist/components/tile-radio.d.ts +20 -0
- package/dist/components/tile-radio.js +25 -8
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +8 -7
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +5 -4
- package/dist/components/ui.js +60 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +66 -1
- package/dist/feedback/feedback-attachment.js +185 -65
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +66 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +67 -252
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +66 -287
- package/dist/feedback/feedback-thread.js +16 -3
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
- package/dist/feedback.d.ts +65 -3
- package/dist/hooks/use-file-drop.d.ts +8 -7
- package/dist/i18n/defaults.d.ts +8 -7
- package/dist/i18n/defaults.js +3 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +8 -7
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +8 -7
- package/dist/i18n/locales/de-CH.d.ts +8 -7
- package/dist/i18n/locales/en.d.ts +8 -7
- package/dist/i18n/locales/en.js +7 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +8 -7
- package/dist/i18n/locales/es.js +37 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +8 -7
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +8 -7
- package/dist/i18n/locales/hu.js +38 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +8 -7
- package/dist/i18n/locales/it.js +37 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +8 -7
- package/dist/i18n/locales/zh.js +34 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +8 -7
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/strip-fade.d.ts +2 -2
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +142 -13
- package/dist/rhf/fields.js +185 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +8 -7
- package/dist/rhf.d.ts +9 -8
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +8 -7
- package/dist/shell/top-bar-brand.d.ts +8 -7
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +8 -7
- package/dist/table-text.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +38 -9
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +8 -7
- package/package.json +1 -1
- package/src/components/amount-input.tsx +50 -14
- package/src/components/autocomplete.tsx +188 -116
- package/src/components/column-mapper.tsx +768 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +655 -335
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +135 -22
- package/src/components/danger-confirm.tsx +274 -24
- package/src/components/date-picker.tsx +423 -10
- package/src/components/entity-combobox.tsx +243 -129
- package/src/components/field-parts.tsx +224 -5
- package/src/components/field-strip.tsx +149 -0
- package/src/components/form-actions.tsx +52 -5
- package/src/components/icon-picker.tsx +23 -4
- package/src/components/money-field.tsx +3 -0
- package/src/components/month-picker.tsx +2 -1
- package/src/components/multi-entity-combobox.tsx +221 -121
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/swatch-picker.tsx +29 -5
- package/src/components/tile-radio.tsx +74 -13
- package/src/components/toggle-group.tsx +2 -2
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +114 -33
- package/src/feedback/feedback-attachment.tsx +372 -72
- package/src/feedback/feedback-dialog.tsx +7 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +112 -9
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +42 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/en.ts +27 -5
- package/src/i18n/locales/es.ts +41 -0
- package/src/i18n/locales/fr.ts +42 -0
- package/src/i18n/locales/hu.ts +37 -0
- package/src/i18n/locales/it.ts +41 -0
- package/src/i18n/locales/zh.ts +32 -0
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +18 -0
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/strip-fade.ts +2 -2
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +421 -6
- package/src/rhf.ts +2 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/wizard/stepper-nav.tsx +35 -1
- package/dist/components/field-anatomy.d.ts +0 -95
- package/dist/components/field-anatomy.js +0 -84
- package/dist/components/field-anatomy.js.map +0 -1
- package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
- package/src/components/field-anatomy.tsx +0 -190
|
@@ -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";
|
|
@@ -324,6 +324,18 @@ export interface DangerConfirmLabels {
|
|
|
324
324
|
/** 0.22: the checkbox `requireAcknowledge={true}` shows — "I have read what this does
|
|
325
325
|
* and want to continue" (keksdose's `admin.users.confirm_ack`). */
|
|
326
326
|
acknowledge: string;
|
|
327
|
+
/**
|
|
328
|
+
* 0.23: why the armed confirm is held while the typed phrase does not match yet — in
|
|
329
|
+
* the confirm's tooltip and its description (keksdose G4a). A FUNCTION of the phrase
|
|
330
|
+
* by default ("Type “DELETE” to confirm"), or a finished STRING, as `phrase`.
|
|
331
|
+
*/
|
|
332
|
+
needsPhrase: string | ((phrase: string) => string);
|
|
333
|
+
/** 0.23: why the armed confirm is held while the `requireAcknowledge` box is unticked
|
|
334
|
+
* — "Tick the box to confirm". */
|
|
335
|
+
needsAcknowledge: string;
|
|
336
|
+
/** 0.23: why the armed confirm is held while the `requirePassword` field is empty —
|
|
337
|
+
* "Enter your password to confirm". */
|
|
338
|
+
needsPassword: string;
|
|
327
339
|
}
|
|
328
340
|
|
|
329
341
|
/** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
|
|
@@ -336,8 +348,33 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
|
|
|
336
348
|
password: "Password",
|
|
337
349
|
phrase: (phrase: string) => `Type “${phrase}” to confirm`,
|
|
338
350
|
acknowledge: "I have read what this does and want to continue.",
|
|
351
|
+
needsPhrase: (phrase: string) => `Type “${phrase}” to confirm`,
|
|
352
|
+
needsAcknowledge: "Tick the box to confirm",
|
|
353
|
+
needsPassword: "Enter your password to confirm",
|
|
339
354
|
} satisfies DangerConfirmLabels;
|
|
340
355
|
|
|
356
|
+
/**
|
|
357
|
+
* What the guards were answered with — the second argument of
|
|
358
|
+
* {@link DangerConfirmProps.onConfirm} (0.23, keksdose G4b). A key is there only when its
|
|
359
|
+
* guard was asked for, so the object can go into a request body as it is.
|
|
360
|
+
*/
|
|
361
|
+
export interface DangerConfirmValues {
|
|
362
|
+
/**
|
|
363
|
+
* The text typed into the `phrase` field — as the match rule compared it: trimmed
|
|
364
|
+
* under `phraseMatch` `"trim"` and `"caseless"` (the spaces a phone keyboard adds are
|
|
365
|
+
* not part of it), exactly as typed under `"exact"`. The case is the user's, even
|
|
366
|
+
* under `"caseless"`: a server that re-checks applies its own rule to it. Absent
|
|
367
|
+
* without a `phrase`.
|
|
368
|
+
*/
|
|
369
|
+
typed?: string;
|
|
370
|
+
/** The entered password — the same value as the first argument. Absent without
|
|
371
|
+
* `requirePassword`. */
|
|
372
|
+
password?: string;
|
|
373
|
+
/** `true` when the `requireAcknowledge` box was shown — it was ticked, since the
|
|
374
|
+
* confirm is held until it is. Absent without one. */
|
|
375
|
+
acknowledged?: true;
|
|
376
|
+
}
|
|
377
|
+
|
|
341
378
|
/**
|
|
342
379
|
* One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
|
|
343
380
|
* string, or this, for a key of its own and the `severe` mark.
|
|
@@ -360,12 +397,25 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
360
397
|
* (fields wiped) when it resolves, still armed when it rejects — so the user can
|
|
361
398
|
* correct a wrong password and retry. The rejection is not swallowed for you to
|
|
362
399
|
* miss: handle it (and show why) in the caller, as with any mutation.
|
|
400
|
+
*
|
|
401
|
+
* 0.23: the second argument is what every guard was answered with — the typed phrase,
|
|
402
|
+
* the password, the tick ({@link DangerConfirmValues}). keksdose G4b: its admin
|
|
403
|
+
* actions send the address the admin TYPED, and the server re-checks it against the
|
|
404
|
+
* account (`assert_confirmed`), so a client that put the wrong row's id in the request
|
|
405
|
+
* is refused rather than obeyed; the tile kept the text to itself.
|
|
406
|
+
*
|
|
407
|
+
* onConfirm={(_password, { typed }) =>
|
|
408
|
+
* reset.mutateAsync({ id, confirm: { acknowledged: true, confirm_email: typed ?? null } })}
|
|
409
|
+
*
|
|
410
|
+
* A second argument rather than one object in place of the password: every
|
|
411
|
+
* `(password) => …` and `() => …` handler, and a `mutation.mutate` passed as it is,
|
|
412
|
+
* keeps working unchanged, and a handler that needs one guard's answer names it.
|
|
363
413
|
*/
|
|
364
|
-
onConfirm: (password
|
|
365
|
-
/** Show a password field; confirm
|
|
414
|
+
onConfirm: (password: string | undefined, values: DangerConfirmValues) => void | Promise<unknown>;
|
|
415
|
+
/** Show a password field; confirm is held until it is filled. */
|
|
366
416
|
requirePassword?: boolean;
|
|
367
417
|
/**
|
|
368
|
-
* Show an "I understand" checkbox; confirm
|
|
418
|
+
* Show an "I understand" checkbox; confirm is held until it is ticked
|
|
369
419
|
* (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
|
|
370
420
|
* checkbox's label ("I understand Anna will be signed out everywhere").
|
|
371
421
|
*
|
|
@@ -386,8 +436,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
386
436
|
* who reads the same paragraph on every row stops reading it (keksdose dev#488).
|
|
387
437
|
*/
|
|
388
438
|
consequences?: readonly (string | DangerConsequence)[];
|
|
389
|
-
/** Show a "type <phrase> to confirm" field; confirm
|
|
390
|
-
*
|
|
439
|
+
/** Show a "type <phrase> to confirm" field; confirm is held until the field matches
|
|
440
|
+
* (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
|
|
391
441
|
phrase?: string;
|
|
392
442
|
/**
|
|
393
443
|
* How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
|
|
@@ -401,15 +451,99 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
401
451
|
/** The warning above the fields. Defaults to `labels.prompt`. */
|
|
402
452
|
prompt?: ReactNode;
|
|
403
453
|
/** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
|
|
404
|
-
* 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. */
|
|
405
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;
|
|
406
520
|
/** Visible text of the arm button; defaults to `labels.arm`. */
|
|
407
521
|
armLabel?: ReactNode;
|
|
408
522
|
/** Visible text of the confirm button; defaults to `labels.confirm`. */
|
|
409
523
|
confirmLabel?: ReactNode;
|
|
410
|
-
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
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
|
+
*/
|
|
413
547
|
busy?: boolean;
|
|
414
548
|
/** The arm button is disabled. */
|
|
415
549
|
disabled?: boolean;
|
|
@@ -443,11 +577,55 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
443
577
|
labels?: Partial<DangerConfirmLabels>;
|
|
444
578
|
}
|
|
445
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
|
+
|
|
446
612
|
/**
|
|
447
613
|
* An "arm → confirm" tile for destructive actions: one button, which expands into a
|
|
448
|
-
* warning, an optional list of consequences,
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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).
|
|
618
|
+
*
|
|
619
|
+
* A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
|
|
620
|
+
* `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
|
|
621
|
+
* on it — with the reason in the kit {@link Tooltip} and its description ("Type
|
|
622
|
+
* “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
|
|
623
|
+
* `labels.needs*`), the first open guard in the order they are drawn. FormActions'
|
|
624
|
+
* `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
|
|
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`).
|
|
451
629
|
*
|
|
452
630
|
* Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
|
|
453
631
|
* budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
|
|
@@ -468,6 +646,9 @@ export function DangerConfirm({
|
|
|
468
646
|
phraseMatch = "trim",
|
|
469
647
|
prompt,
|
|
470
648
|
tone = "danger",
|
|
649
|
+
confirmVariant,
|
|
650
|
+
confirmDisabledReason,
|
|
651
|
+
children,
|
|
471
652
|
armLabel,
|
|
472
653
|
confirmLabel,
|
|
473
654
|
busy: busyProp,
|
|
@@ -495,13 +676,18 @@ export function DangerConfirm({
|
|
|
495
676
|
const consequencesId = useId();
|
|
496
677
|
const reasonId = useId();
|
|
497
678
|
|
|
498
|
-
// The
|
|
679
|
+
// The buttons focus is moved to are found by id.
|
|
499
680
|
const armId = useId();
|
|
500
681
|
const cancelId = useId();
|
|
682
|
+
const confirmId = useId();
|
|
501
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);
|
|
502
686
|
// Focus moves only after a transition — never on mount, so a tile that renders armed
|
|
503
687
|
// (controlled) does not take the page's focus merely by existing.
|
|
504
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);
|
|
505
691
|
|
|
506
692
|
// A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
|
|
507
693
|
// During render, like NumberField's draft, so no frame shows a collapsed tile that
|
|
@@ -524,15 +710,38 @@ export function DangerConfirm({
|
|
|
524
710
|
moveFocus.current = false;
|
|
525
711
|
if (!changed) return;
|
|
526
712
|
if (armed) {
|
|
527
|
-
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
|
+
}
|
|
528
717
|
return;
|
|
529
718
|
}
|
|
719
|
+
confirmed.current = false;
|
|
530
720
|
// A controlled parent collapsing the tile from its own `onSuccess` did not go
|
|
531
721
|
// through `setArmed`; if focus went down with the form, bring it back too.
|
|
532
722
|
const lost = document.activeElement === null || document.activeElement === document.body;
|
|
533
723
|
if (byUser || lost) document.getElementById(armId)?.focus();
|
|
534
724
|
}, [armed, armId, cancelId]);
|
|
535
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
|
+
|
|
536
745
|
const setArmed = (next: boolean) => {
|
|
537
746
|
moveFocus.current = true;
|
|
538
747
|
if (armedProp === undefined) setArmedState(next);
|
|
@@ -548,15 +757,46 @@ export function DangerConfirm({
|
|
|
548
757
|
const passwordOk = !requirePassword || password !== "";
|
|
549
758
|
const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
|
|
550
759
|
const acknowledgeOk = !asksAcknowledge || acknowledged;
|
|
551
|
-
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;
|
|
764
|
+
// The first guard still open, in the order the fields are drawn — the one the user
|
|
765
|
+
// meets next, so the sentence points at it.
|
|
766
|
+
const guardReason: string | undefined = !acknowledgeOk
|
|
767
|
+
? labels.needsAcknowledge
|
|
768
|
+
: !phraseOk
|
|
769
|
+
? typeof labels.needsPhrase === "function"
|
|
770
|
+
? labels.needsPhrase(phrase ?? "")
|
|
771
|
+
: labels.needsPhrase
|
|
772
|
+
: !passwordOk
|
|
773
|
+
? labels.needsPassword
|
|
774
|
+
: undefined;
|
|
775
|
+
// The lock first (no guard can lift it); none while busy, when the guards were met and
|
|
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;
|
|
552
785
|
|
|
553
786
|
const submit = (e: FormEvent) => {
|
|
554
787
|
e.preventDefault();
|
|
555
|
-
// `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).
|
|
556
790
|
if (!canConfirm || locked) return;
|
|
791
|
+
const values: DangerConfirmValues = {
|
|
792
|
+
...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
|
|
793
|
+
...(requirePassword && { password }),
|
|
794
|
+
...(asksAcknowledge && { acknowledged: true as const }),
|
|
795
|
+
};
|
|
796
|
+
confirmed.current = true;
|
|
557
797
|
// Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
|
|
558
798
|
// shows why, the user retries.
|
|
559
|
-
run(onConfirm(
|
|
799
|
+
run(onConfirm(values.password, values), () => setArmed(false));
|
|
560
800
|
};
|
|
561
801
|
|
|
562
802
|
// The first field takes focus on arm: the tick, then the phrase, then the password —
|
|
@@ -634,6 +874,13 @@ export function DangerConfirm({
|
|
|
634
874
|
))}
|
|
635
875
|
</ul>
|
|
636
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
|
+
)}
|
|
637
884
|
{asksAcknowledge && (
|
|
638
885
|
<Checkbox
|
|
639
886
|
ref={firstField === "acknowledge" ? firstFieldRef : undefined}
|
|
@@ -673,13 +920,16 @@ export function DangerConfirm({
|
|
|
673
920
|
{labels.cancel}
|
|
674
921
|
</Button>
|
|
675
922
|
<Button
|
|
923
|
+
id={confirmId}
|
|
676
924
|
type="submit"
|
|
677
|
-
variant={tone === "warning" ? "primary" : "danger"}
|
|
925
|
+
variant={confirmVariant ?? (tone === "warning" ? "primary" : "danger")}
|
|
678
926
|
disabled={!canConfirm}
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
|
|
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`.
|
|
932
|
+
disabledReason={confirmReason}
|
|
683
933
|
aria-busy={busy || undefined}
|
|
684
934
|
>
|
|
685
935
|
{busy && <Spinner label={null} className="h-4 w-4" />}
|