@eifi1/ui-kit 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +5 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +33 -6
  9. package/dist/components/autocomplete.js +62 -16
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +5 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/chip.d.ts +34 -14
  20. package/dist/components/chip.js +63 -28
  21. package/dist/components/chip.js.map +1 -1
  22. package/dist/components/choice-card.d.ts +18 -1
  23. package/dist/components/choice-card.js +21 -8
  24. package/dist/components/choice-card.js.map +1 -1
  25. package/dist/components/combobox-core.d.ts +61 -2
  26. package/dist/components/combobox-core.js +68 -17
  27. package/dist/components/combobox-core.js.map +1 -1
  28. package/dist/components/combobox.d.ts +11 -7
  29. package/dist/components/combobox.js +51 -28
  30. package/dist/components/combobox.js.map +1 -1
  31. package/dist/components/currency-select.js +3 -3
  32. package/dist/components/currency-select.js.map +1 -1
  33. package/dist/components/danger-confirm.d.ts +30 -6
  34. package/dist/components/danger-confirm.js +51 -32
  35. package/dist/components/danger-confirm.js.map +1 -1
  36. package/dist/components/data-table-filter-popover.d.ts +1 -1
  37. package/dist/components/data-table-filters.d.ts +1 -1
  38. package/dist/components/data-table-pagination.d.ts +6 -3
  39. package/dist/components/data-table-pagination.js +6 -5
  40. package/dist/components/data-table-pagination.js.map +1 -1
  41. package/dist/components/data-table.d.ts +1 -1
  42. package/dist/components/data-table.js +332 -248
  43. package/dist/components/data-table.js.map +1 -1
  44. package/dist/components/date-picker.js +17 -7
  45. package/dist/components/date-picker.js.map +1 -1
  46. package/dist/components/dialog-frame.d.ts +28 -3
  47. package/dist/components/dialog-frame.js +47 -11
  48. package/dist/components/dialog-frame.js.map +1 -1
  49. package/dist/components/disclosure.d.ts +22 -2
  50. package/dist/components/disclosure.js +41 -8
  51. package/dist/components/disclosure.js.map +1 -1
  52. package/dist/components/dropdown.d.ts +7 -3
  53. package/dist/components/dropdown.js +6 -2
  54. package/dist/components/dropdown.js.map +1 -1
  55. package/dist/components/entity-combobox.d.ts +17 -8
  56. package/dist/components/entity-combobox.js +7 -5
  57. package/dist/components/entity-combobox.js.map +1 -1
  58. package/dist/components/file-button.d.ts +38 -5
  59. package/dist/components/file-button.js +23 -2
  60. package/dist/components/file-button.js.map +1 -1
  61. package/dist/components/file-dropzone.d.ts +11 -5
  62. package/dist/components/file-dropzone.js +14 -19
  63. package/dist/components/file-dropzone.js.map +1 -1
  64. package/dist/components/grouped-picker.js +2 -2
  65. package/dist/components/grouped-picker.js.map +1 -1
  66. package/dist/components/hover-menu.d.ts +8 -3
  67. package/dist/components/hover-menu.js +88 -4
  68. package/dist/components/hover-menu.js.map +1 -1
  69. package/dist/components/icon-picker.d.ts +10 -1
  70. package/dist/components/icon-picker.js +5 -1
  71. package/dist/components/icon-picker.js.map +1 -1
  72. package/dist/components/measured-grid.d.ts +165 -0
  73. package/dist/components/measured-grid.js +559 -0
  74. package/dist/components/measured-grid.js.map +1 -0
  75. package/dist/components/mini-calendar.js +7 -8
  76. package/dist/components/mini-calendar.js.map +1 -1
  77. package/dist/components/month-picker.js +13 -9
  78. package/dist/components/month-picker.js.map +1 -1
  79. package/dist/components/multi-entity-combobox.js +2 -2
  80. package/dist/components/multi-entity-combobox.js.map +1 -1
  81. package/dist/components/multi-select.d.ts +9 -1
  82. package/dist/components/multi-select.js +22 -8
  83. package/dist/components/multi-select.js.map +1 -1
  84. package/dist/components/number-field.d.ts +5 -0
  85. package/dist/components/number-input.d.ts +5 -0
  86. package/dist/components/number-input.js +3 -3
  87. package/dist/components/number-input.js.map +1 -1
  88. package/dist/components/numpad-sheet.d.ts +5 -0
  89. package/dist/components/numpad-sheet.js +3 -3
  90. package/dist/components/numpad-sheet.js.map +1 -1
  91. package/dist/components/picker-sheet.js +1 -1
  92. package/dist/components/picker-sheet.js.map +1 -1
  93. package/dist/components/popover.js +11 -2
  94. package/dist/components/popover.js.map +1 -1
  95. package/dist/components/series-chart-ticks.d.ts +13 -0
  96. package/dist/components/series-chart-ticks.js +29 -0
  97. package/dist/components/series-chart-ticks.js.map +1 -0
  98. package/dist/components/series-chart.js +33 -27
  99. package/dist/components/series-chart.js.map +1 -1
  100. package/dist/components/settings-fields.d.ts +2 -0
  101. package/dist/components/swatch-picker.d.ts +10 -1
  102. package/dist/components/swatch-picker.js +2 -0
  103. package/dist/components/swatch-picker.js.map +1 -1
  104. package/dist/components/swipeable-row.d.ts +11 -2
  105. package/dist/components/swipeable-row.js +5 -3
  106. package/dist/components/swipeable-row.js.map +1 -1
  107. package/dist/components/tile-radio.js +5 -7
  108. package/dist/components/tile-radio.js.map +1 -1
  109. package/dist/components/time-input.d.ts +2 -0
  110. package/dist/components/toggle-group.d.ts +13 -3
  111. package/dist/components/toggle-group.js +21 -1
  112. package/dist/components/toggle-group.js.map +1 -1
  113. package/dist/components/toggle-legend.js +1 -2
  114. package/dist/components/toggle-legend.js.map +1 -1
  115. package/dist/components/tooltip.d.ts +10 -4
  116. package/dist/components/tooltip.js +23 -6
  117. package/dist/components/tooltip.js.map +1 -1
  118. package/dist/components/treemap.d.ts +6 -1
  119. package/dist/components/treemap.js +16 -6
  120. package/dist/components/treemap.js.map +1 -1
  121. package/dist/components/ui.d.ts +36 -8
  122. package/dist/components/ui.js +27 -19
  123. package/dist/components/ui.js.map +1 -1
  124. package/dist/components/use-anchor-dir.d.ts +15 -0
  125. package/dist/components/use-anchor-dir.js +14 -0
  126. package/dist/components/use-anchor-dir.js.map +1 -0
  127. package/dist/components/use-mobile-reveal.d.ts +8 -1
  128. package/dist/components/use-mobile-reveal.js +3 -2
  129. package/dist/components/use-mobile-reveal.js.map +1 -1
  130. package/dist/components/use-table-state.d.ts +4 -4
  131. package/dist/components/use-table-state.js +12 -5
  132. package/dist/components/use-table-state.js.map +1 -1
  133. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  134. package/dist/data-table.d.ts +1 -1
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/feedback/feedback-attachment.d.ts +22 -3
  137. package/dist/feedback/feedback-attachment.js +15 -7
  138. package/dist/feedback/feedback-attachment.js.map +1 -1
  139. package/dist/feedback/feedback-dialog.d.ts +3 -2
  140. package/dist/feedback/feedback-dialog.js.map +1 -1
  141. package/dist/feedback.d.ts +1 -1
  142. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  143. package/dist/hooks/use-windowed-rows.js +31 -0
  144. package/dist/hooks/use-windowed-rows.js.map +1 -0
  145. package/dist/i18n/defaults.d.ts +5 -0
  146. package/dist/i18n/defaults.js +5 -1
  147. package/dist/i18n/defaults.js.map +1 -1
  148. package/dist/i18n/kit-labels.d.ts +7 -0
  149. package/dist/i18n/kit-labels.js.map +1 -1
  150. package/dist/i18n/locales/de-CH.d.ts +45 -0
  151. package/dist/i18n/locales/de-CH.js +21 -0
  152. package/dist/i18n/locales/de-CH.js.map +1 -0
  153. package/dist/i18n/locales/de.d.ts +45 -0
  154. package/dist/i18n/locales/de.js +344 -0
  155. package/dist/i18n/locales/de.js.map +1 -0
  156. package/dist/i18n/locales/es.d.ts +45 -0
  157. package/dist/i18n/locales/es.js +339 -0
  158. package/dist/i18n/locales/es.js.map +1 -0
  159. package/dist/i18n/locales/fr.d.ts +45 -0
  160. package/dist/i18n/locales/fr.js +344 -0
  161. package/dist/i18n/locales/fr.js.map +1 -0
  162. package/dist/i18n/locales/hu.d.ts +45 -0
  163. package/dist/i18n/locales/hu.js +343 -0
  164. package/dist/i18n/locales/hu.js.map +1 -0
  165. package/dist/i18n/locales/it.d.ts +45 -0
  166. package/dist/i18n/locales/it.js +342 -0
  167. package/dist/i18n/locales/it.js.map +1 -0
  168. package/dist/i18n/locales/zh.d.ts +45 -0
  169. package/dist/i18n/locales/zh.js +338 -0
  170. package/dist/i18n/locales/zh.js.map +1 -0
  171. package/dist/index.d.ts +7 -4
  172. package/dist/index.js +4 -1
  173. package/dist/index.js.map +1 -1
  174. package/dist/lib/direction.d.ts +24 -0
  175. package/dist/lib/direction.js +19 -0
  176. package/dist/lib/direction.js.map +1 -0
  177. package/dist/rhf/form.d.ts +2 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/rhf.d.ts +2 -0
  180. package/dist/search/command-palette.d.ts +5 -0
  181. package/dist/search/command-palette.js +80 -50
  182. package/dist/search/command-palette.js.map +1 -1
  183. package/dist/shell/app-shell.d.ts +13 -1
  184. package/dist/shell/app-shell.js +134 -85
  185. package/dist/shell/app-shell.js.map +1 -1
  186. package/dist/shell/topbar-action-menu.js +1 -1
  187. package/dist/shell/topbar-action-menu.js.map +1 -1
  188. package/dist/shell/topbar-controls.d.ts +7 -3
  189. package/dist/shell/topbar-controls.js +38 -31
  190. package/dist/shell/topbar-controls.js.map +1 -1
  191. package/dist/theme/chart-palette.d.ts +7 -1
  192. package/dist/theme/chart-palette.js +2 -1
  193. package/dist/theme/chart-palette.js.map +1 -1
  194. package/dist/tour/tour.d.ts +11 -5
  195. package/dist/tour/tour.js +124 -58
  196. package/dist/tour/tour.js.map +1 -1
  197. package/dist/wizard/use-wizard.js +30 -13
  198. package/dist/wizard/use-wizard.js.map +1 -1
  199. package/dist/wizard/wizard-summary.js +1 -1
  200. package/dist/wizard/wizard-summary.js.map +1 -1
  201. package/package.json +12 -2
  202. package/src/components/account-settings.tsx +6 -2
  203. package/src/components/amount-input.tsx +10 -8
  204. package/src/components/autocomplete.tsx +110 -24
  205. package/src/components/calculator.tsx +5 -1
  206. package/src/components/chart-zoom.tsx +8 -1
  207. package/src/components/chart.tsx +68 -7
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +74 -5
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/tile-radio.tsx +5 -7
  240. package/src/components/toggle-group.tsx +39 -3
  241. package/src/components/toggle-legend.tsx +5 -2
  242. package/src/components/tooltip.tsx +40 -12
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +79 -33
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +201 -130
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
@@ -1,7 +1,6 @@
1
1
  import * as react from 'react';
2
- import { useSearchParams } from 'react-router';
3
2
  import { SortState } from './data-table-sort.js';
4
- import { a as DataTableColumn, F as FilterState } from '../data-table-filters-DM1auUfH.js';
3
+ import { a as DataTableColumn, F as FilterState } from '../data-table-filters-noy0Abvi.js';
5
4
  import './swipeable-row.js';
6
5
  import './data-table-labels.js';
7
6
 
@@ -23,13 +22,14 @@ declare const DEFAULT_PERSIST_PREFIX = "hbui-table:";
23
22
  * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the
24
23
  * per-table identity; `urlSync` is opt-in because only some tables want their view in
25
24
  * a shareable address.
25
+ * * **A controlled filter or sort that changes sends the table back to page 1.** See
26
+ * the note at the reset below.
26
27
  */
