@eifi1/ui-kit 0.53.0 → 0.54.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 +9 -9
- package/dist/account/delete-account-setting.d.ts +3 -2
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/auth/auth-errors.d.ts +36 -0
- package/dist/auth/auth-errors.js +20 -0
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calendar-heatmap.js +4 -2
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/currency-option-list.d.ts +2 -1
- package/dist/components/currency-option-list.js +10 -3
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -1
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +22 -1
- package/dist/components/danger-confirm.js +50 -28
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +2 -1
- package/dist/components/data-table-filter-popover.js +3 -2
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +33 -3
- package/dist/components/data-table-filters.js +6 -4
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +3 -1
- package/dist/components/dialog-frame.js +6 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/mini-calendar.js +3 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +27 -0
- package/dist/components/modal.js +5 -3
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/stat-tile.d.ts +21 -2
- package/dist/components/stat-tile.js +4 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/currencies.d.ts +16 -2
- package/dist/currencies.js +2 -1
- package/dist/currencies.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-context-labels.js +2 -1
- package/dist/feedback/feedback-context-labels.js.map +1 -1
- package/dist/feedback/feedback-context.d.ts +19 -3
- package/dist/feedback/feedback-context.js +12 -1
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-diagnostics.d.ts +17 -0
- package/dist/feedback/feedback-diagnostics.js +27 -0
- package/dist/feedback/feedback-diagnostics.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +10 -0
- package/dist/feedback/feedback-dialog.js +6 -4
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-record.d.ts +5 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail-labels.js +1 -0
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +3 -0
- package/dist/feedback/feedback-row-detail.js +3 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
- package/dist/feedback/feedback-submit-dialog.js +15 -3
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-submit.d.ts +30 -2
- package/dist/feedback/feedback-submit.js +5 -2
- package/dist/feedback/feedback-submit.js.map +1 -1
- package/dist/hooks/use-hotkey.js +11 -1
- package/dist/hooks/use-hotkey.js.map +1 -1
- package/dist/i18n/german.js +3 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +3 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +3 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +3 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +3 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +3 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +3 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +7 -1
- package/dist/lib/dates.js +8 -1
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/format.d.ts +37 -2
- package/dist/lib/format.js +25 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/intl-cache.d.ts +4 -2
- package/dist/lib/intl-cache.js.map +1 -1
- package/dist/lib/translation-review.d.ts +26 -0
- package/dist/lib/translation-review.js +14 -0
- package/dist/lib/translation-review.js.map +1 -1
- package/package.json +1 -1
- package/src/account/delete-account-setting.tsx +3 -2
- package/src/auth/auth-errors.ts +69 -0
- package/src/components/amount-input.tsx +2 -1
- package/src/components/calendar-heatmap.tsx +8 -2
- package/src/components/currency-option-list.tsx +12 -4
- package/src/components/currency-select.tsx +3 -1
- package/src/components/danger-confirm.tsx +80 -27
- package/src/components/data-table-filter-popover.tsx +19 -11
- package/src/components/data-table-filters.ts +36 -8
- package/src/components/dialog-frame.tsx +12 -4
- package/src/components/mini-calendar.tsx +5 -2
- package/src/components/modal.tsx +36 -7
- package/src/components/number-input.tsx +4 -3
- package/src/components/stat-tile.tsx +32 -3
- package/src/currencies.ts +23 -3
- package/src/feedback/feedback-context-labels.ts +1 -0
- package/src/feedback/feedback-context.tsx +34 -2
- package/src/feedback/feedback-diagnostics.tsx +41 -0
- package/src/feedback/feedback-dialog.tsx +16 -4
- package/src/feedback/feedback-record.ts +5 -0
- package/src/feedback/feedback-row-detail-labels.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +13 -0
- package/src/feedback/feedback-submit-dialog.tsx +15 -2
- package/src/feedback/feedback-submit.tsx +36 -3
- package/src/hooks/use-hotkey.ts +24 -1
- package/src/i18n/german.ts +2 -0
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +14 -2
- package/src/lib/dates.ts +24 -2
- package/src/lib/format.ts +66 -3
- package/src/lib/intl-cache.ts +4 -2
- package/src/lib/translation-review.ts +52 -0
|
@@ -146,8 +146,9 @@ function isConsequence(item: DeleteAccountConsequence): item is DangerConsequenc
|
|
|
146
146
|
* and the password. They are the tile's own {@link TypedConfirmField} and
|
|
147
147
|
* {@link CurrentPasswordInput}, drawn in its fields slot and held by
|
|
148
148
|
* `confirmDisabledReason` with the tile's own sentences, rather than its built-in
|
|
149
|
-
* `phrase` / `requirePassword` — those
|
|
150
|
-
*
|
|
149
|
+
* `phrase` / `requirePassword` — those share ONE `error` (0.54, under the password), and
|
|
150
|
+
* this card's refusals go to two fields: a wrong password under the password, a
|
|
151
|
+
* mismatched address under the address. Its consequences are the app's list, plus
|
|
151
152
|
* Kurvenschmiede's hand-over line (`handOverCount`, §2.7: shared work goes to an
|
|
152
153
|
* administrator, never a refusal). The typed address goes to `onRequest` as typed, so
|
|
153
154
|
* the server re-checks it (`confirm_email_matches`).
|
package/src/auth/auth-errors.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BillingLabels } from "../billing/billing-labels";
|
|
1
2
|
import { httpStatusOf } from "../lib/http-status";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -254,6 +255,74 @@ export function isBillingError(err: unknown, code?: BillingErrorCode): boolean {
|
|
|
254
255
|
return found !== undefined && BILLING_CODES.has(found as BillingErrorCode) && (code === undefined || found === code);
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
/** Who reads a `billing_read_only` refusal — {@link billingRefusalText}'s third argument,
|
|
259
|
+
* `useBillingLockReason`'s two options. */
|
|
260
|
+
export interface BillingRefusalTextOptions {
|
|
261
|
+
/** The reader is a GUEST in someone else's item (§12.6): the words never say why. */
|
|
262
|
+
guest?: boolean;
|
|
263
|
+
/** A guest's item, by its own name ("Household 2026"). Ignored for the payer. */
|
|
264
|
+
item?: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type RefusalWords = (labels: BillingLabels, options: BillingRefusalTextOptions) => string;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Each billing refusal's words in the `billing` labels. A `Record` over the whole union, so
|
|
271
|
+
* a code server-kit adds is a type error here until it has words.
|
|
272
|
+
*/
|
|
273
|
+
const BILLING_REFUSAL_WORDS: Record<BillingErrorCode, RefusalWords> = {
|
|
274
|
+
billing_disabled: (labels) => labels.disabled,
|
|
275
|
+
// The lock's own sentence (`useBillingLockReason`): a guest's never says why (§12.6).
|
|
276
|
+
billing_read_only: (labels, options) => (options.guest ? labels.guestReadOnly(options.item) : labels.lockReason),
|
|
277
|
+
// The notice's first line. The figure and the way to more are `isPlanLimit`'s, for
|
|
278
|
+
// `PlanLimitNotice` or `usePlanLimitToast`.
|
|
279
|
+
plan_limit: (labels) => labels.limitReached,
|
|
280
|
+
billing_not_configured: (labels) => labels.notConfigured,
|
|
281
|
+
billing_provider_unavailable: (labels) => labels.providerUnavailable,
|
|
282
|
+
billing_not_at_provider: (labels) => labels.notAtProvider,
|
|
283
|
+
billing_already_subscribed: (labels) => labels.alreadySubscribed,
|
|
284
|
+
billing_plan_not_sold: (labels) => labels.planNotSold,
|
|
285
|
+
billing_not_subscribed: (labels) => labels.notSubscribed,
|
|
286
|
+
billing_plan_unchanged: (labels) => labels.planUnchanged,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The words for a billing refusal, in `labels` (an app's `useBillingLabels()`, or its
|
|
291
|
+
* `billing` catalogue outside React), or `null` when `err` is not one — for the toast or
|
|
292
|
+
* the dialog after a billing call failed, with the app's own words for anything else
|
|
293
|
+
* (kastlan reuse audit 2026-11-02, proposal 4).
|
|
294
|
+
*
|
|
295
|
+
* All three apps mapped the codes to the labels by hand: kastlan's
|
|
296
|
+
* `billing-refusal-dialog.tsx`, keksdose's `subscription-cards.tsx` and Kurvenschmiede's
|
|
297
|
+
* `api/client.ts`, the same eight codes each. This follows the most complete of them:
|
|
298
|
+
* every {@link BillingErrorCode}, so it is `null` only for an error that is not billing's.
|
|
299
|
+
*
|
|
300
|
+
* - `billing_read_only` — the write lock's sentence, as `useBillingLockReason` says it:
|
|
301
|
+
* the payer's `lockReason`, or with `{ guest: true, item }` a guest's `guestReadOnly`,
|
|
302
|
+
* which never says why (§12.6) — kastlan's dialog, which passes `guest: !isAdmin`.
|
|
303
|
+
* - `plan_limit` — `limitReached`, "Plan limit reached". Where the figure fits, read
|
|
304
|
+
* `isPlanLimit` first and show `PlanLimitNotice` or `usePlanLimitToast`, as kastlan does.
|
|
305
|
+
* - the eight others — their labels: `disabled`, `notConfigured`, `providerUnavailable`,
|
|
306
|
+
* `notAtProvider`, `alreadySubscribed`, `planNotSold`, `notSubscribed`, `planUnchanged`.
|
|
307
|
+
*
|
|
308
|
+
* Reads the code wherever {@link authErrorCode} does (axios, ofetch, a generated client,
|
|
309
|
+
* FastAPI's nested `detail`).
|
|
310
|
+
*
|
|
311
|
+
* catch (err) {
|
|
312
|
+
* toast.error(billingRefusalText(err, labels) ?? describeError(err));
|
|
313
|
+
* }
|
|
314
|
+
*/
|
|
315
|
+
export function billingRefusalText(
|
|
316
|
+
err: unknown,
|
|
317
|
+
labels: BillingLabels,
|
|
318
|
+
options: BillingRefusalTextOptions = {},
|
|
319
|
+
): string | null {
|
|
320
|
+
const found = authErrorCode(err);
|
|
321
|
+
return found !== undefined && BILLING_CODES.has(found as BillingErrorCode)
|
|
322
|
+
? BILLING_REFUSAL_WORDS[found as BillingErrorCode](labels, options)
|
|
323
|
+
: null;
|
|
324
|
+
}
|
|
325
|
+
|
|
257
326
|
/**
|
|
258
327
|
* The parsed body that carries a code, wherever {@link authErrorCode} would find it (in
|
|
259
328
|
* the same order) — the object with the `code`, so FastAPI's nested `detail` when the code
|
|
@@ -149,7 +149,8 @@ export interface AmountInputProps extends FieldHelpProps, DataAttributes {
|
|
|
149
149
|
currencyNames?: Record<string, string>;
|
|
150
150
|
/** Offer only these ISO codes in the chip's list, in this order — `CurrencySelect`'s
|
|
151
151
|
* `options`, since the chip renders CurrencySelect's list (round 0.34 Wave 2, DUP-4).
|
|
152
|
-
* Default every currency the kit knows
|
|
152
|
+
* Default every currency the kit knows but a `retired` one (the lev), which only the
|
|
153
|
+
* field that holds it lists. */
|
|
153
154
|
currencyOptions?: string[];
|
|
154
155
|
/** Focus the field on mount — on mobile this also opens the numpad sheet, so a
|
|
155
156
|
* new-transaction form can jump straight to amount entry (feedback #70). */
|
|
@@ -3,6 +3,7 @@ import type { ComponentPropsWithRef, CSSProperties, KeyboardEvent, ReactNode } f
|
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { dirOf, horizontalStep } from "../lib/direction";
|
|
5
5
|
import { addMonthsClamped, localeWeekStart, parseIsoDate, toLocalIso } from "../lib/dates";
|
|
6
|
+
import { dateFormatter } from "../lib/intl-cache";
|
|
6
7
|
import { useKitLabels, useKitLocale, useKitWeekStart, type LabelOverride } from "../i18n/kit-labels";
|
|
7
8
|
import { lerpHex, textOn } from "../theme/chart-palette";
|
|
8
9
|
import { parseHex } from "../theme/color";
|
|
@@ -154,6 +155,8 @@ const DAY_NAME_FORMAT: Intl.DateTimeFormatOptions = {
|
|
|
154
155
|
month: "long",
|
|
155
156
|
year: "numeric",
|
|
156
157
|
};
|
|
158
|
+
/** A month's name over its first column ("Sep"). */
|
|
159
|
+
const MONTH_LABEL_FORMAT: Intl.DateTimeFormatOptions = { month: "short" };
|
|
157
160
|
|
|
158
161
|
/** The empty day: a wash of the text colour, like `--bg-hover`, so it reads as a cell
|
|
159
162
|
* on both themes without claiming any value. */
|
|
@@ -345,7 +348,10 @@ export function CalendarHeatmap({
|
|
|
345
348
|
};
|
|
346
349
|
}, [layout, firstIso, lastIso]);
|
|
347
350
|
|
|
348
|
-
|
|
351
|
+
// Every day cell names its date, so through the kit's formatter cache, as the month
|
|
352
|
+
// labels are: `toLocaleDateString` built one per cell and render, 365 for a year's grid
|
|
353
|
+
// (keksdose audit follow-up, 2026-11-01).
|
|
354
|
+
const formatDay = (d: Date) => dateFormatter(locale, DAY_NAME_FORMAT).format(d);
|
|
349
355
|
|
|
350
356
|
const weekdayLabels = useMemo(() => {
|
|
351
357
|
const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
|
|
@@ -503,7 +509,7 @@ export function CalendarHeatmap({
|
|
|
503
509
|
for (let r = 0; r < 7; r++) {
|
|
504
510
|
const d = dayAt(w * 7 + r);
|
|
505
511
|
if (d && (d.getDate() === 1 || (w === 0 && r === lead && d.getDate() <= 14))) {
|
|
506
|
-
return
|
|
512
|
+
return dateFormatter(locale, MONTH_LABEL_FORMAT).format(d);
|
|
507
513
|
}
|
|
508
514
|
}
|
|
509
515
|
return "";
|
|
@@ -73,7 +73,8 @@ export interface CurrencyOptionListProps {
|
|
|
73
73
|
/** A currency was picked. The host closes the list. */
|
|
74
74
|
onChoose: (code: string) => void;
|
|
75
75
|
/** Only these ISO codes, in this order; codes the kit has no definition for are
|
|
76
|
-
* dropped. Default every one of {@link CURRENCIES}
|
|
76
|
+
* dropped. Default every one of {@link CURRENCIES} that is not `retired` (the chosen
|
|
77
|
+
* one too, if it is). A retired code named here stays: the list is the caller's. */
|
|
77
78
|
codes?: readonly string[];
|
|
78
79
|
/** Translated names by code — see {@link currencyName}. */
|
|
79
80
|
currencyNames?: Record<string, string>;
|
|
@@ -112,19 +113,27 @@ export function CurrencyOptionList({
|
|
|
112
113
|
searchPlaceholder,
|
|
113
114
|
sheetTitle,
|
|
114
115
|
}: CurrencyOptionListProps) {
|
|
116
|
+
const held = getCurrency(value);
|
|
117
|
+
const chosen = held?.code;
|
|
118
|
+
// A retired currency the field holds — the one the default list keeps for it. Its own
|
|
119
|
+
// key, so a change of an ordinary value does not rebuild the rows and their names.
|
|
120
|
+
const heldRetired = held?.retired ? held.code : undefined;
|
|
115
121
|
// Keyed on the codes' CONTENTS: an inline `options={["EUR", "CHF"]}` is a new array
|
|
116
122
|
// every render.
|
|
117
123
|
const codesKey = codes?.join(",");
|
|
118
124
|
const pool = useMemo(
|
|
119
125
|
() =>
|
|
120
126
|
codesKey === undefined
|
|
121
|
-
?
|
|
127
|
+
? // No currency that was replaced (the lev, since Bulgaria's euro: keksdose audit
|
|
128
|
+
// follow-up, 2026-11-01) — unless the field holds it, which then opens on its
|
|
129
|
+
// own row, checked, rather than on a list where nothing is chosen.
|
|
130
|
+
CURRENCIES.filter((c) => !c.retired || c.code === heldRetired)
|
|
122
131
|
: // The caller's order; codes the kit has no definition for are dropped.
|
|
123
132
|
codesKey
|
|
124
133
|
.split(",")
|
|
125
134
|
.map((code) => getCurrency(code))
|
|
126
135
|
.filter((c): c is CurrencyOption => c !== undefined),
|
|
127
|
-
[codesKey],
|
|
136
|
+
[codesKey, heldRetired],
|
|
128
137
|
);
|
|
129
138
|
const named = useMemo(
|
|
130
139
|
() => pool.map((c) => ({ c, name: currencyName(c, currencyNames, locale) })),
|
|
@@ -147,7 +156,6 @@ export function CurrencyOptionList({
|
|
|
147
156
|
}));
|
|
148
157
|
}, [named, query, locale]);
|
|
149
158
|
|
|
150
|
-
const chosen = getCurrency(value)?.code;
|
|
151
159
|
return (
|
|
152
160
|
<ComboboxPanel
|
|
153
161
|
// The core with the currency filter's rows in place of its own.
|
|
@@ -41,7 +41,9 @@ export interface CurrencySelectProps extends Omit<ComponentPropsWithoutRef<"div"
|
|
|
41
41
|
/**
|
|
42
42
|
* Restrict the pickable currencies to these ISO codes (e.g. only the
|
|
43
43
|
* currencies actually present in a budget). Order is preserved. Defaults to
|
|
44
|
-
* the full {@link CURRENCIES} list
|
|
44
|
+
* the full {@link CURRENCIES} list but a `retired` currency (the lev, since
|
|
45
|
+
* Bulgaria's euro), which only a field that holds it lists. A retired code named
|
|
46
|
+
* here is offered: the list is the caller's.
|
|
45
47
|
*/
|
|
46
48
|
options?: string[];
|
|
47
49
|
/** Required and unanswered — {@link FIELD_INVALID}, the same rose border the
|
|
@@ -6,6 +6,7 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
|
6
6
|
import { Button } from "./button";
|
|
7
7
|
import { Input } from "./input";
|
|
8
8
|
import { Label } from "./field-base";
|
|
9
|
+
import { FIELD_ERROR_CLASS } from "./field-parts";
|
|
9
10
|
import { Spinner } from "./spinner";
|
|
10
11
|
import type { ButtonVariant } from "./button-look";
|
|
11
12
|
import type { InputProps } from "./input";
|
|
@@ -536,6 +537,27 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithRef<"div">, "
|
|
|
536
537
|
* confirm, so a keyboard user can retry or Shift+Tab to the field to correct (0.24).
|
|
537
538
|
*/
|
|
538
539
|
busy?: boolean;
|
|
540
|
+
/**
|
|
541
|
+
* Why the last confirm failed ("Wrong password"), in the tile beside the field it is
|
|
542
|
+
* about rather than in a toast away from it (keksdose audit follow-up, 2026-11-01:
|
|
543
|
+
* PasswordDangerCard toasted its `password_incorrect` for want of this). The app sets
|
|
544
|
+
* it from the failure and words it — a 400 is a wrong password, a 500 is not.
|
|
545
|
+
*
|
|
546
|
+
* Drawn as the field's own `error`, as {@link ReauthDialog} draws its own: under the
|
|
547
|
+
* password with `requirePassword`, else under the typed phrase — the field painted,
|
|
548
|
+
* `aria-invalid`, the message its `aria-describedby` — and in a tile with neither, as
|
|
549
|
+
* a line above the buttons. Announced as it appears (`role="alert"`): it arrives
|
|
550
|
+
* after the press, with focus on the confirm, where a field's description alone is
|
|
551
|
+
* not heard. Passed at all — even as `undefined` — the field keeps its box, as with
|
|
552
|
+
* Input's `error`, so the message going away does not rebuild the field mid-edit.
|
|
553
|
+
*
|
|
554
|
+
* Hidden while the next attempt runs, once the user edits the tick, the phrase or the
|
|
555
|
+
* password (it is about what was submitted), and when the tile arms or disarms, until
|
|
556
|
+
* the next confirm; shown again when a confirm fails with it still set. So a
|
|
557
|
+
* mutation's error can be handed over as it is:
|
|
558
|
+
* `error={mut.error ? message(mut.error) : undefined}`.
|
|
559
|
+
*/
|
|
560
|
+
error?: ReactNode;
|
|
539
561
|
/** The arm button is disabled. */
|
|
540
562
|
disabled?: boolean;
|
|
541
563
|
/**
|
|
@@ -628,30 +650,32 @@ function firstSlotField(slot: HTMLElement | null): HTMLElement | null {
|
|
|
628
650
|
* destructive button itself), and disarming moves it back to the arm button, so the
|
|
629
651
|
* keyboard user is never left on an element that just vanished.
|
|
630
652
|
*/
|
|
631
|
-
export function DangerConfirm({
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
653
|
+
export function DangerConfirm(props: DangerConfirmProps) {
|
|
654
|
+
const {
|
|
655
|
+
onConfirm,
|
|
656
|
+
requirePassword,
|
|
657
|
+
requireAcknowledge,
|
|
658
|
+
consequences,
|
|
659
|
+
phrase,
|
|
660
|
+
phraseMatch = "trim",
|
|
661
|
+
prompt,
|
|
662
|
+
tone = "danger",
|
|
663
|
+
confirmVariant,
|
|
664
|
+
confirmDisabledReason,
|
|
665
|
+
children,
|
|
666
|
+
armLabel,
|
|
667
|
+
confirmLabel,
|
|
668
|
+
busy: busyProp,
|
|
669
|
+
error,
|
|
670
|
+
disabled,
|
|
671
|
+
lockedReason: ownLockedReason,
|
|
672
|
+
commit,
|
|
673
|
+
armed: armedProp,
|
|
674
|
+
onArmedChange,
|
|
675
|
+
labels: labelsProp,
|
|
676
|
+
className,
|
|
677
|
+
...rest
|
|
678
|
+
} = props;
|
|
655
679
|
const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
|
|
656
680
|
const lockedReason = useCommitReason(commit, ownLockedReason);
|
|
657
681
|
const [armedState, setArmedState] = useState(false);
|
|
@@ -659,6 +683,9 @@ export function DangerConfirm({
|
|
|
659
683
|
const [password, setPassword] = useState("");
|
|
660
684
|
const [typed, setTyped] = useState("");
|
|
661
685
|
const [acknowledged, setAcknowledged] = useState(false);
|
|
686
|
+
// The `error` describes what was submitted: an edit, an arm or a disarm leaves it
|
|
687
|
+
// describing nothing on screen. Cleared by the next confirm — ReauthDialog's `edited`.
|
|
688
|
+
const [errorStale, setErrorStale] = useState(false);
|
|
662
689
|
const { pending, run } = usePromisePending();
|
|
663
690
|
const busy = Boolean(busyProp) || pending;
|
|
664
691
|
// `readOnly` rather than `disabled` on the fields while busy: disabling the field
|
|
@@ -686,6 +713,8 @@ export function DangerConfirm({
|
|
|
686
713
|
const [wasArmed, setWasArmed] = useState(armed);
|
|
687
714
|
if (wasArmed !== armed) {
|
|
688
715
|
setWasArmed(armed);
|
|
716
|
+
// An `error` still passed is about the attempt before: not shown until a confirm.
|
|
717
|
+
setErrorStale(true);
|
|
689
718
|
if (!armed) {
|
|
690
719
|
setPassword("");
|
|
691
720
|
setTyped("");
|
|
@@ -785,11 +814,22 @@ export function DangerConfirm({
|
|
|
785
814
|
...(asksAcknowledge && { acknowledged: true as const }),
|
|
786
815
|
};
|
|
787
816
|
confirmed.current = true;
|
|
817
|
+
setErrorStale(false);
|
|
788
818
|
// Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
|
|
789
819
|
// shows why, the user retries.
|
|
790
820
|
run(onConfirm(values.password, values), () => setArmed(false));
|
|
791
821
|
};
|
|
792
822
|
|
|
823
|
+
// The failure's message, under the field it is about: the password, else the phrase,
|
|
824
|
+
// else (a tile with neither) a line of its own. Not while the next attempt runs.
|
|
825
|
+
const shownError = errorStale || busy ? undefined : error;
|
|
826
|
+
const errorAlert = hasContent(shownError) ? <span role="alert">{shownError}</span> : undefined;
|
|
827
|
+
const errorOn = requirePassword ? "password" : phrase !== undefined ? "phrase" : "line";
|
|
828
|
+
// Passed at all, the carrying field keeps its box (Input's `error` rule): only then.
|
|
829
|
+
const fieldError = (field: "password" | "phrase") =>
|
|
830
|
+
"error" in props && errorOn === field ? { error: errorAlert } : {};
|
|
831
|
+
const edited = () => setErrorStale(true);
|
|
832
|
+
|
|
793
833
|
// The first field takes focus on arm: the tick, then the phrase, then the password —
|
|
794
834
|
// the order they are drawn in.
|
|
795
835
|
const firstField = asksAcknowledge ? "acknowledge" : phrase !== undefined ? "phrase" : "password";
|
|
@@ -880,7 +920,9 @@ export function DangerConfirm({
|
|
|
880
920
|
// Not `disabled` while busy, for the same reason the fields are readOnly:
|
|
881
921
|
// the box may hold the focus. A change while busy is simply not taken.
|
|
882
922
|
onCheckedChange={(next) => {
|
|
883
|
-
if (
|
|
923
|
+
if (busy) return;
|
|
924
|
+
setAcknowledged(next);
|
|
925
|
+
edited();
|
|
884
926
|
}}
|
|
885
927
|
/>
|
|
886
928
|
)}
|
|
@@ -890,11 +932,15 @@ export function DangerConfirm({
|
|
|
890
932
|
target={phrase}
|
|
891
933
|
match={phraseMatch}
|
|
892
934
|
value={typed}
|
|
893
|
-
onValueChange={
|
|
935
|
+
onValueChange={(next) => {
|
|
936
|
+
setTyped(next);
|
|
937
|
+
edited();
|
|
938
|
+
}}
|
|
894
939
|
busy={busy}
|
|
895
940
|
// Already resolved through this tile's own `labels` prop; handed on so a
|
|
896
941
|
// per-instance phrase wording reaches the field.
|
|
897
942
|
labels={{ phrase: labels.phrase, phrasePlaceholder: labels.phrasePlaceholder }}
|
|
943
|
+
{...fieldError("phrase")}
|
|
898
944
|
/>
|
|
899
945
|
)}
|
|
900
946
|
{requirePassword && (
|
|
@@ -903,9 +949,16 @@ export function DangerConfirm({
|
|
|
903
949
|
label={labels.password}
|
|
904
950
|
value={password}
|
|
905
951
|
busy={busy}
|
|
906
|
-
onValueChange={
|
|
952
|
+
onValueChange={(next) => {
|
|
953
|
+
setPassword(next);
|
|
954
|
+
edited();
|
|
955
|
+
}}
|
|
956
|
+
{...fieldError("password")}
|
|
907
957
|
/>
|
|
908
958
|
)}
|
|
959
|
+
{errorOn === "line" && errorAlert !== undefined && (
|
|
960
|
+
<p className={cn(FIELD_ERROR_CLASS, "mt-0")}>{errorAlert}</p>
|
|
961
|
+
)}
|
|
909
962
|
<div className="flex flex-wrap gap-2">
|
|
910
963
|
<Button id={cancelId} type="button" variant="ghost" disabled={busy} onClick={() => setArmed(false)}>
|
|
911
964
|
{labels.cancel}
|
|
@@ -40,7 +40,8 @@ interface FilterPopoverProps<T> {
|
|
|
40
40
|
* The contents of a column's filter Popover. Renders the right control set for
|
|
41
41
|
* the column's filter type — text search, select checklist, date range (presets
|
|
42
42
|
* + {@link MiniCalendar} + from/to inputs), or numeric min/max with an abs
|
|
43
|
-
* toggle — driving the shared
|
|
43
|
+
* toggle (none for `numberFilter(getValue, { abs: false })`) — driving the shared
|
|
44
|
+
* {@link FilterValue} state.
|
|
44
45
|
*/
|
|
45
46
|
export function FilterPopover<T>({
|
|
46
47
|
column,
|
|
@@ -219,6 +220,11 @@ export function FilterPopover<T>({
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
// number
|
|
223
|
+
// `abs: false` on the filter: no "Absolute value" box, and a state that carries `abs`
|
|
224
|
+
// anyway (a view stored before the column lost the box) is handed on without it at the
|
|
225
|
+
// first bound typed — there is no box to untick it with (kastlan reuse audit
|
|
226
|
+
// 2026-11-02, proposal 7).
|
|
227
|
+
const absBox = filter.abs !== false;
|
|
222
228
|
const v = state.type === "number" ? state : { type: "number" as const, min: "", max: "", abs: false };
|
|
223
229
|
// The bounds were native `type="number"` inputs, the one numeric entry in the kit that
|
|
224
230
|
// bypassed decimal-marks (BUG-10, refactor audit 2026-10): where the browser refuses a
|
|
@@ -237,7 +243,7 @@ export function FilterPopover<T>({
|
|
|
237
243
|
type: "number",
|
|
238
244
|
min: bound === "min" ? read : v.min,
|
|
239
245
|
max: bound === "max" ? read : v.max,
|
|
240
|
-
abs: v.abs,
|
|
246
|
+
abs: absBox && v.abs,
|
|
241
247
|
});
|
|
242
248
|
};
|
|
243
249
|
return (
|
|
@@ -266,15 +272,17 @@ export function FilterPopover<T>({
|
|
|
266
272
|
/>
|
|
267
273
|
</label>
|
|
268
274
|
</div>
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
{absBox && (
|
|
276
|
+
<label className="flex items-center gap-2 text-sm text-secondary">
|
|
277
|
+
<input
|
|
278
|
+
type="checkbox"
|
|
279
|
+
checked={v.abs}
|
|
280
|
+
onChange={(e) => onChange({ type: "number", min: v.min, max: v.max, abs: e.target.checked })}
|
|
281
|
+
className="size-3.5"
|
|
282
|
+
/>
|
|
283
|
+
{labels.numberAbs}
|
|
284
|
+
</label>
|
|
285
|
+
)}
|
|
278
286
|
{clearRow}
|
|
279
287
|
</div>
|
|
280
288
|
);
|
|
@@ -8,7 +8,15 @@ export type ColumnFilter<T> =
|
|
|
8
8
|
| { type: "text"; getValue: (row: T) => string }
|
|
9
9
|
| { type: "select"; getValue: (row: T) => string; options?: { value: string; label?: string }[] }
|
|
10
10
|
| { type: "date"; getValue: (row: T) => string | null | undefined }
|
|
11
|
-
| {
|
|
11
|
+
| {
|
|
12
|
+
type: "number";
|
|
13
|
+
getValue: (row: T) => number | null | undefined;
|
|
14
|
+
/** `false`: no "Absolute value" box, for a figure that is never negative (a year, a
|
|
15
|
+
* count, a size), where the box only offers a choice that changes nothing. A state
|
|
16
|
+
* or a link that carries `abs` anyway is read without it. Default `true`. See
|
|
17
|
+
* {@link numberFilter}. */
|
|
18
|
+
abs?: boolean;
|
|
19
|
+
};
|
|
12
20
|
|
|
13
21
|
/** The current state of a column's filter (what the user has entered). */
|
|
14
22
|
export type FilterValue =
|
|
@@ -46,8 +54,15 @@ export function isFilterActive(v: FilterValue | undefined): boolean {
|
|
|
46
54
|
* match every row while the column showed its filter as active; `Number(" ")` is 0, and
|
|
47
55
|
* a blank bound kept every negative row out. The popover hands in the dot-decimal text
|
|
48
56
|
* its locale's marks read (`readTyped`); anything else is no bound.
|
|
57
|
+
*
|
|
58
|
+
* Exported for a server-side table (`serverPagination`), which sends the bounds to its
|
|
59
|
+
* server instead of comparing here: send what this reads, and leave out a `null`. The box
|
|
60
|
+
* holds a half-typed "-" or "5e" while the user types, and kastlan's server tables sent
|
|
61
|
+
* that text on to `col >= :bound` until they wrote this rule again for themselves (kastlan
|
|
62
|
+
* reuse audit 2026-11-02, proposal 7). And send `abs` only beside a bound: on its own it
|
|
63
|
+
* narrows nothing.
|
|
49
64
|
*/
|
|
50
|
-
function numberBound(raw: string): number | null {
|
|
65
|
+
export function numberBound(raw: string): number | null {
|
|
51
66
|
if (raw.trim() === "") return null;
|
|
52
67
|
const n = Number(raw);
|
|
53
68
|
return Number.isFinite(n) ? n : null;
|
|
@@ -108,7 +123,9 @@ export function rowMatches<T>(col: DataTableColumn<T>, row: T, state: FilterValu
|
|
|
108
123
|
const min = numberBound(state.min);
|
|
109
124
|
const max = numberBound(state.max);
|
|
110
125
|
if (raw == null || Number.isNaN(raw)) return min === null && max === null;
|
|
111
|
-
|
|
126
|
+
// A column without the box (`abs: false`) compares the figure as it is, whatever
|
|
127
|
+
// the state says: there is nothing on screen to untick.
|
|
128
|
+
const x = state.abs && filter.abs !== false ? Math.abs(raw) : raw;
|
|
112
129
|
if (min !== null && x < min) return false;
|
|
113
130
|
if (max !== null && x > max) return false;
|
|
114
131
|
return true;
|
|
@@ -204,9 +221,12 @@ export function decodeFilterValueOfType(type: FilterValue["type"], raw: string):
|
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
|
|
207
|
-
/** Inverse of {@link encodeFilterValue}, typed by the column's filter config.
|
|
224
|
+
/** Inverse of {@link encodeFilterValue}, typed by the column's filter config. A number
|
|
225
|
+
* filter without the "Absolute value" box (`abs: false`) drops a link's `!`: kept, a
|
|
226
|
+
* server-side table would send `abs` that nobody can see or untick. */
|
|
208
227
|
export function decodeFilterValue<T>(filter: ColumnFilter<T>, raw: string): FilterValue {
|
|
209
|
-
|
|
228
|
+
const value = decodeFilterValueOfType(filter.type, raw);
|
|
229
|
+
return value.type === "number" && filter.type === "number" && filter.abs === false ? { ...value, abs: false } : value;
|
|
210
230
|
}
|
|
211
231
|
|
|
212
232
|
// ---------- Filter builders ----------
|
|
@@ -244,9 +264,17 @@ export function dateFilter<T>(getValue: (row: T) => string | null | undefined):
|
|
|
244
264
|
return { type: "date", getValue };
|
|
245
265
|
}
|
|
246
266
|
|
|
247
|
-
/**
|
|
248
|
-
|
|
249
|
-
|
|
267
|
+
/**
|
|
268
|
+
* A min/max (and absolute-value) filter over a number. `{ abs: false }` leaves out the
|
|
269
|
+
* "Absolute value" box, for a figure that is never negative — a year, a count, a size —
|
|
270
|
+
* where it only offers a choice that changes nothing (kastlan reuse audit 2026-11-02,
|
|
271
|
+
* proposal 7: its server tables drew it under construction years and file sizes).
|
|
272
|
+
*/
|
|
273
|
+
export function numberFilter<T>(
|
|
274
|
+
getValue: (row: T) => number | null | undefined,
|
|
275
|
+
options?: { abs?: boolean },
|
|
276
|
+
): ColumnFilter<T> {
|
|
277
|
+
return options?.abs === false ? { type: "number", getValue, abs: false } : { type: "number", getValue };
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
/**
|
|
@@ -26,7 +26,9 @@ export { DEFAULT_DIALOG_FRAME_LABELS };
|
|
|
26
26
|
* `onKeyDown`, a `data-tour` anchor, still reaches the `Modal` — and since 0.35 `ref`
|
|
27
27
|
* (round 0.35 R4): the panel, the `role="dialog"` element the frame's header, body and
|
|
28
28
|
* actions sit in. `busy` too (DUP-3): it holds the frame's X and the `close` that
|
|
29
|
-
* `actions` and `headerActions` receive, since both are the `Modal`'s own close.
|
|
29
|
+
* `actions` and `headerActions` receive, since both are the `Modal`'s own close. And
|
|
30
|
+
* `held` (keksdose audit follow-up, 2026-11-01), which holds the same and shows the X
|
|
31
|
+
* disabled — see {@link ModalProps.held}.
|
|
30
32
|
*/
|
|
31
33
|
export interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "children" | "title"> {
|
|
32
34
|
/** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */
|
|
@@ -187,7 +189,7 @@ export function DialogFrame({
|
|
|
187
189
|
<FrameHeaderActions actions={headerActions} onClose={modal.onClose} />
|
|
188
190
|
)}
|
|
189
191
|
</div>
|
|
190
|
-
{closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}
|
|
192
|
+
{closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} held={modal.held} />}
|
|
191
193
|
</div>
|
|
192
194
|
{hasBody && (
|
|
193
195
|
<div
|
|
@@ -210,8 +212,10 @@ export function DialogFrame({
|
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
/** The X. A component of its own so it can read the panel's animated close, which
|
|
213
|
-
* only exists INSIDE the `Modal` (the frame's own body runs outside it).
|
|
214
|
-
|
|
215
|
+
* only exists INSIDE the `Modal` (the frame's own body runs outside it). Disabled
|
|
216
|
+
* while `held`; under `busy` it stays enabled and the `Modal` drops its press (see
|
|
217
|
+
* {@link ModalProps.held} for why the two differ). */
|
|
218
|
+
function FrameClose({ label, onClose, held = false }: { label?: string; onClose: () => void; held?: boolean }) {
|
|
215
219
|
const close = useContext(ModalCloseContext) ?? onClose;
|
|
216
220
|
const labels = useKitLabels(
|
|
217
221
|
"dialogFrame",
|
|
@@ -222,9 +226,13 @@ function FrameClose({ label, onClose }: { label?: string; onClose: () => void })
|
|
|
222
226
|
<button
|
|
223
227
|
type="button"
|
|
224
228
|
onClick={close}
|
|
229
|
+
disabled={held}
|
|
225
230
|
aria-label={labels.close}
|
|
226
231
|
className={cn(
|
|
227
232
|
"-me-1.5 -mt-1 shrink-0 rounded p-1.5 text-muted outline-none hover:bg-hover hover:text-secondary",
|
|
233
|
+
// The kit's disabled look (Button, IconButton), and the resting look pinned under
|
|
234
|
+
// `disabled:hover:` so a refused X does not light up under the pointer.
|
|
235
|
+
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted",
|
|
228
236
|
FOCUS_RING,
|
|
229
237
|
)}
|
|
230
238
|
>
|
|
@@ -8,6 +8,7 @@ import { FOCUS_RING, FOCUS_RING_WIDTH } from "./focus-ring";
|
|
|
8
8
|
import { horizontalStep } from "../lib/direction";
|
|
9
9
|
import { useAnnounce } from "../hooks/use-announce";
|
|
10
10
|
import { addMonthsClamped, localeWeekStart, monthKey, parseIsoDate, sameYmd, toLocalIso } from "../lib/dates";
|
|
11
|
+
import { dateFormatter } from "../lib/intl-cache";
|
|
11
12
|
import { useKitLabels, useKitLocale, useKitWeekStart, type LabelOverride } from "../i18n/kit-labels";
|
|
12
13
|
import { DEFAULT_MINI_CALENDAR_LABELS } from "./mini-calendar-labels";
|
|
13
14
|
|
|
@@ -355,8 +356,10 @@ export function MiniCalendar({
|
|
|
355
356
|
// The first letter only, not CSS `capitalize`, which upper-cased every word: Spanish
|
|
356
357
|
// read "Septiembre De 2026" and Hungarian "2026. Szeptember" (refactor audit
|
|
357
358
|
// 2026-10-30, DUP-11) — while the same panel with `monthJump` read them right.
|
|
358
|
-
|
|
359
|
-
|
|
359
|
+
// Both through the kit's formatter cache: every day cell names its date, which was a
|
|
360
|
+
// formatter per cell and render (keksdose audit follow-up, 2026-11-01).
|
|
361
|
+
const monthLabel = upperFirst(dateFormatter(locale, { month: "long", year: "numeric" }).format(firstOfMonth), locale);
|
|
362
|
+
const formatDay = (d: Date) => dateFormatter(locale, DAY_NAME_FORMAT).format(d);
|
|
360
363
|
|
|
361
364
|
// Weekday labels via locale, from the week's first day: the narrow one for the
|
|
362
365
|
// column head, the long one as its accessible name — "M" is the same letter for
|