@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
@@ -44,6 +44,16 @@ export interface BillingOverview {
44
44
  * is what shows that a switch landed; `checkoutFingerprint` reads it.
45
45
  */
46
46
  paid: PaidPrice | null;
47
+ /**
48
+ * 0.40 (server-kit 0.13, §15.2.10): a provider subscription runs — the server's own
49
+ * rule, the one its checkout (409 `billing_already_subscribed`) and switch (409
50
+ * `billing_not_subscribed`) routes apply, so exactly one of the two answers. While it
51
+ * is true a `PlanPicker` pick opens `PlanSwitchConfirm` (`POST /billing/plan/preview`,
52
+ * then `POST /billing/plan`); while false, the checkout (`POST /billing/checkout`).
53
+ * Read it instead of mirroring the rule on the client. Optional so an overview from
54
+ * server-kit before 0.13 still type-checks; absent, read it as false.
55
+ */
56
+ switching?: boolean;
47
57
  }
48
58
 
49
59
  /** 0.39: what the payer pays at the provider (server-kit's `PaidPrice`, §15.2.10),
@@ -76,9 +86,9 @@ export interface PlanSwitchPreview {
76
86
  * charges. */
77
87
  now_minor: number;
78
88
  /** The next regular charge, in minor units. */
79
- next_minor: number | null;
89
+ next_minor?: number | null;
80
90
  /** When it is charged; null when Paddle names no date. */
81
- next_billed_at: string | null;
91
+ next_billed_at?: string | null;
82
92
  /** The plan and period switched to. */
83
93
  plan: string;
84
94
  interval: BillingInterval;
