@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
@@ -21,6 +21,13 @@ import { useTextSize } from "../theme/text-size";
21
21
  export interface ViewportBox {
22
22
  top: number;
23
23
  height: number;
24
+ /**
25
+ * The height of the LAYOUT viewport itself, which `position: fixed`'s `bottom` is
26
+ * measured from. Only {@link anchoredPanelPlacement} reads it, for a flipped panel's
27
+ * `bottom`. Defaults to `top + height`: the two are the same box whenever no keyboard
28
+ * and no zoom shrink the visible region.
29
+ */
30
+ layoutHeight?: number;
24
31
  }
25
32
 
26
33
  /**
@@ -71,9 +78,21 @@ export function readKeyboardInset({ minimum = MIN_KEYBOARD_PX }: KeyboardInsetOp
71
78
  function readViewport(): ViewportBox {
72
79
  if (typeof window === "undefined") return { top: 0, height: 0 };
73
80
  const vv = window.visualViewport;
81
+ // The layout viewport's height is `innerHeight`, the reference `readKeyboardInset`
82
+ // measures the keyboard against. The keyboard leaves it alone (it shrinks only the
83
+ // VISUAL viewport; under `interactive-widget=resizes-content` it shrinks both, the
84
+ // fixed box with them), and it follows the mobile URL bar as it slides away, as a
85
+ // fixed box's `bottom` does. Not `documentElement.clientHeight`: on mobile Chrome and
86
+ // Safari that stays at the small, URL-bar-shown height, so with the bar scrolled away
87
+ // a flipped panel would sit that much too low, over its own field. `innerHeight`'s one
88
+ // error is a desktop page with a horizontal scrollbar, which it counts and the fixed
89
+ // box does not: the gap widens by the bar's thickness, and never closes.
90
+ const layoutHeight = window.innerHeight;
74
91
  // offsetTop, not 0: with the keyboard up the user can also pan the visual viewport
75
92
  // within the layout viewport, which shifts where "visible" starts.
76
- return vv ? { top: vv.offsetTop, height: vv.height } : { top: 0, height: window.innerHeight };
93
+ return vv
94
+ ? { top: vv.offsetTop, height: vv.height, layoutHeight }
95
+ : { top: 0, height: window.innerHeight, layoutHeight };
77
96
  }
78
97
 
79
98
  export interface AnchoredPanelOptions {
@@ -90,11 +109,24 @@ export interface AnchoredPanelOptions {
90
109
  minHeight?: number;
91
110
  }
92
111
 
112
+ /**
113
+ * `top` and `bottom` are the two edges of a panel exactly `maxHeight` tall. A panel is
114
+ * sized by its content and `maxHeight` only caps it, so apply the edge on the trigger's
115
+ * side, the one that holds whatever the height: `top` while the panel is below,
116
+ * `bottom` (and no `top`) once it is flipped `above`.
117
+ */
93
118
  export interface AnchoredPanel {
94
119
  /** The trigger's rect, or null while closed/unmeasured. */
95
120
  rect: AnchorRect | null;
96
- /** Viewport-space `top` for the panel. */
121
+ /** Viewport-space `top` for the panel — the one to apply while it is below. Flipped,
122
+ * it is where a FULL-height panel's top would be, and a three-row list placed by it
123
+ * floated far above its field (keksdose K1, live #401); place that one by `bottom`. */
97
124
  top: number;
125
+ /** CSS `bottom` for a `position: fixed` panel: the distance from the layout
126
+ * viewport's bottom edge up to the panel's. The one to apply once it is flipped
127
+ * `above`: it keeps the panel's foot `gap` above the trigger however short its
128
+ * content, and while a filtered list shrinks or grows under the user's typing. */
129
+ bottom: number;
98
130
  /** Cap that keeps the panel inside the VISIBLE viewport — apply as `maxHeight`. */
99
131
  maxHeight: number;
100
132
  /** Whether the panel was flipped above the trigger. */
@@ -122,6 +154,14 @@ const FLIP_GAIN = 48;
122
154
  * returned `maxHeight` is what makes the flip sufficient rather than merely different:
123
155
  * without it a tall panel placed above just runs off the top instead of the bottom.
124
156
  *
157
+ * A flipped panel hangs from its BOTTOM edge, `gap` above the trigger, and is placed by
158
+ * `bottom` (keksdose K1, live #401). It used to be placed by a `top` worked out as
159
+ * though the panel were `maxHeight` tall, and a panel is only as tall as its content:
160
+ * a three-row address list under a 256 px cap sat ~176 px above its field, on a phone
161
+ * with the keyboard up. Only when even the {@link FLOOR} does not fit above does that
162
+ * edge give way, as `top` always did, so the panel's head (a search box, often) stays
163
+ * on screen rather than above it.
164
+ *
125
165
  * Split out so the geometry is testable without a DOM and a fake keyboard — @hb/ui has
126
166
  * no test runner of its own, so this is exercised from the consuming app.
127
167
  */
@@ -140,19 +180,17 @@ export function anchoredPanelPlacement(
140
180
  (spaceBelow < preferredHeight && (spaceAbove >= preferredHeight || spaceAbove - spaceBelow >= FLIP_GAIN)));
141
181
  const space = Math.max(FLOOR, above ? spaceAbove : spaceBelow);
142
182
  const maxHeight = Math.min(preferredHeight, space);
143
- return {
144
- top: above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap,
145
- maxHeight,
146
- above,
147
- };
183
+ // Unchanged, for the apps that call the hook and apply it: the edge to use below, and
184
+ // a full-height panel's top above. In layout-viewport space, like the rect.
185
+ const top = above ? Math.max(viewTop, rect.top - gap - maxHeight) : rect.bottom + gap;
186
+ // `bottom` counts up from the LAYOUT viewport's bottom edge, not the visible region's:
187
+ // `position: fixed` is laid out in the layout viewport, which runs on behind the
188
+ // keyboard, exactly as the rect is measured in it. The keyboard, the pan and the zoom
189
+ // are already in `maxHeight`, which keeps the panel's top inside what is on screen.
190
+ const layoutHeight = viewport.layoutHeight ?? viewport.top + viewport.height;
191
+ return { top, bottom: layoutHeight - (top + maxHeight), maxHeight, above };
148
192
  }
149
193
 
150
- /**
151
- * Where to put a panel anchored under `ref`, tracking the visible viewport.
152
- *
153
- * Callers own horizontal placement — the pickers align to the trigger's left edge, the
154
- * popovers to its right — and must apply `maxHeight` with an internal `overflow-y`.
155
- */
156
194
  /**
157
195
  * The region actually on screen, tracked while `active` (Keksdose live #328).
158
196
  *
@@ -204,6 +242,14 @@ export function useVisualViewport(active: boolean): ViewportBox | null {
204
242
  return box;
205
243
  }
206
244
 
245
+ /**
246
+ * Where to put a panel anchored under `ref`, tracking the visible viewport.
247
+ *
248
+ * Callers own horizontal placement — the pickers align to the trigger's left edge, the
249
+ * popovers to its right — and must apply `maxHeight` with an internal `overflow-y`.
250
+ * Vertically, apply `top` while the panel is below and `bottom` (with no `top`) once it
251
+ * is `above`; see {@link AnchoredPanel}.
252
+ */
207
253
  export function useAnchoredPanel<T extends HTMLElement>(
208
254
  ref: RefObject<T | null>,
209
255
  open: boolean,
@@ -236,7 +282,7 @@ export function useAnchoredPanel<T extends HTMLElement>(
236
282
  }, [open]);
237
283
 
238
284
  if (!rect) {
239
- return { rect: null, top: 0, maxHeight: preferredHeight, above: false };
285
+ return { rect: null, top: 0, bottom: 0, maxHeight: preferredHeight, above: false };
240
286
  }
241
287
  return { rect, ...anchoredPanelPlacement(rect, viewport, { ...options, preferredHeight, minHeight }) };
242
288
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
 
3
3
  /**
4
4
  * Keep keyboard focus inside an open overlay, and give it back when it closes.
@@ -23,6 +23,10 @@ import { useEffect, useRef } from "react";
23
23
  * `<body>`, which for a keyboard user means losing their place in the page
24
24
  * entirely. When the target is gone, focus falls back to the nearest thing that
25
25
  * still exists rather than to nothing.
26
+ * - **Leaves an app's own autofocus alone** (0.33.1). React honours a child's
27
+ * `autoFocus` while it commits, and this hook's effect runs AFTER that commit, so
28
+ * focusing the container there took focus straight back off the field the app had
29
+ * asked for. See `initialFocus`.
26
30
  * - **Nests.** An overlay can open another — a picker inside a dialog. Only the
27
31
  * innermost trap handles a keystroke, and closing it hands control back to the one
28
32
  * underneath. Innermost is decided by DOM containment rather than by mount order;
@@ -66,29 +70,61 @@ export interface FocusTrapOptions {
66
70
  * what `Modal` does and why: focusing the first FIELD pops the software keyboard on
67
71
  * a phone the moment a dialog opens, before the user has asked to type.
68
72
  * The container needs `tabIndex={-1}` for this to work.
73
+ *
74
+ * `"container"` and `"first"` both step aside when focus is ALREADY inside the
75
+ * container, on something other than the container itself. That is a child the app
76
+ * focused on purpose — an `autoFocus`, which React honours before this hook's effect
77
+ * runs — and the keyboard it brings up is the one the user was promised. Moving focus
78
+ * off it lost keksdose's create card its amount field the instant it got it, and on a
79
+ * phone the numpad sheet with it, which shows only while the field is focused (live
80
+ * #402). A function is the caller's explicit choice and is always followed.
69
81
  */
70
82
  initialFocus?: "container" | "first" | (() => HTMLElement | null);
71
83
  }
