@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
@@ -0,0 +1,20 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import { dirOf, type Direction } from "../lib/direction";
4
+
5
+ /**
6
+ * The reading direction at a portalled panel's anchor — internal, not exported from
7
+ * the barrel. The field pickers' panels are portalled to `<body>`, out of the subtree
8
+ * their `dir` came from, so they carry it across on their own root.
9
+ *
10
+ * Read in a layout effect while `open`, rather than off the ref during render: the
11
+ * anchored rect these panels also wait for is measured the same way, so the panel's
12
+ * first painted frame already has both, and a render never reads a ref.
13
+ */
14
+ export function useAnchorDir(ref: RefObject<Element | null>, open: boolean): Direction {
15
+ const [dir, setDir] = useState<Direction>("ltr");
16
+ useLayoutEffect(() => {
17
+ if (open) setDir(dirOf(ref.current));
18
+ }, [open, ref]);
19
+ return dir;
20
+ }
@@ -11,6 +11,11 @@ import { useEffect, useRef, useState } from "react";
11
11
  * is why nothing resets it: narrowing a filter cannot leave the limit pointing past the
12
12
  * end, and widening one does not have to re-reveal what the user already scrolled past.
13
13
  *
14
+ * `paginated={false}` opts out entirely: a table that is short by construction (an
15
+ * invoice's line items) renders every row on a phone too. It used to be chunked here
16
+ * regardless, so a twelve-line invoice showed its first ten lines and a "Loading…"
17
+ * sentinel for rows that were already in memory.
18
+ *
14
19
  * Lifted out of `DataTable` with the rest of its state; the sentinel ref belongs to the
15
20
  * caller's `<li>`.
16
21
  */
@@ -19,6 +24,7 @@ export function useMobileReveal<T>({
19
24
  slice,
20
25
  isServer,
21
26
  isMdUp,
27
+ unpaged = false,
22
28
  defaultPageSize,
23
29
  }: {
24
30
  /** The whole sorted list — what there is to reveal. */
@@ -27,11 +33,13 @@ export function useMobileReveal<T>({
27
33
  slice: T[];
28
34
  isServer: boolean;
29
35
  isMdUp: boolean;
36
+ /** `paginated={false}` on a client-side table: show every row, reveal nothing. */
37
+ unpaged?: boolean;
30
38
  defaultPageSize: number;
31
39
  }) {
32
40
  const [mobileLimit, setMobileLimit] = useState(defaultPageSize);
33
- const mobileSlice = isServer ? slice : rows.slice(0, mobileLimit);
34
- const canRevealMoreMobile = !isServer && !isMdUp && mobileLimit < rows.length;
41
+ const mobileSlice = isServer ? slice : unpaged ? rows : rows.slice(0, mobileLimit);
42
+ const canRevealMoreMobile = !isServer && !unpaged && !isMdUp && mobileLimit < rows.length;
35
43
  const loadMoreRef = useRef<HTMLLIElement | null>(null);
36
44
 
37
45
  useEffect(() => {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import type { useSearchParams } from "react-router";
2
+ import { useSearchParams } from "react-router";
3
3
  import { readStored, writeStored } from "../lib/safe-storage";
4
4
  import {
5
5
  decodeFilterValue,
@@ -59,9 +59,17 @@ interface UrlState {
59
59
  page?: number; // 0-based
60
60
  }
61
61
 
62
- function readUrlState<T>(columns: DataTableColumn<T>[]): UrlState {
63
- if (typeof window === "undefined") return {};
64
- const sp = new URLSearchParams(window.location.search);
62
+ /**
63
+ * The managed keys out of the ROUTER's query string — the one `setSearchParams` writes.
64
+ *
65
+ * This read `window.location.search` until 0.7.0, which is the same string only under a
66
+ * BrowserRouter. Under a HashRouter the router's query lives after the `#`
67
+ * (`/#/tx?f.payee=Rewe`) and `window.location.search` is empty: a shared link opened
68
+ * unfiltered, and the table's first write then replaced the link's filter with its own
69
+ * empty state. Reading and writing through `useSearchParams` is one source for both
70
+ * directions under every router, MemoryRouter included.
71
+ */
72
+ function readUrlState<T>(sp: URLSearchParams, columns: DataTableColumn<T>[]): UrlState {
65
73
  const out: UrlState = {};
66
74
 
67
75
  const filters: FilterState = {};
@@ -179,13 +187,14 @@ function writeUrlState<T>(
179
187
  * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the
180
188
  * per-table identity; `urlSync` is opt-in because only some tables want their view in
181
189
  * a shareable address.
190
+ * * **A controlled filter or sort that changes sends the table back to page 1.** See
191
+ * the note at the reset below.
182
192
  */
183
193
  export function useTableState<T>({
184
194
  columns,
185
195
  storageKey,
186
196
  storageKeyPrefix,
187
197
  urlSync,
188
- setSearchParams,
189
198
  defaultPageSize,
190
199
  sortsProp,
191
200
  filtersProp,
@@ -194,14 +203,18 @@ export function useTableState<T>({
194
203
  storageKey?: string;
195
204
  storageKeyPrefix: string;
196
205
  urlSync?: boolean;
197
- setSearchParams: ReturnType<typeof useSearchParams>[1];
198
206
  defaultPageSize: number;
199
207
  sortsProp?: SortState[];
200
208
  filtersProp?: FilterState;
201
209
  }) {
210
+ // Called unconditionally (a Router is required either way — see src/data-table.ts),
211
+ // and the SAME pair is used to read and to write; see `readUrlState`.
212
+ const [searchParams, setSearchParams] = useSearchParams();
202
213
  // Read URL once on mount so URL-encoded views (e.g. shared links, deep links from
203
214
  // other pages) populate initial state. After mount, state is local.
204
- const [urlInitial] = useState<UrlState>(() => (urlSync ? readUrlState(columns) : {}));
215
+ const [urlInitial] = useState<UrlState>(() =>
216
+ urlSync ? readUrlState(searchParams, columns) : {},
217
+ );
205
218
  const initial = useMemo(
206
219
  () => loadPersisted(storageKey, storageKeyPrefix),
207
220
  [storageKey, storageKeyPrefix],
@@ -217,6 +230,30 @@ export function useTableState<T>({
217
230
  const sorts = sortsProp ?? internalSorts;
218
231
  const filters = filtersProp ?? internalFilters;
219
232
  const [page, setPage] = useState(() => urlInitial.page ?? 0);
233
+
234
+ // ---- Controlled filters/sorts reset the page ----
235
+ //
236
+ // A self-owned table resets to page 1 in the filter handlers. A CONTROLLED one could
237
+ // not: the handlers hand the change to the owner and return, and `page` lives here,
238
+ // where the owner has no way to reach it — so narrowing a filter from outside (a
239
+ // saved view, a chip dismissed above the table) left a client-side table on page 4
240
+ // of what was now two pages, clamped to the last one instead of the first.
241
+ //
242
+ // So the table does it: whenever the controlled VALUE changes, back to the first
243
+ // page. Compared by value, because a controlled prop is a fresh object on every
244
+ // parent render. Adjusted during render rather than in an effect, so the stale page
245
+ // is never painted and never written to the URL. Not on mount: a deep link's `p=3`
246
+ // must survive the owner's first render. (In `serverPagination` mode this state is
247
+ // unused — the owner's `page` is the page, and resetting it stays the owner's job.)
248
+ const controlledSig =
249
+ sortsProp !== undefined || filtersProp !== undefined
250
+ ? JSON.stringify([sortsProp ?? null, filtersProp ?? null])
251
+ : null;
252
+ const [seenControlledSig, setSeenControlledSig] = useState(controlledSig);
253
+ if (seenControlledSig !== controlledSig) {
254
+ setSeenControlledSig(controlledSig);
255
+ setPage(0);
256
+ }
220
257
  const [pageSize, setPageSize] = useState<number>(() => {
221
258
  if (urlInitial.pageSize !== undefined) return urlInitial.pageSize;
222
259
  if (initial.pageSize === "all") return Infinity;
package/src/data-table.ts CHANGED
@@ -16,4 +16,5 @@ export type {
16
16
  DataTableProps,
17
17
  ServerPagination,
18
18
  FilterState,
19
+ MobileSwipeActions,
19
20
  } from "./components/data-table";
@@ -2,12 +2,36 @@ import { cn } from "../lib/cn";
2
2
  import { useEffect, useRef, useState, type RefObject } from "react";
3
3
  import { Camera, FileText, Paperclip, X } from "lucide-react";
4
4
  import { Button } from "../components/ui";
5
- import { useKitFileLabels } from "../i18n/kit-labels";
5
+ import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
6
6
  import type { FeedbackAttachmentLabels } from "./feedback-dialog";
7
7
 
8
8
  export const DEFAULT_ATTACHMENT_ACCEPT = ["image/png", "image/jpeg", "image/webp", "image/gif"];
9
9
  export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
10
10
 
11
+ /**
12
+ * The field's own strings — the `feedbackAttachment` namespace of `<UiKitProvider
13
+ * labels>`. The keys are {@link FeedbackAttachmentLabels}' own, so the `labels` prop
14
+ * (and a whole `FeedbackDialogLabels` handed down by the dialog) merges over them
15
+ * one to one. The optional heading, `attachment`, stays a prop: a note editor puts
16
+ * the buttons straight under its textarea and wants none.
17
+ *
18
+ * Before 0.7.0 the two optional keys fell back to hard-coded English, so a German app
19
+ * that relied on its provider got "Capture screenshot" under a German form.
20
+ */
21
+ export interface FeedbackAttachmentFieldLabels {
22
+ attachmentAdd: string;
23
+ attachmentCapture: string;
24
+ attachmentPaste: string;
25
+ attachmentRemove: string;
26
+ }
27
+
28
+ export const DEFAULT_FEEDBACK_ATTACHMENT_LABELS: FeedbackAttachmentFieldLabels = {
29
+ attachmentAdd: "Attach image",
30
+ attachmentCapture: "Capture screenshot",
31
+ attachmentPaste: "…or paste a screenshot from the clipboard.",
32
+ attachmentRemove: "Remove attachment",
33
+ };
34
+
11
35
  /**
12
36
  * Picking one picture: the file dialog, a capture of the app view, or a paste.
13
37
  *
@@ -40,7 +64,7 @@ export const DEFAULT_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
40
64
  export function FeedbackAttachmentField({
41
65
  value,
42
66
  onChange,
43
- labels,
67
+ labels: labelsProp,
44
68
  accept = DEFAULT_ATTACHMENT_ACCEPT,
45
69
  maxBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
46
70
  onError,
@@ -51,7 +75,9 @@ export function FeedbackAttachmentField({
51
75
  }: {
52
76
  value: File | null;
53
77
  onChange: (file: File | null) => void;
54
- labels: FeedbackAttachmentLabels;
78
+ /** Prop > `<UiKitProvider labels={{ feedbackAttachment }}>` > English. Optional
79
+ * since 0.7.0; `attachment` (the heading) is only ever read from here. */
80
+ labels?: Partial<FeedbackAttachmentLabels>;
55
81
  accept?: string[];
56
82
  maxBytes?: number;
57
83
  onError?: (kind: "type" | "size") => void;
@@ -73,6 +99,7 @@ export function FeedbackAttachmentField({
73
99
  const fileInputRef = useRef<HTMLInputElement>(null);
74
100
  // `file.size` from `<UiKitProvider labels>`, formatted in its locale — see FileDropzone.
75
101
  const fileText = useKitFileLabels();
102
+ const text = useKitLabels("feedbackAttachment", DEFAULT_FEEDBACK_ATTACHMENT_LABELS, labelsProp);
76
103
 
77
104
  // Object-URL preview lifecycle (create on change, revoke on cleanup).
78
105
  useEffect(() => {
@@ -151,9 +178,9 @@ export function FeedbackAttachmentField({
151
178
  : (event) => takeImage(event.clipboardData, () => event.preventDefault())
152
179
  }
153
180
  >
154
- {labels.attachment && (
181
+ {labelsProp?.attachment && (
155
182
  <div className="mb-1 text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
156
- {labels.attachment}
183
+ {labelsProp.attachment}
157
184
  </div>
158
185
  )}
159
186
  {value ? (
@@ -180,7 +207,7 @@ export function FeedbackAttachmentField({
180
207
  <button
181
208
  type="button"
182
209
  onClick={() => onChange(null)}
183
- aria-label={labels.attachmentRemove}
210
+ aria-label={text.attachmentRemove}
184
211
  className="rounded p-1.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
185
212
  >
186
213
  <X className="size-4" />
@@ -190,7 +217,7 @@ export function FeedbackAttachmentField({
190
217
  <div className="space-y-1.5">
191
218
  <div className="flex flex-wrap gap-2">
192
219
  <Button type="button" variant="secondary" onClick={() => fileInputRef.current?.click()}>
193
- <Paperclip className="size-4" /> {labels.attachmentAdd}
220
+ <Paperclip className="size-4" /> {text.attachmentAdd}
194
221
  </Button>
195
222
  {onCaptureScreenshot && (
196
223
  <Button
@@ -208,14 +235,14 @@ export function FeedbackAttachmentField({
208
235
  }}
209
236
  >
210
237
  <Camera className="size-4" />{" "}
211
- {capturing ? "…" : (labels.attachmentCapture ?? "Capture screenshot")}
238
+ {capturing ? "…" : text.attachmentCapture}
212
239
  </Button>
213
240
  )}
214
241
  </div>
215
242
  {/* Said out loud, because a gesture with no affordance is a gesture
216
243
  nobody finds. */}
217
244
  <p className="text-xs text-[var(--text-muted)]">
218
- {labels.attachmentPaste ?? "…or paste a screenshot from the clipboard."}
245
+ {text.attachmentPaste}
219
246
  </p>
220
247
  </div>
221
248
  )}
@@ -19,10 +19,11 @@ export interface FeedbackAttachmentLabels {
19
19
  * straight under its textarea, where a second heading is noise. */
20
20
  attachment?: string;
21
21
  attachmentAdd: string;
22
- /** Label for the "capture screenshot" button. Optional — falls back to an English default. */
22
+ /** Label for the "capture screenshot" button. Optional — falls back to the provider's
23
+ * `feedbackAttachment.attachmentCapture`, then English. */
23
24
  attachmentCapture?: string;
24
25
  /** The line under the attachment buttons saying a screenshot can be pasted
25
- * straight in. Optional — falls back to an English default. */
26
+ * straight in. Optional — falls back to the provider's `feedbackAttachment`, then English. */
26
27
  attachmentPaste?: string;
27
28
  attachmentRemove: string;
28
29
  }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { RefObject } from "react";
3
+
4
+ /** Rows kept mounted beyond each edge, so a flick-scroll has something to show
5
+ * before the next render lands. */
6
+ const OVERSCAN = 6;
7
+
8
+ /** What {@link useWindowedRows} answers: render rows `first` up to (not including)
9
+ * `last`, inside a scroll body `totalHeight` pixels tall. */
10
+ export interface WindowedRows {
11
+ first: number;
12
+ last: number;
13
+ totalHeight: number;
14
+ }
15
+
16
+ /**
17
+ * Which rows of a fixed-height list are worth rendering.
18
+ *
19
+ * Hand-rolled rather than pulled from a virtualization library, for two reasons. With
20
+ * a fixed row height the whole problem is two divisions — a library earns its keep on
21
+ * measured, variable-height rows, and there are none here. And the obvious library for
22
+ * it (`@tanstack/react-virtual`) returns functions that cannot be memoized safely, so
23
+ * the React Compiler skips optimising any component that calls it: a list that exists
24
+ * to stay fast would opt out of the consumer's main source of speed.
25
+ *
26
+ * `scrollRef` is the element that scrolls. The rows are assumed to start at its top;
27
+ * a sticky header above them costs a row or two of the overscan, not correctness.
28
+ *
29
+ * Moved from Lenkbank, where `MeasuredGrid` and a segment list window the same way over
30
+ * rows of different heights — two copies of this would be two places to fix the day
31
+ * the overscan turns out to be wrong.
32
+ */
33
+ export function useWindowedRows(
34
+ count: number,
35
+ rowHeight: number,
36
+ scrollRef: RefObject<HTMLElement | null>,
37
+ ): WindowedRows {
38
+ const [scrollTop, setScrollTop] = useState(0);
39
+ const [viewport, setViewport] = useState(0);
40
+
41
+ useEffect(() => {
42
+ const element = scrollRef.current;
43
+ if (!element) return;
44
+ const measure = () => {
45
+ setScrollTop(element.scrollTop);
46
+ setViewport(element.clientHeight);
47
+ };
48
+ measure();
49
+ element.addEventListener("scroll", measure, { passive: true });
50
+ // The pane usually sits in a responsive layout, so its height changes without the
51
+ // window's — a resize listener alone would miss every one of those. Guarded:
52
+ // jsdom and older embedded webviews have no ResizeObserver, and the list still
53
+ // has to render there.
54
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
55
+ observer?.observe(element);
56
+ return () => {
57
+ element.removeEventListener("scroll", measure);
58
+ observer?.disconnect();
59
+ };
60
+ }, [scrollRef]);
61
+
62
+ const first = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
63
+ // A viewport of 0 is the first render, before the pane has been laid out (and every
64
+ // render under jsdom). Rendering nothing then would leave the list blank until
65
+ // something scrolled it, so fall back to a screenful.
66
+ const visible = Math.ceil((viewport || 600) / rowHeight) + OVERSCAN * 2;
67
+ const last = Math.min(count, first + visible);
68
+ return { first, last, totalHeight: count * rowHeight };
69
+ }
@@ -19,6 +19,8 @@ import { DEFAULT_SWATCH_PICKER_LABELS } from "../components/swatch-picker";
19
19
  import { DEFAULT_ICON_PICKER_LABELS } from "../components/icon-picker";
20
20
  import { DEFAULT_DIALOG_FRAME_LABELS } from "../components/dialog-frame";
21
21
  import { DEFAULT_FILE_PICKER_LABELS } from "../components/file-button";
22
+ import { DEFAULT_MEASURED_GRID_LABELS } from "../components/measured-grid";
23
+ import { DEFAULT_FEEDBACK_ATTACHMENT_LABELS } from "../feedback/feedback-attachment";
22
24
  import {
23
25
  DEFAULT_APP_SHELL_LABELS,
24
26
  DEFAULT_CALCULATOR_LABELS,
@@ -76,4 +78,6 @@ export const DEFAULT_UI_KIT_LABELS: UiKitLabels = {
76
78
  iconPicker: DEFAULT_ICON_PICKER_LABELS,
77
79
  dialogFrame: DEFAULT_DIALOG_FRAME_LABELS,
78
80
  filePicker: DEFAULT_FILE_PICKER_LABELS,
81
+ measuredGrid: DEFAULT_MEASURED_GRID_LABELS,
82
+ feedbackAttachment: DEFAULT_FEEDBACK_ATTACHMENT_LABELS,
79
83
  };
@@ -21,6 +21,8 @@ import type { SwatchPickerLabels } from "../components/swatch-picker";
21
21
  import type { IconPickerLabels } from "../components/icon-picker";
22
22
  import type { DialogFrameLabels } from "../components/dialog-frame";
23
23
  import type { FilePickerLabels } from "../components/file-button";
24
+ import type { MeasuredGridLabels } from "../components/measured-grid";
25
+ import type { FeedbackAttachmentFieldLabels } from "../feedback/feedback-attachment";
24
26
 
25
27
  /**
26
28
  * EVERY string the kit renders, as one typed tree — and an optional provider that
@@ -208,6 +210,8 @@ export interface UiKitLabels {
208
210
  iconPicker: IconPickerLabels;
209
211
  dialogFrame: DialogFrameLabels;
210
212
  filePicker: FilePickerLabels;
213
+ measuredGrid: MeasuredGridLabels;
214
+ feedbackAttachment: FeedbackAttachmentFieldLabels;
211
215
  }
212
216
 
213
217
  /** Any subset of the tree, one level deep — each namespace may be partial, and a
@@ -0,0 +1,30 @@
1
+ import type { UiKitLabels } from "../kit-labels";
2
+ import { uiKitLabelsDe } from "./de";
3
+
4
+ /**
5
+ * Swiss Standard German: the German labels with Swiss spelling — "ss" for every "ß"
6
+ * (Schliessen, Grösse), which is the one systematic difference in UI text — and
7
+ * `de-CH` number formatting (1’234.5).
8
+ *
9
+ * Derived rather than duplicated, so a key added to the German labels is Swiss the
10
+ * moment it lands. Function labels are wrapped: their RESULT is respelled, so an
11
+ * argument the app passes in (a file name, a phrase to type) is respelled too — which
12
+ * is what a Swiss app writing "ss" everywhere wants.
13
+ */
14
+ export const UI_KIT_LABELS_DE_CH: UiKitLabels = swiss(uiKitLabelsDe("de-CH"));
15
+
16
+ function respell(text: string): string {
17
+ return text.replace(/ß/g, "ss").replace(/ẞ/g, "SS");
18
+ }
19
+
20
+ function swiss<T>(value: T): T {
21
+ if (typeof value === "string") return respell(value) as T;
22
+ if (typeof value === "function") {
23
+ const fn = value as (...args: unknown[]) => unknown;
24
+ return ((...args: unknown[]) => swiss(fn(...args))) as T;
25
+ }
26
+ if (value && typeof value === "object") {
27
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, swiss(v)])) as T;
28
+ }
29
+ return value;
30
+ }