@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.
Files changed (161) hide show
  1. package/README.md +3 -3
  2. package/dist/account/delete-account-setting.d.ts +2 -1
  3. package/dist/admin/admin-action-confirm.d.ts +2 -1
  4. package/dist/admin/invitations-panel.d.ts +2 -1
  5. package/dist/admin/plan-change.d.ts +2 -1
  6. package/dist/admin/plan-change.js +3 -1
  7. package/dist/admin/plan-change.js.map +1 -1
  8. package/dist/admin/reviewer-scope-editor.d.ts +2 -1
  9. package/dist/admin/role-select.d.ts +2 -1
  10. package/dist/admin/transfer-ownership-dialog.d.ts +2 -1
  11. package/dist/admin/user-roster.d.ts +2 -1
  12. package/dist/auth/register-form.d.ts +2 -1
  13. package/dist/billing/billing-banner.d.ts +2 -1
  14. package/dist/billing/billing-labels.d.ts +2 -1
  15. package/dist/billing/billing-lock.d.ts +2 -1
  16. package/dist/billing/billing-overview.d.ts +2 -1
  17. package/dist/billing/billing-standing.d.ts +2 -1
  18. package/dist/billing/checkout-processing.d.ts +2 -1
  19. package/dist/billing/pay-page.d.ts +11 -6
  20. package/dist/billing/pay-page.js.map +1 -1
  21. package/dist/billing/plan-card.d.ts +2 -1
  22. package/dist/billing/plan-limit-notice.d.ts +2 -1
  23. package/dist/billing/plan-limit-toast.d.ts +2 -1
  24. package/dist/billing/subscription-actions.d.ts +2 -1
  25. package/dist/billing/subscription-status-chip.d.ts +2 -1
  26. package/dist/chart.d.ts +2 -1
  27. package/dist/components/amount-input.d.ts +2 -1
  28. package/dist/components/autocomplete.js +2 -2
  29. package/dist/components/autocomplete.js.map +1 -1
  30. package/dist/components/button-group.d.ts +2 -1
  31. package/dist/components/calculator.d.ts +2 -1
  32. package/dist/components/combobox-core.js +2 -1
  33. package/dist/components/combobox-core.js.map +1 -1
  34. package/dist/components/combobox.js +2 -2
  35. package/dist/components/combobox.js.map +1 -1
  36. package/dist/components/confirm-dialog.d.ts +2 -1
  37. package/dist/components/copy-button.d.ts +2 -1
  38. package/dist/components/danger-confirm.d.ts +2 -1
  39. package/dist/components/dropdown.js +9 -1
  40. package/dist/components/dropdown.js.map +1 -1
  41. package/dist/components/facing-pair.d.ts +2 -1
  42. package/dist/components/file-button.d.ts +2 -1
  43. package/dist/components/file-dropzone.d.ts +2 -1
  44. package/dist/components/form-actions.d.ts +2 -1
  45. package/dist/components/full-bleed-dialog.js.map +1 -1
  46. package/dist/components/iban-input.d.ts +2 -1
  47. package/dist/components/language-select.d.ts +2 -1
  48. package/dist/components/legal-page.d.ts +2 -1
  49. package/dist/components/legal.d.ts +2 -1
  50. package/dist/components/modal.js.map +1 -1
  51. package/dist/components/money-field.d.ts +2 -1
  52. package/dist/components/number-field.d.ts +9 -1
  53. package/dist/components/number-field.js.map +1 -1
  54. package/dist/components/number-input.d.ts +29 -2
  55. package/dist/components/number-input.js +27 -12
  56. package/dist/components/number-input.js.map +1 -1
  57. package/dist/components/numpad-sheet.d.ts +2 -1
  58. package/dist/components/phone-input.d.ts +2 -1
  59. package/dist/components/popover.js +6 -2
  60. package/dist/components/popover.js.map +1 -1
  61. package/dist/components/row-actions.d.ts +2 -1
  62. package/dist/components/series-chart.d.ts +2 -1
  63. package/dist/components/settings-fields.d.ts +2 -1
  64. package/dist/components/share-card.d.ts +2 -1
  65. package/dist/components/skeleton.d.ts +10 -2
  66. package/dist/components/skeleton.js.map +1 -1
  67. package/dist/components/text-link.d.ts +2 -1
  68. package/dist/components/time-input.d.ts +2 -1
  69. package/dist/components/ui.d.ts +2 -1
  70. package/dist/{delete-account-setting-DiPX4Vue.d.ts → delete-account-setting-DAgGBrbn.d.ts} +44 -7
  71. package/dist/feedback/feedback-attachment.d.ts +2 -1
  72. package/dist/feedback/feedback-context.d.ts +2 -1
  73. package/dist/feedback/feedback-crash.d.ts +2 -1
  74. package/dist/feedback/feedback-dialog.d.ts +2 -1
  75. package/dist/feedback/feedback-inbox.d.ts +2 -1
  76. package/dist/feedback/feedback-labels.d.ts +2 -1
  77. package/dist/feedback/feedback-menu.d.ts +2 -1
  78. package/dist/feedback/feedback-record.d.ts +2 -1
  79. package/dist/feedback/feedback-row-detail.d.ts +2 -1
  80. package/dist/feedback/feedback-status-undo.d.ts +2 -1
  81. package/dist/feedback/feedback-submit.d.ts +2 -1
  82. package/dist/feedback/feedback-swipe.d.ts +2 -1
  83. package/dist/feedback/feedback-table.d.ts +2 -1
  84. package/dist/feedback/feedback-thread.d.ts +3 -2
  85. package/dist/feedback/feedback-thread.js +5 -1
  86. package/dist/feedback/feedback-thread.js.map +1 -1
  87. package/dist/feedback.d.ts +2 -1
  88. package/dist/hooks/use-anchored-panel.d.ts +37 -7
  89. package/dist/hooks/use-anchored-panel.js +6 -7
  90. package/dist/hooks/use-anchored-panel.js.map +1 -1
  91. package/dist/hooks/use-file-drop.d.ts +2 -1
  92. package/dist/hooks/use-focus-trap.d.ts +8 -0
  93. package/dist/hooks/use-focus-trap.js +17 -11
  94. package/dist/hooks/use-focus-trap.js.map +1 -1
  95. package/dist/i18n/defaults.d.ts +2 -1
  96. package/dist/i18n/german.d.ts +2 -1
  97. package/dist/i18n/german.js +4 -1
  98. package/dist/i18n/german.js.map +1 -1
  99. package/dist/i18n/kit-labels.d.ts +2 -1
  100. package/dist/i18n/languages.d.ts +2 -1
  101. package/dist/i18n/locales/de-CH.d.ts +2 -1
  102. package/dist/i18n/locales/en.d.ts +2 -1
  103. package/dist/i18n/locales/es.d.ts +2 -1
  104. package/dist/i18n/locales/es.js +2 -1
  105. package/dist/i18n/locales/es.js.map +1 -1
  106. package/dist/i18n/locales/fr.d.ts +2 -1
  107. package/dist/i18n/locales/fr.js +3 -1
  108. package/dist/i18n/locales/fr.js.map +1 -1
  109. package/dist/i18n/locales/hu.d.ts +2 -1
  110. package/dist/i18n/locales/hu.js +4 -2
  111. package/dist/i18n/locales/hu.js.map +1 -1
  112. package/dist/i18n/locales/it.d.ts +2 -1
  113. package/dist/i18n/locales/it.js +2 -1
  114. package/dist/i18n/locales/it.js.map +1 -1
  115. package/dist/i18n/locales/zh.d.ts +2 -1
  116. package/dist/i18n/locales/zh.js +1 -0
  117. package/dist/i18n/locales/zh.js.map +1 -1
  118. package/dist/i18n/plurals.d.ts +20 -1
  119. package/dist/i18n/plurals.js +11 -0
  120. package/dist/i18n/plurals.js.map +1 -1
  121. package/dist/i18n/review.d.ts +2 -1
  122. package/dist/index.d.ts +2 -2
  123. package/dist/index.js.map +1 -1
  124. package/dist/landing/landing-actions.d.ts +2 -1
  125. package/dist/landing/landing-sections.d.ts +2 -1
  126. package/dist/landing/public-header.d.ts +2 -1
  127. package/dist/landing/routing.d.ts +2 -1
  128. package/dist/rhf/fields.d.ts +2 -1
  129. package/dist/rhf/form.d.ts +2 -1
  130. package/dist/rhf.d.ts +2 -1
  131. package/dist/settings/settings-section.d.ts +2 -1
  132. package/dist/shell/app-shell.d.ts +2 -1
  133. package/dist/shell/top-bar-brand.d.ts +2 -1
  134. package/dist/shell.d.ts +2 -1
  135. package/dist/wizard/stepper-nav.d.ts +2 -1
  136. package/dist/wizard.d.ts +2 -1
  137. package/package.json +1 -1
  138. package/src/admin/plan-change.tsx +35 -7
  139. package/src/billing/pay-page.ts +9 -5
  140. package/src/components/autocomplete.tsx +5 -2
  141. package/src/components/combobox-core.tsx +5 -2
  142. package/src/components/combobox.tsx +4 -2
  143. package/src/components/dropdown.tsx +12 -2
  144. package/src/components/full-bleed-dialog.tsx +6 -0
  145. package/src/components/modal.tsx +5 -0
  146. package/src/components/number-field.tsx +7 -0
  147. package/src/components/number-input.tsx +71 -11
  148. package/src/components/popover.tsx +6 -2
  149. package/src/components/skeleton.tsx +10 -2
  150. package/src/feedback/feedback-thread.tsx +20 -1
  151. package/src/hooks/use-anchored-panel.ts +60 -14
  152. package/src/hooks/use-focus-trap.ts +55 -12
  153. package/src/i18n/german.ts +4 -1
  154. package/src/i18n/locales/es.ts +2 -1
  155. package/src/i18n/locales/fr.ts +3 -1
  156. package/src/i18n/locales/hu.ts +4 -2
  157. package/src/i18n/locales/it.ts +2 -1
  158. package/src/i18n/locales/zh.ts +1 -0
  159. package/src/i18n/plurals.ts +29 -0
  160. package/src/index.ts +3 -1
  161. 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
