@eifi1/ui-kit 0.32.0 → 0.32.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 (206) 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/phone-input.d.ts +1 -1
  67. package/dist/components/phone-input.js +2 -2
  68. package/dist/components/phone-input.js.map +1 -1
  69. package/dist/components/row-actions.d.ts +1 -1
  70. package/dist/components/row-actions.js +103 -37
  71. package/dist/components/row-actions.js.map +1 -1
  72. package/dist/components/series-chart.d.ts +1 -1
  73. package/dist/components/settings-fields.d.ts +7 -3
  74. package/dist/components/settings-fields.js +2 -1
  75. package/dist/components/settings-fields.js.map +1 -1
  76. package/dist/components/share-card.d.ts +1 -1
  77. package/dist/components/share-card.js +6 -2
  78. package/dist/components/share-card.js.map +1 -1
  79. package/dist/components/status-dot.js +9 -2
  80. package/dist/components/status-dot.js.map +1 -1
  81. package/dist/components/swipeable-row.js +8 -1
  82. package/dist/components/swipeable-row.js.map +1 -1
  83. package/dist/components/text-link.d.ts +1 -1
  84. package/dist/components/time-input.d.ts +1 -1
  85. package/dist/components/toggle-group.d.ts +9 -2
  86. package/dist/components/toggle-group.js +3 -3
  87. package/dist/components/toggle-group.js.map +1 -1
  88. package/dist/components/translation-review-editor.js +1 -1
  89. package/dist/components/translation-review-editor.js.map +1 -1
  90. package/dist/components/translation-review.js +2 -2
  91. package/dist/components/translation-review.js.map +1 -1
  92. package/dist/components/ui.d.ts +1 -1
  93. package/dist/components/ui.js +5 -5
  94. package/dist/components/ui.js.map +1 -1
  95. package/dist/components/wizard-stepper.js +20 -14
  96. package/dist/components/wizard-stepper.js.map +1 -1
  97. package/dist/{delete-account-setting-BvdQdbv3.d.ts → delete-account-setting-FLyJf-kZ.d.ts} +43 -8
  98. package/dist/feedback/feedback-attachment.d.ts +1 -1
  99. package/dist/feedback/feedback-context.d.ts +1 -1
  100. package/dist/feedback/feedback-crash.d.ts +1 -1
  101. package/dist/feedback/feedback-dialog.d.ts +1 -1
  102. package/dist/feedback/feedback-inbox.d.ts +1 -1
  103. package/dist/feedback/feedback-labels.d.ts +1 -1
  104. package/dist/feedback/feedback-menu.d.ts +1 -1
  105. package/dist/feedback/feedback-record.d.ts +1 -1
  106. package/dist/feedback/feedback-row-detail.d.ts +1 -1
  107. package/dist/feedback/feedback-status-undo.d.ts +1 -1
  108. package/dist/feedback/feedback-submit.d.ts +1 -1
  109. package/dist/feedback/feedback-swipe.d.ts +1 -1
  110. package/dist/feedback/feedback-table.d.ts +1 -1
  111. package/dist/feedback/feedback-thread.d.ts +1 -1
  112. package/dist/feedback.d.ts +1 -1
  113. package/dist/hooks/use-breakpoint.d.ts +19 -8
  114. package/dist/hooks/use-breakpoint.js +1 -1
  115. package/dist/hooks/use-breakpoint.js.map +1 -1
  116. package/dist/hooks/use-file-drop.d.ts +1 -1
  117. package/dist/i18n/defaults.d.ts +1 -1
  118. package/dist/i18n/german.d.ts +1 -1
  119. package/dist/i18n/german.js +7 -3
  120. package/dist/i18n/german.js.map +1 -1
  121. package/dist/i18n/kit-labels.d.ts +1 -1
  122. package/dist/i18n/languages.d.ts +1 -1
  123. package/dist/i18n/locales/de-CH.d.ts +1 -1
  124. package/dist/i18n/locales/en.d.ts +1 -1
  125. package/dist/i18n/locales/es.d.ts +1 -1
  126. package/dist/i18n/locales/es.js +7 -3
  127. package/dist/i18n/locales/es.js.map +1 -1
  128. package/dist/i18n/locales/fr.d.ts +1 -1
  129. package/dist/i18n/locales/fr.js +7 -3
  130. package/dist/i18n/locales/fr.js.map +1 -1
  131. package/dist/i18n/locales/hu.d.ts +1 -1
  132. package/dist/i18n/locales/hu.js +7 -3
  133. package/dist/i18n/locales/hu.js.map +1 -1
  134. package/dist/i18n/locales/it.d.ts +1 -1
  135. package/dist/i18n/locales/it.js +7 -3
  136. package/dist/i18n/locales/it.js.map +1 -1
  137. package/dist/i18n/locales/zh.d.ts +1 -1
  138. package/dist/i18n/locales/zh.js +7 -3
  139. package/dist/i18n/locales/zh.js.map +1 -1
  140. package/dist/i18n/review.d.ts +1 -1
  141. package/dist/index.d.ts +3 -3
  142. package/dist/index.js +3 -1
  143. package/dist/index.js.map +1 -1
  144. package/dist/landing/landing-actions.d.ts +1 -1
  145. package/dist/landing/landing-sections.d.ts +1 -1
  146. package/dist/landing/landing-sections.js +7 -3
  147. package/dist/landing/landing-sections.js.map +1 -1
  148. package/dist/landing/public-header.d.ts +1 -1
  149. package/dist/landing/routing.d.ts +1 -1
  150. package/dist/rhf/fields.d.ts +1 -1
  151. package/dist/rhf/form.d.ts +1 -1
  152. package/dist/rhf.d.ts +1 -1
  153. package/dist/settings/settings-section.d.ts +1 -1
  154. package/dist/settings/use-account-appearance.d.ts +1 -1
  155. package/dist/settings/use-account-appearance.js.map +1 -1
  156. package/dist/shell/app-shell.d.ts +1 -1
  157. package/dist/shell/top-bar-brand.d.ts +1 -1
  158. package/dist/shell.d.ts +1 -1
  159. package/dist/theme/contrast.d.ts +3 -2
  160. package/dist/theme/contrast.js.map +1 -1
  161. package/dist/theme/text-size.d.ts +19 -3
  162. package/dist/theme/text-size.js.map +1 -1
  163. package/dist/wizard/stepper-nav.d.ts +1 -1
  164. package/dist/wizard/wizard-summary.js +1 -1
  165. package/dist/wizard/wizard-summary.js.map +1 -1
  166. package/dist/wizard.d.ts +1 -1
  167. package/package.json +1 -1
  168. package/src/admin/admin-action-log.tsx +6 -1
  169. package/src/billing/billing-labels.ts +16 -0
  170. package/src/billing/plan-card.tsx +1 -1
  171. package/src/chart.ts +2 -0
  172. package/src/components/alert-banner.tsx +5 -1
  173. package/src/components/appearance-labels.ts +7 -0
  174. package/src/components/button-group.tsx +21 -0
  175. package/src/components/chip.tsx +34 -6
  176. package/src/components/choice-card.tsx +1 -1
  177. package/src/components/data-table-pagination.tsx +60 -31
  178. package/src/components/data-table.tsx +8 -4
  179. package/src/components/date-picker.tsx +32 -1
  180. package/src/components/inline-edit-field.tsx +20 -1
  181. package/src/components/month-picker.tsx +6 -1
  182. package/src/components/phone-input.tsx +7 -2
  183. package/src/components/row-actions.tsx +155 -49
  184. package/src/components/settings-fields.tsx +8 -3
  185. package/src/components/share-card.tsx +21 -2
  186. package/src/components/status-dot.tsx +25 -2
  187. package/src/components/swipeable-row.tsx +8 -1
  188. package/src/components/toggle-group.tsx +26 -12
  189. package/src/components/translation-review-editor.tsx +1 -1
  190. package/src/components/translation-review.tsx +5 -4
  191. package/src/components/ui.tsx +22 -8
  192. package/src/components/wizard-stepper.tsx +5 -1
  193. package/src/hooks/use-breakpoint.ts +20 -9
  194. package/src/i18n/german.ts +4 -0
  195. package/src/i18n/locales/es.ts +4 -0
  196. package/src/i18n/locales/fr.ts +4 -0
  197. package/src/i18n/locales/hu.ts +4 -0
  198. package/src/i18n/locales/it.ts +4 -0
  199. package/src/i18n/locales/zh.ts +4 -0
  200. package/src/index.ts +4 -2
  201. package/src/landing/landing-sections.tsx +7 -3
  202. package/src/settings/use-account-appearance.ts +1 -1
  203. package/src/theme/contrast.ts +3 -2
  204. package/src/theme/text-size.ts +19 -3
  205. package/src/wizard/wizard-summary.tsx +4 -2
  206. package/tokens.css +15 -7
