@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,156 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
6
|
+
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
7
|
+
import { useCloseTransition } from "../hooks/use-close-transition.js";
|
|
8
|
+
function useDragOffset(enabled) {
|
|
9
|
+
const [offset, setOffset] = useState(null);
|
|
10
|
+
const from = useRef(null);
|
|
11
|
+
const onPointerMove = (e) => {
|
|
12
|
+
const start = from.current;
|
|
13
|
+
if (!start) return;
|
|
14
|
+
setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });
|
|
15
|
+
};
|
|
16
|
+
const onPointerUp = () => {
|
|
17
|
+
from.current = null;
|
|
18
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
19
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : void 0,
|
|
23
|
+
onPointerDown: !enabled ? void 0 : (e) => {
|
|
24
|
+
if (e.button !== 0 || window.innerWidth < 768) return;
|
|
25
|
+
const target = e.target;
|
|
26
|
+
if (target.closest("input,textarea,select,button,a,[role='button']")) return;
|
|
27
|
+
from.current = { x: e.clientX, y: e.clientY, ox: offset?.x ?? 0, oy: offset?.y ?? 0 };
|
|
28
|
+
window.addEventListener("pointermove", onPointerMove);
|
|
29
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function useBackdropClose(onClose) {
|
|
34
|
+
const pressStartedOnBackdrop = useRef(false);
|
|
35
|
+
return {
|
|
36
|
+
onMouseDown: (e) => {
|
|
37
|
+
pressStartedOnBackdrop.current = e.target === e.currentTarget;
|
|
38
|
+
},
|
|
39
|
+
onMouseUp: (e) => {
|
|
40
|
+
const started = pressStartedOnBackdrop.current;
|
|
41
|
+
pressStartedOnBackdrop.current = false;
|
|
42
|
+
if (started && e.target === e.currentTarget) onClose();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
47
|
+
function Modal({
|
|
48
|
+
onClose,
|
|
49
|
+
children,
|
|
50
|
+
size = "md",
|
|
51
|
+
className,
|
|
52
|
+
labelledBy,
|
|
53
|
+
onKeyDown,
|
|
54
|
+
fullBleed,
|
|
55
|
+
draggable
|
|
56
|
+
}) {
|
|
57
|
+
const panelRef = useRef(null);
|
|
58
|
+
const drag = useDragOffset(Boolean(draggable));
|
|
59
|
+
const { closing, requestClose } = useCloseTransition(onClose);
|
|
60
|
+
const backdropClose = useBackdropClose(requestClose);
|
|
61
|
+
useOverlayHistory(true, requestClose);
|
|
62
|
+
useBodyScrollLock(true);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const previouslyFocused = document.activeElement;
|
|
65
|
+
panelRef.current?.focus();
|
|
66
|
+
return () => {
|
|
67
|
+
previouslyFocused?.focus?.();
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
const handleKeyDown = (e) => {
|
|
71
|
+
onKeyDown?.(e);
|
|
72
|
+
if (e.defaultPrevented) return;
|
|
73
|
+
if (e.key === "Escape") {
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
requestClose();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (e.key !== "Tab") return;
|
|
79
|
+
const focusables = panelRef.current?.querySelectorAll(FOCUSABLE);
|
|
80
|
+
if (!focusables || focusables.length === 0) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
panelRef.current?.focus();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const first = focusables[0];
|
|
86
|
+
const last = focusables[focusables.length - 1];
|
|
87
|
+
const active = document.activeElement;
|
|
88
|
+
if (e.shiftKey && (active === first || active === panelRef.current)) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
last.focus();
|
|
91
|
+
} else if (!e.shiftKey && active === last) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
first.focus();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return createPortal(
|
|
97
|
+
/* @__PURE__ */ jsx(
|
|
98
|
+
"div",
|
|
99
|
+
{
|
|
100
|
+
className: cn(
|
|
101
|
+
// Live #320. The panel below rises only where it is bottom-anchored: from md up
|
|
102
|
+
// it is centred, and a centred box sliding up from off-screen reads as a
|
|
103
|
+
// different component arriving rather than as the same one settling.
|
|
104
|
+
closing ? "animate-overlay-out" : "animate-overlay",
|
|
105
|
+
"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
|
|
106
|
+
fullBleed ? "p-0 md:p-4" : "p-4"
|
|
107
|
+
),
|
|
108
|
+
...backdropClose,
|
|
109
|
+
children: /* @__PURE__ */ jsx(
|
|
110
|
+
"div",
|
|
111
|
+
{
|
|
112
|
+
ref: panelRef,
|
|
113
|
+
role: "dialog",
|
|
114
|
+
"aria-modal": "true",
|
|
115
|
+
"aria-labelledby": labelledBy,
|
|
116
|
+
tabIndex: -1,
|
|
117
|
+
onKeyDown: handleKeyDown,
|
|
118
|
+
onPointerDown: drag.onPointerDown,
|
|
119
|
+
style: drag.style,
|
|
120
|
+
className: cn(
|
|
121
|
+
closing ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
|
|
122
|
+
"w-full rounded-lg border border-slate-200 bg-white shadow-sm outline-none dark:border-slate-800 dark:bg-slate-900",
|
|
123
|
+
{ md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
|
|
124
|
+
// A panel taller than the screen has to scroll ITSELF. The backdrop is
|
|
125
|
+
// `fixed inset-0` and the body is scroll-locked while a dialog is open, so
|
|
126
|
+
// without this the overflow simply had nowhere to go: on a phone the panel
|
|
127
|
+
// is bottom-anchored, so it grew UPWARD past the top edge and the part that
|
|
128
|
+
// went past it could not be reached by any gesture (Keksdose live #254, on
|
|
129
|
+
// the price-merge dialog — but it was every dialog with more than a screen
|
|
130
|
+
// of content). `max-h-full` resolves against the backdrop's content box,
|
|
131
|
+
// which is the viewport minus its own padding, so the panel stops exactly
|
|
132
|
+
// where the screen does; `overscroll-contain` keeps the fling inside it.
|
|
133
|
+
//
|
|
134
|
+
// Three callers had already patched themselves with `max-h-[90vh]` +
|
|
135
|
+
// an inner scroller. Those still win — `cn` is tailwind-merge — and this is
|
|
136
|
+
// the default they should not have needed.
|
|
137
|
+
"max-h-full overflow-y-auto overscroll-contain",
|
|
138
|
+
"p-4",
|
|
139
|
+
// The grab affordance sits on the panel's own top strip: a drag handle of
|
|
140
|
+
// its own would be one more control in a dialog that is mostly one field.
|
|
141
|
+
draggable && "md:[&>*:first-child]:cursor-grab",
|
|
142
|
+
className
|
|
143
|
+
),
|
|
144
|
+
children
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
),
|
|
149
|
+
document.body
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
Modal,
|
|
154
|
+
useBackdropClose
|
|
155
|
+
};
|
|
156
|
+
//# sourceMappingURL=modal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n KeyboardEvent,\n MouseEvent as ReactMouseEvent,\n PointerEvent as ReactPointerEvent,\n ReactNode,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\n\n/**\n * Drag the panel by its top strip, in pointer space (dev#460).\n *\n * Deliberately not a library and not persisted: the offset is component state that\n * dies with the dialog. It starts only on a press that lands on the panel's own\n * chrome — never on a field, a button or a link, which is what keeps \"drag to select\n * text in the textarea\" working. Above `md` only: below it the panel is a\n * full-width bottom sheet with nothing beside it to uncover.\n */\nfunction useDragOffset(enabled: boolean) {\n const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);\n const from = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);\n\n const onPointerMove = (e: PointerEvent) => {\n const start = from.current;\n if (!start) return;\n setOffset({ x: start.ox + e.clientX - start.x, y: start.oy + e.clientY - start.y });\n };\n const onPointerUp = () => {\n from.current = null;\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n };\n\n return {\n style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,\n onPointerDown: !enabled\n ? undefined\n : (e: ReactPointerEvent<HTMLDivElement>) => {\n if (e.button !== 0 || window.innerWidth < 768) return;\n const target = e.target as HTMLElement;\n // Only the panel itself and its non-interactive chrome start a drag; a\n // press inside any control belongs to that control.\n if (target.closest(\"input,textarea,select,button,a,[role='button']\")) return;\n from.current = { x: e.clientX, y: e.clientY, ox: offset?.x ?? 0, oy: offset?.y ?? 0 };\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n },\n };\n}\n\n/**\n * Backdrop handlers that close only when a press starts AND ends on the\n * backdrop itself. A plain onClick fires on mouseup, so dragging a text\n * selection that starts inside the panel and releases over the backdrop would\n * dismiss the dialog (feedback #254) — and the reverse drag would too, since\n * click targets the common ancestor of the down/up elements.\n */\nexport function useBackdropClose(onClose: () => void) {\n const pressStartedOnBackdrop = useRef(false);\n return {\n onMouseDown: (e: ReactMouseEvent<HTMLDivElement>) => {\n pressStartedOnBackdrop.current = e.target === e.currentTarget;\n },\n onMouseUp: (e: ReactMouseEvent<HTMLDivElement>) => {\n const started = pressStartedOnBackdrop.current;\n pressStartedOnBackdrop.current = false;\n if (started && e.target === e.currentTarget) onClose();\n },\n };\n}\n\n// Tab-cycle target set: the interactive descendants the focus trap rotates\n// through. Querying live (per keystroke) means dynamically rendered fields are\n// included automatically.\nconst FOCUSABLE =\n 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport interface ModalProps {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n children: ReactNode;\n /**\n * Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).\n *\n * `xl` exists because two callers had already written `className=\"max-w-3xl\"` to\n * reach it — the payee backfill's preview table and the price-merge dialog, both\n * of which hold a list and several fields rather than one question. A caller\n * patching a missing size IS the signal that the size is missing, and a\n * hand-written width in a `className` is a width nothing else can line up with.\n *\n * The panel is `w-full` under every one of them, so the cap only bites once the\n * viewport is wider than it: on a phone all three are the same full-width bottom\n * sheet. Nothing here may introduce a `min-w-*`, which is what would break that.\n */\n size?: \"md\" | \"lg\" | \"xl\";\n /** Extra classes for the panel (e.g. `space-y-3` for form content spacing). */\n className?: string;\n /** id of the heading element, wired to `aria-labelledby`. */\n labelledBy?: string;\n /**\n * Extra key handling on the panel (e.g. Ctrl/Cmd+Enter to submit). Runs first;\n * if it calls `preventDefault()` the built-in Escape/Tab handling is skipped\n * for that event.\n */\n onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;\n /**\n * Drop the backdrop's mobile margin so the panel can reach the screen edges\n * (a full-screen sheet on phones). The panel still needs its own\n * `h-[100dvh] max-w-full rounded-none` classes via `className` to fill it; the\n * desktop `md:p-4` margin is kept. Defaults to false (padded on all sizes).\n */\n fullBleed?: boolean;\n /**\n * Let the user drag the panel out of the way by its top edge (Keksdose feedback\n * dev#460: *\"Make the feedback dialog draggable so I can see behind it if it\n * blocks something.\"*).\n *\n * Opt-in, because it only makes sense for a dialog whose content is ABOUT the page\n * behind it — the feedback composer is the case, an ordinary form is not, and a\n * draggable panel that has nothing to reveal is a control that can only get lost.\n *\n * Pointer-only by design: it is a \"move this out of the way\" gesture, not a layout\n * choice worth persisting, and the position resets when the dialog closes. On a\n * phone the panel is a bottom sheet the width of the screen, so dragging is\n * suppressed there — there is nothing beside it to see.\n */\n draggable?: boolean;\n}\n\n/**\n * Centered (bottom-sheet on mobile) modal dialog: dimmed backdrop, single\n * rounded panel. Owns the overlay mechanics that the hand-rolled dialogs each\n * re-implemented or lacked — backdrop-click + Escape to close, the platform Back\n * gesture (Keksdose feedback #172), background scroll lock (feedback #204), focus\n * into the panel on open and back to the trigger on close, a Tab focus trap, and\n * `role=\"dialog\"`/`aria-modal`.\n */\nexport function Modal({\n onClose,\n children,\n size = \"md\",\n className,\n labelledBy,\n onKeyDown,\n fullBleed,\n draggable,\n}: ModalProps) {\n const panelRef = useRef<HTMLDivElement>(null);\n const drag = useDragOffset(Boolean(draggable));\n // The panel lowers itself before the caller unmounts it (live #320 rework). Every\n // dismissal below goes through `requestClose`; `onClose` still does the closing, one\n // animation later. A caller that closes the dialog ITSELF — after a save, say —\n // unmounts with no exit, which is the documented limit of this.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // Back means the same as Escape here. On a phone Escape doesn't exist, so without\n // this the only way out of a dialog is finding its close button — and Back, the\n // gesture everyone reaches for, navigated the page underneath instead (#172).\n // Mounted only while open, hence the constant `true`.\n useOverlayHistory(true, requestClose);\n\n // Mounted only while open, hence the constant `true`. Through the shared hook and\n // not by hand: this component's own save/restore copy was one half of the pair that\n // left the page permanently unscrollable when a dialog containing an open sheet was\n // closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(true);\n\n useEffect(() => {\n const previouslyFocused = document.activeElement as HTMLElement | null;\n // Focus the panel itself (not the first field) so opening doesn't pop the\n // mobile keyboard, while still moving focus into the dialog for keyboard and\n // screen-reader users.\n panelRef.current?.focus();\n return () => {\n previouslyFocused?.focus?.();\n };\n }, []);\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(e);\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n e.stopPropagation();\n requestClose();\n return;\n }\n if (e.key !== \"Tab\") return;\n // Keep Tab within the dialog. Elements inside a portal'd panel (e.g. an open\n // dropdown) live outside this subtree, so this handler never fires for them\n // — they manage their own focus.\n const focusables = panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);\n if (!focusables || focusables.length === 0) {\n e.preventDefault();\n panelRef.current?.focus();\n return;\n }\n const first = focusables[0];\n const last = focusables[focusables.length - 1];\n const active = document.activeElement;\n if (e.shiftKey && (active === first || active === panelRef.current)) {\n e.preventDefault();\n last.focus();\n } else if (!e.shiftKey && active === last) {\n e.preventDefault();\n first.focus();\n }\n };\n\n // Portal to <body> so the `fixed inset-0 z-50` overlay escapes whatever stacking\n // context its caller sits in. Rendered inline, a modal opened from e.g. the top\n // bar (a z-30 context) could paint BELOW a sibling z-30 sticky element like the\n // expanded transaction editor (feedback #320). The tour/command-palette overlays\n // already portal for the same reason.\n return createPortal(\n <div\n className={cn(\n // Live #320. The panel below rises only where it is bottom-anchored: from md up\n // it is centred, and a centred box sliding up from off-screen reads as a\n // different component arriving rather than as the same one settling.\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n \"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center\",\n fullBleed ? \"p-0 md:p-4\" : \"p-4\",\n )}\n {...backdropClose}\n >\n <div\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={labelledBy}\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n onPointerDown={drag.onPointerDown}\n style={drag.style}\n className={cn(\n closing ? \"animate-sheet-out md:animate-none\" : \"animate-sheet md:animate-none\",\n \"w-full rounded-lg border border-slate-200 bg-white shadow-sm outline-none dark:border-slate-800 dark:bg-slate-900\",\n { md: \"max-w-md\", lg: \"max-w-lg\", xl: \"max-w-3xl\" }[size],\n // A panel taller than the screen has to scroll ITSELF. The backdrop is\n // `fixed inset-0` and the body is scroll-locked while a dialog is open, so\n // without this the overflow simply had nowhere to go: on a phone the panel\n // is bottom-anchored, so it grew UPWARD past the top edge and the part that\n // went past it could not be reached by any gesture (Keksdose live #254, on\n // the price-merge dialog — but it was every dialog with more than a screen\n // of content). `max-h-full` resolves against the backdrop's content box,\n // which is the viewport minus its own padding, so the panel stops exactly\n // where the screen does; `overscroll-contain` keeps the fling inside it.\n //\n // Three callers had already patched themselves with `max-h-[90vh]` +\n // an inner scroller. Those still win — `cn` is tailwind-merge — and this is\n // the default they should not have needed.\n \"max-h-full overflow-y-auto overscroll-contain\",\n \"p-4\",\n // The grab affordance sits on the panel's own top strip: a drag handle of\n // its own would be one more control in a dialog that is mostly one field.\n draggable && \"md:[&>*:first-child]:cursor-grab\",\n className,\n )}\n >\n {children}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 MultiEntityComboboxProps<V extends string | number> {
|
|
8
|
+
/** Selected ids. */
|
|
9
|
+
value: V[];
|
|
10
|
+
onChange: (value: V[]) => void;
|
|
11
|
+
/** Already-loaded options (client-side filtered); also resolves selected labels. */
|
|
12
|
+
options?: ComboOption<V>[];
|
|
13
|
+
/** Async option source, debounced + race-safe. When set, `options` resolves
|
|
14
|
+
* labels only. */
|
|
15
|
+
loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
|
|
16
|
+
loading?: boolean;
|
|
17
|
+
label?: ReactNode;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
searchPlaceholder?: string;
|
|
20
|
+
emptyLabel?: string;
|
|
21
|
+
/** Trigger summary when 1+ selected (default: the labels joined by ", ", or
|
|
22
|
+
* "N selected" when a label can't be resolved). */
|
|
23
|
+
itemLabel?: (count: number) => string;
|
|
24
|
+
clearable?: boolean;
|
|
25
|
+
clearLabel?: string;
|
|
26
|
+
/** The phone sheet's close button. Its own prop rather than a reuse of
|
|
27
|
+
* `clearLabel`: "clear the selection" and "close this screen" are different
|
|
28
|
+
* actions, and on a full-screen sheet the close button is the only way out —
|
|
29
|
+
* so it is the one control here that MUST be in the reader's language. */
|
|
30
|
+
closeLabel?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** When set, a "create" row appears for a non-empty query with no exact match.
|
|
33
|
+
* The panel stays open (adding the new entity to `value` is the caller's job). */
|
|
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
|
+
* Multi-value sibling of {@link EntityCombobox}: pick several entities by search,
|
|
42
|
+
* with the same async `loadOptions`/`onCreate` support. Rows show a checkbox and
|
|
43
|
+
* toggle without closing the panel; the trigger summarises the selection.
|
|
44
|
+
*/
|
|
45
|
+
declare function MultiEntityCombobox<V extends string | number>({ value, onChange, options, loadOptions, loading, label, placeholder, searchPlaceholder, emptyLabel, itemLabel, clearable, clearLabel, closeLabel, disabled, onCreate, createLabel, className, invalid, }: MultiEntityComboboxProps<V>): react.JSX.Element;
|
|
46
|
+
|
|
47
|
+
export { MultiEntityCombobox, type MultiEntityComboboxProps };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui.js";
|
|
6
|
+
import { ComboboxPanel, useComboboxCore } from "./combobox-core.js";
|
|
7
|
+
function MultiEntityCombobox({
|
|
8
|
+
value,
|
|
9
|
+
onChange,
|
|
10
|
+
options,
|
|
11
|
+
loadOptions,
|
|
12
|
+
loading,
|
|
13
|
+
label,
|
|
14
|
+
placeholder,
|
|
15
|
+
searchPlaceholder = "Search",
|
|
16
|
+
emptyLabel = "No results",
|
|
17
|
+
itemLabel,
|
|
18
|
+
clearable,
|
|
19
|
+
clearLabel,
|
|
20
|
+
closeLabel,
|
|
21
|
+
disabled,
|
|
22
|
+
onCreate,
|
|
23
|
+
createLabel,
|
|
24
|
+
className,
|
|
25
|
+
invalid
|
|
26
|
+
}) {
|
|
27
|
+
const core = useComboboxCore({ options, loadOptions, loading });
|
|
28
|
+
const { results, resolve, setOpen } = core;
|
|
29
|
+
const valueSet = useMemo(() => new Set(value), [value]);
|
|
30
|
+
const summary = (() => {
|
|
31
|
+
if (value.length === 0) return placeholder ?? "";
|
|
32
|
+
if (itemLabel) return itemLabel(value.length);
|
|
33
|
+
const chosen = value.map(resolve);
|
|
34
|
+
return chosen.every(Boolean) ? chosen.map((o) => o.label).join(", ") : `${value.length} selected`;
|
|
35
|
+
})();
|
|
36
|
+
const q = core.query.trim();
|
|
37
|
+
const showCreate = Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
38
|
+
const showClear = Boolean(clearable && value.length > 0 && !disabled);
|
|
39
|
+
const toggle = (o) => {
|
|
40
|
+
core.cacheRef.current.set(o.value, o);
|
|
41
|
+
if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));
|
|
42
|
+
else onChange([...value, o.value]);
|
|
43
|
+
};
|
|
44
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("relative", className), children: [
|
|
45
|
+
label !== void 0 && /* @__PURE__ */ jsx(FieldLabel, { children: label }),
|
|
46
|
+
/* @__PURE__ */ jsxs(
|
|
47
|
+
"button",
|
|
48
|
+
{
|
|
49
|
+
ref: core.triggerRef,
|
|
50
|
+
type: "button",
|
|
51
|
+
"aria-label": typeof label === "string" ? `${label}: ${summary}` : void 0,
|
|
52
|
+
disabled,
|
|
53
|
+
"aria-invalid": invalid || void 0,
|
|
54
|
+
onClick: () => !disabled && setOpen((o) => !o),
|
|
55
|
+
className: cn(
|
|
56
|
+
FIELD_TRIGGER,
|
|
57
|
+
"pr-9",
|
|
58
|
+
label !== void 0 && FIELD_FLOATING_PAD,
|
|
59
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
60
|
+
invalid && FIELD_INVALID
|
|
61
|
+
),
|
|
62
|
+
children: [
|
|
63
|
+
/* @__PURE__ */ jsx(
|
|
64
|
+
"span",
|
|
65
|
+
{
|
|
66
|
+
className: cn(
|
|
67
|
+
"min-w-0 truncate",
|
|
68
|
+
// Inherit FIELD_BASE's ink for a value, keep the placeholder grey — the
|
|
69
|
+
// same rule every field on a form row follows (Keksdose dev#477).
|
|
70
|
+
!value.length && "text-slate-400 dark:text-slate-500"
|
|
71
|
+
),
|
|
72
|
+
children: summary
|
|
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([]);
|
|
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
|
+
multi: true,
|
|
97
|
+
sheetTitle: label ?? placeholder,
|
|
98
|
+
searchPlaceholder,
|
|
99
|
+
emptyLabel,
|
|
100
|
+
closeLabel,
|
|
101
|
+
isSelected: (v) => valueSet.has(v),
|
|
102
|
+
onChoose: toggle,
|
|
103
|
+
showCreate,
|
|
104
|
+
onCreate: () => {
|
|
105
|
+
onCreate?.(q);
|
|
106
|
+
core.setQuery("");
|
|
107
|
+
},
|
|
108
|
+
createContent: createLabel ? createLabel(q) : `Create \u201C${q}\u201D`
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
] });
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
MultiEntityCombobox
|
|
115
|
+
};
|
|
116
|
+
//# sourceMappingURL=multi-entity-combobox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/multi-entity-combobox.tsx"],"sourcesContent":["import { useMemo, 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 interface MultiEntityComboboxProps<V extends string | number> {\n /** Selected ids. */\n value: V[];\n onChange: (value: V[]) => void;\n /** Already-loaded options (client-side filtered); also resolves selected labels. */\n options?: ComboOption<V>[];\n /** Async option source, debounced + race-safe. When set, `options` resolves\n * labels only. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n /** Trigger summary when 1+ selected (default: the labels joined by \", \", or\n * \"N selected\" when a label can't be resolved). */\n itemLabel?: (count: number) => 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 * The panel stays open (adding the new entity to `value` is the caller's job). */\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 * Multi-value sibling of {@link EntityCombobox}: pick several entities by search,\n * with the same async `loadOptions`/`onCreate` support. Rows show a checkbox and\n * toggle without closing the panel; the trigger summarises the selection.\n */\nexport function MultiEntityCombobox<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 itemLabel,\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n}: MultiEntityComboboxProps<V>) {\n const core = useComboboxCore<V>({ options, loadOptions, loading });\n const { results, resolve, setOpen } = core;\n\n const valueSet = useMemo(() => new Set(value), [value]);\n const summary = (() => {\n if (value.length === 0) return placeholder ?? \"\";\n if (itemLabel) return itemLabel(value.length);\n const chosen = value.map(resolve);\n return chosen.every(Boolean)\n ? chosen.map((o) => o!.label).join(\", \")\n : `${value.length} selected`;\n })();\n\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.length > 0 && !disabled);\n\n const toggle = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n if (valueSet.has(o.value)) onChange(value.filter((v) => v !== o.value));\n else onChange([...value, o.value]);\n // Stay open — multi-select keeps adding.\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}: ${summary}` : 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\n className={cn(\n \"min-w-0 truncate\",\n // Inherit FIELD_BASE's ink for a value, keep the placeholder grey — the\n // same rule every field on a form row follows (Keksdose dev#477).\n !value.length && \"text-slate-400 dark:text-slate-500\",\n )}\n >\n {summary}\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([]);\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 multi\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) => valueSet.has(v)}\n onChoose={toggle}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n core.setQuery(\"\");\n }}\n createContent={createLabel ? createLabel(q) : `Create “${q}”`}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface MultiSelectOption {
|
|
5
|
+
value: string | number;
|
|
6
|
+
label: string;
|
|
7
|
+
hint?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Props {
|
|
10
|
+
options: MultiSelectOption[];
|
|
11
|
+
values: (string | number)[];
|
|
12
|
+
onChange: (next: (string | number)[]) => void;
|
|
13
|
+
/** Displayed when no value is selected (i.e. "all"). Defaults to "All"; pass a
|
|
14
|
+
* translated string for i18n. */
|
|
15
|
+
allLabel?: string;
|
|
16
|
+
/** Singular/plural countable label, used when 1+ items are selected. */
|
|
17
|
+
itemLabel?: (count: number) => string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** Embedded top-boundary label, matching the native Input/Select fields. */
|
|
20
|
+
label?: ReactNode;
|
|
21
|
+
/** Search-box placeholder (default "Search"). */
|
|
22
|
+
searchLabel?: string;
|
|
23
|
+
/** "Select all" action label (default "Select all"). */
|
|
24
|
+
selectAllLabel?: string;
|
|
25
|
+
/** "Clear" action label (default "Clear"). */
|
|
26
|
+
clearLabel?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Extra classes for the open dropdown PANEL — the way to widen it past its
|
|
29
|
+
* 16rem default when the rows carry more than a label (Keksdose feedback #147). */
|
|
30
|
+
panelClassName?: string;
|
|
31
|
+
/** Required and unanswered — {@link FIELD_INVALID}, the same rose border the
|
|
32
|
+
* native `Select` has worn since feedback #235. Every field-styled control in
|
|
33
|
+
* this package carries it now, so a form can mark any of its fields rather than
|
|
34
|
+
* only the one that happened to have it first. */
|
|
35
|
+
invalid?: boolean;
|
|
36
|
+
}
|
|
37
|
+
declare function MultiSelect({ options, values, onChange, allLabel, itemLabel, placeholder, label, searchLabel, selectAllLabel, clearLabel, className, panelClassName, invalid, }: Props): react.JSX.Element;
|
|
38
|
+
|
|
39
|
+
export { MultiSelect, type MultiSelectOption };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from "./ui.js";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from "./dropdown.js";
|
|
7
|
+
function MultiSelect({
|
|
8
|
+
options,
|
|
9
|
+
values,
|
|
10
|
+
onChange,
|
|
11
|
+
allLabel,
|
|
12
|
+
itemLabel,
|
|
13
|
+
placeholder,
|
|
14
|
+
label,
|
|
15
|
+
searchLabel = "Search",
|
|
16
|
+
selectAllLabel = "Select all",
|
|
17
|
+
clearLabel = "Clear",
|
|
18
|
+
className,
|
|
19
|
+
panelClassName,
|
|
20
|
+
invalid
|
|
21
|
+
}) {
|
|
22
|
+
const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
|
|
23
|
+
const filtered = useMemo(() => {
|
|
24
|
+
const q = query.trim().toLowerCase();
|
|
25
|
+
if (!q) return options;
|
|
26
|
+
return options.filter(
|
|
27
|
+
(o) => o.label.toLowerCase().includes(q) || (o.hint ?? "").toLowerCase().includes(q)
|
|
28
|
+
);
|
|
29
|
+
}, [options, query]);
|
|
30
|
+
const valueSet = useMemo(() => new Set(values), [values]);
|
|
31
|
+
const allChecked = options.length > 0 && options.every((o) => valueSet.has(o.value));
|
|
32
|
+
const anyChecked = values.length > 0;
|
|
33
|
+
const selectedSummary = (() => {
|
|
34
|
+
if (!anyChecked) return allLabel ?? placeholder ?? "All";
|
|
35
|
+
if (allChecked) return allLabel ?? placeholder ?? "All";
|
|
36
|
+
return itemLabel ? itemLabel(values.length) : `${values.length}`;
|
|
37
|
+
})();
|
|
38
|
+
const toggle = (val) => {
|
|
39
|
+
if (valueSet.has(val)) onChange(values.filter((v) => v !== val));
|
|
40
|
+
else onChange([...values, val]);
|
|
41
|
+
};
|
|
42
|
+
const selectAll = () => onChange(options.map((o) => o.value));
|
|
43
|
+
const clearAll = () => onChange([]);
|
|
44
|
+
return /* @__PURE__ */ jsxs("div", { ref: wrapperRef, className: cn("relative", className), children: [
|
|
45
|
+
label !== void 0 && /* @__PURE__ */ jsx(FieldLabel, { children: label }),
|
|
46
|
+
/* @__PURE__ */ jsxs(
|
|
47
|
+
"button",
|
|
48
|
+
{
|
|
49
|
+
type: "button",
|
|
50
|
+
onClick: () => setOpen((v) => !v),
|
|
51
|
+
"aria-invalid": invalid || void 0,
|
|
52
|
+
className: cn(FIELD_TRIGGER, "pr-9", label !== void 0 && FIELD_FLOATING_PAD, invalid && FIELD_INVALID),
|
|
53
|
+
children: [
|
|
54
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: selectedSummary }),
|
|
55
|
+
/* @__PURE__ */ jsx(FieldChevron, {})
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
open && /* @__PURE__ */ jsx(
|
|
60
|
+
DropdownPanel,
|
|
61
|
+
{
|
|
62
|
+
className: cn("w-64", panelClassName),
|
|
63
|
+
empty: filtered.length === 0,
|
|
64
|
+
header: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
65
|
+
/* @__PURE__ */ jsx(
|
|
66
|
+
DropdownSearchHeader,
|
|
67
|
+
{
|
|
68
|
+
query,
|
|
69
|
+
onQueryChange: setQuery,
|
|
70
|
+
inputRef,
|
|
71
|
+
placeholder: searchLabel
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 px-2 py-1.5 text-xs", children: [
|
|
75
|
+
/* @__PURE__ */ jsx(
|
|
76
|
+
"button",
|
|
77
|
+
{
|
|
78
|
+
type: "button",
|
|
79
|
+
onClick: selectAll,
|
|
80
|
+
disabled: allChecked,
|
|
81
|
+
className: "rounded px-2 py-0.5 text-slate-600 hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800",
|
|
82
|
+
children: selectAllLabel
|
|
83
|
+
}
|
|
84
|
+
),
|
|
85
|
+
/* @__PURE__ */ jsx(
|
|
86
|
+
"button",
|
|
87
|
+
{
|
|
88
|
+
type: "button",
|
|
89
|
+
onClick: clearAll,
|
|
90
|
+
disabled: !anyChecked,
|
|
91
|
+
className: "rounded px-2 py-0.5 text-slate-600 hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800",
|
|
92
|
+
children: clearLabel
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
] })
|
|
96
|
+
] }),
|
|
97
|
+
children: filtered.map((o) => {
|
|
98
|
+
const checked = valueSet.has(o.value);
|
|
99
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
|
|
100
|
+
"button",
|
|
101
|
+
{
|
|
102
|
+
type: "button",
|
|
103
|
+
onClick: () => toggle(o.value),
|
|
104
|
+
className: cn(
|
|
105
|
+
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-sm text-left hover:bg-slate-50 dark:hover:bg-slate-800",
|
|
106
|
+
checked && "bg-slate-100/60 dark:bg-slate-800/60"
|
|
107
|
+
),
|
|
108
|
+
children: /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
"span",
|
|
111
|
+
{
|
|
112
|
+
className: cn(
|
|
113
|
+
"flex size-4 shrink-0 items-center justify-center rounded border",
|
|
114
|
+
checked ? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900" : "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900"
|
|
115
|
+
),
|
|
116
|
+
children: checked && /* @__PURE__ */ jsx(Check, { className: "size-3" })
|
|
117
|
+
}
|
|
118
|
+
),
|
|
119
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-slate-900 dark:text-slate-100", children: o.label }),
|
|
120
|
+
o.hint && /* @__PURE__ */ jsx("span", { className: "truncate text-xs text-slate-400 dark:text-slate-500", children: o.hint })
|
|
121
|
+
] })
|
|
122
|
+
}
|
|
123
|
+
) }, o.value);
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
] });
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
MultiSelect
|
|
131
|
+
};
|
|
132
|
+
//# sourceMappingURL=multi-select.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/multi-select.tsx"],"sourcesContent":["import { useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_INVALID, FIELD_FLOATING_PAD } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownPanel, DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\n\nexport interface MultiSelectOption {\n value: string | number;\n label: string;\n hint?: string;\n}\n\ninterface Props {\n options: MultiSelectOption[];\n values: (string | number)[];\n onChange: (next: (string | number)[]) => void;\n /** Displayed when no value is selected (i.e. \"all\"). Defaults to \"All\"; pass a\n * translated string for i18n. */\n allLabel?: string;\n /** Singular/plural countable label, used when 1+ items are selected. */\n itemLabel?: (count: number) => string;\n placeholder?: string;\n /** Embedded top-boundary label, matching the native Input/Select fields. */\n label?: ReactNode;\n /** Search-box placeholder (default \"Search\"). */\n searchLabel?: string;\n /** \"Select all\" action label (default \"Select all\"). */\n selectAllLabel?: string;\n /** \"Clear\" action label (default \"Clear\"). */\n clearLabel?: string;\n className?: string;\n /** Extra classes for the open dropdown PANEL — the way to widen it past its\n * 16rem default when the rows carry more than a label (Keksdose feedback #147). */\n panelClassName?: string;\n /** Required and unanswered — {@link FIELD_INVALID}, the same rose border the\n * native `Select` has worn since feedback #235. Every field-styled control in\n * this package carries it now, so a form can mark any of its fields rather than\n * only the one that happened to have it first. */\n invalid?: boolean;\n}\n\nexport function MultiSelect({\n options,\n values,\n onChange,\n allLabel,\n itemLabel,\n placeholder,\n label,\n searchLabel = \"Search\",\n selectAllLabel = \"Select all\",\n clearLabel = \"Clear\",\n className,\n panelClassName,\n invalid,\n}: Props) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n const filtered = useMemo(() => {\n const q = query.trim().toLowerCase();\n if (!q) return options;\n return options.filter(\n (o) =>\n o.label.toLowerCase().includes(q) ||\n (o.hint ?? \"\").toLowerCase().includes(q),\n );\n }, [options, query]);\n\n const valueSet = useMemo(() => new Set(values), [values]);\n const allChecked = options.length > 0 && options.every((o) => valueSet.has(o.value));\n const anyChecked = values.length > 0;\n const selectedSummary = (() => {\n if (!anyChecked) return allLabel ?? placeholder ?? \"All\";\n if (allChecked) return allLabel ?? placeholder ?? \"All\";\n return itemLabel ? itemLabel(values.length) : `${values.length}`;\n })();\n\n const toggle = (val: string | number) => {\n if (valueSet.has(val)) onChange(values.filter((v) => v !== val));\n else onChange([...values, val]);\n };\n\n const selectAll = () => onChange(options.map((o) => o.value));\n const clearAll = () => onChange([]);\n\n return (\n <div ref={wrapperRef} className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n aria-invalid={invalid || undefined}\n className={cn(FIELD_TRIGGER, \"pr-9\", label !== undefined && FIELD_FLOATING_PAD, invalid && FIELD_INVALID)}\n >\n {/* The summary IS the field's value, so it inherits FIELD_BASE's ink rather\n than restating a lighter one (Keksdose dev#477). */}\n <span className=\"truncate\">{selectedSummary}</span>\n <FieldChevron />\n </button>\n {open && (\n <DropdownPanel\n // 16rem by default; `panelClassName` is how a caller widens it (Keksdose\n // feedback #147: the market picker's rows carry a postcode, a town, a\n // distance and a receipt count, all of which were being truncated). The\n // override used to be applied from the OUTSIDE with a\n // `[&>div]:w-full` descendant selector, which worked and was a hack —\n // \"change the package\" was the right call.\n className={cn(\"w-64\", panelClassName)}\n empty={filtered.length === 0}\n header={\n <>\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={searchLabel}\n />\n <div className=\"flex items-center justify-between gap-2 px-2 py-1.5 text-xs\">\n <button\n type=\"button\"\n onClick={selectAll}\n disabled={allChecked}\n className=\"rounded px-2 py-0.5 text-slate-600 hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800\"\n >\n {selectAllLabel}\n </button>\n <button\n type=\"button\"\n onClick={clearAll}\n disabled={!anyChecked}\n className=\"rounded px-2 py-0.5 text-slate-600 hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800\"\n >\n {clearLabel}\n </button>\n </div>\n </>\n }\n >\n {filtered.map((o) => {\n const checked = valueSet.has(o.value);\n return (\n <li key={o.value}>\n <button\n type=\"button\"\n onClick={() => toggle(o.value)}\n className={cn(\n \"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-sm text-left hover:bg-slate-50 dark:hover:bg-slate-800\",\n checked && \"bg-slate-100/60 dark:bg-slate-800/60\",\n )}\n >\n <span className=\"flex items-center gap-2 min-w-0\">\n <span\n className={cn(\n \"flex size-4 shrink-0 items-center justify-center rounded border\",\n checked\n ? \"border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900\"\n : \"border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-900\",\n )}\n >\n {checked && <Check className=\"size-3\" />}\n </span>\n <span className=\"truncate text-slate-900 dark:text-slate-100\">{o.label}</span>\n {o.hint && (\n <span className=\"truncate text-xs text-slate-400 dark:text-slate-500\">\n {o.hint}\n </span>\n )}\n </span>\n </button>\n </li>\n );\n })}\n </DropdownPanel>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { CalculatorButtonLabels } from './calculator.js';
|
|
4
|
+
|
|
5
|
+
interface NumberInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
/** Fired on every keystroke with the raw text (comma → dot, operators kept so
|
|
8
|
+
* a typed expression survives). */
|
|
9
|
+
onChange: (value: string) => void;
|
|
10
|
+
/** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur
|
|
11
|
+
* fields so the save always sees the resolved number, never "10+5". */
|
|
12
|
+
onCommit?: (value: string) => void;
|
|
13
|
+
label?: ReactNode;
|
|
14
|
+
ariaLabel?: string;
|
|
15
|
+
/** Names for the calculator this field renders — its trigger, and the controls
|
|
16
|
+
* inside the popover. English defaults, so a host that does not translate is
|
|
17
|
+
* unaffected. */
|
|
18
|
+
labels?: {
|
|
19
|
+
calculatorTrigger?: string;
|
|
20
|
+
calculator?: CalculatorButtonLabels;
|
|
21
|
+
};
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
autoFocus?: boolean;
|
|
25
|
+
/** Wrapper class. */
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Overrides the input's own styling (merged after the field base). */
|
|
28
|
+
inputClassName?: string;
|
|
29
|
+
id?: string;
|
|
30
|
+
/** Show the calculator-popover trigger (default true). Set false for fields in
|
|
31
|
+
* an ephemeral close-on-blur editor, where clicking the icon would blur away
|
|
32
|
+
* and tear the editor down before the popover could open — inline typing
|
|
33
|
+
* (e.g. "200+50" → Enter) still evaluates there. */
|
|
34
|
+
calculator?: boolean;
|
|
35
|
+
/** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field
|
|
36
|
+
* chrome dropped. The currency-free half of the same treatment `AmountInput`
|
|
37
|
+
* carries, for a dialog whose whole point is the one number (a budget goal's
|
|
38
|
+
* target); not for a field among many, and never for the compact inline
|
|
39
|
+
* editors this control also serves. */
|
|
40
|
+
variant?: "field" | "display";
|
|
41
|
+
/** A {@link FieldHint} "?" on the label's own line, beside the label rather
|
|
42
|
+
* than at the far end of the strip — the far end is where the calculator
|
|
43
|
+
* lives, and a hint placed there landed on top of it (steering-design
|
|
44
|
+
* feedback #48). */
|
|
45
|
+
hint?: ReactNode;
|
|
46
|
+
/** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
|
|
47
|
+
invalid?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* A static unit shown at the field's right edge — "%", "kg", "km/h".
|
|
50
|
+
*
|
|
51
|
+
* The read-out half of {@link AmountInput}'s currency chip, and it exists for the
|
|
52
|
+
* same reason that one does: a unit belongs to the FIELD, not to the text, so
|
|
53
|
+
* typing it is a way to get it into the value. Keksdose had a percentage spelled
|
|
54
|
+
* three different ways across three screens — `<Input type="number" step="0.01">`
|
|
55
|
+
* for a loan rate, `step="0.1"` for a tax rate, `inputMode="decimal"` for a VAT
|
|
56
|
+
* rate — and none of the three showed a "%" anywhere near the box, so what the
|
|
57
|
+
* digits meant was a question the label alone had to answer.
|
|
58
|
+
*
|
|
59
|
+
* `aria-hidden`, like the currency read-out: it is a property of the field that
|
|
60
|
+
* the label already names ("Interest rate (%)"), and announcing it again after
|
|
61
|
+
* every value reads as part of the number.
|
|
62
|
+
*/
|
|
63
|
+
suffix?: ReactNode;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A numeric text field with a built-in calculator: type a calculation straight
|
|
67
|
+
* in (e.g. "12+5", evaluated on blur/Enter) or click the trailing calculator
|
|
68
|
+
* icon for a keypad. String `value`/`onChange` contract, mirroring
|
|
69
|
+
* {@link AmountInput} — the currency-free counterpart for budgets, goals,
|
|
70
|
+
* invoice totals and split lines.
|
|
71
|
+
*/
|
|
72
|
+
declare function NumberInput({ value, onChange, onCommit, label, ariaLabel, labels, placeholder, disabled, autoFocus, className, inputClassName, id, calculator, variant, hint, invalid, suffix, }: NumberInputProps): react.JSX.Element;
|
|
73
|
+
|
|
74
|
+
export { NumberInput };
|