@eifi1/ui-kit 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/README.md +219 -0
  2. package/dist/components/account-settings.d.ts +71 -0
  3. package/dist/components/account-settings.js +159 -0
  4. package/dist/components/account-settings.js.map +1 -0
  5. package/dist/components/alert-banner.d.ts +20 -0
  6. package/dist/components/alert-banner.js +39 -0
  7. package/dist/components/alert-banner.js.map +1 -0
  8. package/dist/components/amount-input.d.ts +117 -0
  9. package/dist/components/amount-input.js +243 -0
  10. package/dist/components/amount-input.js.map +1 -0
  11. package/dist/components/calculator.d.ts +42 -0
  12. package/dist/components/calculator.js +161 -0
  13. package/dist/components/calculator.js.map +1 -0
  14. package/dist/components/chart.d.ts +58 -0
  15. package/dist/components/chart.js +158 -0
  16. package/dist/components/chart.js.map +1 -0
  17. package/dist/components/combobox-core.d.ts +77 -0
  18. package/dist/components/combobox-core.js +263 -0
  19. package/dist/components/combobox-core.js.map +1 -0
  20. package/dist/components/combobox.d.ts +141 -0
  21. package/dist/components/combobox.js +550 -0
  22. package/dist/components/combobox.js.map +1 -0
  23. package/dist/components/currency-select.d.ts +37 -0
  24. package/dist/components/currency-select.js +131 -0
  25. package/dist/components/currency-select.js.map +1 -0
  26. package/dist/components/data-table-filter-popover.d.ts +28 -0
  27. package/dist/components/data-table-filter-popover.js +201 -0
  28. package/dist/components/data-table-filter-popover.js.map +1 -0
  29. package/dist/components/data-table-filters.d.ts +5 -0
  30. package/dist/components/data-table-filters.js +118 -0
  31. package/dist/components/data-table-filters.js.map +1 -0
  32. package/dist/components/data-table-labels.d.ts +33 -0
  33. package/dist/components/data-table-labels.js +53 -0
  34. package/dist/components/data-table-labels.js.map +1 -0
  35. package/dist/components/data-table-pagination.d.ts +18 -0
  36. package/dist/components/data-table-pagination.js +92 -0
  37. package/dist/components/data-table-pagination.js.map +1 -0
  38. package/dist/components/data-table-sort.d.ts +23 -0
  39. package/dist/components/data-table-sort.js +55 -0
  40. package/dist/components/data-table-sort.js.map +1 -0
  41. package/dist/components/data-table.d.ts +5 -0
  42. package/dist/components/data-table.js +926 -0
  43. package/dist/components/data-table.js.map +1 -0
  44. package/dist/components/date-picker.d.ts +85 -0
  45. package/dist/components/date-picker.js +253 -0
  46. package/dist/components/date-picker.js.map +1 -0
  47. package/dist/components/dropdown.d.ts +98 -0
  48. package/dist/components/dropdown.js +122 -0
  49. package/dist/components/dropdown.js.map +1 -0
  50. package/dist/components/entity-combobox.d.ts +49 -0
  51. package/dist/components/entity-combobox.js +115 -0
  52. package/dist/components/entity-combobox.js.map +1 -0
  53. package/dist/components/file-dropzone.d.ts +18 -0
  54. package/dist/components/file-dropzone.js +101 -0
  55. package/dist/components/file-dropzone.js.map +1 -0
  56. package/dist/components/full-bleed-dialog.d.ts +57 -0
  57. package/dist/components/full-bleed-dialog.js +68 -0
  58. package/dist/components/full-bleed-dialog.js.map +1 -0
  59. package/dist/components/grouped-picker.d.ts +37 -0
  60. package/dist/components/grouped-picker.js +87 -0
  61. package/dist/components/grouped-picker.js.map +1 -0
  62. package/dist/components/hover-menu.d.ts +16 -0
  63. package/dist/components/hover-menu.js +113 -0
  64. package/dist/components/hover-menu.js.map +1 -0
  65. package/dist/components/mini-calendar.d.ts +33 -0
  66. package/dist/components/mini-calendar.js +133 -0
  67. package/dist/components/mini-calendar.js.map +1 -0
  68. package/dist/components/modal.d.ts +76 -0
  69. package/dist/components/modal.js +156 -0
  70. package/dist/components/modal.js.map +1 -0
  71. package/dist/components/multi-entity-combobox.d.ts +47 -0
  72. package/dist/components/multi-entity-combobox.js +116 -0
  73. package/dist/components/multi-entity-combobox.js.map +1 -0
  74. package/dist/components/multi-select.d.ts +39 -0
  75. package/dist/components/multi-select.js +132 -0
  76. package/dist/components/multi-select.js.map +1 -0
  77. package/dist/components/number-input.d.ts +74 -0
  78. package/dist/components/number-input.js +114 -0
  79. package/dist/components/number-input.js.map +1 -0
  80. package/dist/components/numpad-sheet.d.ts +45 -0
  81. package/dist/components/numpad-sheet.js +102 -0
  82. package/dist/components/numpad-sheet.js.map +1 -0
  83. package/dist/components/picker-sheet.d.ts +44 -0
  84. package/dist/components/picker-sheet.js +78 -0
  85. package/dist/components/picker-sheet.js.map +1 -0
  86. package/dist/components/popover.d.ts +20 -0
  87. package/dist/components/popover.js +46 -0
  88. package/dist/components/popover.js.map +1 -0
  89. package/dist/components/search-field.d.ts +52 -0
  90. package/dist/components/search-field.js +64 -0
  91. package/dist/components/search-field.js.map +1 -0
  92. package/dist/components/settings-fields.d.ts +39 -0
  93. package/dist/components/settings-fields.js +17 -0
  94. package/dist/components/settings-fields.js.map +1 -0
  95. package/dist/components/swipeable-row.d.ts +60 -0
  96. package/dist/components/swipeable-row.js +115 -0
  97. package/dist/components/swipeable-row.js.map +1 -0
  98. package/dist/components/toggle-group.d.ts +34 -0
  99. package/dist/components/toggle-group.js +90 -0
  100. package/dist/components/toggle-group.js.map +1 -0
  101. package/dist/components/tooltip.d.ts +86 -0
  102. package/dist/components/tooltip.js +171 -0
  103. package/dist/components/tooltip.js.map +1 -0
  104. package/dist/components/ui.d.ts +239 -0
  105. package/dist/components/ui.js +512 -0
  106. package/dist/components/ui.js.map +1 -0
  107. package/dist/components/use-mobile-reveal.d.ts +31 -0
  108. package/dist/components/use-mobile-reveal.js +33 -0
  109. package/dist/components/use-mobile-reveal.js.map +1 -0
  110. package/dist/components/use-table-state.d.ts +54 -0
  111. package/dist/components/use-table-state.js +153 -0
  112. package/dist/components/use-table-state.js.map +1 -0
  113. package/dist/components/user-avatar.d.ts +23 -0
  114. package/dist/components/user-avatar.js +33 -0
  115. package/dist/components/user-avatar.js.map +1 -0
  116. package/dist/components/wizard-stepper.d.ts +14 -0
  117. package/dist/components/wizard-stepper.js +27 -0
  118. package/dist/components/wizard-stepper.js.map +1 -0
  119. package/dist/data-table-filters-aXyf0Xub.d.ts +261 -0
  120. package/dist/feedback/feedback-attachment.d.ts +63 -0
  121. package/dist/feedback/feedback-attachment.js +160 -0
  122. package/dist/feedback/feedback-attachment.js.map +1 -0
  123. package/dist/feedback/feedback-dialog.d.ts +72 -0
  124. package/dist/feedback/feedback-dialog.js +109 -0
  125. package/dist/feedback/feedback-dialog.js.map +1 -0
  126. package/dist/feedback/feedback-inbox.d.ts +226 -0
  127. package/dist/feedback/feedback-inbox.js +315 -0
  128. package/dist/feedback/feedback-inbox.js.map +1 -0
  129. package/dist/hooks/use-anchored-panel.d.ts +80 -0
  130. package/dist/hooks/use-anchored-panel.js +71 -0
  131. package/dist/hooks/use-anchored-panel.js.map +1 -0
  132. package/dist/hooks/use-anchored-rect.d.ts +24 -0
  133. package/dist/hooks/use-anchored-rect.js +40 -0
  134. package/dist/hooks/use-anchored-rect.js.map +1 -0
  135. package/dist/hooks/use-body-scroll-lock.d.ts +9 -0
  136. package/dist/hooks/use-body-scroll-lock.js +41 -0
  137. package/dist/hooks/use-body-scroll-lock.js.map +1 -0
  138. package/dist/hooks/use-close-transition.d.ts +41 -0
  139. package/dist/hooks/use-close-transition.js +36 -0
  140. package/dist/hooks/use-close-transition.js.map +1 -0
  141. package/dist/hooks/use-dismiss.d.ts +34 -0
  142. package/dist/hooks/use-dismiss.js +41 -0
  143. package/dist/hooks/use-dismiss.js.map +1 -0
  144. package/dist/hooks/use-media-query.d.ts +8 -0
  145. package/dist/hooks/use-media-query.js +18 -0
  146. package/dist/hooks/use-media-query.js.map +1 -0
  147. package/dist/hooks/use-overlay-history.d.ts +10 -0
  148. package/dist/hooks/use-overlay-history.js +99 -0
  149. package/dist/hooks/use-overlay-history.js.map +1 -0
  150. package/dist/hooks/use-row-swipe.d.ts +30 -0
  151. package/dist/hooks/use-row-swipe.js +128 -0
  152. package/dist/hooks/use-row-swipe.js.map +1 -0
  153. package/dist/index.d.ts +75 -0
  154. package/dist/index.js +90 -0
  155. package/dist/index.js.map +1 -0
  156. package/dist/lib/calc.d.ts +89 -0
  157. package/dist/lib/calc.js +180 -0
  158. package/dist/lib/calc.js.map +1 -0
  159. package/dist/lib/cn.d.ts +6 -0
  160. package/dist/lib/cn.js +7 -0
  161. package/dist/lib/cn.js.map +1 -0
  162. package/dist/lib/dates.d.ts +84 -0
  163. package/dist/lib/dates.js +108 -0
  164. package/dist/lib/dates.js.map +1 -0
  165. package/dist/lib/logger.d.ts +17 -0
  166. package/dist/lib/logger.js +30 -0
  167. package/dist/lib/logger.js.map +1 -0
  168. package/dist/lib/safe-storage.d.ts +23 -0
  169. package/dist/lib/safe-storage.js +20 -0
  170. package/dist/lib/safe-storage.js.map +1 -0
  171. package/dist/search/command-palette.d.ts +71 -0
  172. package/dist/search/command-palette.js +198 -0
  173. package/dist/search/command-palette.js.map +1 -0
  174. package/dist/shell/app-shell.d.ts +64 -0
  175. package/dist/shell/app-shell.js +237 -0
  176. package/dist/shell/app-shell.js.map +1 -0
  177. package/dist/shell/option-switcher-menu.d.ts +24 -0
  178. package/dist/shell/option-switcher-menu.js +44 -0
  179. package/dist/shell/option-switcher-menu.js.map +1 -0
  180. package/dist/shell/role-switcher.d.ts +19 -0
  181. package/dist/shell/role-switcher.js +28 -0
  182. package/dist/shell/role-switcher.js.map +1 -0
  183. package/dist/shell/top-bar.d.ts +15 -0
  184. package/dist/shell/top-bar.js +29 -0
  185. package/dist/shell/top-bar.js.map +1 -0
  186. package/dist/shell/topbar-action-menu.d.ts +44 -0
  187. package/dist/shell/topbar-action-menu.js +49 -0
  188. package/dist/shell/topbar-action-menu.js.map +1 -0
  189. package/dist/shell/topbar-controls.d.ts +54 -0
  190. package/dist/shell/topbar-controls.js +152 -0
  191. package/dist/shell/topbar-controls.js.map +1 -0
  192. package/dist/theme/chart-palette.d.ts +60 -0
  193. package/dist/theme/chart-palette.js +79 -0
  194. package/dist/theme/chart-palette.js.map +1 -0
  195. package/dist/theme/palette-presets.d.ts +39 -0
  196. package/dist/theme/palette-presets.js +440 -0
  197. package/dist/theme/palette-presets.js.map +1 -0
  198. package/dist/theme/palette-store.d.ts +45 -0
  199. package/dist/theme/palette-store.js +52 -0
  200. package/dist/theme/palette-store.js.map +1 -0
  201. package/dist/theme/theme-store.d.ts +57 -0
  202. package/dist/theme/theme-store.js +96 -0
  203. package/dist/theme/theme-store.js.map +1 -0
  204. package/dist/tour/tour.d.ts +104 -0
  205. package/dist/tour/tour.js +351 -0
  206. package/dist/tour/tour.js.map +1 -0
  207. package/dist/wizard/stepper-nav.d.ts +30 -0
  208. package/dist/wizard/stepper-nav.js +146 -0
  209. package/dist/wizard/stepper-nav.js.map +1 -0
  210. package/dist/wizard/types.d.ts +124 -0
  211. package/dist/wizard/types.js +24 -0
  212. package/dist/wizard/types.js.map +1 -0
  213. package/dist/wizard/use-wizard.d.ts +6 -0
  214. package/dist/wizard/use-wizard.js +222 -0
  215. package/dist/wizard/use-wizard.js.map +1 -0
  216. package/dist/wizard/validation.d.ts +23 -0
  217. package/dist/wizard/validation.js +13 -0
  218. package/dist/wizard/validation.js.map +1 -0
  219. package/dist/wizard/wizard-context.d.ts +19 -0
  220. package/dist/wizard/wizard-context.js +15 -0
  221. package/dist/wizard/wizard-context.js.map +1 -0
  222. package/dist/wizard/wizard-step.d.ts +11 -0
  223. package/dist/wizard/wizard-step.js +12 -0
  224. package/dist/wizard/wizard-step.js.map +1 -0
  225. package/dist/wizard/wizard-summary.d.ts +18 -0
  226. package/dist/wizard/wizard-summary.js +41 -0
  227. package/dist/wizard/wizard-summary.js.map +1 -0
  228. package/package.json +87 -0
  229. package/tokens.css +254 -0