72
84
 
85
+ /** Whatever has focus right now; nothing on the server, where there is no document. */
86
+ function focusedElement(): Element | null {
87
+ return typeof document === "undefined" ? null : document.activeElement;
88
+ }
89
+
73
90
  export function useFocusTrap(
74
91
  containerRef: React.RefObject<HTMLElement | null>,
75
92
  { active = true, restoreFocus = true, initialFocus = "container" }: FocusTrapOptions = {},
76
93
  ): void {
77
94
  // Latest-ref so a caller passing an inline `initialFocus` thunk — which every caller
78
95
  // will — does not tear the trap down and re-engage it on each render, stealing focus
79
- // back to the top of the panel mid-interaction.
80
- const optsRef = useRef({ restoreFocus, initialFocus });
81
- useEffect(() => {
82
- optsRef.current = { restoreFocus, initialFocus };
96
+ // back to the top of the panel mid-interaction. Written in a LAYOUT effect so that
97
+ // the trap's cleanup, which runs after the layout effects of the same commit, reads
98
+ // this render's `active` rather than the last one's — see the cleanup for why.
99
+ const optsRef = useRef({ active, restoreFocus, initialFocus });
100
+ useLayoutEffect(() => {
101
+ optsRef.current = { active, restoreFocus, initialFocus };
83
102
  });
84
103
 
104
+ // Where focus was BEFORE the trap engaged: the element it is given back to on close.
105
+ //
106
+ // Read DURING the render that turns the trap on, and that timing is the point. React
107
+ // honours a child's `autoFocus` while it commits that render, before any effect runs,
108
+ // so the effect below asking `document.activeElement` would get the child, inside the
109
+ // overlay. Restoring to that on close aimed focus at the dialog that was closing —
110
+ // already out of the DOM, so focus fell to <body> rather than going back to the
111
+ // button that opened it.
112
+ //
113
+ // State adjusted during render (the pattern `Modal` uses for `open`) rather than a
114
+ // ref written there: set once per opening and cleared on release, so a re-render
115
+ // while open cannot replace it with something inside, and StrictMode's second render
116
+ // and second effect both see the same element. `undefined` means "not captured";
117
+ // `null` is a capture that found nothing.
118
+ const [opener, setOpener] = useState<Element | null | undefined>(undefined);
119
+ if (active && opener === undefined) setOpener(focusedElement());
120
+ else if (!active && opener !== undefined) setOpener(undefined);
121
+
85
122
  useEffect(() => {
86
123
  if (!active) return;
87
124
  const container = containerRef.current;
88
125
  if (!container) return;
89
126
 
90
127
  container.setAttribute(TRAP_ATTR, "");
91
- const previouslyFocused = document.activeElement as HTMLElement | null;
92
128
 
93
129
  /** Is `node` inside a trap nested within this one? */
94
130
  const inNestedTrap = (node: Element | null) => {
@@ -106,12 +142,13 @@ export function useFocusTrap(
106
142
  );
107
143
 
108
144
  const { initialFocus: how } = optsRef.current;
145
+ const focused = document.activeElement;
146
+ // A child that already has focus was given it on purpose — see `initialFocus`.
147
+ const childHasFocus = focused !== container && container.contains(focused);
109
148
  if (typeof how === "function") {
110
149
  (how() ?? container).focus();
111
- } else if (how === "first") {
112
- (tabbables()[0] ?? container).focus();
113
- } else {
114
- container.focus();
150
+ } else if (!childHasFocus) {
151
+ (how === "first" ? (tabbables()[0] ?? container) : container).focus();
115
152
  }
116
153
 
117
154
  const onKeyDown = (e: KeyboardEvent) => {
@@ -149,12 +186,18 @@ export function useFocusTrap(
149
186
  container.removeEventListener("keydown", onKeyDown);
150
187
  container.removeAttribute(TRAP_ATTR);
151
188
 
189
+ // Only when the trap is really being released: the container has left the page,
190
+ // or `active` has turned false. StrictMode runs this cleanup once more right after
191
+ // mounting, with the overlay still up, and then the effect again — and React does
192
+ // not re-apply `autoFocus` for that second run. Restoring here would pull focus
193
+ // out of the autofocused field for good: live #402 again, in development.
194
+ if (container.isConnected && optsRef.current.active) return;
152
195
  if (!optsRef.current.restoreFocus) return;
153
196
  // `isConnected` is the whole point: restoring to a node that has been removed
154
197
  // sends focus to <body> and the user loses their place entirely. When the
155
198
  // original is gone, leave focus where the browser put it rather than moving it
156
199
  // somewhere arbitrary — a wrong guess is worse than no move.
157
- if (previouslyFocused?.isConnected) previouslyFocused.focus?.();
200
+ if (opener?.isConnected) (opener as HTMLElement).focus?.();
158
201
  };
159
- }, [active, containerRef]);
202
+ }, [active, containerRef, opener]);
160
203
  }
@@ -1573,7 +1573,10 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1573
1573
  keptBeta: "Die Beta behält ihr Ende; nur der Tarif wurde geändert.",
1574
1574
  untilDone: (date) => `Kostenlos bis ${date}.`,
1575
1575
  overLimit: "Über dem Limit des neuen Tarifs: Nichts wird entfernt, neue Einträge sind gesperrt.",
1576
- usageOf: (used, limit, dimension) => `${used} von ${limit} ${dimension}`,
1576
+ // 0.33.1: the app's word heads the line. After "von" it would want the dative
1577
+ // plural ("von 5 Objekten"), which no app's `one`/`other` form carries; as a
1578
+ // heading it is the nominative, in the form for the limit ("Objekt: 1 von 1").
1579
+ usageOf: (used, limit, dimension) => `${dimension}: ${used} von ${limit}`,
1577
1580
  },
1578
1581
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
1579
1582
  // the "Darstellung" group.
@@ -1524,7 +1524,8 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
1524
1524
  keptBeta: "La beta conserva su fin; solo ha cambiado el plan.",
1525
1525
  untilDone: (date) => `Gratis hasta el ${date}.`,
1526
1526
  overLimit: "Por encima del límite del nuevo plan: no se elimina nada, los elementos nuevos quedan bloqueados.",
1527
- // Figures formatted; "de", as `billing.usage`.
1527
+ // Figures formatted; "de", as `billing.usage`. The app's word follows the limit,
1528
+ // in its form (0.33.1): "1 de 1 presupuesto", "1 de 5 presupuestos".
1528
1529
  usageOf: (used, limit, dimension) => `${used} de ${limit} ${dimension}`,
1529
1530
  },