@@ -208,7 +208,7 @@ function appAt(name: string, site: string, returnPath: string): PayPageApp {
208
208
  */
209
209
  export const PAY_PAGE_APPS: Readonly<Record<string, Readonly<PayPageApp>>> = Object.freeze({
210
210
  keksdose: Object.freeze(appAt("Keksdose", "https://keksdose.app", "/settings/subscription")),
211
- kastlan: Object.freeze(appAt("kastlan", "https://kastlan.app", "/admin/billing")),
211
+ kastlan: Object.freeze(appAt("Kastlan", "https://kastlan.app", "/admin/billing")),
212
212
  kurvenschmiede: Object.freeze(appAt("Kurvenschmiede", "https://kurvenschmiede.app", "/settings/subscription")),
213
213
  });
214
214
 
@@ -12,6 +12,7 @@ import {
12
12
  ComboboxFieldLabel,
13
13
  DISABLED_ROW_CLASS,
14
14
  endHintRowProps,
15
+ GROUP_HEADING_STICKY,
15
16
  isOptionEnabled,
16
17
  stepEnabled,
17
18
  SUGGESTION_LIST_CLASS,
@@ -547,7 +548,10 @@ export function Autocomplete<V extends string | number = string>(props: Autocomp
547
548
  {startsGroup && (
548
549
  <li
549
550
  role="presentation"
550
- className="px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1"
551
+ className={cn(
552
+ GROUP_HEADING_STICKY,
553
+ "px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1",
554
+ )}
551
555
  >
552
556
  {o.group}
553
557
  </li>
@@ -51,7 +51,11 @@ export interface ComboOption<V extends string | number> {
51
51
  * members indented beneath — {@link InlineEntityCombobox} does — this is a genuine
52
52
  * grouping, not decoration: the name appears once per section rather than trailing
53
53
  * every row in small grey type, which is barely legible on a phone (feedback #136).
54
- * Searchable like `sublabel`.
54
+ * The heading stays at the top of the list while its rows scroll beneath it.
55
+ *
56
+ * NOT searched (0.40, kk run 79): a group is where a row is filed, not what it is
57
+ * called — with first-letter groups ("A", "B", … "#"), typing "a" matched every row
58
+ * filed under A. Searching a category's name means putting it in `sublabel`.
55
59
  */
56
60
  group?: string;
57
61
  /**
@@ -463,6 +467,16 @@ export function useComboboxCore<V extends string | number>({
463
467
 
464
468
  /** The anchored suggestion list's box and rows — one look for {@link Combobox},
465
469
  * {@link InlineEntityCombobox} and {@link Autocomplete}. */
470
+ /**
471
+ * A group's heading in a picker list stays at the top of the list while its rows scroll
472
+ * under it (kk run 79 gap 3): with first-letter groups over two hundred payees, the
473
+ * heading is the one thing that says where in the alphabet the thumb is. Opaque — the
474
+ * list's own `bg-surface` — so the rows pass beneath it rather than through it. Shared
475
+ * by every list that heads groups (the panel and the sheet, `Combobox`, `Autocomplete`,
476
+ * `InlineEntityCombobox`).
477
+ */
478
+ export const GROUP_HEADING_STICKY = "sticky top-0 z-10 bg-surface";
479
+
466
480
  export const SUGGESTION_LIST_CLASS =
467
481
  "max-h-64 overflow-y-auto rounded-md border border-subtle bg-surface py-1 shadow-lg";
468
482
 
@@ -964,6 +978,7 @@ export function ComboboxPanel<V extends string | number>({
964
978
  <li
965
979
  role="presentation"
966
980
  className={cn(
981
+ GROUP_HEADING_STICKY,
967
982
  "font-semibold uppercase tracking-wide text-muted",
968
983
  isPhone ? "px-4 pb-1 pt-3 text-xs" : "px-3 pb-0.5 pt-2 text-caption first:pt-1",
969
984
  )}
@@ -1009,6 +1024,10 @@ export function ComboboxPanel<V extends string | number>({
1009
1024
  onSearchKeyDown={onKeyDown}
1010
1025
  listboxId={listboxId}
1011
1026
  activeId={activeId}
1027
+ // A multi list stays open as rows are ticked, so the sheet needs a way out
1028
+ // that is not the X in its corner: the kit's sticky "Done" (keksdose #412).
1029
+ // A single pick closes the sheet itself.
1030
+ done={multi}
1012
1031
  >
1013
1032
  {toolbar}
1014
1033
  <div className="relative">
@@ -8,7 +8,7 @@ import { takeEscape, useDismissLayer } from "../hooks/use-dismiss";
8
8
  import { isImeEnter } from "../lib/ime";
9
9
  import { usePhoneLayout } from "../hooks/use-breakpoint";
10
10
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
- import { ComboboxFieldLabel, endHintRowProps, useActiveOptionScroll } from "./combobox-core";
11
+ import { ComboboxFieldLabel, endHintRowProps, GROUP_HEADING_STICKY, useActiveOptionScroll } from "./combobox-core";
12
12
  import { useKitLocale } from "../i18n/kit-labels";
13
13
  import { rankMatch } from "../lib/text-match";
14
14
  import {
@@ -574,7 +574,10 @@ export function Combobox(props: ComboboxProps) {
574
574
  {group !== undefined && (
575
575
  <li
576
576
  role="presentation"
577
- className="px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted"
577
+ className={cn(
578
+ GROUP_HEADING_STICKY,
579
+ "px-4 pb-0.5 pt-3 text-caption font-semibold uppercase tracking-wide text-muted",
580
+ )}
578
581
  >
579
582
  {group}
580
583
  </li>
@@ -631,7 +634,10 @@ export function Combobox(props: ComboboxProps) {
631
634
  {group !== undefined && (
632
635
  <li
633
636
  role="presentation"
634
- className="px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1"
637
+ className={cn(
638
+ GROUP_HEADING_STICKY,
639
+ "px-3 pb-0.5 pt-2 text-caption font-semibold uppercase tracking-wide text-muted first:pt-1",
640
+ )}
635
641
  >
636
642
  {group}
637
643
  </li>
@@ -18,6 +18,11 @@ export interface DataTableLabels extends DataTableSortLabels {
18
18
  filter: string;
19
19
  close: string;
20
20
  selectRow: string;
21
+ /** The phone card list's way into selection mode for a keyboard and a screen reader
22
+ * (0.40, #421) — a button, visually hidden until focused; a long press is the touch way in. */
23
+ selectRows: string;
24
+ /** Leaves the phone card list's selection mode, clearing the selection (0.40, #421). */
25
+ cancelSelection: string;
21
26
  /**
22
27
  * The name of an opening row's disclosure button on desktop — visually hidden, in the
23
28
  * row's first cell, carrying `aria-expanded` and `aria-controls` (round 0.34, the
@@ -116,6 +121,8 @@ export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
116
121
  filter: "Filter",
117
122
  close: "Close",
118
123
  selectRow: "Select row",
124
+ selectRows: "Select rows",
125
+ cancelSelection: "Cancel",
119
126
  rowDetails: "Details",
120
127
  autoSize: "Auto-size columns",
121
128
  loading: "Loading…",
@@ -1,4 +1,4 @@
1
- import { Fragment, useId, useMemo, useState } from "react";
1
+ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
  import { ArrowDownWideNarrow, ArrowUpNarrowWide, ChevronDown, ChevronRight, Filter } from "lucide-react";
4
4
  import { FOCUS_RING } from "./focus-ring";
@@ -12,6 +12,8 @@ import type { SortCycle, SortState } from "./data-table-sort";
12
12
  import { FilterPopover } from "./data-table-filter-popover";
13
13
  import { SwipeableRow, type SwipeAction } from "./swipeable-row";
14
14
  import { DialogFrame } from "./dialog-frame";
15
+ import { Checkbox } from "./checkbox";
16
+ import { useLongPress } from "../hooks/use-long-press";
15
17
  import type { DataTableLabels } from "./data-table-labels";
16
18
  import { RowLink, cleanAttrs, columnLabel, fromOutside } from "./data-table-shared";
17
19
  import type {
@@ -202,6 +204,20 @@ export interface MobileRowProps<T> {
202
204
  compact: boolean;
203
205
  /** The text size is Large or Extra large (§4). */
204
206
  large: boolean;
207
+ /** Phone selection (0.40, keksdose live #421); null without DataTable's `selection`. */
208
+ select: MobileRowSelect | null;
209
+ }
210
+
211
+ /** A phone card's part in DataTable's `selection` (0.40, keksdose live #421). */
212
+ export interface MobileRowSelect {
213
+ /** The card list is in selection mode: the card shows its box and a tap toggles it. */
214
+ active: boolean;
215
+ /** `selection.isSelectable` for this row. */
216
+ selectable: boolean;
217
+ selected: boolean;
218
+ onToggle: (checked: boolean) => void;
219
+ /** A long press outside the mode: enter it with this row selected. */
220
+ onLongPress: () => void;
205
221
  }
206
222
 
207
223
  /** One mobile card. Shared by the flat list and the grouped list, so both draw
@@ -224,8 +240,17 @@ export function MobileRow<T>({
224
240
  mobileExpandAsDialog,
225
241
  compact,
226
242
  large,
243
+ select,
227
244
  }: MobileRowProps<T>) {
228
245
  const expanded = isExpanded?.(row) ?? false;
246
+ // Selection mode (#421): the whole card is the row's checkbox — a tap toggles it
247
+ // instead of opening the row, and the swipe and the long press stand down, so no
248
+ // gesture means two things at once (keksdose's register list, its feedback #153).
249
+ const selecting = !!select?.active;
250
+ // Not on an open card, as in kk's register: its editor and a selection are two modes
251
+ // fighting over the same row.
252
+ const pressable = !!select && select.selectable && !selecting && !expanded;
253
+ const longPress = useLongPress(() => select?.onLongPress(), { enabled: pressable });
229
254
  const expansion = expanded ? openedRowContent(row) : null;
230
255
  const tint = rowClassName?.(row);
231
256
  // Use div+role="button" rather than a real <button> so cells that
@@ -241,9 +266,12 @@ export function MobileRow<T>({
241
266
  const opensDetail = interactive && opensRow;
242
267
  const swipeActions = swipeActionsFor?.(row);
243
268
  const swipe = swipeActions ? physicalSwipe(swipeActions, mobileDir) : null;
244
- // An expanded row never swipes: the editor below it owns the horizontal space,
245
- // and dragging the header away from its own form reads as a glitch.
246
- const swipeEnabled = !!swipe && !expanded;
269
+ // An OPEN card swipes too (0.40, keksdose live #416, Marcel: "faster than scrolling
270
+ // down to the buttons"). Only the card's header — the summary line — is inside
271
+ // SwipeableRow; the opened body below is a sibling, so its fields, sliders and
272
+ // scrolling never meet the gesture. Until 0.40 an open card silently lost it.
273
+ // In selection mode a sideways drag would be a second meaning for a selecting touch.
274
+ const swipeEnabled = !!swipe && !selecting;
247
275
  const href = mobileCardLinkable ? rowHref?.(row) : undefined;
248
276
  const cardClass = cn(
249
277
  "w-full text-start flex items-center",
@@ -264,7 +292,13 @@ export function MobileRow<T>({
264
292
  // AppShell main's `overflow-x-clip` — so the frame went missing on one side. A press
265
293
  // keeps the fill and the squeeze above; an outline on every tap of a long list would
266
294
  // be louder than the acknowledgement it replaces.
267
- interactive && cn("[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset", FOCUS_RING),
295
+ (interactive || selecting) && cn("[-webkit-tap-highlight-color:transparent] focus-visible:ring-inset", FOCUS_RING),
296
+ selecting && "cursor-pointer",
297
+ select?.selected && selecting && "bg-brand-soft",
298
+ // A 500 ms hold would otherwise start a text selection, and iOS's link callout —
299
+ // which only yields to this CSS. The trade kk made on its register: the card's text
300
+ // is not selectable on a phone where the table offers selection.
301
+ select && "select-none [-webkit-touch-callout:none]",
268
302
  );
269
303
  // The card's content is written once and worn by either tag below. Note that
270
304
  // the primary cell is rendered RAW here, never through `linkColumn` — the card
@@ -324,7 +358,7 @@ export function MobileRow<T>({
324
358
  </>
325
359
  )}
326
360
  </div>
327
- {opensDetail &&
361
+ {opensDetail && !selecting &&
328
362
  // `aria-hidden`: the row already announces itself as a button with
329
363
  // `aria-expanded`, so the icon would only add a second, wordless stop.
330
364
  (mobileExpandAsDialog ? (
@@ -344,13 +378,56 @@ export function MobileRow<T>({
344
378
  ))}
345
379
  </>
346
380
  );
347
- const body =
348
- interactive && href ? (
381
+ // The long press rides the card itself; the click it leaves behind is swallowed on
382
+ // the `<li>` below, in the capture phase, before the card (or RowLink) sees it.
383
+ // Attached whenever the table selects, the hook's `enabled` deciding whether a press
384
+ // arms: its pointerdown also clears a stale swallow latch, which must happen in the
385
+ // mode too, or a long press whose click never came would eat the first toggle.
386
+ const press = select ? longPress.handlers : undefined;
387
+ const body = selecting ? (
388
+ // In the mode a card is a toggle — kk's register row: `role="button"` with
389
+ // `aria-pressed`, the box beside it only a picture of that state (the card is the
390
+ // one control, so the two can never fight or fire twice).
391
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role="button",
392
+ tabIndex 0 and Enter/Space are all here; the rule cannot follow the conditional role. */
393
+ <div
394
+ role={select!.selectable ? "button" : undefined}
395
+ tabIndex={select!.selectable ? 0 : undefined}
396
+ aria-pressed={select!.selectable ? select!.selected : undefined}
397
+ aria-disabled={select!.selectable ? undefined : true}
398
+ data-card-selecting=""
399
+ {...press}
400
+ onClick={(e) => {
401
+ if (!select!.selectable || fromOutside(e.target, e.currentTarget)) return;
402
+ select!.onToggle(!select!.selected);
403
+ }}
404
+ onKeyDown={(e) => {
405
+ if (!select!.selectable || e.target !== e.currentTarget) return;
406
+ if (e.key === "Enter" || e.key === " ") {
407
+ e.preventDefault();
408
+ select!.onToggle(!select!.selected);
409
+ }
410
+ }}
411
+ className={cardClass}
412
+ >
413
+ <Checkbox
414
+ readOnly
415
+ tabIndex={-1}
416
+ aria-hidden
417
+ checked={select!.selected}
418
+ disabled={!select!.selectable}
419
+ className="pointer-events-none shrink-0 self-center"
420
+ inputClassName="pointer-events-none"
421
+ />
422
+ {cardInner}
423
+ </div>
424
+ ) : interactive && href ? (
349
425
  // A card with a URL of its own is a LINK, not a div wearing role="button"
350
426
  // (feedback #451): the phone's long-press "open in new tab" and a screen
351
427
  // reader's link semantics both come free, and the fake role goes away.
352
428
  <RowLink
353
429
  href={href}
430
+ {...press}
354
431
  onActivate={() => onRowClick!(row)}
355
432
  aria-expanded={opensRow ? expanded : undefined}
356
433
  className={cardClass}
@@ -371,6 +448,7 @@ export function MobileRow<T>({
371
448
  only attached when `interactive`, and then so are role="button", tabIndex 0 and the
372
449
  Enter/Space handler below; the rule cannot follow the conditional role. */
373
450
  <div
451
+ {...press}
374
452
  role={interactive ? "button" : undefined}
375
453
  tabIndex={interactive ? 0 : undefined}
376
454
  onClick={
@@ -400,7 +478,20 @@ export function MobileRow<T>({
400
478
  </div>
401
479
  );
402
480
  return (
403
- <li className={cn(tint)} {...cleanAttrs(rowAttributes?.(row))}>
481
+ <li
482
+ className={cn(tint)}
483
+ {...cleanAttrs(rowAttributes?.(row))}
484
+ data-selected={selecting && select!.selected ? "" : undefined}
485
+ onClickCapture={
486
+ select
487
+ ? (e) => {
488
+ if (!longPress.consumeClick()) return;
489
+ e.preventDefault();
490
+ e.stopPropagation();
491
+ }
492
+ : undefined
493
+ }
494
+ >
404
495
  {swipeEnabled ? (
405
496
  <SwipeableRow enabled left={swipe!.left} right={swipe!.right}>
406
497
  {body}
@@ -422,6 +513,99 @@ export function MobileRow<T>({
422
513
  );
423
514
  }
424
515
 
516
+ // ---------- Phone selection bar (0.40, keksdose live #421) ----------
517
+
518
+ /**
519
+ * The card list's way into and out of selection mode for a keyboard and a screen
520
+ * reader — the long press is a touch gesture, and a gesture alone is no path at all.
521
+ *
522
+ * Outside the mode it is one button, "Select rows", visually hidden until it takes
523
+ * focus (SwipeableRow's keyboard buttons, the same rule): the card list keeps the look
524
+ * kk's register has, where the long press is the way in, and Tab or a screen reader's
525
+ * cursor still finds it. In the mode it is a visible row: "Select all rows" — the
526
+ * desktop header's box — and Cancel, which clears the selection and leaves, as the
527
+ * bulk bar's Clear does. The owner's bulk bar is the owner's, as on desktop: it
528
+ * appears because the selection is no longer empty.
529
+ */
530
+ export function MobileSelectBar({
531
+ active,
532
+ allSelected,
533
+ someSelected,
534
+ compact,
535
+ labels,
536
+ onEnter,
537
+ onToggleAll,
538
+ onCancel,
539
+ }: {
540
+ active: boolean;
541
+ allSelected: boolean;
542
+ someSelected: boolean;
543
+ compact: boolean;
544
+ labels: DataTableLabels;
545
+ onEnter: () => void;
546
+ onToggleAll: (checked: boolean) => void;
547
+ onCancel: () => void;
548
+ }) {
549
+ // The control that was pressed unmounts with the mode it switched, so focus is
550
+ // handed across: into the bar's box on the way in, back to the button on the way out.
551
+ const handOff = useRef(false);
552
+ const allBox = useRef<HTMLInputElement>(null);
553
+ const enterButton = useRef<HTMLButtonElement>(null);
554
+ useEffect(() => {
555
+ if (!handOff.current) return;
556
+ handOff.current = false;
557
+ (active ? allBox.current : enterButton.current)?.focus();
558
+ }, [active]);
559
+ if (!active)
560
+ return (
561
+ <button
562
+ ref={enterButton}
563
+ type="button"
564
+ data-table-mobile-select=""
565
+ onClick={() => {
566
+ handOff.current = true;
567
+ onEnter();
568
+ }}
569
+ className={cn(
570
+ "sr-only rounded-md text-sm font-medium text-secondary",
571
+ // Padding only once shown: unconditional, it beats sr-only's `padding: 0`
572
+ // (SwipeableRow's 0.32.1 note).
573
+ "focus:not-sr-only focus:m-2 focus:inline-flex focus:px-2 focus:py-1",
574
+ FOCUS_RING,
575
+ )}
576
+ >
577
+ {labels.selectRows}
578
+ </button>
579
+ );
580
+ return (
581
+ <div
582
+ data-table-mobile-select=""
583
+ className={cn(
584
+ "flex flex-wrap items-center justify-between gap-2 border-b border-subtle",
585
+ compact ? "px-3 py-1.5" : "px-4 py-2",
586
+ )}
587
+ >
588
+ <Checkbox
589
+ ref={allBox}
590
+ label={labels.selectAllRows}
591
+ checked={allSelected}
592
+ indeterminate={someSelected && !allSelected}
593
+ onChange={(e) => onToggleAll(e.target.checked)}
594
+ />
595
+ <button
596
+ type="button"
597
+ onClick={() => {
598
+ handOff.current = true;
599
+ onCancel();
600
+ }}
601
+ className={cn("rounded-md px-2 py-1 text-sm text-muted hover:text-secondary", FOCUS_RING)}
602
+ >
603
+ {labels.cancelSelection}
604
+ </button>
605
+ </div>
606
+ );
607
+ }
608
+
425
609
  // ---------- Mobile filter sheet ----------
426
610
 
427
611
  /**