@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,68 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useBackdropClose } from "./modal.js";
|
|
6
|
+
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
7
|
+
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
8
|
+
import { useCloseTransition } from "../hooks/use-close-transition.js";
|
|
9
|
+
function FullBleedDialog({
|
|
10
|
+
open,
|
|
11
|
+
onClose,
|
|
12
|
+
header,
|
|
13
|
+
closeLabel,
|
|
14
|
+
children,
|
|
15
|
+
backCloses = true,
|
|
16
|
+
className
|
|
17
|
+
}) {
|
|
18
|
+
const { closing, requestClose } = useCloseTransition(onClose);
|
|
19
|
+
const backdropClose = useBackdropClose(requestClose);
|
|
20
|
+
useBodyScrollLock(open);
|
|
21
|
+
useOverlayHistory(open && backCloses, requestClose);
|
|
22
|
+
if (!open || typeof document === "undefined") return null;
|
|
23
|
+
return createPortal(
|
|
24
|
+
/* @__PURE__ */ jsx(
|
|
25
|
+
"div",
|
|
26
|
+
{
|
|
27
|
+
className: cn(
|
|
28
|
+
"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40",
|
|
29
|
+
closing ? "animate-overlay-out" : "animate-overlay"
|
|
30
|
+
),
|
|
31
|
+
role: "dialog",
|
|
32
|
+
"aria-modal": "true",
|
|
33
|
+
...backdropClose,
|
|
34
|
+
children: /* @__PURE__ */ jsxs(
|
|
35
|
+
"div",
|
|
36
|
+
{
|
|
37
|
+
className: cn(
|
|
38
|
+
"flex h-full w-full flex-col overflow-hidden bg-white shadow-xl dark:bg-slate-900",
|
|
39
|
+
closing ? "animate-sheet-out" : "animate-sheet",
|
|
40
|
+
className
|
|
41
|
+
),
|
|
42
|
+
children: [
|
|
43
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-3 dark:border-slate-800", children: [
|
|
44
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0 font-medium", children: header }),
|
|
45
|
+
/* @__PURE__ */ jsx(
|
|
46
|
+
"button",
|
|
47
|
+
{
|
|
48
|
+
type: "button",
|
|
49
|
+
onClick: requestClose,
|
|
50
|
+
"aria-label": closeLabel,
|
|
51
|
+
className: "-mr-1 shrink-0 rounded p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200",
|
|
52
|
+
children: /* @__PURE__ */ jsx(X, { className: "size-5" })
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
] }),
|
|
56
|
+
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto overscroll-contain px-3 py-3", children })
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
),
|
|
62
|
+
document.body
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
FullBleedDialog
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=full-bleed-dialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\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 * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n}: {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n}) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-white shadow-xl dark:bg-slate-900\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-3 dark:border-slate-800\">\n <div className=\"min-w-0 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-mr-1 shrink-0 rounded p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n <div className=\"flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"AA0GQ,SACE,KADF;AA1GR,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AA8B5B,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AACF,GAuBG;AAID,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QASC,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QACV,GAAG;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qGACb;AAAA,oCAAC,SAAI,WAAU,uBAAuB,kBAAO;AAAA,gBAC7C;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAIA,oBAAC,SAAI,WAAU,uDAAuD,UAAS;AAAA;AAAA;AAAA,QACjF;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface PickerGroup {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** `icon` is an optional leading glyph, drawn before the label — the same slot
|
|
8
|
+
* `ComboOption` has always had, so the two pickers can present one option set
|
|
9
|
+
* the same way. Omitted, a row is exactly what it was: label only, with no
|
|
10
|
+
* reserved space, so a list where nothing has an icon does not indent. */
|
|
11
|
+
items: {
|
|
12
|
+
key: string;
|
|
13
|
+
label: string;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
/** Panel picker for large grouped option sets: instead of one long flat
|
|
18
|
+
* dropdown, the groups sit side by side as columns with their items listed
|
|
19
|
+
* beneath (feedback #267/#276), narrowed by a filter box at the top (feedback
|
|
20
|
+
* #267 rework). A query that matches a group's name keeps the whole group;
|
|
21
|
+
* otherwise groups are reduced to their matching items. */
|
|
22
|
+
declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLabel, filterPlaceholder, className, }: {
|
|
23
|
+
groups: PickerGroup[];
|
|
24
|
+
/** Face of the closed picker button. */
|
|
25
|
+
buttonLabel: ReactNode;
|
|
26
|
+
/** Keys of the current selection, if any. */
|
|
27
|
+
selected: {
|
|
28
|
+
group: string;
|
|
29
|
+
item: string;
|
|
30
|
+
} | null;
|
|
31
|
+
onSelect: (groupKey: string, itemKey: string) => void;
|
|
32
|
+
ariaLabel?: string;
|
|
33
|
+
filterPlaceholder?: string;
|
|
34
|
+
className?: string;
|
|
35
|
+
}): react.JSX.Element;
|
|
36
|
+
|
|
37
|
+
export { GroupedPicker, type PickerGroup };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo } from "react";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { DropdownSearchHeader, useDropdownSearch } from "./dropdown.js";
|
|
6
|
+
function GroupedPicker({
|
|
7
|
+
groups,
|
|
8
|
+
buttonLabel,
|
|
9
|
+
selected,
|
|
10
|
+
onSelect,
|
|
11
|
+
ariaLabel,
|
|
12
|
+
filterPlaceholder,
|
|
13
|
+
className
|
|
14
|
+
}) {
|
|
15
|
+
const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!open) return;
|
|
18
|
+
const onKey = (e) => {
|
|
19
|
+
if (e.key === "Escape") setOpen(false);
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener("keydown", onKey);
|
|
22
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
23
|
+
}, [open, setOpen]);
|
|
24
|
+
const filtered = useMemo(() => {
|
|
25
|
+
const q = query.trim().toLowerCase();
|
|
26
|
+
if (!q) return groups;
|
|
27
|
+
return groups.map(
|
|
28
|
+
(g) => g.label.toLowerCase().includes(q) ? g : { ...g, items: g.items.filter((i) => i.label.toLowerCase().includes(q)) }
|
|
29
|
+
).filter((g) => g.items.length > 0);
|
|
30
|
+
}, [groups, query]);
|
|
31
|
+
return /* @__PURE__ */ jsxs("div", { ref: wrapperRef, className: cn("relative", className), children: [
|
|
32
|
+
/* @__PURE__ */ jsxs(
|
|
33
|
+
"button",
|
|
34
|
+
{
|
|
35
|
+
type: "button",
|
|
36
|
+
"aria-haspopup": "dialog",
|
|
37
|
+
"aria-expanded": open,
|
|
38
|
+
"aria-label": ariaLabel,
|
|
39
|
+
onClick: () => setOpen((o) => !o),
|
|
40
|
+
className: "flex w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800",
|
|
41
|
+
children: [
|
|
42
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: buttonLabel }),
|
|
43
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-slate-400" })
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
),
|
|
47
|
+
open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900", children: [
|
|
48
|
+
/* @__PURE__ */ jsx(
|
|
49
|
+
DropdownSearchHeader,
|
|
50
|
+
{
|
|
51
|
+
query,
|
|
52
|
+
onQueryChange: setQuery,
|
|
53
|
+
inputRef,
|
|
54
|
+
placeholder: filterPlaceholder
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
/* @__PURE__ */ jsx("div", { className: "overflow-y-auto p-3", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500 dark:text-slate-400", children: "\u2014" }) : /* @__PURE__ */ jsx("div", { className: "columns-2 gap-4 md:columns-3 xl:columns-4", children: filtered.map((g) => /* @__PURE__ */ jsxs("div", { className: "mb-3 break-inside-avoid", children: [
|
|
58
|
+
/* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400", children: g.label }),
|
|
59
|
+
/* @__PURE__ */ jsx("ul", { className: "space-y-0.5", children: g.items.map((item) => {
|
|
60
|
+
const isSelected = selected !== null && g.key === selected.group && item.key === selected.item;
|
|
61
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
|
|
62
|
+
"button",
|
|
63
|
+
{
|
|
64
|
+
type: "button",
|
|
65
|
+
onClick: () => {
|
|
66
|
+
onSelect(g.key, item.key);
|
|
67
|
+
setOpen(false);
|
|
68
|
+
},
|
|
69
|
+
className: cn(
|
|
70
|
+
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
|
|
71
|
+
isSelected ? "bg-sky-100 font-medium text-sky-900 dark:bg-sky-900/40 dark:text-sky-200" : "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
72
|
+
),
|
|
73
|
+
children: [
|
|
74
|
+
item.icon && /* @__PURE__ */ jsx("span", { className: "shrink-0", children: item.icon }),
|
|
75
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.label })
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
) }, item.key);
|
|
79
|
+
}) })
|
|
80
|
+
] }, g.key)) }) })
|
|
81
|
+
] })
|
|
82
|
+
] });
|
|
83
|
+
}
|
|
84
|
+
export {
|
|
85
|
+
GroupedPicker
|
|
86
|
+
};
|
|
87
|
+
//# sourceMappingURL=grouped-picker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n filterPlaceholder,\n className,\n}: {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n ariaLabel?: string;\n filterPlaceholder?: string;\n className?: string;\n}) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n const filtered = useMemo(() => {\n const q = query.trim().toLowerCase();\n if (!q) return groups;\n return groups\n .map((g) =>\n g.label.toLowerCase().includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => i.label.toLowerCase().includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query]);\n\n return (\n <div ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-slate-400\" />\n </button>\n {open && (\n <div className=\"absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-slate-500 dark:text-slate-400\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm\",\n isSelected\n ? \"bg-sky-100 font-medium text-sky-900 dark:bg-sky-900/40 dark:text-sky-200\"\n : \"text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"AAiEM,SAQE,KARF;AAjEN,SAAS,WAAW,eAAe;AAEnC,SAAS,mBAAmB;AAC5B,SAAS,UAAU;AACnB,SAAS,sBAAsB,yBAAyB;AAiBjD,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUG;AACD,QAAM,EAAE,MAAM,SAAS,YAAY,OAAO,UAAU,SAAS,IAAI,kBAAkB;AAEnF,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,SAAQ,KAAK;AAAA,IACvC;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,MAAM,OAAO,CAAC;AAElB,QAAM,WAAW,QAAQ,MAAM;AAC7B,UAAM,IAAI,MAAM,KAAK,EAAE,YAAY;AACnC,QAAI,CAAC,EAAG,QAAO;AACf,WAAO,OACJ;AAAA,MAAI,CAAC,MACJ,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,IAC5B,IACA,EAAE,GAAG,GAAG,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,CAAC,EAAE;AAAA,IAC9E,EACC,OAAO,CAAC,MAAM,EAAE,MAAM,SAAS,CAAC;AAAA,EACrC,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SACE,qBAAC,SAAI,KAAK,YAAY,WAAW,GAAG,YAAY,SAAS,GACvD;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,iBAAc;AAAA,QACd,iBAAe;AAAA,QACf,cAAY;AAAA,QACZ,SAAS,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,QAChC,WAAU;AAAA,QAEV;AAAA,8BAAC,UAAK,WAAU,YAAY,uBAAY;AAAA,UACxC,oBAAC,eAAY,WAAU,kCAAiC;AAAA;AAAA;AAAA,IAC1D;AAAA,IACC,QACC,qBAAC,SAAI,WAAU,sLACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,eAAe;AAAA,UACf;AAAA,UACA,aAAa;AAAA;AAAA,MACf;AAAA,MACA,oBAAC,SAAI,WAAU,uBACZ,mBAAS,WAAW,IACnB,oBAAC,OAAE,WAAU,8CAA6C,oBAAC,IAE3D,oBAAC,SAAI,WAAU,6CACZ,mBAAS,IAAI,CAAC,MACb,qBAAC,SAAgB,WAAU,2BACzB;AAAA,4BAAC,SAAI,WAAU,yFACZ,YAAE,OACL;AAAA,QACA,oBAAC,QAAG,WAAU,eACX,YAAE,MAAM,IAAI,CAAC,SAAS;AACrB,gBAAM,aACJ,aAAa,QACb,EAAE,QAAQ,SAAS,SACnB,KAAK,QAAQ,SAAS;AACxB,iBACE,oBAAC,QACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS,MAAM;AACb,yBAAS,EAAE,KAAK,KAAK,GAAG;AACxB,wBAAQ,KAAK;AAAA,cACf;AAAA,cACA,WAAW;AAAA,gBACT;AAAA,gBACA,aACI,6EACA;AAAA,cACN;AAAA,cAEC;AAAA,qBAAK,QAAQ,oBAAC,UAAK,WAAU,YAAY,eAAK,MAAK;AAAA,gBACpD,oBAAC,UAAK,WAAU,oBAAoB,eAAK,OAAM;AAAA;AAAA;AAAA,UACjD,KAhBO,KAAK,GAiBd;AAAA,QAEJ,CAAC,GACH;AAAA,WA/BQ,EAAE,GAgCZ,CACD,GACH,GAEJ;AAAA,OACF;AAAA,KAEJ;AAEJ;","names":[]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface HoverMenuProps {
|
|
5
|
+
trigger: (state: {
|
|
6
|
+
open: boolean;
|
|
7
|
+
toggle: () => void;
|
|
8
|
+
}) => ReactNode;
|
|
9
|
+
children: (close: () => void) => ReactNode;
|
|
10
|
+
align?: "left" | "right";
|
|
11
|
+
panelClassName?: string;
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
}
|
|
14
|
+
declare function HoverMenu({ trigger, children, align, panelClassName, ariaLabel }: HoverMenuProps): react.JSX.Element;
|
|
15
|
+
|
|
16
|
+
export { HoverMenu };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { useOutsideClick } from "../hooks/use-dismiss.js";
|
|
5
|
+
const OPEN_DELAY_MS = 120;
|
|
6
|
+
const CLOSE_DELAY_MS = 120;
|
|
7
|
+
let activeClose = null;
|
|
8
|
+
function HoverMenu({ trigger, children, align = "right", panelClassName, ariaLabel }) {
|
|
9
|
+
const wrapperRef = useRef(null);
|
|
10
|
+
const [phase, setPhase] = useState("closed");
|
|
11
|
+
const open = phase === "open" || phase === "closing";
|
|
12
|
+
const openedByHoverRef = useRef(false);
|
|
13
|
+
const close = useCallback(() => {
|
|
14
|
+
openedByHoverRef.current = false;
|
|
15
|
+
setPhase("closed");
|
|
16
|
+
}, []);
|
|
17
|
+
const toggle = useCallback(
|
|
18
|
+
() => setPhase((p) => {
|
|
19
|
+
if (p === "open" || p === "closing") return openedByHoverRef.current ? p : "closed";
|
|
20
|
+
openedByHoverRef.current = false;
|
|
21
|
+
return "open";
|
|
22
|
+
}),
|
|
23
|
+
[]
|
|
24
|
+
);
|
|
25
|
+
const handleMouseEnter = useCallback(
|
|
26
|
+
() => setPhase((p) => p === "closing" ? "open" : p === "closed" ? "opening" : p),
|
|
27
|
+
[]
|
|
28
|
+
);
|
|
29
|
+
const handleMouseLeave = useCallback(
|
|
30
|
+
() => setPhase((p) => p === "opening" ? "closed" : p === "open" ? "closing" : p),
|
|
31
|
+
[]
|
|
32
|
+
);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (phase === "opening") {
|
|
35
|
+
const id = setTimeout(() => {
|
|
36
|
+
openedByHoverRef.current = true;
|
|
37
|
+
setPhase("open");
|
|
38
|
+
}, OPEN_DELAY_MS);
|
|
39
|
+
return () => clearTimeout(id);
|
|
40
|
+
}
|
|
41
|
+
if (phase === "closing") {
|
|
42
|
+
const id = setTimeout(() => setPhase("closed"), CLOSE_DELAY_MS);
|
|
43
|
+
return () => clearTimeout(id);
|
|
44
|
+
}
|
|
45
|
+
}, [phase]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
if (activeClose !== close) activeClose?.();
|
|
49
|
+
activeClose = close;
|
|
50
|
+
return () => {
|
|
51
|
+
if (activeClose === close) activeClose = null;
|
|
52
|
+
};
|
|
53
|
+
}, [open, close]);
|
|
54
|
+
useOutsideClick(wrapperRef, close, open);
|
|
55
|
+
const panelRef = useRef(null);
|
|
56
|
+
const [shiftX, setShiftX] = useState(0);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!open) {
|
|
59
|
+
setShiftX(0);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const el = panelRef.current;
|
|
63
|
+
if (!el) return;
|
|
64
|
+
const rect = el.getBoundingClientRect();
|
|
65
|
+
if (rect.width === 0) return;
|
|
66
|
+
const margin = 8;
|
|
67
|
+
const naturalLeft = rect.left - shiftX;
|
|
68
|
+
const naturalRight = rect.right - shiftX;
|
|
69
|
+
let next = 0;
|
|
70
|
+
if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;
|
|
71
|
+
if (naturalLeft + next < margin) next = margin - naturalLeft;
|
|
72
|
+
setShiftX(next);
|
|
73
|
+
}, [open]);
|
|
74
|
+
return /* @__PURE__ */ jsxs(
|
|
75
|
+
"div",
|
|
76
|
+
{
|
|
77
|
+
ref: wrapperRef,
|
|
78
|
+
className: "relative",
|
|
79
|
+
onMouseEnter: handleMouseEnter,
|
|
80
|
+
onMouseLeave: handleMouseLeave,
|
|
81
|
+
"aria-label": ariaLabel,
|
|
82
|
+
children: [
|
|
83
|
+
trigger({ open, toggle }),
|
|
84
|
+
open && /* @__PURE__ */ jsx(
|
|
85
|
+
"div",
|
|
86
|
+
{
|
|
87
|
+
ref: panelRef,
|
|
88
|
+
className: cn(
|
|
89
|
+
"absolute top-full z-40 pt-2",
|
|
90
|
+
align === "right" ? "right-0" : "left-0"
|
|
91
|
+
),
|
|
92
|
+
style: shiftX ? { transform: `translateX(${shiftX}px)` } : void 0,
|
|
93
|
+
children: /* @__PURE__ */ jsx(
|
|
94
|
+
"div",
|
|
95
|
+
{
|
|
96
|
+
role: "menu",
|
|
97
|
+
className: cn(
|
|
98
|
+
"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900",
|
|
99
|
+
panelClassName
|
|
100
|
+
),
|
|
101
|
+
children: children(close)
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
HoverMenu
|
|
112
|
+
};
|
|
113
|
+
//# sourceMappingURL=hover-menu.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useOutsideClick } from \"../hooks/use-dismiss\";\n\ninterface HoverMenuProps {\n trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n align?: \"left\" | \"right\";\n panelClassName?: string;\n ariaLabel?: string;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({ trigger, children, align = \"right\", panelClassName, ariaLabel }: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n const openedByHoverRef = useRef(false);\n\n const close = useCallback(() => {\n openedByHoverRef.current = false;\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(\n () =>\n setPhase((p) => {\n if (p === \"open\" || p === \"closing\") return openedByHoverRef.current ? p : \"closed\";\n openedByHoverRef.current = false;\n return \"open\";\n }),\n [],\n );\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n openedByHoverRef.current = true;\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin. Recomputed on each open; `shiftX` is\n // subtracted first so the measurement is against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n useLayoutEffect(() => {\n if (!open) {\n setShiftX(0);\n return;\n }\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const margin = 8;\n const naturalLeft = rect.left - shiftX;\n const naturalRight = rect.right - shiftX;\n let next = 0;\n if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;\n if (naturalLeft + next < margin) next = margin - naturalLeft;\n setShiftX(next);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n aria-label={ariaLabel}\n >\n {trigger({ open, toggle })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n align === \"right\" ? \"right-0\" : \"left-0\",\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n role=\"menu\"\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
interface MiniCalendarProps {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
locale: string;
|
|
7
|
+
onSelect: (from: string, to: string) => void;
|
|
8
|
+
/** `"range"` (default) = two-click from→to; `"single"` = one click selects a
|
|
9
|
+
* single day (emitted as `onSelect(iso, iso)`). */
|
|
10
|
+
mode?: "single" | "range";
|
|
11
|
+
/** Optional inclusive ISO bounds; days outside `[min, max]` are disabled. */
|
|
12
|
+
min?: string;
|
|
13
|
+
max?: string;
|
|
14
|
+
/** Screen-reader names for the two month arrows. The package carries no
|
|
15
|
+
* translation catalog (see the README), so every user-facing string is a prop
|
|
16
|
+
* with an English default — these two were the exception, and a German app
|
|
17
|
+
* reached this component through a bare re-export and announced them in
|
|
18
|
+
* English on a page whose `<html lang>` says `de`. */
|
|
19
|
+
labels?: {
|
|
20
|
+
previousMonth?: string;
|
|
21
|
+
nextMonth?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Month-grid range picker used by the data-table date filter. Click once to set
|
|
26
|
+
* the start (and clear the end), click again to set the end; clicking with a
|
|
27
|
+
* full range already selected starts over. Monday-first, locale-aware labels.
|
|
28
|
+
* In `single` mode each click selects one day. Days outside `min`/`max` (when
|
|
29
|
+
* given) are disabled.
|
|
30
|
+
*/
|
|
31
|
+
declare function MiniCalendar({ from, to, locale, onSelect, mode, min, max, labels, }: MiniCalendarProps): react.JSX.Element;
|
|
32
|
+
|
|
33
|
+
export { MiniCalendar, type MiniCalendarProps };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { parseIsoDate, sameYmd, toLocalIso } from "../lib/dates.js";
|
|
6
|
+
function MiniCalendar({
|
|
7
|
+
from,
|
|
8
|
+
to,
|
|
9
|
+
locale,
|
|
10
|
+
onSelect,
|
|
11
|
+
mode = "range",
|
|
12
|
+
min,
|
|
13
|
+
max,
|
|
14
|
+
labels
|
|
15
|
+
}) {
|
|
16
|
+
const fromDate = parseIsoDate(from);
|
|
17
|
+
const toDate = parseIsoDate(to);
|
|
18
|
+
const minDate = min ? parseIsoDate(min) : null;
|
|
19
|
+
const maxDate = max ? parseIsoDate(max) : null;
|
|
20
|
+
const isDisabled = (d) => Boolean(minDate && d < minDate || maxDate && d > maxDate);
|
|
21
|
+
const initial = fromDate ?? toDate ?? /* @__PURE__ */ new Date();
|
|
22
|
+
const [view, setView] = useState({
|
|
23
|
+
year: initial.getFullYear(),
|
|
24
|
+
month: initial.getMonth()
|
|
25
|
+
});
|
|
26
|
+
const [lastKey, setLastKey] = useState(`${from}|${to}`);
|
|
27
|
+
const currentKey = `${from}|${to}`;
|
|
28
|
+
if (currentKey !== lastKey) {
|
|
29
|
+
setLastKey(currentKey);
|
|
30
|
+
const next = parseIsoDate(from) ?? parseIsoDate(to);
|
|
31
|
+
if (next && (next.getFullYear() !== view.year || next.getMonth() !== view.month)) {
|
|
32
|
+
setView({ year: next.getFullYear(), month: next.getMonth() });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const firstOfMonth = new Date(view.year, view.month, 1);
|
|
36
|
+
const lastOfMonth = new Date(view.year, view.month + 1, 0);
|
|
37
|
+
const startWeekday = (firstOfMonth.getDay() + 6) % 7;
|
|
38
|
+
const cells = [];
|
|
39
|
+
for (let i = 0; i < startWeekday; i++) cells.push(null);
|
|
40
|
+
for (let d = 1; d <= lastOfMonth.getDate(); d++) cells.push(new Date(view.year, view.month, d));
|
|
41
|
+
while (cells.length % 7 !== 0) cells.push(null);
|
|
42
|
+
const monthLabel = firstOfMonth.toLocaleDateString(locale, { month: "long", year: "numeric" });
|
|
43
|
+
const weekdayLabels = useMemo(() => {
|
|
44
|
+
const ref = new Date(2024, 0, 1);
|
|
45
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
46
|
+
const d = new Date(ref);
|
|
47
|
+
d.setDate(ref.getDate() + i);
|
|
48
|
+
return d.toLocaleDateString(locale, { weekday: "narrow" });
|
|
49
|
+
});
|
|
50
|
+
}, [locale]);
|
|
51
|
+
const move = (delta) => {
|
|
52
|
+
setView((v) => {
|
|
53
|
+
const d = new Date(v.year, v.month + delta, 1);
|
|
54
|
+
return { year: d.getFullYear(), month: d.getMonth() };
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const handleClick = (d) => {
|
|
58
|
+
const iso = toLocalIso(d);
|
|
59
|
+
if (mode === "single") {
|
|
60
|
+
onSelect(iso, iso);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const f = parseIsoDate(from);
|
|
64
|
+
const tt = parseIsoDate(to);
|
|
65
|
+
if (!f || f && tt) {
|
|
66
|
+
onSelect(iso, "");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (d < f) {
|
|
70
|
+
onSelect(iso, toLocalIso(f));
|
|
71
|
+
} else {
|
|
72
|
+
onSelect(toLocalIso(f), iso);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const today = /* @__PURE__ */ new Date();
|
|
76
|
+
const inRange = (d) => {
|
|
77
|
+
if (!fromDate || !toDate) return false;
|
|
78
|
+
return d >= fromDate && d <= toDate;
|
|
79
|
+
};
|
|
80
|
+
return /* @__PURE__ */ jsxs("div", { className: "select-none", children: [
|
|
81
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between pb-1", children: [
|
|
82
|
+
/* @__PURE__ */ jsx(
|
|
83
|
+
"button",
|
|
84
|
+
{
|
|
85
|
+
type: "button",
|
|
86
|
+
onClick: () => move(-1),
|
|
87
|
+
"aria-label": labels?.previousMonth ?? "Previous month",
|
|
88
|
+
className: "rounded p-1 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
|
|
89
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "size-4" })
|
|
90
|
+
}
|
|
91
|
+
),
|
|
92
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs font-medium capitalize text-slate-700 dark:text-slate-200", children: monthLabel }),
|
|
93
|
+
/* @__PURE__ */ jsx(
|
|
94
|
+
"button",
|
|
95
|
+
{
|
|
96
|
+
type: "button",
|
|
97
|
+
onClick: () => move(1),
|
|
98
|
+
"aria-label": labels?.nextMonth ?? "Next month",
|
|
99
|
+
className: "rounded p-1 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
|
|
100
|
+
children: /* @__PURE__ */ jsx(ChevronRight, { className: "size-4" })
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
] }),
|
|
104
|
+
/* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-0.5 text-center text-[10px] font-medium uppercase text-slate-400 dark:text-slate-500", children: weekdayLabels.map((w, i) => /* @__PURE__ */ jsx("div", { className: "py-0.5", children: w }, i)) }),
|
|
105
|
+
/* @__PURE__ */ jsx("div", { className: "grid grid-cols-7 gap-0.5", children: cells.map((d, i) => {
|
|
106
|
+
if (!d) return /* @__PURE__ */ jsx("div", { className: "h-7" }, i);
|
|
107
|
+
const disabled = isDisabled(d);
|
|
108
|
+
const isStart = !disabled && fromDate && sameYmd(d, fromDate);
|
|
109
|
+
const isEnd = !disabled && toDate && sameYmd(d, toDate);
|
|
110
|
+
const isToday = sameYmd(d, today);
|
|
111
|
+
const isInRange = !disabled && inRange(d) && !isStart && !isEnd;
|
|
112
|
+
return /* @__PURE__ */ jsx(
|
|
113
|
+
"button",
|
|
114
|
+
{
|
|
115
|
+
type: "button",
|
|
116
|
+
disabled,
|
|
117
|
+
onClick: () => handleClick(d),
|
|
118
|
+
className: cn(
|
|
119
|
+
"h-7 text-xs rounded transition-colors",
|
|
120
|
+
disabled ? "cursor-not-allowed text-slate-300 dark:text-slate-600" : isStart || isEnd ? "bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400" : isInRange ? "bg-sky-100 text-sky-800 hover:bg-sky-200 dark:bg-sky-500/20 dark:text-sky-200" : "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
|
|
121
|
+
isToday && !isStart && !isEnd && !isInRange && !disabled && "ring-1 ring-inset ring-slate-300 dark:ring-slate-600"
|
|
122
|
+
),
|
|
123
|
+
children: d.getDate()
|
|
124
|
+
},
|
|
125
|
+
i
|
|
126
|
+
);
|
|
127
|
+
}) })
|
|
128
|
+
] });
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
MiniCalendar
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=mini-calendar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/mini-calendar.tsx"],"sourcesContent":["import { useMemo, useState } from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { parseIsoDate, sameYmd, toLocalIso } from \"../lib/dates\";\n\nexport interface MiniCalendarProps {\n from: string;\n to: string;\n locale: string;\n onSelect: (from: string, to: string) => void;\n /** `\"range\"` (default) = two-click from→to; `\"single\"` = one click selects a\n * single day (emitted as `onSelect(iso, iso)`). */\n mode?: \"single\" | \"range\";\n /** Optional inclusive ISO bounds; days outside `[min, max]` are disabled. */\n min?: string;\n max?: string;\n /** Screen-reader names for the two month arrows. The package carries no\n * translation catalog (see the README), so every user-facing string is a prop\n * with an English default — these two were the exception, and a German app\n * reached this component through a bare re-export and announced them in\n * English on a page whose `<html lang>` says `de`. */\n labels?: { previousMonth?: string; nextMonth?: string };\n}\n\n/**\n * Month-grid range picker used by the data-table date filter. Click once to set\n * the start (and clear the end), click again to set the end; clicking with a\n * full range already selected starts over. Monday-first, locale-aware labels.\n * In `single` mode each click selects one day. Days outside `min`/`max` (when\n * given) are disabled.\n */\nexport function MiniCalendar({\n from,\n to,\n locale,\n onSelect,\n mode = \"range\",\n min,\n max,\n labels,\n}: MiniCalendarProps) {\n const fromDate = parseIsoDate(from);\n const toDate = parseIsoDate(to);\n const minDate = min ? parseIsoDate(min) : null;\n const maxDate = max ? parseIsoDate(max) : null;\n const isDisabled = (d: Date) => Boolean((minDate && d < minDate) || (maxDate && d > maxDate));\n const initial = fromDate ?? toDate ?? new Date();\n const [view, setView] = useState<{ year: number; month: number }>({\n year: initial.getFullYear(),\n month: initial.getMonth(),\n });\n const [lastKey, setLastKey] = useState(`${from}|${to}`);\n const currentKey = `${from}|${to}`;\n if (currentKey !== lastKey) {\n setLastKey(currentKey);\n const next = parseIsoDate(from) ?? parseIsoDate(to);\n if (next && (next.getFullYear() !== view.year || next.getMonth() !== view.month)) {\n setView({ year: next.getFullYear(), month: next.getMonth() });\n }\n }\n\n const firstOfMonth = new Date(view.year, view.month, 1);\n const lastOfMonth = new Date(view.year, view.month + 1, 0);\n // Monday-first grid\n const startWeekday = (firstOfMonth.getDay() + 6) % 7;\n const cells: (Date | null)[] = [];\n for (let i = 0; i < startWeekday; i++) cells.push(null);\n for (let d = 1; d <= lastOfMonth.getDate(); d++) cells.push(new Date(view.year, view.month, d));\n while (cells.length % 7 !== 0) cells.push(null);\n\n const monthLabel = firstOfMonth.toLocaleDateString(locale, { month: \"long\", year: \"numeric\" });\n\n // Monday..Sunday short weekday labels via locale\n const weekdayLabels = useMemo(() => {\n const ref = new Date(2024, 0, 1); // a Monday\n return Array.from({ length: 7 }, (_, i) => {\n const d = new Date(ref);\n d.setDate(ref.getDate() + i);\n return d.toLocaleDateString(locale, { weekday: \"narrow\" });\n });\n }, [locale]);\n\n const move = (delta: number) => {\n setView((v) => {\n const d = new Date(v.year, v.month + delta, 1);\n return { year: d.getFullYear(), month: d.getMonth() };\n });\n };\n\n const handleClick = (d: Date) => {\n const iso = toLocalIso(d);\n if (mode === \"single\") {\n onSelect(iso, iso);\n return;\n }\n const f = parseIsoDate(from);\n const tt = parseIsoDate(to);\n if (!f || (f && tt)) {\n // start a new selection\n onSelect(iso, \"\");\n return;\n }\n // f set, tt empty\n if (d < f) {\n onSelect(iso, toLocalIso(f));\n } else {\n onSelect(toLocalIso(f), iso);\n }\n };\n\n const today = new Date();\n const inRange = (d: Date) => {\n if (!fromDate || !toDate) return false;\n return d >= fromDate && d <= toDate;\n };\n\n return (\n <div className=\"select-none\">\n <div className=\"flex items-center justify-between pb-1\">\n <button\n type=\"button\"\n onClick={() => move(-1)}\n aria-label={labels?.previousMonth ?? \"Previous month\"}\n className=\"rounded p-1 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800\"\n >\n <ChevronLeft className=\"size-4\" />\n </button>\n <div className=\"text-xs font-medium capitalize text-slate-700 dark:text-slate-200\">{monthLabel}</div>\n <button\n type=\"button\"\n onClick={() => move(1)}\n aria-label={labels?.nextMonth ?? \"Next month\"}\n className=\"rounded p-1 text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800\"\n >\n <ChevronRight className=\"size-4\" />\n </button>\n </div>\n <div className=\"grid grid-cols-7 gap-0.5 text-center text-[10px] font-medium uppercase text-slate-400 dark:text-slate-500\">\n {weekdayLabels.map((w, i) => (\n <div key={i} className=\"py-0.5\">{w}</div>\n ))}\n </div>\n <div className=\"grid grid-cols-7 gap-0.5\">\n {cells.map((d, i) => {\n if (!d) return <div key={i} className=\"h-7\" />;\n const disabled = isDisabled(d);\n const isStart = !disabled && fromDate && sameYmd(d, fromDate);\n const isEnd = !disabled && toDate && sameYmd(d, toDate);\n const isToday = sameYmd(d, today);\n const isInRange = !disabled && inRange(d) && !isStart && !isEnd;\n return (\n <button\n key={i}\n type=\"button\"\n disabled={disabled}\n onClick={() => handleClick(d)}\n className={cn(\n \"h-7 text-xs rounded transition-colors\",\n disabled\n ? \"cursor-not-allowed text-slate-300 dark:text-slate-600\"\n : isStart || isEnd\n ? \"bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400\"\n : isInRange\n ? \"bg-sky-100 text-sky-800 hover:bg-sky-200 dark:bg-sky-500/20 dark:text-sky-200\"\n : \"text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800\",\n isToday && !isStart && !isEnd && !isInRange && !disabled && \"ring-1 ring-inset ring-slate-300 dark:ring-slate-600\",\n )}\n >\n {d.getDate()}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, KeyboardEvent, MouseEvent } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Backdrop handlers that close only when a press starts AND ends on the
|
|
6
|
+
* backdrop itself. A plain onClick fires on mouseup, so dragging a text
|
|
7
|
+
* selection that starts inside the panel and releases over the backdrop would
|
|
8
|
+
* dismiss the dialog (feedback #254) — and the reverse drag would too, since
|
|
9
|
+
* click targets the common ancestor of the down/up elements.
|
|
10
|
+
*/
|
|
11
|
+
declare function useBackdropClose(onClose: () => void): {
|
|
12
|
+
onMouseDown: (e: MouseEvent<HTMLDivElement>) => void;
|
|
13
|
+
onMouseUp: (e: MouseEvent<HTMLDivElement>) => void;
|
|
14
|
+
};
|
|
15
|
+
interface ModalProps {
|
|
16
|
+
/** Invoked on backdrop click and on Escape. */
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).
|
|
21
|
+
*
|
|
22
|
+
* `xl` exists because two callers had already written `className="max-w-3xl"` to
|
|
23
|
+
* reach it — the payee backfill's preview table and the price-merge dialog, both
|
|
24
|
+
* of which hold a list and several fields rather than one question. A caller
|
|
25
|
+
* patching a missing size IS the signal that the size is missing, and a
|
|
26
|
+
* hand-written width in a `className` is a width nothing else can line up with.
|
|
27
|
+
*
|
|
28
|
+
* The panel is `w-full` under every one of them, so the cap only bites once the
|
|
29
|
+
* viewport is wider than it: on a phone all three are the same full-width bottom
|
|
30
|
+
* sheet. Nothing here may introduce a `min-w-*`, which is what would break that.
|
|
31
|
+
*/
|
|
32
|
+
size?: "md" | "lg" | "xl";
|
|
33
|
+
/** Extra classes for the panel (e.g. `space-y-3` for form content spacing). */
|
|
34
|
+
className?: string;
|
|
35
|
+
/** id of the heading element, wired to `aria-labelledby`. */
|
|
36
|
+
labelledBy?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Extra key handling on the panel (e.g. Ctrl/Cmd+Enter to submit). Runs first;
|
|
39
|
+
* if it calls `preventDefault()` the built-in Escape/Tab handling is skipped
|
|
40
|
+
* for that event.
|
|
41
|
+
*/
|
|
42
|
+
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Drop the backdrop's mobile margin so the panel can reach the screen edges
|
|
45
|
+
* (a full-screen sheet on phones). The panel still needs its own
|
|
46
|
+
* `h-[100dvh] max-w-full rounded-none` classes via `className` to fill it; the
|
|
47
|
+
* desktop `md:p-4` margin is kept. Defaults to false (padded on all sizes).
|
|
48
|
+
*/
|
|
49
|
+
fullBleed?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Let the user drag the panel out of the way by its top edge (Keksdose feedback
|
|
52
|
+
* dev#460: *"Make the feedback dialog draggable so I can see behind it if it
|
|
53
|
+
* blocks something."*).
|
|
54
|
+
*
|
|
55
|
+
* Opt-in, because it only makes sense for a dialog whose content is ABOUT the page
|
|
56
|
+
* behind it — the feedback composer is the case, an ordinary form is not, and a
|
|
57
|
+
* draggable panel that has nothing to reveal is a control that can only get lost.
|
|
58
|
+
*
|
|
59
|
+
* Pointer-only by design: it is a "move this out of the way" gesture, not a layout
|
|
60
|
+
* choice worth persisting, and the position resets when the dialog closes. On a
|
|
61
|
+
* phone the panel is a bottom sheet the width of the screen, so dragging is
|
|
62
|
+
* suppressed there — there is nothing beside it to see.
|
|
63
|
+
*/
|
|
64
|
+
draggable?: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Centered (bottom-sheet on mobile) modal dialog: dimmed backdrop, single
|
|
68
|
+
* rounded panel. Owns the overlay mechanics that the hand-rolled dialogs each
|
|
69
|
+
* re-implemented or lacked — backdrop-click + Escape to close, the platform Back
|
|
70
|
+
* gesture (Keksdose feedback #172), background scroll lock (feedback #204), focus
|
|
71
|
+
* into the panel on open and back to the trigger on close, a Tab focus trap, and
|
|
72
|
+
* `role="dialog"`/`aria-modal`.
|
|
73
|
+
*/
|
|
74
|
+
declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, }: ModalProps): react.ReactPortal;
|
|
75
|
+
|
|
76
|
+
export { Modal, type ModalProps, useBackdropClose };
|