1530
1531
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
@@ -1561,7 +1561,9 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
1561
1561
  untilDone: (date) => `Gratuit jusqu’au ${date}.`,
1562
1562
  overLimit:
1563
1563
  "Au-delà de la limite de la nouvelle formule\u00a0: rien n’est supprimé, les nouveaux éléments sont bloqués.",
1564
- // Figures formatted; "sur", as `billing.usage`.
1564
+ // Figures formatted; "sur", as `billing.usage`. The app's word follows the limit,
1565
+ // in its form (0.33.1): "1 sur 1 budget", "1 sur 5 budgets" — and French reads 0
1566
+ // as singular, which the forms' `one` covers.
1565
1567
  usageOf: (used, limit, dimension) => `${used} sur ${limit} ${dimension}`,
1566
1568
  },
1567
1569
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
@@ -1531,8 +1531,10 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
1531
1531
  keptBeta: "A béta megtartja a lejáratát; csak a csomag változott.",
1532
1532
  untilDone: (date) => `Ingyenes eddig: ${date}.`,
1533
1533
  overLimit: "Az új csomag korlátja felett: semmi sem törlődik, de újak nem hozhatók létre.",
1534
- // The figures bare, as `billing.usage` (convention 3).
1535
- usageOf: (used, limit, dimension) => `${dimension}: ${used} / ${limit}`,
1534
+ // The figures bare, as `billing.usage` (convention 3), and the app's word after
1535
+ // them, as `dataTable.filterResults` ("1 / 5 költségvetés"): since 0.33.1 it is the
1536
+ // word as it reads after a number, which stays singular here (convention 2).
1537
+ usageOf: (used, limit, dimension) => `${used} / ${limit} ${dimension}`,
1536
1538
  },
