@eifi1/ui-kit 0.32.0 → 0.32.2

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 (216) hide show
  1. package/dist/account/delete-account-setting.d.ts +1 -1
  2. package/dist/admin/admin-action-confirm.d.ts +1 -1
  3. package/dist/admin/admin-action-log.d.ts +1 -1
  4. package/dist/admin/admin-action-log.js +4 -2
  5. package/dist/admin/admin-action-log.js.map +1 -1
  6. package/dist/admin/invitations-panel.d.ts +1 -1
  7. package/dist/admin/reviewer-scope-editor.d.ts +1 -1
  8. package/dist/admin/role-select.d.ts +1 -1
  9. package/dist/admin/transfer-ownership-dialog.d.ts +1 -1
  10. package/dist/admin/user-roster.d.ts +1 -1
  11. package/dist/auth/register-form.d.ts +1 -1
  12. package/dist/billing/billing-banner.d.ts +1 -1
  13. package/dist/billing/billing-labels.d.ts +1 -1
  14. package/dist/billing/billing-labels.js +3 -1
  15. package/dist/billing/billing-labels.js.map +1 -1
  16. package/dist/billing/billing-lock.d.ts +1 -1
  17. package/dist/billing/billing-standing.d.ts +1 -1
  18. package/dist/billing/plan-card.d.ts +1 -1
  19. package/dist/billing/plan-card.js +1 -1
  20. package/dist/billing/plan-card.js.map +1 -1
  21. package/dist/billing/plan-limit-notice.d.ts +1 -1
  22. package/dist/billing/subscription-actions.d.ts +1 -1
  23. package/dist/billing/subscription-status-chip.d.ts +1 -1
  24. package/dist/chart.d.ts +2 -2
  25. package/dist/chart.js.map +1 -1
  26. package/dist/components/alert-banner.js +1 -1
  27. package/dist/components/alert-banner.js.map +1 -1
  28. package/dist/components/amount-input.d.ts +1 -1
  29. package/dist/components/appearance-labels.d.ts +6 -0
  30. package/dist/components/appearance-labels.js +2 -1
  31. package/dist/components/appearance-labels.js.map +1 -1
  32. package/dist/components/button-group.d.ts +1 -1
  33. package/dist/components/button-group.js +7 -1
  34. package/dist/components/button-group.js.map +1 -1
  35. package/dist/components/calculator.d.ts +1 -1
  36. package/dist/components/chip.js +7 -2
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/choice-card.js +1 -1
  39. package/dist/components/choice-card.js.map +1 -1
  40. package/dist/components/confirm-dialog.d.ts +1 -1
  41. package/dist/components/copy-button.d.ts +1 -1
  42. package/dist/components/danger-confirm.d.ts +1 -1
  43. package/dist/components/data-table-pagination.d.ts +2 -1
  44. package/dist/components/data-table-pagination.js +15 -6
  45. package/dist/components/data-table-pagination.js.map +1 -1
  46. package/dist/components/data-table.js +2 -2
  47. package/dist/components/data-table.js.map +1 -1
  48. package/dist/components/date-picker.js +11 -1
  49. package/dist/components/date-picker.js.map +1 -1
  50. package/dist/components/facing-pair.d.ts +1 -1
  51. package/dist/components/file-button.d.ts +1 -1
  52. package/dist/components/file-dropzone.d.ts +1 -1
  53. package/dist/components/form-actions.d.ts +1 -1
  54. package/dist/components/iban-input.d.ts +1 -1
  55. package/dist/components/inline-edit-field.js +4 -1
  56. package/dist/components/inline-edit-field.js.map +1 -1
  57. package/dist/components/language-select.d.ts +1 -1
  58. package/dist/components/legal-page.d.ts +1 -1
  59. package/dist/components/legal.d.ts +1 -1
  60. package/dist/components/money-field.d.ts +1 -1
  61. package/dist/components/month-picker.js +41 -34
  62. package/dist/components/month-picker.js.map +1 -1
  63. package/dist/components/number-field.d.ts +1 -1
  64. package/dist/components/number-input.d.ts +1 -1
  65. package/dist/components/numpad-sheet.d.ts +1 -1
  66. package/dist/components/page-header.js +6 -1
  67. package/dist/components/page-header.js.map +1 -1
  68. package/dist/components/phone-input.d.ts +1 -1
  69. package/dist/components/phone-input.js +2 -2
  70. package/dist/components/phone-input.js.map +1 -1
  71. package/dist/components/popover.d.ts +1 -1
  72. package/dist/components/popover.js +2 -0
  73. package/dist/components/popover.js.map +1 -1
  74. package/dist/components/row-actions.d.ts +1 -1
  75. package/dist/components/row-actions.js +97 -37
  76. package/dist/components/row-actions.js.map +1 -1
  77. package/dist/components/series-chart.d.ts +1 -1
  78. package/dist/components/settings-fields.d.ts +7 -3
  79. package/dist/components/settings-fields.js +2 -1
  80. package/dist/components/settings-fields.js.map +1 -1
  81. package/dist/components/share-card.d.ts +1 -1
  82. package/dist/components/share-card.js +6 -2
  83. package/dist/components/share-card.js.map +1 -1
  84. package/dist/components/status-dot.js +9 -2
  85. package/dist/components/status-dot.js.map +1 -1
  86. package/dist/components/swipeable-row.js +8 -1
  87. package/dist/components/swipeable-row.js.map +1 -1
  88. package/dist/components/text-link.d.ts +1 -1
  89. package/dist/components/time-input.d.ts +1 -1
  90. package/dist/components/toggle-group.d.ts +9 -2
  91. package/dist/components/toggle-group.js +3 -3
  92. package/dist/components/toggle-group.js.map +1 -1
  93. package/dist/components/translation-review-editor.js +1 -1
  94. package/dist/components/translation-review-editor.js.map +1 -1
  95. package/dist/components/translation-review.js +2 -2
  96. package/dist/components/translation-review.js.map +1 -1
  97. package/dist/components/ui.d.ts +1 -1
  98. package/dist/components/ui.js +6 -6
  99. package/dist/components/ui.js.map +1 -1
  100. package/dist/components/wizard-stepper.js +20 -14
  101. package/dist/components/wizard-stepper.js.map +1 -1
  102. package/dist/{delete-account-setting-BvdQdbv3.d.ts → delete-account-setting-Dy4kJgUm.d.ts} +53 -14
  103. package/dist/feedback/feedback-attachment.d.ts +1 -1
  104. package/dist/feedback/feedback-context.d.ts +1 -1
  105. package/dist/feedback/feedback-crash.d.ts +1 -1
  106. package/dist/feedback/feedback-dialog.d.ts +1 -1
  107. package/dist/feedback/feedback-inbox.d.ts +1 -1
  108. package/dist/feedback/feedback-labels.d.ts +1 -1
  109. package/dist/feedback/feedback-menu.d.ts +1 -1
  110. package/dist/feedback/feedback-record.d.ts +1 -1
  111. package/dist/feedback/feedback-row-detail.d.ts +1 -1
  112. package/dist/feedback/feedback-status-undo.d.ts +1 -1
  113. package/dist/feedback/feedback-submit.d.ts +1 -1
  114. package/dist/feedback/feedback-swipe.d.ts +1 -1
  115. package/dist/feedback/feedback-table.d.ts +1 -1
  116. package/dist/feedback/feedback-thread.d.ts +1 -1
  117. package/dist/feedback.d.ts +1 -1
  118. package/dist/hooks/use-breakpoint.d.ts +19 -8
  119. package/dist/hooks/use-breakpoint.js +1 -1
  120. package/dist/hooks/use-breakpoint.js.map +1 -1
  121. package/dist/hooks/use-file-drop.d.ts +1 -1
  122. package/dist/i18n/defaults.d.ts +1 -1
  123. package/dist/i18n/german.d.ts +1 -1
  124. package/dist/i18n/german.js +7 -3
  125. package/dist/i18n/german.js.map +1 -1
  126. package/dist/i18n/kit-labels.d.ts +1 -1
  127. package/dist/i18n/languages.d.ts +1 -1
  128. package/dist/i18n/locales/de-CH.d.ts +1 -1
  129. package/dist/i18n/locales/en.d.ts +1 -1
  130. package/dist/i18n/locales/es.d.ts +1 -1
  131. package/dist/i18n/locales/es.js +7 -3
  132. package/dist/i18n/locales/es.js.map +1 -1
  133. package/dist/i18n/locales/fr.d.ts +1 -1
  134. package/dist/i18n/locales/fr.js +7 -3
  135. package/dist/i18n/locales/fr.js.map +1 -1
  136. package/dist/i18n/locales/hu.d.ts +1 -1
  137. package/dist/i18n/locales/hu.js +7 -3
  138. package/dist/i18n/locales/hu.js.map +1 -1
  139. package/dist/i18n/locales/it.d.ts +1 -1
  140. package/dist/i18n/locales/it.js +7 -3
  141. package/dist/i18n/locales/it.js.map +1 -1
  142. package/dist/i18n/locales/zh.d.ts +1 -1
  143. package/dist/i18n/locales/zh.js +7 -3
  144. package/dist/i18n/locales/zh.js.map +1 -1
  145. package/dist/i18n/review.d.ts +1 -1
  146. package/dist/index.d.ts +3 -3
  147. package/dist/index.js +3 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/landing/landing-actions.d.ts +1 -1
  150. package/dist/landing/landing-sections.d.ts +1 -1
  151. package/dist/landing/landing-sections.js +7 -3
  152. package/dist/landing/landing-sections.js.map +1 -1
  153. package/dist/landing/public-header.d.ts +1 -1
  154. package/dist/landing/routing.d.ts +1 -1
  155. package/dist/rhf/fields.d.ts +1 -1
  156. package/dist/rhf/form.d.ts +1 -1
  157. package/dist/rhf.d.ts +1 -1
  158. package/dist/settings/settings-section.d.ts +1 -1
  159. package/dist/settings/use-account-appearance.d.ts +1 -1
  160. package/dist/settings/use-account-appearance.js.map +1 -1
  161. package/dist/shell/app-shell.d.ts +1 -1
  162. package/dist/shell/app-shell.js +2 -1
  163. package/dist/shell/app-shell.js.map +1 -1
  164. package/dist/shell/top-bar-brand.d.ts +1 -1
  165. package/dist/shell.d.ts +1 -1
  166. package/dist/theme/contrast.d.ts +3 -2
  167. package/dist/theme/contrast.js.map +1 -1
  168. package/dist/theme/text-size.d.ts +19 -3
  169. package/dist/theme/text-size.js.map +1 -1
  170. package/dist/wizard/stepper-nav.d.ts +1 -1
  171. package/dist/wizard/wizard-summary.js +1 -1
  172. package/dist/wizard/wizard-summary.js.map +1 -1
  173. package/dist/wizard.d.ts +1 -1
  174. package/package.json +1 -1
  175. package/src/admin/admin-action-log.tsx +6 -1
  176. package/src/billing/billing-labels.ts +16 -0
  177. package/src/billing/plan-card.tsx +1 -1
  178. package/src/chart.ts +2 -0
  179. package/src/components/alert-banner.tsx +5 -1
  180. package/src/components/appearance-labels.ts +7 -0
  181. package/src/components/button-group.tsx +21 -0
  182. package/src/components/chip.tsx +34 -6
  183. package/src/components/choice-card.tsx +1 -1
  184. package/src/components/data-table-pagination.tsx +60 -31
  185. package/src/components/data-table.tsx +8 -4
  186. package/src/components/date-picker.tsx +32 -1
  187. package/src/components/inline-edit-field.tsx +20 -1
  188. package/src/components/month-picker.tsx +6 -1
  189. package/src/components/page-header.tsx +15 -1
  190. package/src/components/phone-input.tsx +7 -2
  191. package/src/components/popover.tsx +4 -0
  192. package/src/components/row-actions.tsx +156 -49
  193. package/src/components/settings-fields.tsx +8 -3
  194. package/src/components/share-card.tsx +21 -2
  195. package/src/components/status-dot.tsx +25 -2
  196. package/src/components/swipeable-row.tsx +8 -1
  197. package/src/components/toggle-group.tsx +26 -12
  198. package/src/components/translation-review-editor.tsx +1 -1
  199. package/src/components/translation-review.tsx +5 -4
  200. package/src/components/ui.tsx +28 -9
  201. package/src/components/wizard-stepper.tsx +5 -1
  202. package/src/hooks/use-breakpoint.ts +20 -9
  203. package/src/i18n/german.ts +4 -0
  204. package/src/i18n/locales/es.ts +4 -0
  205. package/src/i18n/locales/fr.ts +4 -0
  206. package/src/i18n/locales/hu.ts +4 -0
  207. package/src/i18n/locales/it.ts +4 -0
  208. package/src/i18n/locales/zh.ts +4 -0
  209. package/src/index.ts +4 -2
  210. package/src/landing/landing-sections.tsx +7 -3
  211. package/src/settings/use-account-appearance.ts +1 -1
  212. package/src/shell/app-shell.tsx +11 -6
  213. package/src/theme/contrast.ts +3 -2
  214. package/src/theme/text-size.ts +19 -3
  215. package/src/wizard/wizard-summary.tsx +4 -2
  216. package/tokens.css +15 -7
