@eifi1/ui-kit 0.33.0 → 0.33.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 +3 -3
- package/dist/account/delete-account-setting.d.ts +2 -1
- package/dist/admin/admin-action-confirm.d.ts +2 -1
- package/dist/admin/invitations-panel.d.ts +2 -1
- package/dist/admin/plan-change.d.ts +2 -1
- package/dist/admin/plan-change.js +3 -1
- package/dist/admin/plan-change.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.d.ts +2 -1
- package/dist/admin/role-select.d.ts +2 -1
- package/dist/admin/transfer-ownership-dialog.d.ts +2 -1
- package/dist/admin/user-roster.d.ts +2 -1
- package/dist/auth/register-form.d.ts +2 -1
- package/dist/billing/billing-banner.d.ts +2 -1
- package/dist/billing/billing-labels.d.ts +2 -1
- package/dist/billing/billing-lock.d.ts +2 -1
- package/dist/billing/billing-overview.d.ts +2 -1
- package/dist/billing/billing-standing.d.ts +2 -1
- package/dist/billing/checkout-processing.d.ts +2 -1
- package/dist/billing/pay-page.d.ts +11 -6
- package/dist/billing/pay-page.js.map +1 -1
- package/dist/billing/plan-card.d.ts +2 -1
- package/dist/billing/plan-limit-notice.d.ts +2 -1
- package/dist/billing/plan-limit-toast.d.ts +2 -1
- package/dist/billing/subscription-actions.d.ts +2 -1
- package/dist/billing/subscription-status-chip.d.ts +2 -1
- package/dist/chart.d.ts +2 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/autocomplete.js +2 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +2 -1
- package/dist/components/calculator.d.ts +2 -1
- package/dist/components/combobox-core.js +2 -1
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +2 -2
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +2 -1
- package/dist/components/copy-button.d.ts +2 -1
- package/dist/components/danger-confirm.d.ts +2 -1
- package/dist/components/dropdown.js +9 -1
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -1
- package/dist/components/file-button.d.ts +2 -1
- package/dist/components/file-dropzone.d.ts +2 -1
- package/dist/components/form-actions.d.ts +2 -1
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/iban-input.d.ts +2 -1
- package/dist/components/language-select.d.ts +2 -1
- package/dist/components/legal-page.d.ts +2 -1
- package/dist/components/legal.d.ts +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/money-field.d.ts +2 -1
- package/dist/components/number-field.d.ts +9 -1
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +29 -2
- package/dist/components/number-input.js +27 -12
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +2 -1
- package/dist/components/phone-input.d.ts +2 -1
- package/dist/components/popover.js +6 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/row-actions.d.ts +2 -1
- package/dist/components/series-chart.d.ts +2 -1
- package/dist/components/settings-fields.d.ts +2 -1
- package/dist/components/share-card.d.ts +2 -1
- package/dist/components/skeleton.d.ts +10 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/text-link.d.ts +2 -1
- package/dist/components/time-input.d.ts +2 -1
- package/dist/components/ui.d.ts +2 -1
- package/dist/{delete-account-setting-DiPX4Vue.d.ts → delete-account-setting-DAgGBrbn.d.ts} +44 -7
- package/dist/feedback/feedback-attachment.d.ts +2 -1
- package/dist/feedback/feedback-context.d.ts +2 -1
- package/dist/feedback/feedback-crash.d.ts +2 -1
- package/dist/feedback/feedback-dialog.d.ts +2 -1
- package/dist/feedback/feedback-inbox.d.ts +2 -1
- package/dist/feedback/feedback-labels.d.ts +2 -1
- package/dist/feedback/feedback-menu.d.ts +2 -1
- package/dist/feedback/feedback-record.d.ts +2 -1
- package/dist/feedback/feedback-row-detail.d.ts +2 -1
- package/dist/feedback/feedback-status-undo.d.ts +2 -1
- package/dist/feedback/feedback-submit.d.ts +2 -1
- package/dist/feedback/feedback-swipe.d.ts +2 -1
- package/dist/feedback/feedback-table.d.ts +2 -1
- package/dist/feedback/feedback-thread.d.ts +3 -2
- package/dist/feedback/feedback-thread.js +5 -1
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback.d.ts +2 -1
- package/dist/hooks/use-anchored-panel.d.ts +37 -7
- package/dist/hooks/use-anchored-panel.js +6 -7
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +2 -1
- package/dist/hooks/use-focus-trap.d.ts +8 -0
- package/dist/hooks/use-focus-trap.js +17 -11
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/i18n/defaults.d.ts +2 -1
- package/dist/i18n/german.d.ts +2 -1
- package/dist/i18n/german.js +4 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +2 -1
- package/dist/i18n/languages.d.ts +2 -1
- package/dist/i18n/locales/de-CH.d.ts +2 -1
- package/dist/i18n/locales/en.d.ts +2 -1
- package/dist/i18n/locales/es.d.ts +2 -1
- package/dist/i18n/locales/es.js +2 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +2 -1
- package/dist/i18n/locales/fr.js +3 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +2 -1
- package/dist/i18n/locales/hu.js +4 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +2 -1
- package/dist/i18n/locales/it.js +2 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +2 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/plurals.d.ts +20 -1
- package/dist/i18n/plurals.js +11 -0
- package/dist/i18n/plurals.js.map +1 -1
- package/dist/i18n/review.d.ts +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-actions.d.ts +2 -1
- package/dist/landing/landing-sections.d.ts +2 -1
- package/dist/landing/public-header.d.ts +2 -1
- package/dist/landing/routing.d.ts +2 -1
- package/dist/rhf/fields.d.ts +2 -1
- package/dist/rhf/form.d.ts +2 -1
- package/dist/rhf.d.ts +2 -1
- package/dist/settings/settings-section.d.ts +2 -1
- package/dist/shell/app-shell.d.ts +2 -1
- package/dist/shell/top-bar-brand.d.ts +2 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +2 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +1 -1
- package/src/admin/plan-change.tsx +35 -7
- package/src/billing/pay-page.ts +9 -5
- package/src/components/autocomplete.tsx +5 -2
- package/src/components/combobox-core.tsx +5 -2
- package/src/components/combobox.tsx +4 -2
- package/src/components/dropdown.tsx +12 -2
- package/src/components/full-bleed-dialog.tsx +6 -0
- package/src/components/modal.tsx +5 -0
- package/src/components/number-field.tsx +7 -0
- package/src/components/number-input.tsx +71 -11
- package/src/components/popover.tsx +6 -2
- package/src/components/skeleton.tsx +10 -2
- package/src/feedback/feedback-thread.tsx +20 -1
- package/src/hooks/use-anchored-panel.ts +60 -14
- package/src/hooks/use-focus-trap.ts +55 -12
- package/src/i18n/german.ts +4 -1
- package/src/i18n/locales/es.ts +2 -1
- package/src/i18n/locales/fr.ts +3 -1
- package/src/i18n/locales/hu.ts +4 -2
- package/src/i18n/locales/it.ts +2 -1
- package/src/i18n/locales/zh.ts +1 -0
- package/src/i18n/plurals.ts +29 -0
- package/src/index.ts +3 -1
- package/tokens.css +18 -0
|
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useState } from "react";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
5
|
+
import { pluralForm } from "../i18n/plurals";
|
|
6
|
+
import type { PluralForms } from "../i18n/plurals";
|
|
5
7
|
import { formatNumber, EMPTY_FORMATTED_VALUE } from "../lib/format";
|
|
6
8
|
import { toLocalIso } from "../lib/dates";
|
|
7
9
|
import type { DataTableColumn } from "../components/data-table";
|
|
@@ -61,8 +63,14 @@ export interface PlanChangeLabels {
|
|
|
61
63
|
untilDone: (date: string) => string;
|
|
62
64
|
/** The result: the account owns more than the new plan allows. */
|
|
63
65
|
overLimit: string;
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
+
/**
|
|
67
|
+
* The column's usage, spelled out (its tooltip): "1 of 5 budgets". Figures arrive
|
|
68
|
+
* formatted; `dimension` is the app's word, as `usage.label` gives it — since 0.33.1
|
|
69
|
+
* in the form the LIMIT takes when the label gives its plural forms ("1 of 1 budget",
|
|
70
|
+
* "1 of 5 budgets"): in every shipped language the word follows the limit, or heads
|
|
71
|
+
* the line in German, whose `von` would want a dative plural no app form carries
|
|
72
|
+
* ("Budgets: 1 von 5"). An override of yours gets the same word.
|
|
73
|
+
*/
|
|
66
74
|
usageOf: (used: string, limit: string, dimension: string) => string;
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -98,8 +106,17 @@ export interface PlanColumnUsage {
|
|
|
98
106
|
used: number;
|
|
99
107
|
/** The plan's limit for it; `null` (unlimited, or none known): no line. */
|
|
100
108
|
limit: number | null;
|
|
101
|
-
/**
|
|
102
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The app's word for what is counted, as it reads after a number: "budgets" in "1 of 5
|
|
111
|
+
* budgets" — the word in a sentence, not a heading's "Budgets".
|
|
112
|
+
*
|
|
113
|
+
* Its plural forms (0.33.1, keksdose): `{ one: "budget", other: "budgets" }` — your
|
|
114
|
+
* catalogue's `_one` / `_other` pair, any CLDR category allowed, `other` for the rest
|
|
115
|
+
* ({@link PluralForms}). The column picks the form for the limit in the provider's
|
|
116
|
+
* locale, so a plan of one reads "1 of 1 budget". A plain string is used as it is,
|
|
117
|
+
* whatever the count.
|
|
118
|
+
*/
|
|
119
|
+
label: string | PluralForms;
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
export interface PlanColumnOptions<T> {
|
|
@@ -113,13 +130,18 @@ export interface PlanColumnOptions<T> {
|
|
|
113
130
|
* undefined for a row: no chip — billing off, where a status says nothing. */
|
|
114
131
|
status?: (row: T) => SubscriptionStatus | undefined;
|
|
115
132
|
/** The row's usage of its plan's limit: "1/5" under the name, "1 of 5 budgets" in
|
|
116
|
-
* the tooltip.
|
|
133
|
+
* the tooltip (`planChange.usageOf`, with the label's form for the limit). Undefined
|
|
134
|
+
* for a row, or a `null` limit: no line. */
|
|
117
135
|
usage?: (row: T) => PlanColumnUsage | undefined;
|
|
118
136
|
/** A select filter over these codes, each named by `planName`. Left out: none. */
|
|
119
137
|
filterPlans?: readonly string[];
|
|
120
138
|
/**
|
|
121
139
|
* The sort. Default: by the plan code (keksdose's, `users-panel.tsx:739`). A function:
|
|
122
140
|
* by its value — the catalogue's `sort` for rank order. `false`: not sortable.
|
|
141
|
+
*
|
|
142
|
+
* Client-side only, as every column's `sortBy`: a `DataTable` with `serverPagination`
|
|
143
|
+
* holds one page, so it sorts nothing itself — with `onSortsChange` it reports the
|
|
144
|
+
* column's `key` (default `"plan"`), and your server orders the rows by it.
|
|
123
145
|
*/
|
|
124
146
|
sortBy?: false | ((row: T) => string | number | null | undefined);
|
|
125
147
|
/** The header's text. Default `planChange.column`. */
|
|
@@ -145,6 +167,8 @@ function PlanCell({
|
|
|
145
167
|
}) {
|
|
146
168
|
const locale = useKitLocale();
|
|
147
169
|
const figure = (value: number) => formatNumber(value, { locale });
|
|
170
|
+
const word = (label: PlanColumnUsage["label"], limit: number) =>
|
|
171
|
+
typeof label === "string" ? label : pluralForm(limit, label, locale);
|
|
148
172
|
return (
|
|
149
173
|
<div className="whitespace-nowrap" data-plan={code ?? undefined}>
|
|
150
174
|
<div className="flex items-center gap-1.5">
|
|
@@ -155,7 +179,7 @@ function PlanCell({
|
|
|
155
179
|
{usage && usage.limit !== null && (
|
|
156
180
|
// "1/5" under the plan, "1 of 5 budgets" in the tooltip: the sentence spelled out
|
|
157
181
|
// inline was the widest thing in keksdose's table.
|
|
158
|
-
<Tooltip label={usageOf(figure(usage.used), figure(usage.limit), usage.label)}>
|
|
182
|
+
<Tooltip label={usageOf(figure(usage.used), figure(usage.limit), word(usage.label, usage.limit))}>
|
|
159
183
|
<span className="block text-xs tabular-nums text-[var(--text-muted)]">
|
|
160
184
|
{figure(usage.used)}/{figure(usage.limit)}
|
|
161
185
|
</span>
|
|
@@ -175,7 +199,11 @@ function PlanCell({
|
|
|
175
199
|
* plan: (u) => u.plan,
|
|
176
200
|
* planName,
|
|
177
201
|
* status: billingOn ? (u) => u.subscription?.status : undefined,
|
|
178
|
-
* usage: (u) => ({
|
|
202
|
+
* usage: (u) => ({
|
|
203
|
+
* used: u.budgets_owned,
|
|
204
|
+
* limit: u.limits.budgets,
|
|
205
|
+
* label: { one: t("planUsage.budget_one"), other: t("planUsage.budget_other") },
|
|
206
|
+
* }),
|
|
179
207
|
* filterPlans: plans.map((p) => p.code),
|
|
180
208
|
* })]
|
|
181
209
|
*
|
package/src/billing/pay-page.ts
CHANGED
|
@@ -73,9 +73,10 @@ function kitLanguageOf(tag: unknown): PayPageLanguage | undefined {
|
|
|
73
73
|
* The kit's locale → Paddle's checkout locale: `de-CH` → `de`, `zh` → `zh-Hans`, `hu` →
|
|
74
74
|
* `en` (Paddle has no Hungarian), the others as they are. Any tag is read as the kit
|
|
75
75
|
* reads it (`de-AT` is the one German, `zh-TW` the one Chinese); one the kit doesn't
|
|
76
|
-
* ship is English.
|
|
76
|
+
* ship is English, and so is `undefined` (0.33.1) — `useKitLocale()` with no provider
|
|
77
|
+
* locale.
|
|
77
78
|
*/
|
|
78
|
-
export function paddleLocale(kitLocale: string): string {
|
|
79
|
+
export function paddleLocale(kitLocale: string | undefined): string {
|
|
79
80
|
return PADDLE_LOCALES[kitLanguageOf(kitLocale) ?? "en"];
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -86,11 +87,14 @@ export function paddleLocale(kitLocale: string): string {
|
|
|
86
87
|
*
|
|
87
88
|
* const { url } = await api.checkout({ plan, interval, currency });
|
|
88
89
|
* noteCheckoutStarted(user.id, overview);
|
|
89
|
-
* window.location.assign(payPageUrl(url,
|
|
90
|
+
* window.location.assign(payPageUrl(url, locale)); // locale = useKitLocale()
|
|
90
91
|
*
|
|
91
|
-
*
|
|
92
|
+
* `locale` may be `undefined` (0.33.1, keksdose): `useKitLocale()` answers that with no
|
|
93
|
+
* provider locale, the runtime's language. The link then goes without `lang`, and the
|
|
94
|
+
* page speaks the browser's language, else English — what a missing or wrong `lang`
|
|
95
|
+
* already did: it costs nothing but the language.
|
|
92
96
|
*/
|
|
93
|
-
export function payPageUrl(checkoutUrl: string, locale: string): string {
|
|
97
|
+
export function payPageUrl(checkoutUrl: string, locale: string | undefined): string {
|
|
94
98
|
const language = kitLanguageOf(locale);
|
|
95
99
|
if (!language) return checkoutUrl;
|
|
96
100
|
let url: URL;
|
|
@@ -266,7 +266,7 @@ function AutocompleteInner<V extends string | number = string>(
|
|
|
266
266
|
// the highlight put one there.
|
|
267
267
|
const activeId = expanded && isOptionEnabled(results[active]) ? optionId(active) : undefined;
|
|
268
268
|
useActiveOptionScroll(activeId);
|
|
269
|
-
const { rect, top, maxHeight } = useAnchoredPanel(fieldRef, expanded, { preferredHeight: 256 });
|
|
269
|
+
const { rect, top, bottom, maxHeight, above } = useAnchoredPanel(fieldRef, expanded, { preferredHeight: 256 });
|
|
270
270
|
const dir = useAnchorDir(fieldRef, expanded);
|
|
271
271
|
|
|
272
272
|
const close = () => {
|
|
@@ -484,7 +484,10 @@ function AutocompleteInner<V extends string | number = string>(
|
|
|
484
484
|
dir={dir}
|
|
485
485
|
onMouseDown={(e) => e.preventDefault()}
|
|
486
486
|
className="fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg"
|
|
487
|
-
|
|
487
|
+
// Flipped above, the list hangs from its bottom edge: three rows under a
|
|
488
|
+
// 256 px cap, placed by `top`, floated ~176 px above the field (keksdose K1),
|
|
489
|
+
// and the rows come and go as the user types.
|
|
490
|
+
style={{ ...(above ? { bottom } : { top }), left: rect.left, width: rect.width, maxHeight }}
|
|
488
491
|
>
|
|
489
492
|
{results.length > 0 && (
|
|
490
493
|
<ul
|
|
@@ -899,11 +899,14 @@ export function ComboboxPanel<V extends string | number>({
|
|
|
899
899
|
onKeyDown={onKeyDown}
|
|
900
900
|
// `placement` keeps the panel inside the region actually on screen: on a phone
|
|
901
901
|
// the search box below pulls up the keyboard, and a panel pinned under a
|
|
902
|
-
// low trigger would otherwise sit entirely behind it (feedback #135).
|
|
902
|
+
// low trigger would otherwise sit entirely behind it (feedback #135). Flipped
|
|
903
|
+
// above, it hangs from its bottom edge, so a list shorter than its cap still meets
|
|
904
|
+
// the trigger (keksdose K1); the edge not in use is cleared, a caller's included.
|
|
903
905
|
style={{
|
|
904
906
|
...style,
|
|
905
907
|
position: "fixed",
|
|
906
|
-
top: placement.top,
|
|
908
|
+
top: placement.above ? undefined : placement.top,
|
|
909
|
+
bottom: placement.above ? placement.bottom : undefined,
|
|
907
910
|
...inline,
|
|
908
911
|
width: rect.width,
|
|
909
912
|
// 220 px at Normal, in rem so the panel's text keeps its room at Large (§3.2).
|
|
@@ -112,7 +112,7 @@ function SuggestionList({
|
|
|
112
112
|
panelRef: RefObject<HTMLDivElement | null>;
|
|
113
113
|
children: ReactNode;
|
|
114
114
|
}) {
|
|
115
|
-
const { rect, top, maxHeight } = useAnchoredPanel(anchorRef, true, { preferredHeight: 256 });
|
|
115
|
+
const { rect, top, bottom, maxHeight, above } = useAnchoredPanel(anchorRef, true, { preferredHeight: 256 });
|
|
116
116
|
const dir = useAnchorDir(anchorRef, true);
|
|
117
117
|
if (!rect) return null;
|
|
118
118
|
return createPortal(
|
|
@@ -124,7 +124,9 @@ function SuggestionList({
|
|
|
124
124
|
// Portalled out of the form's `dir`; the field's is put back. The list is
|
|
125
125
|
// exactly the field's width, so `left` places it in either direction.
|
|
126
126
|
dir={dir}
|
|
127
|
-
|
|
127
|
+
// Flipped above, the list hangs from its bottom edge: it filters as the user
|
|
128
|
+
// types, and a short one placed by `top` floated above the field (keksdose K1).
|
|
129
|
+
style={{ ...(above ? { bottom } : { top }), left: rect.left, width: rect.width }}
|
|
128
130
|
>
|
|
129
131
|
<ul id={id} role="listbox" data-clips="" className={LIST_CLASS} style={{ maxHeight }}>
|
|
130
132
|
{children}
|
|
@@ -341,8 +341,18 @@ export function DropdownPanel({
|
|
|
341
341
|
dir={dir}
|
|
342
342
|
// The caller's `style` is kept underneath, but the placement is measured rather
|
|
343
343
|
// than chosen (dev#548: an `overflow` ancestor clipped a third of this panel
|
|
344
|
-
// away), so
|
|
345
|
-
|
|
344
|
+
// away), so these values are the panel's own. Flipped above, it hangs from its
|
|
345
|
+
// bottom edge, so a list shorter than its cap still meets the trigger (keksdose
|
|
346
|
+
// K1); the edge not in use is cleared, a caller's included.
|
|
347
|
+
style={{
|
|
348
|
+
...style,
|
|
349
|
+
position: "fixed",
|
|
350
|
+
top: anchored.above ? undefined : anchored.top,
|
|
351
|
+
bottom: anchored.above ? anchored.bottom : undefined,
|
|
352
|
+
left,
|
|
353
|
+
width,
|
|
354
|
+
maxHeight: anchored.maxHeight,
|
|
355
|
+
}}
|
|
346
356
|
className={cn(
|
|
347
357
|
"z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
|
|
348
358
|
// The list, not the panel, is what scrolls: the search header has to stay put
|
|
@@ -154,6 +154,12 @@ export function FullBleedDialog({
|
|
|
154
154
|
// This is the phone's row EDITOR and its first child is usually an input; focusing it
|
|
155
155
|
// would pull the software keyboard up over half the form before the user has said
|
|
156
156
|
// which field they came for. `Modal` made the same call for the same reason.
|
|
157
|
+
//
|
|
158
|
+
// That default only decides when nothing inside asked for focus. A field the app
|
|
159
|
+
// autofocuses on purpose keeps it (0.33.1): keksdose's create card autofocuses its
|
|
160
|
+
// amount, and the trap used to take focus straight back to the dialog — and on a
|
|
161
|
+
// phone the numpad sheet, which shows only while the field is focused, went with it
|
|
162
|
+
// (live #402). Closing still hands focus back to whatever opened the dialog.
|
|
157
163
|
useFocusTrap(dialogRef, { active: open });
|
|
158
164
|
|
|
159
165
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
package/src/components/modal.tsx
CHANGED
|
@@ -333,6 +333,11 @@ function ModalPanel({
|
|
|
333
333
|
// hook adds per-keystroke recomputation, a guard on restoring to a detached node,
|
|
334
334
|
// and nesting, none of which this copy had. Released when `open` turns false, which
|
|
335
335
|
// hands focus back to the trigger while the panel is still on its way out.
|
|
336
|
+
//
|
|
337
|
+
// "The panel itself" only when nothing inside asked for focus: a field the app
|
|
338
|
+
// autofocuses on purpose keeps it (0.33.1, keksdose live #402 — the same trap took
|
|
339
|
+
// an autofocused amount straight back in `FullBleedDialog`). Its keyboard is then the
|
|
340
|
+
// one the app chose to show; the trigger still gets focus back on close.
|
|
336
341
|
useFocusTrap(panelRef, { active, initialFocus: "container" });
|
|
337
342
|
const leaving = closing || exiting;
|
|
338
343
|
|
|
@@ -141,6 +141,13 @@ function roundTo(n: number, digits: number | undefined): number {
|
|
|
141
141
|
* or a dot typed as the decimal in a comma locale); it is re-spelt in the locale's mark
|
|
142
142
|
* when it commits.
|
|
143
143
|
*
|
|
144
|
+
* LOCKED (0.33.1, Kurvenschmiede). A field that saves itself takes `commit` (or a
|
|
145
|
+
* `disabledReason` of its own), NumberInput's: under a locked `WriteLockProvider` it
|
|
146
|
+
* stays in the tab order showing its value, `readOnly`, with the lock's reason in the
|
|
147
|
+
* kit Tooltip and its description; the step keys, the calculator and the numpad are
|
|
148
|
+
* off, and nothing reaches `onValueChange` or `onCommit`. `disabled` takes it out of
|
|
149
|
+
* the tab order, which is what a lock must not do.
|
|
150
|
+
*
|
|
144
151
|
* NOT FOR MONEY. `digits={2}` rounds, but a currency is more than two decimals: JPY has
|
|
145
152
|
* none, a unit price has four, and the chip, the minor-unit default and `min`/`max` on
|
|
146
153
|
* the SIGNED figure are {@link MoneyField}'s (number value) and {@link AmountInput}'s
|
|
@@ -5,7 +5,8 @@ import { NumberPadSheet, type NumberPadSheetLabels } from "./numpad-sheet";
|
|
|
5
5
|
import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField } from "./ui";
|
|
6
6
|
import { CURRENCIES } from "./currency-select";
|
|
7
7
|
import { hasMessage, mergeDescribedBy } from "./choice-parts";
|
|
8
|
-
import { useInlineHint } from "./field-parts";
|
|
8
|
+
import { LockedReason, useInlineHint, useLockReason } from "./field-parts";
|
|
9
|
+
import type { CommitScope } from "./write-lock";
|
|
9
10
|
import { cn } from "../lib/cn";
|
|
10
11
|
import { commitExpression, formatResult } from "../lib/calc";
|
|
11
12
|
import { decimalMark, localizeMark, readTyped, showsTyped } from "../lib/decimal-marks";
|
|
@@ -135,6 +136,32 @@ interface NumberInputProps {
|
|
|
135
136
|
* numeric field that clamps what is typed as well. */
|
|
136
137
|
min?: number;
|
|
137
138
|
max?: number;
|
|
139
|
+
/**
|
|
140
|
+
* Why the value cannot be changed — {@link Button}'s `disabledReason`, for a number
|
|
141
|
+
* that SAVES itself: a setting committed on blur, a figure in a row editor
|
|
142
|
+
* (Kurvenschmiede's session page, 0.33.1). `disabled` takes the field out of the tab
|
|
143
|
+
* order, so the reason never reached a keyboard, and Kurvenschmiede wrote its own
|
|
144
|
+
* `CommitInput` to keep it in.
|
|
145
|
+
*
|
|
146
|
+
* With a reason the field stays in the tab order and shows its value, `readOnly` (the
|
|
147
|
+
* settled grey FIELD_BASE gives a `[readonly]` field) and `aria-disabled`, as the
|
|
148
|
+
* Combobox family's lock is. Nothing changes the value: the step keys do nothing, the
|
|
149
|
+
* calculator and the phone's numpad are not offered, and neither blur nor Enter
|
|
150
|
+
* commits — so `onChange` and `onCommit` are never called. Enter is swallowed, so the
|
|
151
|
+
* field does not submit the form around it. The reason is in the kit {@link Tooltip}
|
|
152
|
+
* and on the input's `aria-describedby`, between the caption and the error. Wins over
|
|
153
|
+
* `disabled`.
|
|
154
|
+
*/
|
|
155
|
+
disabledReason?: ReactNode;
|
|
156
|
+
/**
|
|
157
|
+
* This field COMMITS — {@link Button}'s `commit`: under a locked
|
|
158
|
+
* {@link WriteLockProvider} it is locked the `disabledReason` way with the lock's reason
|
|
159
|
+
* (which wins over its own). A field whose value only reaches the server through the
|
|
160
|
+
* form's Save leaves this off and stays editable under the lock; the Save takes
|
|
161
|
+
* `commit`. No provider, or an unlocked one: no effect. {@link NumberField} and
|
|
162
|
+
* {@link IntegerField} pass it on.
|
|
163
|
+
*/
|
|
164
|
+
commit?: CommitScope;
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
/** Decimal places of `n` as written ("0.25" → 2, "1e-7" → 7), for decimal-exact
|
|
@@ -289,11 +316,18 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
289
316
|
step,
|
|
290
317
|
min,
|
|
291
318
|
max,
|
|
319
|
+
disabledReason,
|
|
320
|
+
commit: commitScope,
|
|
292
321
|
} = props;
|
|
293
322
|
const generatedId = useId();
|
|
294
323
|
const fieldId = id ?? generatedId;
|
|
295
324
|
const hintId = useId();
|
|
296
325
|
const errorId = useId();
|
|
326
|
+
// The lock: focusable, `readOnly`, nothing changes or commits — see `disabledReason`.
|
|
327
|
+
const lock = useLockReason(commitScope, disabledReason);
|
|
328
|
+
const locked = lock.locked;
|
|
329
|
+
// Locked wins over `disabled`: the point of a reason is a field a keyboard reaches.
|
|
330
|
+
const off = Boolean(disabled) && !locked;
|
|
297
331
|
// §4 "No fact only in a tooltip" (docs/text-size-harmonization.md): at Large and on a
|
|
298
332
|
// touch screen a FieldHint is its words, so it takes the caption's path below — the
|
|
299
333
|
// other kit fields do the same through `useFieldHint`.
|
|
@@ -303,15 +337,17 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
303
337
|
const hasError = hasMessage(error);
|
|
304
338
|
// Passed at all — even as `undefined` — the field keeps its box; see `error`.
|
|
305
339
|
const reserve = "error" in props || "hint" in props;
|
|
306
|
-
// The standing advice first, the news second: the caller's ids, the caption, the
|
|
307
|
-
|
|
340
|
+
// The standing advice first, the news second: the caller's ids, the caption, the
|
|
341
|
+
// error — a lock's reason between the last two, as on Select.
|
|
342
|
+
const describedBy = mergeDescribedBy(ariaDescribedBy, textHint && hintId, locked && lock.reasonId, hasError && errorId);
|
|
308
343
|
const invalid = Boolean(invalidProp) || hasError || ariaInvalid === true || ariaInvalid === "true";
|
|
309
344
|
const labelled = label !== undefined;
|
|
310
345
|
// On phones we suppress the OS keyboard (inputMode="none" below) for our own
|
|
311
346
|
// calculator numpad, so the desktop popover trigger is hidden (feedback #334).
|
|
312
347
|
const isMobile = usePhoneLayout();
|
|
313
348
|
const asDisplay = isMobile && variant === "display";
|
|
314
|
-
|
|
349
|
+
// A locked field offers no calculator: its result would be a value.
|
|
350
|
+
const showCalc = calculator && !disabled && !locked && !isMobile;
|
|
315
351
|
const [focused, setFocused] = useState(false);
|
|
316
352
|
// The unit and the calculator's measured width, reserved as the field's end padding.
|
|
317
353
|
const endRef = useRef<HTMLDivElement>(null);
|
|
@@ -338,7 +374,7 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
338
374
|
}, [hasEnd]);
|
|
339
375
|
// On mobile, focusing opens the numpad bottom sheet instead of the native
|
|
340
376
|
// keyboard (#334); the ref lets its "Done" blur → commit + close.
|
|
341
|
-
const showNumpad = isMobile && !disabled && focused;
|
|
377
|
+
const showNumpad = isMobile && !disabled && !locked && focused;
|
|
342
378
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
343
379
|
|
|
344
380
|
// The mark is the locale's; see the component's note. `derived` is the value in that
|
|
@@ -395,6 +431,12 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
395
431
|
};
|
|
396
432
|
|
|
397
433
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
434
|
+
// Locked: Enter submits no form and commits nothing, and no key steps the value.
|
|
435
|
+
// The arrows and Home/End stay the caret's, for a long value someone is reading.
|
|
436
|
+
if (locked) {
|
|
437
|
+
if (e.key === "Enter") e.preventDefault();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
398
440
|
if (e.key === "Enter") return commit();
|
|
399
441
|
// A zero, negative or non-finite step is no step: the keys stay the caret's.
|
|
400
442
|
if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
@@ -406,9 +448,12 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
406
448
|
stepBy(count);
|
|
407
449
|
};
|
|
408
450
|
|
|
451
|
+
// While locked the field sits in the reason's Tooltip, which becomes its outermost
|
|
452
|
+
// box — so `className` (a width, a flex or grid cell) moves onto that, and the form
|
|
453
|
+
// keeps its layout, as on Select.
|
|
409
454
|
const field = (
|
|
410
455
|
<FloatingField
|
|
411
|
-
className={cn("w-full", className)}
|
|
456
|
+
className={cn("w-full", !locked && className)}
|
|
412
457
|
htmlFor={fieldId}
|
|
413
458
|
label={label}
|
|
414
459
|
srOnlyLabel={asDisplay}
|
|
@@ -425,16 +470,23 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
425
470
|
autoComplete="off"
|
|
426
471
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
|
|
427
472
|
autoFocus={autoFocus}
|
|
428
|
-
disabled={
|
|
473
|
+
disabled={off}
|
|
474
|
+
// Locked: focusable, `readOnly`, `aria-disabled` — see `disabledReason`.
|
|
475
|
+
readOnly={locked || undefined}
|
|
476
|
+
aria-disabled={locked || undefined}
|
|
429
477
|
// Display mode has no floating label to feed the blank-placeholder trick,
|
|
430
478
|
// and an empty borderless figure shows nothing — so it keeps whatever
|
|
431
479
|
// placeholder the caller gave, falling back to a zero to aim at.
|
|
432
480
|
placeholder={asDisplay ? (placeholder ?? "0") : labelled ? " " : placeholder}
|
|
433
481
|
value={display}
|
|
434
|
-
onChange={(e) =>
|
|
482
|
+
onChange={(e) => {
|
|
483
|
+
if (!locked) typed(e.target.value);
|
|
484
|
+
}}
|
|
435
485
|
onFocus={() => setFocused(true)}
|
|
436
486
|
onBlur={() => {
|
|
437
|
-
|
|
487
|
+
// Nothing commits under the lock — not even a draft typed before it arrived,
|
|
488
|
+
// which stays in the field for when it lifts.
|
|
489
|
+
if (!locked) commit();
|
|
438
490
|
setFocused(false);
|
|
439
491
|
}}
|
|
440
492
|
onKeyDown={onKeyDown}
|
|
@@ -455,6 +507,7 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
455
507
|
inputClassName,
|
|
456
508
|
showCalc && suffix !== undefined ? "pe-16" : showCalc ? "pe-9" : suffix !== undefined ? "pe-8" : undefined,
|
|
457
509
|
invalid && FIELD_INVALID,
|
|
510
|
+
locked && "cursor-not-allowed",
|
|
458
511
|
)}
|
|
459
512
|
// The classes above are the fallback; once the end controls are measured the
|
|
460
513
|
// field reserves exactly their width (+4px). A fixed pe-8 for any unit left a
|
|
@@ -511,13 +564,20 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
511
564
|
)}
|
|
512
565
|
</FloatingField>
|
|
513
566
|
);
|
|
514
|
-
|
|
567
|
+
const box = locked ? (
|
|
568
|
+
<LockedReason lock={lock} className={cn("block w-full", className)}>
|
|
569
|
+
{field}
|
|
570
|
+
</LockedReason>
|
|
571
|
+
) : (
|
|
572
|
+
field
|
|
573
|
+
);
|
|
574
|
+
if (!textHint && !hasError && !reserve) return box;
|
|
515
575
|
// Outside the field's own `relative` box, as Select's caption is: the calculator
|
|
516
576
|
// and the unit are `inset-y-0` in it and would stretch down over a second line.
|
|
517
577
|
// `className` stays on the field, so adding a caption cannot change what it styles.
|
|
518
578
|
return (
|
|
519
579
|
<div>
|
|
520
|
-
{
|
|
580
|
+
{box}
|
|
521
581
|
{textHint && (
|
|
522
582
|
<p id={hintId} className="mt-1 text-caption leading-tight text-[var(--text-muted)]">
|
|
523
583
|
{hint}
|
|
@@ -144,7 +144,10 @@ export function Popover({
|
|
|
144
144
|
: 0;
|
|
145
145
|
const pos = rect
|
|
146
146
|
? {
|
|
147
|
-
|
|
147
|
+
// Flipped above, the panel hangs from its bottom edge, so one shorter than its
|
|
148
|
+
// cap still meets the trigger (keksdose K1); the edge not in use is cleared.
|
|
149
|
+
top: placement.above ? undefined : placement.top,
|
|
150
|
+
bottom: placement.above ? placement.bottom : undefined,
|
|
148
151
|
left: Math.min(
|
|
149
152
|
Math.max(EDGE, dir === "rtl" ? rect.left : rect.right - panelWidth),
|
|
150
153
|
window.innerWidth - panelWidth - EDGE,
|
|
@@ -213,12 +216,13 @@ export function Popover({
|
|
|
213
216
|
tabIndex={-1}
|
|
214
217
|
onKeyDown={handleKeyDown}
|
|
215
218
|
style={{
|
|
216
|
-
// Merged over the caller's, never replaced by it: these
|
|
219
|
+
// Merged over the caller's, never replaced by it: these are measured
|
|
217
220
|
// against the trigger every scroll and resize, and a panel that dropped
|
|
218
221
|
// them would paint at the top-left of the viewport.
|
|
219
222
|
...style,
|
|
220
223
|
position: "fixed",
|
|
221
224
|
top: pos.top,
|
|
225
|
+
bottom: pos.bottom,
|
|
222
226
|
left: pos.left,
|
|
223
227
|
width: panelWidth,
|
|
224
228
|
maxHeight: placement.maxHeight,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ComponentPropsWithRef } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -14,7 +14,15 @@ export const SKELETON_CLASS = "animate-pulse rounded bg-[var(--bg-active)] motio
|
|
|
14
14
|
|
|
15
15
|
export type SkeletonShape = "line" | "block" | "circle";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A `div`'s props, `ref` included (0.33.1, keksdose): they were spread onto the
|
|
19
|
+
* placeholder all along, but `ComponentPropsWithoutRef` kept `ref` out of the type, so an
|
|
20
|
+
* app could not hold the element (to measure it, or scroll it into view) without a
|
|
21
|
+
* wrapper. The ref is the placeholder's — with `lines`, the box round the lines; with
|
|
22
|
+
* `label`, still the placeholder, not the `role="status"` region round it — where
|
|
23
|
+
* `className` goes too.
|
|
24
|
+
*/
|
|
25
|
+
export interface SkeletonProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
18
26
|
/** `line` (default): a bar the height of a line of small text. `block`: an area
|
|
19
27
|
* (an image, a chart, a card body). `circle`: an avatar. Size any of them with
|
|
20
28
|
* `className` (`w-32`, `h-40`, `size-8`). */
|
|
@@ -7,6 +7,8 @@ import { Button, EmptyState, Spinner, Textarea } from "../components/ui";
|
|
|
7
7
|
import type { CharacterCountLabels } from "../components/ui";
|
|
8
8
|
import { Skeleton } from "../components/skeleton";
|
|
9
9
|
import { Tooltip } from "../components/tooltip";
|
|
10
|
+
import { useWriteLock } from "../components/write-lock";
|
|
11
|
+
import type { CommitScope } from "../components/write-lock";
|
|
10
12
|
import { useKitFileLabels, useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
11
13
|
import { FeedbackAttachmentField } from "./feedback-attachment";
|
|
12
14
|
import type { FeedbackAttachmentFieldSingleProps } from "./feedback-attachment";
|
|
@@ -565,6 +567,18 @@ export interface FeedbackComposerProps {
|
|
|
565
567
|
/** Rendered INSTEAD of the composer — the thread is closed, or read-only for this
|
|
566
568
|
* reader. */
|
|
567
569
|
disabledReason?: ReactNode;
|
|
570
|
+
/**
|
|
571
|
+
* Send COMMITS (0.33.1, kastlan's assistant): under a locked {@link WriteLockProvider}
|
|
572
|
+
* Send is disabled the `disabledReason` way — focusable, the lock's reason in its
|
|
573
|
+
* tooltip and description, as on {@link Button}'s `commit` — and neither Enter nor
|
|
574
|
+
* Ctrl/⌘+Enter sends. The box stays editable and the draft stays: nothing in it
|
|
575
|
+
* reaches the server until Send, and the lock may lift. No provider, or an unlocked
|
|
576
|
+
* one: no effect. A {@link CommitScope}: kastlan's assistant answers under the demo
|
|
577
|
+
* and not under a lapsed plan (its backend's 402), so it passes
|
|
578
|
+
* `commit={{ except: ["demo"] }}` where it read `useWriteLock` and fed `canSend` and a
|
|
579
|
+
* reason by hand. Not the prop above: that one replaces the box, this one keeps it.
|
|
580
|
+
*/
|
|
581
|
+
commit?: CommitScope;
|
|
568
582
|
rows?: number;
|
|
569
583
|
/**
|
|
570
584
|
* The longest draft the box takes, in UTF-16 code units as the browser counts — the
|
|
@@ -671,6 +685,7 @@ export function FeedbackComposer({
|
|
|
671
685
|
canSend: canSendProp,
|
|
672
686
|
sendOn = "mod-enter",
|
|
673
687
|
disabledReason,
|
|
688
|
+
commit,
|
|
674
689
|
rows = 3,
|
|
675
690
|
maxLength,
|
|
676
691
|
showCount,
|
|
@@ -686,6 +701,9 @@ export function FeedbackComposer({
|
|
|
686
701
|
...rest
|
|
687
702
|
}: FeedbackComposerProps) {
|
|
688
703
|
const labels = useKitLabels("feedbackComposer", DEFAULT_FEEDBACK_COMPOSER_LABELS, labelsProp);
|
|
704
|
+
// The lock as Send sees it, with its scope (§12.36). Send's Button reads the same
|
|
705
|
+
// through its own `commit`; this holds the keyboard's send back with it.
|
|
706
|
+
const lock = useWriteLock(commit ?? false);
|
|
689
707
|
const rootAttributes = { id, ...dataAttributes(rest) };
|
|
690
708
|
const hintId = useId();
|
|
691
709
|
const [ownDraft, setOwnDraft] = useState("");
|
|
@@ -752,7 +770,7 @@ export function FeedbackComposer({
|
|
|
752
770
|
// Longer than `maxLength` only through `insertText` or a controlled value (typing
|
|
753
771
|
// stops at it): the server would refuse it, so it does not go.
|
|
754
772
|
const tooLong = maxLength !== undefined && draft.length > maxLength;
|
|
755
|
-
const canSend = (canSendProp ?? draft.trim().length > 0) && !pending && !tooLong;
|
|
773
|
+
const canSend = (canSendProp ?? draft.trim().length > 0) && !pending && !tooLong && !lock.locked;
|
|
756
774
|
const send = () => {
|
|
757
775
|
if (!canSend) return;
|
|
758
776
|
const sentDraft = draft;
|
|
@@ -844,6 +862,7 @@ export function FeedbackComposer({
|
|
|
844
862
|
<Button
|
|
845
863
|
onClick={send}
|
|
846
864
|
disabled={!canSend}
|
|
865
|
+
commit={commit}
|
|
847
866
|
aria-busy={pending || undefined}
|
|
848
867
|
size={size === "sm" ? "sm" : undefined}
|
|
849
868
|
>
|