1537
1539
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
1538
1540
  // the "Megjelenés" group.
@@ -1541,7 +1541,8 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
1541
1541
  keptBeta: "La beta mantiene la sua scadenza; è cambiato solo il piano.",
1542
1542
  untilDone: (date) => `Gratuito fino al ${date}.`,
1543
1543
  overLimit: "Oltre il limite del nuovo piano: non viene rimosso nulla, i nuovi elementi sono bloccati.",
1544
- // Figures formatted; "di", as `billing.usage`.
1544
+ // Figures formatted; "di", as `billing.usage`. The app's word follows the limit,
1545
+ // in its form (0.33.1): "1 di 1 budget", "1 di 5 budget".
1545
1546
  usageOf: (used, limit, dimension) => `${used} di ${limit} ${dimension}`,
1546
1547
  },
1547
1548
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
@@ -1464,6 +1464,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1464
1464
  keptBeta: "测试版保留其结束日期;只更改了套餐。",
1465
1465
  untilDone: (date) => `免费至 ${date}。`,
1466
1466
  overLimit: "超出新套餐的上限:不会删除任何内容,但无法新建。",
1467
+ // No plural: the app's word reads the same after any number (0.33.1).
1467
1468
  usageOf: (used, limit, dimension) => `${used}/${limit} ${dimension}`,