@@ -0,0 +1,64 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useRef } from "react";
3
+ import { Search, X } from "lucide-react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { FIELD_BASE } from "./ui.js";
6
+ const SearchField = forwardRef(function SearchField2({ value, onChange, label, clearLabel, className, placeholder, ...rest }, ref) {
7
+ const innerRef = useRef(null);
8
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
9
+ /* @__PURE__ */ jsx(
10
+ Search,
11
+ {
12
+ "aria-hidden": true,
13
+ className: "pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400"
14
+ }
15
+ ),
16
+ /* @__PURE__ */ jsx(
17
+ "input",
18
+ {
19
+ ref: (node) => {
20
+ innerRef.current = node;
21
+ if (typeof ref === "function") ref(node);
22
+ else if (ref) ref.current = node;
23
+ },
24
+ type: "search",
25
+ value,
26
+ onChange: (e) => onChange(e.target.value),
27
+ "aria-label": label,
28
+ placeholder: placeholder ?? label,
29
+ name: "search",
30
+ autoComplete: "off",
31
+ autoCapitalize: "none",
32
+ autoCorrect: "off",
33
+ spellCheck: false,
34
+ ...rest,
35
+ className: cn(
36
+ FIELD_BASE,
37
+ "pl-9",
38
+ // Room for our own "×" only when there is one; a field that can't be
39
+ // cleared has no reason to reserve the space.
40
+ clearLabel === void 0 ? "pr-3" : "pr-9",
41
+ "[&::-webkit-search-cancel-button]:appearance-none"
42
+ )
43
+ }
44
+ ),
45
+ clearLabel !== void 0 && value !== "" && /* @__PURE__ */ jsx(
46
+ "button",
47
+ {
48
+ type: "button",
49
+ onClick: () => {
50
+ onChange("");
51
+ innerRef.current?.focus();
52
+ },
53
+ "aria-label": clearLabel,
54
+ className: "absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300",
55
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
56
+ }
57
+ )
58
+ ] });
59
+ });
60
+ SearchField.displayName = "SearchField";
61
+ export {
62
+ SearchField
63
+ };
64
+ //# sourceMappingURL=search-field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/search-field.tsx"],"sourcesContent":["import { forwardRef, useRef } from \"react\";\nimport type { InputHTMLAttributes } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FIELD_BASE } from \"./ui\";\n\nexport interface SearchFieldProps\n extends Omit<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"onChange\" | \"type\"> {\n value: string;\n /** The typed text. Emitted as a plain string, not an event: every caller of the\n * four this replaced wanted the string, and one of them held it in a URL param\n * rather than in state. */\n onChange: (value: string) => void;\n /** The field's accessible name. Required, and deliberately not defaulted to the\n * placeholder: a placeholder disappears the moment someone types, so a field\n * named only by one is unnamed exactly when a screen-reader user is working in\n * it. Two of the boxes this replaced had no name but the placeholder. */\n label: string;\n /** Names the clear button. Omit it and no clear button is rendered — which is a\n * decision, not a default: a filter you can type into and not untype is the\n * complaint that produced the button on three of these four screens. */\n clearLabel?: string;\n className?: string;\n}\n\n/**\n * The page-level filter box: a search icon, a `type=\"search\"` input, and a clear\n * \"×\" that appears once there is something to clear.\n *\n * ## Why it is a component\n *\n * Keksdose had four of these and no two agreed. Two were character-identical\n * twenty-line copies (the settings catalogue filter and the tour list filter) —\n * the same absolute icon at `left-3`, the same `pl-9 pr-9`, the same\n * `[&::-webkit-search-cancel-button]:appearance-none`, the same conditional clear\n * button at `right-2` — differing only in which state setter they called. The\n * third (the admin support queue) was the same idea at `left-2`/`size-3.5`/`pl-7`\n * with no clear button and no `type=\"search\"`, so it had neither the `searchbox`\n * role nor a way to empty itself. The fourth (the bank picker) was a bare `<Input\n * placeholder=\"Search\">`: no icon, no clear, and no accessible name at all once\n * the placeholder went away.\n *\n * None of that is a design decision anyone made; it is four people solving the\n * same problem on four days. A shared component is how the fourth screen gets the\n * clear button the first one earned.\n *\n * ## The two X's\n *\n * `type=\"search\"` is what gives the field its `searchbox` role — and, in Chrome, a\n * NATIVE clear cross that paints right next to ours, so a non-empty box shows two\n * X's an inch apart. Ours is the one that survives: it carries an accessible name\n * and matches the app's other icon buttons, and it returns focus to the input so\n * the next query can be typed straight away.\n */\nexport const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(\n { value, onChange, label, clearLabel, className, placeholder, ...rest },\n ref,\n) {\n const innerRef = useRef<HTMLInputElement>(null);\n return (\n <div className={cn(\"relative\", className)}>\n <Search\n aria-hidden\n className=\"pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400\"\n />\n <input\n ref={(node) => {\n innerRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n }}\n type=\"search\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n aria-label={label}\n placeholder={placeholder ?? label}\n // Keksdose live #333: *\"Entering the search here — is that set as password?\n // … I got prompted for remembering my name or mail address\"*, typed into the\n // settings filter on a phone.\n //\n // A `type=\"search\"` input with no `name` and no `autocomplete` is a field\n // Chrome's autofill classifies by GUESSWORK, from the labels around it and\n // the other fields on the page — and a settings page has an email and a\n // password on it. Naming the field and declaring the intent is what takes it\n // out of that guess: `autocomplete=\"off\"` says it is not part of any profile,\n // and a `name` of \"search\" is the strongest hint the heuristics take.\n //\n // The three that follow are not about autofill but about the same keyboard:\n // a filter box that capitalises the first letter and autocorrects \"2fa\" to\n // \"2FA\" is fighting the person typing into it. Declared BEFORE `{...rest}` so\n // a caller with a reason can still override any of them.\n name=\"search\"\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n {...rest}\n className={cn(\n FIELD_BASE,\n \"pl-9\",\n // Room for our own \"×\" only when there is one; a field that can't be\n // cleared has no reason to reserve the space.\n clearLabel === undefined ? \"pr-3\" : \"pr-9\",\n \"[&::-webkit-search-cancel-button]:appearance-none\",\n )}\n />\n {clearLabel !== undefined && value !== \"\" && (\n <button\n type=\"button\"\n onClick={() => {\n onChange(\"\");\n // Clearing is the start of the next query far more often than it is the\n // end of this one, so the caret stays where it can be typed into.\n innerRef.current?.focus();\n }}\n aria-label={clearLabel}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300\"\n >\n <X className=\"size-4\" />\n </button>\n )}\n </div>\n );\n});\nSearchField.displayName = \"SearchField\";\n"],"mappings":"AA4DI,SACE,KADF;AA5DJ,SAAS,YAAY,cAAc;AAEnC,SAAS,QAAQ,SAAS;AAC1B,SAAS,UAAU;AACnB,SAAS,kBAAkB;AAkDpB,MAAM,cAAc,WAA+C,SAASA,aACjF,EAAE,OAAO,UAAU,OAAO,YAAY,WAAW,aAAa,GAAG,KAAK,GACtE,KACA;AACA,QAAM,WAAW,OAAyB,IAAI;AAC9C,SACE,qBAAC,SAAI,WAAW,GAAG,YAAY,SAAS,GACtC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAU;AAAA;AAAA,IACZ;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,CAAC,SAAS;AACb,mBAAS,UAAU;AACnB,cAAI,OAAO,QAAQ,WAAY,KAAI,IAAI;AAAA,mBAC9B,IAAK,KAAI,UAAU;AAAA,QAC9B;AAAA,QACA,MAAK;AAAA,QACL;AAAA,QACA,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,QACxC,cAAY;AAAA,QACZ,aAAa,eAAe;AAAA,QAgB5B,MAAK;AAAA,QACL,cAAa;AAAA,QACb,gBAAe;AAAA,QACf,aAAY;AAAA,QACZ,YAAY;AAAA,QACX,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA,UAGA,eAAe,SAAY,SAAS;AAAA,UACpC;AAAA,QACF;AAAA;AAAA,IACF;AAAA,IACC,eAAe,UAAa,UAAU,MACrC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM;AACb,mBAAS,EAAE;AAGX,mBAAS,SAAS,MAAM;AAAA,QAC1B;AAAA,QACA,cAAY;AAAA,QACZ,WAAU;AAAA,QAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,IACxB;AAAA,KAEJ;AAEJ,CAAC;AACD,YAAY,cAAc;","names":["SearchField"]}
@@ -0,0 +1,39 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ThemePreference } from '../theme/theme-store.js';
4
+ import 'zustand/middleware';
5
+ import 'zustand';
6
+
7
+ /**
8
+ * App-agnostic settings-field components (feedback #333): the language + theme
9
+ * controls that every app's settings page needs, so they live in @hb/ui and the
10
+ * app just supplies the current value, a change handler and translated labels.
11
+ * The app owns WHERE these render (its own settings page) and any app-specific
12
+ * fields around them.
13
+ */
14
+ interface ThemeSettingProps {
15
+ value: ThemePreference;
16
+ onChange: (value: ThemePreference) => void;
17
+ label: ReactNode;
18
+ /** Translated option labels. */
19
+ optionLabels: {
20
+ system: string;
21
+ light: string;
22
+ dark: string;
23
+ };
24
+ id?: string;
25
+ }
26
+ declare function ThemeSetting({ value, onChange, label, optionLabels, id }: ThemeSettingProps): react.JSX.Element;
27
+ interface LanguageSettingProps {
28
+ value: string;
29
+ onChange: (code: string) => void;
30
+ label: ReactNode;
31
+ options: {
32
+ code: string;
33
+ label: string;
34
+ }[];
35
+ id?: string;
36
+ }
37
+ declare function LanguageSetting({ value, onChange, label, options, id }: LanguageSettingProps): react.JSX.Element;
38
+
39
+ export { LanguageSetting, type LanguageSettingProps, ThemeSetting, type ThemeSettingProps };
@@ -0,0 +1,17 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Select } from "./ui.js";
3
+ function ThemeSetting({ value, onChange, label, optionLabels, id }) {
4
+ return /* @__PURE__ */ jsxs(Select, { id, label, value, onChange: (e) => onChange(e.target.value), children: [
5
+ /* @__PURE__ */ jsx("option", { value: "system", children: optionLabels.system }),
6
+ /* @__PURE__ */ jsx("option", { value: "light", children: optionLabels.light }),
7
+ /* @__PURE__ */ jsx("option", { value: "dark", children: optionLabels.dark })
8
+ ] });
9
+ }
10
+ function LanguageSetting({ value, onChange, label, options, id }) {
11
+ return /* @__PURE__ */ jsx(Select, { id, label, value, onChange: (e) => onChange(e.target.value), children: options.map((o) => /* @__PURE__ */ jsx("option", { value: o.code, children: o.label }, o.code)) });
12
+ }
13
+ export {
14
+ LanguageSetting,
15
+ ThemeSetting
16
+ };
17
+ //# sourceMappingURL=settings-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/settings-fields.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { Select } from \"./ui\";\nimport type { ThemePreference } from \"../theme/theme-store\";\n\n/**\n * App-agnostic settings-field components (feedback #333): the language + theme\n * controls that every app's settings page needs, so they live in @hb/ui and the\n * app just supplies the current value, a change handler and translated labels.\n * The app owns WHERE these render (its own settings page) and any app-specific\n * fields around them.\n */\n\nexport interface ThemeSettingProps {\n value: ThemePreference;\n onChange: (value: ThemePreference) => void;\n label: ReactNode;\n /** Translated option labels. */\n optionLabels: { system: string; light: string; dark: string };\n id?: string;\n}\n\nexport function ThemeSetting({ value, onChange, label, optionLabels, id }: ThemeSettingProps) {\n return (\n <Select id={id} label={label} value={value} onChange={(e) => onChange(e.target.value as ThemePreference)}>\n <option value=\"system\">{optionLabels.system}</option>\n <option value=\"light\">{optionLabels.light}</option>\n <option value=\"dark\">{optionLabels.dark}</option>\n </Select>\n );\n}\n\nexport interface LanguageSettingProps {\n value: string;\n onChange: (code: string) => void;\n label: ReactNode;\n options: { code: string; label: string }[];\n id?: string;\n}\n\nexport function LanguageSetting({ value, onChange, label, options, id }: LanguageSettingProps) {\n return (\n <Select id={id} label={label} value={value} onChange={(e) => onChange(e.target.value)}>\n {options.map((o) => (\n <option key={o.code} value={o.code}>\n {o.label}\n </option>\n ))}\n </Select>\n );\n}\n"],"mappings":"AAuBI,SACE,KADF;AAtBJ,SAAS,cAAc;AAoBhB,SAAS,aAAa,EAAE,OAAO,UAAU,OAAO,cAAc,GAAG,GAAsB;AAC5F,SACE,qBAAC,UAAO,IAAQ,OAAc,OAAc,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAwB,GACrG;AAAA,wBAAC,YAAO,OAAM,UAAU,uBAAa,QAAO;AAAA,IAC5C,oBAAC,YAAO,OAAM,SAAS,uBAAa,OAAM;AAAA,IAC1C,oBAAC,YAAO,OAAM,QAAQ,uBAAa,MAAK;AAAA,KAC1C;AAEJ;AAUO,SAAS,gBAAgB,EAAE,OAAO,UAAU,OAAO,SAAS,GAAG,GAAyB;AAC7F,SACE,oBAAC,UAAO,IAAQ,OAAc,OAAc,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK,GACjF,kBAAQ,IAAI,CAAC,MACZ,oBAAC,YAAoB,OAAO,EAAE,MAC3B,YAAE,SADQ,EAAE,IAEf,CACD,GACH;AAEJ;","names":[]}
@@ -0,0 +1,60 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * One armed swipe action: what it does, and how the reveal panel presents it.
6
+ *
7
+ * The two background classes are the idle and armed states. Before the first
8
+ * threshold is crossed the panel previews the nearest action in its IDLE colour, so
9
+ * the user can see what a little more drag will do; crossing the threshold flips it to
10
+ * `armedClassName`. Passing the same value for both simply removes that feedback.
11
+ */
12
+ interface SwipeAction {
13
+ onCommit: () => void;
14
+ label: string;
15
+ /** Rendered inside the panel. Icons stay the consumer's choice — this package
16
+ * ships no opinion about which icon set an app uses. */
17
+ icon?: ReactNode;
18
+ /** Background utility class while the action is previewed but not yet armed. */
19
+ className: string;
20
+ /** Background utility class once the drag has passed this action's threshold. */
21
+ armedClassName: string;
22
+ }
23
+ interface SwipeableRowProps {
24
+ /** Actions committed by dragging RIGHT, nearest threshold first. */
25
+ right?: SwipeAction[];
26
+ /** Actions committed by dragging LEFT, nearest threshold first. */
27
+ left?: SwipeAction[];
28
+ /** Turn the gesture off — an open editor, a pending mutation, a locked row. */
29
+ enabled?: boolean;
30
+ className?: string;
31
+ children: ReactNode;
32
+ }
33
+ /**
34
+ * A row whose horizontal drag reveals actions underneath it.
35
+ *
36
+ * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be
37
+ * two half-width blocks side by side, which capped the drag at half the row so the
38
+ * opposite panel could never be exposed — and on a row bound to three actions that
39
+ * left ~55px between stages on a phone, close enough that the wrong one committed.
40
+ * Only the side being dragged toward is painted now, so full width costs nothing:
41
+ * at rest the content covers both anyway, and a drag only ever reveals one side.
42
+ *
43
+ * Thresholds are still not fixed pixel values — they are spread evenly across the
44
+ * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed
45
+ * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every
46
+ * action reachable and roughly doubles the room between them.
47
+ *
48
+ * **Whether the action will fire is stated three ways, not one** (feedback #174): the
49
+ * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the
50
+ * label turns bold — plus the existing haptic tick per newly armed stage. A colour
51
+ * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot
52
+ * distinguish the two tones.
53
+ *
54
+ * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe
55
+ * would also fire whatever click handler the row content carries (expanding it, opening
56
+ * a dialog) on top of the action it just committed.
57
+ */
58
+ declare function SwipeableRow({ right, left, enabled, className, children, }: SwipeableRowProps): react.JSX.Element;
59
+
60
+ export { type SwipeAction, SwipeableRow, type SwipeableRowProps };
@@ -0,0 +1,115 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { useRowSwipe } from "../hooks/use-row-swipe.js";
5
+ const PEEK_PX = 40;
6
+ function SwipeableRow({
7
+ right,
8
+ left,
9
+ enabled = true,
10
+ className,
11
+ children
12
+ }) {
13
+ const rowRef = useRef(null);
14
+ const [rowWidth, setRowWidth] = useState(0);
15
+ useEffect(() => {
16
+ const el = rowRef.current;
17
+ if (!el) return;
18
+ setRowWidth(el.clientWidth);
19
+ if (typeof ResizeObserver === "undefined") return;
20
+ const ro = new ResizeObserver((entries) => {
21
+ for (const e of entries) setRowWidth(e.contentRect.width);
22
+ });
23
+ ro.observe(el);
24
+ return () => ro.disconnect();
25
+ }, []);
26
+ const rightActions = right ?? [];
27
+ const leftActions = left ?? [];
28
+ const active = enabled && (rightActions.length > 0 || leftActions.length > 0);
29
+ const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;
30
+ const spread = (actions) => actions.map((a, i) => ({
31
+ threshold: Math.round(maxDrag * (i + 1) / (actions.length + 1)),
32
+ onCommit: a.onCommit
33
+ }));
34
+ const swipe = useRowSwipe({
35
+ enabled: active,
36
+ rightStages: spread(rightActions),
37
+ leftStages: spread(leftActions),
38
+ maxDrag
39
+ });
40
+ const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;
41
+ const prevArmed = useRef(-1);
42
+ useEffect(() => {
43
+ if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);
44
+ prevArmed.current = armedNow;
45
+ }, [armedNow]);
46
+ const dx = swipe.dx;
47
+ const lastSide = useRef("right");
48
+ if (dx !== 0) lastSide.current = dx < 0 ? "left" : "right";
49
+ const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === "left";
50
+ const shown = draggingLeft ? leftActions[Math.max(0, swipe.armedLeftIndex)] : rightActions[Math.max(0, swipe.armedRightIndex)];
51
+ const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;
52
+ return /* @__PURE__ */ jsxs("div", { ref: rowRef, className: cn("relative overflow-hidden", className), children: [
53
+ active && shown && /* @__PURE__ */ jsxs(
54
+ "div",
55
+ {
56
+ className: cn(
57
+ "pointer-events-none absolute inset-0 flex items-center gap-2 px-4",
58
+ "text-xs font-medium text-white transition-all",
59
+ // Dimmed until the drag has actually passed a threshold: "nothing will
60
+ // happen yet" has to look different from "let go and this fires", and on
61
+ // a moving row a one-step colour change was too quiet to notice.
62
+ armed ? "opacity-100" : "opacity-60",
63
+ armed ? shown.armedClassName : shown.className,
64
+ // The panel is anchored to the edge the row is uncovering, so the label
65
+ // sits where the eye already is rather than across the screen.
66
+ draggingLeft && "flex-row-reverse"
67
+ ),
68
+ children: [
69
+ shown.icon && /* @__PURE__ */ jsx(
70
+ "span",
71
+ {
72
+ className: cn(
73
+ "flex shrink-0 items-center justify-center rounded-full transition-all",
74
+ // Armed, the icon gets a disc of its own and grows — a second,
75
+ // non-colour signal, and the one that stays readable at a glance.
76
+ armed ? "size-8 scale-110 bg-white/25" : "size-7 bg-white/10"
77
+ ),
78
+ children: shown.icon
79
+ }
80
+ ),
81
+ /* @__PURE__ */ jsx("span", { className: cn("truncate transition-all", armed && "text-sm font-semibold"), children: shown.label })
82
+ ]
83
+ }
84
+ ),
85
+ /* @__PURE__ */ jsx(
86
+ "div",
87
+ {
88
+ onClickCapture: (e) => {
89
+ if (!swipe.consumeClick()) return;
90
+ e.preventDefault();
91
+ e.stopPropagation();
92
+ },
93
+ className: cn(
94
+ "relative bg-[var(--bg-surface)]",
95
+ // A mouse drag is a supported way to swipe (use-row-swipe gates only on
96
+ // the button, not the pointer type), and a horizontal mouse drag over
97
+ // text also SELECTS that text — so committing an action left the row
98
+ // highlighted blue. Suppressed only while dragging, so text in a row at
99
+ // rest stays selectable.
100
+ swipe.dragging ? "select-none" : "transition-transform duration-150"
101
+ ),
102
+ style: {
103
+ transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : void 0,
104
+ touchAction: active ? "pan-y" : void 0
105
+ },
106
+ ...active ? swipe.handlers : {},
107
+ children
108
+ }
109
+ )
110
+ ] });
111
+ }
112
+ export {
113
+ SwipeableRow
114
+ };
115
+ //# sourceMappingURL=swipeable-row.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\nexport interface SwipeableRowProps {\n /** Actions committed by dragging RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. */\n enabled?: boolean;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n className,\n children,\n}: SwipeableRowProps) {\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Written during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms) and `setState` in an effect body is an eslint error in\n // the consuming app. The value is derived from this render's own dx, so a\n // concurrent re-render recomputes it identically.\n const lastSide = useRef<\"left\" | \"right\">(\"right\");\n if (dx !== 0) lastSide.current = dx < 0 ? \"left\" : \"right\";\n const draggingLeft = dx !== 0 ? dx < 0 : lastSide.current === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen.\n draggingLeft && \"flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n </div>\n );\n}\n"],"mappings":"AAgJQ,SAeI,KAfJ;AAhJR,SAAS,WAAW,QAAQ,gBAAgB;AAE5C,SAAS,UAAU;AACnB,SAAS,mBAAoC;AAoC7C,MAAM,UAAU;AA2BT,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA;AACF,GAAsB;AACpB,QAAM,SAAS,OAAuB,IAAI;AAC1C,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,CAAC;AAC1C,YAAU,MAAM;AACd,UAAM,KAAK,OAAO;AAClB,QAAI,CAAC,GAAI;AACT,gBAAY,GAAG,WAAW;AAC1B,QAAI,OAAO,mBAAmB,YAAa;AAC3C,UAAM,KAAK,IAAI,eAAe,CAAC,YAAY;AACzC,iBAAW,KAAK,QAAS,aAAY,EAAE,YAAY,KAAK;AAAA,IAC1D,CAAC;AACD,OAAG,QAAQ,EAAE;AACb,WAAO,MAAM,GAAG,WAAW;AAAA,EAC7B,GAAG,CAAC,CAAC;AAEL,QAAM,eAAe,SAAS,CAAC;AAC/B,QAAM,cAAc,QAAQ,CAAC;AAC7B,QAAM,SAAS,YAAY,aAAa,SAAS,KAAK,YAAY,SAAS;AAI3E,QAAM,UAAU,WAAW,IAAI,KAAK,IAAI,IAAI,KAAK,MAAM,WAAW,OAAO,CAAC,IAAI;AAC9E,QAAM,SAAS,CAAC,YACd,QAAQ,IAAI,CAAC,GAAG,OAAO;AAAA,IACrB,WAAW,KAAK,MAAO,WAAW,IAAI,MAAO,QAAQ,SAAS,EAAE;AAAA,IAChE,UAAU,EAAE;AAAA,EACd,EAAE;AAEJ,QAAM,QAAQ,YAAY;AAAA,IACxB,SAAS;AAAA,IACT,aAAa,OAAO,YAAY;AAAA,IAChC,YAAY,OAAO,WAAW;AAAA,IAC9B;AAAA,EACF,CAAC;AAGD,QAAM,WAAW,MAAM,MAAM,IAAI,MAAM,kBAAkB,MAAM;AAC/D,QAAM,YAAY,OAAO,EAAE;AAC3B,YAAU,MAAM;AACd,QAAI,WAAW,UAAU,WAAW,YAAY,EAAG,WAAU,UAAU,EAAE;AACzE,cAAU,UAAU;AAAA,EACtB,GAAG,CAAC,QAAQ,CAAC;AAGb,QAAM,KAAK,MAAM;AAiBjB,QAAM,WAAW,OAAyB,OAAO;AACjD,MAAI,OAAO,EAAG,UAAS,UAAU,KAAK,IAAI,SAAS;AACnD,QAAM,eAAe,OAAO,IAAI,KAAK,IAAI,SAAS,YAAY;AAC9D,QAAM,QAAQ,eACV,YAAY,KAAK,IAAI,GAAG,MAAM,cAAc,CAAC,IAC7C,aAAa,KAAK,IAAI,GAAG,MAAM,eAAe,CAAC;AACnD,QAAM,QAAQ,eAAe,MAAM,kBAAkB,IAAI,MAAM,mBAAmB;AAElF,SACE,qBAAC,SAAI,KAAK,QAAQ,WAAW,GAAG,4BAA4B,SAAS,GAClE;AAAA,cAAU,SACT;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA;AAAA,UAIA,QAAQ,gBAAgB;AAAA,UACxB,QAAQ,MAAM,iBAAiB,MAAM;AAAA;AAAA;AAAA,UAGrC,gBAAgB;AAAA,QAClB;AAAA,QAEC;AAAA,gBAAM,QACL;AAAA,YAAC;AAAA;AAAA,cACC,WAAW;AAAA,gBACT;AAAA;AAAA;AAAA,gBAGA,QAAQ,iCAAiC;AAAA,cAC3C;AAAA,cAEC,gBAAM;AAAA;AAAA,UACT;AAAA,UAEF,oBAAC,UAAK,WAAW,GAAG,2BAA2B,SAAS,uBAAuB,GAC5E,gBAAM,OACT;AAAA;AAAA;AAAA,IACF;AAAA,IAEF;AAAA,MAAC;AAAA;AAAA,QAGC,gBAAgB,CAAC,MAAM;AACrB,cAAI,CAAC,MAAM,aAAa,EAAG;AAC3B,YAAE,eAAe;AACjB,YAAE,gBAAgB;AAAA,QACpB;AAAA,QACA,WAAW;AAAA,UACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAMA,MAAM,WAAW,gBAAgB;AAAA,QACnC;AAAA,QACA,OAAO;AAAA,UACL,WAAW,OAAO,IAAI,eAAe,EAAE,YAAY;AAAA,UACnD,aAAa,SAAS,UAAU;AAAA,QAClC;AAAA,QACC,GAAI,SAAS,MAAM,WAAW,CAAC;AAAA,QAE/B;AAAA;AAAA,IACH;AAAA,KACF;AAEJ;","names":[]}
@@ -0,0 +1,34 @@
1
+ import * as react from 'react';
2
+
3
+ interface ToggleOption<T extends string> {
4
+ value: T;
5
+ label: string;
6
+ className?: string;
7
+ }
8
+ interface ToggleGroupProps<T extends string> {
9
+ value: T;
10
+ onChange: (value: T) => void;
11
+ options: ToggleOption<T>[];
12
+ className?: string;
13
+ /** Applied to every option button (e.g. to tune height/rounding to match
14
+ * adjacent fields). Per-option `className` still wins over this. */
15
+ optionClassName?: string;
16
+ ariaLabel?: string;
17
+ /**
18
+ * Show, refuse the change (Keksdose live #288: a payment dated in the future has no
19
+ * state to set).
20
+ *
21
+ * Whatever `value` says stays pressed and keeps its own fill rather than going grey
22
+ * with the rest — a reader who cannot see WHICH option is chosen has been told less
23
+ * than before it was disabled. A caller with nothing to show passes no value, and
24
+ * the group renders dimmed with nothing pressed, which is the shape Keksdose's
25
+ * status picker uses for a row whose status does not exist yet.
26
+ *
27
+ * On the whole GROUP, not per option: a segmented control where some segments are
28
+ * live and others are not is a menu with holes in it, and no caller here wants one.
29
+ */
30
+ disabled?: boolean;
31
+ }
32
+ declare function ToggleGroup<T extends string>({ value, onChange, options, className, optionClassName, ariaLabel, disabled, }: ToggleGroupProps<T>): react.JSX.Element;
33
+
34
+ export { ToggleGroup };
@@ -0,0 +1,90 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { cn } from "../lib/cn.js";
3
+ function ToggleGroup({
4
+ value,
5
+ onChange,
6
+ options,
7
+ className,
8
+ optionClassName,
9
+ ariaLabel,
10
+ disabled = false
11
+ }) {
12
+ return /* @__PURE__ */ jsx(
13
+ "div",
14
+ {
15
+ role: "radiogroup",
16
+ "aria-label": ariaLabel,
17
+ "aria-disabled": disabled || void 0,
18
+ className: cn(
19
+ // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment
20
+ // sits in a uniform 2px moat and no two fills ever touch. Flush segments were
21
+ // Keksdose live #268's rework: the pressed segment wears a saturated fill and
22
+ // an unpressed neighbour wears a pale hover fill, and with a shared edge the
23
+ // two rectangles read as one smeared shape — *"the boundary of the selected
24
+ // option and hovering next to it overlays the boundary of the selected
25
+ // button"*. A gap is what makes each segment its own chip; it cannot be
26
+ // undone by a caller's per-option colour, which a hover-only fix could.
27
+ //
28
+ // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every
29
+ // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a
30
+ // phone does see is the focus ring — see the segment's own note below.)
31
+ "inline-flex w-full gap-0.5 rounded-md border border-slate-300 bg-white p-0.5 shadow-sm dark:border-slate-700 dark:bg-slate-900",
32
+ // The whole group fades, the way every other disabled control in this
33
+ // package does; `cursor-not-allowed` is on the buttons, which is what a
34
+ // pointer is actually over.
35
+ disabled && "opacity-60",
36
+ className
37
+ ),
38
+ children: options.map((opt) => {
39
+ const active = opt.value === value;
40
+ return /* @__PURE__ */ jsx(
41
+ "button",
42
+ {
43
+ type: "button",
44
+ role: "radio",
45
+ "aria-checked": active,
46
+ disabled,
47
+ onClick: () => onChange(opt.value),
48
+ className: cn(
49
+ // `truncate` (which carries whitespace-nowrap) rather than letting a
50
+ // label wrap: a segmented control sizes its whole row to the tallest
51
+ // option, so one two-word option — Keksdose feedback #147's "Where I
52
+ // am" — silently doubles the height of every segment beside it.
53
+ //
54
+ // `basis-auto` is what keeps that ellipsis a LAST resort rather than
55
+ // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a
56
+ // shrink-to-fit group (`w-auto`) still resolves to the sum of the
57
+ // labels' widths — and then splits it EQUALLY, so the short option
58
+ // got 66px it did not need and "Where I am" got 66 of the 81 it did:
59
+ // truncated at 1778px of free screen. Basing each segment on its own
60
+ // content and sharing only the LEFTOVER space keeps a full-width
61
+ // group's segments near-equal and an auto-width group's exact.
62
+ //
63
+ // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring
64
+ // is a box-shadow that spreads OUTWARD, so on a flush group it painted
65
+ // 2px of slate over both neighbours and over the container's own border
66
+ // — and on a phone it appeared on every TAP, because a tap focuses the
67
+ // button. That is the other half of what live #268's rework saw
68
+ // overlaying the selected segment's boundary. Inset keeps the ring
69
+ // inside the segment it belongs to; focus-visible keeps it for the
70
+ // keyboard, which is the only input that needs it.
71
+ "min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400",
72
+ active ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" : "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
73
+ // No hover fill on a group that cannot be changed — a segment that
74
+ // lights up under the pointer is an offer, and there is none here.
75
+ disabled && "cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent",
76
+ optionClassName,
77
+ opt.className
78
+ ),
79
+ children: opt.label
80
+ },
81
+ opt.value
82
+ );
83
+ })
84
+ }
85
+ );
86
+ }
87
+ export {
88
+ ToggleGroup
89
+ };
90
+ //# sourceMappingURL=toggle-group.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/toggle-group.tsx"],"sourcesContent":["import { cn } from \"../lib/cn\";\n\ninterface ToggleOption<T extends string> {\n value: T;\n label: string;\n className?: string;\n}\n\ninterface ToggleGroupProps<T extends string> {\n value: T;\n onChange: (value: T) => void;\n options: ToggleOption<T>[];\n className?: string;\n /** Applied to every option button (e.g. to tune height/rounding to match\n * adjacent fields). Per-option `className` still wins over this. */\n optionClassName?: string;\n ariaLabel?: string;\n /**\n * Show, refuse the change (Keksdose live #288: a payment dated in the future has no\n * state to set).\n *\n * Whatever `value` says stays pressed and keeps its own fill rather than going grey\n * with the rest — a reader who cannot see WHICH option is chosen has been told less\n * than before it was disabled. A caller with nothing to show passes no value, and\n * the group renders dimmed with nothing pressed, which is the shape Keksdose's\n * status picker uses for a row whose status does not exist yet.\n *\n * On the whole GROUP, not per option: a segmented control where some segments are\n * live and others are not is a menu with holes in it, and no caller here wants one.\n */\n disabled?: boolean;\n}\n\nexport function ToggleGroup<T extends string>({\n value,\n onChange,\n options,\n className,\n optionClassName,\n ariaLabel,\n disabled = false,\n}: ToggleGroupProps<T>) {\n return (\n <div\n role=\"radiogroup\"\n aria-label={ariaLabel}\n // `aria-disabled` on the group as well as `disabled` on each button: a radio\n // group is what the user is being refused, and a screen reader announcing\n // three separately-disabled radios does not say that.\n aria-disabled={disabled || undefined}\n className={cn(\n // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment\n // sits in a uniform 2px moat and no two fills ever touch. Flush segments were\n // Keksdose live #268's rework: the pressed segment wears a saturated fill and\n // an unpressed neighbour wears a pale hover fill, and with a shared edge the\n // two rectangles read as one smeared shape — *\"the boundary of the selected\n // option and hovering next to it overlays the boundary of the selected\n // button\"*. A gap is what makes each segment its own chip; it cannot be\n // undone by a caller's per-option colour, which a hover-only fix could.\n //\n // (The hover fill is the DESKTOP half of that report: Tailwind v4 wraps every\n // `hover:` in `@media (hover: hover)`, so a phone never paints it. The half a\n // phone does see is the focus ring — see the segment's own note below.)\n \"inline-flex w-full gap-0.5 rounded-md border border-slate-300 bg-white p-0.5 shadow-sm dark:border-slate-700 dark:bg-slate-900\",\n // The whole group fades, the way every other disabled control in this\n // package does; `cursor-not-allowed` is on the buttons, which is what a\n // pointer is actually over.\n disabled && \"opacity-60\",\n className,\n )}\n >\n {options.map((opt) => {\n const active = opt.value === value;\n return (\n <button\n key={opt.value}\n type=\"button\"\n role=\"radio\"\n aria-checked={active}\n disabled={disabled}\n onClick={() => onChange(opt.value)}\n className={cn(\n // `truncate` (which carries whitespace-nowrap) rather than letting a\n // label wrap: a segmented control sizes its whole row to the tallest\n // option, so one two-word option — Keksdose feedback #147's \"Where I\n // am\" — silently doubles the height of every segment beside it.\n //\n // `basis-auto` is what keeps that ellipsis a LAST resort rather than\n // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a\n // shrink-to-fit group (`w-auto`) still resolves to the sum of the\n // labels' widths — and then splits it EQUALLY, so the short option\n // got 66px it did not need and \"Where I am\" got 66 of the 81 it did:\n // truncated at 1778px of free screen. Basing each segment on its own\n // content and sharing only the LEFTOVER space keeps a full-width\n // group's segments near-equal and an auto-width group's exact.\n //\n // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring\n // is a box-shadow that spreads OUTWARD, so on a flush group it painted\n // 2px of slate over both neighbours and over the container's own border\n // — and on a phone it appeared on every TAP, because a tap focuses the\n // button. That is the other half of what live #268's rework saw\n // overlaying the selected segment's boundary. Inset keeps the ring\n // inside the segment it belongs to; focus-visible keeps it for the\n // keyboard, which is the only input that needs it.\n \"min-w-0 flex-1 basis-auto truncate rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400\",\n active\n ? \"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900\"\n : \"text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800\",\n // No hover fill on a group that cannot be changed — a segment that\n // lights up under the pointer is an offer, and there is none here.\n disabled && \"cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent\",\n optionClassName,\n opt.className,\n )}\n >\n {opt.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":"AA0EU;AA1EV,SAAS,UAAU;AAiCZ,SAAS,YAA8B;AAAA,EAC5C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AACb,GAAwB;AACtB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY;AAAA,MAIZ,iBAAe,YAAY;AAAA,MAC3B,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAaT;AAAA;AAAA;AAAA;AAAA,QAIA,YAAY;AAAA,QACZ;AAAA,MACF;AAAA,MAEC,kBAAQ,IAAI,CAAC,QAAQ;AACpB,cAAM,SAAS,IAAI,UAAU;AAC7B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,gBAAc;AAAA,YACd;AAAA,YACA,SAAS,MAAM,SAAS,IAAI,KAAK;AAAA,YACjC,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,cAuBT;AAAA,cACA,SACI,kEACA;AAAA;AAAA;AAAA,cAGJ,YAAY;AAAA,cACZ;AAAA,cACA,IAAI;AAAA,YACN;AAAA,YAEC,cAAI;AAAA;AAAA,UAxCA,IAAI;AAAA,QAyCX;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
@@ -0,0 +1,86 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { AnchorRect } from '../hooks/use-anchored-rect.js';
4
+
5
+ type TooltipSide = "top" | "bottom" | "left" | "right";
6
+ /**
7
+ * Hover/focus label for a control.
8
+ *
9
+ * Two implementations, and the choice matters more than it looks. The default is
10
+ * CSS-only: the bubble is always mounted next to the trigger and fades in on
11
+ * `:hover`, which costs no state and works in a plain render test. The `portal`
12
+ * variant mounts the bubble in `document.body` only while hovered, positioned by
13
+ * measurement.
14
+ *
15
+ * ⚠️ **A bubble that repeats a value has to be redactable.** The consuming app blurs
16
+ * `[data-private]` under a `demo-mode` class on `<html>` — and the portalled bubble is
17
+ * mounted on `document.body`, which is INSIDE that class, so the rule reaches it as
18
+ * long as the bubble is tagged. It is not tagged by default, because most labels are
19
+ * UI strings; pass `redact` on the ones that repeat the user's own data (a truncated
20
+ * payee, an account name, a memo). Getting this wrong is silent: the trigger blurs,
21
+ * the bubble spells the value out on hover.
22
+ *
23
+ * ⚠️ **An empty label renders nothing at all.** `title={payee ?? ""}` is an ordinary
24
+ * shape at a call site that reveals truncated text, and the native attribute answers
25
+ * it by showing no tooltip. A component that faithfully rendered an empty bubble
26
+ * would be a worse `title`, so the emptiness check is here rather than at every call
27
+ * site that could forget it.
28
+ *
29
+ * ⚠️ **Inside a scroll container, use `portal`.** An always-mounted bubble is
30
+ * absolutely positioned, but an absolutely positioned descendant still counts
31
+ * towards its scroll-container ancestor's scrollable overflow — so an invisible
32
+ * bubble on a control near the right edge makes the container scroll sideways
33
+ * with nothing to reveal. That is what Keksdose feedback dev#488 reported on the
34
+ * admin roster: 66px of horizontal scroll on a table that fit, 44px of it owed to
35
+ * tooltips nobody could see. The portalled bubble is `position: fixed` and absent
36
+ * until hovered, so it adds no width — and, being outside the container, it also
37
+ * cannot be clipped by it.
38
+ */
39
+ declare function Tooltip({ label, side, className, portal, redact, children, }: {
40
+ label: ReactNode;
41
+ side?: TooltipSide;
42
+ className?: string;
43
+ portal?: boolean;
44
+ /** Tag the bubble `data-private`, for a label that repeats the user's own data. */
45
+ redact?: boolean;
46
+ children: ReactNode;
47
+ }): react.JSX.Element;
48
+ interface TooltipSize {
49
+ width: number;
50
+ height: number;
51
+ }
52
+ interface TooltipViewport {
53
+ width: number;
54
+ height: number;
55
+ }
56
+ interface TooltipPlacement {
57
+ left: number;
58
+ top: number;
59
+ /** Which side it ended up on, which need not be the one that was asked for. */
60
+ side: TooltipSide;
61
+ }
62
+ /**
63
+ * Where the bubble actually goes, given how big it turned out to be.
64
+ *
65
+ * Two rules, and they are separate because they fix separate failures.
66
+ *
67
+ * **Turn round when the preferred side has no room.** `side` says which side of
68
+ * the trigger the label reads best on, and on a form near the left edge of the
69
+ * window that side is off the screen — the capped bubble can only wrap, not
70
+ * move, so what the reader gets is a sentence with its first half outside the
71
+ * glass. Flipped only when the *other* side is genuinely better: a trigger in a
72
+ * viewport too narrow for the bubble either way keeps the side it asked for, and
73
+ * the clamp below does what it can.
74
+ *
75
+ * **Then clamp both axes.** The cross axis is the one that needs it — a `top`
76
+ * bubble is centred on the trigger, so a trigger near the left edge pushes half
77
+ * the label off even though the side it is on is right — and clamping the main
78
+ * axis too costs nothing and covers the flip having nowhere to land.
79
+ *
80
+ * Pure, and measured in viewport pixels throughout, so it can be tested without
81
+ * a layout: the caller supplies the trigger's rect, the bubble's own size and
82
+ * the window.
83
+ */
84
+ declare function placeTooltip(r: AnchorRect, side: TooltipSide, size: TooltipSize, viewport: TooltipViewport): TooltipPlacement;
85
+
86
+ export { Tooltip, type TooltipPlacement, type TooltipSize, type TooltipViewport, placeTooltip };