@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.
- package/README.md +219 -0
- package/dist/components/account-settings.d.ts +71 -0
- package/dist/components/account-settings.js +159 -0
- package/dist/components/account-settings.js.map +1 -0
- package/dist/components/alert-banner.d.ts +20 -0
- package/dist/components/alert-banner.js +39 -0
- package/dist/components/alert-banner.js.map +1 -0
- package/dist/components/amount-input.d.ts +117 -0
- package/dist/components/amount-input.js +243 -0
- package/dist/components/amount-input.js.map +1 -0
- package/dist/components/calculator.d.ts +42 -0
- package/dist/components/calculator.js +161 -0
- package/dist/components/calculator.js.map +1 -0
- package/dist/components/chart.d.ts +58 -0
- package/dist/components/chart.js +158 -0
- package/dist/components/chart.js.map +1 -0
- package/dist/components/combobox-core.d.ts +77 -0
- package/dist/components/combobox-core.js +263 -0
- package/dist/components/combobox-core.js.map +1 -0
- package/dist/components/combobox.d.ts +141 -0
- package/dist/components/combobox.js +550 -0
- package/dist/components/combobox.js.map +1 -0
- package/dist/components/currency-select.d.ts +37 -0
- package/dist/components/currency-select.js +131 -0
- package/dist/components/currency-select.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +28 -0
- package/dist/components/data-table-filter-popover.js +201 -0
- package/dist/components/data-table-filter-popover.js.map +1 -0
- package/dist/components/data-table-filters.d.ts +5 -0
- package/dist/components/data-table-filters.js +118 -0
- package/dist/components/data-table-filters.js.map +1 -0
- package/dist/components/data-table-labels.d.ts +33 -0
- package/dist/components/data-table-labels.js +53 -0
- package/dist/components/data-table-labels.js.map +1 -0
- package/dist/components/data-table-pagination.d.ts +18 -0
- package/dist/components/data-table-pagination.js +92 -0
- package/dist/components/data-table-pagination.js.map +1 -0
- package/dist/components/data-table-sort.d.ts +23 -0
- package/dist/components/data-table-sort.js +55 -0
- package/dist/components/data-table-sort.js.map +1 -0
- package/dist/components/data-table.d.ts +5 -0
- package/dist/components/data-table.js +926 -0
- package/dist/components/data-table.js.map +1 -0
- package/dist/components/date-picker.d.ts +85 -0
- package/dist/components/date-picker.js +253 -0
- package/dist/components/date-picker.js.map +1 -0
- package/dist/components/dropdown.d.ts +98 -0
- package/dist/components/dropdown.js +122 -0
- package/dist/components/dropdown.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +49 -0
- package/dist/components/entity-combobox.js +115 -0
- package/dist/components/entity-combobox.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +18 -0
- package/dist/components/file-dropzone.js +101 -0
- package/dist/components/file-dropzone.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +57 -0
- package/dist/components/full-bleed-dialog.js +68 -0
- package/dist/components/full-bleed-dialog.js.map +1 -0
- package/dist/components/grouped-picker.d.ts +37 -0
- package/dist/components/grouped-picker.js +87 -0
- package/dist/components/grouped-picker.js.map +1 -0
- package/dist/components/hover-menu.d.ts +16 -0
- package/dist/components/hover-menu.js +113 -0
- package/dist/components/hover-menu.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +33 -0
- package/dist/components/mini-calendar.js +133 -0
- package/dist/components/mini-calendar.js.map +1 -0
- package/dist/components/modal.d.ts +76 -0
- package/dist/components/modal.js +156 -0
- package/dist/components/modal.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +47 -0
- package/dist/components/multi-entity-combobox.js +116 -0
- package/dist/components/multi-entity-combobox.js.map +1 -0
- package/dist/components/multi-select.d.ts +39 -0
- package/dist/components/multi-select.js +132 -0
- package/dist/components/multi-select.js.map +1 -0
- package/dist/components/number-input.d.ts +74 -0
- package/dist/components/number-input.js +114 -0
- package/dist/components/number-input.js.map +1 -0
- package/dist/components/numpad-sheet.d.ts +45 -0
- package/dist/components/numpad-sheet.js +102 -0
- package/dist/components/numpad-sheet.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +44 -0
- package/dist/components/picker-sheet.js +78 -0
- package/dist/components/picker-sheet.js.map +1 -0
- package/dist/components/popover.d.ts +20 -0
- package/dist/components/popover.js +46 -0
- package/dist/components/popover.js.map +1 -0
- package/dist/components/search-field.d.ts +52 -0
- package/dist/components/search-field.js +64 -0
- package/dist/components/search-field.js.map +1 -0
- package/dist/components/settings-fields.d.ts +39 -0
- package/dist/components/settings-fields.js +17 -0
- package/dist/components/settings-fields.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +60 -0
- package/dist/components/swipeable-row.js +115 -0
- package/dist/components/swipeable-row.js.map +1 -0
- package/dist/components/toggle-group.d.ts +34 -0
- package/dist/components/toggle-group.js +90 -0
- package/dist/components/toggle-group.js.map +1 -0
- package/dist/components/tooltip.d.ts +86 -0
- package/dist/components/tooltip.js +171 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/components/ui.d.ts +239 -0
- package/dist/components/ui.js +512 -0
- package/dist/components/ui.js.map +1 -0
- package/dist/components/use-mobile-reveal.d.ts +31 -0
- package/dist/components/use-mobile-reveal.js +33 -0
- package/dist/components/use-mobile-reveal.js.map +1 -0
- package/dist/components/use-table-state.d.ts +54 -0
- package/dist/components/use-table-state.js +153 -0
- package/dist/components/use-table-state.js.map +1 -0
- package/dist/components/user-avatar.d.ts +23 -0
- package/dist/components/user-avatar.js +33 -0
- package/dist/components/user-avatar.js.map +1 -0
- package/dist/components/wizard-stepper.d.ts +14 -0
- package/dist/components/wizard-stepper.js +27 -0
- package/dist/components/wizard-stepper.js.map +1 -0
- package/dist/data-table-filters-aXyf0Xub.d.ts +261 -0
- package/dist/feedback/feedback-attachment.d.ts +63 -0
- package/dist/feedback/feedback-attachment.js +160 -0
- package/dist/feedback/feedback-attachment.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +72 -0
- package/dist/feedback/feedback-dialog.js +109 -0
- package/dist/feedback/feedback-dialog.js.map +1 -0
- package/dist/feedback/feedback-inbox.d.ts +226 -0
- package/dist/feedback/feedback-inbox.js +315 -0
- package/dist/feedback/feedback-inbox.js.map +1 -0
- package/dist/hooks/use-anchored-panel.d.ts +80 -0
- package/dist/hooks/use-anchored-panel.js +71 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -0
- package/dist/hooks/use-anchored-rect.d.ts +24 -0
- package/dist/hooks/use-anchored-rect.js +40 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.d.ts +9 -0
- package/dist/hooks/use-body-scroll-lock.js +41 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -0
- package/dist/hooks/use-close-transition.d.ts +41 -0
- package/dist/hooks/use-close-transition.js +36 -0
- package/dist/hooks/use-close-transition.js.map +1 -0
- package/dist/hooks/use-dismiss.d.ts +34 -0
- package/dist/hooks/use-dismiss.js +41 -0
- package/dist/hooks/use-dismiss.js.map +1 -0
- package/dist/hooks/use-media-query.d.ts +8 -0
- package/dist/hooks/use-media-query.js +18 -0
- package/dist/hooks/use-media-query.js.map +1 -0
- package/dist/hooks/use-overlay-history.d.ts +10 -0
- package/dist/hooks/use-overlay-history.js +99 -0
- package/dist/hooks/use-overlay-history.js.map +1 -0
- package/dist/hooks/use-row-swipe.d.ts +30 -0
- package/dist/hooks/use-row-swipe.js +128 -0
- package/dist/hooks/use-row-swipe.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +90 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/calc.d.ts +89 -0
- package/dist/lib/calc.js +180 -0
- package/dist/lib/calc.js.map +1 -0
- package/dist/lib/cn.d.ts +6 -0
- package/dist/lib/cn.js +7 -0
- package/dist/lib/cn.js.map +1 -0
- package/dist/lib/dates.d.ts +84 -0
- package/dist/lib/dates.js +108 -0
- package/dist/lib/dates.js.map +1 -0
- package/dist/lib/logger.d.ts +17 -0
- package/dist/lib/logger.js +30 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/safe-storage.d.ts +23 -0
- package/dist/lib/safe-storage.js +20 -0
- package/dist/lib/safe-storage.js.map +1 -0
- package/dist/search/command-palette.d.ts +71 -0
- package/dist/search/command-palette.js +198 -0
- package/dist/search/command-palette.js.map +1 -0
- package/dist/shell/app-shell.d.ts +64 -0
- package/dist/shell/app-shell.js +237 -0
- package/dist/shell/app-shell.js.map +1 -0
- package/dist/shell/option-switcher-menu.d.ts +24 -0
- package/dist/shell/option-switcher-menu.js +44 -0
- package/dist/shell/option-switcher-menu.js.map +1 -0
- package/dist/shell/role-switcher.d.ts +19 -0
- package/dist/shell/role-switcher.js +28 -0
- package/dist/shell/role-switcher.js.map +1 -0
- package/dist/shell/top-bar.d.ts +15 -0
- package/dist/shell/top-bar.js +29 -0
- package/dist/shell/top-bar.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +44 -0
- package/dist/shell/topbar-action-menu.js +49 -0
- package/dist/shell/topbar-action-menu.js.map +1 -0
- package/dist/shell/topbar-controls.d.ts +54 -0
- package/dist/shell/topbar-controls.js +152 -0
- package/dist/shell/topbar-controls.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +60 -0
- package/dist/theme/chart-palette.js +79 -0
- package/dist/theme/chart-palette.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +39 -0
- package/dist/theme/palette-presets.js +440 -0
- package/dist/theme/palette-presets.js.map +1 -0
- package/dist/theme/palette-store.d.ts +45 -0
- package/dist/theme/palette-store.js +52 -0
- package/dist/theme/palette-store.js.map +1 -0
- package/dist/theme/theme-store.d.ts +57 -0
- package/dist/theme/theme-store.js +96 -0
- package/dist/theme/theme-store.js.map +1 -0
- package/dist/tour/tour.d.ts +104 -0
- package/dist/tour/tour.js +351 -0
- package/dist/tour/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.d.ts +30 -0
- package/dist/wizard/stepper-nav.js +146 -0
- package/dist/wizard/stepper-nav.js.map +1 -0
- package/dist/wizard/types.d.ts +124 -0
- package/dist/wizard/types.js +24 -0
- package/dist/wizard/types.js.map +1 -0
- package/dist/wizard/use-wizard.d.ts +6 -0
- package/dist/wizard/use-wizard.js +222 -0
- package/dist/wizard/use-wizard.js.map +1 -0
- package/dist/wizard/validation.d.ts +23 -0
- package/dist/wizard/validation.js +13 -0
- package/dist/wizard/validation.js.map +1 -0
- package/dist/wizard/wizard-context.d.ts +19 -0
- package/dist/wizard/wizard-context.js +15 -0
- package/dist/wizard/wizard-context.js.map +1 -0
- package/dist/wizard/wizard-step.d.ts +11 -0
- package/dist/wizard/wizard-step.js +12 -0
- package/dist/wizard/wizard-step.js.map +1 -0
- package/dist/wizard/wizard-summary.d.ts +18 -0
- package/dist/wizard/wizard-summary.js +41 -0
- package/dist/wizard/wizard-summary.js.map +1 -0
- package/package.json +87 -0
- 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 };
|