1468
1469
  },
1469
1470
  // 0.32.0 — docs/text-size-harmonization.md §6: the text-size and contrast settings in
@@ -52,3 +52,32 @@ function pluralCategories(locale: string, type: Intl.PluralRuleType): readonly s
52
52
  return [];
53
53
  }
54
54
  }
55
+
56
+ /**
57
+ * A word in the plural forms a count can ask for, `other` required: an app's
58
+ * `key_one` / `key_other` pair, or any of CLDR's categories (`zero`, `one`, `two`, `few`,
59
+ * `many`). A category left out reads `other`, the stand-in {@link withAllPlurals} fills
60
+ * a catalogue with — so English's two forms serve every language, as in the apps'
61
+ * catalogues.
62
+ *
63
+ * { one: t("planUsage.budget_one"), other: t("planUsage.budget_other") }
64
+ */
65
+ export type PluralForms = { other: string } & Partial<Record<Intl.LDMLPluralRule, string>>;
66
+
67
+ /**
68
+ * The form of `forms` that `count` takes in `locale` — CLDR's cardinal category for it,
69
+ * through `Intl.PluralRules`, else `other`. A locale `Intl` cannot read reads as the
70
+ * runtime's, as everywhere the kit formats (and `undefined` is the runtime's). For the
71
+ * kit's own parts that put an app's word after a count: `planColumn`'s usage.
72
+ */
73
+ export function pluralForm(count: number, forms: PluralForms, locale?: string): string {
74
+ return forms[pluralCategory(count, locale)] ?? forms.other;
75
+ }
76
+
77
+ function pluralCategory(count: number, locale: string | undefined): Intl.LDMLPluralRule {
78
+ try {
79
+ return new Intl.PluralRules(locale).select(count);
80
+ } catch {
81
+ return new Intl.PluralRules().select(count);
82
+ }
83
+ }
package/src/index.ts CHANGED
@@ -402,8 +402,10 @@ export { kitLabelStrings } from "./i18n/review";
402
402
  export type { KitLabelStringsOptions } from "./i18n/review";
