@eifi1/ui-kit 0.21.0 → 0.22.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 (278) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. package/src/tour/tour.tsx +66 -28
@@ -3,8 +3,10 @@ import type { CSSProperties, ReactNode, TdHTMLAttributes, ThHTMLAttributes } fro
3
3
  import { createPortal } from "react-dom";
4
4
  import {
5
5
  ArrowDown,
6
+ ArrowDownWideNarrow,
6
7
  ArrowUp,
7
8
  ArrowUpDown,
9
+ ArrowUpNarrowWide,
8
10
  ChevronDown,
9
11
  ChevronRight,
10
12
  Filter,
@@ -12,7 +14,7 @@ import {
12
14
  Trash2,
13
15
  X,
14
16
  } from "lucide-react";
15
- import { Card, IconButton, Spinner } from "./ui";
17
+ import { Card, IconButton, Select, Spinner } from "./ui";
16
18
  import { cn } from "../lib/cn";
17
19
  import {
18
20
  defaultFilterState,
@@ -33,7 +35,7 @@ import { FullBleedDialog } from "./full-bleed-dialog";
33
35
  import { Popover } from "./popover";
34
36
  import { useMediaQuery } from "../hooks/use-media-query";
35
37
  import { useAnnounce } from "../hooks/use-announce";
36
- import { resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
38
+ import { DEFAULT_DATA_TABLE_LABELS, resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
37
39
  import { useKitLabelOverrides, useKitLocale } from "../i18n/kit-labels";
38
40
  import { Tooltip } from "./tooltip";
39
41
  import { dirOf, isRtl, type Direction } from "../lib/direction";
@@ -78,6 +80,14 @@ export interface DataTableColumn<T> {
78
80
  * Default `"asc"`.
79
81
  */
80
82
  firstSort?: SortDir;
83
+ /**
84
+ * The column's name as plain text, for the places a ReactNode `header` cannot go:
85
+ * the phone sort control's options ({@link DataTableProps.mobileSort}), the spoken
86
+ * "Sorted by …" and the column-settings checklist. Only needed when `header` is not
87
+ * a string (an icon, a unit suffix); without it those places fall back to the
88
+ * header string, then to `key`.
89
+ */
90
+ headerText?: string;
81
91
  /**
82
92
  * Extra attributes for this column's `<td>` in a given row — a `data-*` hook, a
83
93
  * `title`, an `aria-describedby`, a class. keksdose's accounts page
@@ -390,6 +400,18 @@ export interface DataTableProps<T> {
390
400
  * wastes space (feedback #317). Desktop is unaffected.
391
401
  */
392
402
  mobileCard?: (row: T) => ReactNode;
403
+ /**
404
+ * A "Sort by" select and a direction button above the phone card list (keksdose
405
+ * K10). The card list has no header row, so without it a phone user is stuck with
406
+ * whatever order the table opened in. It drives the same sort state as the column
407
+ * headers — `sorts`/`onSortsChange` when controlled, the URL and `storageKey` when
408
+ * not — and offers the columns a header click could sort (those with a `sortBy`).
409
+ * Each option is named by the column's `headerText`, else its string `header`.
410
+ *
411
+ * Off by default, so no existing phone layout gains a row. Strings:
412
+ * {@link DataTableSortLabels}. Phones only; the desktop table is unchanged.
413
+ */
414
+ mobileSort?: boolean;
393
415
  /**
394
416
  * Swipe actions for a mobile row, revealed by dragging it horizontally. Return
395
417
  * `null` (or empty sides) for rows that should not move — a locked row, one whose
@@ -683,7 +705,120 @@ function cleanAttrs(
683
705
  */
684
706
  function columnLabel<T>(col: DataTableColumn<T>, labels?: DataTableLabels): string {
685
707
  if (col.key === ROW_ACTIONS_KEY && labels) return labels.actions;
686
- return typeof col.header === "string" ? col.header : col.key;
708
+ return col.headerText ?? (typeof col.header === "string" ? col.header : col.key);
709
+ }
710
+
711
+ // ---------- Phone sort control (keksdose K10) ----------
712
+
713
+ /**
714
+ * The phone sort control's strings (keksdose K10). They are `dataTable` strings —
715
+ * read from the table's resolved labels, so the `labels` prop and the provider's
716
+ * `dataTable` namespace reach them — and are declared here, beside the control, with
717
+ * English defaults until `DataTableLabels` declares them itself.
718
+ */
719
+ export interface DataTableSortLabels {
720
+ /** The control's visible label: "Sort by". */
721
+ sortBy: string;
722
+ /** The "no sort" option — the rows in the order they were given: "Default order". */
723
+ sortDefault: string;
724
+ /** The direction button's name while the sort ascends: "Ascending". */
725
+ sortAscending: string;
726
+ /** …and while it descends: "Descending". */
727
+ sortDescending: string;
728
+ }
729
+
730
+ /** The phone sort control's words, as they sit in {@link DEFAULT_DATA_TABLE_LABELS}
731
+ * (DataTableLabels extends DataTableSortLabels; one source for the English). */
732
+ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
733
+ sortBy: DEFAULT_DATA_TABLE_LABELS.sortBy,
734
+ sortDefault: DEFAULT_DATA_TABLE_LABELS.sortDefault,
735
+ sortAscending: DEFAULT_DATA_TABLE_LABELS.sortAscending,
736
+ sortDescending: DEFAULT_DATA_TABLE_LABELS.sortDescending,
737
+ };
738
+
739
+ const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
740
+
741
+ /**
742
+ * The card list's sort control: a "Sort by" select and a direction button.
743
+ *
744
+ * keksdose K10 (inputs audit 2026-10-02 §5; its aggregated report tables, #258): a
745
+ * table is sorted by its header row and the phone card list has none, so the two
746
+ * decisions the header carries — which column, which direction — were unreachable
747
+ * below the breakpoint. keksdose hand-built this row over its DataTable to answer
748
+ * "who did I pay the most" on the device it was asked on; this is that row, driven by
749
+ * the SAME sort state as the headers (controlled `sorts` included), so turning the
750
+ * phone sideways keeps the order and a sort chosen here is the one the URL and the
751
+ * storage key hold.
752
+ *
753
+ * The rules are the header's: a newly picked column starts in its `firstSort`, the
754
+ * direction button flips the primary sort (secondary ones stay), and "Default order"
755
+ * — the third click of a `"tri"` cycle — is offered only under `"tri"`, since a
756
+ * `"toggle"` table promises it is always ranked by something. The select's label is
757
+ * a real `<label>` beside it, and the button sits outside that label: a click inside
758
+ * a label is forwarded to the control it names, so a nested button would flip AND
759
+ * open the select.
760
+ */
761
+ function MobileSort<T>({
762
+ columns,
763
+ sorts,
764
+ sortCycle,
765
+ compact,
766
+ labels,
767
+ onPick,
768
+ onFlip,
769
+ }: {
770
+ columns: DataTableColumn<T>[];
771
+ sorts: SortState[];
772
+ sortCycle: SortCycle;
773
+ compact: boolean;
774
+ labels: DataTableLabels;
775
+ onPick: (key: string | null) => void;
776
+ onFlip: () => void;
777
+ }) {
778
+ const selectId = useId();
779
+ const sortLabels: DataTableSortLabels = labels;
780
+ if (columns.length === 0) return null;
781
+ // A persisted or URL sort can name a column with no `sortBy` (or a hidden one): the
782
+ // sort chain drops it, and so does the control rather than claiming it.
783
+ const primary = sorts[0] && columns.some((c) => c.key === sorts[0].key) ? sorts[0] : null;
784
+ const offerDefault = sortCycle === "tri" || primary === null;
785
+ const desc = primary?.dir === "desc";
786
+ return (
787
+ <div
788
+ data-table-mobile-sort=""
789
+ className={cn(
790
+ "flex items-center gap-2 border-b border-[var(--border)]",
791
+ compact ? "px-3 py-1.5" : "px-4 py-2",
792
+ )}
793
+ >
794
+ <label htmlFor={selectId} className="shrink-0 text-xs text-[var(--text-muted)]">
795
+ {sortLabels.sortBy}
796
+ </label>
797
+ <Select
798
+ id={selectId}
799
+ size={compact ? "sm" : "md"}
800
+ className="min-w-0 flex-1"
801
+ value={primary?.key ?? ""}
802
+ onChange={(e) => onPick(e.target.value || null)}
803
+ >
804
+ {offerDefault && <option value="">{sortLabels.sortDefault}</option>}
805
+ {columns.map((col) => (
806
+ <option key={col.key} value={col.key}>
807
+ {columnLabel(col, labels)}
808
+ </option>
809
+ ))}
810
+ </Select>
811
+ <IconButton
812
+ size={compact ? "xs" : "md"}
813
+ variant="secondary"
814
+ label={desc ? sortLabels.sortDescending : sortLabels.sortAscending}
815
+ disabled={primary === null}
816
+ onClick={onFlip}
817
+ >
818
+ {desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}
819
+ </IconButton>
820
+ </div>
821
+ );
687
822
  }
688
823
 
689
824
  /**
@@ -857,6 +992,7 @@ export function DataTable<T>({
857
992
  mobileGroupBy,
858
993
  mobileGroupLabel,
859
994
  mobileCard,
995
+ mobileSort = false,
860
996
  mobileSwipeActions,
861
997
  leadingRow,
862
998
  density = "comfortable",
@@ -1125,6 +1261,13 @@ export function DataTable<T>({
1125
1261
  firstDir: col?.firstSort,
1126
1262
  cycle: sortCycle,
1127
1263
  });
1264
+ commitSorts(next, key);
1265
+ };
1266
+
1267
+ // Header clicks and the phone sort control (`mobileSort`) land here alike, so both
1268
+ // go through the same owner and say the same thing.
1269
+ const commitSorts = (next: SortState[], key: string) => {
1270
+ const col = columns.find((c) => c.key === key);
1128
1271
  if (onSortsChange) onSortsChange(next);
1129
1272
  else setInternalSorts(next);
1130
1273
 
@@ -1405,9 +1548,9 @@ export function DataTable<T>({
1405
1548
 
1406
1549
  // ---- Mobile card list (md:hidden) ----
1407
1550
  // Renders the same paged/filtered/sorted slice but as stacked cards instead
1408
- // of a horizontally-scrolling table. Filters / sort / column settings are
1409
- // intentionally hidden here — the mobile layout assumes the user wants to
1410
- // scan rows quickly. Switch to a wider viewport for fine-grained control.
1551
+ // of a horizontally-scrolling table. Column settings stay desktop-only; the
1552
+ // filters come through a bottom sheet (feedback #299) and the sort through the
1553
+ // opt-in `mobileSort` row (keksdose K10), both on the table's own state.
1411
1554
  const mobileColumns = visibleColumns.filter((c) => !c.mobileHidden);
1412
1555
  const mobilePrimaryCol =
1413
1556
  mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
@@ -1765,6 +1908,32 @@ export function DataTable<T>({
1765
1908
  locale={locale}
1766
1909
  />
1767
1910
  )}
1911
+ {mobileSort && (
1912
+ <MobileSort
1913
+ columns={visibleColumns.filter((c) => (!isServer || !!onSortsChange) && !!c.sortBy)}
1914
+ sorts={sorts}
1915
+ sortCycle={sortCycle}
1916
+ compact={compact}
1917
+ labels={labels}
1918
+ onPick={(key) => {
1919
+ if (key === null) {
1920
+ if (sorts.length > 0) commitSorts([], sorts[0].key);
1921
+ return;
1922
+ }
1923
+ if (sorts[0]?.key === key) return;
1924
+ // A newly picked column starts where its header's first click would.
1925
+ const col = columns.find((c) => c.key === key);
1926
+ commitSorts([{ key, dir: col?.firstSort ?? "asc" }], key);
1927
+ }}
1928
+ onFlip={() => {
1929
+ if (sorts.length === 0) return;
1930
+ commitSorts(
1931
+ sorts.map((s, i) => (i === 0 ? { ...s, dir: flipDir(s.dir) } : s)),
1932
+ sorts[0].key,
1933
+ );
1934
+ }}
1935
+ />
1936
+ )}
1768
1937
  <ul
1769
1938
  className={cn(
1770
1939
  "divide-y divide-[var(--border)]",
@@ -7,13 +7,16 @@ import { addDaysIso, parseIsoDate } from "../lib/dates";
7
7
  import {
8
8
  DEFAULT_DATE_PICKER_LABELS,
9
9
  DEFAULT_PICKER_SHEET_LABELS,
10
+ useKitDateFormatter,
10
11
  useKitLabels,
11
12
  useKitLocale,
12
13
  type DatePickerLabels,
14
+ type KitDateFormatContext,
13
15
  } from "../i18n/kit-labels";
14
16
  import { splitTriggerAria } from "./trigger-aria";
15
17
  import type { TriggerAria } from "./trigger-aria";
16
- import { Button, FieldLabel, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
18
+ import { Button, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
19
+ import { FieldBox, FieldLabelLine, useFieldMessages } from "./field-anatomy";
17
20
  import { FullBleedDialog } from "./full-bleed-dialog";
18
21
  import { useMediaQuery } from "../hooks/use-media-query";
19
22
  import { MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
@@ -47,13 +50,33 @@ interface DatePickerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "onC
47
50
  disabled?: boolean;
48
51
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
49
52
  invalid?: boolean;
53
+ /**
54
+ * Standing advice for the field (keksdose K4, "uniform field anatomy"), as on a
55
+ * {@link Select}: plain TEXT is a caption under the field, attached with
56
+ * `aria-describedby`; a {@link FieldHint} rides the label line (or, unlabelled, sits
57
+ * at the field's end, outside the box).
58
+ */
59
+ hint?: ReactNode;
60
+ /**
61
+ * What is wrong with the date, in the caller's words ("The due date is before the
62
+ * invoice date") — {@link Input}'s `error` (keksdose K4: DateField errors had no slot).
63
+ * Rendered under the field (under the whole row, with `step` / `today`), attached to
64
+ * the trigger with `aria-describedby` — merged with any the caller passed, after the
65
+ * caption — and implies `invalid`. Passing the key at all keeps the field's box while
66
+ * there is no message, so the trigger is never remounted as one comes and goes.
67
+ */
68
+ error?: ReactNode;
50
69
  className?: string;
51
- /** Intl options for the trigger's formatted date (default: locale short date). */
70
+ /** Intl options for the trigger's formatted date (default: locale short date). Win
71
+ * over the provider's `formatDate`. */
52
72
  formatOptions?: Intl.DateTimeFormatOptions;
53
- /** Render the trigger's text for an ISO date yourself; wins over `formatOptions`.
73
+ /** Render the trigger's text for an ISO date yourself; wins over `formatOptions` and
74
+ * over the provider's `formatDate`.
54
75
  * For a host whose date rendering is more than one `Intl` call can say — Keksdose
55
76
  * puts the weekday's name in the UI language beside digits ordered by a separate
56
- * format preference, two locales in one string. `locale` still drives the calendar. */
77
+ * format preference, two locales in one string. `locale` still drives the calendar.
78
+ * Set that rendering once with `<UiKitProvider formatDate>` (keksdose K12) and this
79
+ * is for the one field that differs. */
57
80
  formatValue?: (iso: string) => string;
58
81
  /** Passed through to the {@link MiniCalendar} this opens. Its month arrows are
59
82
  * icon-only, so their `aria-label` is the only name they have — and the package
@@ -69,6 +92,30 @@ function formatDate(iso: string, locale: string | undefined, options?: Intl.Date
69
92
  return parseIsoDate(iso)?.toLocaleDateString(locale, options) ?? "";
70
93
  }
71
94
 
95
+ /**
96
+ * The trigger's date renderer, in the kit's order of precedence: the picker's own
97
+ * `formatValue`, then its `formatOptions` (an explicit `Intl` look for this field),
98
+ * then the provider's `formatDate` (keksdose K12), then the locale's short date. A
99
+ * provider formatter that answers `""` falls through to the default for that date.
100
+ */
101
+ function useTriggerFormat(
102
+ source: KitDateFormatContext["source"],
103
+ locale: string | undefined,
104
+ formatOptions: Intl.DateTimeFormatOptions | undefined,
105
+ formatValue: ((iso: string) => string) | undefined,
106
+ ): (iso: string) => string {
107
+ const fromProvider = useKitDateFormatter();
108
+ if (formatValue) return formatValue;
109
+ return (iso) => {
110
+ if (formatOptions === undefined && fromProvider) {
111
+ // A single date stands alone, where the weekday helps; a range's two ends do not.
112
+ const text = fromProvider(iso, { unit: "day", source, locale, weekday: source === "datePicker" });
113
+ if (text) return text;
114
+ }
115
+ return formatDate(iso, locale, formatOptions);
116
+ };
117
+ }
118
+
72
119
  /**
73
120
  * Everything the kit's own trigger button carries, handed to a custom one
74
121
  * ({@link DateRangePickerProps.renderTrigger}) to spread onto its `<button>`. Spread
@@ -285,6 +332,7 @@ function DateField({
285
332
  sheet = false,
286
333
  sheetBackCloses = true,
287
334
  renderTrigger,
335
+ labelHint,
288
336
  children,
289
337
  // The trigger's, not the wrapper's: `Field`'s render-prop spreads it with the id
290
338
  // pair, and a required date read as optional while it sat on the role-less div.
@@ -295,6 +343,8 @@ function DateField({
295
343
  triggerText: string;
296
344
  hasValue: boolean;
297
345
  label?: ReactNode;
346
+ /** A `FieldHint` for the label line, beside the label. */
347
+ labelHint?: ReactNode;
298
348
  disabled?: boolean;
299
349
  clearable?: boolean;
300
350
  /** Already resolved by the picker (prop > provider > English). */
@@ -412,7 +462,7 @@ function DateField({
412
462
  // The caller's attributes land here, on the field's own box — the trigger inside is
413
463
  // named by `aria-labelledby` and must keep the id pair it is given.
414
464
  <div {...wrapperRest} ref={rootRef} className={cn("relative", className)}>
415
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
465
+ {label !== undefined && <FieldLabelLine label={label} hint={labelHint} />}
416
466
  {/* The visible FieldLabel is a plain span, not a `<label htmlFor>`, so it names
417
467
  nothing on its own — this hidden twin is what the trigger is named by.
418
468
  `aria-hidden` because a name reference reads a hidden element deliberately
@@ -568,35 +618,61 @@ function useDatePickerLabels(
568
618
  return useKitLabels("datePicker", DEFAULT_DATE_PICKER_LABELS, fromProps);
569
619
  }
570
620
 
621
+ /**
622
+ * The caller's `hint` when it has no label line to ride: at the end of the field,
623
+ * outside the box, as an unlabelled {@link Select} places its FieldHint (0.15.5 P8).
624
+ */
625
+ function withEndHint(field: ReactNode, hint: ReactNode): ReactNode {
626
+ if (hint === undefined) return field;
627
+ return (
628
+ <div className="flex items-center gap-1.5">
629
+ <div className="min-w-0 flex-1">{field}</div>
630
+ <span className="flex shrink-0 items-center">{hint}</span>
631
+ </div>
632
+ );
633
+ }
634
+
571
635
  /** Single-date picker: a field showing the formatted date, opening a calendar. */
572
- export function DatePicker({
573
- value,
574
- onChange,
575
- locale: localeProp,
576
- placeholder,
577
- min,
578
- max,
579
- formatOptions,
580
- formatValue,
581
- step,
582
- stepLabels,
583
- today,
584
- todayLabel,
585
- className,
586
- calendarLabels,
587
- label,
588
- clearable,
589
- clearLabel,
590
- disabled,
591
- invalid,
592
- // Named so it reaches the field (and so its trigger) when a step/today row is the
593
- // root and takes the rest of the caller's props.
594
- "aria-required": ariaRequired,
595
- ...rest
596
- }: DatePickerProps) {
636
+ export function DatePicker(props: DatePickerProps) {
637
+ const {
638
+ value,
639
+ onChange,
640
+ locale: localeProp,
641
+ placeholder,
642
+ min,
643
+ max,
644
+ formatOptions,
645
+ formatValue,
646
+ step,
647
+ stepLabels,
648
+ today,
649
+ todayLabel,
650
+ className,
651
+ calendarLabels,
652
+ label,
653
+ clearable,
654
+ clearLabel,
655
+ disabled,
656
+ invalid,
657
+ hint,
658
+ error,
659
+ // Named so it reaches the field (and so its trigger) when a step/today row is the
660
+ // root and takes the rest of the caller's props.
661
+ "aria-required": ariaRequired,
662
+ ...rest
663
+ } = props;
597
664
  const locale = useKitLocale(localeProp);
598
665
  const text = useDatePickerLabels(clearLabel, stepLabels, todayLabel);
599
- const render = formatValue ?? ((iso: string) => formatDate(iso, locale, formatOptions));
666
+ const render = useTriggerFormat("datePicker", locale, formatOptions, formatValue);
667
+ // keksdose K4: the caption and the error under the field, attached to the TRIGGER
668
+ // (the element a screen reader is on) after any description of the caller's own.
669
+ const messages = useFieldMessages({
670
+ hint,
671
+ error,
672
+ invalid,
673
+ describedBy: rest["aria-describedby"],
674
+ ariaInvalid: rest["aria-invalid"],
675
+ });
600
676
  // With a step or a today button beside it the field is no longer the outermost
601
677
  // element — the flex row at the bottom is, and a caller's attributes belong on
602
678
  // whichever of the two is actually the root. Naming the shared props rather than
@@ -608,13 +684,15 @@ export function DatePicker({
608
684
  // With step/today buttons the flex row is the root and takes the caller's props —
609
685
  // except the ones that name the field, which still belong on its trigger.
610
686
  {...(wrapped ? triggerAria : rest)}
687
+ aria-describedby={messages.describedBy}
611
688
  aria-required={ariaRequired}
612
689
  label={label}
690
+ labelHint={messages.labelHint}
613
691
  clearable={clearable}
614
692
  clearLabel={text.clear}
615
693
  panelLabel={text.panel}
616
694
  disabled={disabled}
617
- invalid={invalid}
695
+ invalid={messages.isInvalid}
618
696
  className={step ? "min-w-0 flex-1" : className}
619
697
  hasValue={Boolean(value)}
620
698
  triggerText={value ? render(value) : (placeholder ?? "")}
@@ -638,7 +716,16 @@ export function DatePicker({
638
716
  )}
639
717
  </DateField>
640
718
  );
641
- if (!wrapped) return field;
719
+ // Passed at all — even as `undefined` — the field keeps its box; see FieldBox.
720
+ const reserve = "error" in props || "hint" in props;
721
+ const endHint = label === undefined ? messages.labelHint : undefined;
722
+ if (!wrapped) {
723
+ return (
724
+ <FieldBox below={messages.below} reserve={reserve}>
725
+ {withEndHint(field, endHint)}
726
+ </FieldBox>
727
+ );
728
+ }
642
729
 
643
730
  // An empty field has nothing to step from, so both buttons are dead until a date
644
731
  // is picked. Bounds are compared as strings: "YYYY-MM-DD" sorts chronologically.
@@ -649,7 +736,7 @@ export function DatePicker({
649
736
  if (!next || disabled) return true;
650
737
  return outOfBounds(next);
651
738
  };
652
- return (
739
+ const row = (
653
740
  // ONE joined control, not three boxes in a row: the buttons and the field share
654
741
  // their borders (each piece overlaps the previous by a pixel) and only the outer
655
742
  // ends are rounded — the button-group shape, so ‹ date › reads as one field with
@@ -701,6 +788,13 @@ export function DatePicker({
701
788
  )}
702
789
  </div>
703
790
  );
791
+ // Under the whole row, not under the field between the buttons: the message is about
792
+ // the date, and the steppers are part of how it is entered.
793
+ return (
794
+ <FieldBox below={messages.below} reserve={reserve}>
795
+ {withEndHint(row, endHint)}
796
+ </FieldBox>
797
+ );
704
798
  }
705
799
 
706
800
  export interface DateRangePickerPreset {
@@ -1052,34 +1146,44 @@ function RangePanel({
1052
1146
  /** Two-date range picker: click a start then an end; closes once both are set.
1053
1147
  * With `presets`, a column of named ranges is shown beside the calendar; with
1054
1148
  * `commit="apply"` the panel drafts and commits on Apply (see {@link DateRangeCommit}). */
1055
- export function DateRangePicker({
1056
- from,
1057
- to,
1058
- onChange,
1059
- locale: localeProp,
1060
- placeholder,
1061
- min,
1062
- max,
1063
- formatOptions,
1064
- formatValue,
1065
- separator = " – ",
1066
- presets,
1067
- preset,
1068
- commit = "immediate",
1069
- renderTrigger,
1070
- sheetBackCloses,
1071
- renderDraftSummary,
1072
- calendarLabels,
1073
- label,
1074
- clearable,
1075
- clearLabel,
1076
- disabled,
1077
- invalid,
1078
- ...rest
1079
- }: DateRangePickerProps) {
1149
+ export function DateRangePicker(props: DateRangePickerProps) {
1150
+ const {
1151
+ from,
1152
+ to,
1153
+ onChange,
1154
+ locale: localeProp,
1155
+ placeholder,
1156
+ min,
1157
+ max,
1158
+ formatOptions,
1159
+ formatValue,
1160
+ separator = " – ",
1161
+ presets,
1162
+ preset,
1163
+ commit = "immediate",
1164
+ renderTrigger,
1165
+ sheetBackCloses,
1166
+ renderDraftSummary,
1167
+ calendarLabels,
1168
+ label,
1169
+ clearable,
1170
+ clearLabel,
1171
+ disabled,
1172
+ invalid,
1173
+ hint,
1174
+ error,
1175
+ ...rest
1176
+ } = props;
1080
1177
  const locale = useKitLocale(localeProp);
1081
1178
  const text = useDatePickerLabels(clearLabel, undefined, undefined);
1082
- const render = formatValue ?? ((iso: string) => formatDate(iso, locale, formatOptions));
1179
+ const render = useTriggerFormat("dateRangePicker", locale, formatOptions, formatValue);
1180
+ const messages = useFieldMessages({
1181
+ hint,
1182
+ error,
1183
+ invalid,
1184
+ describedBy: rest["aria-describedby"],
1185
+ ariaInvalid: rest["aria-invalid"],
1186
+ });
1083
1187
  // The preset this picker committed itself, for a caller that does not control
1084
1188
  // `preset`. Cleared by a hand-picked range or a clear.
1085
1189
  const [ownPreset, setOwnPreset] = useState<string | undefined>(undefined);
@@ -1101,15 +1205,17 @@ export function DateRangePicker({
1101
1205
  if (presetId === undefined) onChange(f, t);
1102
1206
  else onChange(f, t, presetId);
1103
1207
  };
1104
- return (
1208
+ const field = (
1105
1209
  <DateField
1106
1210
  {...rest}
1211
+ aria-describedby={messages.describedBy}
1107
1212
  label={label}
1213
+ labelHint={messages.labelHint}
1108
1214
  clearable={clearable}
1109
1215
  clearLabel={text.clear}
1110
1216
  panelLabel={text.rangePanel}
1111
1217
  disabled={disabled}
1112
- invalid={invalid}
1218
+ invalid={messages.isInvalid}
1113
1219
  // Widen so the preset column sits beside the calendar (default otherwise).
1114
1220
  width={hasPresets ? 440 : undefined}
1115
1221
  hasValue={Boolean(from || to)}
@@ -1150,4 +1256,9 @@ export function DateRangePicker({
1150
1256
  )}
1151
1257
  </DateField>
1152
1258
  );
1259
+ return (
1260
+ <FieldBox below={messages.below} reserve={"error" in props || "hint" in props}>
1261
+ {withEndHint(field, label === undefined ? messages.labelHint : undefined)}
1262
+ </FieldBox>
1263
+ );
1153
1264
  }