@@ -242,7 +242,12 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function
242
242
  role={named ? "group" : undefined}
243
243
  aria-label={named ? label : undefined}
244
244
  >
245
- <div className="flex items-stretch gap-2">
245
+ {/* On a phone at Large the number takes a line of its own under the country (0.32.1,
246
+ the 360 px Extra-large sweep; docs/text-size-harmonization.md §4 "nothing
247
+ truncates"): the 7rem select is 168 px at 150 %, and on a 360 px phone it left
248
+ the number 82 px — "Ph…" over "021 (". `large:max-sm:`, the scaled phone width,
249
+ so a wider screen at Large and Normal keep the one row. */}
250
+ <div className="flex items-stretch gap-2 large:max-sm:flex-wrap">
246
251
  <Select
247
252
  aria-label={text.countryCode}
248
253
  value={country}
@@ -261,7 +266,7 @@ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function
261
266
  <option value="other">{text.other}</option>
262
267
  </Select>
263
268
  {/* Its own flex item, so nothing Input wraps itself in can take the sizing. */}
264
- <div className="min-w-0 flex-1">
269
+ <div className="min-w-0 flex-1 large:max-sm:basis-full">
265
270
  <Input
266
271
  ref={ref}
267
272
  {...rest}
@@ -110,6 +110,7 @@ export function Popover({
110
110
  className,
111
111
  style,
112
112
  "aria-label": ariaLabel,
113
+ onKeyDown: onKeyDownProp,
113
114
  ...rest
114
115
  }: PopoverProps) {
115
116
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -169,6 +170,9 @@ export function Popover({
169
170
  });
170
171
 
171
172
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
173
+ // A caller's own handler first (0.32.2: RowActions stops Enter / Space at the panel);
174
+ // it used to be dropped, overwritten by this one.
175
+ onKeyDownProp?.(e);
172
176
  if (e.key !== "Escape") return;
173
177
  // `useEscapeKey` above listens on the document and still covers the case where
174
178
  // focus somehow is not in here. This handler is for the case that focus IS, which
@@ -1,5 +1,5 @@
1
1
  import { isValidElement } from "react";
2
- import type { ReactElement, ReactNode } from "react";
2
+ import type { KeyboardEvent, MouseEvent, ReactElement, ReactNode } from "react";
3
3
  import { MoreHorizontal } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
@@ -7,6 +7,7 @@ import { useLargeText } from "../hooks/use-large-text";
7
7
  import { useKitLabels } from "../i18n/kit-labels";
8
8
  import { Popover } from "./popover";
9
9
  import { Button, CompactControls, IconButton } from "./ui";
10
+ import { useCommitReason } from "./write-lock";
10
11
  import type { IconButtonSize } from "./ui";
11
12
  import type { DataTableColumn } from "./data-table";
12
13
 
@@ -31,8 +32,25 @@ export interface RowAction {
31
32
  key?: string;
32
33
  /** The action's name: the inline icon's name and tooltip, the menu entry's words. */
33
34
  label: string;
34
- /** Runs it. The kit sends nothing — the app opens its confirmation or calls its API. */
35
- onSelect: () => void;
35
+ /**
36
+ * Runs it. The kit sends nothing — the app opens its confirmation or calls its API.
37
+ * Optional only beside `href`; an action has one or the other (or both: `onSelect`
38
+ * then runs as the link is followed).
39
+ */
40
+ onSelect?: () => void;
41
+ /**
42
+ * The action NAVIGATES — an edit page, a mail to compose (0.32.1, kastlan's 0.32
43
+ * report): a real link inline and in the menu, so open in a new tab, a middle click
44
+ * and the URL on hover work, which `onSelect` + `navigate` lost. The kit's link rule
45
+ * applies ({@link Button}'s `href`): in-app through the provider's `linkComponent`, an
46
+ * external or `#anchor` one a plain `<a>`, `mailto:` included.
47
+ *
48
+ * A link cannot carry a reason or a spinner, so an action with a `disabledReason`
49
+ * (or under a write lock, with `commit`) or `pending` stays a button and says why.
50
+ */
51
+ href?: string;
52
+ /** With `href`: leaves the app — a new tab, and a screen reader is told so. */
53
+ external?: boolean;
36
54
  /**
37
55
  * The glyph: a Lucide icon (`Pencil`), or an element (`<Pencil />`, an app's own
38
56
  * svg) — DataTable's `rowActions` hand their `icon` node through. Inline, an action
@@ -104,8 +122,8 @@ export type RowActionsSize = Extract<IconButtonSize, "md" | "sm" | "xs" | "2xs">
104
122
  export interface RowActionsProps {
105
123
  actions: RowActionList;
106
124
  /** The row's name, for the menu button's name — "Actions for Ada Example". Without it
107
- * the button is "Actions". */
108
- name?: string;
125
+ * (or `null`, as API fields often are) the button is "Actions". */
126
+ name?: string | null;
109
127
  /** When the actions collapse into the menu — see {@link RowActionsCollapse}. */
110
128
  collapse?: RowActionsCollapse;
111
129
  /** The inline icons' and the "⋯" button's size. Default `xs`, a list row's action. */
@@ -196,83 +214,171 @@ export function RowActions({
196
214
  <MoreHorizontal />
197
215
  </IconButton>
198
216
  )}
217
+ // React carries an event out of a portal along the COMPONENT tree, so a click in
218
+ // the menu reached a clickable row or a DataTable `onRowClick` the menu was opened
219
+ // from, though the panel is nowhere near it in the page — only the "⋯" trigger
220
+ // stopped its own (keksdose's 0.32 report). Stopped on the PANEL itself, its padding
221
+ // and border included (0.32.2: 0.32.1 stopped it on an inner box, and a click on
222
+ // the panel's `p-1` still went through): clicks, and the Enter / Space a row also
223
+ // listens for. Escape goes on, to the popover's own handler that closes it.
224
+ onClick={stopClick}
225
+ onKeyDown={stopActivationKeys}
199
226
  >
200
227
  {(close) => (
201
228
  <ul data-slot="row-actions-menu" className="space-y-0.5">
202
- {entries.map((action) => {
203
- const glyph = glyphOf(action.icon);
204
- return (
205
- <li key={action.key ?? action.label}>
206
- <Button
207
- type="button"
208
- variant="ghost"
209
- size="sm"
210
- stretch
211
- tone={action.tone === "danger" ? "danger" : undefined}
212
- disabledReason={action.disabledReason}
213
- commit={action.commit}
214
- pending={action.pending}
215
- disabled={action.disabled}
216
- className="w-full justify-start text-start"
217
- onClick={() => {
218
- close();
219
- action.onSelect();
220
- }}
221
- >
222
- {glyph && (
223
- <span aria-hidden className="flex shrink-0 [&_svg]:size-4">
224
- {glyph}
225
- </span>
226
- )}
227
- {action.label}
228
- </Button>
229
- </li>
230
- );
231
- })}
229
+ {entries.map((action) => (
230
+ <li key={action.key ?? action.label}>
231
+ <MenuAction action={action} close={close} />
232
+ </li>
233
+ ))}
232
234
  </ul>
233
235
  )}
234
236
  </Popover>
235
237
  );
236
238
  }