403
403
  // The seven languages, how an app resolves one, and the kit catalogue loader for it.
404
404
  export * from "./i18n/languages";
405
- // CLDR plural categories for an app's i18next catalogue, filled from `_other`.
405
+ // CLDR plural categories for an app's i18next catalogue, filled from `_other`; and the
406
+ // forms of an app's word a kit part picks from by count (0.33.1, `PlanColumnUsage.label`).
406
407
  export { withAllPlurals } from "./i18n/plurals";
408
+ export type { PluralForms } from "./i18n/plurals";
407
409
  // Translation review (0.19): one contract for kastlan's and keksdose's review pages —
408
410
  // the pure rows/status/placeholder logic and the presentational page parts.
409
411
  export {
package/tokens.css CHANGED
@@ -192,6 +192,24 @@ html[data-text-size="xlarge"] {
192
192
  }
193
193
  }
194
194
 
195
+ /* The same for FIGURES — an amount, a date, a count with its unit (0.33.1, keksdose):
196
+ `truncate` at Normal, and from Large up a wrap at spaces only (`overflow-wrap: normal`).
197
+ `truncate-until-large` breaks anywhere, so at Large it split an amount mid-number
198
+ ("CHF 1’2" over "34.50"), which reads as two numbers. Here "CHF" and "1’234.50" may
199
+ take two lines, but a number never splits: one wider than its box is cut at its end
200
+ instead, so give a figure's cell the room for it (`shrink-0`). Prose — a name, a
201
+ title, a sentence — keeps `truncate-until-large`, where breaking a long word is what
202
+ keeps the line on the screen. */
203
+ @utility truncate-until-large-words {
204
+ overflow: hidden;
205
+ text-overflow: ellipsis;
206
+ white-space: nowrap;
207
+ @variant large {
208
+ white-space: normal;
209
+ overflow-wrap: normal;
210
+ }
211
+ }
212
+
195
213
  :root {
196
214
  --bg-page: #ede3cf; /* warm dim sand — low-glare light page */
197
215
  --bg-surface: #f6ecd9; /* warm off-white card/panel (not pure white) */