@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,98 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, RefObject } from 'react';
3
+
4
+ /**
5
+ * Open state + close-on-outside-click for the custom dropdowns (MultiSelect,
6
+ * CurrencySelect, AmountInput's currency picker, Combobox). Attach `wrapperRef`
7
+ * to the relatively-positioned container; the popover lives inside it so a click
8
+ * anywhere else closes it.
9
+ *
10
+ * ## Back closes the list, not the page behind it
11
+ *
12
+ * Keksdose live #309 rework: *"It shall only close it if it is prior open, and not
13
+ * when I am seeing the dialog."* Round one wired {@link PickerSheet} — the shape
14
+ * these lists take on a PHONE — into {@link useOverlayHistory}, and left the pointer
15
+ * shape out. So on a desktop one Back press over an open list still went past it to
16
+ * the dialog underneath: measured on his own budget, the transfer form's account list
17
+ * was showing and Back took the entire add card, amount and all. That is the same
18
+ * loss round one called *"discarding an edit in progress in order to dismiss a
19
+ * list"*, in the other shell.
20
+ *
21
+ * Every dropdown built on this hook is dismissible and is the topmost thing on screen
22
+ * while it is up, so the rule is the hook's rather than each caller's. `backCloses`
23
+ * is for the ONE case where a second entry would be wrong: a caller whose panel is
24
+ * itself a `PickerSheet`, which already registers one — both comboboxes swap shape at
25
+ * {@link PHONE_QUERY}, and two entries would cost two Back presses to close one sheet.
26
+ */
27
+ declare function useDropdown<T extends HTMLElement = HTMLDivElement>({ backCloses, }?: {
28
+ backCloses?: boolean;
29
+ }): {
30
+ open: boolean;
31
+ setOpen: react.Dispatch<react.SetStateAction<boolean>>;
32
+ wrapperRef: RefObject<T | null>;
33
+ panelRef: RefObject<HTMLDivElement | null>;
34
+ };
35
+ /**
36
+ * {@link useDropdown} plus a search box: clears the query and focuses the search
37
+ * input each time the panel opens, so typing filters immediately.
38
+ */
39
+ declare function useDropdownSearch<T extends HTMLElement = HTMLDivElement>(): {
40
+ open: boolean;
41
+ setOpen: react.Dispatch<react.SetStateAction<boolean>>;
42
+ wrapperRef: RefObject<T | null>;
43
+ panelRef: RefObject<HTMLDivElement | null>;
44
+ query: string;
45
+ setQuery: react.Dispatch<react.SetStateAction<string>>;
46
+ inputRef: RefObject<HTMLInputElement | null>;
47
+ };
48
+ /** The search row (magnifier + text input) shared by the searchable dropdowns. */
49
+ declare function DropdownSearchHeader({ query, onQueryChange, inputRef, placeholder, }: {
50
+ query: string;
51
+ onQueryChange: (v: string) => void;
52
+ inputRef: RefObject<HTMLInputElement | null>;
53
+ placeholder?: string;
54
+ }): react.JSX.Element;
55
+ /**
56
+ * The floating popover surface for the searchable dropdowns: bordered/shadowed
57
+ * panel, an optional `header` slot (search box, plus any actions), and a
58
+ * scrollable `<ul>` of `children` with an "empty" row when nothing matches.
59
+ *
60
+ * ## Anchored, or merely absolute
61
+ *
62
+ * Without `anchorRef` the panel is `position: absolute` inside whatever relative box
63
+ * the caller put it in, and the caller places it with `className` (`w-64`,
64
+ * `right-0 top-full`). That is fine in a page that does not scroll around it, and
65
+ * wrong the moment an ancestor has `overflow` — the panel is then CLIPPED by that
66
+ * ancestor's box, with no error and no scrollbar, just a list with its side sliced
67
+ * off. Keksdose dev#548 is what that looks like in practice: the app's own content
68
+ * scroller starts 280px from the left, the amount field's currency picker opens
69
+ * right-aligned and 256px wide, and in the transaction row editor its left third —
70
+ * the search box's first letter, and every row's currency symbol — was cut away.
71
+ *
72
+ * With `anchorRef` the panel is PORTALLED to `<body>` and positioned `fixed` against
73
+ * that trigger's rect, so no ancestor can clip it: the same treatment {@link Popover}
74
+ * already gives the calculator, and the same reason. It re-aligns on scroll and
75
+ * resize, flips above the trigger when the room is there instead (a phone keyboard
76
+ * eats the bottom of the screen), and is clamped to the viewport's edges so a trigger
77
+ * near either side cannot push it off-screen.
78
+ *
79
+ * Pass `panelRef` from {@link useDropdown} along with it: once portalled the panel is
80
+ * no longer inside the wrapper, so the outside-click handler has to be told about it
81
+ * or the first click on an option closes the list instead of choosing from it.
82
+ */
83
+ declare function DropdownPanel({ header, empty, className, anchorRef, panelRef, width, align, children, }: {
84
+ header?: ReactNode;
85
+ empty?: boolean;
86
+ className?: string;
87
+ /** The trigger to hang off. Given, the panel portals and goes `fixed`. */
88
+ anchorRef?: RefObject<HTMLElement | null>;
89
+ /** {@link useDropdown}'s `panelRef`, so an outside-click sees this as inside. */
90
+ panelRef?: RefObject<HTMLDivElement | null>;
91
+ /** Panel width in px, for the anchored form (the CSS `w-*` cannot be measured). */
92
+ width?: number;
93
+ /** Which of the panel's edges lines up with the trigger's, room permitting. */
94
+ align?: "left" | "right";
95
+ children: ReactNode;
96
+ }): react.JSX.Element | null;
97
+
98
+ export { DropdownPanel, DropdownSearchHeader, useDropdown, useDropdownSearch };
@@ -0,0 +1,122 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { Search } from "lucide-react";
5
+ import { useAnchoredPanel } from "../hooks/use-anchored-panel.js";
6
+ import { useOverlayHistory } from "../hooks/use-overlay-history.js";
7
+ import { cn } from "../lib/cn.js";
8
+ function useDropdown({
9
+ backCloses = true
10
+ } = {}) {
11
+ const [open, setOpen] = useState(false);
12
+ const wrapperRef = useRef(null);
13
+ const panelRef = useRef(null);
14
+ useEffect(() => {
15
+ if (!open) return;
16
+ const handler = (e) => {
17
+ const target = e.target;
18
+ const inside = (wrapperRef.current?.contains(target) ?? false) || (panelRef.current?.contains(target) ?? false);
19
+ if (!inside) setOpen(false);
20
+ };
21
+ document.addEventListener("mousedown", handler);
22
+ return () => document.removeEventListener("mousedown", handler);
23
+ }, [open]);
24
+ useOverlayHistory(open && backCloses, useCallback(() => setOpen(false), []));
25
+ return { open, setOpen, wrapperRef, panelRef };
26
+ }
27
+ function useDropdownSearch() {
28
+ const { open, setOpen, wrapperRef, panelRef } = useDropdown();
29
+ const [query, setQuery] = useState("");
30
+ const inputRef = useRef(null);
31
+ useEffect(() => {
32
+ if (open) {
33
+ setQuery("");
34
+ requestAnimationFrame(() => inputRef.current?.focus());
35
+ }
36
+ }, [open]);
37
+ return { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef };
38
+ }
39
+ function DropdownSearchHeader({
40
+ query,
41
+ onQueryChange,
42
+ inputRef,
43
+ placeholder
44
+ }) {
45
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-slate-100 dark:border-slate-800 px-2 py-1.5", children: [
46
+ /* @__PURE__ */ jsx(Search, { className: "size-4 text-slate-400 dark:text-slate-500" }),
47
+ /* @__PURE__ */ jsx(
48
+ "input",
49
+ {
50
+ ref: inputRef,
51
+ type: "text",
52
+ value: query,
53
+ onChange: (e) => onQueryChange(e.target.value),
54
+ placeholder,
55
+ className: "w-full bg-transparent text-sm outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500"
56
+ }
57
+ )
58
+ ] });
59
+ }
60
+ function DropdownPanel({
61
+ header,
62
+ empty,
63
+ className,
64
+ anchorRef,
65
+ panelRef,
66
+ width = 256,
67
+ align = "right",
68
+ children
69
+ }) {
70
+ const anchored = useAnchoredPanel(anchorRef ?? EMPTY_REF, Boolean(anchorRef));
71
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
72
+ header,
73
+ /* @__PURE__ */ jsxs("ul", { className: "overflow-y-auto py-1", children: [
74
+ children,
75
+ empty && /* @__PURE__ */ jsx("li", { className: "px-3 py-2 text-sm text-slate-500 dark:text-slate-400", children: "\u2014" })
76
+ ] })
77
+ ] });
78
+ if (!anchorRef) {
79
+ return /* @__PURE__ */ jsx(
80
+ "div",
81
+ {
82
+ ref: panelRef,
83
+ className: cn(
84
+ "absolute z-30 mt-1 rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900",
85
+ "[&>ul]:max-h-64",
86
+ className
87
+ ),
88
+ children: body
89
+ }
90
+ );
91
+ }
92
+ const rect = anchored.rect;
93
+ if (!rect) return null;
94
+ const wanted = align === "right" ? rect.right - width : rect.left;
95
+ const left = Math.min(Math.max(8, wanted), window.innerWidth - width - 8);
96
+ return createPortal(
97
+ /* @__PURE__ */ jsx(
98
+ "div",
99
+ {
100
+ ref: panelRef,
101
+ style: { position: "fixed", top: anchored.top, left, width, maxHeight: anchored.maxHeight },
102
+ className: cn(
103
+ "z-50 flex flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900",
104
+ // The list, not the panel, is what scrolls: the search header has to stay put
105
+ // while the options move under it.
106
+ "[&>ul]:min-h-0 [&>ul]:flex-1",
107
+ className
108
+ ),
109
+ children: body
110
+ }
111
+ ),
112
+ document.body
113
+ );
114
+ }
115
+ const EMPTY_REF = { current: null };
116
+ export {
117
+ DropdownPanel,
118
+ DropdownSearchHeader,
119
+ useDropdown,
120
+ useDropdownSearch
121
+ };
122
+ //# sourceMappingURL=dropdown.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/dropdown.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Search } from \"lucide-react\";\nimport { useAnchoredPanel } from \"../hooks/use-anchored-panel\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * Open state + close-on-outside-click for the custom dropdowns (MultiSelect,\n * CurrencySelect, AmountInput's currency picker, Combobox). Attach `wrapperRef`\n * to the relatively-positioned container; the popover lives inside it so a click\n * anywhere else closes it.\n *\n * ## Back closes the list, not the page behind it\n *\n * Keksdose live #309 rework: *\"It shall only close it if it is prior open, and not\n * when I am seeing the dialog.\"* Round one wired {@link PickerSheet} — the shape\n * these lists take on a PHONE — into {@link useOverlayHistory}, and left the pointer\n * shape out. So on a desktop one Back press over an open list still went past it to\n * the dialog underneath: measured on his own budget, the transfer form's account list\n * was showing and Back took the entire add card, amount and all. That is the same\n * loss round one called *\"discarding an edit in progress in order to dismiss a\n * list\"*, in the other shell.\n *\n * Every dropdown built on this hook is dismissible and is the topmost thing on screen\n * while it is up, so the rule is the hook's rather than each caller's. `backCloses`\n * is for the ONE case where a second entry would be wrong: a caller whose panel is\n * itself a `PickerSheet`, which already registers one — both comboboxes swap shape at\n * {@link PHONE_QUERY}, and two entries would cost two Back presses to close one sheet.\n */\nexport function useDropdown<T extends HTMLElement = HTMLDivElement>({\n backCloses = true,\n}: { backCloses?: boolean } = {}) {\n const [open, setOpen] = useState(false);\n const wrapperRef = useRef<T>(null);\n // The panel, once it is PORTALLED (see {@link DropdownPanel}'s `anchorRef`): it is\n // then a child of <body> rather than of the wrapper, so \"did the click land inside\n // the wrapper\" answers no for every click on the list itself and the first option a\n // user picked closed the dropdown without picking anything.\n const panelRef = useRef<HTMLDivElement>(null);\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n const target = e.target as Node;\n const inside =\n (wrapperRef.current?.contains(target) ?? false) ||\n (panelRef.current?.contains(target) ?? false);\n if (!inside) setOpen(false);\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n // Same close path Escape and an outside click take, so a list dismissed by Back\n // cannot end up in a different state from one dismissed any other way.\n useOverlayHistory(open && backCloses, useCallback(() => setOpen(false), []));\n return { open, setOpen, wrapperRef, panelRef };\n}\n\n/**\n * {@link useDropdown} plus a search box: clears the query and focuses the search\n * input each time the panel opens, so typing filters immediately.\n */\nexport function useDropdownSearch<T extends HTMLElement = HTMLDivElement>() {\n const { open, setOpen, wrapperRef, panelRef } = useDropdown<T>();\n const [query, setQuery] = useState(\"\");\n const inputRef = useRef<HTMLInputElement>(null);\n useEffect(() => {\n if (open) {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reset the query on open\n setQuery(\"\");\n requestAnimationFrame(() => inputRef.current?.focus());\n }\n }, [open]);\n return { open, setOpen, wrapperRef, panelRef, query, setQuery, inputRef };\n}\n\n/** The search row (magnifier + text input) shared by the searchable dropdowns. */\nexport function DropdownSearchHeader({\n query,\n onQueryChange,\n inputRef,\n placeholder,\n}: {\n query: string;\n onQueryChange: (v: string) => void;\n inputRef: RefObject<HTMLInputElement | null>;\n placeholder?: string;\n}) {\n return (\n <div className=\"flex items-center gap-2 border-b border-slate-100 dark:border-slate-800 px-2 py-1.5\">\n <Search className=\"size-4 text-slate-400 dark:text-slate-500\" />\n <input\n ref={inputRef}\n type=\"text\"\n value={query}\n onChange={(e) => onQueryChange(e.target.value)}\n placeholder={placeholder}\n className=\"w-full bg-transparent text-sm outline-none placeholder:text-slate-400 dark:text-slate-100 dark:placeholder:text-slate-500\"\n />\n </div>\n );\n}\n\n/**\n * The floating popover surface for the searchable dropdowns: bordered/shadowed\n * panel, an optional `header` slot (search box, plus any actions), and a\n * scrollable `<ul>` of `children` with an \"empty\" row when nothing matches.\n *\n * ## Anchored, or merely absolute\n *\n * Without `anchorRef` the panel is `position: absolute` inside whatever relative box\n * the caller put it in, and the caller places it with `className` (`w-64`,\n * `right-0 top-full`). That is fine in a page that does not scroll around it, and\n * wrong the moment an ancestor has `overflow` — the panel is then CLIPPED by that\n * ancestor's box, with no error and no scrollbar, just a list with its side sliced\n * off. Keksdose dev#548 is what that looks like in practice: the app's own content\n * scroller starts 280px from the left, the amount field's currency picker opens\n * right-aligned and 256px wide, and in the transaction row editor its left third —\n * the search box's first letter, and every row's currency symbol — was cut away.\n *\n * With `anchorRef` the panel is PORTALLED to `<body>` and positioned `fixed` against\n * that trigger's rect, so no ancestor can clip it: the same treatment {@link Popover}\n * already gives the calculator, and the same reason. It re-aligns on scroll and\n * resize, flips above the trigger when the room is there instead (a phone keyboard\n * eats the bottom of the screen), and is clamped to the viewport's edges so a trigger\n * near either side cannot push it off-screen.\n *\n * Pass `panelRef` from {@link useDropdown} along with it: once portalled the panel is\n * no longer inside the wrapper, so the outside-click handler has to be told about it\n * or the first click on an option closes the list instead of choosing from it.\n */\nexport function DropdownPanel({\n header,\n empty,\n className,\n anchorRef,\n panelRef,\n width = 256,\n align = \"right\",\n children,\n}: {\n header?: ReactNode;\n empty?: boolean;\n className?: string;\n /** The trigger to hang off. Given, the panel portals and goes `fixed`. */\n anchorRef?: RefObject<HTMLElement | null>;\n /** {@link useDropdown}'s `panelRef`, so an outside-click sees this as inside. */\n panelRef?: RefObject<HTMLDivElement | null>;\n /** Panel width in px, for the anchored form (the CSS `w-*` cannot be measured). */\n width?: number;\n /** Which of the panel's edges lines up with the trigger's, room permitting. */\n align?: \"left\" | \"right\";\n children: ReactNode;\n}) {\n const anchored = useAnchoredPanel(anchorRef ?? EMPTY_REF, Boolean(anchorRef));\n const body = (\n <>\n {header}\n {/* The anchored form caps its own height against the VISIBLE viewport and lets\n this list have the rest; the absolute form keeps the fixed `max-h-64` it has\n always had, applied by the wrapper below. */}\n <ul className=\"overflow-y-auto py-1\">\n {children}\n {empty && <li className=\"px-3 py-2 text-sm text-slate-500 dark:text-slate-400\">—</li>}\n </ul>\n </>\n );\n\n if (!anchorRef) {\n return (\n <div\n ref={panelRef}\n className={cn(\n \"absolute z-30 mt-1 rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\",\n \"[&>ul]:max-h-64\",\n className,\n )}\n >\n {body}\n </div>\n );\n }\n\n const rect = anchored.rect;\n if (!rect) return null;\n // Clamped exactly as `Popover` clamps: the panel may leave the trigger's edge to\n // stay on screen, because a list half off the viewport is the bug this exists for.\n const wanted = align === \"right\" ? rect.right - width : rect.left;\n const left = Math.min(Math.max(8, wanted), window.innerWidth - width - 8);\n return createPortal(\n <div\n ref={panelRef}\n style={{ position: \"fixed\", top: anchored.top, left, width, maxHeight: anchored.maxHeight }}\n className={cn(\n \"z-50 flex flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\",\n // The list, not the panel, is what scrolls: the search header has to stay put\n // while the options move under it.\n \"[&>ul]:min-h-0 [&>ul]:flex-1\",\n className,\n )}\n >\n {body}\n </div>,\n document.body,\n );\n}\n\n/** A ref that is always null, for the un-anchored form — hooks may not be conditional. */\nconst EMPTY_REF: RefObject<HTMLElement | null> = { current: null };\n"],"mappings":"AA0FI,SAmEA,UAlEE,KADF;AA1FJ,SAAS,aAAa,WAAW,QAAQ,gBAAgB;AAEzD,SAAS,oBAAoB;AAC7B,SAAS,cAAc;AACvB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,UAAU;AAyBZ,SAAS,YAAoD;AAAA,EAClE,aAAa;AACf,IAA8B,CAAC,GAAG;AAChC,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,aAAa,OAAU,IAAI;AAKjC,QAAM,WAAW,OAAuB,IAAI;AAC5C,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,UAAU,CAAC,MAAkB;AACjC,YAAM,SAAS,EAAE;AACjB,YAAM,UACH,WAAW,SAAS,SAAS,MAAM,KAAK,WACxC,SAAS,SAAS,SAAS,MAAM,KAAK;AACzC,UAAI,CAAC,OAAQ,SAAQ,KAAK;AAAA,IAC5B;AACA,aAAS,iBAAiB,aAAa,OAAO;AAC9C,WAAO,MAAM,SAAS,oBAAoB,aAAa,OAAO;AAAA,EAChE,GAAG,CAAC,IAAI,CAAC;AAGT,oBAAkB,QAAQ,YAAY,YAAY,MAAM,QAAQ,KAAK,GAAG,CAAC,CAAC,CAAC;AAC3E,SAAO,EAAE,MAAM,SAAS,YAAY,SAAS;AAC/C;AAMO,SAAS,oBAA4D;AAC1E,QAAM,EAAE,MAAM,SAAS,YAAY,SAAS,IAAI,YAAe;AAC/D,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,EAAE;AACrC,QAAM,WAAW,OAAyB,IAAI;AAC9C,YAAU,MAAM;AACd,QAAI,MAAM;AAER,eAAS,EAAE;AACX,4BAAsB,MAAM,SAAS,SAAS,MAAM,CAAC;AAAA,IACvD;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AACT,SAAO,EAAE,MAAM,SAAS,YAAY,UAAU,OAAO,UAAU,SAAS;AAC1E;AAGO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAKG;AACD,SACE,qBAAC,SAAI,WAAU,uFACb;AAAA,wBAAC,UAAO,WAAU,6CAA4C;AAAA,IAC9D;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,OAAO;AAAA,QACP,UAAU,CAAC,MAAM,cAAc,EAAE,OAAO,KAAK;AAAA,QAC7C;AAAA,QACA,WAAU;AAAA;AAAA,IACZ;AAAA,KACF;AAEJ;AA8BO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR;AACF,GAaG;AACD,QAAM,WAAW,iBAAiB,aAAa,WAAW,QAAQ,SAAS,CAAC;AAC5E,QAAM,OACJ,iCACG;AAAA;AAAA,IAID,qBAAC,QAAG,WAAU,wBACX;AAAA;AAAA,MACA,SAAS,oBAAC,QAAG,WAAU,wDAAuD,oBAAC;AAAA,OAClF;AAAA,KACF;AAGF,MAAI,CAAC,WAAW;AACd,WACE;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QAEC;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,QAAM,OAAO,SAAS;AACtB,MAAI,CAAC,KAAM,QAAO;AAGlB,QAAM,SAAS,UAAU,UAAU,KAAK,QAAQ,QAAQ,KAAK;AAC7D,QAAM,OAAO,KAAK,IAAI,KAAK,IAAI,GAAG,MAAM,GAAG,OAAO,aAAa,QAAQ,CAAC;AACxE,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,OAAO,EAAE,UAAU,SAAS,KAAK,SAAS,KAAK,MAAM,OAAO,WAAW,SAAS,UAAU;AAAA,QAC1F,WAAW;AAAA,UACT;AAAA;AAAA;AAAA,UAGA;AAAA,UACA;AAAA,QACF;AAAA,QAEC;AAAA;AAAA,IACH;AAAA,IACA,SAAS;AAAA,EACX;AACF;AAGA,MAAM,YAA2C,EAAE,SAAS,KAAK;","names":[]}
@@ -0,0 +1,49 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ComboOption } from './combobox-core.js';
4
+ import '../hooks/use-anchored-rect.js';
5
+ import '../hooks/use-anchored-panel.js';
6
+
7
+ interface EntityComboboxProps<V extends string | number> {
8
+ /** Selected id, or null/undefined when nothing is selected. */
9
+ value: V | null | undefined;
10
+ /** Selecting an option emits its value; the clear button emits `null`. */
11
+ onChange: (value: V | null) => void;
12
+ /** Already-loaded options (client-side filtered). Also used to resolve the
13
+ * trigger label for the current `value`. */
14
+ options?: ComboOption<V>[];
15
+ /** Async option source, debounced and called on open + as the query changes.
16
+ * Stale responses are ignored. When set, `options` is used only for label
17
+ * resolution, not as the result list. */
18
+ loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
19
+ /** External loading flag (OR-ed with the internal async state). */
20
+ loading?: boolean;
21
+ label?: ReactNode;
22
+ placeholder?: string;
23
+ searchPlaceholder?: string;
24
+ emptyLabel?: string;
25
+ clearable?: boolean;
26
+ clearLabel?: string;
27
+ /** The phone sheet's close button. Its own prop rather than a reuse of
28
+ * `clearLabel`: "clear the selection" and "close this screen" are different
29
+ * actions, and on a full-screen sheet the close button is the only way out —
30
+ * so it is the one control here that MUST be in the reader's language. */
31
+ closeLabel?: string;
32
+ disabled?: boolean;
33
+ /** When set, a "create" row appears for a non-empty query with no exact match. */
34
+ onCreate?: (query: string) => void;
35
+ createLabel?: (query: string) => string;
36
+ className?: string;
37
+ /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
38
+ invalid?: boolean;
39
+ }
40
+ /**
41
+ * An id-keyed, searchable entity picker: a field-styled trigger showing the
42
+ * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`
43
+ * options — loaded up front via `options` or lazily via `loadOptions`. Built on
44
+ * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking
45
+ * several entities use {@link MultiEntityCombobox}.
46
+ */
47
+ declare function EntityCombobox<V extends string | number>({ value, onChange, options, loadOptions, loading, label, placeholder, searchPlaceholder, emptyLabel, clearable, clearLabel, closeLabel, disabled, onCreate, createLabel, className, invalid, }: EntityComboboxProps<V>): react.JSX.Element;
48
+
49
+ export { ComboOption, EntityCombobox, type EntityComboboxProps };
@@ -0,0 +1,115 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { X } from "lucide-react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui.js";
5
+ import { ComboboxPanel, useComboboxCore } from "./combobox-core.js";
6
+ function EntityCombobox({
7
+ value,
8
+ onChange,
9
+ options,
10
+ loadOptions,
11
+ loading,
12
+ label,
13
+ placeholder,
14
+ searchPlaceholder = "Search",
15
+ emptyLabel = "No results",
16
+ clearable,
17
+ clearLabel,
18
+ closeLabel,
19
+ disabled,
20
+ onCreate,
21
+ createLabel,
22
+ className,
23
+ invalid
24
+ }) {
25
+ const core = useComboboxCore({ options, loadOptions, loading });
26
+ const { results, resolve, setOpen } = core;
27
+ const selectedOption = value == null ? null : resolve(value);
28
+ const q = core.query.trim();
29
+ const showCreate = Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
30
+ const showClear = Boolean(clearable && value != null && !disabled);
31
+ const triggerText = selectedOption?.label ?? placeholder ?? "";
32
+ const choose = (o) => {
33
+ core.cacheRef.current.set(o.value, o);
34
+ onChange(o.value);
35
+ setOpen(false);
36
+ };
37
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
38
+ label !== void 0 && /* @__PURE__ */ jsx(FieldLabel, { children: label }),
39
+ /* @__PURE__ */ jsxs(
40
+ "button",
41
+ {
42
+ ref: core.triggerRef,
43
+ type: "button",
44
+ "aria-label": typeof label === "string" ? `${label}: ${triggerText}` : void 0,
45
+ disabled,
46
+ "aria-invalid": invalid || void 0,
47
+ onClick: () => !disabled && setOpen((o) => !o),
48
+ className: cn(
49
+ FIELD_TRIGGER,
50
+ "pr-9",
51
+ label !== void 0 && FIELD_FLOATING_PAD,
52
+ disabled && "cursor-not-allowed opacity-50",
53
+ invalid && FIELD_INVALID
54
+ ),
55
+ children: [
56
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [
57
+ selectedOption?.icon && /* @__PURE__ */ jsx("span", { className: "shrink-0", children: selectedOption.icon }),
58
+ /* @__PURE__ */ jsx(
59
+ "span",
60
+ {
61
+ className: cn(
62
+ "truncate",
63
+ // A chosen value is the field's VALUE, so it is set in the same ink an
64
+ // <input>'s value is — FIELD_BASE's own text colour, inherited rather
65
+ // than restated (Keksdose dev#477). It used to be one notch lighter
66
+ // than the typeahead fields beside it, which is visible when a picker
67
+ // and a text field share a form row. Nothing selected keeps the
68
+ // placeholder grey, which every field here agrees on.
69
+ !selectedOption && "text-slate-400 dark:text-slate-500"
70
+ ),
71
+ children: selectedOption?.label ?? placeholder ?? ""
72
+ }
73
+ )
74
+ ] }),
75
+ showClear ? /* @__PURE__ */ jsx(
76
+ "span",
77
+ {
78
+ role: "button",
79
+ tabIndex: -1,
80
+ "aria-label": clearLabel ?? "Clear",
81
+ onClick: (e) => {
82
+ e.stopPropagation();
83
+ onChange(null);
84
+ },
85
+ className: "absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300",
86
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
87
+ }
88
+ ) : /* @__PURE__ */ jsx(FieldChevron, {})
89
+ ]
90
+ }
91
+ ),
92
+ /* @__PURE__ */ jsx(
93
+ ComboboxPanel,
94
+ {
95
+ core,
96
+ sheetTitle: label ?? placeholder,
97
+ searchPlaceholder,
98
+ emptyLabel,
99
+ closeLabel,
100
+ isSelected: (v) => v === value,
101
+ onChoose: choose,
102
+ showCreate,
103
+ onCreate: () => {
104
+ onCreate?.(q);
105
+ setOpen(false);
106
+ },
107
+ createContent: createLabel ? createLabel(q) : `Create \u201C${q}\u201D`
108
+ }
109
+ )
110
+ ] });
111
+ }
112
+ export {
113
+ EntityCombobox
114
+ };
115
+ //# sourceMappingURL=entity-combobox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./ui\";\nimport { ComboboxPanel, useComboboxCore, type ComboOption } from \"./combobox-core\";\n\nexport type { ComboOption } from \"./combobox-core\";\n\nexport interface EntityComboboxProps<V extends string | number> {\n /** Selected id, or null/undefined when nothing is selected. */\n value: V | null | undefined;\n /** Selecting an option emits its value; the clear button emits `null`. */\n onChange: (value: V | null) => void;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes.\n * Stale responses are ignored. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n disabled?: boolean;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n className?: string;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number>({\n value,\n onChange,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder = \"Search\",\n emptyLabel = \"No results\",\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n}: EntityComboboxProps<V>) {\n const core = useComboboxCore<V>({ options, loadOptions, loading });\n const { results, resolve, setOpen } = core;\n\n const selectedOption = value == null ? null : resolve(value);\n const q = core.query.trim();\n const showCreate =\n Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());\n const showClear = Boolean(clearable && value != null && !disabled);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n\n const choose = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n onChange(o.value);\n setOpen(false);\n };\n\n return (\n <div className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n ref={core.triggerRef}\n type=\"button\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n aria-label={\n typeof label === \"string\" ? `${label}: ${triggerText}` : undefined\n }\n disabled={disabled}\n aria-invalid={invalid || undefined}\n onClick={() => !disabled && setOpen((o) => !o)}\n className={cn(\n FIELD_TRIGGER,\n \"pr-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n disabled && \"cursor-not-allowed opacity-50\",\n invalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n \"truncate\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder grey, which every field here agrees on.\n !selectedOption && \"text-slate-400 dark:text-slate-500\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={clearLabel ?? \"Clear\"}\n onClick={(e) => {\n e.stopPropagation();\n onChange(null);\n }}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>\n <ComboboxPanel\n core={core}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={label ?? placeholder}\n searchPlaceholder={searchPlaceholder}\n emptyLabel={emptyLabel}\n closeLabel={closeLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n setOpen(false);\n }}\n createContent={createLabel ? createLabel(q) : `Create “${q}”`}\n />\n </div>\n );\n}\n"],"mappings":"AAuF8B,cAuBtB,YAvBsB;AAtF9B,SAAS,SAAS;AAClB,SAAS,UAAU;AACnB,SAAS,cAAc,YAAY,eAAe,oBAAoB,qBAAqB;AAC3F,SAAS,eAAe,uBAAyC;AA6C1D,SAAS,eAA0C;AAAA,EACxD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AACzB,QAAM,OAAO,gBAAmB,EAAE,SAAS,aAAa,QAAQ,CAAC;AACjE,QAAM,EAAE,SAAS,SAAS,QAAQ,IAAI;AAEtC,QAAM,iBAAiB,SAAS,OAAO,OAAO,QAAQ,KAAK;AAC3D,QAAM,IAAI,KAAK,MAAM,KAAK;AAC1B,QAAM,aACJ,QAAQ,QAAQ,KAAK,EAAE,SAAS,KAAK,CAAC,QAAQ,KAAK,CAAC,MAAM,EAAE,MAAM,YAAY,MAAM,EAAE,YAAY,CAAC;AACrG,QAAM,YAAY,QAAQ,aAAa,SAAS,QAAQ,CAAC,QAAQ;AAEjE,QAAM,cAAc,gBAAgB,SAAS,eAAe;AAE5D,QAAM,SAAS,CAAC,MAAsB;AACpC,SAAK,SAAS,QAAQ,IAAI,EAAE,OAAO,CAAC;AACpC,aAAS,EAAE,KAAK;AAChB,YAAQ,KAAK;AAAA,EACf;AAEA,SACE,qBAAC,SAAI,WAAW,GAAG,YAAY,SAAS,GACrC;AAAA,cAAU,UAAa,oBAAC,cAAY,iBAAM;AAAA,IAC3C;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,KAAK;AAAA,QACV,MAAK;AAAA,QAML,cACE,OAAO,UAAU,WAAW,GAAG,KAAK,KAAK,WAAW,KAAK;AAAA,QAE3D;AAAA,QACA,gBAAc,WAAW;AAAA,QACzB,SAAS,MAAM,CAAC,YAAY,QAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,QAC7C,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA,UAAU,UAAa;AAAA,UACvB,YAAY;AAAA,UACZ,WAAW;AAAA,QACb;AAAA,QAEA;AAAA,+BAAC,UAAK,WAAU,mCACb;AAAA,4BAAgB,QAAQ,oBAAC,UAAK,WAAU,YAAY,yBAAe,MAAK;AAAA,YACzE;AAAA,cAAC;AAAA;AAAA,gBACC,WAAW;AAAA,kBACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAOA,CAAC,kBAAkB;AAAA,gBACrB;AAAA,gBAEC,0BAAgB,SAAS,eAAe;AAAA;AAAA,YAC3C;AAAA,aACF;AAAA,UACC,YACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,UAAU;AAAA,cACV,cAAY,cAAc;AAAA,cAC1B,SAAS,CAAC,MAAM;AACd,kBAAE,gBAAgB;AAClB,yBAAS,IAAI;AAAA,cACf;AAAA,cACA,WAAU;AAAA,cAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,UACxB,IAEA,oBAAC,gBAAa;AAAA;AAAA;AAAA,IAElB;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QAGA,YAAY,SAAS;AAAA,QACrB;AAAA,QACA;AAAA,QACA;AAAA,QACA,YAAY,CAAC,MAAM,MAAM;AAAA,QACzB,UAAU;AAAA,QACV;AAAA,QACA,UAAU,MAAM;AACd,qBAAW,CAAC;AACZ,kBAAQ,KAAK;AAAA,QACf;AAAA,QACA,eAAe,cAAc,YAAY,CAAC,IAAI,gBAAW,CAAC;AAAA;AAAA,IAC5D;AAAA,KACF;AAEJ;","names":[]}
@@ -0,0 +1,18 @@
1
+ import * as react from 'react';
2
+
3
+ /** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).
4
+ * Validation failures toast `invalidMessage`; the chosen file is echoed with
5
+ * its size, otherwise `emptyLabel` + `hint` describe what to drop. */
6
+ declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMessage, dropLabel, browseLabel, emptyLabel, hint, }: {
7
+ file: File | null;
8
+ onFileSelected: (file: File) => void;
9
+ accept: string;
10
+ isValid: (file: File) => boolean;
11
+ invalidMessage: string;
12
+ dropLabel: string;
13
+ browseLabel: string;
14
+ emptyLabel: string;
15
+ hint: string;
16
+ }): react.JSX.Element;
17
+
18
+ export { FileDropzone };
@@ -0,0 +1,101 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { Upload } from "lucide-react";
5
+ import { Button } from "./ui.js";
6
+ import { cn } from "../lib/cn.js";
7
+ function FileDropzone({
8
+ file,
9
+ onFileSelected,
10
+ accept,
11
+ isValid,
12
+ invalidMessage,
13
+ dropLabel,
14
+ browseLabel,
15
+ emptyLabel,
16
+ hint
17
+ }) {
18
+ const [dragOver, setDragOver] = useState(false);
19
+ const fileInputRef = useRef(null);
20
+ const acceptFile = (f) => {
21
+ if (!f) return;
22
+ if (!isValid(f)) {
23
+ toast.error(invalidMessage);
24
+ return;
25
+ }
26
+ onFileSelected(f);
27
+ };
28
+ return /* @__PURE__ */ jsxs(
29
+ "div",
30
+ {
31
+ onDragEnter: (e) => {
32
+ e.preventDefault();
33
+ e.stopPropagation();
34
+ if (e.dataTransfer.types.includes("Files")) setDragOver(true);
35
+ },
36
+ onDragOver: (e) => {
37
+ e.preventDefault();
38
+ e.stopPropagation();
39
+ e.dataTransfer.dropEffect = "copy";
40
+ if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
41
+ },
42
+ onDragLeave: (e) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+ if (e.currentTarget.contains(e.relatedTarget)) return;
46
+ setDragOver(false);
47
+ },
48
+ onDrop: (e) => {
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+ setDragOver(false);
52
+ acceptFile(e.dataTransfer.files?.[0]);
53
+ },
54
+ onClick: () => fileInputRef.current?.click(),
55
+ onKeyDown: (e) => {
56
+ if (e.key === "Enter" || e.key === " ") {
57
+ e.preventDefault();
58
+ fileInputRef.current?.click();
59
+ }
60
+ },
61
+ role: "button",
62
+ tabIndex: 0,
63
+ "aria-label": dropLabel,
64
+ className: cn(
65
+ "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
66
+ dragOver ? "border-slate-500 bg-slate-100 dark:border-slate-300 dark:bg-slate-800" : "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-500"
67
+ ),
68
+ children: [
69
+ /* @__PURE__ */ jsx(Upload, { className: "size-5 text-slate-500 dark:text-slate-400" }),
70
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-slate-700 dark:text-slate-200", children: file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel }),
71
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-slate-500 dark:text-slate-400", children: file ? `${Math.round(file.size / 1024)} KB` : hint }),
72
+ /* @__PURE__ */ jsx(
73
+ Button,
74
+ {
75
+ type: "button",
76
+ variant: "secondary",
77
+ onClick: (e) => {
78
+ e.stopPropagation();
79
+ fileInputRef.current?.click();
80
+ },
81
+ children: browseLabel
82
+ }
83
+ ),
84
+ /* @__PURE__ */ jsx(
85
+ "input",
86
+ {
87
+ ref: fileInputRef,
88
+ type: "file",
89
+ accept,
90
+ onChange: (e) => acceptFile(e.target.files?.[0]),
91
+ className: "sr-only"
92
+ }
93
+ )
94
+ ]
95
+ }
96
+ );
97
+ }
98
+ export {
99
+ FileDropzone
100
+ };
101
+ //# sourceMappingURL=file-dropzone.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).\n * Validation failures toast `invalidMessage`; the chosen file is echoed with\n * its size, otherwise `emptyLabel` + `hint` describe what to drop. */\nexport function FileDropzone({\n file,\n onFileSelected,\n accept,\n isValid,\n invalidMessage,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n}: {\n file: File | null;\n onFileSelected: (file: File) => void;\n accept: string;\n isValid: (file: File) => boolean;\n invalidMessage: string;\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n}) {\n const [dragOver, setDragOver] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const acceptFile = (f: File | undefined | null) => {\n if (!f) return;\n if (!isValid(f)) {\n toast.error(invalidMessage);\n return;\n }\n onFileSelected(f);\n };\n\n return (\n <div\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFile(e.dataTransfer.files?.[0]);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n aria-label={dropLabel}\n className={cn(\n \"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n dragOver\n ? \"border-slate-500 bg-slate-100 dark:border-slate-300 dark:bg-slate-800\"\n : \"border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-500\",\n )}\n >\n <Upload className=\"size-5 text-slate-500 dark:text-slate-400\" />\n <div className=\"text-sm text-slate-700 dark:text-slate-200\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n </div>\n <div className=\"text-xs text-slate-500 dark:text-slate-400\">\n {file ? `${Math.round(file.size / 1024)} KB` : hint}\n </div>\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n onChange={(e) => acceptFile(e.target.files?.[0])}\n className=\"sr-only\"\n />\n </div>\n );\n}\n"],"mappings":"AA2CI,SAyCE,KAzCF;AA3CJ,SAAS,QAAQ,gBAAgB;AACjC,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,cAAc;AACvB,SAAS,UAAU;AAKZ,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUG;AACD,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,KAAK;AAC9C,QAAM,eAAe,OAAyB,IAAI;AAElD,QAAM,aAAa,CAAC,MAA+B;AACjD,QAAI,CAAC,EAAG;AACR,QAAI,CAAC,QAAQ,CAAC,GAAG;AACf,YAAM,MAAM,cAAc;AAC1B;AAAA,IACF;AACA,mBAAe,CAAC;AAAA,EAClB;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAa,CAAC,MAAM;AAClB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,YAAI,EAAE,aAAa,MAAM,SAAS,OAAO,EAAG,aAAY,IAAI;AAAA,MAC9D;AAAA,MACA,YAAY,CAAC,MAAM;AACjB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,UAAE,aAAa,aAAa;AAC5B,YAAI,CAAC,YAAY,EAAE,aAAa,MAAM,SAAS,OAAO,EAAG,aAAY,IAAI;AAAA,MAC3E;AAAA,MACA,aAAa,CAAC,MAAM;AAClB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,YAAI,EAAE,cAAc,SAAS,EAAE,aAAqB,EAAG;AACvD,oBAAY,KAAK;AAAA,MACnB;AAAA,MACA,QAAQ,CAAC,MAAM;AACb,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,oBAAY,KAAK;AACjB,mBAAW,EAAE,aAAa,QAAQ,CAAC,CAAC;AAAA,MACtC;AAAA,MACA,SAAS,MAAM,aAAa,SAAS,MAAM;AAAA,MAC3C,WAAW,CAAC,MAAM;AAChB,YAAI,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAK;AACtC,YAAE,eAAe;AACjB,uBAAa,SAAS,MAAM;AAAA,QAC9B;AAAA,MACF;AAAA,MACA,MAAK;AAAA,MACL,UAAU;AAAA,MACV,cAAY;AAAA,MACZ,WAAW;AAAA,QACT;AAAA,QACA,WACI,0EACA;AAAA,MACN;AAAA,MAEA;AAAA,4BAAC,UAAO,WAAU,6CAA4C;AAAA,QAC9D,oBAAC,SAAI,WAAU,8CACZ,iBAAO,oBAAC,UAAK,WAAU,eAAe,eAAK,MAAK,IAAU,YAC7D;AAAA,QACA,oBAAC,SAAI,WAAU,8CACZ,iBAAO,GAAG,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC,QAAQ,MACjD;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,SAAS,CAAC,MAAM;AACd,gBAAE,gBAAgB;AAClB,2BAAa,SAAS,MAAM;AAAA,YAC9B;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL;AAAA,YACA,UAAU,CAAC,MAAM,WAAW,EAAE,OAAO,QAAQ,CAAC,CAAC;AAAA,YAC/C,WAAU;AAAA;AAAA,QACZ;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -0,0 +1,57 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * The phone's full-screen dialog: a panel that covers the viewport edge to edge.
6
+ *
7
+ * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback
8
+ * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,
9
+ * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is
10
+ * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any
11
+ * layout that does leave a backdrop visible, and costs nothing where none is.
12
+ *
13
+ * ## Why it is a component
14
+ *
15
+ * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose
16
+ * live #307's follow-up needed the same shell for the transactions CREATE card —
17
+ * *"Is the creation window of the tx still different than the edit window? Since the tx
18
+ * list still appears below when scrolling down?"* — and the two answers to that are
19
+ * either one component or two copies that drift. Every previous convergence in that app
20
+ * drifted the moment a second surface was written beside the first, which is what
21
+ * `field-layer-parity` exists to catch one layer down.
22
+ *
23
+ * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back
24
+ * handling are all here, once.
25
+ *
26
+ * ## What the caller still owns
27
+ *
28
+ * The HEADER's content, because the two callers name themselves differently: the row
29
+ * editor shows the row's own first column, the create card a plain title. And `open`,
30
+ * because who decides is the caller's business — see {@link backCloses}.
31
+ */
32
+ declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, }: {
33
+ open: boolean;
34
+ /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
35
+ * the platform Back gesture all call this. */
36
+ onClose: () => void;
37
+ /** What the header strip shows beside the close button. */
38
+ header?: ReactNode;
39
+ closeLabel: string;
40
+ children: ReactNode;
41
+ /**
42
+ * Push a history entry so Back dismisses this dialog instead of navigating
43
+ * (Keksdose feedback #172). On by default, because a full-screen form that ignores
44
+ * the phone's universal "go back" gesture does not merely fail to close — the
45
+ * navigation lands somewhere else with the form still notionally open.
46
+ *
47
+ * **Off for a caller whose open state is already in the URL.** The transactions
48
+ * create card is `?action=new`, so Back already pops it through the router; a
49
+ * sentinel on top of that would cost two presses to close one dialog. One owner of
50
+ * the Back press, always.
51
+ */
52
+ backCloses?: boolean;
53
+ /** Extra classes for the PANEL (not the backdrop). */
54
+ className?: string;
55
+ }): react.ReactPortal | null;
56
+
57
+ export { FullBleedDialog };