27
- declare function useTableState<T>({ columns, storageKey, storageKeyPrefix, urlSync, setSearchParams, defaultPageSize, sortsProp, filtersProp, }: {
28
+ declare function useTableState<T>({ columns, storageKey, storageKeyPrefix, urlSync, defaultPageSize, sortsProp, filtersProp, }: {
28
29
  columns: DataTableColumn<T>[];
29
30
  storageKey?: string;
30
31
  storageKeyPrefix: string;
31
32
  urlSync?: boolean;
32
- setSearchParams: ReturnType<typeof useSearchParams>[1];
33
33
  defaultPageSize: number;
34
34
  sortsProp?: SortState[];
35
35
  filtersProp?: FilterState;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useSearchParams } from "react-router";
3
4
  import { readStored, writeStored } from "../lib/safe-storage.js";
4
5
  import {
5
6
  decodeFilterValue,
@@ -27,9 +28,7 @@ const URL_FILTER_PREFIX = "f.";
27
28
  const URL_SORT_KEY = "sort";
28
29
  const URL_PAGE_KEY = "p";
29
30
  const URL_PAGE_SIZE_KEY = "ps";
30
- function readUrlState(columns) {
31
- if (typeof window === "undefined") return {};
32
- const sp = new URLSearchParams(window.location.search);
31
+ function readUrlState(sp, columns) {
33
32
  const out = {};
34
33
  const filters = {};
35
34
  for (const col of columns) {
@@ -96,12 +95,14 @@ function useTableState({
96
95
  storageKey,
97
96
  storageKeyPrefix,
98
97
  urlSync,
99
- setSearchParams,
100
98
  defaultPageSize,
101
99
  sortsProp,
102
100
  filtersProp
103
101
  }) {
104
- const [urlInitial] = useState(() => urlSync ? readUrlState(columns) : {});
102
+ const [searchParams, setSearchParams] = useSearchParams();
103
+ const [urlInitial] = useState(
104
+ () => urlSync ? readUrlState(searchParams, columns) : {}
105
+ );
105
106
  const initial = useMemo(
106
107
  () => loadPersisted(storageKey, storageKeyPrefix),
107
108
  [storageKey, storageKeyPrefix]
@@ -115,6 +116,12 @@ function useTableState({
115
116
  const sorts = sortsProp ?? internalSorts;
116
117
  const filters = filtersProp ?? internalFilters;
117
118
  const [page, setPage] = useState(() => urlInitial.page ?? 0);
119
+ const controlledSig = sortsProp !== void 0 || filtersProp !== void 0 ? JSON.stringify([sortsProp ?? null, filtersProp ?? null]) : null;
120
+ const [seenControlledSig, setSeenControlledSig] = useState(controlledSig);
121
+ if (seenControlledSig !== controlledSig) {
122
+ setSeenControlledSig(controlledSig);
123
+ setPage(0);
124
+ }
118
125
  const [pageSize, setPageSize] = useState(() => {
119
126
  if (urlInitial.pageSize !== void 0) return urlInitial.pageSize;
120
127
  if (initial.pageSize === "all") return Infinity;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/use-table-state.ts"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport type { useSearchParams } from \"react-router\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport {\n decodeFilterValue,\n encodeFilterValue,\n isFilterActive,\n resolveFilter,\n} from \"./data-table-filters\";\nimport { decodeSorts, encodeSorts, normalizeSorts, type SortState } from \"./data-table-sort\";\nimport type { DataTableColumn, FilterState } from \"./data-table\";\n\n// ---------- Persisted + URL-encoded view state ----------\n//\n// Moved here with `useTableState` when `DataTable` was split: these are what the hook\n// reads and writes, and nothing else in the table ever called them.\n\ninterface PersistedState {\n // Multi-sort array; blobs from before the upgrade hold a single {key, dir}\n // object (or null) — read back through normalizeSorts.\n sort: SortState[] | { key: string; dir: string } | null;\n filters: FilterState;\n pageSize: number | \"all\";\n widths?: Record<string, number>;\n hidden?: string[];\n}\n\nexport const DEFAULT_PERSIST_PREFIX = \"hbui-table:\";\n\nfunction loadPersisted(storageKey: string | undefined, prefix: string): Partial<PersistedState> {\n if (!storageKey) return {};\n const raw = readStored(prefix + storageKey);\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as Partial<PersistedState>;\n return parsed && typeof parsed === \"object\" ? parsed : {};\n } catch {\n return {}; // corrupt JSON — the storage access itself is guarded by readStored\n }\n}\n\n/** The blob that would be stored — also the value the effect below compares by, so\n * the comparison and the write can never disagree about what \"unchanged\" means. */\nfunction serializePersisted(state: PersistedState): string {\n return JSON.stringify(state);\n}\n\n// ---------- URL sync helpers ----------\n\nconst URL_FILTER_PREFIX = \"f.\";\nconst URL_SORT_KEY = \"sort\";\nconst URL_PAGE_KEY = \"p\";\nconst URL_PAGE_SIZE_KEY = \"ps\";\n\ninterface UrlState {\n sorts?: SortState[];\n filters?: FilterState;\n pageSize?: number; // Infinity for \"all\"\n page?: number; // 0-based\n}\n\nfunction readUrlState<T>(columns: DataTableColumn<T>[]): UrlState {\n if (typeof window === \"undefined\") return {};\n const sp = new URLSearchParams(window.location.search);\n const out: UrlState = {};\n\n const filters: FilterState = {};\n for (const col of columns) {\n const filter = resolveFilter(col);\n if (!filter) continue;\n const raw = sp.get(URL_FILTER_PREFIX + col.key);\n if (raw == null) continue;\n filters[col.key] = decodeFilterValue(filter, raw);\n }\n if (Object.keys(filters).length) out.filters = filters;\n\n const sortRaw = sp.get(URL_SORT_KEY);\n if (sortRaw) {\n const decoded = decodeSorts(sortRaw, new Set(columns.map((c) => c.key)));\n if (decoded.length) out.sorts = decoded;\n }\n\n const psRaw = sp.get(URL_PAGE_SIZE_KEY);\n if (psRaw === \"all\") out.pageSize = Infinity;\n else if (psRaw && Number(psRaw) > 0) out.pageSize = Number(psRaw);\n\n const pRaw = sp.get(URL_PAGE_KEY);\n if (pRaw && Number(pRaw) >= 1) out.page = Number(pRaw) - 1;\n\n return out;\n}\n\n/**\n * The managed part of the query string, as a string, for comparing by VALUE.\n *\n * Everything the table puts in the address and nothing else, in a fixed order.\n * `columns` is walked for its keys only, which is why it can be left out of the\n * effect's dependencies without lying: a new column ARRAY carrying the same keys\n * produces the same signature, and a genuinely different set of filterable columns\n * produces a different one.\n */\nfunction urlSignature<T>(\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n): string {\n const parts: string[] = [];\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) parts.push(`${URL_FILTER_PREFIX}${col.key}=${enc}`);\n }\n parts.push(`${URL_SORT_KEY}=${encodeSorts(sorts)}`);\n parts.push(\n `${URL_PAGE_SIZE_KEY}=${\n pageSize === Infinity ? \"all\" : pageSize === defaultPageSize ? \"\" : String(pageSize)\n }`,\n );\n parts.push(`${URL_PAGE_KEY}=${page > 0 ? String(page + 1) : \"\"}`);\n return parts.join(\"&\");\n}\n\nfunction writeUrlState<T>(\n setSearchParams: ReturnType<typeof useSearchParams>[1],\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n): void {\n setSearchParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n // clear managed keys\n for (const col of columns) next.delete(URL_FILTER_PREFIX + col.key);\n next.delete(URL_SORT_KEY);\n next.delete(URL_PAGE_KEY);\n next.delete(URL_PAGE_SIZE_KEY);\n // write filters\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) next.set(URL_FILTER_PREFIX + col.key, enc);\n }\n // write sort\n const sortEnc = encodeSorts(sorts);\n if (sortEnc) next.set(URL_SORT_KEY, sortEnc);\n // write page size if non-default\n if (pageSize === Infinity) next.set(URL_PAGE_SIZE_KEY, \"all\");\n else if (pageSize !== defaultPageSize) next.set(URL_PAGE_SIZE_KEY, String(pageSize));\n // write page if not first\n if (page > 0) next.set(URL_PAGE_KEY, String(page + 1));\n return next;\n },\n { replace: true },\n );\n}\n\n/**\n * Where a table's view lives: what it is sorted and filtered by, which page, how wide\n * the columns are and which are hidden — plus the two places that outlives a render.\n *\n * Lifted out of `DataTable`, which was 984 lines and twenty hooks in one function. This\n * is the part that is genuinely about STATE rather than about drawing, and the three\n * rules it encodes are easy to lose among the markup:\n *\n * * **The URL is read once, on mount.** A shared or deep link populates the initial view;\n * after that the state is local, or the browser's own history would fight the user's\n * next click.\n * * **Sorting and filtering are optionally CONTROLLED.** A server-driven table passes\n * state and a callback and owns them; everything else is self-owned. That is the\n * `sortsProp ?? internalSorts` pair, and it is why the setters are exposed separately.\n * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the\n * per-table identity; `urlSync` is opt-in because only some tables want their view in\n * a shareable address.\n */\nexport function useTableState<T>({\n columns,\n storageKey,\n storageKeyPrefix,\n urlSync,\n setSearchParams,\n defaultPageSize,\n sortsProp,\n filtersProp,\n}: {\n columns: DataTableColumn<T>[];\n storageKey?: string;\n storageKeyPrefix: string;\n urlSync?: boolean;\n setSearchParams: ReturnType<typeof useSearchParams>[1];\n defaultPageSize: number;\n sortsProp?: SortState[];\n filtersProp?: FilterState;\n}) {\n // Read URL once on mount so URL-encoded views (e.g. shared links, deep links from\n // other pages) populate initial state. After mount, state is local.\n const [urlInitial] = useState<UrlState>(() => (urlSync ? readUrlState(columns) : {}));\n const initial = useMemo(\n () => loadPersisted(storageKey, storageKeyPrefix),\n [storageKey, storageKeyPrefix],\n );\n const [internalSorts, setInternalSorts] = useState<SortState[]>(() =>\n urlInitial.sorts !== undefined ? urlInitial.sorts : normalizeSorts(initial.sort),\n );\n const [internalFilters, setInternalFilters] = useState<FilterState>(\n () => urlInitial.filters ?? initial.filters ?? {},\n );\n // Controlled when the owner passes state + callback (server-driven tables);\n // self-owned otherwise.\n const sorts = sortsProp ?? internalSorts;\n const filters = filtersProp ?? internalFilters;\n const [page, setPage] = useState(() => urlInitial.page ?? 0);\n const [pageSize, setPageSize] = useState<number>(() => {\n if (urlInitial.pageSize !== undefined) return urlInitial.pageSize;\n if (initial.pageSize === \"all\") return Infinity;\n if (typeof initial.pageSize === \"number\" && initial.pageSize > 0) return initial.pageSize;\n return defaultPageSize;\n });\n const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});\n const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set(initial.hidden ?? []));\n const [showSettings, setShowSettings] = useState(false);\n const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);\n\n // ---- Writing out: both of these compare by VALUE ----\n //\n // A CONTROLLED table (`sorts`/`filters` props plus callbacks — every server-driven\n // one) receives a freshly-built object on every render of the page around it, and\n // `{}` is never `===` the `{}` before it. Keyed on identity, both effects below\n // therefore fired on every unrelated parent render: persistence wrote the same JSON\n // into `localStorage` each time — a synchronous main-thread write, on a page whose\n // other half may be a form being typed into — and the URL one handed react-router a\n // `replace` it answers with a render, which is the loop the audit records. A `ref`\n // holding what was last written is what makes \"changed\" mean changed.\n const lastPersisted = useRef<string | null>(null);\n const persisted = !storageKey\n ? \"\"\n : serializePersisted({\n sort: sorts,\n filters,\n pageSize: pageSize === Infinity ? \"all\" : pageSize,\n widths,\n hidden: Array.from(hiddenCols),\n });\n useEffect(() => {\n if (!storageKey) return;\n // The destination is part of the signature: two tables can hold identical state,\n // and a table that swaps `storageKey` has to write under the new one even though\n // nothing about the view changed.\n const target = storageKeyPrefix + storageKey;\n const sig = `${target}\\u0000${persisted}`;\n if (lastPersisted.current === sig) return;\n lastPersisted.current = sig;\n writeStored(target, persisted);\n }, [storageKey, storageKeyPrefix, persisted]);\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, sorts, filters, pageSize, page, defaultPageSize)\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n // `columns` is deliberately not a dependency and does not need to be: the\n // signature above is computed from it, so a column set that would produce a\n // different address produces a different signature first.\n writeUrlState(setSearchParams, columns, sorts, filters, pageSize, page, defaultPageSize);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n return {\n sorts,\n filters,\n setInternalSorts,\n setInternalFilters,\n page,\n setPage,\n pageSize,\n setPageSize,\n widths,\n setWidths,\n hiddenCols,\n setHiddenCols,\n showSettings,\n setShowSettings,\n headRefs,\n };\n}\n"],"mappings":";AAAA,SAAS,WAAW,SAAS,QAAQ,gBAAgB;AAErD,SAAS,YAAY,mBAAmB;AACxC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa,aAAa,sBAAsC;AAkBlE,MAAM,yBAAyB;AAEtC,SAAS,cAAc,YAAgC,QAAyC;AAC9F,MAAI,CAAC,WAAY,QAAO,CAAC;AACzB,QAAM,MAAM,WAAW,SAAS,UAAU;AAC1C,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,UAAU,OAAO,WAAW,WAAW,SAAS,CAAC;AAAA,EAC1D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAIA,SAAS,mBAAmB,OAA+B;AACzD,SAAO,KAAK,UAAU,KAAK;AAC7B;AAIA,MAAM,oBAAoB;AAC1B,MAAM,eAAe;AACrB,MAAM,eAAe;AACrB,MAAM,oBAAoB;AAS1B,SAAS,aAAgB,SAAyC;AAChE,MAAI,OAAO,WAAW,YAAa,QAAO,CAAC;AAC3C,QAAM,KAAK,IAAI,gBAAgB,OAAO,SAAS,MAAM;AACrD,QAAM,MAAgB,CAAC;AAEvB,QAAM,UAAuB,CAAC;AAC9B,aAAW,OAAO,SAAS;AACzB,UAAM,SAAS,cAAc,GAAG;AAChC,QAAI,CAAC,OAAQ;AACb,UAAM,MAAM,GAAG,IAAI,oBAAoB,IAAI,GAAG;AAC9C,QAAI,OAAO,KAAM;AACjB,YAAQ,IAAI,GAAG,IAAI,kBAAkB,QAAQ,GAAG;AAAA,EAClD;AACA,MAAI,OAAO,KAAK,OAAO,EAAE,OAAQ,KAAI,UAAU;AAE/C,QAAM,UAAU,GAAG,IAAI,YAAY;AACnC,MAAI,SAAS;AACX,UAAM,UAAU,YAAY,SAAS,IAAI,IAAI,QAAQ,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AACvE,QAAI,QAAQ,OAAQ,KAAI,QAAQ;AAAA,EAClC;AAEA,QAAM,QAAQ,GAAG,IAAI,iBAAiB;AACtC,MAAI,UAAU,MAAO,KAAI,WAAW;AAAA,WAC3B,SAAS,OAAO,KAAK,IAAI,EAAG,KAAI,WAAW,OAAO,KAAK;AAEhE,QAAM,OAAO,GAAG,IAAI,YAAY;AAChC,MAAI,QAAQ,OAAO,IAAI,KAAK,EAAG,KAAI,OAAO,OAAO,IAAI,IAAI;AAEzD,SAAO;AACT;AAWA,SAAS,aACP,SACA,OACA,SACA,UACA,MACA,iBACQ;AACR,QAAM,QAAkB,CAAC;AACzB,aAAW,OAAO,SAAS;AACzB,UAAM,QAAQ,QAAQ,IAAI,GAAG;AAC7B,QAAI,CAAC,SAAS,CAAC,eAAe,KAAK,EAAG;AACtC,UAAM,MAAM,kBAAkB,KAAK;AACnC,QAAI,OAAO,KAAM,OAAM,KAAK,GAAG,iBAAiB,GAAG,IAAI,GAAG,IAAI,GAAG,EAAE;AAAA,EACrE;AACA,QAAM,KAAK,GAAG,YAAY,IAAI,YAAY,KAAK,CAAC,EAAE;AAClD,QAAM;AAAA,IACJ,GAAG,iBAAiB,IAClB,aAAa,WAAW,QAAQ,aAAa,kBAAkB,KAAK,OAAO,QAAQ,CACrF;AAAA,EACF;AACA,QAAM,KAAK,GAAG,YAAY,IAAI,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,EAAE,EAAE;AAChE,SAAO,MAAM,KAAK,GAAG;AACvB;AAEA,SAAS,cACP,iBACA,SACA,OACA,SACA,UACA,MACA,iBACM;AACN;AAAA,IACE,CAAC,SAAS;AACR,YAAM,OAAO,IAAI,gBAAgB,IAAI;AAErC,iBAAW,OAAO,QAAS,MAAK,OAAO,oBAAoB,IAAI,GAAG;AAClE,WAAK,OAAO,YAAY;AACxB,WAAK,OAAO,YAAY;AACxB,WAAK,OAAO,iBAAiB;AAE7B,iBAAW,OAAO,SAAS;AACzB,cAAM,QAAQ,QAAQ,IAAI,GAAG;AAC7B,YAAI,CAAC,SAAS,CAAC,eAAe,KAAK,EAAG;AACtC,cAAM,MAAM,kBAAkB,KAAK;AACnC,YAAI,OAAO,KAAM,MAAK,IAAI,oBAAoB,IAAI,KAAK,GAAG;AAAA,MAC5D;AAEA,YAAM,UAAU,YAAY,KAAK;AACjC,UAAI,QAAS,MAAK,IAAI,cAAc,OAAO;AAE3C,UAAI,aAAa,SAAU,MAAK,IAAI,mBAAmB,KAAK;AAAA,eACnD,aAAa,gBAAiB,MAAK,IAAI,mBAAmB,OAAO,QAAQ,CAAC;AAEnF,UAAI,OAAO,EAAG,MAAK,IAAI,cAAc,OAAO,OAAO,CAAC,CAAC;AACrD,aAAO;AAAA,IACT;AAAA,IACA,EAAE,SAAS,KAAK;AAAA,EAClB;AACF;AAoBO,SAAS,cAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASG;AAGD,QAAM,CAAC,UAAU,IAAI,SAAmB,MAAO,UAAU,aAAa,OAAO,IAAI,CAAC,CAAE;AACpF,QAAM,UAAU;AAAA,IACd,MAAM,cAAc,YAAY,gBAAgB;AAAA,IAChD,CAAC,YAAY,gBAAgB;AAAA,EAC/B;AACA,QAAM,CAAC,eAAe,gBAAgB,IAAI;AAAA,IAAsB,MAC9D,WAAW,UAAU,SAAY,WAAW,QAAQ,eAAe,QAAQ,IAAI;AAAA,EACjF;AACA,QAAM,CAAC,iBAAiB,kBAAkB,IAAI;AAAA,IAC5C,MAAM,WAAW,WAAW,QAAQ,WAAW,CAAC;AAAA,EAClD;AAGA,QAAM,QAAQ,aAAa;AAC3B,QAAM,UAAU,eAAe;AAC/B,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,MAAM,WAAW,QAAQ,CAAC;AAC3D,QAAM,CAAC,UAAU,WAAW,IAAI,SAAiB,MAAM;AACrD,QAAI,WAAW,aAAa,OAAW,QAAO,WAAW;AACzD,QAAI,QAAQ,aAAa,MAAO,QAAO;AACvC,QAAI,OAAO,QAAQ,aAAa,YAAY,QAAQ,WAAW,EAAG,QAAO,QAAQ;AACjF,WAAO;AAAA,EACT,CAAC;AACD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAiC,MAAM,QAAQ,UAAU,CAAC,CAAC;AACvF,QAAM,CAAC,YAAY,aAAa,IAAI,SAAsB,MAAM,IAAI,IAAI,QAAQ,UAAU,CAAC,CAAC,CAAC;AAC7F,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,WAAW,QAAQ,MAAM,oBAAI,IAAkC,GAAG,CAAC,CAAC;AAY1E,QAAM,gBAAgB,OAAsB,IAAI;AAChD,QAAM,YAAY,CAAC,aACf,KACA,mBAAmB;AAAA,IACjB,MAAM;AAAA,IACN;AAAA,IACA,UAAU,aAAa,WAAW,QAAQ;AAAA,IAC1C;AAAA,IACA,QAAQ,MAAM,KAAK,UAAU;AAAA,EAC/B,CAAC;AACL,YAAU,MAAM;AACd,QAAI,CAAC,WAAY;AAIjB,UAAM,SAAS,mBAAmB;AAClC,UAAM,MAAM,GAAG,MAAM,KAAS,SAAS;AACvC,QAAI,cAAc,YAAY,IAAK;AACnC,kBAAc,UAAU;AACxB,gBAAY,QAAQ,SAAS;AAAA,EAC/B,GAAG,CAAC,YAAY,kBAAkB,SAAS,CAAC;AAE5C,QAAM,UAAU,OAAsB,IAAI;AAC1C,QAAM,SAAS,UACX,aAAa,SAAS,OAAO,SAAS,UAAU,MAAM,eAAe,IACrE;AACJ,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,QAAI,QAAQ,YAAY,OAAQ;AAChC,YAAQ,UAAU;AAIlB,kBAAc,iBAAiB,SAAS,OAAO,SAAS,UAAU,MAAM,eAAe;AAAA,EAEzF,GAAG,CAAC,SAAS,MAAM,CAAC;AAEpB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/components/use-table-state.ts"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useSearchParams } from \"react-router\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport {\n decodeFilterValue,\n encodeFilterValue,\n isFilterActive,\n resolveFilter,\n} from \"./data-table-filters\";\nimport { decodeSorts, encodeSorts, normalizeSorts, type SortState } from \"./data-table-sort\";\nimport type { DataTableColumn, FilterState } from \"./data-table\";\n\n// ---------- Persisted + URL-encoded view state ----------\n//\n// Moved here with `useTableState` when `DataTable` was split: these are what the hook\n// reads and writes, and nothing else in the table ever called them.\n\ninterface PersistedState {\n // Multi-sort array; blobs from before the upgrade hold a single {key, dir}\n // object (or null) — read back through normalizeSorts.\n sort: SortState[] | { key: string; dir: string } | null;\n filters: FilterState;\n pageSize: number | \"all\";\n widths?: Record<string, number>;\n hidden?: string[];\n}\n\nexport const DEFAULT_PERSIST_PREFIX = \"hbui-table:\";\n\nfunction loadPersisted(storageKey: string | undefined, prefix: string): Partial<PersistedState> {\n if (!storageKey) return {};\n const raw = readStored(prefix + storageKey);\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as Partial<PersistedState>;\n return parsed && typeof parsed === \"object\" ? parsed : {};\n } catch {\n return {}; // corrupt JSON — the storage access itself is guarded by readStored\n }\n}\n\n/** The blob that would be stored — also the value the effect below compares by, so\n * the comparison and the write can never disagree about what \"unchanged\" means. */\nfunction serializePersisted(state: PersistedState): string {\n return JSON.stringify(state);\n}\n\n// ---------- URL sync helpers ----------\n\nconst URL_FILTER_PREFIX = \"f.\";\nconst URL_SORT_KEY = \"sort\";\nconst URL_PAGE_KEY = \"p\";\nconst URL_PAGE_SIZE_KEY = \"ps\";\n\ninterface UrlState {\n sorts?: SortState[];\n filters?: FilterState;\n pageSize?: number; // Infinity for \"all\"\n page?: number; // 0-based\n}\n\n/**\n * The managed keys out of the ROUTER's query string — the one `setSearchParams` writes.\n *\n * This read `window.location.search` until 0.7.0, which is the same string only under a\n * BrowserRouter. Under a HashRouter the router's query lives after the `#`\n * (`/#/tx?f.payee=Rewe`) and `window.location.search` is empty: a shared link opened\n * unfiltered, and the table's first write then replaced the link's filter with its own\n * empty state. Reading and writing through `useSearchParams` is one source for both\n * directions under every router, MemoryRouter included.\n */\nfunction readUrlState<T>(sp: URLSearchParams, columns: DataTableColumn<T>[]): UrlState {\n const out: UrlState = {};\n\n const filters: FilterState = {};\n for (const col of columns) {\n const filter = resolveFilter(col);\n if (!filter) continue;\n const raw = sp.get(URL_FILTER_PREFIX + col.key);\n if (raw == null) continue;\n filters[col.key] = decodeFilterValue(filter, raw);\n }\n if (Object.keys(filters).length) out.filters = filters;\n\n const sortRaw = sp.get(URL_SORT_KEY);\n if (sortRaw) {\n const decoded = decodeSorts(sortRaw, new Set(columns.map((c) => c.key)));\n if (decoded.length) out.sorts = decoded;\n }\n\n const psRaw = sp.get(URL_PAGE_SIZE_KEY);\n if (psRaw === \"all\") out.pageSize = Infinity;\n else if (psRaw && Number(psRaw) > 0) out.pageSize = Number(psRaw);\n\n const pRaw = sp.get(URL_PAGE_KEY);\n if (pRaw && Number(pRaw) >= 1) out.page = Number(pRaw) - 1;\n\n return out;\n}\n\n/**\n * The managed part of the query string, as a string, for comparing by VALUE.\n *\n * Everything the table puts in the address and nothing else, in a fixed order.\n * `columns` is walked for its keys only, which is why it can be left out of the\n * effect's dependencies without lying: a new column ARRAY carrying the same keys\n * produces the same signature, and a genuinely different set of filterable columns\n * produces a different one.\n */\nfunction urlSignature<T>(\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n): string {\n const parts: string[] = [];\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) parts.push(`${URL_FILTER_PREFIX}${col.key}=${enc}`);\n }\n parts.push(`${URL_SORT_KEY}=${encodeSorts(sorts)}`);\n parts.push(\n `${URL_PAGE_SIZE_KEY}=${\n pageSize === Infinity ? \"all\" : pageSize === defaultPageSize ? \"\" : String(pageSize)\n }`,\n );\n parts.push(`${URL_PAGE_KEY}=${page > 0 ? String(page + 1) : \"\"}`);\n return parts.join(\"&\");\n}\n\nfunction writeUrlState<T>(\n setSearchParams: ReturnType<typeof useSearchParams>[1],\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n): void {\n setSearchParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n // clear managed keys\n for (const col of columns) next.delete(URL_FILTER_PREFIX + col.key);\n next.delete(URL_SORT_KEY);\n next.delete(URL_PAGE_KEY);\n next.delete(URL_PAGE_SIZE_KEY);\n // write filters\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) next.set(URL_FILTER_PREFIX + col.key, enc);\n }\n // write sort\n const sortEnc = encodeSorts(sorts);\n if (sortEnc) next.set(URL_SORT_KEY, sortEnc);\n // write page size if non-default\n if (pageSize === Infinity) next.set(URL_PAGE_SIZE_KEY, \"all\");\n else if (pageSize !== defaultPageSize) next.set(URL_PAGE_SIZE_KEY, String(pageSize));\n // write page if not first\n if (page > 0) next.set(URL_PAGE_KEY, String(page + 1));\n return next;\n },\n { replace: true },\n );\n}\n\n/**\n * Where a table's view lives: what it is sorted and filtered by, which page, how wide\n * the columns are and which are hidden — plus the two places that outlives a render.\n *\n * Lifted out of `DataTable`, which was 984 lines and twenty hooks in one function. This\n * is the part that is genuinely about STATE rather than about drawing, and the three\n * rules it encodes are easy to lose among the markup:\n *\n * * **The URL is read once, on mount.** A shared or deep link populates the initial view;\n * after that the state is local, or the browser's own history would fight the user's\n * next click.\n * * **Sorting and filtering are optionally CONTROLLED.** A server-driven table passes\n * state and a callback and owns them; everything else is self-owned. That is the\n * `sortsProp ?? internalSorts` pair, and it is why the setters are exposed separately.\n * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the\n * per-table identity; `urlSync` is opt-in because only some tables want their view in\n * a shareable address.\n * * **A controlled filter or sort that changes sends the table back to page 1.** See\n * the note at the reset below.\n */\nexport function useTableState<T>({\n columns,\n storageKey,\n storageKeyPrefix,\n urlSync,\n defaultPageSize,\n sortsProp,\n filtersProp,\n}: {\n columns: DataTableColumn<T>[];\n storageKey?: string;\n storageKeyPrefix: string;\n urlSync?: boolean;\n defaultPageSize: number;\n sortsProp?: SortState[];\n filtersProp?: FilterState;\n}) {\n // Called unconditionally (a Router is required either way — see src/data-table.ts),\n // and the SAME pair is used to read and to write; see `readUrlState`.\n const [searchParams, setSearchParams] = useSearchParams();\n // Read URL once on mount so URL-encoded views (e.g. shared links, deep links from\n // other pages) populate initial state. After mount, state is local.\n const [urlInitial] = useState<UrlState>(() =>\n urlSync ? readUrlState(searchParams, columns) : {},\n );\n const initial = useMemo(\n () => loadPersisted(storageKey, storageKeyPrefix),\n [storageKey, storageKeyPrefix],\n );\n const [internalSorts, setInternalSorts] = useState<SortState[]>(() =>\n urlInitial.sorts !== undefined ? urlInitial.sorts : normalizeSorts(initial.sort),\n );\n const [internalFilters, setInternalFilters] = useState<FilterState>(\n () => urlInitial.filters ?? initial.filters ?? {},\n );\n // Controlled when the owner passes state + callback (server-driven tables);\n // self-owned otherwise.\n const sorts = sortsProp ?? internalSorts;\n const filters = filtersProp ?? internalFilters;\n const [page, setPage] = useState(() => urlInitial.page ?? 0);\n\n // ---- Controlled filters/sorts reset the page ----\n //\n // A self-owned table resets to page 1 in the filter handlers. A CONTROLLED one could\n // not: the handlers hand the change to the owner and return, and `page` lives here,\n // where the owner has no way to reach it — so narrowing a filter from outside (a\n // saved view, a chip dismissed above the table) left a client-side table on page 4\n // of what was now two pages, clamped to the last one instead of the first.\n //\n // So the table does it: whenever the controlled VALUE changes, back to the first\n // page. Compared by value, because a controlled prop is a fresh object on every\n // parent render. Adjusted during render rather than in an effect, so the stale page\n // is never painted and never written to the URL. Not on mount: a deep link's `p=3`\n // must survive the owner's first render. (In `serverPagination` mode this state is\n // unused — the owner's `page` is the page, and resetting it stays the owner's job.)\n const controlledSig =\n sortsProp !== undefined || filtersProp !== undefined\n ? JSON.stringify([sortsProp ?? null, filtersProp ?? null])\n : null;\n const [seenControlledSig, setSeenControlledSig] = useState(controlledSig);\n if (seenControlledSig !== controlledSig) {\n setSeenControlledSig(controlledSig);\n setPage(0);\n }\n const [pageSize, setPageSize] = useState<number>(() => {\n if (urlInitial.pageSize !== undefined) return urlInitial.pageSize;\n if (initial.pageSize === \"all\") return Infinity;\n if (typeof initial.pageSize === \"number\" && initial.pageSize > 0) return initial.pageSize;\n return defaultPageSize;\n });\n const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});\n const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set(initial.hidden ?? []));\n const [showSettings, setShowSettings] = useState(false);\n const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);\n\n // ---- Writing out: both of these compare by VALUE ----\n //\n // A CONTROLLED table (`sorts`/`filters` props plus callbacks — every server-driven\n // one) receives a freshly-built object on every render of the page around it, and\n // `{}` is never `===` the `{}` before it. Keyed on identity, both effects below\n // therefore fired on every unrelated parent render: persistence wrote the same JSON\n // into `localStorage` each time — a synchronous main-thread write, on a page whose\n // other half may be a form being typed into — and the URL one handed react-router a\n // `replace` it answers with a render, which is the loop the audit records. A `ref`\n // holding what was last written is what makes \"changed\" mean changed.\n const lastPersisted = useRef<string | null>(null);\n const persisted = !storageKey\n ? \"\"\n : serializePersisted({\n sort: sorts,\n filters,\n pageSize: pageSize === Infinity ? \"all\" : pageSize,\n widths,\n hidden: Array.from(hiddenCols),\n });\n useEffect(() => {\n if (!storageKey) return;\n // The destination is part of the signature: two tables can hold identical state,\n // and a table that swaps `storageKey` has to write under the new one even though\n // nothing about the view changed.\n const target = storageKeyPrefix + storageKey;\n const sig = `${target}\\u0000${persisted}`;\n if (lastPersisted.current === sig) return;\n lastPersisted.current = sig;\n writeStored(target, persisted);\n }, [storageKey, storageKeyPrefix, persisted]);\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, sorts, filters, pageSize, page, defaultPageSize)\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n // `columns` is deliberately not a dependency and does not need to be: the\n // signature above is computed from it, so a column set that would produce a\n // different address produces a different signature first.\n writeUrlState(setSearchParams, columns, sorts, filters, pageSize, page, defaultPageSize);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n return {\n sorts,\n filters,\n setInternalSorts,\n setInternalFilters,\n page,\n setPage,\n pageSize,\n setPageSize,\n widths,\n setWidths,\n hiddenCols,\n setHiddenCols,\n showSettings,\n setShowSettings,\n headRefs,\n };\n}\n"],"mappings":"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","names":[]}
@@ -34,7 +34,19 @@ interface ServerPagination {
34
34
  pageSize: number;
35
35
  total: number;
36
36
  onPageChange: (page: number) => void;
37
+ /** Without it the page size is the server's to choose, and the pager shows no
38
+ * page-size select at all — one that changed nothing was a control that lied. */
37
39
  onPageSizeChange?: (pageSize: number) => void;
40
+ /**
41
+ * A page is being fetched. The table marks itself `aria-busy`, dims the rows it is
42
+ * still showing (they are the PREVIOUS page's, and about to go), and replaces the
43
+ * empty state with a loading row when there is nothing to show yet — "no results"
44
+ * while the first page is still in flight is a claim the table cannot make.
45
+ *
46
+ * The pager stays usable. Disabling it would drop focus from the very button the
47
+ * user just pressed (a disabled button cannot hold focus), and a second click
48
+ * during a fetch is a request the owner already has to handle for a slow network.
49
+ */
38
50
  isLoading?: boolean;
39
51
  }
40
52
  interface DataTableProps<T> {
@@ -127,8 +139,14 @@ interface DataTableProps<T> {
127
139
  * Controlled filter/sort state. When the `on*Change` callback is provided
128
140
  * the table stops owning that piece of state: it renders `filters`/`sorts`
129
141
  * as given and reports user interactions through the callback (including
130
- * in serverPagination mode, where the UI is otherwise disabled). The owner
131
- * is responsible for any page reset on change.
142
+ * in serverPagination mode, where the UI is otherwise disabled).
143
+ *
144
+ * Page reset: on a client-side table, a change in the VALUE of `filters` or
145
+ * `sorts` sends the table back to page 1 — whoever made the change, the user
146
+ * through the header or the owner from outside (a saved view, a chip). Compared
147
+ * by value, so passing a fresh but equal object on every render does not reset
148
+ * anything. In `serverPagination` mode the owner's `page` is the page, and
149
+ * resetting it on a filter change stays the owner's job.
132
150
  */
133
151
  filters?: FilterState;
134
152
  onFiltersChange?: (next: FilterState) => void;
@@ -199,15 +217,19 @@ interface DataTableProps<T> {
199
217
  * `null` (or empty sides) for rows that should not move — a locked row, one whose
200
218
  * mutation is in flight, one the user has expanded.
201
219
  *
220
+ * Prefer `start`/`end`: actions committed by dragging the row toward the reading
221
+ * START or END of the line. In a left-to-right layout `end` is a drag to the
222
+ * right, and in a right-to-left one it is a drag to the left — so a "drag forward
223
+ * to archive" row stays forward in Arabic or Hebrew without a second definition.
224
+ * `left`/`right` are the physical directions and keep meaning exactly that; each
225
+ * is used only where the logical side for that direction is not given.
226
+ *
202
227
  * Mobile only, and deliberately so: the desktop table already has room for an
203
228
  * actions column, and a drag gesture on a pointer device is a worse version of a
204
229
  * button. Wraps only the row body, so an expansion panel below stays put while the
205
230
  * row above it slides.
206
231
  */
207
- mobileSwipeActions?: (row: T) => {
208
- left?: SwipeAction[];
209
- right?: SwipeAction[];
210
- } | null;
232
+ mobileSwipeActions?: (row: T) => MobileSwipeActions | null;
211
233
  /**
212
234
  * Renders as a full-width row above the data rows in the desktop table
213
235
  * (mobile has no equivalent list-row slot, so it's desktop-only). For a
@@ -220,6 +242,17 @@ interface DataTableProps<T> {
220
242
  }
221
243
  type FilterState = Record<string, FilterValue>;
222
244
 
245
+ /** What {@link DataTableProps.mobileSwipeActions} returns for one row. */
246
+ interface MobileSwipeActions {
247
+ /** Committed by dragging toward the reading start (left in LTR, right in RTL). */
248
+ start?: SwipeAction[];
249
+ /** Committed by dragging toward the reading end (right in LTR, left in RTL). */
250
+ end?: SwipeAction[];
251
+ /** Committed by dragging physically LEFT, whatever the direction. */
252
+ left?: SwipeAction[];
253
+ /** Committed by dragging physically RIGHT, whatever the direction. */
254
+ right?: SwipeAction[];
255
+ }
223
256
  declare function DataTable<T>({ rows, columns, rowKey, defaultPageSize, onRowClick, rowHref, expandedRow, isExpanded, rowClassName, rowAttributes, paginated, empty, storageKey, urlSync, fillHeight, maxBodyHeight, serverPagination, filters: filtersProp, onFiltersChange, sorts: sortsProp, onSortsChange, selection, labels: labelsProp, locale: localeProp, storageKeyPrefix, mobileExpandAsDialog, mobileGroupBy, mobileGroupLabel, mobileCard, mobileSwipeActions, leadingRow, }: DataTableProps<T>): react.JSX.Element;
224
257
 
225
258
  /** How a column is filtered. `text`/`select`/`date`/`number` each read a value
@@ -280,4 +313,4 @@ declare function decodeFilterValueOfType(type: FilterValue["type"], raw: string)
280
313
  /** Inverse of {@link encodeFilterValue}, typed by the column's filter config. */
281
314
  declare function decodeFilterValue<T>(filter: ColumnFilter<T>, raw: string): FilterValue;
282
315
 
283
- export { type ColumnFilter as C, DataTable as D, type FilterState as F, type ServerPagination as S, type DataTableColumn as a, type DataTableProps as b, type FilterValue as c, decodeFilterValue as d, decodeFilterValueOfType as e, defaultFilterState as f, encodeFilterValue as g, rowMatches as h, isFilterActive as i, resolveFilter as r };
316
+ export { type ColumnFilter as C, DataTable as D, type FilterState as F, type MobileSwipeActions as M, type ServerPagination as S, type DataTableColumn as a, type DataTableProps as b, type FilterValue as c, decodeFilterValue as d, decodeFilterValueOfType as e, defaultFilterState as f, encodeFilterValue as g, rowMatches as h, isFilterActive as i, resolveFilter as r };
@@ -1,6 +1,6 @@
1
1
  export { DEFAULT_DATA_TABLE_LABELS, DataTableLabels, missingDataTableLabels, resolveDataTableLabels } from './components/data-table-labels.js';
2
2
  export { SortDir, SortState, decodeSorts, encodeSorts, nextSorts, normalizeSorts } from './components/data-table-sort.js';
3
- export { C as ColumnFilter, D as DataTable, a as DataTableColumn, b as DataTableProps, F as FilterState, c as FilterValue, S as ServerPagination, d as decodeFilterValue, e as decodeFilterValueOfType, f as defaultFilterState, g as encodeFilterValue, i as isFilterActive, r as resolveFilter, h as rowMatches } from './data-table-filters-DM1auUfH.js';
3
+ export { C as ColumnFilter, D as DataTable, a as DataTableColumn, b as DataTableProps, F as FilterState, c as FilterValue, M as MobileSwipeActions, S as ServerPagination, d as decodeFilterValue, e as decodeFilterValueOfType, f as defaultFilterState, g as encodeFilterValue, i as isFilterActive, r as resolveFilter, h as rowMatches } from './data-table-filters-noy0Abvi.js';
4
4
  export { PAGE_SIZE_OPTIONS, Pagination } from './components/data-table-pagination.js';
5
5
  export { FilterPopover } from './components/data-table-filter-popover.js';
6
6
  import 'react';
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable } from \"./components/data-table\";\nexport type {\n DataTableColumn,\n DataTableProps,\n ServerPagination,\n FilterState,\n} from \"./components/data-table\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,iBAAiB;","names":[]}
1
+ {"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable } from \"./components/data-table\";\nexport type {\n DataTableColumn,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n} from \"./components/data-table\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,iBAAiB;","names":[]}
@@ -4,6 +4,23 @@ import { FeedbackAttachmentLabels } from './feedback-dialog.js';
4
4
 
5
5
  declare const DEFAULT_ATTACHMENT_ACCEPT: string[];
6
6
  declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
7
+ /**
8
+ * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider
9
+ * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop
10
+ * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them
11
+ * one to one. The optional heading, `attachment`, stays a prop: a note editor puts
12
+ * the buttons straight under its textarea and wants none.
13
+ *
14
+ * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app
15
+ * that relied on its provider got "Capture screenshot" under a German form.
16
+ */
17
+ interface FeedbackAttachmentFieldLabels {
18
+ attachmentAdd: string;
19
+ attachmentCapture: string;
20
+ attachmentPaste: string;
21
+ attachmentRemove: string;
22
+ }
23
+ declare const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels;
7
24
  /**
8
25
  * Picking one picture: the file dialog, a capture of the app view, or a paste.
9
26
  *
@@ -33,10 +50,12 @@ declare const DEFAULT_MAX_ATTACHMENT_BYTES: number;
33
50
  * (Steering Design feedback #140): the element whose subtree is listened to,
34
51
  * handed in by whoever renders both the box and this field side by side.
35
52
  */
36
- declare function FeedbackAttachmentField({ value, onChange, labels, accept, maxBytes, onError, onCaptureScreenshot, documentPaste, pasteFrom, className, }: {
53
+ declare function FeedbackAttachmentField({ value, onChange, labels: labelsProp, accept, maxBytes, onError, onCaptureScreenshot, documentPaste, pasteFrom, className, }: {
37
54
  value: File | null;
38
55
  onChange: (file: File | null) => void;
39
- labels: FeedbackAttachmentLabels;
56
+ /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
57
+ * since 0.7.0; `attachment` (the heading) is only ever read from here. */
58
+ labels?: Partial<FeedbackAttachmentLabels>;
40
59
  accept?: string[];
41
60
  maxBytes?: number;
42
61
  onError?: (kind: "type" | "size") => void;
@@ -60,4 +79,4 @@ declare function FeedbackAttachmentField({ value, onChange, labels, accept, maxB
60
79
  * a PNG is one the receiving end opens wrong. */
61
80
  declare function pastedName(type: string): string;
62
81
 
63
- export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, pastedName };
82
+ export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_FEEDBACK_ATTACHMENT_LABELS, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, type FeedbackAttachmentFieldLabels, pastedName };
@@ -4,13 +4,19 @@ import { cn } from "../lib/cn.js";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { Camera, FileText, Paperclip, X } from "lucide-react";
6
6
  import { Button } from "../components/ui.js";
7
- import { useKitFileLabels } from "../i18n/kit-labels.js";
7
+ import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
8
8
  const DEFAULT_ATTACHMENT_ACCEPT = ["image/png", "image/jpeg", "image/webp", "image/gif"];
9
9
  const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
10
+ const DEFAULT_FEEDBACK_ATTACHMENT_LABELS = {
11
+ attachmentAdd: "Attach image",
12
+ attachmentCapture: "Capture screenshot",
13
+ attachmentPaste: "\u2026or paste a screenshot from the clipboard.",
14
+ attachmentRemove: "Remove attachment"
15
+ };
10
16
  function FeedbackAttachmentField({
11
17
  value,
12
18
  onChange,
13
- labels,
19
+ labels: labelsProp,
14
20
  accept = DEFAULT_ATTACHMENT_ACCEPT,
15
21
  maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
16
22
  onError,
@@ -23,6 +29,7 @@ function FeedbackAttachmentField({
23
29
  const [capturing, setCapturing] = useState(false);
24
30
  const fileInputRef = useRef(null);
25
31
  const fileText = useKitFileLabels();
32
+ const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
26
33
  useEffect(() => {
27
34
  if (!value) {
28
35
  setPreview(null);
@@ -73,7 +80,7 @@ function FeedbackAttachmentField({
73
80
  className: cn("relative", className),
74
81
  onPaste: listensElsewhere ? void 0 : (event) => takeImage(event.clipboardData, () => event.preventDefault()),
75
82
  children: [
76
- labels.attachment && /* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]", children: labels.attachment }),
83
+ labelsProp?.attachment && /* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]", children: labelsProp.attachment }),
77
84
  value ? /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
78
85
  value.type.startsWith("image/") && preview ? /* @__PURE__ */ jsx(
79
86
  "img",
@@ -96,7 +103,7 @@ function FeedbackAttachmentField({
96
103
  {
97
104
  type: "button",
98
105
  onClick: () => onChange(null),
99
- "aria-label": labels.attachmentRemove,
106
+ "aria-label": text.attachmentRemove,
100
107
  className: "rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
101
108
  children: /* @__PURE__ */ jsx(X, { className: "size-4" })
102
109
  }
@@ -106,7 +113,7 @@ function FeedbackAttachmentField({
106
113
  /* @__PURE__ */ jsxs(Button, { type: "button", variant: "secondary", onClick: () => fileInputRef.current?.click(), children: [
107
114
  /* @__PURE__ */ jsx(Paperclip, { className: "size-4" }),
108
115
  " ",
109
- labels.attachmentAdd
116
+ text.attachmentAdd
110
117
  ] }),
111
118
  onCaptureScreenshot && /* @__PURE__ */ jsxs(
112
119
  Button,
@@ -124,12 +131,12 @@ function FeedbackAttachmentField({
124
131
  children: [
125
132
  /* @__PURE__ */ jsx(Camera, { className: "size-4" }),
126
133
  " ",
127
- capturing ? "\u2026" : labels.attachmentCapture ?? "Capture screenshot"
134
+ capturing ? "\u2026" : text.attachmentCapture
128
135
  ]
129
136
  }
130
137
  )
131
138
  ] }),
132
- /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: labels.attachmentPaste ?? "\u2026or paste a screenshot from the clipboard." })
139
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: text.attachmentPaste })
133
140
  ] }),
134
141
  /* @__PURE__ */ jsx(
135
142
  "input",
@@ -154,6 +161,7 @@ function pastedName(type) {
154
161
  }
155
162
  export {
156
163
  DEFAULT_ATTACHMENT_ACCEPT,
164
+ DEFAULT_FEEDBACK_ATTACHMENT_LABELS,
157
165
  DEFAULT_MAX_ATTACHMENT_BYTES,
158
166
  FeedbackAttachmentField,
159
167
  pastedName
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/feedback/feedback-attachment.tsx"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { Camera, FileText, Paperclip, X } from \"lucide-react\";\nimport { Button } from \"../components/ui\";\nimport { useKitFileLabels } from \"../i18n/kit-labels\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\nexport const DEFAULT_ATTACHMENT_ACCEPT = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nexport const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;\n\n/**\n * Picking one picture: the file dialog, a capture of the app view, or a paste.\n *\n * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can\n * have it too (Steering Design feedback #128). A report is a conversation — it\n * is filed, it is answered, and the reporter sends it back saying that is not\n * what they meant — and the screenshot showing what they mean is taken at\n * whichever of those points they looked. Only the first of them had a way to\n * attach one, so everything after it had to be described in words.\n *\n * **Three ways in, and they are three because no one of them covers the others.**\n * The file dialog needs a file on disk; `onCaptureScreenshot` snapshots the\n * *whole* app view; and the clipboard is where a region snip already is on every\n * platform (Steering Design feedback #39). Somebody who wanted to show one panel\n * had to save a crop and then find it again.\n *\n * `documentPaste` and `pasteFrom` decide where the paste is listened for, and\n * it is a real choice rather than a flag with a default. A modal traps focus,\n * so while it is up every paste in the page is meant for it — including one\n * made with nothing in particular focused, which never reaches a React\n * `onPaste` on a child: that is `documentPaste`. An editor **inline on a\n * page** is not that: the page around it has its own fields, so it listens\n * within its own subtree and a paste elsewhere stays where it was aimed. But\n * \"its own subtree\" has to include the text box the paste is actually made in,\n * and that box is this field's *sibling*, not its child — a paste in it bubbles\n * to their common parent and never through here. `pasteFrom` is that parent\n * (Steering Design feedback #140): the element whose subtree is listened to,\n * handed in by whoever renders both the box and this field side by side.\n */\nexport function FeedbackAttachmentField({\n value,\n onChange,\n labels,\n accept = DEFAULT_ATTACHMENT_ACCEPT,\n maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onError,\n onCaptureScreenshot,\n documentPaste = false,\n pasteFrom,\n className,\n}: {\n value: File | null;\n onChange: (file: File | null) => void;\n labels: FeedbackAttachmentLabels;\n accept?: string[];\n maxBytes?: number;\n onError?: (kind: \"type\" | \"size\") => void;\n /** Snapshot the app view behind this and return it as a File. A \"Capture\n * screenshot\" button appears only when it is given. */\n onCaptureScreenshot?: () => Promise<File | null>;\n /** Listen for the paste on `document` rather than on this field's own\n * subtree. For a modal, which owns the whole page while it is up. */\n documentPaste?: boolean;\n /** Listen for the paste within this element's subtree rather than this\n * field's own — for a field standing *beside* the text box a paste is made\n * in, whose common parent is where the event bubbles to. Ignored when\n * `documentPaste` is set. */\n pasteFrom?: RefObject<HTMLElement | null>;\n className?: string;\n}) {\n const [preview, setPreview] = useState<string | null>(null);\n const [capturing, setCapturing] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.\n const fileText = useKitFileLabels();\n\n // Object-URL preview lifecycle (create on change, revoke on cleanup).\n useEffect(() => {\n if (!value) {\n setPreview(null);\n return;\n }\n const url = URL.createObjectURL(value);\n setPreview(url);\n return () => URL.revokeObjectURL(url);\n }, [value]);\n\n const pick = (file: File | undefined | null) => {\n if (!file) return;\n if (!accept.includes(file.type)) {\n onError?.(\"type\");\n return;\n }\n if (file.size > maxBytes) {\n onError?.(\"size\");\n return;\n }\n onChange(file);\n };\n\n // The paste handler is registered once rather than per render, so it reads\n // `pick` — which closes over props that change identity on every render — out\n // of a ref rather than out of its own dependency list.\n const latest = useRef(pick);\n useEffect(() => {\n latest.current = pick;\n });\n\n const takeImage = (clipboard: DataTransfer | null, stop: () => void) => {\n const items = Array.from(clipboard?.items ?? []);\n const image = items.find((item) => item.kind === \"file\" && item.type.startsWith(\"image/\"));\n const file = image?.getAsFile();\n if (!file) return;\n // Only once there IS an image: a paste of text into a field must stay a\n // paste of text, and a clipboard holding both is a copy whose text half is\n // what the field was focused for.\n stop();\n // Clipboard images arrive named \"image.png\" at best and unnamed at worst,\n // and the name is what the inbox shows beside the thumbnail. A name that\n // says where it came from is more use than the browser's.\n latest.current(new File([file], pastedName(file.type), { type: file.type }));\n };\n\n // The element listened on, where it is not this field's own subtree: the\n // document for a modal, the given parent for an inline editor. Read inside\n // the effect rather than in render, because a ref's `current` is only set\n // once the parent has mounted — which it has by the time effects run.\n useEffect(() => {\n const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);\n if (!target) return;\n const onPaste = (event: Event) => {\n const clipboard = (event as ClipboardEvent).clipboardData;\n takeImage(clipboard, () => event.preventDefault());\n };\n target.addEventListener(\"paste\", onPaste);\n return () => target.removeEventListener(\"paste\", onPaste);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- takeImage reads `latest`, which is a ref\n }, [documentPaste, pasteFrom]);\n const listensElsewhere = documentPaste || pasteFrom !== undefined;\n\n return (\n <div\n // `relative` for the same reason as FileDropzone: the `sr-only` file input below\n // is `position: absolute`, and without a positioned ancestor it is laid out\n // against the initial containing block — extending the DOCUMENT height to its own\n // offset and producing a phantom second scrollbar on any long page.\n className={cn(\"relative\", className)}\n onPaste={\n listensElsewhere\n ? undefined\n : (event) => takeImage(event.clipboardData, () => event.preventDefault())\n }\n >\n {labels.attachment && (\n <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]\">\n {labels.attachment}\n </div>\n )}\n {value ? (\n <div className=\"flex items-start gap-2\">\n {value.type.startsWith(\"image/\") && preview ? (\n <img\n src={preview}\n alt={value.name}\n className=\"h-20 w-20 rounded border border-[var(--border)] object-cover\"\n />\n ) : (\n // Non-image attachments (PDF, text) can't preview as an <img>, so\n // show a neutral file tile with the name/size beside it instead.\n <div className=\"flex h-20 w-20 items-center justify-center rounded border border-[var(--border)] text-[var(--text-placeholder)]\">\n <FileText className=\"size-8\" />\n </div>\n )}\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm text-[var(--text-secondary)]\">{value.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {fileText.size(value.size)}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onChange(null)}\n aria-label={labels.attachmentRemove}\n className=\"rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n ) : (\n <div className=\"space-y-1.5\">\n <div className=\"flex flex-wrap gap-2\">\n <Button type=\"button\" variant=\"secondary\" onClick={() => fileInputRef.current?.click()}>\n <Paperclip className=\"size-4\" /> {labels.attachmentAdd}\n </Button>\n {onCaptureScreenshot && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n disabled={capturing}\n onClick={() => {\n if (capturing) return;\n setCapturing(true);\n void onCaptureScreenshot()\n .then((file) => {\n if (file) pick(file);\n })\n .finally(() => setCapturing(false));\n }}\n >\n <Camera className=\"size-4\" />{\" \"}\n {capturing ? \"…\" : (labels.attachmentCapture ?? \"Capture screenshot\")}\n </Button>\n )}\n </div>\n {/* Said out loud, because a gesture with no affordance is a gesture\n nobody finds. */}\n <p className=\"text-xs text-[var(--text-muted)]\">\n {labels.attachmentPaste ?? \"…or paste a screenshot from the clipboard.\"}\n </p>\n </div>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept.join(\",\")}\n className=\"sr-only\"\n onChange={(e) => {\n pick(e.target.files?.[0]);\n e.target.value = \"\";\n }}\n />\n </div>\n );\n}\n\n/** What a pasted image is called once it is an attachment.\n *\n * The extension is read off the mime type rather than assumed to be `.png`:\n * Safari puts TIFF on the clipboard and a file called `pasted.png` that is not\n * a PNG is one the receiving end opens wrong. */\nexport function pastedName(type: string): string {\n const subtype = type.split(\"/\")[1] ?? \"png\";\n // `image/svg+xml` and friends carry a suffix that is not part of the\n // extension, and none of them are in the accepted list anyway.\n return `pasted.${subtype.split(\"+\")[0]}`;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/feedback/feedback-attachment.tsx"],"sourcesContent":["import { cn } from \"../lib/cn\";\nimport { useEffect, useRef, useState, type RefObject } from \"react\";\nimport { Camera, FileText, Paperclip, X } from \"lucide-react\";\nimport { Button } from \"../components/ui\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\n\nexport const DEFAULT_ATTACHMENT_ACCEPT = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\nexport const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;\n\n/**\n * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider\n * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop\n * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them\n * one to one. The optional heading, `attachment`, stays a prop: a note editor puts\n * the buttons straight under its textarea and wants none.\n *\n * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app\n * that relied on its provider got \"Capture screenshot\" under a German form.\n */\nexport interface FeedbackAttachmentFieldLabels {\n attachmentAdd: string;\n attachmentCapture: string;\n attachmentPaste: string;\n attachmentRemove: string;\n}\n\nexport const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {\n attachmentAdd: \"Attach image\",\n attachmentCapture: \"Capture screenshot\",\n attachmentPaste: \"…or paste a screenshot from the clipboard.\",\n attachmentRemove: \"Remove attachment\",\n};\n\n/**\n * Picking one picture: the file dialog, a capture of the app view, or a paste.\n *\n * Lifted out of {@link FeedbackDialog} so the *reply* half of the feature can\n * have it too (Steering Design feedback #128). A report is a conversation — it\n * is filed, it is answered, and the reporter sends it back saying that is not\n * what they meant — and the screenshot showing what they mean is taken at\n * whichever of those points they looked. Only the first of them had a way to\n * attach one, so everything after it had to be described in words.\n *\n * **Three ways in, and they are three because no one of them covers the others.**\n * The file dialog needs a file on disk; `onCaptureScreenshot` snapshots the\n * *whole* app view; and the clipboard is where a region snip already is on every\n * platform (Steering Design feedback #39). Somebody who wanted to show one panel\n * had to save a crop and then find it again.\n *\n * `documentPaste` and `pasteFrom` decide where the paste is listened for, and\n * it is a real choice rather than a flag with a default. A modal traps focus,\n * so while it is up every paste in the page is meant for it — including one\n * made with nothing in particular focused, which never reaches a React\n * `onPaste` on a child: that is `documentPaste`. An editor **inline on a\n * page** is not that: the page around it has its own fields, so it listens\n * within its own subtree and a paste elsewhere stays where it was aimed. But\n * \"its own subtree\" has to include the text box the paste is actually made in,\n * and that box is this field's *sibling*, not its child — a paste in it bubbles\n * to their common parent and never through here. `pasteFrom` is that parent\n * (Steering Design feedback #140): the element whose subtree is listened to,\n * handed in by whoever renders both the box and this field side by side.\n */\nexport function FeedbackAttachmentField({\n value,\n onChange,\n labels: labelsProp,\n accept = DEFAULT_ATTACHMENT_ACCEPT,\n maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onError,\n onCaptureScreenshot,\n documentPaste = false,\n pasteFrom,\n className,\n}: {\n value: File | null;\n onChange: (file: File | null) => void;\n /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional\n * since 0.7.0; `attachment` (the heading) is only ever read from here. */\n labels?: Partial<FeedbackAttachmentLabels>;\n accept?: string[];\n maxBytes?: number;\n onError?: (kind: \"type\" | \"size\") => void;\n /** Snapshot the app view behind this and return it as a File. A \"Capture\n * screenshot\" button appears only when it is given. */\n onCaptureScreenshot?: () => Promise<File | null>;\n /** Listen for the paste on `document` rather than on this field's own\n * subtree. For a modal, which owns the whole page while it is up. */\n documentPaste?: boolean;\n /** Listen for the paste within this element's subtree rather than this\n * field's own — for a field standing *beside* the text box a paste is made\n * in, whose common parent is where the event bubbles to. Ignored when\n * `documentPaste` is set. */\n pasteFrom?: RefObject<HTMLElement | null>;\n className?: string;\n}) {\n const [preview, setPreview] = useState<string | null>(null);\n const [capturing, setCapturing] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.\n const fileText = useKitFileLabels();\n const text = useKitLabels(\"feedbackAttachment\", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);\n\n // Object-URL preview lifecycle (create on change, revoke on cleanup).\n useEffect(() => {\n if (!value) {\n setPreview(null);\n return;\n }\n const url = URL.createObjectURL(value);\n setPreview(url);\n return () => URL.revokeObjectURL(url);\n }, [value]);\n\n const pick = (file: File | undefined | null) => {\n if (!file) return;\n if (!accept.includes(file.type)) {\n onError?.(\"type\");\n return;\n }\n if (file.size > maxBytes) {\n onError?.(\"size\");\n return;\n }\n onChange(file);\n };\n\n // The paste handler is registered once rather than per render, so it reads\n // `pick` — which closes over props that change identity on every render — out\n // of a ref rather than out of its own dependency list.\n const latest = useRef(pick);\n useEffect(() => {\n latest.current = pick;\n });\n\n const takeImage = (clipboard: DataTransfer | null, stop: () => void) => {\n const items = Array.from(clipboard?.items ?? []);\n const image = items.find((item) => item.kind === \"file\" && item.type.startsWith(\"image/\"));\n const file = image?.getAsFile();\n if (!file) return;\n // Only once there IS an image: a paste of text into a field must stay a\n // paste of text, and a clipboard holding both is a copy whose text half is\n // what the field was focused for.\n stop();\n // Clipboard images arrive named \"image.png\" at best and unnamed at worst,\n // and the name is what the inbox shows beside the thumbnail. A name that\n // says where it came from is more use than the browser's.\n latest.current(new File([file], pastedName(file.type), { type: file.type }));\n };\n\n // The element listened on, where it is not this field's own subtree: the\n // document for a modal, the given parent for an inline editor. Read inside\n // the effect rather than in render, because a ref's `current` is only set\n // once the parent has mounted — which it has by the time effects run.\n useEffect(() => {\n const target: EventTarget | null = documentPaste ? document : (pasteFrom?.current ?? null);\n if (!target) return;\n const onPaste = (event: Event) => {\n const clipboard = (event as ClipboardEvent).clipboardData;\n takeImage(clipboard, () => event.preventDefault());\n };\n target.addEventListener(\"paste\", onPaste);\n return () => target.removeEventListener(\"paste\", onPaste);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- takeImage reads `latest`, which is a ref\n }, [documentPaste, pasteFrom]);\n const listensElsewhere = documentPaste || pasteFrom !== undefined;\n\n return (\n <div\n // `relative` for the same reason as FileDropzone: the `sr-only` file input below\n // is `position: absolute`, and without a positioned ancestor it is laid out\n // against the initial containing block — extending the DOCUMENT height to its own\n // offset and producing a phantom second scrollbar on any long page.\n className={cn(\"relative\", className)}\n onPaste={\n listensElsewhere\n ? undefined\n : (event) => takeImage(event.clipboardData, () => event.preventDefault())\n }\n >\n {labelsProp?.attachment && (\n <div className=\"mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]\">\n {labelsProp.attachment}\n </div>\n )}\n {value ? (\n <div className=\"flex items-start gap-2\">\n {value.type.startsWith(\"image/\") && preview ? (\n <img\n src={preview}\n alt={value.name}\n className=\"h-20 w-20 rounded border border-[var(--border)] object-cover\"\n />\n ) : (\n // Non-image attachments (PDF, text) can't preview as an <img>, so\n // show a neutral file tile with the name/size beside it instead.\n <div className=\"flex h-20 w-20 items-center justify-center rounded border border-[var(--border)] text-[var(--text-placeholder)]\">\n <FileText className=\"size-8\" />\n </div>\n )}\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm text-[var(--text-secondary)]\">{value.name}</div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {fileText.size(value.size)}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={() => onChange(null)}\n aria-label={text.attachmentRemove}\n className=\"rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </button>\n </div>\n ) : (\n <div className=\"space-y-1.5\">\n <div className=\"flex flex-wrap gap-2\">\n <Button type=\"button\" variant=\"secondary\" onClick={() => fileInputRef.current?.click()}>\n <Paperclip className=\"size-4\" /> {text.attachmentAdd}\n </Button>\n {onCaptureScreenshot && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n disabled={capturing}\n onClick={() => {\n if (capturing) return;\n setCapturing(true);\n void onCaptureScreenshot()\n .then((file) => {\n if (file) pick(file);\n })\n .finally(() => setCapturing(false));\n }}\n >\n <Camera className=\"size-4\" />{\" \"}\n {capturing ? \"…\" : text.attachmentCapture}\n </Button>\n )}\n </div>\n {/* Said out loud, because a gesture with no affordance is a gesture\n nobody finds. */}\n <p className=\"text-xs text-[var(--text-muted)]\">\n {text.attachmentPaste}\n </p>\n </div>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept.join(\",\")}\n className=\"sr-only\"\n onChange={(e) => {\n pick(e.target.files?.[0]);\n e.target.value = \"\";\n }}\n />\n </div>\n );\n}\n\n/** What a pasted image is called once it is an attachment.\n *\n * The extension is read off the mime type rather than assumed to be `.png`:\n * Safari puts TIFF on the clipboard and a file called `pasted.png` that is not\n * a PNG is one the receiving end opens wrong. */\nexport function pastedName(type: string): string {\n const subtype = type.split(\"/\")[1] ?? \"png\";\n // `image/svg+xml` and friends carry a suffix that is not part of the\n // extension, and none of them are in the accepted list anyway.\n return `pasted.${subtype.split(\"+\")[0]}`;\n}\n"],"mappings":"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","names":[]}
@@ -10,10 +10,11 @@ interface FeedbackAttachmentLabels {
10
10
  * straight under its textarea, where a second heading is noise. */
11
11
  attachment?: string;
12
12
  attachmentAdd: string;
13
- /** Label for the "capture screenshot" button. Optional — falls back to an English default. */
13
+ /** Label for the "capture screenshot" button. Optional — falls back to the provider's
14
+ * `feedbackAttachment.attachmentCapture`, then English. */
14
15
  attachmentCapture?: string;
15
16
  /** The line under the attachment buttons saying a screenshot can be pasted
16
- * straight in. Optional — falls back to an English default. */
17
+ * straight in. Optional — falls back to the provider's `feedbackAttachment`, then English. */
17
18
  attachmentPaste?: string;
18
19
  attachmentRemove: string;
19
20
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/feedback/feedback-dialog.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Button, Input, PHONE_QUERY, Select, Textarea } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport {\n DEFAULT_ATTACHMENT_ACCEPT,\n DEFAULT_MAX_ATTACHMENT_BYTES,\n FeedbackAttachmentField,\n} from \"./feedback-attachment\";\n\nexport interface FeedbackCategoryOption {\n value: string;\n label: string;\n}\n\nexport interface FeedbackAttachmentLabels {\n /** The heading over the field. Optional: a note editor puts the buttons\n * straight under its textarea, where a second heading is noise. */\n attachment?: string;\n attachmentAdd: string;\n /** Label for the \"capture screenshot\" button. Optional — falls back to an English default. */\n attachmentCapture?: string;\n /** The line under the attachment buttons saying a screenshot can be pasted\n * straight in. Optional — falls back to an English default. */\n attachmentPaste?: string;\n attachmentRemove: string;\n}\n\nexport interface FeedbackDialogLabels extends FeedbackAttachmentLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n attachment: string;\n submitHint: string;\n cancel: string;\n save: string;\n}\n\nexport interface FeedbackSubmission {\n title: string;\n body: string;\n category: string;\n attachment: File | null;\n}\n\n/**\n * The generic feedback form dialog: category + subject + body + an optional image\n * attachment, with Ctrl/Cmd+Enter to submit. Domain-free — the app supplies the\n * category options, labels and an `onSubmit` that talks to its own backend, plus\n * an optional `contextSlot` for app-specific context (user, current URL, …).\n *\n * **A screenshot can be pasted straight in.** Ctrl/Cmd+V anywhere in the dialog\n * takes an image off the clipboard and makes it the attachment, through the same\n * validation and the same preview as the file picker. It is the gesture the two\n * ways in did not cover: `onCaptureScreenshot` snapshots the *whole* app view,\n * and the file picker needs a file — so somebody who wanted to show one panel,\n * or one region of one, had to save a crop to disk first and then find it again\n * (Steering Design feedback #39). The clipboard is where a region snip already\n * is on every platform.\n */\nexport function FeedbackDialog({\n open,\n onClose,\n categories,\n category,\n onCategoryChange,\n labels,\n onSubmit,\n submitting = false,\n contextSlot,\n attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,\n maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onAttachmentError,\n onCaptureScreenshot,\n}: {\n open: boolean;\n onClose: () => void;\n categories: FeedbackCategoryOption[];\n category: string;\n onCategoryChange: (value: string) => void;\n labels: FeedbackDialogLabels;\n onSubmit: (data: FeedbackSubmission) => void | Promise<void>;\n submitting?: boolean;\n contextSlot?: ReactNode;\n attachmentAccept?: string[];\n maxAttachmentBytes?: number;\n onAttachmentError?: (kind: \"type\" | \"size\") => void;\n /**\n * Optional: capture a screenshot of the underlying app view and return it as a\n * File. When provided, a \"Capture screenshot\" button is shown next to \"Add\n * attachment\"; the returned file is fed through the same validation + preview.\n */\n onCaptureScreenshot?: () => Promise<File | null>;\n}) {\n const [title, setTitle] = useState(\"\");\n const [body, setBody] = useState(\"\");\n const [attachment, setAttachment] = useState<File | null>(null);\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n\n // Reset the form whenever the dialog is (re)opened.\n useEffect(() => {\n if (open) {\n setTitle(\"\");\n setBody(\"\");\n setAttachment(null);\n }\n }, [open]);\n\n const canSubmit = !!title && !!body && !submitting;\n const trySubmit = () => {\n if (canSubmit) void onSubmit({ title, body, category, attachment });\n };\n\n if (!open) return null;\n\n const categoryField = (\n <Select label={labels.category} value={category} onChange={(e) => onCategoryChange(e.target.value)}>\n {categories.map((c) => (\n <option key={c.value} value={c.value}>\n {c.label}\n </option>\n ))}\n </Select>\n );\n // On a phone the subject leads as a heading rather than as the second of two\n // identical boxes under a select (Keksdose feedback #179). `variant=\"display\"`\n // is a no-op above the phone breakpoint, so the desktop dialog is unchanged.\n const subjectField = (\n <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant=\"display\" />\n );\n const bodyField = (\n <Textarea rows={5} label={labels.body} value={body} onChange={(e) => setBody(e.target.value)} />\n );\n const attachmentField = (\n <FeedbackAttachmentField\n value={attachment}\n onChange={setAttachment}\n labels={labels}\n accept={attachmentAccept}\n maxBytes={maxAttachmentBytes}\n onError={onAttachmentError}\n onCaptureScreenshot={onCaptureScreenshot}\n // On `document`, not on the panel: the Modal focuses its own panel on open\n // and traps Tab inside it, so while this dialog is up every paste in the\n // page is meant for it — including the one made with nothing in\n // particular focused, which never reaches a React `onPaste` on a child.\n documentPaste={open}\n />\n );\n\n return (\n <Modal\n onClose={onClose}\n // The one dialog whose subject is the page behind it (Keksdose dev#460: \"Make\n // the feedback dialog draggable so I can see behind it if it blocks\n // something\") — you are describing what is under it while you type.\n draggable\n onKeyDown={(e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") {\n e.preventDefault();\n trySubmit();\n }\n }}\n >\n {/* Also the drag handle: pressing anywhere on the panel's own chrome moves it,\n and the heading is the strip a user reaches for. */}\n <h2 className=\"mb-3 text-lg font-semibold\">{labels.title}</h2>\n <div className=\"space-y-3\">\n {isMobile ? (\n // Phone shape, the same reasoning as the transaction editor's (#177):\n // the desktop order is the DATA MODEL's order — category, then subject,\n // then body — but nobody opens this dialog to pick a category. What they\n // came to do is say the thing, so that leads; the classification and the\n // evidence follow, a tier down.\n <>\n {subjectField}\n {bodyField}\n <div className=\"space-y-3 border-t border-[var(--border)] pt-3\">\n {categoryField}\n {attachmentField}\n {contextSlot}\n </div>\n </>\n ) : (\n <>\n {categoryField}\n {subjectField}\n {bodyField}\n {attachmentField}\n {contextSlot}\n </>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"text-xs text-[var(--text-placeholder)]\">{labels.submitHint}</div>\n <div className=\"flex gap-2\">\n <Button variant=\"ghost\" onClick={onClose}>\n {labels.cancel}\n </Button>\n <Button onClick={trySubmit} disabled={!canSubmit}>\n {labels.save}\n </Button>\n </div>\n </div>\n </div>\n </Modal>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/feedback/feedback-dialog.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Button, Input, PHONE_QUERY, Select, Textarea } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport {\n DEFAULT_ATTACHMENT_ACCEPT,\n DEFAULT_MAX_ATTACHMENT_BYTES,\n FeedbackAttachmentField,\n} from \"./feedback-attachment\";\n\nexport interface FeedbackCategoryOption {\n value: string;\n label: string;\n}\n\nexport interface FeedbackAttachmentLabels {\n /** The heading over the field. Optional: a note editor puts the buttons\n * straight under its textarea, where a second heading is noise. */\n attachment?: string;\n attachmentAdd: string;\n /** Label for the \"capture screenshot\" button. Optional — falls back to the provider's\n * `feedbackAttachment.attachmentCapture`, then English. */\n attachmentCapture?: string;\n /** The line under the attachment buttons saying a screenshot can be pasted\n * straight in. Optional — falls back to the provider's `feedbackAttachment`, then English. */\n attachmentPaste?: string;\n attachmentRemove: string;\n}\n\nexport interface FeedbackDialogLabels extends FeedbackAttachmentLabels {\n title: string;\n category: string;\n subject: string;\n body: string;\n attachment: string;\n submitHint: string;\n cancel: string;\n save: string;\n}\n\nexport interface FeedbackSubmission {\n title: string;\n body: string;\n category: string;\n attachment: File | null;\n}\n\n/**\n * The generic feedback form dialog: category + subject + body + an optional image\n * attachment, with Ctrl/Cmd+Enter to submit. Domain-free — the app supplies the\n * category options, labels and an `onSubmit` that talks to its own backend, plus\n * an optional `contextSlot` for app-specific context (user, current URL, …).\n *\n * **A screenshot can be pasted straight in.** Ctrl/Cmd+V anywhere in the dialog\n * takes an image off the clipboard and makes it the attachment, through the same\n * validation and the same preview as the file picker. It is the gesture the two\n * ways in did not cover: `onCaptureScreenshot` snapshots the *whole* app view,\n * and the file picker needs a file — so somebody who wanted to show one panel,\n * or one region of one, had to save a crop to disk first and then find it again\n * (Steering Design feedback #39). The clipboard is where a region snip already\n * is on every platform.\n */\nexport function FeedbackDialog({\n open,\n onClose,\n categories,\n category,\n onCategoryChange,\n labels,\n onSubmit,\n submitting = false,\n contextSlot,\n attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,\n maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,\n onAttachmentError,\n onCaptureScreenshot,\n}: {\n open: boolean;\n onClose: () => void;\n categories: FeedbackCategoryOption[];\n category: string;\n onCategoryChange: (value: string) => void;\n labels: FeedbackDialogLabels;\n onSubmit: (data: FeedbackSubmission) => void | Promise<void>;\n submitting?: boolean;\n contextSlot?: ReactNode;\n attachmentAccept?: string[];\n maxAttachmentBytes?: number;\n onAttachmentError?: (kind: \"type\" | \"size\") => void;\n /**\n * Optional: capture a screenshot of the underlying app view and return it as a\n * File. When provided, a \"Capture screenshot\" button is shown next to \"Add\n * attachment\"; the returned file is fed through the same validation + preview.\n */\n onCaptureScreenshot?: () => Promise<File | null>;\n}) {\n const [title, setTitle] = useState(\"\");\n const [body, setBody] = useState(\"\");\n const [attachment, setAttachment] = useState<File | null>(null);\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n\n // Reset the form whenever the dialog is (re)opened.\n useEffect(() => {\n if (open) {\n setTitle(\"\");\n setBody(\"\");\n setAttachment(null);\n }\n }, [open]);\n\n const canSubmit = !!title && !!body && !submitting;\n const trySubmit = () => {\n if (canSubmit) void onSubmit({ title, body, category, attachment });\n };\n\n if (!open) return null;\n\n const categoryField = (\n <Select label={labels.category} value={category} onChange={(e) => onCategoryChange(e.target.value)}>\n {categories.map((c) => (\n <option key={c.value} value={c.value}>\n {c.label}\n </option>\n ))}\n </Select>\n );\n // On a phone the subject leads as a heading rather than as the second of two\n // identical boxes under a select (Keksdose feedback #179). `variant=\"display\"`\n // is a no-op above the phone breakpoint, so the desktop dialog is unchanged.\n const subjectField = (\n <Input label={labels.subject} value={title} onChange={(e) => setTitle(e.target.value)} variant=\"display\" />\n );\n const bodyField = (\n <Textarea rows={5} label={labels.body} value={body} onChange={(e) => setBody(e.target.value)} />\n );\n const attachmentField = (\n <FeedbackAttachmentField\n value={attachment}\n onChange={setAttachment}\n labels={labels}\n accept={attachmentAccept}\n maxBytes={maxAttachmentBytes}\n onError={onAttachmentError}\n onCaptureScreenshot={onCaptureScreenshot}\n // On `document`, not on the panel: the Modal focuses its own panel on open\n // and traps Tab inside it, so while this dialog is up every paste in the\n // page is meant for it — including the one made with nothing in\n // particular focused, which never reaches a React `onPaste` on a child.\n documentPaste={open}\n />\n );\n\n return (\n <Modal\n onClose={onClose}\n // The one dialog whose subject is the page behind it (Keksdose dev#460: \"Make\n // the feedback dialog draggable so I can see behind it if it blocks\n // something\") — you are describing what is under it while you type.\n draggable\n onKeyDown={(e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") {\n e.preventDefault();\n trySubmit();\n }\n }}\n >\n {/* Also the drag handle: pressing anywhere on the panel's own chrome moves it,\n and the heading is the strip a user reaches for. */}\n <h2 className=\"mb-3 text-lg font-semibold\">{labels.title}</h2>\n <div className=\"space-y-3\">\n {isMobile ? (\n // Phone shape, the same reasoning as the transaction editor's (#177):\n // the desktop order is the DATA MODEL's order — category, then subject,\n // then body — but nobody opens this dialog to pick a category. What they\n // came to do is say the thing, so that leads; the classification and the\n // evidence follow, a tier down.\n <>\n {subjectField}\n {bodyField}\n <div className=\"space-y-3 border-t border-[var(--border)] pt-3\">\n {categoryField}\n {attachmentField}\n {contextSlot}\n </div>\n </>\n ) : (\n <>\n {categoryField}\n {subjectField}\n {bodyField}\n {attachmentField}\n {contextSlot}\n </>\n )}\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"text-xs text-[var(--text-placeholder)]\">{labels.submitHint}</div>\n <div className=\"flex gap-2\">\n <Button variant=\"ghost\" onClick={onClose}>\n {labels.cancel}\n </Button>\n <Button onClick={trySubmit} disabled={!canSubmit}>\n {labels.save}\n </Button>\n </div>\n </div>\n </div>\n </Modal>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,4 @@
1
- export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, pastedName } from './feedback/feedback-attachment.js';
1
+ export { DEFAULT_ATTACHMENT_ACCEPT, DEFAULT_FEEDBACK_ATTACHMENT_LABELS, DEFAULT_MAX_ATTACHMENT_BYTES, FeedbackAttachmentField, FeedbackAttachmentFieldLabels, pastedName } from './feedback/feedback-attachment.js';
2
2
  export { FeedbackAttachmentLabels, FeedbackCategoryOption, FeedbackDialog, FeedbackDialogLabels, FeedbackSubmission } from './feedback/feedback-dialog.js';
3
3
  export { FEEDBACK_CATEGORY_META, FEEDBACK_CATEGORY_ORDER, FEEDBACK_STATUS_META, FEEDBACK_STATUS_ORDER, FeedbackCategory, FeedbackCategoryBadge, FeedbackDetail, FeedbackDetailSection, FeedbackNoteAttachment, FeedbackNoteEditor, FeedbackProse, FeedbackStatus, FeedbackStatusBadge, FeedbackStatusTransitions, feedbackCategoryRank, nextFeedbackStatus, selectableFeedbackStatuses, visibleFeedbackStatuses } from './feedback/feedback-inbox.js';
4
4
  import 'react';
@@ -0,0 +1,29 @@
1
+ import { RefObject } from 'react';
2
+
3
+ /** What {@link useWindowedRows} answers: render rows `first` up to (not including)
4
+ * `last`, inside a scroll body `totalHeight` pixels tall. */
5
+ interface WindowedRows {
6
+ first: number;
7
+ last: number;
8
+ totalHeight: number;
9
+ }
10
+ /**
11
+ * Which rows of a fixed-height list are worth rendering.
12
+ *
13
+ * Hand-rolled rather than pulled from a virtualization library, for two reasons. With
14
+ * a fixed row height the whole problem is two divisions — a library earns its keep on
15
+ * measured, variable-height rows, and there are none here. And the obvious library for
16
+ * it (`@tanstack/react-virtual`) returns functions that cannot be memoized safely, so
17
+ * the React Compiler skips optimising any component that calls it: a list that exists
18
+ * to stay fast would opt out of the consumer's main source of speed.
19
+ *
20
+ * `scrollRef` is the element that scrolls. The rows are assumed to start at its top;
21
+ * a sticky header above them costs a row or two of the overscan, not correctness.
22
+ *
23
+ * Moved from Lenkbank, where `MeasuredGrid` and a segment list window the same way over
24
+ * rows of different heights — two copies of this would be two places to fix the day
25
+ * the overscan turns out to be wrong.
26
+ */
27
+ declare function useWindowedRows(count: number, rowHeight: number, scrollRef: RefObject<HTMLElement | null>): WindowedRows;
28
+
29
+ export { type WindowedRows, useWindowedRows };
@@ -0,0 +1,31 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ const OVERSCAN = 6;
4
+ function useWindowedRows(count, rowHeight, scrollRef) {
5
+ const [scrollTop, setScrollTop] = useState(0);
6
+ const [viewport, setViewport] = useState(0);
7
+ useEffect(() => {
8
+ const element = scrollRef.current;
9
+ if (!element) return;
10
+ const measure = () => {
11
+ setScrollTop(element.scrollTop);
12
+ setViewport(element.clientHeight);
13
+ };
14
+ measure();
15
+ element.addEventListener("scroll", measure, { passive: true });
16
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
17
+ observer?.observe(element);
18
+ return () => {
19
+ element.removeEventListener("scroll", measure);
20
+ observer?.disconnect();
21
+ };
22
+ }, [scrollRef]);
23
+ const first = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
24
+ const visible = Math.ceil((viewport || 600) / rowHeight) + OVERSCAN * 2;
25
+ const last = Math.min(count, first + visible);
26
+ return { first, last, totalHeight: count * rowHeight };
27
+ }
28
+ export {
29
+ useWindowedRows
30
+ };
31
+ //# sourceMappingURL=use-windowed-rows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/hooks/use-windowed-rows.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { RefObject } from \"react\";\n\n/** Rows kept mounted beyond each edge, so a flick-scroll has something to show\n * before the next render lands. */\nconst OVERSCAN = 6;\n\n/** What {@link useWindowedRows} answers: render rows `first` up to (not including)\n * `last`, inside a scroll body `totalHeight` pixels tall. */\nexport interface WindowedRows {\n first: number;\n last: number;\n totalHeight: number;\n}\n\n/**\n * Which rows of a fixed-height list are worth rendering.\n *\n * Hand-rolled rather than pulled from a virtualization library, for two reasons. With\n * a fixed row height the whole problem is two divisions — a library earns its keep on\n * measured, variable-height rows, and there are none here. And the obvious library for\n * it (`@tanstack/react-virtual`) returns functions that cannot be memoized safely, so\n * the React Compiler skips optimising any component that calls it: a list that exists\n * to stay fast would opt out of the consumer's main source of speed.\n *\n * `scrollRef` is the element that scrolls. The rows are assumed to start at its top;\n * a sticky header above them costs a row or two of the overscan, not correctness.\n *\n * Moved from Lenkbank, where `MeasuredGrid` and a segment list window the same way over\n * rows of different heights — two copies of this would be two places to fix the day\n * the overscan turns out to be wrong.\n */\nexport function useWindowedRows(\n count: number,\n rowHeight: number,\n scrollRef: RefObject<HTMLElement | null>,\n): WindowedRows {\n const [scrollTop, setScrollTop] = useState(0);\n const [viewport, setViewport] = useState(0);\n\n useEffect(() => {\n const element = scrollRef.current;\n if (!element) return;\n const measure = () => {\n setScrollTop(element.scrollTop);\n setViewport(element.clientHeight);\n };\n measure();\n element.addEventListener(\"scroll\", measure, { passive: true });\n // The pane usually sits in a responsive layout, so its height changes without the\n // window's — a resize listener alone would miss every one of those. Guarded:\n // jsdom and older embedded webviews have no ResizeObserver, and the list still\n // has to render there.\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(measure);\n observer?.observe(element);\n return () => {\n element.removeEventListener(\"scroll\", measure);\n observer?.disconnect();\n };\n }, [scrollRef]);\n\n const first = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);\n // A viewport of 0 is the first render, before the pane has been laid out (and every\n // render under jsdom). Rendering nothing then would leave the list blank until\n // something scrolled it, so fall back to a screenful.\n const visible = Math.ceil((viewport || 600) / rowHeight) + OVERSCAN * 2;\n const last = Math.min(count, first + visible);\n return { first, last, totalHeight: count * rowHeight };\n}\n"],"mappings":";AAAA,SAAS,WAAW,gBAAgB;AAKpC,MAAM,WAAW;AA2BV,SAAS,gBACd,OACA,WACA,WACc;AACd,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,CAAC;AAC5C,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,CAAC;AAE1C,YAAU,MAAM;AACd,UAAM,UAAU,UAAU;AAC1B,QAAI,CAAC,QAAS;AACd,UAAM,UAAU,MAAM;AACpB,mBAAa,QAAQ,SAAS;AAC9B,kBAAY,QAAQ,YAAY;AAAA,IAClC;AACA,YAAQ;AACR,YAAQ,iBAAiB,UAAU,SAAS,EAAE,SAAS,KAAK,CAAC;AAK7D,UAAM,WAAW,OAAO,mBAAmB,cAAc,OAAO,IAAI,eAAe,OAAO;AAC1F,cAAU,QAAQ,OAAO;AACzB,WAAO,MAAM;AACX,cAAQ,oBAAoB,UAAU,OAAO;AAC7C,gBAAU,WAAW;AAAA,IACvB;AAAA,EACF,GAAG,CAAC,SAAS,CAAC;AAEd,QAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,SAAS,IAAI,QAAQ;AAItE,QAAM,UAAU,KAAK,MAAM,YAAY,OAAO,SAAS,IAAI,WAAW;AACtE,QAAM,OAAO,KAAK,IAAI,OAAO,QAAQ,OAAO;AAC5C,SAAO,EAAE,OAAO,MAAM,aAAa,QAAQ,UAAU;AACvD;","names":[]}