@eifi1/ui-kit 0.9.0 → 0.11.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 +37 -23
- package/dist/components/alert-banner.d.ts +57 -5
- package/dist/components/alert-banner.js +63 -17
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +112 -0
- package/dist/components/bulk-action-bar.js +196 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/button-group.d.ts +58 -3
- package/dist/components/button-group.js +56 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/calendar-heatmap.d.ts +124 -0
- package/dist/components/calendar-heatmap.js +295 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/chip.d.ts +31 -5
- package/dist/components/chip.js +85 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +34 -2
- package/dist/components/choice-card.js +49 -0
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +14 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +22 -2
- package/dist/components/date-picker.js +11 -3
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +57 -2
- package/dist/components/disclosure.js +24 -12
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/field.d.ts +43 -0
- package/dist/components/field.js +52 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +1 -0
- package/dist/components/file-dropzone.d.ts +4 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +147 -4
- package/dist/components/floating-panel.js +188 -28
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +187 -0
- package/dist/components/list.js +221 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +109 -0
- package/dist/components/menu-item.js +88 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +70 -5
- package/dist/components/mini-calendar.js +166 -62
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/nav-pills.d.ts +66 -0
- package/dist/components/nav-pills.js +65 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +31 -2
- package/dist/components/progress-bar.js +73 -7
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +32 -0
- package/dist/components/series-chart.js +141 -4
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -0
- package/dist/components/status-dot.d.ts +53 -0
- package/dist/components/status-dot.js +57 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +78 -13
- package/dist/components/table.js +84 -11
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +70 -0
- package/dist/components/text.js +24 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/time-input.d.ts +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +44 -1
- package/dist/components/toggle-group.js +73 -4
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +46 -15
- package/dist/components/tooltip.js +87 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +158 -7
- package/dist/components/ui.js +191 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -0
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +30 -2
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +30 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +30 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +30 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +30 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +30 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +27 -15
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +25 -0
- package/dist/lib/clipping.js +17 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/rhf/form.d.ts +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +2 -0
- package/dist/rhf.js +1 -0
- package/dist/rhf.js.map +1 -1
- package/dist/search/command-palette.d.ts +19 -2
- package/dist/search/command-palette.js +16 -5
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +26 -1
- package/dist/search/global-search.js +29 -13
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/topbar-action-menu.d.ts +73 -3
- package/dist/shell/topbar-action-menu.js +100 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -1
- package/dist/wizard/stepper-nav.d.ts +1 -0
- package/dist/wizard/stepper-nav.js +1 -1
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-context.d.ts +7 -1
- package/dist/wizard/wizard-context.js +4 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +2 -1
- package/package.json +16 -12
- package/src/components/alert-banner.tsx +149 -21
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +362 -0
- package/src/components/button-group.tsx +124 -4
- package/src/components/calendar-heatmap.tsx +504 -0
- package/src/components/chip.tsx +128 -7
- package/src/components/choice-card.tsx +97 -1
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +7 -0
- package/src/components/date-picker.tsx +49 -1
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +93 -21
- package/src/components/field.tsx +137 -0
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +366 -15
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +443 -0
- package/src/components/menu-item.tsx +235 -0
- package/src/components/mini-calendar.tsx +245 -54
- package/src/components/modal.tsx +80 -17
- package/src/components/nav-pills.tsx +145 -0
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +129 -11
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +274 -3
- package/src/components/status-dot.tsx +107 -0
- package/src/components/table.tsx +176 -17
- package/src/components/text.tsx +97 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +128 -4
- package/src/components/tooltip.tsx +200 -100
- package/src/components/ui.tsx +381 -14
- package/src/components/user-avatar.tsx +57 -3
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +29 -0
- package/src/i18n/locales/es.ts +29 -0
- package/src/i18n/locales/fr.ts +29 -0
- package/src/i18n/locales/hu.ts +29 -0
- package/src/i18n/locales/it.ts +29 -0
- package/src/i18n/locales/zh.ts +29 -0
- package/src/index.ts +31 -0
- package/src/lib/clipping.ts +34 -0
- package/src/lib/dates.ts +37 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +2 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- package/src/shell/topbar-action-menu.tsx +239 -49
- package/src/wizard/stepper-nav.tsx +2 -1
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-context.tsx +9 -0
- package/src/wizard/wizard-summary.tsx +14 -10
- package/tokens.css +97 -0
package/dist/components/modal.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useRef, useState } from "react";
|
|
3
|
+
import { createContext, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
6
|
import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
7
7
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
|
|
8
8
|
import { useOverlayHistory } from "../hooks/use-overlay-history.js";
|
|
9
|
-
import { useCloseTransition } from "../hooks/use-close-transition.js";
|
|
9
|
+
import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition.js";
|
|
10
10
|
function useDragOffset(enabled) {
|
|
11
11
|
const [offset, setOffset] = useState(null);
|
|
12
12
|
const from = useRef(null);
|
|
@@ -14,6 +14,9 @@ function useDragOffset(enabled) {
|
|
|
14
14
|
from.current = null;
|
|
15
15
|
};
|
|
16
16
|
return {
|
|
17
|
+
/** Back to where the panel opens. A `Modal` with an `open` prop outlives its own
|
|
18
|
+
* closes, so "the position resets when the dialog closes" needs saying. */
|
|
19
|
+
reset: () => setOffset(null),
|
|
17
20
|
style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : void 0,
|
|
18
21
|
handlers: !enabled ? void 0 : {
|
|
19
22
|
onPointerDown: (e) => {
|
|
@@ -56,6 +59,7 @@ function useBackdropClose(onClose) {
|
|
|
56
59
|
}
|
|
57
60
|
function Modal({
|
|
58
61
|
onClose,
|
|
62
|
+
open,
|
|
59
63
|
children,
|
|
60
64
|
size = "md",
|
|
61
65
|
className,
|
|
@@ -69,29 +73,51 @@ function Modal({
|
|
|
69
73
|
}) {
|
|
70
74
|
const panelRef = useRef(null);
|
|
71
75
|
const drag = useDragOffset(Boolean(draggable));
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const active = open ?? true;
|
|
77
|
+
const [dismissed, setDismissed] = useState(false);
|
|
78
|
+
const [exiting, setExiting] = useState(false);
|
|
79
|
+
const [prevOpen, setPrevOpen] = useState(open);
|
|
80
|
+
if (open !== prevOpen) {
|
|
81
|
+
setPrevOpen(open);
|
|
82
|
+
setDismissed(false);
|
|
83
|
+
setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());
|
|
84
|
+
if (open) drag.reset();
|
|
85
|
+
}
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!exiting) return;
|
|
88
|
+
const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);
|
|
89
|
+
return () => clearTimeout(timer);
|
|
90
|
+
}, [exiting]);
|
|
91
|
+
const { closing, requestClose } = useCloseTransition(() => {
|
|
92
|
+
setDismissed(true);
|
|
93
|
+
onClose();
|
|
94
|
+
});
|
|
95
|
+
const backdropClose = useBackdropClose(active ? requestClose : () => {
|
|
96
|
+
});
|
|
97
|
+
useOverlayHistory(active, requestClose);
|
|
98
|
+
useBodyScrollLock(active);
|
|
99
|
+
useFocusTrap(panelRef, { active, initialFocus: "container" });
|
|
100
|
+
const leaving = closing || exiting;
|
|
77
101
|
const handleKeyDown = (e) => {
|
|
78
102
|
onKeyDown?.(e);
|
|
79
103
|
if (e.defaultPrevented) return;
|
|
80
104
|
if (e.key === "Escape") {
|
|
81
105
|
e.stopPropagation();
|
|
82
|
-
requestClose();
|
|
106
|
+
if (active) requestClose();
|
|
83
107
|
return;
|
|
84
108
|
}
|
|
85
109
|
};
|
|
110
|
+
if (!active && !exiting) return null;
|
|
86
111
|
return createPortal(
|
|
87
112
|
/* @__PURE__ */ jsx(
|
|
88
113
|
"div",
|
|
89
114
|
{
|
|
115
|
+
"aria-hidden": exiting || void 0,
|
|
90
116
|
className: cn(
|
|
91
117
|
// Live #320. The panel below rises only where it is bottom-anchored: from md up
|
|
92
118
|
// it is centred, and a centred box sliding up from off-screen reads as a
|
|
93
119
|
// different component arriving rather than as the same one settling.
|
|
94
|
-
|
|
120
|
+
leaving ? "animate-overlay-out" : "animate-overlay",
|
|
95
121
|
"fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
|
|
96
122
|
fullBleed ? "p-0 md:p-4" : "p-4"
|
|
97
123
|
),
|
|
@@ -109,7 +135,7 @@ function Modal({
|
|
|
109
135
|
...drag.handlers,
|
|
110
136
|
style: { ...style, ...drag.style },
|
|
111
137
|
className: cn(
|
|
112
|
-
|
|
138
|
+
leaving ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
|
|
113
139
|
"w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
|
|
114
140
|
{ md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
|
|
115
141
|
// A panel taller than the screen has to scroll ITSELF. The backdrop is
|
|
@@ -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 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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/modal.tsx"],"sourcesContent":["import { createContext, useEffect, 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 { OVERLAY_EXIT_MS, prefersReducedMotion, 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 /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own\n * closes, so \"the position resets when the dialog closes\" needs saying. */\n reset: () => setOffset(null),\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 /**\n * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.\n *\n * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,\n * which is every caller before this prop and stays exactly as it was. That idiom has\n * one blind spot: a close the CALLER decides on (a save that succeeded, a\n * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,\n * because there is nothing left to animate. kastlan's `FormModal` was nothing but an\n * `if (!open) return null` gate in front of this.\n *\n * Passed, the component stays mounted while closed and renders nothing, and a flip\n * to `false` plays the same exit a dismissal does before the panel goes. The focus\n * trap, the scroll lock and the Back-gesture history entry are held only while\n * `open` is true — they are released the moment it turns false, so focus is back on\n * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,\n * Back, the frame's X) animates first and then calls `onClose`; the `false` that\n * comes back from it is not animated a second time.\n *\n * The body is unmounted once the exit has finished, as it is today, so a form inside\n * still starts fresh on the next open.\n */\n open?: boolean;\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 open,\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 // Without `open`, mounted means open — the constant `true` every hook below used to\n // be given. With it, `open` itself.\n const active = open ?? true;\n\n // Whether the last close came through `requestClose`, i.e. has ALREADY been\n // animated. Set in the same batch as the caller's `onClose`, so the render that sees\n // `open` turn false also sees this and does not lower the panel a second time.\n const [dismissed, setDismissed] = useState(false);\n // The exit of a close the caller decided on (only reachable with `open`). Adjusted\n // during render when `open` flips — `Collapse`'s pattern — so the very render that\n // closes it already paints the `-out` classes rather than a frame of nothing.\n const [exiting, setExiting] = useState(false);\n const [prevOpen, setPrevOpen] = useState(open);\n if (open !== prevOpen) {\n setPrevOpen(open);\n setDismissed(false);\n setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());\n if (open) drag.reset();\n }\n useEffect(() => {\n if (!exiting) return;\n const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);\n return () => clearTimeout(timer);\n }, [exiting]);\n\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 unless it passes `open`, which is what that prop is for.\n const { closing, requestClose } = useCloseTransition(() => {\n setDismissed(true);\n onClose();\n });\n // A panel on its way out after `open` turned false is already closed: a press on\n // its fading backdrop must not report a second close.\n const backdropClose = useBackdropClose(active ? 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 // Held only while open: a closed `open={false}` dialog must not own a history entry.\n useOverlayHistory(active, requestClose);\n\n // Through the shared hook and not by hand: this component's own save/restore copy\n // was one half of the pair that left the page permanently unscrollable when a dialog\n // containing an open sheet was closed — see the note in use-body-scroll-lock.ts.\n useBodyScrollLock(active);\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. Released when `open` turns false, which\n // hands focus back to the trigger while the panel is still on its way out.\n useFocusTrap(panelRef, { active, initialFocus: \"container\" });\n const leaving = closing || exiting;\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n onKeyDown?.(e);\n if (e.defaultPrevented) return;\n if (e.key === \"Escape\") {\n e.stopPropagation();\n if (active) 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 if (!active && !exiting) return null;\n return createPortal(\n <div\n // Out of the accessibility tree for the length of a caller-driven exit: focus is\n // already back on the page, and an `aria-modal` panel that is leaving would hide\n // that page from a screen reader for 220ms. Not `inert` — that would let a tap\n // fall through the fading backdrop to the row underneath.\n aria-hidden={exiting || undefined}\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 leaving ? \"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 leaving ? \"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":[]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, ComponentType, MouseEvent, ComponentPropsWithoutRef, ReactElement } from 'react';
|
|
3
|
+
|
|
4
|
+
interface NavPillItem<T extends string> {
|
|
5
|
+
value: T;
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
/** Where the pill leads. With one it is a link (see `renderLink`); without, a button
|
|
8
|
+
* that calls `onSelect`. */
|
|
9
|
+
href?: string;
|
|
10
|
+
/** A Lucide icon (or any component taking a `className`), before the label. */
|
|
11
|
+
icon?: ComponentType<{
|
|
12
|
+
className?: string;
|
|
13
|
+
"aria-hidden"?: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
/** Buttons only — a link cannot be disabled. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** What {@link NavPillsProps.renderLink} is handed. Spread it onto your router's link
|
|
19
|
+
* — `({ href, ...p }) => <Link to={href} {...p} />`. */
|
|
20
|
+
interface NavPillLinkProps {
|
|
21
|
+
href: string;
|
|
22
|
+
className: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
"aria-current": NavPillsCurrent | undefined;
|
|
25
|
+
onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
|
|
26
|
+
}
|
|
27
|
+
/** The `aria-current` token the current pill carries. */
|
|
28
|
+
type NavPillsCurrent = "page" | "step" | "location" | "true";
|
|
29
|
+
interface NavPillsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"nav">, "children" | "onSelect"> {
|
|
30
|
+
items: NavPillItem<T>[];
|
|
31
|
+
/** The pill that is current. `null` or a value not in `items`: none is. */
|
|
32
|
+
current: T | null;
|
|
33
|
+
/** Called with the pill's value on a click — of a button, and of a link too (before
|
|
34
|
+
* the router follows it), for a caller that keeps the choice in state as well. */
|
|
35
|
+
onSelect?: (value: T) => void;
|
|
36
|
+
/** Your router's link. Default `<a>`. The API `Breadcrumbs`, `Chip` and `ListItem` share. */
|
|
37
|
+
renderLink?: (props: NavPillLinkProps) => ReactElement;
|
|
38
|
+
/** What `aria-current` says on the current pill. Default `page` where the items
|
|
39
|
+
* are links and `true` where they are buttons (keksdose's swipe-surface picker
|
|
40
|
+
* switches a panel on the same page, which is not a page). */
|
|
41
|
+
currentType?: NavPillsCurrent;
|
|
42
|
+
/** `sm`: 12px pills — the compact size of a {@link ToggleGroup}. Default `md`. */
|
|
43
|
+
size?: "sm" | "md";
|
|
44
|
+
/** Render a `<nav>` landmark (default) or a plain `<div role="group">` for a picker
|
|
45
|
+
* that is not the page's navigation. Name either with `aria-label`. */
|
|
46
|
+
landmark?: boolean;
|
|
47
|
+
/** Classes for every pill. */
|
|
48
|
+
itemClassName?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A set of links or buttons that WRAPS onto as many rows as it needs, the current one
|
|
52
|
+
* filled and marked `aria-current`.
|
|
53
|
+
*
|
|
54
|
+
* keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four
|
|
55
|
+
* surface names that do not fit a 406px phone, and a sideways scroll would hide the
|
|
56
|
+
* ones that do not fit behind a gesture nothing announces (live #332: *"Have the tabs
|
|
57
|
+
* in more rows, rather than scrolling sideways"*). It is not {@link Tabs} with `wrap`
|
|
58
|
+
* because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
|
|
59
|
+
* stop and arrow keys, and a set of links to pages (or of buttons that switch the rest
|
|
60
|
+
* of a card) promises none of that. So every pill is its own tab stop, the set is a
|
|
61
|
+
* list (a screen reader hears "list, 4 items"), and "current" is `aria-current` — the
|
|
62
|
+
* attribute a link to where you already are carries.
|
|
63
|
+
*/
|
|
64
|
+
declare function NavPills<T extends string>({ items, current, onSelect, renderLink, currentType, size, landmark, itemClassName, className, ...rest }: NavPillsProps<T>): react.JSX.Element;
|
|
65
|
+
|
|
66
|
+
export { type NavPillItem, type NavPillLinkProps, NavPills, type NavPillsCurrent, type NavPillsProps };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
const PILL = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-strong)] [&_svg]:size-4 [&_svg]:shrink-0";
|
|
5
|
+
const PILL_SM = "gap-1 px-2 py-1 text-xs [&_svg]:size-3.5";
|
|
6
|
+
const PILL_CURRENT = "bg-[var(--bg-inverse)] text-[var(--text-inverse)]";
|
|
7
|
+
const PILL_IDLE = "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
|
|
8
|
+
const PILL_DISABLED = "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-[var(--text-secondary)]";
|
|
9
|
+
function NavPills({
|
|
10
|
+
items,
|
|
11
|
+
current,
|
|
12
|
+
onSelect,
|
|
13
|
+
renderLink,
|
|
14
|
+
currentType,
|
|
15
|
+
size = "md",
|
|
16
|
+
landmark = true,
|
|
17
|
+
itemClassName,
|
|
18
|
+
className,
|
|
19
|
+
...rest
|
|
20
|
+
}) {
|
|
21
|
+
const Root = landmark ? "nav" : "div";
|
|
22
|
+
return /* @__PURE__ */ jsx(Root, { ...rest, role: landmark ? void 0 : "group", className: cn("min-w-0", className), children: /* @__PURE__ */ jsx("ul", { className: "m-0 flex list-none flex-wrap gap-1 p-0", children: items.map((item) => {
|
|
23
|
+
const isCurrent = item.value === current;
|
|
24
|
+
const Icon = item.icon;
|
|
25
|
+
const pillClass = cn(
|
|
26
|
+
PILL,
|
|
27
|
+
size === "sm" && PILL_SM,
|
|
28
|
+
isCurrent ? PILL_CURRENT : PILL_IDLE,
|
|
29
|
+
item.disabled && item.href === void 0 && PILL_DISABLED,
|
|
30
|
+
itemClassName
|
|
31
|
+
);
|
|
32
|
+
const children = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
33
|
+
Icon && /* @__PURE__ */ jsx(Icon, { "aria-hidden": true }),
|
|
34
|
+
item.label
|
|
35
|
+
] });
|
|
36
|
+
let pill;
|
|
37
|
+
if (item.href !== void 0) {
|
|
38
|
+
const props = {
|
|
39
|
+
href: item.href,
|
|
40
|
+
className: pillClass,
|
|
41
|
+
children,
|
|
42
|
+
"aria-current": isCurrent ? currentType ?? "page" : void 0,
|
|
43
|
+
onClick: () => onSelect?.(item.value)
|
|
44
|
+
};
|
|
45
|
+
pill = renderLink ? renderLink(props) : /* @__PURE__ */ jsx("a", { href: props.href, className: props.className, "aria-current": props["aria-current"], onClick: props.onClick, children: props.children });
|
|
46
|
+
} else {
|
|
47
|
+
pill = /* @__PURE__ */ jsx(
|
|
48
|
+
"button",
|
|
49
|
+
{
|
|
50
|
+
type: "button",
|
|
51
|
+
disabled: item.disabled,
|
|
52
|
+
"aria-current": isCurrent ? currentType ?? "true" : void 0,
|
|
53
|
+
onClick: () => onSelect?.(item.value),
|
|
54
|
+
className: pillClass,
|
|
55
|
+
children
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return /* @__PURE__ */ jsx("li", { className: "flex", children: pill }, item.value);
|
|
60
|
+
}) }) });
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
NavPills
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=nav-pills.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/nav-pills.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\n\nexport interface NavPillItem<T extends string> {\n value: T;\n label: ReactNode;\n /** Where the pill leads. With one it is a link (see `renderLink`); without, a button\n * that calls `onSelect`. */\n href?: string;\n /** A Lucide icon (or any component taking a `className`), before the label. */\n icon?: ComponentType<{ className?: string; \"aria-hidden\"?: boolean }>;\n /** Buttons only — a link cannot be disabled. */\n disabled?: boolean;\n}\n\n/** What {@link NavPillsProps.renderLink} is handed. Spread it onto your router's link\n * — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface NavPillLinkProps {\n href: string;\n className: string;\n children: ReactNode;\n \"aria-current\": NavPillsCurrent | undefined;\n onClick: (e: MouseEvent<HTMLAnchorElement>) => void;\n}\n\n/** The `aria-current` token the current pill carries. */\nexport type NavPillsCurrent = \"page\" | \"step\" | \"location\" | \"true\";\n\nexport interface NavPillsProps<T extends string> extends Omit<ComponentPropsWithoutRef<\"nav\">, \"children\" | \"onSelect\"> {\n items: NavPillItem<T>[];\n /** The pill that is current. `null` or a value not in `items`: none is. */\n current: T | null;\n /** Called with the pill's value on a click — of a button, and of a link too (before\n * the router follows it), for a caller that keeps the choice in state as well. */\n onSelect?: (value: T) => void;\n /** Your router's link. Default `<a>`. The API `Breadcrumbs`, `Chip` and `ListItem` share. */\n renderLink?: (props: NavPillLinkProps) => ReactElement;\n /** What `aria-current` says on the current pill. Default `page` where the items\n * are links and `true` where they are buttons (keksdose's swipe-surface picker\n * switches a panel on the same page, which is not a page). */\n currentType?: NavPillsCurrent;\n /** `sm`: 12px pills — the compact size of a {@link ToggleGroup}. Default `md`. */\n size?: \"sm\" | \"md\";\n /** Render a `<nav>` landmark (default) or a plain `<div role=\"group\">` for a picker\n * that is not the page's navigation. Name either with `aria-label`. */\n landmark?: boolean;\n /** Classes for every pill. */\n itemClassName?: string;\n}\n\n// The pill is a ToggleGroup segment's (`rounded`, `px-3 py-1.5`, the inverse fill for\n// the current one), so a strip of these beside a segmented control reads as the same\n// family. Unlike the segments, a pill never truncates: the strip WRAPS instead.\nconst PILL =\n \"inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-strong)] [&_svg]:size-4 [&_svg]:shrink-0\";\nconst PILL_SM = \"gap-1 px-2 py-1 text-xs [&_svg]:size-3.5\";\nconst PILL_CURRENT = \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\";\nconst PILL_IDLE = \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]\";\nconst PILL_DISABLED = \"cursor-not-allowed opacity-50 hover:bg-transparent hover:text-[var(--text-secondary)]\";\n\n/**\n * A set of links or buttons that WRAPS onto as many rows as it needs, the current one\n * filled and marked `aria-current`.\n *\n * keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four\n * surface names that do not fit a 406px phone, and a sideways scroll would hide the\n * ones that do not fit behind a gesture nothing announces (live #332: *\"Have the tabs\n * in more rows, rather than scrolling sideways\"*). It is not {@link Tabs} with `wrap`\n * because its items are not tabs: a `role=\"tab\"` promises a tabpanel, a roving tab\n * stop and arrow keys, and a set of links to pages (or of buttons that switch the rest\n * of a card) promises none of that. So every pill is its own tab stop, the set is a\n * list (a screen reader hears \"list, 4 items\"), and \"current\" is `aria-current` — the\n * attribute a link to where you already are carries.\n */\nexport function NavPills<T extends string>({\n items,\n current,\n onSelect,\n renderLink,\n currentType,\n size = \"md\",\n landmark = true,\n itemClassName,\n className,\n ...rest\n}: NavPillsProps<T>) {\n const Root = landmark ? \"nav\" : \"div\";\n return (\n <Root {...rest} role={landmark ? undefined : \"group\"} className={cn(\"min-w-0\", className)}>\n <ul className=\"m-0 flex list-none flex-wrap gap-1 p-0\">\n {items.map((item) => {\n const isCurrent = item.value === current;\n const Icon = item.icon;\n const pillClass = cn(\n PILL,\n size === \"sm\" && PILL_SM,\n isCurrent ? PILL_CURRENT : PILL_IDLE,\n item.disabled && item.href === undefined && PILL_DISABLED,\n itemClassName,\n );\n const children = (\n <>\n {Icon && <Icon aria-hidden />}\n {item.label}\n </>\n );\n let pill: ReactNode;\n if (item.href !== undefined) {\n const props: NavPillLinkProps = {\n href: item.href,\n className: pillClass,\n children,\n \"aria-current\": isCurrent ? (currentType ?? \"page\") : undefined,\n onClick: () => onSelect?.(item.value),\n };\n pill = renderLink ? (\n renderLink(props)\n ) : (\n <a href={props.href} className={props.className} aria-current={props[\"aria-current\"]} onClick={props.onClick}>\n {props.children}\n </a>\n );\n } else {\n pill = (\n <button\n type=\"button\"\n disabled={item.disabled}\n aria-current={isCurrent ? (currentType ?? \"true\") : undefined}\n onClick={() => onSelect?.(item.value)}\n className={pillClass}\n >\n {children}\n </button>\n );\n }\n return (\n <li key={item.value} className=\"flex\">\n {pill}\n </li>\n );\n })}\n </ul>\n </Root>\n );\n}\n"],"mappings":";AAqGY,mBACW,KADX;AApGZ,SAAS,UAAU;AAoDnB,MAAM,OACJ;AACF,MAAM,UAAU;AAChB,MAAM,eAAe;AACrB,MAAM,YAAY;AAClB,MAAM,gBAAgB;AAgBf,SAAS,SAA2B;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,OAAO,WAAW,QAAQ;AAChC,SACE,oBAAC,QAAM,GAAG,MAAM,MAAM,WAAW,SAAY,SAAS,WAAW,GAAG,WAAW,SAAS,GACtF,8BAAC,QAAG,WAAU,0CACX,gBAAM,IAAI,CAAC,SAAS;AACnB,UAAM,YAAY,KAAK,UAAU;AACjC,UAAM,OAAO,KAAK;AAClB,UAAM,YAAY;AAAA,MAChB;AAAA,MACA,SAAS,QAAQ;AAAA,MACjB,YAAY,eAAe;AAAA,MAC3B,KAAK,YAAY,KAAK,SAAS,UAAa;AAAA,MAC5C;AAAA,IACF;AACA,UAAM,WACJ,iCACG;AAAA,cAAQ,oBAAC,QAAK,eAAW,MAAC;AAAA,MAC1B,KAAK;AAAA,OACR;AAEF,QAAI;AACJ,QAAI,KAAK,SAAS,QAAW;AAC3B,YAAM,QAA0B;AAAA,QAC9B,MAAM,KAAK;AAAA,QACX,WAAW;AAAA,QACX;AAAA,QACA,gBAAgB,YAAa,eAAe,SAAU;AAAA,QACtD,SAAS,MAAM,WAAW,KAAK,KAAK;AAAA,MACtC;AACA,aAAO,aACL,WAAW,KAAK,IAEhB,oBAAC,OAAE,MAAM,MAAM,MAAM,WAAW,MAAM,WAAW,gBAAc,MAAM,cAAc,GAAG,SAAS,MAAM,SAClG,gBAAM,UACT;AAAA,IAEJ,OAAO;AACL,aACE;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,UAAU,KAAK;AAAA,UACf,gBAAc,YAAa,eAAe,SAAU;AAAA,UACpD,SAAS,MAAM,WAAW,KAAK,KAAK;AAAA,UACpC,WAAW;AAAA,UAEV;AAAA;AAAA,MACH;AAAA,IAEJ;AACA,WACE,oBAAC,QAAoB,WAAU,QAC5B,kBADM,KAAK,KAEd;AAAA,EAEJ,CAAC,GACH,GACF;AAEJ;","names":[]}
|
|
@@ -5,6 +5,7 @@ import './calculator.js';
|
|
|
5
5
|
import '../i18n/kit-labels.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
|
+
import './calendar-heatmap.js';
|
|
8
9
|
import './popover.js';
|
|
9
10
|
import './chip.js';
|
|
10
11
|
import 'lucide-react';
|
|
@@ -12,6 +13,7 @@ import './field-sync.js';
|
|
|
12
13
|
import './ui.js';
|
|
13
14
|
import './tooltip.js';
|
|
14
15
|
import '../hooks/use-anchored-rect.js';
|
|
16
|
+
import '../lib/clipping.js';
|
|
15
17
|
import '../wizard/types.js';
|
|
16
18
|
import '../tour/tour.js';
|
|
17
19
|
import '../search/command-palette.js';
|
|
@@ -37,6 +39,10 @@ import '../feedback/feedback-dialog.js';
|
|
|
37
39
|
import './confirm-dialog.js';
|
|
38
40
|
import './floating-panel.js';
|
|
39
41
|
import './copy-button.js';
|
|
42
|
+
import './bulk-action-bar.js';
|
|
43
|
+
import './list.js';
|
|
44
|
+
import './breadcrumbs.js';
|
|
45
|
+
import './toast.js';
|
|
40
46
|
import './numpad-sheet.js';
|
|
41
47
|
|
|
42
48
|
type NumberInputProps = ComponentProps<typeof NumberInput>;
|
|
@@ -5,6 +5,7 @@ import { NumberPadSheetLabels } from './numpad-sheet.js';
|
|
|
5
5
|
import '../i18n/kit-labels.js';
|
|
6
6
|
import './data-table-labels.js';
|
|
7
7
|
import './mini-calendar.js';
|
|
8
|
+
import './calendar-heatmap.js';
|
|
8
9
|
import './popover.js';
|
|
9
10
|
import './chip.js';
|
|
10
11
|
import 'lucide-react';
|
|
@@ -12,6 +13,7 @@ import './field-sync.js';
|
|
|
12
13
|
import './ui.js';
|
|
13
14
|
import './tooltip.js';
|
|
14
15
|
import '../hooks/use-anchored-rect.js';
|
|
16
|
+
import '../lib/clipping.js';
|
|
15
17
|
import '../wizard/types.js';
|
|
16
18
|
import '../tour/tour.js';
|
|
17
19
|
import '../search/command-palette.js';
|
|
@@ -37,6 +39,10 @@ import '../feedback/feedback-dialog.js';
|
|
|
37
39
|
import './confirm-dialog.js';
|
|
38
40
|
import './floating-panel.js';
|
|
39
41
|
import './copy-button.js';
|
|
42
|
+
import './bulk-action-bar.js';
|
|
43
|
+
import './list.js';
|
|
44
|
+
import './breadcrumbs.js';
|
|
45
|
+
import './toast.js';
|
|
40
46
|
|
|
41
47
|
interface NumberInputProps {
|
|
42
48
|
value: string;
|
|
@@ -3,6 +3,7 @@ import { ReactNode } from 'react';
|
|
|
3
3
|
import { CalculatorLabels } from '../i18n/kit-labels.js';
|
|
4
4
|
import './data-table-labels.js';
|
|
5
5
|
import './mini-calendar.js';
|
|
6
|
+
import './calendar-heatmap.js';
|
|
6
7
|
import './popover.js';
|
|
7
8
|
import './chip.js';
|
|
8
9
|
import 'lucide-react';
|
|
@@ -10,6 +11,7 @@ import './field-sync.js';
|
|
|
10
11
|
import './ui.js';
|
|
11
12
|
import './tooltip.js';
|
|
12
13
|
import '../hooks/use-anchored-rect.js';
|
|
14
|
+
import '../lib/clipping.js';
|
|
13
15
|
import '../wizard/types.js';
|
|
14
16
|
import '../tour/tour.js';
|
|
15
17
|
import '../search/command-palette.js';
|
|
@@ -35,6 +37,10 @@ import '../feedback/feedback-dialog.js';
|
|
|
35
37
|
import './confirm-dialog.js';
|
|
36
38
|
import './floating-panel.js';
|
|
37
39
|
import './copy-button.js';
|
|
40
|
+
import './bulk-action-bar.js';
|
|
41
|
+
import './list.js';
|
|
42
|
+
import './breadcrumbs.js';
|
|
43
|
+
import './toast.js';
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
46
|
* A full calculator keypad rendered as a bottom sheet — the mobile counterpart to
|
|
@@ -88,12 +88,11 @@ function PageContents({
|
|
|
88
88
|
"a",
|
|
89
89
|
{
|
|
90
90
|
href: hrefFor(item.id),
|
|
91
|
-
title: variant === "rail" && typeof item.label === "string" ? item.label : void 0,
|
|
92
91
|
"aria-current": current ? "location" : void 0,
|
|
93
92
|
onClick: () => setClicked(item.id),
|
|
94
93
|
className: cn(
|
|
95
94
|
"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150",
|
|
96
|
-
variant === "rail" && "
|
|
95
|
+
variant === "rail" && "[overflow-wrap:anywhere]",
|
|
97
96
|
item.level === 2 ? "ps-6" : "ps-3",
|
|
98
97
|
current ? "border-[var(--brand)] font-medium text-[var(--brand)]" : "border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
|
|
99
98
|
),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/page-contents.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * \"On this page\": a page's own table of contents, with the section being read marked.\n *\n * Three parts, because an app needs them separately:\n *\n * - {@link useScrollSpy} — which section is current, from the headings' positions in\n * a scroll container. No markup; usable for anything that follows the reader.\n * - {@link PageContents} — the list. `variant=\"rail\"` for beside the page,\n * `variant=\"disclosure\"` for a collapsed block under the title on narrow screens.\n * - {@link PageContentsLayout} — the page with the rail sticky on one side of it.\n *\n * Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it\n * works with any router, including a hash router where `#id` alone would be read as a\n * route.\n */\n\nexport interface PageContentsItem {\n /** The id of the heading the entry jumps to. */\n id: string;\n label: ReactNode;\n /** 2 indents the entry under the one before it. */\n level?: 1 | 2;\n}\n\nexport interface PageContentsLabels {\n /** The heading above the list, and the landmark's accessible name. */\n title: string;\n}\n\nexport const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels = {\n title: \"On this page\",\n};\n\nexport interface UseScrollSpyOptions {\n /** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its\n * `<main>` — it is the page's scroll container from `md` up. */\n root?: Element | null;\n /**\n * How far down the scroller the \"reading line\" sits, as a fraction of its height.\n * A section becomes current once its heading passes above it, and stays current\n * until the next heading does — so a long section keeps its entry marked while it\n * is being read, not only while its heading is on screen. Default 0.15: close to\n * the top, so a short section followed by another does not hand its mark to the\n * next one the moment it is scrolled to.\n */\n line?: number;\n}\n\n/**\n * The id of the section being read: the last of `ids` whose element has scrolled\n * above the reading line — or the first, before any has.\n */\nexport function useScrollSpy(ids: readonly string[], options: UseScrollSpyOptions = {}): string | null {\n const { root = null, line = 0.15 } = options;\n const [current, setCurrent] = useState<string | null>(null);\n const key = ids.join(\"\\u0000\");\n\n useEffect(() => {\n if (ids.length === 0) return;\n const scroller: Element | Window = root ?? window;\n const measure = () => {\n const top = root ? root.getBoundingClientRect().top : 0;\n const height = root ? root.clientHeight : window.innerHeight;\n const reading = top + height * line;\n let next = ids[0];\n for (const id of ids) {\n const el = document.getElementById(id);\n if (el && el.getBoundingClientRect().top <= reading) next = id;\n }\n // At the very bottom the last sections may never reach the line; the reader\n // who scrolled to the end is reading the end.\n const atEnd = root\n ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;\n const scrolled = root ? root.scrollTop : window.scrollY;\n if (atEnd && scrolled > 0) next = ids[ids.length - 1];\n setCurrent(next);\n };\n let frame = 0;\n const onScroll = () => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(measure);\n };\n measure();\n scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n return () => {\n cancelAnimationFrame(frame);\n scroller.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n };\n // `key` stands for `ids`: a new array with the same entries is the same list.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [key, root, line]);\n\n return current;\n}\n\nexport interface PageContentsProps extends Omit<ComponentPropsWithoutRef<\"nav\">, \"children\"> {\n items: PageContentsItem[];\n /** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */\n activeId?: string | null;\n /** The link for an entry. Default `#id`; under a hash router build it with the\n * router (react-router's `useHref({ hash: id })` gives `#/page#id`). */\n hrefFor?: (id: string) => string;\n /** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,\n * for narrow screens where a rail has no room. */\n variant?: \"rail\" | \"disclosure\";\n labels?: Partial<PageContentsLabels>;\n}\n\n/**\n * The list itself. The current entry gets a brand bar on the list's rule, the brand\n * colour, and `aria-current=\"location\"` — the marker documentation sites use, and the\n * one a screen reader announces.\n */\nexport function PageContents({\n items,\n activeId,\n hrefFor = (id) => `#${id}`,\n variant = \"rail\",\n labels,\n className,\n ...rest\n}: PageContentsProps) {\n const l = useKitLabels(\"pageContents\", DEFAULT_PAGE_CONTENTS_LABELS, labels);\n const listRef = useRef<HTMLUListElement>(null);\n // The entry just clicked is the current one until the reader scrolls on their own.\n // Without this, jumping to a short section near the end of a page marks whichever\n // later section the jump also carried past the reading line — you click one entry\n // and another lights up.\n const [clicked, setClicked] = useState<string | null>(null);\n useEffect(() => {\n if (!clicked) return;\n const release = () => setClicked(null);\n // A user scroll — wheel, touch, keys — releases it; the programmatic scroll the\n // jump itself causes does not fire these.\n window.addEventListener(\"wheel\", release, { passive: true, once: true });\n window.addEventListener(\"touchmove\", release, { passive: true, once: true });\n window.addEventListener(\"keydown\", release, { once: true });\n return () => {\n window.removeEventListener(\"wheel\", release);\n window.removeEventListener(\"touchmove\", release);\n window.removeEventListener(\"keydown\", release);\n };\n }, [clicked]);\n const shown = clicked ?? activeId;\n\n // Keep the current entry in view when the list is taller than the window.\n useEffect(() => {\n if (!shown || variant !== \"rail\") return;\n listRef.current\n ?.querySelector<HTMLElement>(`[data-entry=\"${CSS.escape(shown)}\"]`)\n ?.scrollIntoView?.({ block: \"nearest\" });\n }, [shown, variant]);\n\n if (items.length === 0) return null;\n\n const list = (\n <ul\n ref={listRef}\n className={cn(\n \"space-y-0.5 border-s border-[var(--border)]\",\n variant === \"rail\" && \"max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]\",\n )}\n >\n {items.map((item) => {\n const current = item.id === shown;\n return (\n <li key={item.id} data-entry={item.id}>\n <a\n href={hrefFor(item.id)}\n // One line per entry in the rail; past 22rem the ellipsis takes over and\n // the full text is on hover. The disclosure has the page's width and wraps.\n title={variant === \"rail\" && typeof item.label === \"string\" ? item.label : undefined}\n aria-current={current ? \"location\" : undefined}\n onClick={() => setClicked(item.id)}\n className={cn(\n \"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150\",\n variant === \"rail\" && \"truncate\",\n item.level === 2 ? \"ps-6\" : \"ps-3\",\n current\n ? \"border-[var(--brand)] font-medium text-[var(--brand)]\"\n : \"border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]\",\n )}\n >\n {item.label}\n </a>\n </li>\n );\n })}\n </ul>\n );\n\n if (variant === \"disclosure\") {\n return (\n <details\n className={cn(\n \"group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]\",\n className,\n )}\n >\n <summary className=\"flex cursor-pointer list-none items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-secondary)] [&::-webkit-details-marker]:hidden\">\n {l.title}\n <ChevronDown\n aria-hidden\n className=\"size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none\"\n />\n </summary>\n <nav {...rest} aria-label={l.title} className=\"border-t border-[var(--border)] px-2 py-2\">\n {list}\n </nav>\n </details>\n );\n }\n\n return (\n <nav {...rest} aria-label={l.title} className={className}>\n <p className=\"mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {l.title}\n </p>\n {list}\n </nav>\n );\n}\n\nexport interface PageContentsLayoutProps extends ComponentPropsWithoutRef<\"div\"> {\n /** What goes in the rail — typically a `<PageContents variant=\"rail\">`. */\n contents: ReactNode;\n /**\n * Which side of the page the rail sits on: `start` is the left in a left-to-right\n * language (next to the app's own sidebar, so both navigations are in one place),\n * `end` the right (the documentation-site convention, clear of the sidebar). Logical,\n * so both flip in a right-to-left UI.\n */\n position?: \"start\" | \"end\";\n children: ReactNode;\n}\n\n/**\n * The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that\n * the rail would squeeze the content, so it is not rendered there — pair it with a\n * `<PageContents variant=\"disclosure\" className=\"xl:hidden\">` in the page.\n *\n * The rail is `sticky top-6`, which sticks inside the nearest scroll container — the\n * viewport, or `AppShell`'s `<main>`.\n */\nexport function PageContentsLayout({\n contents,\n position = \"start\",\n children,\n className,\n ...rest\n}: PageContentsLayoutProps) {\n // In DOM order on its side, not moved there with `order`: Tab and a screen reader\n // follow the source, and a rail painted on the left but read after the page would\n // put the page's index at its end.\n const rail = (\n <aside className=\"hidden xl:block\">\n <div className=\"sticky top-6\">{contents}</div>\n </aside>\n );\n return (\n <div\n {...rest}\n className={cn(\n \"xl:grid xl:gap-10\",\n // `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.\n // A fixed 13rem broke \"Select — unlabelled, labelled, invalid, disabled\" over\n // three lines beside a page with room to spare; an index you scan should read\n // one entry per line.\n position === \"start\"\n ? \"xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]\"\n : \"xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]\",\n className,\n )}\n >\n {position === \"start\" && rail}\n <div className=\"min-w-0\">{children}</div>\n {position === \"end\" && rail}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-contents.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * \"On this page\": a page's own table of contents, with the section being read marked.\n *\n * Three parts, because an app needs them separately:\n *\n * - {@link useScrollSpy} — which section is current, from the headings' positions in\n * a scroll container. No markup; usable for anything that follows the reader.\n * - {@link PageContents} — the list. `variant=\"rail\"` for beside the page,\n * `variant=\"disclosure\"` for a collapsed block under the title on narrow screens.\n * - {@link PageContentsLayout} — the page with the rail sticky on one side of it.\n *\n * Domain-free: the app supplies the entries and how an entry links (`hrefFor`), so it\n * works with any router, including a hash router where `#id` alone would be read as a\n * route.\n */\n\nexport interface PageContentsItem {\n /** The id of the heading the entry jumps to. */\n id: string;\n label: ReactNode;\n /** 2 indents the entry under the one before it. */\n level?: 1 | 2;\n}\n\nexport interface PageContentsLabels {\n /** The heading above the list, and the landmark's accessible name. */\n title: string;\n}\n\nexport const DEFAULT_PAGE_CONTENTS_LABELS: PageContentsLabels = {\n title: \"On this page\",\n};\n\nexport interface UseScrollSpyOptions {\n /** The element that scrolls. Defaults to the viewport. In `AppShell`, pass its\n * `<main>` — it is the page's scroll container from `md` up. */\n root?: Element | null;\n /**\n * How far down the scroller the \"reading line\" sits, as a fraction of its height.\n * A section becomes current once its heading passes above it, and stays current\n * until the next heading does — so a long section keeps its entry marked while it\n * is being read, not only while its heading is on screen. Default 0.15: close to\n * the top, so a short section followed by another does not hand its mark to the\n * next one the moment it is scrolled to.\n */\n line?: number;\n}\n\n/**\n * The id of the section being read: the last of `ids` whose element has scrolled\n * above the reading line — or the first, before any has.\n */\nexport function useScrollSpy(ids: readonly string[], options: UseScrollSpyOptions = {}): string | null {\n const { root = null, line = 0.15 } = options;\n const [current, setCurrent] = useState<string | null>(null);\n const key = ids.join(\"\\u0000\");\n\n useEffect(() => {\n if (ids.length === 0) return;\n const scroller: Element | Window = root ?? window;\n const measure = () => {\n const top = root ? root.getBoundingClientRect().top : 0;\n const height = root ? root.clientHeight : window.innerHeight;\n const reading = top + height * line;\n let next = ids[0];\n for (const id of ids) {\n const el = document.getElementById(id);\n if (el && el.getBoundingClientRect().top <= reading) next = id;\n }\n // At the very bottom the last sections may never reach the line; the reader\n // who scrolled to the end is reading the end.\n const atEnd = root\n ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2\n : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;\n const scrolled = root ? root.scrollTop : window.scrollY;\n if (atEnd && scrolled > 0) next = ids[ids.length - 1];\n setCurrent(next);\n };\n let frame = 0;\n const onScroll = () => {\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(measure);\n };\n measure();\n scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"resize\", onScroll);\n return () => {\n cancelAnimationFrame(frame);\n scroller.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"resize\", onScroll);\n };\n // `key` stands for `ids`: a new array with the same entries is the same list.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [key, root, line]);\n\n return current;\n}\n\nexport interface PageContentsProps extends Omit<ComponentPropsWithoutRef<\"nav\">, \"children\"> {\n items: PageContentsItem[];\n /** The current entry — typically `useScrollSpy(items.map(i => i.id), …)`. */\n activeId?: string | null;\n /** The link for an entry. Default `#id`; under a hash router build it with the\n * router (react-router's `useHref({ hash: id })` gives `#/page#id`). */\n hrefFor?: (id: string) => string;\n /** `rail` — a list beside the page. `disclosure` — collapsed under a summary line,\n * for narrow screens where a rail has no room. */\n variant?: \"rail\" | \"disclosure\";\n labels?: Partial<PageContentsLabels>;\n}\n\n/**\n * The list itself. The current entry gets a brand bar on the list's rule, the brand\n * colour, and `aria-current=\"location\"` — the marker documentation sites use, and the\n * one a screen reader announces.\n */\nexport function PageContents({\n items,\n activeId,\n hrefFor = (id) => `#${id}`,\n variant = \"rail\",\n labels,\n className,\n ...rest\n}: PageContentsProps) {\n const l = useKitLabels(\"pageContents\", DEFAULT_PAGE_CONTENTS_LABELS, labels);\n const listRef = useRef<HTMLUListElement>(null);\n // The entry just clicked is the current one until the reader scrolls on their own.\n // Without this, jumping to a short section near the end of a page marks whichever\n // later section the jump also carried past the reading line — you click one entry\n // and another lights up.\n const [clicked, setClicked] = useState<string | null>(null);\n useEffect(() => {\n if (!clicked) return;\n const release = () => setClicked(null);\n // A user scroll — wheel, touch, keys — releases it; the programmatic scroll the\n // jump itself causes does not fire these.\n window.addEventListener(\"wheel\", release, { passive: true, once: true });\n window.addEventListener(\"touchmove\", release, { passive: true, once: true });\n window.addEventListener(\"keydown\", release, { once: true });\n return () => {\n window.removeEventListener(\"wheel\", release);\n window.removeEventListener(\"touchmove\", release);\n window.removeEventListener(\"keydown\", release);\n };\n }, [clicked]);\n const shown = clicked ?? activeId;\n\n // Keep the current entry in view when the list is taller than the window.\n useEffect(() => {\n if (!shown || variant !== \"rail\") return;\n listRef.current\n ?.querySelector<HTMLElement>(`[data-entry=\"${CSS.escape(shown)}\"]`)\n ?.scrollIntoView?.({ block: \"nearest\" });\n }, [shown, variant]);\n\n if (items.length === 0) return null;\n\n const list = (\n <ul\n ref={listRef}\n className={cn(\n \"space-y-0.5 border-s border-[var(--border)]\",\n variant === \"rail\" && \"max-h-[calc(100dvh-10rem)] overflow-y-auto [scrollbar-width:thin]\",\n )}\n >\n {items.map((item) => {\n const current = item.id === shown;\n return (\n <li key={item.id} data-entry={item.id}>\n <a\n href={hrefFor(item.id)}\n // Entries WRAP in the rail rather than truncate: a cut-off heading needed a\n // native `title` to be read at all, which a keyboard never sees and which\n // broke keksdose's one-tooltip rule (dev#523).\n aria-current={current ? \"location\" : undefined}\n onClick={() => setClicked(item.id)}\n className={cn(\n \"-ms-px block border-s-2 py-1 pe-1 text-[13px] leading-snug transition-colors duration-150\",\n variant === \"rail\" && \"[overflow-wrap:anywhere]\",\n item.level === 2 ? \"ps-6\" : \"ps-3\",\n current\n ? \"border-[var(--brand)] font-medium text-[var(--brand)]\"\n : \"border-transparent text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]\",\n )}\n >\n {item.label}\n </a>\n </li>\n );\n })}\n </ul>\n );\n\n if (variant === \"disclosure\") {\n return (\n <details\n className={cn(\n \"group rounded-md border border-[var(--border)] bg-[var(--bg-surface)]\",\n className,\n )}\n >\n <summary className=\"flex cursor-pointer list-none items-center justify-between px-3 py-2 text-sm font-medium text-[var(--text-secondary)] [&::-webkit-details-marker]:hidden\">\n {l.title}\n <ChevronDown\n aria-hidden\n className=\"size-4 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none\"\n />\n </summary>\n <nav {...rest} aria-label={l.title} className=\"border-t border-[var(--border)] px-2 py-2\">\n {list}\n </nav>\n </details>\n );\n }\n\n return (\n <nav {...rest} aria-label={l.title} className={className}>\n <p className=\"mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {l.title}\n </p>\n {list}\n </nav>\n );\n}\n\nexport interface PageContentsLayoutProps extends ComponentPropsWithoutRef<\"div\"> {\n /** What goes in the rail — typically a `<PageContents variant=\"rail\">`. */\n contents: ReactNode;\n /**\n * Which side of the page the rail sits on: `start` is the left in a left-to-right\n * language (next to the app's own sidebar, so both navigations are in one place),\n * `end` the right (the documentation-site convention, clear of the sidebar). Logical,\n * so both flip in a right-to-left UI.\n */\n position?: \"start\" | \"end\";\n children: ReactNode;\n}\n\n/**\n * The page with a sticky contents rail beside it, from `xl` (1280px) up. Below that\n * the rail would squeeze the content, so it is not rendered there — pair it with a\n * `<PageContents variant=\"disclosure\" className=\"xl:hidden\">` in the page.\n *\n * The rail is `sticky top-6`, which sticks inside the nearest scroll container — the\n * viewport, or `AppShell`'s `<main>`.\n */\nexport function PageContentsLayout({\n contents,\n position = \"start\",\n children,\n className,\n ...rest\n}: PageContentsLayoutProps) {\n // In DOM order on its side, not moved there with `order`: Tab and a screen reader\n // follow the source, and a rail painted on the left but read after the page would\n // put the page's index at its end.\n const rail = (\n <aside className=\"hidden xl:block\">\n <div className=\"sticky top-6\">{contents}</div>\n </aside>\n );\n return (\n <div\n {...rest}\n className={cn(\n \"xl:grid xl:gap-10\",\n // `fit-content(22rem)`: the rail is as wide as its longest entry, up to 22rem.\n // A fixed 13rem broke \"Select — unlabelled, labelled, invalid, disabled\" over\n // three lines beside a page with room to spare; an index you scan should read\n // one entry per line.\n position === \"start\"\n ? \"xl:grid-cols-[fit-content(22rem)_minmax(0,1fr)]\"\n : \"xl:grid-cols-[minmax(0,1fr)_fit-content(22rem)]\",\n className,\n )}\n >\n {position === \"start\" && rail}\n <div className=\"min-w-0\">{children}</div>\n {position === \"end\" && rail}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a
|
|
5
|
+
* phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */
|
|
6
|
+
type PageHeaderSize = "sm" | "md";
|
|
7
|
+
interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
/** A sentence under the title. */
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
/** Buttons for the page — "New", "Export". At the title's end from `sm` up; under it,
|
|
12
|
+
* wrapping, on a phone. */
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
/** A `Breadcrumbs` trail, drawn above everything else. */
|
|
15
|
+
breadcrumbs?: ReactNode;
|
|
16
|
+
/** A small uppercase line above the title — the section or the kind of record
|
|
17
|
+
* ("Building", "Settings"). The section-label type. */
|
|
18
|
+
eyebrow?: ReactNode;
|
|
19
|
+
/** The title's element. Default `h1`: a page has one, and this is it. `h2` for a
|
|
20
|
+
* header inside a page that already has its `h1` (a tab's own header). */
|
|
21
|
+
as?: "h1" | "h2" | "h3";
|
|
22
|
+
size?: PageHeaderSize;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the
|
|
26
|
+
* page's actions.
|
|
27
|
+
*
|
|
28
|
+
* kastlan's (shared/components/display/page-header.tsx) is the model — title and
|
|
29
|
+
* description on one side, actions on the other, stacked on a phone — and this adds
|
|
30
|
+
* what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`
|
|
31
|
+
* title (a status chip beside the name), and actions that WRAP when there are more
|
|
32
|
+
* than fit a phone's width, instead of running off it. No outer margin: the page's
|
|
33
|
+
* own rhythm (`space-y-*`, a grid gap) places it.
|
|
34
|
+
*/
|
|
35
|
+
declare function PageHeader({ title, description, actions, breadcrumbs, eyebrow, as, size, className, ...rest }: PageHeaderProps): react.JSX.Element;
|
|
36
|
+
|
|
37
|
+
export { PageHeader, type PageHeaderProps, type PageHeaderSize };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { SECTION_LABEL_CLASS } from "./text.js";
|
|
5
|
+
const TITLE = {
|
|
6
|
+
sm: "text-lg font-semibold",
|
|
7
|
+
md: "text-xl font-semibold tracking-tight sm:text-2xl"
|
|
8
|
+
};
|
|
9
|
+
function PageHeader({
|
|
10
|
+
title,
|
|
11
|
+
description,
|
|
12
|
+
actions,
|
|
13
|
+
breadcrumbs,
|
|
14
|
+
eyebrow,
|
|
15
|
+
as = "h1",
|
|
16
|
+
size = "md",
|
|
17
|
+
className,
|
|
18
|
+
...rest
|
|
19
|
+
}) {
|
|
20
|
+
const Heading = as;
|
|
21
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, className: cn("flex min-w-0 flex-col gap-2", className), children: [
|
|
22
|
+
breadcrumbs,
|
|
23
|
+
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4", children: [
|
|
24
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
25
|
+
eyebrow != null && /* @__PURE__ */ jsx("p", { className: cn(SECTION_LABEL_CLASS.xs, "mb-1"), children: eyebrow }),
|
|
26
|
+
/* @__PURE__ */ jsx(Heading, { className: cn("break-words text-[var(--text-primary)]", TITLE[size]), children: title }),
|
|
27
|
+
description != null && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-[var(--text-muted)]", children: description })
|
|
28
|
+
] }),
|
|
29
|
+
actions != null && /* @__PURE__ */ jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })
|
|
30
|
+
] })
|
|
31
|
+
] });
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
PageHeader
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=page-header.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/page-header.tsx"],"sourcesContent":["import type { ComponentPropsWithoutRef, ElementType, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { SECTION_LABEL_CLASS } from \"./text\";\n\n/** `md` (default) is kastlan's `text-2xl` page title from `sm` up, a step smaller on a\n * phone; `sm` is lenkbank's `text-lg` title on a dense tool page (projects-page.tsx). */\nexport type PageHeaderSize = \"sm\" | \"md\";\n\nconst TITLE: Record<PageHeaderSize, string> = {\n sm: \"text-lg font-semibold\",\n md: \"text-xl font-semibold tracking-tight sm:text-2xl\",\n};\n\nexport interface PageHeaderProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n title: ReactNode;\n /** A sentence under the title. */\n description?: ReactNode;\n /** Buttons for the page — \"New\", \"Export\". At the title's end from `sm` up; under it,\n * wrapping, on a phone. */\n actions?: ReactNode;\n /** A `Breadcrumbs` trail, drawn above everything else. */\n breadcrumbs?: ReactNode;\n /** A small uppercase line above the title — the section or the kind of record\n * (\"Building\", \"Settings\"). The section-label type. */\n eyebrow?: ReactNode;\n /** The title's element. Default `h1`: a page has one, and this is it. `h2` for a\n * header inside a page that already has its `h1` (a tab's own header). */\n as?: \"h1\" | \"h2\" | \"h3\";\n size?: PageHeaderSize;\n}\n\n/**\n * A page's head: optional breadcrumbs, an eyebrow, the title, a description, and the\n * page's actions.\n *\n * kastlan's (shared/components/display/page-header.tsx) is the model — title and\n * description on one side, actions on the other, stacked on a phone — and this adds\n * what its pages kept writing around it: a trail above, an eyebrow, a `ReactNode`\n * title (a status chip beside the name), and actions that WRAP when there are more\n * than fit a phone's width, instead of running off it. No outer margin: the page's\n * own rhythm (`space-y-*`, a grid gap) places it.\n */\nexport function PageHeader({\n title,\n description,\n actions,\n breadcrumbs,\n eyebrow,\n as = \"h1\",\n size = \"md\",\n className,\n ...rest\n}: PageHeaderProps) {\n const Heading = as as ElementType;\n return (\n <div {...rest} className={cn(\"flex min-w-0 flex-col gap-2\", className)}>\n {breadcrumbs}\n <div className=\"flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4\">\n <div className=\"min-w-0 flex-1\">\n {eyebrow != null && <p className={cn(SECTION_LABEL_CLASS.xs, \"mb-1\")}>{eyebrow}</p>}\n <Heading className={cn(\"break-words text-[var(--text-primary)]\", TITLE[size])}>{title}</Heading>\n {description != null && <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>}\n </div>\n {actions != null && <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{actions}</div>}\n </div>\n </div>\n );\n}\n"],"mappings":";AA0DQ,SACsB,KADtB;AAzDR,SAAS,UAAU;AACnB,SAAS,2BAA2B;AAMpC,MAAM,QAAwC;AAAA,EAC5C,IAAI;AAAA,EACJ,IAAI;AACN;AA+BO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL,OAAO;AAAA,EACP;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,QAAM,UAAU;AAChB,SACE,qBAAC,SAAK,GAAG,MAAM,WAAW,GAAG,+BAA+B,SAAS,GAClE;AAAA;AAAA,IACD,qBAAC,SAAI,WAAU,sFACb;AAAA,2BAAC,SAAI,WAAU,kBACZ;AAAA,mBAAW,QAAQ,oBAAC,OAAE,WAAW,GAAG,oBAAoB,IAAI,MAAM,GAAI,mBAAQ;AAAA,QAC/E,oBAAC,WAAQ,WAAW,GAAG,0CAA0C,MAAM,IAAI,CAAC,GAAI,iBAAM;AAAA,QACrF,eAAe,QAAQ,oBAAC,OAAE,WAAU,yCAAyC,uBAAY;AAAA,SAC5F;AAAA,MACC,WAAW,QAAQ,oBAAC,SAAI,WAAU,8CAA8C,mBAAQ;AAAA,OAC3F;AAAA,KACF;AAEJ;","names":[]}
|