@eifi1/ui-kit 0.7.1 → 0.8.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 +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +26 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +2 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +87 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +48 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +10 -2
- package/src/components/ui.tsx +263 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- package/src/wizard.ts +3 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { dirOf } from "../lib/direction.js";
|
|
8
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
9
|
+
const DEFAULT_FLOATING_PANEL_LABELS = { close: "Close" };
|
|
10
|
+
const BOTTOM_CLEARANCE = "max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))";
|
|
11
|
+
const SIDE_GUTTER = "max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))";
|
|
12
|
+
function cornerStyle(corner, lift) {
|
|
13
|
+
return {
|
|
14
|
+
bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,
|
|
15
|
+
// Logical properties, so the corner follows the reading direction with no JS. The
|
|
16
|
+
// portal carries the `dir` across (see `usePortalDir`).
|
|
17
|
+
[corner === "bottom-start" ? "insetInlineStart" : "insetInlineEnd"]: SIDE_GUTTER
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function usePortalDir() {
|
|
21
|
+
const marker = useRef(null);
|
|
22
|
+
const [dir, setDir] = useState(void 0);
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
setDir(dirOf(marker.current));
|
|
25
|
+
}, []);
|
|
26
|
+
return [marker, dir];
|
|
27
|
+
}
|
|
28
|
+
function FloatingActionButton({
|
|
29
|
+
label,
|
|
30
|
+
icon,
|
|
31
|
+
corner = "bottom-end",
|
|
32
|
+
offset = "1rem",
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
type = "button",
|
|
36
|
+
...rest
|
|
37
|
+
}) {
|
|
38
|
+
const [marker, dir] = usePortalDir();
|
|
39
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
40
|
+
/* @__PURE__ */ jsx("span", { ref: marker, hidden: true }),
|
|
41
|
+
typeof document !== "undefined" && createPortal(
|
|
42
|
+
/* @__PURE__ */ jsx(
|
|
43
|
+
"button",
|
|
44
|
+
{
|
|
45
|
+
...rest,
|
|
46
|
+
type,
|
|
47
|
+
dir,
|
|
48
|
+
"aria-label": label,
|
|
49
|
+
title: rest.title ?? label,
|
|
50
|
+
style: { ...cornerStyle(corner, offset), ...style },
|
|
51
|
+
className: cn(
|
|
52
|
+
"fixed z-40 inline-flex size-12 items-center justify-center rounded-full",
|
|
53
|
+
"bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]",
|
|
54
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2",
|
|
55
|
+
"[&_svg]:size-6",
|
|
56
|
+
className
|
|
57
|
+
),
|
|
58
|
+
children: icon
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
document.body
|
|
62
|
+
)
|
|
63
|
+
] });
|
|
64
|
+
}
|
|
65
|
+
function FloatingPanel({
|
|
66
|
+
title,
|
|
67
|
+
fabLabel,
|
|
68
|
+
fabIcon,
|
|
69
|
+
open: openProp,
|
|
70
|
+
defaultOpen = false,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
corner = "bottom-end",
|
|
73
|
+
offset = "1rem",
|
|
74
|
+
closeLabel,
|
|
75
|
+
initialFocus,
|
|
76
|
+
className,
|
|
77
|
+
bodyClassName,
|
|
78
|
+
children
|
|
79
|
+
}) {
|
|
80
|
+
const labels = useKitLabels(
|
|
81
|
+
"floatingPanel",
|
|
82
|
+
DEFAULT_FLOATING_PANEL_LABELS,
|
|
83
|
+
closeLabel === void 0 ? void 0 : { close: closeLabel }
|
|
84
|
+
);
|
|
85
|
+
const [ownOpen, setOwnOpen] = useState(defaultOpen);
|
|
86
|
+
const open = openProp ?? ownOpen;
|
|
87
|
+
const setOpen = (next) => {
|
|
88
|
+
if (openProp === void 0) setOwnOpen(next);
|
|
89
|
+
onOpenChange?.(next);
|
|
90
|
+
};
|
|
91
|
+
const uid = useId();
|
|
92
|
+
const panelId = `${uid}-panel`;
|
|
93
|
+
const titleId = `${uid}-title`;
|
|
94
|
+
const fabRef = useRef(null);
|
|
95
|
+
const panelRef = useRef(null);
|
|
96
|
+
const [marker, dir] = usePortalDir();
|
|
97
|
+
const initialFocusRef = useRef(initialFocus);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
initialFocusRef.current = initialFocus;
|
|
100
|
+
});
|
|
101
|
+
const wasOpen = useRef(open);
|
|
102
|
+
const focusInside = useRef(false);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const changed = wasOpen.current !== open;
|
|
105
|
+
wasOpen.current = open;
|
|
106
|
+
if (!changed) return;
|
|
107
|
+
if (open) {
|
|
108
|
+
(initialFocusRef.current?.() ?? panelRef.current)?.focus();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const active = document.activeElement;
|
|
112
|
+
const lost = active === null || active === document.body;
|
|
113
|
+
if (focusInside.current || lost) fabRef.current?.focus();
|
|
114
|
+
focusInside.current = false;
|
|
115
|
+
}, [open]);
|
|
116
|
+
const onPanelKeyDown = (e) => {
|
|
117
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
118
|
+
e.stopPropagation();
|
|
119
|
+
focusInside.current = true;
|
|
120
|
+
setOpen(false);
|
|
121
|
+
};
|
|
122
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
123
|
+
/* @__PURE__ */ jsx("span", { ref: marker, hidden: true }),
|
|
124
|
+
/* @__PURE__ */ jsx(
|
|
125
|
+
FloatingActionButton,
|
|
126
|
+
{
|
|
127
|
+
ref: fabRef,
|
|
128
|
+
label: fabLabel,
|
|
129
|
+
icon: fabIcon,
|
|
130
|
+
corner,
|
|
131
|
+
offset,
|
|
132
|
+
"aria-expanded": open,
|
|
133
|
+
"aria-controls": open ? panelId : void 0,
|
|
134
|
+
onClick: () => {
|
|
135
|
+
focusInside.current = false;
|
|
136
|
+
setOpen(!open);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
),
|
|
140
|
+
open && typeof document !== "undefined" && createPortal(
|
|
141
|
+
// A container for Escape bubbling up from anything inside it, as a dialog is.
|
|
142
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
143
|
+
/* @__PURE__ */ jsxs(
|
|
144
|
+
"div",
|
|
145
|
+
{
|
|
146
|
+
ref: panelRef,
|
|
147
|
+
id: panelId,
|
|
148
|
+
role: "dialog",
|
|
149
|
+
"aria-labelledby": titleId,
|
|
150
|
+
tabIndex: -1,
|
|
151
|
+
dir,
|
|
152
|
+
"data-corner": corner,
|
|
153
|
+
onKeyDown: onPanelKeyDown,
|
|
154
|
+
onFocus: () => {
|
|
155
|
+
focusInside.current = true;
|
|
156
|
+
},
|
|
157
|
+
onBlur: (e) => {
|
|
158
|
+
if (!e.currentTarget.contains(e.relatedTarget)) focusInside.current = false;
|
|
159
|
+
},
|
|
160
|
+
style: {
|
|
161
|
+
"--fp-bottom": `calc(${BOTTOM_CLEARANCE})`,
|
|
162
|
+
"--fp-bottom-md": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,
|
|
163
|
+
"--fp-side": SIDE_GUTTER
|
|
164
|
+
},
|
|
165
|
+
className: cn(
|
|
166
|
+
"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none",
|
|
167
|
+
"border border-[var(--border)] bg-[var(--bg-surface)] shadow-2xl",
|
|
168
|
+
// Phone: full width, docked, rounded only where it meets the page.
|
|
169
|
+
"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0",
|
|
170
|
+
"pb-[env(safe-area-inset-bottom,0px)] md:pb-0",
|
|
171
|
+
// md+: a card in the corner, above the FAB.
|
|
172
|
+
"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border",
|
|
173
|
+
corner === "bottom-start" ? "md:start-[var(--fp-side)]" : "md:end-[var(--fp-side)]",
|
|
174
|
+
className
|
|
175
|
+
),
|
|
176
|
+
children: [
|
|
177
|
+
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3", children: [
|
|
178
|
+
/* @__PURE__ */ jsx("h2", { id: titleId, className: "min-w-0 truncate text-base font-semibold text-[var(--text-primary)]", children: title }),
|
|
179
|
+
/* @__PURE__ */ jsx(
|
|
180
|
+
"button",
|
|
181
|
+
{
|
|
182
|
+
type: "button",
|
|
183
|
+
onClick: () => {
|
|
184
|
+
focusInside.current = true;
|
|
185
|
+
setOpen(false);
|
|
186
|
+
},
|
|
187
|
+
"aria-label": labels.close,
|
|
188
|
+
className: "-me-1.5 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
|
|
189
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true, className: "size-5" })
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
] }),
|
|
193
|
+
/* @__PURE__ */ jsx("div", { className: cn("min-h-0 flex-1 overflow-y-auto overscroll-contain p-4", bodyClassName), children })
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
),
|
|
197
|
+
document.body
|
|
198
|
+
)
|
|
199
|
+
] });
|
|
200
|
+
}
|
|
201
|
+
export {
|
|
202
|
+
DEFAULT_FLOATING_PANEL_LABELS,
|
|
203
|
+
FloatingActionButton,
|
|
204
|
+
FloatingPanel
|
|
205
|
+
};
|
|
206
|
+
//# sourceMappingURL=floating-panel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/floating-panel.tsx"],"sourcesContent":["import { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ButtonHTMLAttributes, CSSProperties, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\nexport interface FloatingPanelLabels {\n /** Accessible name of the panel header's X. */\n close: string;\n}\n\nexport const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels = { close: \"Close\" };\n\n/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */\nexport type FloatingCorner = \"bottom-end\" | \"bottom-start\";\n\n/**\n * How far off the bottom edge anything floating here sits: clear of `AppShell`'s phone\n * nav (`--app-nav-h`, `0px` above `md` where the nav is not rendered) and of the home\n * indicator, whichever reaches higher — the nav does not pad itself for the inset, so\n * with no nav on screen the inset is all there is to clear. `max`, not a sum: where the\n * nav does cover the inset, adding both would float the button a nav-height too high.\n */\nconst BOTTOM_CLEARANCE = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n/** The side gutter, widened for a landscape notch. Both insets, because a logical\n * side has no one physical inset to name. */\nconst SIDE_GUTTER = \"max(1rem, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))\";\n\nfunction cornerStyle(corner: FloatingCorner, lift: string): CSSProperties {\n return {\n bottom: `calc(${BOTTOM_CLEARANCE} + ${lift})`,\n // Logical properties, so the corner follows the reading direction with no JS. The\n // portal carries the `dir` across (see `usePortalDir`).\n [corner === \"bottom-start\" ? \"insetInlineStart\" : \"insetInlineEnd\"]: SIDE_GUTTER,\n };\n}\n\n/**\n * The reading direction where the component was RENDERED, for its portalled root: a\n * portal to `<body>` leaves the subtree whose `dir` it inherited, and a logical\n * `inset-inline-end` resolves against the portal's own direction. Read off a hidden\n * inline marker in a layout effect, so the first painted frame already has it.\n */\nfunction usePortalDir(): [Ref<HTMLSpanElement>, Direction | undefined] {\n const marker = useRef<HTMLSpanElement>(null);\n const [dir, setDir] = useState<Direction | undefined>(undefined);\n useLayoutEffect(() => {\n setDir(dirOf(marker.current));\n }, []);\n return [marker, dir];\n}\n\nexport interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"children\"> {\n /** The accessible name — the button shows only its icon. */\n label: string;\n icon: ReactNode;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /**\n * Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it\n * to stack above another floating control in the same corner — keksdose's assistant\n * clears the transactions page's own action group this way (`calc(1rem + 4rem)`).\n */\n offset?: string;\n ref?: Ref<HTMLButtonElement>;\n}\n\n/**\n * A round, icon-only button pinned to a corner of the viewport — the trigger of a\n * {@link FloatingPanel}, and usable alone for a page's one primary action.\n *\n * keksdose hand-placed each of its floating controls: the assistant launcher at\n * `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions\n * page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess\n * at the phone nav's height plus whatever else floats there (Keksdose live #314 found\n * out what such a guess is worth for a footer), `right` rather than `end` puts it over\n * the page's leading edge in RTL, and none allows for the home indicator. Here the\n * base is `--app-nav-h` and the safe-area inset, the corner is logical, and\n * {@link FloatingActionButtonProps.offset} is the one number left to the caller — the\n * height of whatever else it has to clear, not the nav's.\n *\n * Portalled, because `position: fixed` inside any transformed ancestor (an animated\n * page transition) is fixed to that ancestor, not to the screen.\n */\nexport function FloatingActionButton({\n label,\n icon,\n corner = \"bottom-end\",\n offset = \"1rem\",\n className,\n style,\n type = \"button\",\n ...rest\n}: FloatingActionButtonProps) {\n const [marker, dir] = usePortalDir();\n return (\n <>\n <span ref={marker} hidden />\n {typeof document !== \"undefined\" &&\n createPortal(\n <button\n {...rest}\n type={type}\n dir={dir}\n aria-label={label}\n title={rest.title ?? label}\n style={{ ...cornerStyle(corner, offset), ...style }}\n className={cn(\n \"fixed z-40 inline-flex size-12 items-center justify-center rounded-full\",\n \"bg-[var(--brand)] text-[var(--brand-contrast)] shadow-lg transition-colors hover:bg-[var(--brand-hover)]\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2\",\n \"[&_svg]:size-6\",\n className,\n )}\n >\n {icon}\n </button>,\n document.body,\n )}\n </>\n );\n}\n\nexport interface FloatingPanelProps {\n /** The panel's heading, and its accessible name. */\n title: ReactNode;\n /** The trigger's accessible name (\"Assistant\", \"Send feedback\"). */\n fabLabel: string;\n fabIcon: ReactNode;\n /** Controlled open state. Leave it out to let the panel own it. */\n open?: boolean;\n /** Uncontrolled initial state. Default closed. */\n defaultOpen?: boolean;\n /** Every request to open or close — the FAB, the X, Escape. */\n onOpenChange?: (open: boolean) => void;\n /** Default `bottom-end`. */\n corner?: FloatingCorner;\n /** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.\n * From `md` up the card sits above the FAB, so it moves with it. */\n offset?: string;\n /** Default: `floatingPanel.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /**\n * Where focus goes on open. Default the panel itself, so a phone's keyboard does not\n * rise over the sheet before the user has asked to type; pass the chat field for a\n * panel whose one job is typing.\n */\n initialFocus?: () => HTMLElement | null;\n /** Extra classes for the panel. */\n className?: string;\n /** Extra classes for the scrolling body. */\n bodyClassName?: string;\n children: ReactNode;\n}\n\n/**\n * A docked, NON-MODAL panel in a corner of the screen, opened by a\n * {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,\n * which each built one.\n *\n * ## Not a Modal\n *\n * The page stays usable while it is open: that is the point of an assistant beside the\n * data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and\n * no focus trap — Tab walks out of the panel into the page and back, and a click on the\n * page does not close it. It is `role=\"dialog\"` without `aria-modal`, which is what\n * ARIA calls a non-modal dialog, named by its heading.\n *\n * What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a\n * keyboard user who pressed the FAB should not have to hunt for what appeared), and\n * closing gives it back to the FAB — but only when focus was in the panel, so a panel\n * closed from the page (a controlled owner) never pulls focus out of the field the\n * user is typing in. Escape closes it while focus is inside, and only then: on the\n * panel element, not the document, so Escape in the page keeps meaning whatever the\n * page means by it.\n *\n * ## Layout\n *\n * From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet\n * docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and\n * clear of the home indicator. Pure CSS — no width is measured in JavaScript.\n *\n * The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The\n * panel unmounts when closed; an app that must keep a draft across a close keeps it in\n * its own state, where it also survives a reload.\n */\nexport function FloatingPanel({\n title,\n fabLabel,\n fabIcon,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n corner = \"bottom-end\",\n offset = \"1rem\",\n closeLabel,\n initialFocus,\n className,\n bodyClassName,\n children,\n}: FloatingPanelProps) {\n const labels = useKitLabels(\n \"floatingPanel\",\n DEFAULT_FLOATING_PANEL_LABELS,\n closeLabel === undefined ? undefined : { close: closeLabel },\n );\n const [ownOpen, setOwnOpen] = useState(defaultOpen);\n const open = openProp ?? ownOpen;\n const setOpen = (next: boolean) => {\n if (openProp === undefined) setOwnOpen(next);\n onOpenChange?.(next);\n };\n\n const uid = useId();\n const panelId = `${uid}-panel`;\n const titleId = `${uid}-title`;\n const fabRef = useRef<HTMLButtonElement>(null);\n const panelRef = useRef<HTMLDivElement>(null);\n const [marker, dir] = usePortalDir();\n\n const initialFocusRef = useRef(initialFocus);\n useEffect(() => {\n initialFocusRef.current = initialFocus;\n });\n\n // Focus follows the open/closed TRANSITION, never the mount: a panel that renders\n // open (`defaultOpen`, a restored route) does not take the page's focus by existing.\n const wasOpen = useRef(open);\n // Whether focus was inside the panel when it last changed — read at close time, when\n // the panel (and whatever had focus in it) is already gone.\n const focusInside = useRef(false);\n useEffect(() => {\n const changed = wasOpen.current !== open;\n wasOpen.current = open;\n if (!changed) return;\n if (open) {\n (initialFocusRef.current?.() ?? panelRef.current)?.focus();\n return;\n }\n const active = document.activeElement;\n const lost = active === null || active === document.body;\n if (focusInside.current || lost) fabRef.current?.focus();\n focusInside.current = false;\n }, [open]);\n\n const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\" || e.defaultPrevented) return;\n // Stopped here, so an overlay underneath with a document-level Escape listener does\n // not close too — the innermost thing that has focus is the one that closes.\n e.stopPropagation();\n focusInside.current = true;\n setOpen(false);\n };\n\n return (\n <>\n <span ref={marker} hidden />\n <FloatingActionButton\n ref={fabRef}\n label={fabLabel}\n icon={fabIcon}\n corner={corner}\n offset={offset}\n aria-expanded={open}\n aria-controls={open ? panelId : undefined}\n onClick={() => {\n // A press ON the FAB closing the panel: the FAB keeps focus by being clicked,\n // nothing to hand back.\n focusInside.current = false;\n setOpen(!open);\n }}\n />\n {open &&\n typeof document !== \"undefined\" &&\n createPortal(\n // A container for Escape bubbling up from anything inside it, as a dialog is.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n ref={panelRef}\n id={panelId}\n role=\"dialog\"\n aria-labelledby={titleId}\n tabIndex={-1}\n dir={dir}\n data-corner={corner}\n onKeyDown={onPanelKeyDown}\n onFocus={() => {\n focusInside.current = true;\n }}\n onBlur={(e) => {\n if (!e.currentTarget.contains(e.relatedTarget as Node | null)) focusInside.current = false;\n }}\n // The phone's sheet sits on the nav; from `md` up the card floats above the\n // FAB (its offset + the 3rem button + a 0.75rem gap).\n style={\n {\n \"--fp-bottom\": `calc(${BOTTOM_CLEARANCE})`,\n \"--fp-bottom-md\": `calc(${BOTTOM_CLEARANCE} + ${offset} + 3.75rem)`,\n \"--fp-side\": SIDE_GUTTER,\n } as CSSProperties\n }\n className={cn(\n \"animate-sheet fixed z-40 flex flex-col overflow-hidden outline-none\",\n \"border border-[var(--border)] bg-[var(--bg-surface)] shadow-2xl\",\n // Phone: full width, docked, rounded only where it meets the page.\n \"inset-x-0 bottom-[var(--fp-bottom)] max-h-[min(85dvh,calc(100dvh_-_var(--fp-bottom)_-_1rem))] rounded-t-xl border-x-0 border-b-0\",\n \"pb-[env(safe-area-inset-bottom,0px)] md:pb-0\",\n // md+: a card in the corner, above the FAB.\n \"md:inset-x-auto md:bottom-[var(--fp-bottom-md)] md:w-[24rem] md:max-h-[min(36rem,calc(100dvh_-_var(--fp-bottom-md)_-_1rem))] md:rounded-xl md:border\",\n corner === \"bottom-start\" ? \"md:start-[var(--fp-side)]\" : \"md:end-[var(--fp-side)]\",\n className,\n )}\n >\n <div className=\"flex shrink-0 items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3\">\n <h2 id={titleId} className=\"min-w-0 truncate text-base font-semibold text-[var(--text-primary)]\">\n {title}\n </h2>\n <button\n type=\"button\"\n onClick={() => {\n focusInside.current = true;\n setOpen(false);\n }}\n aria-label={labels.close}\n className=\"-me-1.5 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]\"\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n </div>\n <div className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-4\", bodyClassName)}>\n {children}\n </div>\n </div>,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":";AAmGI,mBACE,KADF;AAnGJ,SAAS,WAAW,OAAO,iBAAiB,QAAQ,gBAAgB;AACpE,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,aAA6B;AACtC,SAAS,oBAAoB;AAOtB,MAAM,gCAAqD,EAAE,OAAO,QAAQ;AAYnF,MAAM,mBAAmB;AAGzB,MAAM,cAAc;AAEpB,SAAS,YAAY,QAAwB,MAA6B;AACxE,SAAO;AAAA,IACL,QAAQ,QAAQ,gBAAgB,MAAM,IAAI;AAAA;AAAA;AAAA,IAG1C,CAAC,WAAW,iBAAiB,qBAAqB,gBAAgB,GAAG;AAAA,EACvE;AACF;AAQA,SAAS,eAA8D;AACrE,QAAM,SAAS,OAAwB,IAAI;AAC3C,QAAM,CAAC,KAAK,MAAM,IAAI,SAAgC,MAAS;AAC/D,kBAAgB,MAAM;AACpB,WAAO,MAAM,OAAO,OAAO,CAAC;AAAA,EAC9B,GAAG,CAAC,CAAC;AACL,SAAO,CAAC,QAAQ,GAAG;AACrB;AAkCO,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,GAAG;AACL,GAA8B;AAC5B,QAAM,CAAC,QAAQ,GAAG,IAAI,aAAa;AACnC,SACE,iCACE;AAAA,wBAAC,UAAK,KAAK,QAAQ,QAAM,MAAC;AAAA,IACzB,OAAO,aAAa,eACnB;AAAA,MACE;AAAA,QAAC;AAAA;AAAA,UACE,GAAG;AAAA,UACJ;AAAA,UACA;AAAA,UACA,cAAY;AAAA,UACZ,OAAO,KAAK,SAAS;AAAA,UACrB,OAAO,EAAE,GAAG,YAAY,QAAQ,MAAM,GAAG,GAAG,MAAM;AAAA,UAClD,WAAW;AAAA,YACT;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,UACF;AAAA,UAEC;AAAA;AAAA,MACH;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;AAiEO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,cAAc;AAAA,EACd;AAAA,EACA,SAAS;AAAA,EACT,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAuB;AACrB,QAAM,SAAS;AAAA,IACb;AAAA,IACA;AAAA,IACA,eAAe,SAAY,SAAY,EAAE,OAAO,WAAW;AAAA,EAC7D;AACA,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,WAAW;AAClD,QAAM,OAAO,YAAY;AACzB,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,aAAa,OAAW,YAAW,IAAI;AAC3C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,MAAM,MAAM;AAClB,QAAM,UAAU,GAAG,GAAG;AACtB,QAAM,UAAU,GAAG,GAAG;AACtB,QAAM,SAAS,OAA0B,IAAI;AAC7C,QAAM,WAAW,OAAuB,IAAI;AAC5C,QAAM,CAAC,QAAQ,GAAG,IAAI,aAAa;AAEnC,QAAM,kBAAkB,OAAO,YAAY;AAC3C,YAAU,MAAM;AACd,oBAAgB,UAAU;AAAA,EAC5B,CAAC;AAID,QAAM,UAAU,OAAO,IAAI;AAG3B,QAAM,cAAc,OAAO,KAAK;AAChC,YAAU,MAAM;AACd,UAAM,UAAU,QAAQ,YAAY;AACpC,YAAQ,UAAU;AAClB,QAAI,CAAC,QAAS;AACd,QAAI,MAAM;AACR,OAAC,gBAAgB,UAAU,KAAK,SAAS,UAAU,MAAM;AACzD;AAAA,IACF;AACA,UAAM,SAAS,SAAS;AACxB,UAAM,OAAO,WAAW,QAAQ,WAAW,SAAS;AACpD,QAAI,YAAY,WAAW,KAAM,QAAO,SAAS,MAAM;AACvD,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,IAAI,CAAC;AAET,QAAM,iBAAiB,CAAC,MAAqC;AAC3D,QAAI,EAAE,QAAQ,YAAY,EAAE,iBAAkB;AAG9C,MAAE,gBAAgB;AAClB,gBAAY,UAAU;AACtB,YAAQ,KAAK;AAAA,EACf;AAEA,SACE,iCACE;AAAA,wBAAC,UAAK,KAAK,QAAQ,QAAM,MAAC;AAAA,IAC1B;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN;AAAA,QACA;AAAA,QACA,iBAAe;AAAA,QACf,iBAAe,OAAO,UAAU;AAAA,QAChC,SAAS,MAAM;AAGb,sBAAY,UAAU;AACtB,kBAAQ,CAAC,IAAI;AAAA,QACf;AAAA;AAAA,IACF;AAAA,IACC,QACC,OAAO,aAAa,eACpB;AAAA;AAAA;AAAA,MAGE;AAAA,QAAC;AAAA;AAAA,UACC,KAAK;AAAA,UACL,IAAI;AAAA,UACJ,MAAK;AAAA,UACL,mBAAiB;AAAA,UACjB,UAAU;AAAA,UACV;AAAA,UACA,eAAa;AAAA,UACb,WAAW;AAAA,UACX,SAAS,MAAM;AACb,wBAAY,UAAU;AAAA,UACxB;AAAA,UACA,QAAQ,CAAC,MAAM;AACb,gBAAI,CAAC,EAAE,cAAc,SAAS,EAAE,aAA4B,EAAG,aAAY,UAAU;AAAA,UACvF;AAAA,UAGA,OACE;AAAA,YACE,eAAe,QAAQ,gBAAgB;AAAA,YACvC,kBAAkB,QAAQ,gBAAgB,MAAM,MAAM;AAAA,YACtD,aAAa;AAAA,UACf;AAAA,UAEF,WAAW;AAAA,YACT;AAAA,YACA;AAAA;AAAA,YAEA;AAAA,YACA;AAAA;AAAA,YAEA;AAAA,YACA,WAAW,iBAAiB,8BAA8B;AAAA,YAC1D;AAAA,UACF;AAAA,UAEA;AAAA,iCAAC,SAAI,WAAU,8FACb;AAAA,kCAAC,QAAG,IAAI,SAAS,WAAU,uEACxB,iBACH;AAAA,cACA;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS,MAAM;AACb,gCAAY,UAAU;AACtB,4BAAQ,KAAK;AAAA,kBACf;AAAA,kBACA,cAAY,OAAO;AAAA,kBACnB,WAAU;AAAA,kBAEV,8BAAC,KAAE,eAAW,MAAC,WAAU,UAAS;AAAA;AAAA,cACpC;AAAA,eACF;AAAA,YACA,oBAAC,SAAI,WAAW,GAAG,yDAAyD,aAAa,GACtF,UACH;AAAA;AAAA;AAAA,MACF;AAAA,MACA,SAAS;AAAA,IACX;AAAA,KACJ;AAEJ;","names":[]}
|
|
@@ -34,6 +34,17 @@ interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
34
34
|
backCloses?: boolean;
|
|
35
35
|
/** Extra classes for the PANEL (not the backdrop). */
|
|
36
36
|
className?: string;
|
|
37
|
+
/**
|
|
38
|
+
* A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`
|
|
39
|
+
* (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of
|
|
40
|
+
* exactly this slot: a date range picked on a phone is committed by a sticky Apply,
|
|
41
|
+
* and a button at the end of the body scrolls away with the calendar.
|
|
42
|
+
*
|
|
43
|
+
* Outside the scroller, so it stays put without `position: sticky` (which would
|
|
44
|
+
* need the body to be the scroll container of its own child and paint over the
|
|
45
|
+
* last field); padded for the home indicator, since it is the panel's bottom edge.
|
|
46
|
+
*/
|
|
47
|
+
footer?: ReactNode;
|
|
37
48
|
}
|
|
38
49
|
/**
|
|
39
50
|
* The phone's full-screen dialog: a panel that covers the viewport edge to edge.
|
|
@@ -63,6 +74,6 @@ interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
63
74
|
* editor shows the row's own first column, the create card a plain title. And `open`,
|
|
64
75
|
* because who decides is the caller's business — see {@link backCloses}.
|
|
65
76
|
*/
|
|
66
|
-
declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, ...rest }: FullBleedDialogProps): react.ReactPortal | null;
|
|
77
|
+
declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, footer, onMouseDown, onMouseUp, onKeyDown, ...rest }: FullBleedDialogProps): react.ReactPortal | null;
|
|
67
78
|
|
|
68
79
|
export { FullBleedDialog, type FullBleedDialogProps };
|
|
@@ -17,6 +17,10 @@ function FullBleedDialog({
|
|
|
17
17
|
children,
|
|
18
18
|
backCloses = true,
|
|
19
19
|
className,
|
|
20
|
+
footer,
|
|
21
|
+
onMouseDown,
|
|
22
|
+
onMouseUp,
|
|
23
|
+
onKeyDown,
|
|
20
24
|
...rest
|
|
21
25
|
}) {
|
|
22
26
|
const { closing, requestClose } = useCloseTransition(onClose);
|
|
@@ -26,7 +30,8 @@ function FullBleedDialog({
|
|
|
26
30
|
const dialogRef = useRef(null);
|
|
27
31
|
useFocusTrap(dialogRef, { active: open });
|
|
28
32
|
const handleKeyDown = (e) => {
|
|
29
|
-
|
|
33
|
+
onKeyDown?.(e);
|
|
34
|
+
if (e.defaultPrevented || e.key !== "Escape") return;
|
|
30
35
|
e.stopPropagation();
|
|
31
36
|
requestClose();
|
|
32
37
|
};
|
|
@@ -45,7 +50,14 @@ function FullBleedDialog({
|
|
|
45
50
|
tabIndex: -1,
|
|
46
51
|
ref: dialogRef,
|
|
47
52
|
onKeyDown: handleKeyDown,
|
|
48
|
-
|
|
53
|
+
onMouseDown: (e) => {
|
|
54
|
+
onMouseDown?.(e);
|
|
55
|
+
backdropClose.onMouseDown(e);
|
|
56
|
+
},
|
|
57
|
+
onMouseUp: (e) => {
|
|
58
|
+
onMouseUp?.(e);
|
|
59
|
+
backdropClose.onMouseUp(e);
|
|
60
|
+
},
|
|
49
61
|
children: /* @__PURE__ */ jsxs(
|
|
50
62
|
"div",
|
|
51
63
|
{
|
|
@@ -56,19 +68,28 @@ function FullBleedDialog({
|
|
|
56
68
|
),
|
|
57
69
|
children: [
|
|
58
70
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3", children: [
|
|
59
|
-
/* @__PURE__ */ jsx("div", { className: "min-w-0 font-medium", children: header }),
|
|
71
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1 font-medium", children: header }),
|
|
60
72
|
/* @__PURE__ */ jsx(
|
|
61
73
|
"button",
|
|
62
74
|
{
|
|
63
75
|
type: "button",
|
|
64
76
|
onClick: requestClose,
|
|
65
77
|
"aria-label": closeLabel,
|
|
66
|
-
className: "-
|
|
78
|
+
className: "-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
|
|
67
79
|
children: /* @__PURE__ */ jsx(X, { className: "size-5" })
|
|
68
80
|
}
|
|
69
81
|
)
|
|
70
82
|
] }),
|
|
71
|
-
/* @__PURE__ */ jsx("div", { className: "flex-1 overflow-y-auto overscroll-contain px-3 py-3", children })
|
|
83
|
+
/* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3", children }),
|
|
84
|
+
footer !== void 0 && footer !== null && footer !== false && /* @__PURE__ */ jsx(
|
|
85
|
+
"div",
|
|
86
|
+
{
|
|
87
|
+
"data-full-bleed-footer": "",
|
|
88
|
+
className: "flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3",
|
|
89
|
+
style: { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" },
|
|
90
|
+
children: footer
|
|
91
|
+
}
|
|
92
|
+
)
|
|
72
93
|
]
|
|
73
94
|
}
|
|
74
95
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, 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\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends ComponentPropsWithoutRef<\"div\"> {\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\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 ...rest\n}: FullBleedDialogProps) {\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 const dialogRef = useRef<HTMLDivElement>(null);\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\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 //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\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-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\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":";AA+JQ,SACE,KADF;AA/JR,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAiEtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAY7C,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAGX,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;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,qFACb;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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, 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\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends ComponentPropsWithoutRef<\"div\"> {\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 * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n}\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 footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ...rest\n}: FullBleedDialogProps) {\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 const dialogRef = useRef<HTMLDivElement>(null);\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); `preventDefault()` there claims the key, Escape included.\n onKeyDown?.(e);\n if (e.defaultPrevented || e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\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 //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\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 {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AA+LQ,SACE,KADF;AA/LR,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AA4EtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAY7C,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAG1D,gBAAY,CAAC;AACb,QAAI,EAAE,oBAAoB,EAAE,QAAQ,SAAU;AAQ9C,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAGX,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QAQX,aAAa,CAAC,MAAM;AAClB,wBAAc,CAAC;AACf,wBAAc,YAAY,CAAC;AAAA,QAC7B;AAAA,QACA,WAAW,CAAC,MAAM;AAChB,sBAAY,CAAC;AACb,wBAAc,UAAU,CAAC;AAAA,QAC3B;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACb;AAAA,oCAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,gBACpD;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,cAMA,oBAAC,SAAI,WAAU,+DAA+D,UAAS;AAAA,cACtF,WAAW,UAAa,WAAW,QAAQ,WAAW,SACrD;AAAA,gBAAC;AAAA;AAAA,kBACC,0BAAuB;AAAA,kBACvB,WAAU;AAAA,kBACV,OAAO,EAAE,eAAe,4CAA4C;AAAA,kBAEnE;AAAA;AAAA,cACH;AAAA;AAAA;AAAA,QAEJ;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
@@ -25,9 +25,13 @@ declare function useBackdropClose(onClose: () => void): {
|
|
|
25
25
|
* where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has
|
|
26
26
|
* to land; before this, a closed prop list dropped all four silently (audit §api-design).
|
|
27
27
|
*/
|
|
28
|
-
interface ModalProps extends ComponentPropsWithoutRef<"div"> {
|
|
28
|
+
interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
|
|
29
29
|
/** Invoked on backdrop click and on Escape. */
|
|
30
30
|
onClose: () => void;
|
|
31
|
+
/** `"alertdialog"` for a dialog that interrupts to ask a question the user must
|
|
32
|
+
* answer (a confirmation) — screen readers announce it with more urgency. Only the
|
|
33
|
+
* two dialog roles are allowed: the panel stays modal either way. */
|
|
34
|
+
role?: "dialog" | "alertdialog";
|
|
31
35
|
children: ReactNode;
|
|
32
36
|
/**
|
|
33
37
|
* Panel max-width: `md` (28rem), `lg` (32rem), `xl` (48rem).
|
|
@@ -84,6 +88,6 @@ interface ModalProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
84
88
|
* into the panel on open and back to the trigger on close, a Tab focus trap, and
|
|
85
89
|
* `role="dialog"`/`aria-modal`.
|
|
86
90
|
*/
|
|
87
|
-
declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, style, ...rest }: ModalProps): react.ReactPortal;
|
|
91
|
+
declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, style, role, ...rest }: ModalProps): react.ReactPortal;
|
|
88
92
|
|
|
89
93
|
export { Modal, ModalCloseContext, type ModalProps, useBackdropClose };
|
package/dist/components/modal.js
CHANGED
|
@@ -64,6 +64,7 @@ function Modal({
|
|
|
64
64
|
fullBleed,
|
|
65
65
|
draggable,
|
|
66
66
|
style,
|
|
67
|
+
role = "dialog",
|
|
67
68
|
...rest
|
|
68
69
|
}) {
|
|
69
70
|
const panelRef = useRef(null);
|
|
@@ -100,7 +101,7 @@ function Modal({
|
|
|
100
101
|
{
|
|
101
102
|
...rest,
|
|
102
103
|
ref: panelRef,
|
|
103
|
-
role: "dialog",
|
|
104
|
+
role: role === "alertdialog" ? "alertdialog" : "dialog",
|
|
104
105
|
"aria-modal": "true",
|
|
105
106
|
"aria-labelledby": labelledBy,
|
|
106
107
|
tabIndex: -1,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { createContext, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ComponentPropsWithoutRef,\n KeyboardEvent,\n MouseEvent as ReactMouseEvent,\n PointerEvent as ReactPointerEvent,\n ReactNode,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\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 *\n * The whole gesture stays on the PANEL — captured pointer, React handlers — rather than\n * on `window`. The first shape of this added `pointermove`/`pointerup` to `window` from\n * inside the `pointerdown` handler and took them off again from the `pointerup` handler,\n * so the teardown ran only if the gesture ended: React never sees a listener added from\n * an event handler, and an unmount mid-drag left a live `pointermove` on `window` for\n * the life of the page, pinning this hook's state with it. The one caller that enables\n * dragging is the feedback composer, which closes ITSELF on submit — a Ctrl+Enter sent\n * with the pointer still down is exactly that order of events, once per drag.\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 endDrag = () => {\n from.current = null;\n };\n\n return {\n style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,\n handlers: !enabled\n ? undefined\n : {\n onPointerDown: (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 try {\n // Capture is what lets the handlers live on the panel: every later\n // pointer event for this pointer retargets here, so the drag keeps\n // working once the pointer outruns the panel's own box — which it always\n // does, since the panel moves out from under it.\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // jsdom implements no pointer capture, and a browser throws\n // NotFoundError for a pointerId that is already gone. Neither is worth\n // refusing the drag: uncaptured, the panel still gets the moves that\n // land on it.\n }\n },\n onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => {\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 onPointerUp: endDrag,\n // A cancel (the browser taking the pointer for a scroll or a gesture) and a\n // lost capture both mean no `pointerup` is coming. Without them the drag would\n // stay \"live\" and the next move over the panel would jump it.\n onPointerCancel: endDrag,\n onLostPointerCapture: endDrag,\n },\n };\n}\n\n/**\n * The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X\n * and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the\n * way Escape does — calling `onClose` from inside unmounts with no exit, which is the\n * documented limit of {@link useCloseTransition}. `null` outside a `Modal`.\n */\nexport const ModalCloseContext = createContext<(() => void) | null>(null);\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.\n/**\n * `extends ComponentPropsWithoutRef<\"div\">` so anything a `<div>` takes reaches the\n * PANEL — the element with `role=\"dialog\"`, which is the one a consumer means. That is\n * where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has\n * to land; before this, a closed prop list dropped all four silently (audit §api-design).\n */\nexport interface ModalProps extends ComponentPropsWithoutRef<\"div\"> {\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 style,\n ...rest\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 // Focus the panel itself (not the first field) so opening doesn't pop the mobile\n // keyboard, while still moving focus into the dialog for keyboard and screen-reader\n // users. Through the shared hook since this component is where it came from — the\n // hook adds per-keystroke recomputation, a guard on restoring to a detached node,\n // and nesting, none of which this copy had.\n useFocusTrap(panelRef, { active: true, initialFocus: \"container\" });\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 // Tab containment lives in useFocusTrap, which listens on the panel itself.\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 // `...rest` FIRST, everything the dialog needs to be a dialog after it. A\n // wrapper that spreads its own props through to this one cannot then take away\n // the role, the modality or the `tabIndex={-1}` that `useFocusTrap` needs —\n // losing any of the three is silent, and the symptom (a dialog a screen reader\n // walks straight out of) shows up nowhere near the call site that caused it.\n {...rest}\n ref={panelRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby={labelledBy}\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n {...drag.handlers}\n // MERGED, not replaced: `drag.style` is undefined until a drag starts, so\n // assigning it outright would drop a caller's own `style` on every render but\n // the ones where the panel is being dragged.\n style={{ ...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-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none\",\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 <ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { createContext, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type {\n ComponentPropsWithoutRef,\n KeyboardEvent,\n MouseEvent as ReactMouseEvent,\n PointerEvent as ReactPointerEvent,\n ReactNode,\n} from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\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 *\n * The whole gesture stays on the PANEL — captured pointer, React handlers — rather than\n * on `window`. The first shape of this added `pointermove`/`pointerup` to `window` from\n * inside the `pointerdown` handler and took them off again from the `pointerup` handler,\n * so the teardown ran only if the gesture ended: React never sees a listener added from\n * an event handler, and an unmount mid-drag left a live `pointermove` on `window` for\n * the life of the page, pinning this hook's state with it. The one caller that enables\n * dragging is the feedback composer, which closes ITSELF on submit — a Ctrl+Enter sent\n * with the pointer still down is exactly that order of events, once per drag.\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 endDrag = () => {\n from.current = null;\n };\n\n return {\n style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : undefined,\n handlers: !enabled\n ? undefined\n : {\n onPointerDown: (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 try {\n // Capture is what lets the handlers live on the panel: every later\n // pointer event for this pointer retargets here, so the drag keeps\n // working once the pointer outruns the panel's own box — which it always\n // does, since the panel moves out from under it.\n e.currentTarget.setPointerCapture(e.pointerId);\n } catch {\n // jsdom implements no pointer capture, and a browser throws\n // NotFoundError for a pointerId that is already gone. Neither is worth\n // refusing the drag: uncaptured, the panel still gets the moves that\n // land on it.\n }\n },\n onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => {\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 onPointerUp: endDrag,\n // A cancel (the browser taking the pointer for a scroll or a gesture) and a\n // lost capture both mean no `pointerup` is coming. Without them the drag would\n // stay \"live\" and the next move over the panel would jump it.\n onPointerCancel: endDrag,\n onLostPointerCapture: endDrag,\n },\n };\n}\n\n/**\n * The panel's own ANIMATED dismissal, for chrome rendered inside it. `DialogFrame`'s X\n * and its `actions(close)` read this, so a Cancel or an X lowers the panel exactly the\n * way Escape does — calling `onClose` from inside unmounts with no exit, which is the\n * documented limit of {@link useCloseTransition}. `null` outside a `Modal`.\n */\nexport const ModalCloseContext = createContext<(() => void) | null>(null);\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.\n/**\n * `extends ComponentPropsWithoutRef<\"div\">` so anything a `<div>` takes reaches the\n * PANEL — the element with `role=\"dialog\"`, which is the one a consumer means. That is\n * where a `data-tour` anchor, a test id, an `aria-describedby` or an `aria-label` has\n * to land; before this, a closed prop list dropped all four silently (audit §api-design).\n */\nexport interface ModalProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"role\"> {\n /** Invoked on backdrop click and on Escape. */\n onClose: () => void;\n /** `\"alertdialog\"` for a dialog that interrupts to ask a question the user must\n * answer (a confirmation) — screen readers announce it with more urgency. Only the\n * two dialog roles are allowed: the panel stays modal either way. */\n role?: \"dialog\" | \"alertdialog\";\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 style,\n role = \"dialog\",\n ...rest\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 // Focus the panel itself (not the first field) so opening doesn't pop the mobile\n // keyboard, while still moving focus into the dialog for keyboard and screen-reader\n // users. Through the shared hook since this component is where it came from — the\n // hook adds per-keystroke recomputation, a guard on restoring to a detached node,\n // and nesting, none of which this copy had.\n useFocusTrap(panelRef, { active: true, initialFocus: \"container\" });\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 // Tab containment lives in useFocusTrap, which listens on the panel itself.\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 // `...rest` FIRST, everything the dialog needs to be a dialog after it. A\n // wrapper that spreads its own props through to this one cannot then take away\n // the role, the modality or the `tabIndex={-1}` that `useFocusTrap` needs —\n // losing any of the three is silent, and the symptom (a dialog a screen reader\n // walks straight out of) shows up nowhere near the call site that caused it.\n {...rest}\n ref={panelRef}\n // Normalised at runtime too: an untyped caller's `role=\"presentation\"` must not\n // take the dialog role away (props-passthrough pins that).\n role={role === \"alertdialog\" ? \"alertdialog\" : \"dialog\"}\n aria-modal=\"true\"\n aria-labelledby={labelledBy}\n tabIndex={-1}\n onKeyDown={handleKeyDown}\n {...drag.handlers}\n // MERGED, not replaced: `drag.style` is undefined until a drag starts, so\n // assigning it outright would drop a caller's own `style` on every render but\n // the ones where the panel is being dragged.\n style={{ ...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-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none\",\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 <ModalCloseContext.Provider value={requestClose}>{children}</ModalCloseContext.Provider>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -32,6 +32,9 @@ import './file-button.js';
|
|
|
32
32
|
import './measured-grid.js';
|
|
33
33
|
import '../feedback/feedback-attachment.js';
|
|
34
34
|
import '../feedback/feedback-dialog.js';
|
|
35
|
+
import './confirm-dialog.js';
|
|
36
|
+
import './floating-panel.js';
|
|
37
|
+
import './copy-button.js';
|
|
35
38
|
import './numpad-sheet.js';
|
|
36
39
|
|
|
37
40
|
type NumberInputProps = ComponentProps<typeof NumberInput>;
|
|
@@ -32,6 +32,9 @@ import './file-button.js';
|
|
|
32
32
|
import './measured-grid.js';
|
|
33
33
|
import '../feedback/feedback-attachment.js';
|
|
34
34
|
import '../feedback/feedback-dialog.js';
|
|
35
|
+
import './confirm-dialog.js';
|
|
36
|
+
import './floating-panel.js';
|
|
37
|
+
import './copy-button.js';
|
|
35
38
|
|
|
36
39
|
interface NumberInputProps {
|
|
37
40
|
value: string;
|
|
@@ -30,6 +30,9 @@ import './file-button.js';
|
|
|
30
30
|
import './measured-grid.js';
|
|
31
31
|
import '../feedback/feedback-attachment.js';
|
|
32
32
|
import '../feedback/feedback-dialog.js';
|
|
33
|
+
import './confirm-dialog.js';
|
|
34
|
+
import './floating-panel.js';
|
|
35
|
+
import './copy-button.js';
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
type ProgressBarTone = "brand" | "neutral" | "success" | "warning" | "danger" | "info";
|
|
5
|
+
type ProgressBarSize = "sm" | "md" | "lg";
|
|
6
|
+
interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
|
|
7
|
+
/** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
|
|
8
|
+
* and nobody knows how much is left (an import that reports no count). */
|
|
9
|
+
value?: number;
|
|
10
|
+
min?: number;
|
|
11
|
+
max?: number;
|
|
12
|
+
/**
|
|
13
|
+
* `progress` (default): a task moving toward completion — `role="progressbar"`.
|
|
14
|
+
* `meter`: a share of a known whole that is not going anywhere (budget used, disk
|
|
15
|
+
* space, one category's slice of spending) — `role="meter"`. keksdose's share bars
|
|
16
|
+
* were progress bars to a screen reader, which reads "busy" into a figure that is
|
|
17
|
+
* simply a figure. A meter is never indeterminate; a missing value reads as `min`.
|
|
18
|
+
*/
|
|
19
|
+
variant?: "progress" | "meter";
|
|
20
|
+
/** Visible label above the bar, and its accessible name. Without it, pass
|
|
21
|
+
* `aria-label` (an indeterminate bar with neither is named `common.loading`). */
|
|
22
|
+
label?: ReactNode;
|
|
23
|
+
/** Show the value text at the end of the label row. */
|
|
24
|
+
showValue?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The value as words — both the visible `showValue` text and `aria-valuetext`.
|
|
27
|
+
* Default: the fraction as a percentage in the kit's locale ("42 %" in French).
|
|
28
|
+
* Pass one for anything that is not a percentage: `(v, max) => \`${v} of ${max} files\``.
|
|
29
|
+
*/
|
|
30
|
+
formatValue?: (value: number, max: number, min: number) => string;
|
|
31
|
+
tone?: ProgressBarTone;
|
|
32
|
+
size?: ProgressBarSize;
|
|
33
|
+
locale?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A horizontal bar that fills toward `max`.
|
|
37
|
+
*
|
|
38
|
+
* keksdose (import progress, budget share bars) and kastlan (upload and job
|
|
39
|
+
* progress) each drew their own div-in-a-div. None had a role, so a reader heard
|
|
40
|
+
* nothing at all; the share bars that did have one claimed to be progress.
|
|
41
|
+
*
|
|
42
|
+
* `aria-valuetext` is always set when there is a value: a bare `aria-valuenow="37"`
|
|
43
|
+
* is read as "37" by some readers and "37 percent" by others, and neither is right
|
|
44
|
+
* when `max` is 212 files.
|
|
45
|
+
*
|
|
46
|
+
* The indeterminate sweep stops under `prefers-reduced-motion`. It becomes a still,
|
|
47
|
+
* half-strength bar across the whole track — still visibly "something is happening",
|
|
48
|
+
* with nothing moving. The sweep is a Tailwind arbitrary `animate-[…]` rather than
|
|
49
|
+
* tokens.css's `.animate-indeterminate` class because that one is UNLAYERED, and an
|
|
50
|
+
* unlayered rule beats every layered utility, `motion-reduce:animate-none` included.
|
|
51
|
+
*/
|
|
52
|
+
declare function ProgressBar({ value, min, max, variant, label, showValue, formatValue, tone, size, locale, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...rest }: ProgressBarProps): react.JSX.Element;
|
|
53
|
+
|
|
54
|
+
export { ProgressBar, type ProgressBarProps, type ProgressBarSize, type ProgressBarTone };
|