237
239
 
238
- /** One inline action: an IconButton with its icon, else a small text button. */
240
+ function stopClick(e: MouseEvent) {
241
+ e.stopPropagation();
242
+ }
243
+
244
+ function stopActivationKeys(e: KeyboardEvent) {
245
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
246
+ }
247
+
248
+ /**
249
+ * The `href` an action renders as a link with, or `undefined` for a button: a link
250
+ * cannot carry a reason or a spinner (Button's link form has neither), so a refused
251
+ * action — its own `disabledReason`, or a write lock on a `commit` — and a pending one
252
+ * stay buttons and say so.
253
+ */
254
+ function useActionHref(action: RowAction): string | undefined {
255
+ const reason = useCommitReason(action.commit, action.disabledReason);
256
+ const refused = reason !== undefined && reason !== null && reason !== false && reason !== "";
257
+ return action.href !== undefined && !action.pending && !refused ? action.href : undefined;
258
+ }
259
+
260
+ /** One entry of the "⋯" menu: the words, as a link for an `href`, else a button. */
261
+ function MenuAction({ action, close }: { action: RowAction; close: () => void }) {
262
+ const href = useActionHref(action);
263
+ const glyph = glyphOf(action.icon);
264
+ const words = (
265
+ <>
266
+ {glyph && (
267
+ <span aria-hidden className="flex shrink-0 [&_svg]:size-4">
268
+ {glyph}
269
+ </span>
270
+ )}
271
+ {action.label}
272
+ </>
273
+ );
274
+ const tone = action.tone === "danger" ? "danger" : undefined;
275
+ const select = () => {
276
+ close();
277
+ action.onSelect?.();
278
+ };
279
+ if (href !== undefined) {
280
+ return (
281
+ <Button
282
+ href={href}
283
+ external={action.external}
284
+ variant="ghost"
285
+ size="sm"
286
+ stretch
287
+ tone={tone}
288
+ disabled={action.disabled}
289
+ className="w-full justify-start text-start"
290
+ onClick={select}
291
+ >
292
+ {words}
293
+ </Button>
294
+ );
295
+ }
296
+ return (
297
+ <Button
298
+ type="button"
299
+ variant="ghost"
300
+ size="sm"
301
+ stretch
302
+ tone={tone}
303
+ disabledReason={action.disabledReason}
304
+ commit={action.commit}
305
+ pending={action.pending}
306
+ disabled={action.disabled}
307
+ className="w-full justify-start text-start"
308
+ onClick={select}
309
+ >
310
+ {words}
311
+ </Button>
312
+ );
313
+ }
314
+
315
+ /** One inline action: an IconButton with its icon, else a small text button — each a
316
+ * link for an `href` (see {@link useActionHref}). */
239
317
  function InlineAction({ action, size }: { action: RowAction; size: RowActionsSize }) {
318
+ const href = useActionHref(action);
240
319
  const glyph = glyphOf(action.icon);
320
+ const tone = action.tone === "danger" ? "danger" : undefined;
241
321
  if (glyph) {
322
+ if (href !== undefined) {
323
+ return (
324
+ <IconButton
325
+ href={href}
326
+ external={action.external}
327
+ size={size}
328
+ label={action.label}
329
+ tone={tone}
330
+ disabled={action.disabled}
331
+ stopPropagation
332
+ onClick={() => action.onSelect?.()}
333
+ >
334
+ {glyph}
335
+ </IconButton>
336
+ );
337
+ }
242
338
  return (
243
339
  <IconButton
244
340
  size={size}
245
341
  label={action.label}
246
- tone={action.tone === "danger" ? "danger" : undefined}
342
+ tone={tone}
247
343
  disabledReason={action.disabledReason}
248
344
  commit={action.commit}
249
345
  pending={action.pending}
250
346
  disabled={action.disabled}
251
347
  stopPropagation
252
- onClick={action.onSelect}
348
+ onClick={() => action.onSelect?.()}
253
349
  >
254
350
  {glyph}
255
351
  </IconButton>
256
352
  );
257
353
  }
354
+ // A row listening for clicks must not open as well (IconButton's stopPropagation).
355
+ const stop = {
356
+ onClick: (e: MouseEvent) => {
357
+ e.stopPropagation();
358
+ action.onSelect?.();
359
+ },
360
+ onKeyDown: (e: KeyboardEvent) => {
361
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
362
+ },
363
+ };
364
+ if (href !== undefined) {
365
+ return (
366
+ <Button href={href} external={action.external} variant="ghost" size="sm" tone={tone} disabled={action.disabled} {...stop}>
367
+ {action.label}
368
+ </Button>
369
+ );
370
+ }
258
371
  return (
259
372
  <Button
260
373
  type="button"
261
374
  variant="ghost"
262
375
  size="sm"
263
- tone={action.tone === "danger" ? "danger" : undefined}
376
+ tone={tone}
264
377
  disabledReason={action.disabledReason}
265
378
  commit={action.commit}
266
379
  pending={action.pending}
267
380
  disabled={action.disabled}
268
- onClick={(e) => {
269
- // A row listening for clicks must not open as well (IconButton's stopPropagation).
270
- e.stopPropagation();
271
- action.onSelect();
272
- }}
273
- onKeyDown={(e) => {
274
- if (e.key === "Enter" || e.key === " ") e.stopPropagation();
275
- }}
381
+ {...stop}
276
382
  >
277
383
  {action.label}
278
384
  </Button>
@@ -290,8 +396,9 @@ export interface RowActionsColumnOptions<T> {
290
396
  key?: string;
291
397
  /** The row's actions — `false` / `null` for the ones a condition leaves out. */
292
398
  actions: (row: T) => RowActionList;
293
- /** The row's name, for the "⋯" button's name ("Actions for Ada Example"). */
294
- name?: (row: T) => string | undefined;
399
+ /** The row's name, for the "⋯" button's name ("Actions for Ada Example"). `null` and
400
+ * `undefined` both mean "Actions". */
401
+ name?: (row: T) => string | null | undefined;
295
402
  collapse?: RowActionsCollapse;
296
403
  /** Default `xs`. */
297
404
  size?: RowActionsSize;
@@ -45,8 +45,12 @@ export interface ThemeSettingProps
45
45
  * one segmented control instead of a select — system, light and dark side by side, the
46
46
  * choice visible without opening anything, in every app (keksdose offered light and dark
47
47
  * only; its store already knew "system"). A {@link ToggleGroup}'s props pass through —
48
- * `labelPlacement`, `disabledReason`, `commit`, `size` — minus the options and the value,
49
- * which this owns.
48
+ * `labelPlacement`, `disabledReason`, `commit`, `size`, `overflow` — minus the options
49
+ * and the value, which this owns.
50
+ *
51
+ * The options wrap rather than truncate (`overflow="wrap"`), as in its two siblings
52
+ * {@link TextSizeSetting} and {@link ContrastSetting} (0.32.1, Kurvenschmiede's 0.32
53
+ * report: "Syst… Li… D…" in a card at Extra large on a 360 px phone).
50
54
  *
51
55
  * Bind it to the PREFERENCE (`"system"` included), never to the resolved mode: on a
52
56
  * system-dark device a control bound to the mode shows "dark" and cannot say "follow the
@@ -70,10 +74,11 @@ export interface ThemeSettingToggleProps
70
74
 
71
75
  export function ThemeSetting(props: ThemeSettingProps | ThemeSettingToggleProps) {
72
76
  if (props.variant === "toggle") {
73
- const { variant: _variant, value, onChange, label, optionLabels, ...rest } = props;
77
+ const { variant: _variant, value, onChange, label, optionLabels, overflow = "wrap", ...rest } = props;
74
78
  return (
75
79
  <ToggleGroup<ThemePreference>
76
80
  {...rest}
81
+ overflow={overflow}
77
82
  label={label}
78
83
  value={value}
79
84
  onChange={onChange}
@@ -20,6 +20,7 @@ import { Tooltip } from "./tooltip";
20
20
  import { Button, Card, IconButton, Spinner } from "./ui";
21
21
  import { useWriteLock } from "./write-lock";
22
22
  import type { CardProps } from "./ui";
23
+ import type { DisabledReasonDisplay } from "./field-parts";
23
24
 
24
25
  /**
25
26
  * Who holds one record, and the owner's every way of changing that — Kurvenschmiede's
@@ -224,6 +225,16 @@ function ShareRoleChip({ role }: { role: ShareRole | undefined }) {
224
225
  return role.description ? <Tooltip label={role.description}>{chip}</Tooltip> : chip;
225
226
  }
226
227
 
228
+ /**
229
+ * A row's controls: the role toggle and the remove icon, or a pending grant's copy and
230
+ * revoke. Held to the row and wrapping inside it (0.32.1, the 360 px Extra-large sweep;
231
+ * docs/text-size-harmonization.md §4 "nothing overflows"): under a write lock on a touch
232
+ * screen or at Large the remove icon gives its reason as a line under it, up to 20rem
233
+ * wide, and a `shrink-0` box ran 31 px past a 390 px phone at Normal and 269 px past a
234
+ * 360 px one at Extra large. Where the controls fit in one row, nothing changes.
235
+ */
236
+ const ROW_CONTROLS = "ms-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-1";
237
+
227
238
  /**
228
239
  * A row's role choice saves on change, so it is a commit that cannot take `commit`:
229
240
  * under a write lock it is disabled, and the lock's reason is put in the kit Tooltip
@@ -278,6 +289,12 @@ export function SharePanel({
278
289
  const roleOf = (key: string | undefined) => roles.find((r) => r.key === key);
279
290
  const choosable = roles.length > 1;
280
291
  const canAdd = !readOnly && onAdd !== undefined;
292
+ // Under a write lock the form's Share button gives the lock's sentence as a line (on
293
+ // touch and at Large). A row's remove or revoke icon then keeps it in its tooltip, as
294
+ // the role toggle beside it does, rather than repeat the same line under every row
295
+ // (0.32.1, the 360 px Extra-large sweep). Without the form the rows' lines are the
296
+ // only place it is said, so they stay — and ROW_CONTROLS wraps them.
297
+ const rowReason: DisabledReasonDisplay = canAdd ? "tooltip" : "auto";
281
298
 
282
299
  const run = async (key: string, action: () => MaybePromise): Promise<boolean> => {
283
300
  setBusy(key);
@@ -457,7 +474,7 @@ export function SharePanel({
457
474
  )}
458
475
  </div>
459
476
  {(changeable || (editable && onRemove)) && (
460
- <div className="ms-auto flex shrink-0 items-center gap-1">
477
+ <div className={ROW_CONTROLS}>
461
478
  {changeable && (
462
479
  <LockedRoleChoice reason={lock.locked ? lock.reason : undefined}>
463
480
  <ToggleGroup<string>
@@ -484,6 +501,7 @@ export function SharePanel({
484
501
  // spinner and the write lock, which a "⋯" menu entry
485
502
  // cannot, and its confirmation names the action in words.
486
503
  labelVisible={false}
504
+ disabledReasonDisplay={rowReason}
487
505
  pending={busy === key}
488
506
  disabled={busy !== null}
489
507
  onClick={async () => {
@@ -529,7 +547,7 @@ export function SharePanel({
529
547
  </div>
530
548
  </div>
531
549
  {(p.link || (!readOnly && onRevokePending)) && (
532
- <div className="ms-auto flex shrink-0 items-center gap-1">
550
+ <div className={ROW_CONTROLS}>
533
551
  {p.link && <CopyButton size="xs" tone="muted" text={p.link} label={labels.copyLink} />}
534
552
  {!readOnly && onRevokePending && (
535
553
  <IconButton
@@ -541,6 +559,7 @@ export function SharePanel({
541
559
  // copy icon (which is an icon at every size), and the
542
560
  // confirmation says it in words.
543
561
  labelVisible={false}
562
+ disabledReasonDisplay={rowReason}
544
563
  pending={busy === key}
545
564
  disabled={busy !== null}
546
565
  onClick={async () => {
@@ -66,6 +66,28 @@ export function statusDotColor(tone: StatusDotTone): string {
66
66
 
67
67
  const SIZE: Record<StatusDotSize, string> = { sm: "size-2", md: "size-2.5", lg: "size-3" };
68
68
 
69
+ /**
70
+ * A labelled dot at Large and Extra large (0.32.1, keksdose's 0.32 report;
71
+ * docs/text-size-harmonization.md §4 "nothing truncates"): the label wraps instead of
72
+ * ending in "…", so a legend entry or a status keeps its last word, the one that tells
73
+ * two states apart. The dot then sits on the label's FIRST line rather than halfway down
74
+ * the block: the row aligns to the top and the dot moves down by half of what one line
75
+ * (`1lh`, the label's own line height, which the dot inherits) leaves around it.
76
+ * `large:` classes, so Normal keeps its one centred line.
77
+ *
78
+ * `break-words`, not `[overflow-wrap:anywhere]`: a legend often sits in a table, and
79
+ * `anywhere` lowers the label's min-content width to one letter, so an auto-sized column
80
+ * squeezed it to a letter per line (the showcase's tone table at 360 px, Extra large).
81
+ * `break-word` keeps each word whole wherever the width is the content's to choose, and
82
+ * still breaks one that cannot fit its line at all.
83
+ */
84
+ const LABEL_WRAP_AT_LARGE = "large:whitespace-normal large:break-words";
85
+ const DOT_ON_FIRST_LINE: Record<StatusDotSize, string> = {
86
+ sm: "large:mt-[calc((1lh_-_0.5rem)/2)]",
87
+ md: "large:mt-[calc((1lh_-_0.625rem)/2)]",
88
+ lg: "large:mt-[calc((1lh_-_0.75rem)/2)]",
89
+ };
90
+
69
91
  export interface StatusDotProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
70
92
  tone?: StatusDotTone;
71
93
  size?: StatusDotSize;
@@ -116,6 +138,7 @@ export function StatusDot({
116
138
  SIZE[size],
117
139
  FILL[tone],
118
140
  ring && "ring-2 ring-[var(--bg-surface)]",
141
+ label != null && DOT_ON_FIRST_LINE[size],
119
142
  label == null && className,
120
143
  dotClassName,
121
144
  )}
@@ -130,9 +153,9 @@ export function StatusDot({
130
153
  return (
131
154
  // No `aria-label` here: the visible label is the text, and a name on a role-less
132
155
  // span is one ARIA forbids and screen readers ignore.
133
- <span {...rest} className={cn("inline-flex min-w-0 items-center gap-1.5 text-sm", className)}>
156
+ <span {...rest} className={cn("inline-flex min-w-0 items-center gap-1.5 text-sm large:items-start", className)}>
134
157
  {dot}
135
- <span className="min-w-0 truncate">{label}</span>
158
+ <span className={cn("min-w-0 truncate", LABEL_WRAP_AT_LARGE)}>{label}</span>
136
159
  </span>
137
160
  );
138
161
  }
@@ -269,8 +269,15 @@ export function SwipeableRow({
269
269
  type="button"
270
270
  onClick={action.onCommit}
271
271
  className={cn(
272
- "sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm",
272
+ "sr-only whitespace-nowrap rounded-md border-[var(--border)] bg-[var(--bg-surface)] text-xs font-medium text-[var(--text-primary)] shadow-sm",
273
273
  "focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5",
274
+ // The box's padding and border only once it shows (0.32.1, keksdose's
275
+ // 0.32 report). Unconditional, `px-2` and `border` beat `sr-only`'s
276
+ // `padding: 0` and `border-width: 0`, so the hidden button was a 1 px
277
+ // box with 2 × 0.5rem of padding round it: 26 px at Extra large, running
278
+ // past the row's end from the strip's start — invisible under its clip,
279
+ // but counted in the page's scroll width (§4: nothing overflows).
280
+ "focus:border focus:px-2 focus:py-1",
274
281
  FOCUS_RING,
275
282
  )}
276
283
  >
@@ -9,6 +9,7 @@ import {
9
9
  DISABLED_REASON_LINE_CLASS,
10
10
  FIELD_CAPTION_CLASS,
11
11
  hasContent,
12
+ isTextHint,
12
13
  LabelStrip,
13
14
  useDisabledReasonLine,
14
15
  useInlineHint,
@@ -130,8 +131,15 @@ export interface ToggleGroupBaseProps<T extends string>
130
131
  * two stack and both keep their corners.
131
132
  */
132
133
  chromeClassName?: string;
133
- /** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with
134
- * `label`. */
134
+ /**
135
+ * As on a labelled {@link Select}: a {@link FieldHint} "?" on the label line, and plain
136
+ * text (a string) as a caption under the group. Only with `label`.
137
+ *
138
+ * 0.32.1 (keksdose's 0.32 report): text went on the label line in every placement, so
139
+ * `TextSizeSetting`'s hint with `labelPlacement="above"` sat beside the label in body
140
+ * type, and at Extra large became a column one word wide — on the setting a reader who
141
+ * needs large text opens first. The label line holds the "?" only (`isTextHint`).
142
+ */
135
143
  hint?: ReactNode;
136
144
  /** The message under the field when it is wrong: paints the field's border with
137
145
  * `--danger`, marks the group `aria-invalid` and describes it with the message, as
@@ -196,9 +204,9 @@ const CHROME_PAD: Record<"sm" | "md", string> = { md: "pt-5 pb-0.5", sm: "pt-5 p
196
204
  const CHROME_SEGMENT: Record<"sm" | "md", string> = { md: "py-0 leading-[1.125rem]", sm: "py-0 leading-4" };
197
205
 
198
206
  /**
199
- * A field of four or more options at Large and Extra large (0.32,
207
+ * A group at Large and Extra large (0.32,
200
208
  * docs/text-size-harmonization.md §4 "nothing truncates", §3.3): the segments flow onto a
201
- * second row inside the field's frame instead of overflowing it. At 125 % a 390 px phone
209
+ * second row inside the group's frame instead of overflowing it. At 125 % a 390 px phone
202
210
  * is 312 px of Normal type, and "Month / Quarter / Half-year / Year" in one row of the
203
211
  * field ran 7 px past it: a one-row flex group is as wide as all its labels end to end
204
212
  * when a grid or a stretched column asks how small it can get, however its segments
@@ -212,12 +220,16 @@ const CHROME_SEGMENT: Record<"sm" | "md", string> = { md: "py-0 leading-[1.125re
212
220
  * Only where it has to: `flex-wrap` breaks a row only when the segments do not fit, so
213
221
  * a wide field at Large stays one row, and a segment shares out what its row leaves
214
222
  * (`flex-1`); a label wider than the whole row still truncates as the last resort.
215
- * Three or fewer options keep one row: each holds a third of the field. A `large:`
216
- * class, so it costs no render and is right on the first paint. `overflow="wrap"` wraps
217
- * at every size already; outside the field the group keeps its `overflow`.
223
+ * A `large:` class, so it costs no render and is right on the first paint.
224
+ * `overflow="wrap"` wraps at every size already.
225
+ *
226
+ * 0.32.1 (Kurvenschmiede's 0.32 report): every count and every placement but the
227
+ * strip, not only a field of four or more. Since a row breaks only where its labels do
228
+ * not fit, a wrap only ever replaces a truncation — and three options in a card at
229
+ * Extra large on a 360 px phone read "Syst… Li… D…". The strip keeps one row: its
230
+ * 26 px group is what lines it up with the 42 px fields beside it (`STRIP_SEGMENT`).
218
231
  */
219
232
  const WRAP_AT_LARGE = "large:flex-wrap";
220
- const WRAP_AT_LARGE_FROM = 4;
221
233
 
222
234
  /**
223
235
  * The strip placement's segments: a 26px group (1 + 2 + 20 + 2 + 1) under the 16px
@@ -335,9 +347,10 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
335
347
  const locked = hasContent(disabledReason);
336
348
  // §4: at Large and on touch the reason is a line under the group, not a bubble.
337
349
  const reasonLine = useDisabledReasonLine(disabledReasonDisplay) && locked;
338
- // §4: likewise a FieldHint "?" on the label line becomes a caption under the group.
350
+ // §4: likewise a FieldHint "?" on the label line becomes a caption under the group at
351
+ // Large and on touch; text is a caption at every size, as Select's (see `hint`).
339
352
  const inlineHint = useInlineHint(hint);
340
- const hintAsCaption = inlineHint !== hint ? inlineHint : undefined;
353
+ const hintAsCaption = isTextHint(hint) ? hint : inlineHint !== hint ? inlineHint : undefined;
341
354
  const labelHint = hintAsCaption === undefined ? hint : undefined;
342
355
  // Locked looks disabled — the dimmed group, the not-allowed cursor, no hover offer —
343
356
  // but stays focusable; `disabled` alone also takes the segments out of the tab order.
@@ -346,10 +359,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
346
359
  // `field` is the chrome; a label placed above or in a strip keeps the bare group's
347
360
  // own box.
348
361
  const field = labelled && labelPlacement === "field";
349
- // At Large and Extra large a FIELD of four or more options wraps (see WRAP_AT_LARGE).
350
- const wrapAtLarge = field && overflow === "truncate" && options.length >= WRAP_AT_LARGE_FROM;
351
362
  const above = labelled && labelPlacement === "above";
352
363
  const strip = labelled && labelPlacement === "strip";
364
+ // At Large and Extra large a truncating group wraps instead, the strip excepted (see
365
+ // WRAP_AT_LARGE).
366
+ const wrapAtLarge = overflow === "truncate" && !strip;
353
367
  const hasError = labelled && hasContent(error);
354
368
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
355
369
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
@@ -150,7 +150,7 @@ export function TranslationReviewEditor({
150
150
  <div className={cn("min-w-0 space-y-3 text-sm", className)}>
151
151
  <code className="block break-all text-xs text-[var(--text-muted)]">{row.key}</code>
152
152
 
153
- <div className="grid gap-3 md:grid-cols-2">
153
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
154
154
  <div className="min-w-0">
155
155
  <SectionLabel as="span" size="xs">
156
156
  {referenceLabel}
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
4
4
 
5
- import { useBreakpoint } from "../hooks/use-breakpoint";
5
+ import { usePhoneLayout } from "../hooks/use-breakpoint";
6
6
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
7
7
  import { cn } from "../lib/cn";
8
8
  import {
@@ -458,9 +458,10 @@ export function TranslationReviewPanel({
458
458
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
459
459
  const locale = useKitLocale();
460
460
  const lock = useWriteLock();
461
- // The wide screen's breakpoint — the one DataTable switches its layout on, so "a phone"
462
- // means the same here as in the table, at every text size.
463
- const phone = !useBreakpoint("md", true);
461
+ // The phone layout DataTable switches its layout on, so "a phone" means the same here
462
+ // as in the table, at every text size: `usePhoneLayout()` itself (§10.4; 0.32.1,
463
+ // keksdose's 0.32 report — `!useBreakpoint("md")` could disagree with it at 767.5 px).
464
+ const phone = usePhoneLayout();
464
465
  const baseId = useId();
465
466
  const rootRef = useRef<HTMLDivElement>(null);
466
467
  const editable = !readOnly && onSave !== undefined;