- /** The column's usage, spelled out (its tooltip): "1 of 5 budgets". Figures arrive
65
- * formatted; `dimension` is the app's word, as `usage` gives it. */
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
- /** The app's word for what is counted, as in "1 of 5 budgets". */
102
- label: string;
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. Undefined for a row, or a `null` limit: no line. */
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) => ({ used: u.budgets_owned, limit: u.limits.budgets, label: t("budgets") }),
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
  *
@@ -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, i18n.language));
90
+ * window.location.assign(payPageUrl(url, locale)); // locale = useKitLocale()
90
91
  *
91
- * A wrong `lang` costs nothing but the language: the page falls back to the browser's.
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
- style={{ top, left: rect.left, width: rect.width, maxHeight }}
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
- style={{ top, left: rect.left, width: rect.width }}
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 those five values are the panel's own.
345
- style={{ ...style, position: "fixed", top: anchored.top, left, width, maxHeight: anchored.maxHeight }}
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>) => {
@@ -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 error.
307
- const describedBy = mergeDescribedBy(ariaDescribedBy, textHint && hintId, hasError && errorId);
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
- const showCalc = calculator && !disabled && !isMobile;
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={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) => typed(e.target.value)}
482
+ onChange={(e) => {
483
+ if (!locked) typed(e.target.value);
484
+ }}
435
485
  onFocus={() => setFocused(true)}
436
486
  onBlur={() => {
437
- commit();
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
- if (!textHint && !hasError && !reserve) return field;
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
- {field}
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
- top: placement.top,
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 four are measured
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 { ComponentPropsWithoutRef } from "react";
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
- export interface SkeletonProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
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
  >