@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,114 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef, useState } from "react";
3
+ import { CalculatorButton } from "./calculator.js";
4
+ import { NumberPadSheet } from "./numpad-sheet.js";
5
+ import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui.js";
6
+ import { cn } from "../lib/cn.js";
7
+ import { commitExpression, sanitizeLive } from "../lib/calc.js";
8
+ import { useMediaQuery } from "../hooks/use-media-query.js";
9
+ function NumberInput({
10
+ value,
11
+ onChange,
12
+ onCommit,
13
+ label,
14
+ ariaLabel,
15
+ labels,
16
+ placeholder,
17
+ disabled,
18
+ autoFocus,
19
+ className,
20
+ inputClassName,
21
+ id,
22
+ calculator = true,
23
+ variant = "field",
24
+ hint,
25
+ invalid,
26
+ suffix
27
+ }) {
28
+ const generatedId = useId();
29
+ const fieldId = id ?? generatedId;
30
+ const labelled = label !== void 0;
31
+ const isMobile = useMediaQuery(PHONE_QUERY, false);
32
+ const asDisplay = isMobile && variant === "display";
33
+ const showCalc = calculator && !disabled && !isMobile;
34
+ const [focused, setFocused] = useState(false);
35
+ const showNumpad = isMobile && !disabled && focused;
36
+ const inputRef = useRef(null);
37
+ const commit = () => {
38
+ const next = commitExpression(value);
39
+ if (next !== value) onChange(next);
40
+ onCommit?.(next);
41
+ };
42
+ const onKeyDown = (e) => {
43
+ if (e.key === "Enter") commit();
44
+ };
45
+ return /* @__PURE__ */ jsxs(
46
+ FloatingField,
47
+ {
48
+ className: cn("w-full", className),
49
+ htmlFor: fieldId,
50
+ label,
51
+ srOnlyLabel: asDisplay,
52
+ hint,
53
+ children: [
54
+ /* @__PURE__ */ jsx(
55
+ "input",
56
+ {
57
+ ref: inputRef,
58
+ id: fieldId,
59
+ "aria-label": ariaLabel,
60
+ type: "text",
61
+ inputMode: isMobile ? "none" : "decimal",
62
+ autoComplete: "off",
63
+ autoFocus,
64
+ disabled,
65
+ placeholder: asDisplay ? placeholder ?? "0" : labelled ? " " : placeholder,
66
+ value,
67
+ onChange: (e) => onChange(sanitizeLive(e.target.value)),
68
+ onFocus: () => setFocused(true),
69
+ onBlur: () => {
70
+ commit();
71
+ setFocused(false);
72
+ },
73
+ onKeyDown,
74
+ "aria-invalid": invalid || void 0,
75
+ className: cn(
76
+ asDisplay ? cn(FIELD_DISPLAY, "text-4xl font-semibold leading-tight tracking-tight tabular-nums") : labelled ? FLOATING_INPUT_CLASS : FIELD_BASE,
77
+ inputClassName,
78
+ showCalc && suffix !== void 0 ? "pr-16" : showCalc ? "pr-9" : suffix !== void 0 ? "pr-8" : void 0,
79
+ invalid && FIELD_INVALID
80
+ )
81
+ }
82
+ ),
83
+ (showCalc || suffix !== void 0) && // One flex track for both, so the unit and the calculator sit side by side
84
+ // instead of stacking on the same corner — AmountInput's arrangement, which
85
+ // has carried a chip and a calculator together since #430.
86
+ /* @__PURE__ */ jsxs("div", { className: "absolute inset-y-0 right-0 flex items-center", children: [
87
+ showCalc && /* @__PURE__ */ jsx(
88
+ CalculatorButton,
89
+ {
90
+ value,
91
+ onChange,
92
+ className: "self-stretch px-2.5",
93
+ ariaLabel: labels?.calculatorTrigger,
94
+ labels: labels?.calculator
95
+ }
96
+ ),
97
+ suffix !== void 0 && /* @__PURE__ */ jsx(
98
+ "span",
99
+ {
100
+ "aria-hidden": true,
101
+ className: "pointer-events-none pr-3 text-xs font-medium text-slate-500 dark:text-slate-400",
102
+ children: suffix
103
+ }
104
+ )
105
+ ] }),
106
+ showNumpad && /* @__PURE__ */ jsx(NumberPadSheet, { value, onChange, onDone: () => inputRef.current?.blur(), label })
107
+ ]
108
+ }
109
+ );
110
+ }
111
+ export {
112
+ NumberInput
113
+ };
114
+ //# sourceMappingURL=number-input.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/number-input.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { CalculatorButton, type CalculatorButtonLabels } from \"./calculator\";\nimport { NumberPadSheet } from \"./numpad-sheet\";\nimport { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { commitExpression, sanitizeLive } from \"../lib/calc\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\ninterface NumberInputProps {\n value: string;\n /** Fired on every keystroke with the raw text (comma → dot, operators kept so\n * a typed expression survives). */\n onChange: (value: string) => void;\n /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur\n * fields so the save always sees the resolved number, never \"10+5\". */\n onCommit?: (value: string) => void;\n label?: ReactNode;\n ariaLabel?: string;\n /** Names for the calculator this field renders — its trigger, and the controls\n * inside the popover. English defaults, so a host that does not translate is\n * unaffected. */\n labels?: { calculatorTrigger?: string; calculator?: CalculatorButtonLabels };\n placeholder?: string;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Wrapper class. */\n className?: string;\n /** Overrides the input's own styling (merged after the field base). */\n inputClassName?: string;\n id?: string;\n /** Show the calculator-popover trigger (default true). Set false for fields in\n * an ephemeral close-on-blur editor, where clicking the icon would blur away\n * and tear the editor down before the popover could open — inline typing\n * (e.g. \"200+50\" → Enter) still evaluates there. */\n calculator?: boolean;\n /** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field\n * chrome dropped. The currency-free half of the same treatment `AmountInput`\n * carries, for a dialog whose whole point is the one number (a budget goal's\n * target); not for a field among many, and never for the compact inline\n * editors this control also serves. */\n variant?: \"field\" | \"display\";\n /** A {@link FieldHint} \"?\" on the label's own line, beside the label rather\n * than at the far end of the strip — the far end is where the calculator\n * lives, and a hint placed there landed on top of it (steering-design\n * feedback #48). */\n hint?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /**\n * A static unit shown at the field's right edge — \"%\", \"kg\", \"km/h\".\n *\n * The read-out half of {@link AmountInput}'s currency chip, and it exists for the\n * same reason that one does: a unit belongs to the FIELD, not to the text, so\n * typing it is a way to get it into the value. Keksdose had a percentage spelled\n * three different ways across three screens — `<Input type=\"number\" step=\"0.01\">`\n * for a loan rate, `step=\"0.1\"` for a tax rate, `inputMode=\"decimal\"` for a VAT\n * rate — and none of the three showed a \"%\" anywhere near the box, so what the\n * digits meant was a question the label alone had to answer.\n *\n * `aria-hidden`, like the currency read-out: it is a property of the field that\n * the label already names (\"Interest rate (%)\"), and announcing it again after\n * every value reads as part of the number.\n */\n suffix?: ReactNode;\n}\n\n/**\n * A numeric text field with a built-in calculator: type a calculation straight\n * in (e.g. \"12+5\", evaluated on blur/Enter) or click the trailing calculator\n * icon for a keypad. String `value`/`onChange` contract, mirroring\n * {@link AmountInput} — the currency-free counterpart for budgets, goals,\n * invoice totals and split lines.\n */\nexport function NumberInput({\n value,\n onChange,\n onCommit,\n label,\n ariaLabel,\n labels,\n placeholder,\n disabled,\n autoFocus,\n className,\n inputClassName,\n id,\n calculator = true,\n variant = \"field\",\n hint,\n invalid,\n suffix,\n}: NumberInputProps) {\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const labelled = label !== undefined;\n // On phones we suppress the OS keyboard (inputMode=\"none\" below) for our own\n // calculator numpad, so the desktop popover trigger is hidden (feedback #334).\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const asDisplay = isMobile && variant === \"display\";\n const showCalc = calculator && !disabled && !isMobile;\n const [focused, setFocused] = useState(false);\n // On mobile, focusing opens the numpad bottom sheet instead of the native\n // keyboard (#334); the ref lets its \"Done\" blur → commit + close.\n const showNumpad = isMobile && !disabled && focused;\n const inputRef = useRef<HTMLInputElement>(null);\n\n const commit = () => {\n const next = commitExpression(value);\n if (next !== value) onChange(next);\n onCommit?.(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") commit();\n };\n\n return (\n <FloatingField\n className={cn(\"w-full\", className)}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={hint}\n >\n <input\n ref={inputRef}\n id={fieldId}\n aria-label={ariaLabel}\n type=\"text\"\n // Mobile: suppress the OS keyboard so the numpad sheet owns entry (field\n // keeps focus/caret); desktop keeps the native decimal keypad.\n inputMode={isMobile ? \"none\" : \"decimal\"}\n autoComplete=\"off\"\n autoFocus={autoFocus}\n disabled={disabled}\n // Display mode has no floating label to feed the blank-placeholder trick,\n // and an empty borderless figure shows nothing — so it keeps whatever\n // placeholder the caller gave, falling back to a zero to aim at.\n placeholder={asDisplay ? (placeholder ?? \"0\") : labelled ? \" \" : placeholder}\n value={value}\n onChange={(e) => onChange(sanitizeLive(e.target.value))}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n commit();\n setFocused(false);\n }}\n onKeyDown={onKeyDown}\n aria-invalid={invalid || undefined}\n // The right-padding sits last so it always wins over an inputClassName that\n // sets its own px. Both trailing controls can be on at once, so the room\n // they need is reserved together rather than by whichever happens to render:\n // the calculator is ~36px and a short unit ~24px, and a field that reserved\n // only one of them would let the digits run under the other.\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-4xl font-semibold leading-tight tracking-tight tabular-nums\")\n : labelled\n ? FLOATING_INPUT_CLASS\n : FIELD_BASE,\n inputClassName,\n showCalc && suffix !== undefined ? \"pr-16\" : showCalc ? \"pr-9\" : suffix !== undefined ? \"pr-8\" : undefined,\n invalid && FIELD_INVALID,\n )}\n />\n {(showCalc || suffix !== undefined) && (\n // One flex track for both, so the unit and the calculator sit side by side\n // instead of stacking on the same corner — AmountInput's arrangement, which\n // has carried a chip and a calculator together since #430.\n <div className=\"absolute inset-y-0 right-0 flex items-center\">\n {showCalc && (\n <CalculatorButton\n value={value}\n onChange={onChange}\n className=\"self-stretch px-2.5\"\n ariaLabel={labels?.calculatorTrigger}\n labels={labels?.calculator}\n />\n )}\n {suffix !== undefined && (\n <span\n aria-hidden\n className=\"pointer-events-none pr-3 text-xs font-medium text-slate-500 dark:text-slate-400\"\n >\n {suffix}\n </span>\n )}\n </div>\n )}\n {showNumpad && (\n <NumberPadSheet value={value} onChange={onChange} onDone={() => inputRef.current?.blur()} label={label} />\n )}\n </FloatingField>\n );\n}\n"],"mappings":"AA6HM,cA4CE,YA5CF;AA7HN,SAAS,OAAO,QAAQ,gBAAgB;AAExC,SAAS,wBAAqD;AAC9D,SAAS,sBAAsB;AAC/B,SAAS,YAAY,eAAe,eAAe,sBAAsB,eAAe,mBAAmB;AAC3G,SAAS,UAAU;AACnB,SAAS,kBAAkB,oBAAoB;AAC/C,SAAS,qBAAqB;AAmEvB,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,cAAc,MAAM;AAC1B,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,UAAU;AAG3B,QAAM,WAAW,cAAc,aAAa,KAAK;AACjD,QAAM,YAAY,YAAY,YAAY;AAC1C,QAAM,WAAW,cAAc,CAAC,YAAY,CAAC;AAC7C,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAG5C,QAAM,aAAa,YAAY,CAAC,YAAY;AAC5C,QAAM,WAAW,OAAyB,IAAI;AAE9C,QAAM,SAAS,MAAM;AACnB,UAAM,OAAO,iBAAiB,KAAK;AACnC,QAAI,SAAS,MAAO,UAAS,IAAI;AACjC,eAAW,IAAI;AAAA,EACjB;AAEA,QAAM,YAAY,CAAC,MAAuC;AACxD,QAAI,EAAE,QAAQ,QAAS,QAAO;AAAA,EAChC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,UAAU,SAAS;AAAA,MACjC,SAAS;AAAA,MACT;AAAA,MACA,aAAa;AAAA,MACb;AAAA,MAEA;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,IAAI;AAAA,YACJ,cAAY;AAAA,YACZ,MAAK;AAAA,YAGL,WAAW,WAAW,SAAS;AAAA,YAC/B,cAAa;AAAA,YACb;AAAA,YACA;AAAA,YAIA,aAAa,YAAa,eAAe,MAAO,WAAW,MAAM;AAAA,YACjE;AAAA,YACA,UAAU,CAAC,MAAM,SAAS,aAAa,EAAE,OAAO,KAAK,CAAC;AAAA,YACtD,SAAS,MAAM,WAAW,IAAI;AAAA,YAC9B,QAAQ,MAAM;AACZ,qBAAO;AACP,yBAAW,KAAK;AAAA,YAClB;AAAA,YACA;AAAA,YACA,gBAAc,WAAW;AAAA,YAMzB,WAAW;AAAA,cACT,YACI,GAAG,eAAe,kEAAkE,IACpF,WACE,uBACA;AAAA,cACN;AAAA,cACA,YAAY,WAAW,SAAY,UAAU,WAAW,SAAS,WAAW,SAAY,SAAS;AAAA,cACjG,WAAW;AAAA,YACb;AAAA;AAAA,QACF;AAAA,SACE,YAAY,WAAW;AAAA;AAAA;AAAA,QAIvB,qBAAC,SAAI,WAAU,gDACZ;AAAA,sBACC;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,WAAU;AAAA,cACV,WAAW,QAAQ;AAAA,cACnB,QAAQ,QAAQ;AAAA;AAAA,UAClB;AAAA,UAED,WAAW,UACV;AAAA,YAAC;AAAA;AAAA,cACC,eAAW;AAAA,cACX,WAAU;AAAA,cAET;AAAA;AAAA,UACH;AAAA,WAEJ;AAAA,QAED,cACC,oBAAC,kBAAe,OAAc,UAAoB,QAAQ,MAAM,SAAS,SAAS,KAAK,GAAG,OAAc;AAAA;AAAA;AAAA,EAE5G;AAEJ;","names":[]}
@@ -0,0 +1,45 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
6
+ * the desktop {@link CalculatorButton} popover, and the replacement for the old
7
+ * under-the-field operator bar (`MathKeys`, feedback #334).
8
+ *
9
+ * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA
10
+ * cannot — but it CAN suppress the OS keyboard with `inputMode="none"` on the
11
+ * host field and paint its own keypad in the freed space. So the host input keeps
12
+ * real focus/caret/selection + the existing value/commit contract, while this
13
+ * sheet drives it: digits + operators + a live `= …` preview, evaluated through
14
+ * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the
15
+ * native keyboard and the calculator popover); the host gates this to mobile.
16
+ *
17
+ * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —
18
+ * the field text is the single source of truth. `onPointerDown` preventDefault on
19
+ * the root keeps the host input focused (caret stays, keyboard stays hidden) as
20
+ * the user taps keys; the buttons' `onClick` still fires.
21
+ */
22
+ /** Screen-reader names for the pad itself and its three non-digit keys. */
23
+ interface NumberPadSheetLabels {
24
+ pad?: string;
25
+ backspace?: string;
26
+ clear?: string;
27
+ equals?: string;
28
+ }
29
+ declare function NumberPadSheet({ value, onChange, onDone, label, labels, }: {
30
+ value: string;
31
+ /** Fired with the raw (sanitised) field text on every key — same contract as
32
+ * the host field's own `onChange`. */
33
+ onChange: (value: string) => void;
34
+ /** Fired on "Done": the host blurs the input, which commits (evaluates) and
35
+ * unmounts the sheet via its existing blur handler. */
36
+ onDone: () => void;
37
+ /** Optional field label, echoed in the sheet header so the user still knows
38
+ * which field they're editing when the sheet covers it. */
39
+ label?: ReactNode;
40
+ /** Screen-reader names for the pad itself and its three non-digit keys. The
41
+ * digits and operators need none — their visible glyph IS the name. */
42
+ labels?: NumberPadSheetLabels;
43
+ }): react.ReactPortal;
44
+
45
+ export { NumberPadSheet, type NumberPadSheetLabels };
@@ -0,0 +1,102 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { createPortal } from "react-dom";
3
+ import { Delete } from "lucide-react";
4
+ import { cn } from "../lib/cn.js";
5
+ import { evaluateExpression, formatResult, sanitizeLive } from "../lib/calc.js";
6
+ const PAD_KEYS = [
7
+ { kind: "ins", label: "7", ins: "7" },
8
+ { kind: "ins", label: "8", ins: "8" },
9
+ { kind: "ins", label: "9", ins: "9" },
10
+ { kind: "ins", label: "\xF7", ins: "\xF7", accent: true },
11
+ { kind: "ins", label: "4", ins: "4" },
12
+ { kind: "ins", label: "5", ins: "5" },
13
+ { kind: "ins", label: "6", ins: "6" },
14
+ { kind: "ins", label: "\xD7", ins: "\xD7", accent: true },
15
+ { kind: "ins", label: "1", ins: "1" },
16
+ { kind: "ins", label: "2", ins: "2" },
17
+ { kind: "ins", label: "3", ins: "3" },
18
+ { kind: "ins", label: "\u2212", ins: "-", accent: true },
19
+ { kind: "ins", label: ".", ins: "." },
20
+ { kind: "ins", label: "0", ins: "0" },
21
+ { kind: "back" },
22
+ { kind: "ins", label: "+", ins: "+", accent: true }
23
+ ];
24
+ const PAD_BTN = "flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none active:scale-[0.97]";
25
+ const PAD_DIGIT = "bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]";
26
+ const PAD_ACCENT = "bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]";
27
+ function NumberPadSheet({
28
+ value,
29
+ onChange,
30
+ onDone,
31
+ label,
32
+ labels
33
+ }) {
34
+ const result = evaluateExpression(value);
35
+ const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : "";
36
+ const insert = (ch) => onChange(sanitizeLive(value + ch));
37
+ const backspace = () => onChange(value.slice(0, -1));
38
+ const clearAll = () => onChange("");
39
+ const equals = () => {
40
+ if (result !== null) onChange(formatResult(result));
41
+ };
42
+ const sheet = /* @__PURE__ */ jsxs(
43
+ "div",
44
+ {
45
+ role: "group",
46
+ "aria-label": labels?.pad ?? "Number pad",
47
+ onPointerDown: (e) => e.preventDefault(),
48
+ className: "fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]",
49
+ style: { paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" },
50
+ children: [
51
+ /* @__PURE__ */ jsxs("div", { className: "flex items-end justify-between gap-3 px-2 pb-2", children: [
52
+ label != null && /* @__PURE__ */ jsx("span", { className: "truncate pb-1 text-xs font-medium text-[var(--money-neutral)]", children: label }),
53
+ /* @__PURE__ */ jsxs("div", { className: "ml-auto min-w-0 text-right", children: [
54
+ /* @__PURE__ */ jsx("div", { className: "truncate font-mono text-lg leading-tight text-[var(--text-primary)]", children: value || "0" }),
55
+ /* @__PURE__ */ jsx("div", { className: "h-4 font-mono text-xs text-[var(--money-neutral)]", children: preview })
56
+ ] })
57
+ ] }),
58
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1.5", children: PAD_KEYS.map(
59
+ (key, i) => key.kind === "back" ? /* @__PURE__ */ jsx(
60
+ "button",
61
+ {
62
+ type: "button",
63
+ "aria-label": labels?.backspace ?? "Backspace",
64
+ onClick: backspace,
65
+ className: cn(PAD_BTN, PAD_ACCENT),
66
+ children: /* @__PURE__ */ jsx(Delete, { className: "size-5" })
67
+ },
68
+ "back"
69
+ ) : /* @__PURE__ */ jsx(
70
+ "button",
71
+ {
72
+ type: "button",
73
+ "aria-label": key.label,
74
+ onClick: () => insert(key.ins),
75
+ className: cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT),
76
+ children: key.label
77
+ },
78
+ `${key.ins}-${i}`
79
+ )
80
+ ) }),
81
+ /* @__PURE__ */ jsxs("div", { className: "mt-1.5 grid grid-cols-4 gap-1.5", children: [
82
+ /* @__PURE__ */ jsx("button", { type: "button", "aria-label": labels?.clear ?? "Clear", onClick: clearAll, className: cn(PAD_BTN, PAD_ACCENT), children: "C" }),
83
+ /* @__PURE__ */ jsx("button", { type: "button", "aria-label": labels?.equals ?? "Equals", onClick: equals, className: cn(PAD_BTN, PAD_ACCENT), children: "=" }),
84
+ /* @__PURE__ */ jsx(
85
+ "button",
86
+ {
87
+ type: "button",
88
+ onClick: onDone,
89
+ className: cn(PAD_BTN, "col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] active:bg-[var(--brand-hover)]"),
90
+ children: "Done"
91
+ }
92
+ )
93
+ ] })
94
+ ]
95
+ }
96
+ );
97
+ return createPortal(sheet, document.body);
98
+ }
99
+ export {
100
+ NumberPadSheet
101
+ };
102
+ //# sourceMappingURL=numpad-sheet.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/numpad-sheet.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport { Delete } from \"lucide-react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { evaluateExpression, formatResult, sanitizeLive } from \"../lib/calc\";\n\n/**\n * A full calculator keypad rendered as a bottom sheet — the mobile counterpart to\n * the desktop {@link CalculatorButton} popover, and the replacement for the old\n * under-the-field operator bar (`MathKeys`, feedback #334).\n *\n * A native app (YNAB) can swap the system keyboard for its own keypad; a PWA\n * cannot — but it CAN suppress the OS keyboard with `inputMode=\"none\"` on the\n * host field and paint its own keypad in the freed space. So the host input keeps\n * real focus/caret/selection + the existing value/commit contract, while this\n * sheet drives it: digits + operators + a live `= …` preview, evaluated through\n * the shared {@link evaluateExpression} engine. Desktop is untouched (it keeps the\n * native keyboard and the calculator popover); the host gates this to mobile.\n *\n * Buttons commit through the host's `onChange`/blur, so the sheet is stateless —\n * the field text is the single source of truth. `onPointerDown` preventDefault on\n * the root keeps the host input focused (caret stays, keyboard stays hidden) as\n * the user taps keys; the buttons' `onClick` still fires.\n */\n/** Screen-reader names for the pad itself and its three non-digit keys. */\nexport interface NumberPadSheetLabels {\n pad?: string;\n backspace?: string;\n clear?: string;\n equals?: string;\n}\n\ntype PadKey =\n | { kind: \"ins\"; label: string; ins: string; accent?: boolean }\n | { kind: \"back\" };\n\n// Insert chars that survive `sanitizeLive` (it keeps `× ÷` and ASCII `+ - * /`\n// but strips the unicode minus `−`, so minus inserts an ASCII \"-\" while showing\n// the nicer glyph). The evaluator normalises `× ÷` back to `* /`.\nconst PAD_KEYS: PadKey[] = [\n { kind: \"ins\", label: \"7\", ins: \"7\" },\n { kind: \"ins\", label: \"8\", ins: \"8\" },\n { kind: \"ins\", label: \"9\", ins: \"9\" },\n { kind: \"ins\", label: \"÷\", ins: \"÷\", accent: true },\n { kind: \"ins\", label: \"4\", ins: \"4\" },\n { kind: \"ins\", label: \"5\", ins: \"5\" },\n { kind: \"ins\", label: \"6\", ins: \"6\" },\n { kind: \"ins\", label: \"×\", ins: \"×\", accent: true },\n { kind: \"ins\", label: \"1\", ins: \"1\" },\n { kind: \"ins\", label: \"2\", ins: \"2\" },\n { kind: \"ins\", label: \"3\", ins: \"3\" },\n { kind: \"ins\", label: \"−\", ins: \"-\", accent: true },\n { kind: \"ins\", label: \".\", ins: \".\" },\n { kind: \"ins\", label: \"0\", ins: \"0\" },\n { kind: \"back\" },\n { kind: \"ins\", label: \"+\", ins: \"+\", accent: true },\n];\n\nconst PAD_BTN =\n \"flex h-14 items-center justify-center rounded-lg text-lg font-medium transition-transform select-none focus:outline-none active:scale-[0.97]\";\nconst PAD_DIGIT = \"bg-[var(--bg-surface-2)] text-[var(--text-primary)] active:bg-[var(--border)]\";\nconst PAD_ACCENT = \"bg-[var(--border)] text-[var(--text-primary)] active:bg-[var(--bg-surface-2)]\";\n\nexport function NumberPadSheet({\n value,\n onChange,\n onDone,\n label,\n labels,\n}: {\n value: string;\n /** Fired with the raw (sanitised) field text on every key — same contract as\n * the host field's own `onChange`. */\n onChange: (value: string) => void;\n /** Fired on \"Done\": the host blurs the input, which commits (evaluates) and\n * unmounts the sheet via its existing blur handler. */\n onDone: () => void;\n /** Optional field label, echoed in the sheet header so the user still knows\n * which field they're editing when the sheet covers it. */\n label?: ReactNode;\n /** Screen-reader names for the pad itself and its three non-digit keys. The\n * digits and operators need none — their visible glyph IS the name. */\n labels?: NumberPadSheetLabels;\n}) {\n // NO `useBodyScrollLock`, and that is the point of the control (Keksdose live\n // #317: *\"Background not scrollable when the amount input calculator field is\n // open\"*).\n //\n // This sheet is a KEYBOARD, not a dialog. It is opened by focusing a field and\n // exists only because a PWA cannot swap the system keyboard for its own — so it\n // suppresses the OS keyboard with `inputMode=\"none\"` and paints itself in the\n // freed space. An OS keyboard does not freeze the page behind it; it takes the\n // bottom of the screen and leaves you free to scroll what is left, which is how\n // you reach the field you are typing into when the keys cover it.\n //\n // Locking here made the page unreachable at exactly the moment it matters most:\n // correcting a receipt total against the running line sum, where the figure you\n // are comparing against sits below the keypad. The other four holders of that\n // hook are all modal — `Modal`, `PickerSheet`, `FullBleedDialog`, the DataTable\n // row dialog — and they stay locked, because for them the page behind is not\n // part of the task.\n //\n // The pad still does not scroll ITSELF away under a stray drag: the root's\n // `onPointerDown` preventDefault (below) keeps the host input focused and eats\n // the gesture on the sheet, so a touch that starts on a key is not a page scroll.\n\n const result = evaluateExpression(value);\n const preview = result !== null && formatResult(result) !== value.trim() ? `= ${formatResult(result)}` : \"\";\n\n const insert = (ch: string) => onChange(sanitizeLive(value + ch));\n const backspace = () => onChange(value.slice(0, -1));\n const clearAll = () => onChange(\"\");\n const equals = () => {\n if (result !== null) onChange(formatResult(result));\n };\n\n const sheet = (\n <div\n role=\"group\"\n aria-label={labels?.pad ?? \"Number pad\"}\n // Keep the host input focused when tapping the pad: preventDefault on\n // pointerdown blocks the focus/blur, while the buttons' click still fires.\n onPointerDown={(e) => e.preventDefault()}\n className=\"fixed inset-x-0 bottom-0 z-50 border-t border-[var(--border)] bg-[var(--bg-surface)] px-2 pt-2 shadow-[0_-4px_24px_rgba(0,0,0,0.12)]\"\n style={{ paddingBottom: \"max(0.5rem, env(safe-area-inset-bottom))\" }}\n >\n {/* Header display: which field + the running expression and live result, so\n the value stays visible even when the sheet covers the field. */}\n <div className=\"flex items-end justify-between gap-3 px-2 pb-2\">\n {label != null && (\n <span className=\"truncate pb-1 text-xs font-medium text-[var(--money-neutral)]\">{label}</span>\n )}\n <div className=\"ml-auto min-w-0 text-right\">\n <div className=\"truncate font-mono text-lg leading-tight text-[var(--text-primary)]\">{value || \"0\"}</div>\n <div className=\"h-4 font-mono text-xs text-[var(--money-neutral)]\">{preview}</div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-4 gap-1.5\">\n {PAD_KEYS.map((key, i) =>\n key.kind === \"back\" ? (\n <button\n key=\"back\"\n type=\"button\"\n aria-label={labels?.backspace ?? \"Backspace\"}\n onClick={backspace}\n className={cn(PAD_BTN, PAD_ACCENT)}\n >\n <Delete className=\"size-5\" />\n </button>\n ) : (\n <button\n key={`${key.ins}-${i}`}\n type=\"button\"\n aria-label={key.label}\n onClick={() => insert(key.ins)}\n className={cn(PAD_BTN, key.accent ? PAD_ACCENT : PAD_DIGIT)}\n >\n {key.label}\n </button>\n ),\n )}\n </div>\n\n <div className=\"mt-1.5 grid grid-cols-4 gap-1.5\">\n <button type=\"button\" aria-label={labels?.clear ?? \"Clear\"} onClick={clearAll} className={cn(PAD_BTN, PAD_ACCENT)}>\n C\n </button>\n <button type=\"button\" aria-label={labels?.equals ?? \"Equals\"} onClick={equals} className={cn(PAD_BTN, PAD_ACCENT)}>\n =\n </button>\n <button\n type=\"button\"\n onClick={onDone}\n className={cn(PAD_BTN, \"col-span-2 bg-[var(--brand)] text-[var(--brand-contrast)] active:bg-[var(--brand-hover)]\")}\n >\n Done\n </button>\n </div>\n </div>\n );\n\n return createPortal(sheet, document.body);\n}\n"],"mappings":"AAkIU,cAEF,YAFE;AAlIV,SAAS,oBAAoB;AAC7B,SAAS,cAAc;AAEvB,SAAS,UAAU;AACnB,SAAS,oBAAoB,cAAc,oBAAoB;AAmC/D,MAAM,WAAqB;AAAA,EACzB,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,QAAK,KAAK,QAAK,QAAQ,KAAK;AAAA,EAClD,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,QAAK,KAAK,QAAK,QAAQ,KAAK;AAAA,EAClD,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,UAAK,KAAK,KAAK,QAAQ,KAAK;AAAA,EAClD,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,IAAI;AAAA,EACpC,EAAE,MAAM,OAAO;AAAA,EACf,EAAE,MAAM,OAAO,OAAO,KAAK,KAAK,KAAK,QAAQ,KAAK;AACpD;AAEA,MAAM,UACJ;AACF,MAAM,YAAY;AAClB,MAAM,aAAa;AAEZ,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAcG;AAuBD,QAAM,SAAS,mBAAmB,KAAK;AACvC,QAAM,UAAU,WAAW,QAAQ,aAAa,MAAM,MAAM,MAAM,KAAK,IAAI,KAAK,aAAa,MAAM,CAAC,KAAK;AAEzG,QAAM,SAAS,CAAC,OAAe,SAAS,aAAa,QAAQ,EAAE,CAAC;AAChE,QAAM,YAAY,MAAM,SAAS,MAAM,MAAM,GAAG,EAAE,CAAC;AACnD,QAAM,WAAW,MAAM,SAAS,EAAE;AAClC,QAAM,SAAS,MAAM;AACnB,QAAI,WAAW,KAAM,UAAS,aAAa,MAAM,CAAC;AAAA,EACpD;AAEA,QAAM,QACJ;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY,QAAQ,OAAO;AAAA,MAG3B,eAAe,CAAC,MAAM,EAAE,eAAe;AAAA,MACvC,WAAU;AAAA,MACV,OAAO,EAAE,eAAe,2CAA2C;AAAA,MAInE;AAAA,6BAAC,SAAI,WAAU,kDACZ;AAAA,mBAAS,QACR,oBAAC,UAAK,WAAU,iEAAiE,iBAAM;AAAA,UAEzF,qBAAC,SAAI,WAAU,8BACb;AAAA,gCAAC,SAAI,WAAU,uEAAuE,mBAAS,KAAI;AAAA,YACnG,oBAAC,SAAI,WAAU,qDAAqD,mBAAQ;AAAA,aAC9E;AAAA,WACF;AAAA,QAEA,oBAAC,SAAI,WAAU,4BACZ,mBAAS;AAAA,UAAI,CAAC,KAAK,MAClB,IAAI,SAAS,SACX;AAAA,YAAC;AAAA;AAAA,cAEC,MAAK;AAAA,cACL,cAAY,QAAQ,aAAa;AAAA,cACjC,SAAS;AAAA,cACT,WAAW,GAAG,SAAS,UAAU;AAAA,cAEjC,8BAAC,UAAO,WAAU,UAAS;AAAA;AAAA,YANvB;AAAA,UAON,IAEA;AAAA,YAAC;AAAA;AAAA,cAEC,MAAK;AAAA,cACL,cAAY,IAAI;AAAA,cAChB,SAAS,MAAM,OAAO,IAAI,GAAG;AAAA,cAC7B,WAAW,GAAG,SAAS,IAAI,SAAS,aAAa,SAAS;AAAA,cAEzD,cAAI;AAAA;AAAA,YANA,GAAG,IAAI,GAAG,IAAI,CAAC;AAAA,UAOtB;AAAA,QAEJ,GACF;AAAA,QAEA,qBAAC,SAAI,WAAU,mCACb;AAAA,8BAAC,YAAO,MAAK,UAAS,cAAY,QAAQ,SAAS,SAAS,SAAS,UAAU,WAAW,GAAG,SAAS,UAAU,GAAG,eAEnH;AAAA,UACA,oBAAC,YAAO,MAAK,UAAS,cAAY,QAAQ,UAAU,UAAU,SAAS,QAAQ,WAAW,GAAG,SAAS,UAAU,GAAG,eAEnH;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS;AAAA,cACT,WAAW,GAAG,SAAS,0FAA0F;AAAA,cAClH;AAAA;AAAA,UAED;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAGF,SAAO,aAAa,OAAO,SAAS,IAAI;AAC1C;","names":[]}
@@ -0,0 +1,44 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
+
4
+ /**
5
+ * The PHONE presentation of a picker: a full-screen dialog with a search box at
6
+ * the top and the list filling everything below it (Keksdose live #200).
7
+ *
8
+ * *"Paid as full screen dialog with input. Similar to the account select that
9
+ * already appears as full screen."* The account field is a native `<select>`, and
10
+ * a phone browser renders that as a full-screen list — so the app's own pickers,
11
+ * which are anchored dropdown panels, were the odd ones out: a 320px-tall panel
12
+ * squeezed between the field and the keyboard, showing three or four rows of a
13
+ * list that might have two hundred entries.
14
+ *
15
+ * What this fixes beyond size: the panel had to be *placed* (above/below, tracking
16
+ * the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every
17
+ * one of those decisions is a chance to be wrong on a screen this small. A sheet
18
+ * has no placement. It also gives the search input somewhere unambiguous to live,
19
+ * which is the other half of the request: *"add filler possibility to the account
20
+ * select or even better right to the hoc the account select is derived from"* —
21
+ * so this lives in the shared picker, not in one form's copy of it.
22
+ *
23
+ * Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list
24
+ * scrolls, the header does not, and the body behind it is scroll-locked so a drag
25
+ * that overshoots the list does not move the page underneath.
26
+ */
27
+ declare function PickerSheet({ open, onClose, title, query, onQueryChange, searchPlaceholder, inputRef, closeLabel, children, }: {
28
+ open: boolean;
29
+ onClose: () => void;
30
+ /** The field's own label — a sheet that fills the screen has to say what it is
31
+ * asking for, which the anchored panel got for free by sitting under it. */
32
+ title?: ReactNode;
33
+ query: string;
34
+ onQueryChange: (value: string) => void;
35
+ searchPlaceholder?: string;
36
+ inputRef?: RefObject<HTMLInputElement | null>;
37
+ closeLabel?: string;
38
+ children: ReactNode;
39
+ }): react.ReactPortal | null;
40
+ /** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,
41
+ * so a list is comfortable to hit with a thumb rather than merely legible. */
42
+ declare const SHEET_ROW_CLASS: string;
43
+
44
+ export { PickerSheet, SHEET_ROW_CLASS };
@@ -0,0 +1,78 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { X } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { DropdownSearchHeader } from "./dropdown.js";
7
+ import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
8
+ import { useOverlayHistory } from "../hooks/use-overlay-history.js";
9
+ import { useVisualViewport } from "../hooks/use-anchored-panel.js";
10
+ function PickerSheet({
11
+ open,
12
+ onClose,
13
+ title,
14
+ query,
15
+ onQueryChange,
16
+ searchPlaceholder,
17
+ inputRef,
18
+ closeLabel = "Close",
19
+ children
20
+ }) {
21
+ useBodyScrollLock(open);
22
+ useOverlayHistory(open, onClose);
23
+ const vv = useVisualViewport(open);
24
+ const ownRef = useRef(null);
25
+ const searchRef = inputRef ?? ownRef;
26
+ useEffect(() => {
27
+ if (open) searchRef.current?.focus();
28
+ }, [open, searchRef]);
29
+ if (!open || typeof document === "undefined") return null;
30
+ return createPortal(
31
+ /* @__PURE__ */ jsxs(
32
+ "div",
33
+ {
34
+ role: "dialog",
35
+ "aria-modal": "true",
36
+ "aria-label": typeof title === "string" ? title : void 0,
37
+ onMouseDown: (e) => e.stopPropagation(),
38
+ style: vv ? { top: vv.top, height: vv.height, bottom: "auto" } : void 0,
39
+ className: "fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900",
40
+ children: [
41
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-700", children: [
42
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-100", children: title }),
43
+ /* @__PURE__ */ jsx(
44
+ "button",
45
+ {
46
+ type: "button",
47
+ "aria-label": closeLabel,
48
+ onClick: onClose,
49
+ className: "rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
50
+ children: /* @__PURE__ */ jsx(X, { className: "size-5" })
51
+ }
52
+ )
53
+ ] }),
54
+ /* @__PURE__ */ jsx(
55
+ DropdownSearchHeader,
56
+ {
57
+ query,
58
+ onQueryChange,
59
+ inputRef: searchRef,
60
+ placeholder: searchPlaceholder
61
+ }
62
+ ),
63
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children })
64
+ ]
65
+ }
66
+ ),
67
+ document.body
68
+ );
69
+ }
70
+ const SHEET_ROW_CLASS = cn(
71
+ "flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base",
72
+ "text-slate-900 hover:bg-slate-50 dark:text-slate-100 dark:hover:bg-slate-800"
73
+ );
74
+ export {
75
+ PickerSheet,
76
+ SHEET_ROW_CLASS
77
+ };
78
+ //# sourceMappingURL=picker-sheet.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/picker-sheet.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X } from \"lucide-react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader } from \"./dropdown\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useVisualViewport } from \"../hooks/use-anchored-panel\";\n\n/**\n * The PHONE presentation of a picker: a full-screen dialog with a search box at\n * the top and the list filling everything below it (Keksdose live #200).\n *\n * *\"Paid as full screen dialog with input. Similar to the account select that\n * already appears as full screen.\"* The account field is a native `<select>`, and\n * a phone browser renders that as a full-screen list — so the app's own pickers,\n * which are anchored dropdown panels, were the odd ones out: a 320px-tall panel\n * squeezed between the field and the keyboard, showing three or four rows of a\n * list that might have two hundred entries.\n *\n * What this fixes beyond size: the panel had to be *placed* (above/below, tracking\n * the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every\n * one of those decisions is a chance to be wrong on a screen this small. A sheet\n * has no placement. It also gives the search input somewhere unambiguous to live,\n * which is the other half of the request: *\"add filler possibility to the account\n * select or even better right to the hoc the account select is derived from\"* —\n * so this lives in the shared picker, not in one form's copy of it.\n *\n * Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list\n * scrolls, the header does not, and the body behind it is scroll-locked so a drag\n * that overshoots the list does not move the page underneath.\n */\nexport function PickerSheet({\n open,\n onClose,\n title,\n query,\n onQueryChange,\n searchPlaceholder,\n inputRef,\n closeLabel = \"Close\",\n children,\n}: {\n open: boolean;\n onClose: () => void;\n /** The field's own label — a sheet that fills the screen has to say what it is\n * asking for, which the anchored panel got for free by sitting under it. */\n title?: ReactNode;\n query: string;\n onQueryChange: (value: string) => void;\n searchPlaceholder?: string;\n inputRef?: RefObject<HTMLInputElement | null>;\n closeLabel?: string;\n children: ReactNode;\n}) {\n useBodyScrollLock(open);\n /**\n * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:\n * *\"Mouse Back does not only close the select but also the whole edit or create\n * dialog which is cumbersome.\"*).\n *\n * The sheet is a full-screen overlay over whatever opened it, and on a phone that\n * is almost always the row editor — which DOES push a history entry\n * (`data-table.tsx`). So the sheet was invisible to Back: one press popped the\n * editor's entry and took the whole dialog with it, discarding an edit in progress\n * in order to dismiss a list.\n *\n * `useOverlayHistory` was built for exactly this nesting — its stack unwinds\n * last-in-first-out, and the U-12 regression test in its own suite is written\n * around \"a Modal and a PickerSheet inside it\". The sheet simply never called it.\n *\n * Nothing else has to change: the hook is a no-op while `open` is false, and it\n * pops its own entry when the sheet closes some other way (the X, a row, the\n * backdrop) so history does not accumulate husks.\n */\n useOverlayHistory(open, onClose);\n // The visible region, while the sheet is up — see the `style` below for why a\n // full-screen sheet cannot simply be `inset-0` on a phone (live #328).\n const vv = useVisualViewport(open);\n // The sheet focuses its OWN search box when it opens (Keksdose live #212: *\"make\n // the text input filter active on select click\"*).\n //\n // The callers tried: `Combobox`'s field does `setOpen(true); sheetInputRef.current\n // ?.focus()` in one `onFocus`, so the focus call runs while the sheet is still\n // unmounted and the ref is null — and the chevron's `onMouseDown` preventDefaults\n // to keep focus on the field, so opening that way never even attempted it. Neither\n // is fixable from outside: only the sheet knows when its input exists.\n //\n // A fallback ref means a caller that does not need the handle still gets the\n // focus, rather than the behaviour depending on whether a prop was passed.\n const ownRef = useRef<HTMLInputElement | null>(null);\n const searchRef = inputRef ?? ownRef;\n useEffect(() => {\n if (open) searchRef.current?.focus();\n }, [open, searchRef]);\n\n if (!open || typeof document === \"undefined\") return null;\n return createPortal(\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={typeof title === \"string\" ? title : undefined}\n // The sheet is PORTALLED to <body>, so it is not inside the field's own\n // wrapper — and `useDropdown` closes on any document mousedown landing\n // outside that wrapper. Tapping a row therefore unmounted the row (open →\n // false) before its CLICK could fire, so the tap selected nothing at all and\n // the sheet just vanished (Keksdose dev#477).\n //\n // It bit whichever sheet commits on `onClick` — the payee field's, since live\n // #200, where it hid behind the free-text value the search box was already\n // setting; and the account field's the moment dev#477 moved it onto the inline\n // picker. `ComboboxPanel`'s rows happen to commit on `onMouseDown`, so they\n // beat the document listener and were never affected — which is luck, not a\n // design, and exactly why the fix belongs to the sheet.\n //\n // Stopping the mousedown here rather than teaching every caller about the\n // portal: the sheet is modal and full-screen, so \"a click in here is not an\n // outside click\" is a property of the sheet, not of whoever opened it.\n onMouseDown={(e) => e.stopPropagation()}\n // ⚠️ `inset-0` was the whole of Keksdose live #328: *\"When the keyboard overlays\n // the entries I cannot scroll past them … to see also the last entries. Happens\n // when browsing the account select.\"*\n //\n // This sheet FOCUSES ITS OWN SEARCH BOX on open (see above), so the keyboard is\n // up every single time it is used on a phone. On Android that shrinks the visual\n // viewport and leaves the LAYOUT viewport alone — `inset-0` is the layout\n // viewport — so the sheet kept its full height with its bottom third behind the\n // keyboard. The list below is `flex-1 overflow-y-auto`, so it sized itself to\n // that hidden height too: it scrolled to ITS end while the last rows were still\n // under the keys, and no gesture could bring them out. Nothing was clipped and\n // nothing looked broken, which is why it reads as \"I cannot scroll past them\".\n //\n // `visualViewport` is the API that answers rather than hints (the live #292\n // lesson): it reports the region actually on screen, keyboard and pinch-pan\n // included, and it fires on the keyboard opening and closing. Pinned to that box,\n // the sheet ends where the keys begin and the list's own scroll covers the rest.\n // `useVisualViewport` returns null when there is nothing to correct, and then\n // this is character-for-character the sheet that was here before.\n style={vv ? { top: vv.top, height: vv.height, bottom: \"auto\" } : undefined}\n className=\"fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900\"\n >\n <div className=\"flex items-center gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-700\">\n <span className=\"min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-100\">\n {title}\n </span>\n <button\n type=\"button\"\n aria-label={closeLabel}\n onClick={onClose}\n className=\"rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n <DropdownSearchHeader\n query={query}\n onQueryChange={onQueryChange}\n inputRef={searchRef}\n placeholder={searchPlaceholder}\n />\n {/* min-h-0 so the LIST scrolls rather than the dialog growing past the\n viewport — a flex child defaults to min-height:auto. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{children}</div>\n </div>,\n document.body,\n );\n}\n\n/** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,\n * so a list is comfortable to hit with a thumb rather than merely legible. */\nexport const SHEET_ROW_CLASS = cn(\n \"flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base\",\n \"text-slate-900 hover:bg-slate-50 dark:text-slate-100 dark:hover:bg-slate-800\",\n);\n"],"mappings":"AA8IM,SACE,KADF;AA9IN,SAAS,WAAW,cAAc;AAClC,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,4BAA4B;AACrC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAyB3B,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AACF,GAYG;AACD,oBAAkB,IAAI;AAoBtB,oBAAkB,MAAM,OAAO;AAG/B,QAAM,KAAK,kBAAkB,IAAI;AAYjC,QAAM,SAAS,OAAgC,IAAI;AACnD,QAAM,YAAY,YAAY;AAC9B,YAAU,MAAM;AACd,QAAI,KAAM,WAAU,SAAS,MAAM;AAAA,EACrC,GAAG,CAAC,MAAM,SAAS,CAAC;AAEpB,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AACrD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,OAAO,UAAU,WAAW,QAAQ;AAAA,QAiBhD,aAAa,CAAC,MAAM,EAAE,gBAAgB;AAAA,QAoBtC,OAAO,KAAK,EAAE,KAAK,GAAG,KAAK,QAAQ,GAAG,QAAQ,QAAQ,OAAO,IAAI;AAAA,QACjE,WAAU;AAAA,QAEV;AAAA,+BAAC,SAAI,WAAU,qFACb;AAAA,gCAAC,UAAK,WAAU,oFACb,iBACH;AAAA,YACA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,cAAY;AAAA,gBACZ,SAAS;AAAA,gBACT,WAAU;AAAA,gBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,YACxB;AAAA,aACF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,UAAU;AAAA,cACV,aAAa;AAAA;AAAA,UACf;AAAA,UAGA,oBAAC,SAAI,WAAU,qDAAqD,UAAS;AAAA;AAAA;AAAA,IAC/E;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAIO,MAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AACF;","names":[]}
@@ -0,0 +1,20 @@
1
+ import * as react from 'react';
2
+ import { RefObject, ReactNode } from 'react';
3
+
4
+ interface PopoverProps {
5
+ trigger: (state: {
6
+ open: boolean;
7
+ toggle: () => void;
8
+ ref: RefObject<HTMLButtonElement | null>;
9
+ }) => ReactNode;
10
+ children: (close: () => void) => ReactNode;
11
+ width?: number;
12
+ }
13
+ /**
14
+ * Portal-rendered popover anchored under its trigger button. The panel is fixed
15
+ * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on
16
+ * scroll/resize; it closes on outside-click and Escape.
17
+ */
18
+ declare function Popover({ trigger, children, width }: PopoverProps): react.JSX.Element;
19
+
20
+ export { Popover };
@@ -0,0 +1,46 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
5
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss.js";
6
+ const POPOVER_WIDTH = 288;
7
+ function Popover({ trigger, children, width = POPOVER_WIDTH }) {
8
+ const triggerRef = useRef(null);
9
+ const panelRef = useRef(null);
10
+ const [open, setOpen] = useState(false);
11
+ const close = () => setOpen(false);
12
+ const toggle = () => setOpen((v) => !v);
13
+ const placement = useAnchoredPanel(triggerRef, open);
14
+ const rect = placement.rect;
15
+ const pos = rect ? {
16
+ top: placement.top,
17
+ left: Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)
18
+ } : null;
19
+ useOutsideClick([triggerRef, panelRef], close, open);
20
+ useEscapeKey(close, open);
21
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
22
+ trigger({ open, toggle, ref: triggerRef }),
23
+ open && pos && createPortal(
24
+ /* @__PURE__ */ jsx(
25
+ "div",
26
+ {
27
+ ref: panelRef,
28
+ style: {
29
+ position: "fixed",
30
+ top: pos.top,
31
+ left: pos.left,
32
+ width,
33
+ maxHeight: placement.maxHeight
34
+ },
35
+ className: "z-50 overflow-y-auto rounded-md border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-700 dark:bg-slate-900",
36
+ children: children(close)
37
+ }
38
+ ),
39
+ document.body
40
+ )
41
+ ] });
42
+ }
43
+ export {
44
+ Popover
45
+ };
46
+ //# sourceMappingURL=popover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/popover.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\n\ninterface PopoverProps {\n trigger: (state: { open: boolean; toggle: () => void; ref: RefObject<HTMLButtonElement | null> }) => ReactNode;\n children: (close: () => void) => ReactNode;\n width?: number;\n}\n\nconst POPOVER_WIDTH = 288;\n\n/**\n * Portal-rendered popover anchored under its trigger button. The panel is fixed\n * to the viewport (so table/overflow ancestors can't clip it) and re-aligns on\n * scroll/resize; it closes on outside-click and Escape.\n */\nexport function Popover({ trigger, children, width = POPOVER_WIDTH }: PopoverProps) {\n const triggerRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n const close = () => setOpen(false);\n const toggle = () => setOpen((v) => !v);\n\n // Align the panel's right edge to the trigger's and clamp it horizontally; the\n // hook owns the vertical half — below by default, flipped above (and height-capped)\n // when that is where the room is, e.g. once a mobile keyboard has eaten the bottom\n // of the screen (feedback #135). It re-measures on scroll/resize.\n const placement = useAnchoredPanel(triggerRef, open);\n const rect = placement.rect;\n const pos = rect\n ? {\n top: placement.top,\n left: Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8),\n }\n : null;\n\n useOutsideClick([triggerRef, panelRef], close, open);\n useEscapeKey(close, open);\n\n return (\n <>\n {trigger({ open, toggle, ref: triggerRef })}\n {open && pos &&\n createPortal(\n <div\n ref={panelRef}\n style={{\n position: \"fixed\",\n top: pos.top,\n left: pos.left,\n width,\n maxHeight: placement.maxHeight,\n }}\n className=\"z-50 overflow-y-auto rounded-md border border-slate-200 bg-white p-3 shadow-lg dark:border-slate-700 dark:bg-slate-900\"\n >\n {children(close)}\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"AA2CI,mBAIM,KAJN;AA3CJ,SAAS,QAAQ,gBAAgB;AAEjC,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,cAAc,uBAAuB;AAQ9C,MAAM,gBAAgB;AAOf,SAAS,QAAQ,EAAE,SAAS,UAAU,QAAQ,cAAc,GAAiB;AAClF,QAAM,aAAa,OAA0B,IAAI;AACjD,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,QAAQ,MAAM,QAAQ,KAAK;AACjC,QAAM,SAAS,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC;AAMtC,QAAM,YAAY,iBAAiB,YAAY,IAAI;AACnD,QAAM,OAAO,UAAU;AACvB,QAAM,MAAM,OACR;AAAA,IACE,KAAK,UAAU;AAAA,IACf,MAAM,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,QAAQ,KAAK,GAAG,OAAO,aAAa,QAAQ,CAAC;AAAA,EAC/E,IACA;AAEJ,kBAAgB,CAAC,YAAY,QAAQ,GAAG,OAAO,IAAI;AACnD,eAAa,OAAO,IAAI;AAExB,SACE,iCACG;AAAA,YAAQ,EAAE,MAAM,QAAQ,KAAK,WAAW,CAAC;AAAA,IACzC,QAAQ,OACP;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UACC,KAAK;AAAA,UACL,OAAO;AAAA,YACL,UAAU;AAAA,YACV,KAAK,IAAI;AAAA,YACT,MAAM,IAAI;AAAA,YACV;AAAA,YACA,WAAW,UAAU;AAAA,UACvB;AAAA,UACA,WAAU;AAAA,UAET,mBAAS,KAAK;AAAA;AAAA,MACjB;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
@@ -0,0 +1,52 @@
1
+ import * as react from 'react';
2
+ import { InputHTMLAttributes } from 'react';
3
+
4
+ interface SearchFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type"> {
5
+ value: string;
6
+ /** The typed text. Emitted as a plain string, not an event: every caller of the
7
+ * four this replaced wanted the string, and one of them held it in a URL param
8
+ * rather than in state. */
9
+ onChange: (value: string) => void;
10
+ /** The field's accessible name. Required, and deliberately not defaulted to the
11
+ * placeholder: a placeholder disappears the moment someone types, so a field
12
+ * named only by one is unnamed exactly when a screen-reader user is working in
13
+ * it. Two of the boxes this replaced had no name but the placeholder. */
14
+ label: string;
15
+ /** Names the clear button. Omit it and no clear button is rendered — which is a
16
+ * decision, not a default: a filter you can type into and not untype is the
17
+ * complaint that produced the button on three of these four screens. */
18
+ clearLabel?: string;
19
+ className?: string;
20
+ }
21
+ /**
22
+ * The page-level filter box: a search icon, a `type="search"` input, and a clear
23
+ * "×" that appears once there is something to clear.
24
+ *
25
+ * ## Why it is a component
26
+ *
27
+ * Keksdose had four of these and no two agreed. Two were character-identical
28
+ * twenty-line copies (the settings catalogue filter and the tour list filter) —
29
+ * the same absolute icon at `left-3`, the same `pl-9 pr-9`, the same
30
+ * `[&::-webkit-search-cancel-button]:appearance-none`, the same conditional clear
31
+ * button at `right-2` — differing only in which state setter they called. The
32
+ * third (the admin support queue) was the same idea at `left-2`/`size-3.5`/`pl-7`
33
+ * with no clear button and no `type="search"`, so it had neither the `searchbox`
34
+ * role nor a way to empty itself. The fourth (the bank picker) was a bare `<Input
35
+ * placeholder="Search">`: no icon, no clear, and no accessible name at all once
36
+ * the placeholder went away.
37
+ *
38
+ * None of that is a design decision anyone made; it is four people solving the
39
+ * same problem on four days. A shared component is how the fourth screen gets the
40
+ * clear button the first one earned.
41
+ *
42
+ * ## The two X's
43
+ *
44
+ * `type="search"` is what gives the field its `searchbox` role — and, in Chrome, a
45
+ * NATIVE clear cross that paints right next to ours, so a non-empty box shows two
46
+ * X's an inch apart. Ours is the one that survives: it carries an accessible name
47
+ * and matches the app's other icon buttons, and it returns focus to the input so
48
+ * the next query can be typed straight away.
49
+ */
50
+ declare const SearchField: react.ForwardRefExoticComponent<SearchFieldProps & react.RefAttributes<HTMLInputElement>>;
51
+
52
+ export { SearchField, type SearchFieldProps };