@@ -35,7 +35,7 @@ import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
35
35
  import { useOverlayHistory } from "../hooks/use-overlay-history";
36
36
  import { FullBleedDialog } from "./full-bleed-dialog";
37
37
  import { Popover } from "./popover";
38
- import { useBreakpoint } from "../hooks/use-breakpoint";
38
+ import { usePhoneLayout } from "../hooks/use-breakpoint";
39
39
  import { useAnnounce } from "../hooks/use-announce";
40
40
  import { DEFAULT_DATA_TABLE_LABELS, resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
41
41
  import { useKitLabelOverrides, useKitLocale } from "../i18n/kit-labels";
@@ -1655,9 +1655,13 @@ export function DataTable<T>({
1655
1655
  : labels.columnsCount(visibleCount, totalCount);
1656
1656
  // Match the `md:` breakpoint at the text size in force: we render either the table or
1657
1657
  // the card list — never both — so we don't double up DOM nodes that screen readers and
1658
- // integration tests would have to disambiguate. The same answer as `usePhoneLayout()`
1659
- // (inverted), which an app's own phone checks use (§10.4).
1660
- const isMdUp = useBreakpoint("md", true);
1658
+ // integration tests would have to disambiguate. `usePhoneLayout()` itself, not its twin
1659
+ // `useBreakpoint("md")`: the same query an app's own phone checks ask (keksdose's
1660
+ // sticky editor footer), so the card list and the full-screen row dialog switch with
1661
+ // them and never a frame apart (§10.4 "usePhoneLayout() is the single answer"; 0.32.1,
1662
+ // keksdose's 0.32 report, where `md` and `max-md` both missed at 767.5 px). Without
1663
+ // `matchMedia` (SSR, jsdom) it is the table, as before.
1664
+ const isMdUp = !usePhoneLayout();
1661
1665
  // `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
1662
1666
  const desktopScroller = useRef<HTMLDivElement | null>(null);
1663
1667
  const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
@@ -206,6 +206,16 @@ function CustomTrigger({
206
206
  return render(attrs, valueId);
207
207
  }
208
208
 
209
+ /**
210
+ * The trigger's value wraps instead of truncating at Large and Extra large (0.32.1, the
211
+ * 360 px Extra-large sweep; docs/text-size-harmonization.md §4 "nothing truncates"). A
212
+ * range ("01/09/2026 – 30/09/2026") is 312 px at 150 %, and on a 360 px phone it read
213
+ * "01/09/2026 – 30/…" — its end, the half a reader needs, was the half cut off. It breaks
214
+ * at its spaces; a single date breaks only where it cannot fit at all. A `large:` class,
215
+ * so Normal keeps its one-line field.
216
+ */
217
+ const VALUE_WRAP_AT_LARGE = "large:whitespace-normal large:break-words";
218
+
209
219
  /**
210
220
  * The field-shaped button that opens the calendar.
211
221
  *
@@ -320,7 +330,7 @@ function DateFieldTrigger({
320
330
 
321
331
  return (
322
332
  <button {...attrs}>
323
- <span id={valueId} className={cn("truncate", !hasValue && "text-[var(--text-placeholder)]")}>
333
+ <span id={valueId} className={cn("truncate", VALUE_WRAP_AT_LARGE, !hasValue && "text-[var(--text-placeholder)]")}>
324
334
  {/* `|| " "` (a NON-BREAKING space) — triggerText is "" when there is no value and no
325
335
  placeholder was passed. An empty span has no line box, so the
326
336
  trigger collapses to its padding and sits shorter than every
@@ -558,6 +568,23 @@ function DateField({
558
568
  );
559
569
  }
560
570
 
571
+ /**
572
+ * The ‹ date › (today) row on a phone at Large and Extra large (0.32.1, the 360 px
573
+ * Extra-large sweep; docs/text-size-harmonization.md §4): the field takes the first line
574
+ * on its own, whole and with all four corners, and the buttons share the line under it
575
+ * as one joined strip. Three 2.5rem buttons are 180 px at 150 %, and on a 360 px phone
576
+ * they left the field 84 px — "Book… 0" for "Booking date 07/09/2026" — where a 360 px
577
+ * row would hold the date only by cutting it. `large:max-sm:`, the scaled phone width,
578
+ * so a tablet or a desktop at Large keeps the one row, and Normal is unchanged.
579
+ */
580
+ const STEP_ROW_STACK_AT_LARGE = cn(
581
+ "large:max-sm:flex-wrap large:max-sm:gap-y-2",
582
+ "large:max-sm:[&>[data-slot=date-step-field]]:order-first large:max-sm:[&>[data-slot=date-step-field]]:basis-full",
583
+ "large:max-sm:[&>[data-slot=date-step-field]]:ms-0 large:max-sm:[&_[role=combobox]]:rounded-md",
584
+ // The buttons (each in its tooltip's wrapper) share the line under the field.
585
+ "large:max-sm:[&>span]:flex-1 large:max-sm:[&>span>button]:w-full",
586
+ );
587
+
561
588
  /** One ‹ / › day-step button, sized to sit flush beside the field. */
562
589
  function StepButton({
563
590
  icon: Icon,
@@ -1105,6 +1132,9 @@ export function DatePicker(props: DatePickerProps) {
1105
1132
  disabled={disabled}
1106
1133
  invalid={messages.isInvalid}
1107
1134
  className={step ? "min-w-0 flex-1" : className}
1135
+ // Named for STEP_ROW_STACK_AT_LARGE; only in the row, so a caller's own
1136
+ // `data-slot` on a bare field is left alone.
1137
+ {...(wrapped ? { "data-slot": "date-step-field" } : {})}
1108
1138
  hasValue={Boolean(value)}
1109
1139
  triggerText={value ? render(value) : (placeholder ?? "")}
1110
1140
  onClear={() => onChange("")}
@@ -1173,6 +1203,7 @@ export function DatePicker(props: DatePickerProps) {
1173
1203
  // the trigger, which raising the trigger alone covered. Hover raises nothing: it
1174
1204
  // changes no border, and would let a hovered › cover a focused field's edge.
1175
1205
  "[&>*:focus-within]:z-20 [&>*:active]:z-20 [&>*:has([aria-invalid=true])]:z-10",
1206
+ STEP_ROW_STACK_AT_LARGE,
1176
1207
  className,
1177
1208
  )}
1178
1209
  >
@@ -155,6 +155,23 @@ function RenderEditor<T>({ render, ...props }: InlineEditorProps<T> & { render:
155
155
  return render(props);
156
156
  }
157
157
 
158
+ /**
159
+ * The value at Large and Extra large (0.32.1, keksdose's 0.32 report;
160
+ * docs/text-size-harmonization.md §4 "nothing truncates"): the display button, the
161
+ * locked one and the read-only text wrap instead of ending in "…", and break a word too
162
+ * long for the cell, so an IBAN or an e-mail address shows to its last character. The
163
+ * value IS the button's accessible name and the thing being edited: a reader who needs
164
+ * big type cannot recover a cut-off end by squinting. A `large:` class, so a table of
165
+ * these keeps its one-line cells at Normal.
166
+ *
167
+ * `break-words`, not `[overflow-wrap:anywhere]`: these live in table cells (keksdose's
168
+ * budget and invoice lines), and `anywhere` lowers the value's min-content width to one
169
+ * letter, so an auto-sized column could squeeze an amount to a digit per line. With
170
+ * `break-word` a column keeps each word whole; the button is `w-full`, so a word wider
171
+ * than a sized cell still breaks.
172
+ */
173
+ const VALUE_WRAP_AT_LARGE = "large:whitespace-normal large:break-words";
174
+
158
175
  /** `onCommit`'s result, if it is a promise. */
159
176
  function asPromise(result: unknown): Promise<unknown> | null {
160
177
  return result && typeof (result as Promise<unknown>).then === "function" ? (result as Promise<unknown>) : null;
@@ -415,7 +432,7 @@ export function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {
415
432
  let body: ReactNode;
416
433
  if (readOnly) {
417
434
  body = (
418
- <span {...private_} className={cn("block min-w-0 truncate", alignClass)}>
435
+ <span {...private_} className={cn("block min-w-0 truncate", VALUE_WRAP_AT_LARGE, alignClass)}>
419
436
  {content}
420
437
  </span>
421
438
  );
@@ -492,6 +509,7 @@ export function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {
492
509
  onClick={(e) => e.preventDefault()}
493
510
  className={cn(
494
511
  "block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none",
512
+ VALUE_WRAP_AT_LARGE,
495
513
  FOCUS_RING,
496
514
  alignClass,
497
515
  )}
@@ -516,6 +534,7 @@ export function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {
516
534
  onClick={() => setOpen(true)}
517
535
  className={cn(
518
536
  "block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none",
537
+ VALUE_WRAP_AT_LARGE,
519
538
  FOCUS_RING,
520
539
  alignClass,
521
540
  )}
@@ -962,7 +962,12 @@ export function MonthPicker(props: MonthPickerProps) {
962
962
  </Popover>
963
963
  );
964
964
  return (
965
- <div {...wrapperRest} ref={rootRef} className={cn("flex min-w-0 items-center gap-2", className)}>
965
+ // `flex-wrap`: where the heading and the buttons do not fit one row, the buttons
966
+ // take the next line, at the end (0.32.1, the 360 px Extra-large sweep;
967
+ // docs/text-size-harmonization.md §4). In one row at 150 % "Today ‹ ›" left the
968
+ // heading 30 px on a 360 px phone, and "October 2026" was drawn under "Today".
969
+ // Where they fit, nothing changes.
970
+ <div {...wrapperRest} ref={rootRef} className={cn("flex min-w-0 flex-wrap items-center gap-2", className)}>
966
971
  {Heading ? <Heading className="m-0 min-w-0 text-lg font-semibold">{trigger}</Heading> : trigger}
967
972
  <div className="ms-auto flex shrink-0 items-center gap-1">
968
973
  {showToday && (
@@ -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}
@@ -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. */
@@ -198,81 +216,168 @@ export function RowActions({
198
216
  )}
199
217
  >
200
218
  {(close) => (
201
- <ul data-slot="row-actions-menu" className="space-y-0.5">
202
- {entries.map((action) => {
203
- const glyph = glyphOf(action.icon);
204
- return (
219
+ // React carries an event out of a portal along the COMPONENT tree, so a click on
220
+ // an entry (or between two) reached a clickable row or a DataTable `onRowClick`
221
+ // the menu was opened from, though the panel is nowhere near it in the page —
222
+ // only the "⋯" trigger stopped its own (0.32.1, keksdose's 0.32 report). Stopped
223
+ // here for the whole panel: clicks, and the Enter / Space a row also listens for.
224
+ // Escape goes on, to the popover that closes on it.
225
+ <div role="presentation" onClick={stopClick} onKeyDown={stopActivationKeys}>
226
+ <ul data-slot="row-actions-menu" className="space-y-0.5">
227
+ {entries.map((action) => (
205
228
  <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
+ <MenuAction action={action} close={close} />
229
230
  </li>
230
- );
231
- })}
232
- </ul>
231
+ ))}
232
+ </ul>
233
+ </div>
233
234
  )}
234
235
  </Popover>
235
236
  );
236
237
  }
237
238
 
238
- /** One inline action: an IconButton with its icon, else a small text button. */
239
+ function stopClick(e: MouseEvent) {
240
+ e.stopPropagation();
241
+ }
242
+
243
+ function stopActivationKeys(e: KeyboardEvent) {
244
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
245
+ }
246
+
247
+ /**
248
+ * The `href` an action renders as a link with, or `undefined` for a button: a link
249
+ * cannot carry a reason or a spinner (Button's link form has neither), so a refused
250
+ * action — its own `disabledReason`, or a write lock on a `commit` — and a pending one
251
+ * stay buttons and say so.
252
+ */
253
+ function useActionHref(action: RowAction): string | undefined {
254
+ const reason = useCommitReason(action.commit, action.disabledReason);
255
+ const refused = reason !== undefined && reason !== null && reason !== false && reason !== "";
256
+ return action.href !== undefined && !action.pending && !refused ? action.href : undefined;
257
+ }
258
+
259
+ /** One entry of the "⋯" menu: the words, as a link for an `href`, else a button. */
260
+ function MenuAction({ action, close }: { action: RowAction; close: () => void }) {
261
+ const href = useActionHref(action);
262
+ const glyph = glyphOf(action.icon);
263
+ const words = (
264
+ <>
265
+ {glyph && (
266
+ <span aria-hidden className="flex shrink-0 [&_svg]:size-4">
267
+ {glyph}
268
+ </span>
269
+ )}
270
+ {action.label}
271
+ </>
272
+ );
273
+ const tone = action.tone === "danger" ? "danger" : undefined;
274
+ const select = () => {
275
+ close();
276
+ action.onSelect?.();
277
+ };
278
+ if (href !== undefined) {
279
+ return (
280
+ <Button
281
+ href={href}
282
+ external={action.external}
283
+ variant="ghost"
284
+ size="sm"
285
+ stretch
286
+ tone={tone}
287
+ disabled={action.disabled}
288
+ className="w-full justify-start text-start"
289
+ onClick={select}
290
+ >
291
+ {words}
292
+ </Button>
293
+ );
294
+ }
295
+ return (
296
+ <Button
297
+ type="button"
298
+ variant="ghost"
299
+ size="sm"
300
+ stretch
301
+ tone={tone}
302
+ disabledReason={action.disabledReason}
303
+ commit={action.commit}
304
+ pending={action.pending}
305
+ disabled={action.disabled}
306
+ className="w-full justify-start text-start"
307
+ onClick={select}
308
+ >
309
+ {words}
310
+ </Button>
311
+ );
312
+ }
313
+
314
+ /** One inline action: an IconButton with its icon, else a small text button — each a
315
+ * link for an `href` (see {@link useActionHref}). */
239
316
  function InlineAction({ action, size }: { action: RowAction; size: RowActionsSize }) {
317
+ const href = useActionHref(action);
240
318
  const glyph = glyphOf(action.icon);
319
+ const tone = action.tone === "danger" ? "danger" : undefined;
241
320
  if (glyph) {
321
+ if (href !== undefined) {
322
+ return (
323
+ <IconButton
324
+ href={href}
325
+ external={action.external}
326
+ size={size}
327
+ label={action.label}
328
+ tone={tone}
329
+ disabled={action.disabled}
330
+ stopPropagation
331
+ onClick={() => action.onSelect?.()}
332
+ >
333
+ {glyph}
334
+ </IconButton>
335
+ );
336
+ }
242
337
  return (
243
338
  <IconButton
244
339
  size={size}
245
340
  label={action.label}
246
- tone={action.tone === "danger" ? "danger" : undefined}
341
+ tone={tone}
247
342
  disabledReason={action.disabledReason}
248
343
  commit={action.commit}
249
344
  pending={action.pending}
250
345
  disabled={action.disabled}
251
346
  stopPropagation
252
- onClick={action.onSelect}
347
+ onClick={() => action.onSelect?.()}
253
348
  >
254
349
  {glyph}
255
350
  </IconButton>
256
351
  );
257
352
  }
353
+ // A row listening for clicks must not open as well (IconButton's stopPropagation).
354
+ const stop = {
355
+ onClick: (e: MouseEvent) => {
356
+ e.stopPropagation();
357
+ action.onSelect?.();
358
+ },
359
+ onKeyDown: (e: KeyboardEvent) => {
360
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
361
+ },
362
+ };
363
+ if (href !== undefined) {
364
+ return (
365
+ <Button href={href} external={action.external} variant="ghost" size="sm" tone={tone} disabled={action.disabled} {...stop}>
366
+ {action.label}
367
+ </Button>
368
+ );
369
+ }
258
370
  return (
259
371
  <Button
260
372
  type="button"
261
373
  variant="ghost"
262
374
  size="sm"
263
- tone={action.tone === "danger" ? "danger" : undefined}
375
+ tone={tone}
264
376
  disabledReason={action.disabledReason}
265
377
  commit={action.commit}
266
378
  pending={action.pending}
267
379
  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
- }}
380
+ {...stop}
276
381
  >
277
382
  {action.label}
278
383
  </Button>
@@ -290,8 +395,9 @@ export interface RowActionsColumnOptions<T> {
290
395
  key?: string;
291
396
  /** The row's actions — `false` / `null` for the ones a condition leaves out. */
292
397
  actions: (row: T) => RowActionList;
293
- /** The row's name, for the "⋯" button's name ("Actions for Ada Example"). */
294
- name?: (row: T) => string | undefined;
398
+ /** The row's name, for the "⋯" button's name ("Actions for Ada Example"). `null` and
399
+ * `undefined` both mean "Actions". */
400
+ name?: (row: T) => string | null | undefined;
295
401
  collapse?: RowActionsCollapse;
296
402
  /** Default `xs`. */
297
403
  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
  >