@eifi1/ui-kit 0.39.0 → 0.40.0

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 (148) hide show
  1. package/README.md +8 -7
  2. package/dist/billing/billing-overview.d.ts +12 -2
  3. package/dist/billing/pay-page.js +1 -1
  4. package/dist/billing/pay-page.js.map +1 -1
  5. package/dist/bin/eifi1-pay-page.mjs +1 -1
  6. package/dist/components/autocomplete.js +5 -1
  7. package/dist/components/autocomplete.js.map +1 -1
  8. package/dist/components/combobox-core.d.ts +14 -1
  9. package/dist/components/combobox-core.js +4 -0
  10. package/dist/components/combobox-core.js.map +1 -1
  11. package/dist/components/combobox.js +9 -3
  12. package/dist/components/combobox.js.map +1 -1
  13. package/dist/components/data-table-labels.d.ts +5 -0
  14. package/dist/components/data-table-labels.js +2 -0
  15. package/dist/components/data-table-labels.js.map +1 -1
  16. package/dist/components/data-table-mobile.d.ts +36 -1
  17. package/dist/components/data-table-mobile.js +162 -15
  18. package/dist/components/data-table-mobile.js.map +1 -1
  19. package/dist/components/data-table.d.ts +39 -12
  20. package/dist/components/data-table.js +74 -13
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/inline-entity-combobox.js +10 -3
  23. package/dist/components/inline-entity-combobox.js.map +1 -1
  24. package/dist/components/picker-sheet.d.ts +18 -1
  25. package/dist/components/picker-sheet.js +20 -2
  26. package/dist/components/picker-sheet.js.map +1 -1
  27. package/dist/components/tabs.d.ts +26 -2
  28. package/dist/components/tabs.js +41 -2
  29. package/dist/components/tabs.js.map +1 -1
  30. package/dist/components/translation-review-parts.d.ts +2 -1
  31. package/dist/components/translation-review-parts.js +1 -1
  32. package/dist/components/translation-review-parts.js.map +1 -1
  33. package/dist/components/ui-labels.js +3 -1
  34. package/dist/components/ui-labels.js.map +1 -1
  35. package/dist/eslint/index.js +1 -1
  36. package/dist/feedback/feedback-comment-thread.d.ts +8 -1
  37. package/dist/feedback/feedback-comment-thread.js +26 -1
  38. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  39. package/dist/feedback/feedback-labels.d.ts +2 -1
  40. package/dist/feedback/feedback-labels.js +1 -0
  41. package/dist/feedback/feedback-labels.js.map +1 -1
  42. package/dist/feedback/feedback-menu-labels.js +3 -1
  43. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  44. package/dist/feedback/feedback-menu.d.ts +31 -24
  45. package/dist/feedback/feedback-menu.js +16 -7
  46. package/dist/feedback/feedback-menu.js.map +1 -1
  47. package/dist/feedback/feedback-public-labels.d.ts +3 -2
  48. package/dist/feedback/feedback-public-labels.js +1 -1
  49. package/dist/feedback/feedback-public-labels.js.map +1 -1
  50. package/dist/feedback/feedback-public-list.js +1 -0
  51. package/dist/feedback/feedback-public-list.js.map +1 -1
  52. package/dist/feedback/feedback-public-types.d.ts +16 -3
  53. package/dist/feedback/feedback-record.d.ts +20 -3
  54. package/dist/feedback/feedback-record.js +15 -0
  55. package/dist/feedback/feedback-record.js.map +1 -1
  56. package/dist/feedback/feedback-replies.d.ts +13 -2
  57. package/dist/feedback/feedback-replies.js +67 -33
  58. package/dist/feedback/feedback-replies.js.map +1 -1
  59. package/dist/feedback/feedback-rework-entry-chip.d.ts +9 -0
  60. package/dist/feedback/feedback-rework-entry-chip.js +13 -0
  61. package/dist/feedback/feedback-rework-entry-chip.js.map +1 -0
  62. package/dist/feedback/feedback-row-detail.d.ts +31 -8
  63. package/dist/feedback/feedback-row-detail.js +31 -7
  64. package/dist/feedback/feedback-row-detail.js.map +1 -1
  65. package/dist/feedback/feedback-table-labels.js +1 -1
  66. package/dist/feedback/feedback-table-labels.js.map +1 -1
  67. package/dist/feedback/feedback-table.d.ts +5 -2
  68. package/dist/feedback/feedback-table.js +2 -2
  69. package/dist/feedback/feedback-table.js.map +1 -1
  70. package/dist/feedback/feedback-thread.js +3 -1
  71. package/dist/feedback/feedback-thread.js.map +1 -1
  72. package/dist/feedback/feedback-vocabulary.d.ts +9 -6
  73. package/dist/feedback/feedback-vocabulary.js +16 -1
  74. package/dist/feedback/feedback-vocabulary.js.map +1 -1
  75. package/dist/hooks/use-long-press.d.ts +30 -0
  76. package/dist/hooks/use-long-press.js +77 -0
  77. package/dist/hooks/use-long-press.js.map +1 -0
  78. package/dist/hooks/use-overlay-history.js +37 -3
  79. package/dist/hooks/use-overlay-history.js.map +1 -1
  80. package/dist/i18n/german.js +13 -5
  81. package/dist/i18n/german.js.map +1 -1
  82. package/dist/i18n/kit-labels.d.ts +3 -0
  83. package/dist/i18n/kit-labels.js.map +1 -1
  84. package/dist/i18n/locales/en.js +12 -5
  85. package/dist/i18n/locales/en.js.map +2 -2
  86. package/dist/i18n/locales/es.js +13 -5
  87. package/dist/i18n/locales/es.js.map +1 -1
  88. package/dist/i18n/locales/fr.js +13 -5
  89. package/dist/i18n/locales/fr.js.map +1 -1
  90. package/dist/i18n/locales/hu.js +13 -5
  91. package/dist/i18n/locales/hu.js.map +1 -1
  92. package/dist/i18n/locales/it.js +13 -5
  93. package/dist/i18n/locales/it.js.map +1 -1
  94. package/dist/i18n/locales/zh.js +13 -5
  95. package/dist/i18n/locales/zh.js.map +1 -1
  96. package/dist/i18n/shared-labels.js +1 -1
  97. package/dist/i18n/shared-labels.js.map +1 -1
  98. package/dist/pay/pay.js +1 -1
  99. package/dist/shell/app-shell.js +9 -0
  100. package/dist/shell/app-shell.js.map +1 -1
  101. package/dist/shell/fill-height.d.ts +38 -0
  102. package/dist/shell/fill-height.js +12 -0
  103. package/dist/shell/fill-height.js.map +1 -0
  104. package/dist/shell.d.ts +2 -0
  105. package/dist/shell.js +3 -0
  106. package/dist/shell.js.map +1 -1
  107. package/package.json +1 -1
  108. package/src/billing/billing-overview.ts +12 -2
  109. package/src/billing/pay-page.ts +1 -1
  110. package/src/components/autocomplete.tsx +5 -1
  111. package/src/components/combobox-core.tsx +20 -1
  112. package/src/components/combobox.tsx +9 -3
  113. package/src/components/data-table-labels.ts +7 -0
  114. package/src/components/data-table-mobile.tsx +193 -9
  115. package/src/components/data-table.tsx +141 -24
  116. package/src/components/inline-entity-combobox.tsx +12 -4
  117. package/src/components/picker-sheet.tsx +38 -1
  118. package/src/components/tabs.tsx +73 -3
  119. package/src/components/translation-review-parts.tsx +3 -2
  120. package/src/components/ui-labels.ts +2 -0
  121. package/src/feedback/feedback-comment-thread.tsx +39 -1
  122. package/src/feedback/feedback-labels.ts +3 -1
  123. package/src/feedback/feedback-menu-labels.ts +2 -0
  124. package/src/feedback/feedback-menu.tsx +48 -30
  125. package/src/feedback/feedback-public-labels.ts +4 -3
  126. package/src/feedback/feedback-public-list.tsx +4 -1
  127. package/src/feedback/feedback-public-types.ts +17 -3
  128. package/src/feedback/feedback-record.ts +34 -3
  129. package/src/feedback/feedback-replies.tsx +100 -31
  130. package/src/feedback/feedback-rework-entry-chip.tsx +18 -0
  131. package/src/feedback/feedback-row-detail.tsx +65 -12
  132. package/src/feedback/feedback-table-labels.ts +1 -1
  133. package/src/feedback/feedback-table.tsx +7 -4
  134. package/src/feedback/feedback-thread.tsx +5 -1
  135. package/src/feedback/feedback-vocabulary.ts +27 -6
  136. package/src/hooks/use-long-press.ts +126 -0
  137. package/src/hooks/use-overlay-history.ts +74 -3
  138. package/src/i18n/german.ts +10 -2
  139. package/src/i18n/kit-labels.tsx +3 -0
  140. package/src/i18n/locales/es.ts +10 -2
  141. package/src/i18n/locales/fr.ts +10 -2
  142. package/src/i18n/locales/hu.ts +10 -2
  143. package/src/i18n/locales/it.ts +10 -2
  144. package/src/i18n/locales/zh.ts +10 -2
  145. package/src/i18n/shared-labels.ts +1 -1
  146. package/src/shell/app-shell.tsx +9 -0
  147. package/src/shell/fill-height.tsx +45 -0
  148. package/src/shell.ts +3 -0
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode, TdHTMLAttributes, ThHTMLAttributes } from "react";
3
3
  import { Card } from "./card";
4
4
  import { Spinner } from "./spinner";
@@ -14,7 +14,7 @@ import {
14
14
  resolveDataTableLabels,
15
15
  splitCellProps,
16
16
  } from "./data-table-shared";
17
- import { MobileFilters, MobileRow, MobileSort, StackedFields } from "./data-table-mobile";
17
+ import { MobileFilters, MobileRow, MobileSelectBar, MobileSort, StackedFields } from "./data-table-mobile";
18
18
  import { DataTableHead } from "./data-table-head";
19
19
  import { ColumnSettingsPanel } from "./data-table-column-settings";
20
20
  import { useLargeText } from "../hooks/use-large-text";
@@ -236,10 +236,26 @@ export interface DataTableProps<T> {
236
236
  urlSync?: TableUrlSync;
237
237
  /**
238
238
  * When true, the table fills its parent's height and scrolls INTERNALLY, with the
239
- * header pinned — so the page itself doesn't add a second, outer scrollbar. On a
240
- * phone the card list is the scroller instead, with the filter button pinned above
241
- * it; an unbounded parent simply lets either layout grow to its content. The parent must give it a bounded height (e.g. a flex
242
- * column inside the viewport-locked app shell). See feedback #207.
239
+ * header pinned — so the page itself doesn't add a second, outer scrollbar. The parent
240
+ * must give it a bounded height (e.g. a flex column inside the viewport-locked app
241
+ * shell). See feedback #207.
242
+ *
243
+ * **The list page** (0.40, docs/page-layout-harmonization.md §1): the root carries
244
+ * `data-fill-height`, and from `md` up AppShell's `<main>` turns into a flex column
245
+ * while such a table is inside — the shell's banners keep their height, the page takes
246
+ * the rest. Wrap every element between `<main>` and the table in `FillHeight` (or merge
247
+ * `FILL_HEIGHT_CLASS`); nothing else to wire. The body never shrinks below `basis-48`
248
+ * (12rem, about five rows): on a window too short for that, `<main>` scrolls again.
249
+ *
250
+ * **On a phone** the card list becomes the scroller ONLY when its parent really bounds
251
+ * it — measured, not assumed (0.40, keksdose live #420): the list takes the flex
252
+ * room it is given, and only when its rows overflow that room does it turn
253
+ * `overflow-y-auto overscroll-contain`, with the filter button pinned above. Under an
254
+ * unbounded parent — `AppShell` on a phone, where the DOCUMENT scrolls — it stays in
255
+ * the page's flow and grows to its content. Until 0.40 it was a scroller either way,
256
+ * and a scroller with nothing to scroll and `overscroll-contain` swallowed the page's
257
+ * own scroll: a swipe that started on a card moved nothing. So `fillHeight` is safe
258
+ * to pass at every width; `fillHeight={useBreakpoint("md")}` is no longer needed.
243
259
  *
244
260
  * The cure for TWO SCROLLBARS (round 0.34 C14, kastlan feedback 55): the default
245
261
  * `maxBodyHeight` scrolls the body inside a page that scrolls too. How a page fills
@@ -263,14 +279,13 @@ export interface DataTableProps<T> {
263
279
  * the cap on a page with anything else on it shows two scrollbars side by side,
264
280
  * and the wheel moves whichever is under the pointer. For a list page whose table
265
281
  * is the point, fill the height instead, from `md` up (a phone scrolls the page
266
- * and its card list as one):
282
+ * and its card list as one) — docs/page-layout-harmonization.md §1.2 says which
283
+ * pages fill:
267
284
  *
268
- * 1. the shell's content column becomes a flex column of the main area's height
269
- * when the page asks — kastlan's
270
- * `md:has-[>[data-fill-height]]:flex md:has-[>[data-fill-height]]:h-full md:has-[>[data-fill-height]]:flex-col`;
271
- * 2. the page's root asks, and is a flex column that may shrink:
272
- * `data-fill-height` and `md:flex md:min-h-0 md:flex-1 md:flex-col`;
273
- * 3. the table takes the rest: `<DataTable fillHeight>`.
285
+ * 1. `<DataTable fillHeight>`: the table marks itself (`data-fill-height`), and
286
+ * AppShell's `<main>` becomes a flex column while it is inside;
287
+ * 2. each element between `<main>` and the table is a `FillHeight` (the page's
288
+ * root, any wrapper on the way), or merges `FILL_HEIGHT_CLASS`.
274
289
  *
275
290
  * Only the body then scrolls, under its pinned header, and every other page grows
276
291
  * with its content as before (kastlan e3985c9, its leases list). A short table that
@@ -313,11 +328,23 @@ export interface DataTableProps<T> {
313
328
  pageSize?: number;
314
329
  onPageSizeChange?: (pageSize: number) => void;
315
330
  /**
316
- * Opt-in multi-row selection (desktop table only). When provided, a leading
331
+ * Opt-in multi-row selection. When provided, a leading
317
332
  * checkbox column is rendered with a select-all box in the header; the owner
318
333
  * holds the selected set and reacts via the callbacks. Rows for which
319
334
  * `isSelectable` returns false render no checkbox and are excluded from
320
335
  * select-all. See feedback #285 (bulk edit).
336
+ *
337
+ * **On a phone** (0.40, keksdose live #421) the cards enter a selection MODE: a long
338
+ * press on a card (500 ms, keksdose's register gesture) selects it and turns every
339
+ * card into a toggle with its box showing; a tap then selects or deselects. A
340
+ * keyboard or screen reader enters through "Select rows" (`labels.selectRows`),
341
+ * visually hidden until focused; in the mode a bar above the cards holds "Select all
342
+ * rows" and Cancel (`labels.cancelSelection`). The mode lasts while anything is
343
+ * selected — the bulk bar's Clear, the last box unticked or Cancel ends it — and swipe
344
+ * actions rest while it is on. Render the owner's `BulkActionBar` as on desktop; it
345
+ * appears with the first selected row. With `selection` the cards' text is not
346
+ * selectable on a phone, and a linked card's long press selects instead of opening
347
+ * the browser's link menu.
321
348
  */
322
349
  selection?: {
323
350
  isSelectable?: (row: T) => boolean;
@@ -1189,6 +1216,30 @@ export function DataTable<T>({
1189
1216
  if (d !== mobileDir) setMobileDir(d);
1190
1217
  };
1191
1218
 
1219
+ // `fillHeight` on a phone (0.40, keksdose live #420): the card list is a scroller only
1220
+ // while its parent bounds it — its rows overflow the flex room it was given. Under
1221
+ // AppShell's phone layout the DOCUMENT scrolls, the list's room is its own content,
1222
+ // and a scroller there (with `overscroll-contain`) only swallowed the page's scroll.
1223
+ // Measured before paint on every render (rows revealed, filtered, opened), and on a
1224
+ // resize of the list or its parent; one read of two heights.
1225
+ const phoneFill = fillHeight && !isMdUp;
1226
+ const phoneListRef = useRef<HTMLUListElement | null>(null);
1227
+ const [phoneListScrolls, setPhoneListScrolls] = useState(false);
1228
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- every render on purpose: what overflows is the rows, and React bails out on an unchanged boolean
1229
+ useLayoutEffect(() => {
1230
+ const el = phoneListRef.current;
1231
+ if (!phoneFill || !el) return;
1232
+ setPhoneListScrolls(el.scrollHeight > el.clientHeight + 1);
1233
+ });
1234
+ useEffect(() => {
1235
+ const el = phoneListRef.current;
1236
+ if (!phoneFill || !el || typeof ResizeObserver === "undefined") return;
1237
+ const ro = new ResizeObserver(() => setPhoneListScrolls(el.scrollHeight > el.clientHeight + 1));
1238
+ ro.observe(el);
1239
+ if (el.parentElement) ro.observe(el.parentElement);
1240
+ return () => ro.disconnect();
1241
+ }, [phoneFill]);
1242
+
1192
1243
  const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
1193
1244
  rows: sorted,
1194
1245
  slice,
@@ -1197,6 +1248,23 @@ export function DataTable<T>({
1197
1248
  unpaged,
1198
1249
  defaultPageSize,
1199
1250
  });
1251
+ // ---- Phone selection mode (0.40, keksdose live #421) ----
1252
+ // The cards had no boxes, so `selection`'s bulk actions were unreachable on a phone.
1253
+ // A long press on a card (kk's register gesture) — or "Select rows", the keyboard's
1254
+ // and screen reader's way — enters the mode: every card shows its box and a tap
1255
+ // toggles it. The mode lasts while anything is selected, so emptying the selection
1256
+ // (the bulk bar's Clear, the last box unticked, Cancel) leaves it, as kk's does.
1257
+ const anySelected =
1258
+ !!selection && (selection.someSelected || (selection.allSelected && slice.length > 0));
1259
+ const [phoneSelectMode, setPhoneSelectMode] = useState(false);
1260
+ const [hadSelection, setHadSelection] = useState(anySelected);
1261
+ if (anySelected !== hadSelection) {
1262
+ // Adjusted while rendering, so the frame the selection empties is already out of
1263
+ // the mode (an effect would paint a frame of boxes first).
1264
+ setHadSelection(anySelected);
1265
+ if (!anySelected) setPhoneSelectMode(false);
1266
+ }
1267
+ const phoneSelecting = !!selection && !isMdUp && (phoneSelectMode || anySelected);
1200
1268
  // ---- Shift+click range selection ---- (data-table-selection.ts; feedback #289,
1201
1269
  // anchored on the row's KEY so a sort, filter or page change cannot move it)
1202
1270
  const { selectionAnchor, rangeClick, canSelect, selectRange } = useRangeSelection({
@@ -1268,6 +1336,24 @@ export function DataTable<T>({
1268
1336
  mobileExpandAsDialog={mobileExpandAsDialog}
1269
1337
  compact={compact}
1270
1338
  large={large}
1339
+ select={
1340
+ selection
1341
+ ? {
1342
+ active: phoneSelecting,
1343
+ selectable: canSelect(row),
1344
+ selected: selection.isSelected(row),
1345
+ onToggle: (checked) => {
1346
+ selectionAnchor.current = rowKey(row);
1347
+ selection.onToggle(row, checked);
1348
+ },
1349
+ onLongPress: () => {
1350
+ setPhoneSelectMode(true);
1351
+ selectionAnchor.current = rowKey(row);
1352
+ if (!selection.isSelected(row)) selection.onToggle(row, true);
1353
+ },
1354
+ }
1355
+ : null
1356
+ }
1271
1357
  />
1272
1358
  );
1273
1359
 
@@ -1299,13 +1385,19 @@ export function DataTable<T>({
1299
1385
  // classes and a table-cell tooltip must portal under test just as it does in the
1300
1386
  // browser (see `CLIPS_ATTRIBUTE`). The bare root clips nothing and is not marked.
1301
1387
  data-clips={frame ? "" : undefined}
1388
+ // The fill's marker (0.40, docs/page-layout-harmonization.md §1.3): AppShell's
1389
+ // `<main>` and every `FillHeight` between it and this table key off it with
1390
+ // `has-[[data-fill-height]]`, so a page asks for the fill by passing the prop.
1391
+ data-fill-height={fillHeight ? "" : undefined}
1302
1392
  className={cn(
1303
1393
  "group/table",
1304
1394
  frame ? "overflow-clip" : "min-w-0",
1305
1395
  // Both layouts, not just the table: the phone card list overflowing a bounded
1306
1396
  // pane drew itself over whatever followed it, and in a viewport-locked shell
1307
- // the rows below the fold could not be reached at all.
1308
- fillHeight && "flex flex-1 flex-col min-h-0",
1397
+ // the rows below the fold could not be reached at all. From `md` the minimum is
1398
+ // the content's again (`md:min-h-auto`): the body's floor below then holds the
1399
+ // whole chain up to `<main>`, which scrolls instead of leaving a slit.
1400
+ fillHeight && "flex flex-1 flex-col min-h-0 md:min-h-auto",
1309
1401
  className,
1310
1402
  )}
1311
1403
  >
@@ -1373,14 +1465,33 @@ export function DataTable<T>({
1373
1465
  }}
1374
1466
  />
1375
1467
  )}
1468
+ {selection && (
1469
+ <MobileSelectBar
1470
+ active={phoneSelecting}
1471
+ allSelected={selection.allSelected}
1472
+ someSelected={selection.someSelected}
1473
+ compact={compact}
1474
+ labels={labels}
1475
+ onEnter={() => setPhoneSelectMode(true)}
1476
+ onToggleAll={selection.onToggleAll}
1477
+ onCancel={() => {
1478
+ setPhoneSelectMode(false);
1479
+ if (anySelected) selection.onToggleAll(false);
1480
+ }}
1481
+ />
1482
+ )}
1376
1483
  <ul
1484
+ ref={phoneListRef}
1377
1485
  className={cn(
1378
1486
  "divide-y divide-subtle",
1379
- // Under `fillHeight` the list is the scroller, as the table body is on a
1380
- // wide screen: the filter button stays above it and a server pager below.
1381
- // The endless-scroll sentinel still fires in here — an
1382
- // IntersectionObserver on the viewport sees through the clipping ancestor.
1383
- fillHeight && "min-h-0 flex-1 overflow-y-auto overscroll-contain",
1487
+ // Under `fillHeight` the list takes the room its parent gives it, and in a
1488
+ // BOUNDED parent it is the scroller, as the table body is on a wide screen:
1489
+ // the filter button stays above it and a server pager below. Only then —
1490
+ // see `phoneListScrolls` (live #420). The endless-scroll sentinel still
1491
+ // fires in here — an IntersectionObserver on the viewport sees through the
1492
+ // clipping ancestor.
1493
+ fillHeight && "min-h-0 flex-1",
1494
+ fillHeight && phoneListScrolls && "overflow-y-auto overscroll-contain",
1384
1495
  // Same busy treatment as the desktop body; see `isLoading` below.
1385
1496
  isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
1386
1497
  )}
@@ -1490,8 +1601,11 @@ export function DataTable<T>({
1490
1601
  </div>
1491
1602
  )}
1492
1603
  {isMdUp && (
1493
- <div className={cn("flex", fillHeight && "min-h-0 flex-1")}>
1494
- <div className={cn("relative min-w-0 flex-1", fillHeight && "flex flex-col min-h-0")}>
1604
+ <div className={cn("flex", fillHeight && "flex-1")}>
1605
+ {/* Under `fillHeight` neither wrapper is `min-h-0`: a flex item's automatic
1606
+ minimum is its content's, and the content's height counts the body at its
1607
+ basis (`basis-48`, below), not at its rows — so the floor carries up. */}
1608
+ <div className={cn("relative min-w-0 flex-1", fillHeight && "flex flex-col")}>
1495
1609
  {/* scrollbar-gutter:stable reserves the vertical-scrollbar space up
1496
1610
  front. Expanding a row adds height, which can bring the scrollbar
1497
1611
  in, which narrows the content box — and in an `auto` table layout
@@ -1502,7 +1616,10 @@ export function DataTable<T>({
1502
1616
  ref={desktopScroller}
1503
1617
  // The scroller: marked for the Tooltip's auto-portal (see the root above).
1504
1618
  data-clips=""
1505
- className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "flex-1 min-h-0")}
1619
+ // Under `fillHeight` the body grows into the room from a 12rem basis (0.40,
1620
+ // §1.3): about five rows is its floor. A window too short for the page's
1621
+ // header and that much body scrolls `<main>` instead (keksdose's review).
1622
+ className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "min-h-0 grow basis-48")}
1506
1623
  // `edgeFade`: the mask, `data-overflow` and the focus handling; nothing
1507
1624
  // while off. The height bound is merged over the mask, not replaced by it.
1508
1625
  {...edgeFadeProps}
@@ -14,6 +14,7 @@ import {
14
14
  ComboboxFieldLabel,
15
15
  DISABLED_ROW_CLASS,
16
16
  endHintRowProps,
17
+ GROUP_HEADING_STICKY,
17
18
  isOptionEnabled,
18
19
  offersCreate,
19
20
  stepEnabled,
@@ -310,13 +311,14 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
310
311
  const q = foldForSearch(query, locale);
311
312
  const hit = (s: string | undefined) => s !== undefined && foldForSearch(s, locale).includes(q);
312
313
  // The label's whole name or its start first (`rankMatch` 0 and 1), then a match
313
- // anywhere in the label, the sublabel or the group.
314
+ // anywhere in the label or the sublabel. Not the group (0.40, kk run 79): with
315
+ // first-letter groups one typed letter matched its whole group.
314
316
  const leads = (o: ComboOption<V>) => {
315
317
  const rank = rankMatch(o.label, query, { locale });
316
318
  return rank === 0 || rank === 1;
317
319
  };
318
320
  const starts = options.filter(leads);
319
- const rest = options.filter((o) => !leads(o) && (hit(o.label) || hit(o.sublabel) || hit(o.group)));
321
+ const rest = options.filter((o) => !leads(o) && (hit(o.label) || hit(o.sublabel)));
320
322
  ranked = [...starts, ...rest];
321
323
  }
322
324
  if (!ranked.some((o) => o.group)) return ranked;
@@ -647,7 +649,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
647
649
  {o.group && o.group !== matches[i - 1]?.group && (
648
650
  <li
649
651
  role="presentation"
650
- className="px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted"
652
+ className={cn(
653
+ GROUP_HEADING_STICKY,
654
+ "px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted",
655
+ )}
651
656
  >
652
657
  {o.group}
653
658
  </li>
@@ -710,7 +715,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
710
715
  {o.group && o.group !== matches[i - 1]?.group && (
711
716
  <li
712
717
  role="presentation"
713
- className="px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1"
718
+ className={cn(
719
+ GROUP_HEADING_STICKY,
720
+ "px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1",
721
+ )}
714
722
  >
715
723
  {o.group}
716
724
  </li>
@@ -6,6 +6,7 @@ import { cn } from "../lib/cn";
6
6
  import { mergeRefs } from "./field-parts";
7
7
  import { CLIPS_ATTRIBUTE } from "../lib/clipping";
8
8
  import { DropdownSearchHeader } from "./dropdown";
9
+ import { Button } from "./button";
9
10
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
10
11
  import { useOverlayHistory } from "../hooks/use-overlay-history";
11
12
  import { useFocusTrap } from "../hooks/use-focus-trap";
@@ -46,6 +47,23 @@ export interface PickerSheetProps
46
47
  /** The option the arrow keys are on, for the search box's `aria-activedescendant`. */
47
48
  activeId?: string;
48
49
  children: ReactNode;
50
+ /**
51
+ * A row pinned under the scrolling list — {@link FullBleedDialog}'s `footer`, on the
52
+ * picker's sheet (kk run 79 gap 2). Outside the scroller, so it stays put however long
53
+ * the list; padded for the home indicator, since it is the sheet's bottom edge.
54
+ */
55
+ footer?: ReactNode;
56
+ /**
57
+ * The kit's one sticky "Done" at the foot of the sheet (Marcel, keksdose #412): the
58
+ * button a multi-select sheet is left by once its rows are ticked. Ticks apply as
59
+ * they are made, so it closes (`onClose`) rather than saves. After any `footer`.
60
+ * {@link ComboboxPanel} turns it on for every `multi` list — `MultiSelect` and
61
+ * `MultiEntityCombobox` — and a single-select sheet, which closes on the tap that
62
+ * picks, has no use for it. Label: `pickerSheet.done`, else "Done" (`doneLabel`).
63
+ */
64
+ done?: boolean;
65
+ /** Default: `pickerSheet.done` from the {@link UiKitProvider}, else "Done". */
66
+ doneLabel?: string;
49
67
  /** The sheet, where the rest of the props go (0.34, API-2, refactor audit 2026-10) —
50
68
  * merged with the sheet's own ref, which its focus trap holds. */
51
69
  ref?: Ref<HTMLDivElement>;
@@ -88,6 +106,9 @@ export function PickerSheet({
88
106
  listboxId,
89
107
  activeId,
90
108
  children,
109
+ footer,
110
+ done,
111
+ doneLabel,
91
112
  "aria-label": ariaLabel,
92
113
  className,
93
114
  style,
@@ -98,7 +119,8 @@ export function PickerSheet({
98
119
  // their own `closeLabel` through as `undefined` — so this lookup is what makes the
99
120
  // one way out of a full-screen sheet speak the app's language without each of
100
121
  // them having to (see the prop's note in `EntityCombobox`).
101
- const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });
122
+ const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel, done: doneLabel });
123
+ const hasFooter = (footer !== undefined && footer !== null && footer !== false) || Boolean(done);
102
124
  useBodyScrollLock(open);
103
125
  /**
104
126
  * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:
@@ -276,6 +298,21 @@ export function PickerSheet({
276
298
  <div {...{ [CLIPS_ATTRIBUTE]: "" }} className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
277
299
  {children}
278
300
  </div>
301
+ {hasFooter && (
302
+ <div
303
+ data-picker-sheet-footer=""
304
+ className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-subtle bg-surface px-3 pt-3"
305
+ style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
306
+ >
307
+ {footer}
308
+ {done && (
309
+ // Full width: the one thing left to do on the sheet, under the thumb.
310
+ <Button type="button" className="flex-1" onClick={onClose}>
311
+ {labels.done}
312
+ </Button>
313
+ )}
314
+ </div>
315
+ )}
279
316
  </div>,
280
317
  document.body,
281
318
  );
@@ -1,11 +1,12 @@
1
1
  import { useLayoutEffect, useMemo, useRef } from "react";
2
- import { Plus, X } from "lucide-react";
2
+ import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
3
3
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { FOCUS_RING_WIDTH } from "./focus-ring";
6
6
  import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
7
7
  import { horizontalStep } from "../lib/direction";
8
8
  import { usePhoneLayout } from "../hooks/use-breakpoint";
9
+ import { prefersReducedMotion } from "../lib/reduced-motion";
9
10
  import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
10
11
  import { mergeRefs } from "./field-parts";
11
12
  import { DEFAULT_TABS_LABELS } from "./ui-labels";
@@ -47,6 +48,10 @@ export interface TabsLabels {
47
48
  add: string;
48
49
  /** Accessible name of a tab's × — "Remove <tab>". */
49
50
  remove: (tab: string) => string;
51
+ /** The `overflowCue` caret while tabs lie past the strip's end: "More tabs". */
52
+ more: string;
53
+ /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */
54
+ backToFirst: string;
50
55
  }
51
56
 
52
57
  // Defined in ./ui-labels, which holds no component code (0.34, KS U8).
@@ -94,8 +99,28 @@ export interface TabsProps<T extends string>
94
99
  *
95
100
  * Opt-in on purpose. The three-tab strips (invoices, statements, bank imports)
96
101
  * already fit a phone row, and turning those into chips would be an unrequested
97
- * redesign of three other pages. */
102
+ * redesign of three other pages.
103
+ *
104
+ * @deprecated 0.40 — use the scrolling strip with `overflowCue`; removed in 0.41. No
105
+ * consumer is left (keksdose went back to one scrolling row, live #410); the caret
106
+ * says a phone's row goes on. */
98
107
  wrap?: boolean;
108
+ /**
109
+ * The caret beside a SCROLLING strip below `md` (0.40, kk run 79 gap 1): a › while
110
+ * tabs lie past the strip's end — a tap scrolls it on by three quarters of its width —
111
+ * turning into a ‹ at the end, which goes back to the first. Shown only while the strip
112
+ * actually overflows.
113
+ *
114
+ * The edge fade alone could read as the end of the row: at the default scroll position
115
+ * the last visible tab can finish exactly where the fade begins, so nothing looks cut
116
+ * off (keksdose live #410, whose `tab-strip-more.tsx` this replaces). One button that
117
+ * turns round rather than two that come and go, so the strip's width never changes
118
+ * under the thumb. Out of the tab order — the keyboard walks the strip with the arrow
119
+ * keys, which bring the focused tab into view. From `md` up nothing changes. Ignored on
120
+ * a `wrap` strip and a vertical one (a side nav is a column, not a row). Names:
121
+ * `tabs.more` / `tabs.backToFirst`.
122
+ */
123
+ overflowCue?: boolean;
99
124
  /**
100
125
  * `id` of the element the caller renders the open tab's content into — the missing
101
126
  * half of `role="tab"`. A tab that controls nothing is a tab in name only: a screen
@@ -266,6 +291,7 @@ export function Tabs<T extends string>({
266
291
  onChange,
267
292
  className,
268
293
  wrap = false,
294
+ overflowCue = false,
269
295
  panelId,
270
296
  onRemove,
271
297
  removeOn = "every",
@@ -285,6 +311,36 @@ export function Tabs<T extends string>({
285
311
  const stripRef = useRef<HTMLDivElement>(null);
286
312
  const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
287
313
  const fade = useStripFade(stripRef);
314
+ // See `overflowCue`. `start` alone means the strip is scrolled to its end.
315
+ const cue = overflowCue && !wrap && !vertical && fade.overflow !== undefined;
316
+ const cueAtEnd = fade.overflow === "start";
317
+ const onCue = () => {
318
+ const strip = stripRef.current;
319
+ if (!strip) return;
320
+ const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
321
+ if (cueAtEnd) {
322
+ strip.scrollTo({ left: 0, behavior });
323
+ return;
324
+ }
325
+ const rtl = getComputedStyle(strip).direction === "rtl";
326
+ strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });
327
+ };
328
+ const cueButton = cue ? (
329
+ // The strip's bottom rule continues under the caret, so the row reads as one.
330
+ <div data-tabs-overflow-cue="" className="flex shrink-0 items-center self-stretch border-b border-subtle md:hidden">
331
+ <IconButton
332
+ size="sm"
333
+ variant="ghost"
334
+ tone="muted"
335
+ tabIndex={-1}
336
+ type="button"
337
+ aria-label={cueAtEnd ? text.backToFirst : text.more}
338
+ onClick={onCue}
339
+ >
340
+ {cueAtEnd ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
341
+ </IconButton>
342
+ </div>
343
+ ) : null;
288
344
  // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
289
345
  // deep link to the seventh report. A selection behind the cut edge was the worst
290
346
  // case of the overflow above: the one tab the panel below belongs to, sliced to
@@ -410,7 +466,7 @@ export function Tabs<T extends string>({
410
466
  // — it is the box a caller's margin or width is meant for.
411
467
  className={cn(
412
468
  vertical ? TABLIST_VERTICAL_CLASSES : wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
413
- onAdd ? "min-w-0" : className,
469
+ onAdd ? "min-w-0" : overflowCue && !wrap && !vertical ? "min-w-0 flex-1" : className,
414
470
  )}
415
471
  >
416
472
  {tabs.map((tab) => {
@@ -598,6 +654,19 @@ export function Tabs<T extends string>({
598
654
  {text.remove(nameOf(activeTab))}
599
655
  </button>
600
656
  ) : null;
657
+ if (!onAdd && overflowCue && !wrap && !vertical) {
658
+ // The caret needs a row to sit in beside the strip, and `className` goes there — it
659
+ // is the box a caller's margin or width is meant for, as with an add button. The box
660
+ // is there whether or not the strip overflows right now, so a resize never moves
661
+ // the caller's class from one element to another.
662
+ return (
663
+ <div className={cn("flex items-end", className)}>
664
+ {tablist}
665
+ {removeButton}
666
+ {cueButton}
667
+ </div>
668
+ );
669
+ }
601
670
  if (!onAdd) {
602
671
  // No outer box: the strip stays the root, as callers' `className` and tests expect.
603
672
  return removeButton ? (
@@ -631,6 +700,7 @@ export function Tabs<T extends string>({
631
700
  >
632
701
  {tablist}
633
702
  {removeButton}
703
+ {cueButton}
634
704
  {/* Outside the tablist: an action, not a tab — and a tablist may own only
635
705
  tabs. The rule under the strip carries on beneath it. */}
636
706
  <div
@@ -169,7 +169,8 @@ export interface TranslationLocaleTabsProps {
169
169
  }
170
170
 
171
171
  /**
172
- * One tab per locale with its approved/total, wrapping on a phone. Nothing for a single
172
+ * One tab per locale with its approved/total, one scrolling row on a phone with the
173
+ * strip's caret saying it goes on (`overflowCue`; `wrap` is deprecated in 0.40). Nothing for a single
173
174
  * locale: a strip with one tab is a heading that looks clickable.
174
175
  */
175
176
  export function TranslationLocaleTabs({ locales, active, onChange, className, labels: labelsProp }: TranslationLocaleTabsProps) {
@@ -178,7 +179,7 @@ export function TranslationLocaleTabs({ locales, active, onChange, className, la
178
179
  return (
179
180
  <Tabs
180
181
  aria-label={labels.locales}
181
- wrap
182
+ overflowCue
182
183
  className={className}
183
184
  tabs={locales.map((locale) => {
184
185
  const summary = summariseRows(locale.rows);
@@ -19,4 +19,6 @@ export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
19
19
  export const DEFAULT_TABS_LABELS: TabsLabels = {
20
20
  add: "Add tab",
21
21
  remove: (tab) => `Remove ${tab}`,
22
+ more: "More tabs",
23
+ backToFirst: "Back to the first tab",
22
24
  };
@@ -8,9 +8,10 @@ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
8
8
  import { FeedbackStatusBadge } from "./feedback-inbox";
9
9
  import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
10
10
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
11
- import { FEEDBACK_COMMENT_MAX } from "./feedback-record";
11
+ import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
12
12
  import { useFeedbackRepliesLabels } from "./feedback-replies";
13
13
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
14
+ import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
14
15
  import { ChatComposer, FeedbackThread } from "./feedback-thread";
15
16
  import type { FeedbackThreadMessage } from "./feedback-thread";
16
17
  import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
@@ -23,6 +24,10 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
23
24
  *
24
25
  * - **Replies** are drawn as staff posts with the status they set ("→ Done"); a reply
25
26
  * without an author is "Admin" (§9.7).
27
+ * - **Reworks** (0.40, §10.3, `kind: "rework"`) are the report's author's: the
28
+ * **Rework** chip, "→ status" when the entry moved one (a migrated one did not), the
29
+ * file when the view carries it; at the reading end for the author ({@link
30
+ * FeedbackCommentThreadProps.isAuthor}), and a `null` name is "A user", never "Admin".
26
31
  * - **Comments**: the viewer's own at the reading end ("You"); someone else's under the
27
32
  * first name the server sent, `null` as "A user" (§9.5, `feedbackPublic.anonymous`).
28
33
  * - **Hidden** (§9.8): others see "Hidden by an admin" and no name, no text; the writer
@@ -81,6 +86,9 @@ export interface FeedbackCommentThreadProps {
81
86
  comments: readonly FeedbackComment[];
82
87
  /** The viewer is the app's admin: Hide / Unhide, the hidden text struck through. */
83
88
  isAdmin: boolean;
89
+ /** 0.40: the viewer filed the report (the record's `mine`) — its rework entries are
90
+ * theirs, drawn at the reading end as "You". Default `false`. */
91
+ isAuthor?: boolean;
84
92
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
85
93
  * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
86
94
  * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
@@ -125,6 +133,7 @@ export function FeedbackCommentThread({
125
133
  replies: repliesProp = [],
126
134
  comments,
127
135
  isAdmin,
136
+ isAuthor = false,
128
137
  onComment,
129
138
  onHide,
130
139
  onDelete,
@@ -143,6 +152,35 @@ export function FeedbackCommentThread({
143
152
 
144
153
  const entries: Entry[] = [];
145
154
  replies.forEach((reply, order) => {
155
+ if (reply.kind === "rework") {
156
+ entries.push({
157
+ at: instant(reply.created_at),
158
+ order,
159
+ message: {
160
+ id: `reply-${reply.id}`,
161
+ own: isAuthor,
162
+ author: isAuthor ? undefined : (reply.author_name ?? anonymous),
163
+ createdAt: reply.created_at,
164
+ body: reply.body,
165
+ attachments: reply.attachment_url
166
+ ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
167
+ : undefined,
168
+ badge: (
169
+ <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-entry-kind="rework">
170
+ <FeedbackReworkEntryChip />
171
+ {reply.status_to && (
172
+ <span className="inline-flex items-center gap-1" data-feedback-reply-status={reply.status_to}>
173
+ <span className="sr-only">{replyLabels.statusChanged}</span>
174
+ <span aria-hidden>→</span>
175
+ <FeedbackStatusBadge status={reply.status_to} />
176
+ </span>
177
+ )}
178
+ </span>
179
+ ),
180
+ },
181
+ });
182
+ return;
183
+ }
146
184
  entries.push({
147
185
  at: instant(reply.created_at),
148
186
  order,
@@ -24,7 +24,8 @@ import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary";
24
24
  * `feedbackStatus` — one word per status, keyed by the enum value so
25
25
  * `labels[row.status]` is the whole lookup.
26
26
  *
27
- * de-CH canon: OPEN "Offen" · READY "Bereit zur Umsetzung" · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
27
+ * de-CH canon: OPEN "Offen" · READY "Bereit zur Umsetzung" · REWORK "Nacharbeit angefordert"
28
+ * (0.40, §10.6) · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
28
29
  * · NEEDS_LIVE_TEST "Live testen" · POSTPONED "Zurückgestellt" · DONE "Erledigt" ·
29
30
  * WONT_DO "Wird nicht umgesetzt".
30
31
  */
@@ -33,6 +34,7 @@ export type FeedbackStatusLabels = Record<FeedbackStatus, string>;
33
34
  export const DEFAULT_FEEDBACK_STATUS_LABELS: FeedbackStatusLabels = {
34
35
  OPEN: "Open",
35
36
  READY: "Ready to implement",
37
+ REWORK: "Rework requested",
36
38
  IN_PROGRESS: "In progress",
37
39
  IN_EVALUATION: "In evaluation",
38
40
  NEEDS_LIVE_TEST: "Test when live",
@@ -9,4 +9,6 @@ export const DEFAULT_FEEDBACK_MENU_LABELS: FeedbackMenuLabels = {
9
9
  myFeedback: "My feedback",
10
10
  viewFeedback: "View feedback",
11
11
  allFeedback: "All feedback",
12
+ discussion: "Feedback discussion",
13
+ inbox: "Feedback inbox",
12
14
  };