@eifi1/ui-kit 0.9.0 → 0.10.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 +21 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +4 -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 +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +4 -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/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.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/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.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 +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- 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/number-field.d.ts +4 -0
- package/dist/components/number-input.d.ts +4 -0
- package/dist/components/numpad-sheet.d.ts +4 -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/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 +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +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 +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +104 -4
- package/dist/components/ui.js +112 -8
- package/dist/components/ui.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/i18n/defaults.d.ts +4 -0
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +8 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +4 -0
- package/dist/i18n/locales/de-CH.d.ts +4 -0
- package/dist/i18n/locales/de-informal.d.ts +4 -0
- package/dist/i18n/locales/de.d.ts +4 -0
- package/dist/i18n/locales/de.js +21 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +4 -0
- package/dist/i18n/locales/es.js +21 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +4 -0
- package/dist/i18n/locales/fr.js +21 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +4 -0
- package/dist/i18n/locales/hu.js +21 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +4 -0
- package/dist/i18n/locales/it.js +21 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +4 -0
- package/dist/i18n/locales/zh.js +21 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +15 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- 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/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-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +16 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +128 -7
- package/src/components/copy-button.tsx +29 -7
- 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/file-dropzone.tsx +9 -10
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +237 -8
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/kit-labels.tsx +8 -0
- package/src/i18n/locales/de.ts +20 -0
- package/src/i18n/locales/es.ts +20 -0
- package/src/i18n/locales/fr.ts +20 -0
- package/src/i18n/locales/hu.ts +20 -0
- package/src/i18n/locales/it.ts +20 -0
- package/src/i18n/locales/zh.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +42 -5
- package/src/search/global-search.tsx +59 -18
- 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-summary.tsx +14 -10
- package/tokens.css +97 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useRef } from "react";
|
|
3
|
+
import { useId, useRef } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
@@ -12,6 +12,8 @@ import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
|
12
12
|
function FullBleedDialog({
|
|
13
13
|
open,
|
|
14
14
|
onClose,
|
|
15
|
+
title,
|
|
16
|
+
headingAs: Heading = "h2",
|
|
15
17
|
header,
|
|
16
18
|
closeLabel,
|
|
17
19
|
children,
|
|
@@ -28,6 +30,9 @@ function FullBleedDialog({
|
|
|
28
30
|
useBodyScrollLock(open);
|
|
29
31
|
useOverlayHistory(open && backCloses, requestClose);
|
|
30
32
|
const dialogRef = useRef(null);
|
|
33
|
+
const titleId = useId();
|
|
34
|
+
const hasTitle = title !== void 0 && title !== null && title !== false;
|
|
35
|
+
const hasHeader = header !== void 0 && header !== null && header !== false;
|
|
31
36
|
useFocusTrap(dialogRef, { active: open });
|
|
32
37
|
const handleKeyDown = (e) => {
|
|
33
38
|
const preventedBefore = e.defaultPrevented;
|
|
@@ -49,6 +54,7 @@ function FullBleedDialog({
|
|
|
49
54
|
),
|
|
50
55
|
role: "dialog",
|
|
51
56
|
"aria-modal": "true",
|
|
57
|
+
"aria-labelledby": rest["aria-labelledby"] ?? (hasTitle && !rest["aria-label"] ? titleId : void 0),
|
|
52
58
|
tabIndex: -1,
|
|
53
59
|
ref: dialogRef,
|
|
54
60
|
onKeyDown: handleKeyDown,
|
|
@@ -70,7 +76,10 @@ function FullBleedDialog({
|
|
|
70
76
|
),
|
|
71
77
|
children: [
|
|
72
78
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3", children: [
|
|
73
|
-
/* @__PURE__ */
|
|
79
|
+
hasTitle ? /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: [
|
|
80
|
+
/* @__PURE__ */ jsx(Heading, { id: titleId, className: "min-w-0 break-words text-base font-semibold leading-snug text-[var(--text-primary)]", children: title }),
|
|
81
|
+
hasHeader && /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1 font-medium", children: header })
|
|
82
|
+
] }) : /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1 font-medium", children: header }),
|
|
74
83
|
/* @__PURE__ */ jsx(
|
|
75
84
|
"button",
|
|
76
85
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends ComponentPropsWithoutRef<\"div\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims the key, Escape included.\n // Only there: a descendant's preventDefault does not count. A SearchField in the\n // header prevents Escape to stop Chromium clearing a type=search input, and in\n // 0.8.0 that silently stopped Escape from closing the dialog (keksdose).\n const preventedBefore = e.defaultPrevented;\n onKeyDown?.(e);\n const claimedByCaller = !preventedBefore && e.defaultPrevented;\n if (claimedByCaller || e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AAoMQ,SACE,KADF;AApMR,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AA4EtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAY7C,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAM1D,UAAM,kBAAkB,EAAE;AAC1B,gBAAY,CAAC;AACb,UAAM,kBAAkB,CAAC,mBAAmB,EAAE;AAC9C,QAAI,mBAAmB,EAAE,QAAQ,SAAU;AAQ3C,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAGX,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QAQX,aAAa,CAAC,MAAM;AAClB,wBAAc,CAAC;AACf,wBAAc,YAAY,CAAC;AAAA,QAC7B;AAAA,QACA,WAAW,CAAC,MAAM;AAChB,sBAAY,CAAC;AACb,wBAAc,UAAU,CAAC;AAAA,QAC3B;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACb;AAAA,oCAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,gBACpD;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAMA,oBAAC,SAAI,WAAU,+DAA+D,UAAS;AAAA,cACtF,WAAW,UAAa,WAAW,QAAQ,WAAW,SACrD;AAAA,gBAAC;AAAA;AAAA,kBACC,0BAAuB;AAAA,kBACvB,WAAU;AAAA,kBACV,OAAO,EAAE,eAAe,4CAA4C;AAAA,kBAEnE;AAAA;AAAA,cACH;AAAA;AAAA;AAAA,QAEJ;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useId, useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /**\n * The dialog's HEADING: rendered as a real `h2` (see {@link headingAs}) at the start\n * of the header strip, and wired to the dialog's `aria-labelledby`, so the dialog is\n * announced by name rather than as just \"dialog\".\n *\n * Before this the strip was a styled `div`, and a caller that wanted its full-screen\n * phone editor NAMED had to leave this component for `DialogFrame fullBleed` —\n * keksdose's budget-table phone editor says so in a comment at the call site. A\n * caller's own `aria-label` / `aria-labelledby` still wins.\n */\n title?: ReactNode;\n /** The heading level of {@link title}. `h2` by default, as `DialogFrame`'s. */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * Free content for the header strip, beside the close button — after the\n * {@link title} when there is one, or on its own (the row editor shows the row's\n * own first cell here). It does not name the dialog; `title` does.\n */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n /**\n * A row pinned under the scrolling body — an Apply, a Save. Keksdose's `RangeSheet`\n * (reports/report-range-field.tsx) is a hand-rolled copy of this dialog for want of\n * exactly this slot: a date range picked on a phone is committed by a sticky Apply,\n * and a button at the end of the body scrolls away with the calendar.\n *\n * Outside the scroller, so it stays put without `position: sticky` (which would\n * need the body to be the scroll container of its own child and paint over the\n * last field); padded for the home indicator, since it is the panel's bottom edge.\n */\n footer?: ReactNode;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. A plain\n * title belongs in `title`, which is a real heading and the dialog's name; `header`\n * stays the free slot beside it. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n title,\n headingAs: Heading = \"h2\",\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n footer,\n onMouseDown,\n onMouseUp,\n onKeyDown,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n const titleId = useId();\n const hasTitle = title !== undefined && title !== null && title !== false;\n const hasHeader = header !== undefined && header !== null && header !== false;\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // The caller's handler first (keksdose's transaction-search keeps its list keys on\n // the dialog); a `preventDefault()` IN THAT HANDLER claims the key, Escape included.\n // Only there: a descendant's preventDefault does not count. A SearchField in the\n // header prevents Escape to stop Chromium clearing a type=search input, and in\n // 0.8.0 that silently stopped Escape from closing the dialog (keksdose).\n const preventedBefore = e.defaultPrevented;\n onKeyDown?.(e);\n const claimedByCaller = !preventedBefore && e.defaultPrevented;\n if (claimedByCaller || e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // The heading names the dialog unless the caller named it some other way — an\n // `aria-labelledby` of its own, or an `aria-label` (which a `labelledby` would\n // silently outrank).\n aria-labelledby={rest[\"aria-labelledby\"] ?? (hasTitle && !rest[\"aria-label\"] ? titleId : undefined)}\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n // COMPOSED with the backdrop's, not overridden by them. These two used to ride\n // `...rest` and then lose to `{...backdropClose}` spread after it, silently —\n // and the one caller about to pass one is keksdose's RangeSheet, whose\n // `onMouseDown={(e) => e.stopPropagation()}` is its dev#477 fix: a press in a\n // portalled sheet must not reach a document-level \"outside click\" listener,\n // which would unmount the day cell before its `click`. The caller's runs first;\n // the backdrop's still runs after it, since it is the same element.\n onMouseDown={(e) => {\n onMouseDown?.(e);\n backdropClose.onMouseDown(e);\n }}\n onMouseUp={(e) => {\n onMouseUp?.(e);\n backdropClose.onMouseUp(e);\n }}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\n {hasTitle ? (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <Heading id={titleId} className=\"min-w-0 break-words text-base font-semibold leading-snug text-[var(--text-primary)]\">\n {title}\n </Heading>\n {hasHeader && <div className=\"min-w-0 flex-1 font-medium\">{header}</div>}\n </div>\n ) : (\n <div className=\"min-w-0 flex-1 font-medium\">{header}</div>\n )}\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-me-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n {/* `min-h-0` so it is the BODY that gives way to a footer, not the footer that\n is pushed off the bottom edge. */}\n <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n {footer !== undefined && footer !== null && footer !== false && (\n <div\n data-full-bleed-footer=\"\"\n className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-3 pt-3\"\n style={{ paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\" }}\n >\n {footer}\n </div>\n )}\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AAkOY,SACE,KADF;AAlOZ,SAAS,OAAO,cAAc;AAC9B,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AA+FtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,UAAU;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAC7C,QAAM,UAAU,MAAM;AACtB,QAAM,WAAW,UAAU,UAAa,UAAU,QAAQ,UAAU;AACpE,QAAM,YAAY,WAAW,UAAa,WAAW,QAAQ,WAAW;AAYxE,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAM1D,UAAM,kBAAkB,EAAE;AAC1B,gBAAY,CAAC;AACb,UAAM,kBAAkB,CAAC,mBAAmB,EAAE;AAC9C,QAAI,mBAAmB,EAAE,QAAQ,SAAU;AAQ3C,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAIX,mBAAiB,KAAK,iBAAiB,MAAM,YAAY,CAAC,KAAK,YAAY,IAAI,UAAU;AAAA,QAGzF,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QAQX,aAAa,CAAC,MAAM;AAClB,wBAAc,CAAC;AACf,wBAAc,YAAY,CAAC;AAAA,QAC7B;AAAA,QACA,WAAW,CAAC,MAAM;AAChB,sBAAY,CAAC;AACb,wBAAc,UAAU,CAAC;AAAA,QAC3B;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACZ;AAAA,2BACC,qBAAC,SAAI,WAAU,0CACb;AAAA,sCAAC,WAAQ,IAAI,SAAS,WAAU,uFAC7B,iBACH;AAAA,kBACC,aAAa,oBAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,mBACpE,IAEA,oBAAC,SAAI,WAAU,8BAA8B,kBAAO;AAAA,gBAEtD;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAMA,oBAAC,SAAI,WAAU,+DAA+D,UAAS;AAAA,cACtF,WAAW,UAAa,WAAW,QAAQ,WAAW,SACrD;AAAA,gBAAC;AAAA;AAAA,kBACC,0BAAuB;AAAA,kBACvB,WAAU;AAAA,kBACV,OAAO,EAAE,eAAe,4CAA4C;AAAA,kBAEnE;AAAA;AAAA,cACH;AAAA;AAAA;AAAA,QAEJ;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode, MouseEvent, Ref, ReactElement } from 'react';
|
|
3
|
+
import { LucideIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface ListLabels {
|
|
6
|
+
/** Read after an unread row's title — the dot alone says nothing to a screen reader. */
|
|
7
|
+
unread: string;
|
|
8
|
+
/** Read after an external row's title: the row leaves the app in a new tab. */
|
|
9
|
+
opensInNewTab: string;
|
|
10
|
+
}
|
|
11
|
+
declare const DEFAULT_LIST_LABELS: ListLabels;
|
|
12
|
+
/**
|
|
13
|
+
* `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px
|
|
14
|
+
* row every site draws with `py-2.5`, `comfortable` for a list that is the page.
|
|
15
|
+
*/
|
|
16
|
+
type ListDensity = "compact" | "default" | "comfortable";
|
|
17
|
+
/** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space
|
|
18
|
+
* between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */
|
|
19
|
+
type ListSeparator = "divider" | "gap" | "none";
|
|
20
|
+
interface ListProps extends Omit<ComponentPropsWithoutRef<"ul">, "role"> {
|
|
21
|
+
/** `ol` when the order means something (a ranked list, numbered segments). */
|
|
22
|
+
as?: "ul" | "ol";
|
|
23
|
+
/** Every row's density, unless a row says otherwise. Default `default`. */
|
|
24
|
+
density?: ListDensity;
|
|
25
|
+
/** Default `gap`. */
|
|
26
|
+
separator?: ListSeparator;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The list of {@link ListItem}s.
|
|
30
|
+
*
|
|
31
|
+
* A `<ul>` with an explicit `role="list"`: Safari drops a list's semantics once its
|
|
32
|
+
* bullets are styled away, and "list, 7 items" is exactly what a screen-reader user
|
|
33
|
+
* wants to hear before the first row.
|
|
34
|
+
*/
|
|
35
|
+
declare const List: react.ForwardRefExoticComponent<ListProps & react.RefAttributes<HTMLUListElement>>;
|
|
36
|
+
/** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,
|
|
37
|
+
* mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */
|
|
38
|
+
interface ListItemLinkProps {
|
|
39
|
+
href: string;
|
|
40
|
+
/** The row's target look — keep it, or the row's padding and focus ring go. */
|
|
41
|
+
className: string;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
44
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
45
|
+
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
46
|
+
"aria-current"?: "true";
|
|
47
|
+
"aria-busy"?: true;
|
|
48
|
+
id?: string;
|
|
49
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
50
|
+
[key: `data-${string}`]: unknown;
|
|
51
|
+
}
|
|
52
|
+
interface ListItemBaseProps {
|
|
53
|
+
/** The row's name: one line, truncated. */
|
|
54
|
+
title: ReactNode;
|
|
55
|
+
/** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
|
|
56
|
+
subtitle?: ReactNode;
|
|
57
|
+
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
58
|
+
subtitleLines?: 1 | 2;
|
|
59
|
+
/** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
|
|
60
|
+
meta?: ReactNode;
|
|
61
|
+
/** A Lucide icon before the text, muted, at 16px. */
|
|
62
|
+
icon?: LucideIcon;
|
|
63
|
+
/** Anything else before the text — an avatar, a file-type badge, a drag handle. */
|
|
64
|
+
leading?: ReactNode;
|
|
65
|
+
/**
|
|
66
|
+
* Inside the main target, at its end: a status chip, a file size, a count. It is part
|
|
67
|
+
* of what the row's click does and part of its accessible name — so never a control.
|
|
68
|
+
* Controls go in {@link actions}.
|
|
69
|
+
*/
|
|
70
|
+
trailing?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* Controls BESIDE the main target, never inside it: copy, delete, "Open".
|
|
73
|
+
*
|
|
74
|
+
* A button inside a button (or a link) is invalid HTML, and a screen reader folds the
|
|
75
|
+
* inner one into the outer one's name where it cannot be reached. lenkbank found this
|
|
76
|
+
* three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and
|
|
77
|
+
* solved it the same way each time: the row's target and its actions are siblings in
|
|
78
|
+
* one bordered box. That is this slot — and being siblings is also what stops a click
|
|
79
|
+
* on "Copy" from selecting the row.
|
|
80
|
+
*/
|
|
81
|
+
actions?: ReactNode;
|
|
82
|
+
/**
|
|
83
|
+
* The unread mark: a brand dot at the row's end, the title in semibold, and "Unread"
|
|
84
|
+
* read after the title (keksdose notification-inbox.tsx:215). For another status,
|
|
85
|
+
* pass your own mark in {@link status}.
|
|
86
|
+
*/
|
|
87
|
+
unread?: boolean;
|
|
88
|
+
/** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */
|
|
89
|
+
status?: ReactNode;
|
|
90
|
+
/**
|
|
91
|
+
* The row is the chosen one — a brand border on the raised surface (lenkbank's
|
|
92
|
+
* profile bar and segment list). `aria-current="true"` on the target: the row is the
|
|
93
|
+
* current one of a set, which is what those lists mean, not a pressed toggle.
|
|
94
|
+
*/
|
|
95
|
+
selected?: boolean;
|
|
96
|
+
disabled?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Work under way on the row — keksdose's attachment download (support-attachments
|
|
99
|
+
* .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a
|
|
100
|
+
* second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so
|
|
101
|
+
* a keyboard user is not dropped back to the top of the page mid-download.
|
|
102
|
+
*/
|
|
103
|
+
loading?: boolean;
|
|
104
|
+
/** Overrides the {@link List}'s density for this row. */
|
|
105
|
+
density?: ListDensity;
|
|
106
|
+
/** `start` for rows whose text runs to two or three lines, so the icon sits by the
|
|
107
|
+
* title rather than the middle of the paragraph. Default `center`. */
|
|
108
|
+
align?: "center" | "start";
|
|
109
|
+
/** Classes on the visible row (the bordered box). */
|
|
110
|
+
className?: string;
|
|
111
|
+
/** Classes on the main target (the button, link or static block). */
|
|
112
|
+
targetClassName?: string;
|
|
113
|
+
/** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
|
|
114
|
+
as?: "li" | "div";
|
|
115
|
+
/** Reaches the main target — the element with the row's name and action. */
|
|
116
|
+
id?: string;
|
|
117
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
118
|
+
[key: `data-${string}`]: unknown;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The row is ONE thing to activate, so the types allow one: a button, an in-app link,
|
|
122
|
+
* an external link, or nothing (a static row, like kastlan's passkey and user lists).
|
|
123
|
+
*/
|
|
124
|
+
type ListItemProps = ListItemBaseProps & ({
|
|
125
|
+
/** Makes the row a button — select, open, mark read. */
|
|
126
|
+
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
127
|
+
href?: never;
|
|
128
|
+
renderLink?: never;
|
|
129
|
+
external?: never;
|
|
130
|
+
onAuxClick?: never;
|
|
131
|
+
} | {
|
|
132
|
+
/**
|
|
133
|
+
* Makes the row a link. A real `<a href>`, so a middle click opens it in a
|
|
134
|
+
* background tab (keksdose feedback #451) — which a button calling `navigate`
|
|
135
|
+
* cannot do.
|
|
136
|
+
*/
|
|
137
|
+
href: string;
|
|
138
|
+
/** Your router's link for an in-app `href`. Default `<a>`. The API `Chip`,
|
|
139
|
+
* `StatTile` and `MenuItem` share. Ignored for an `external` row. */
|
|
140
|
+
renderLink?: (props: ListItemLinkProps) => ReactElement;
|
|
141
|
+
/**
|
|
142
|
+
* The link leaves the app: a plain `<a target="_blank" rel="noopener
|
|
143
|
+
* noreferrer">` with an external-link mark, and "opens in a new tab" read
|
|
144
|
+
* after the title. keksdose's inbox rows whose URL is absolute.
|
|
145
|
+
*/
|
|
146
|
+
external?: boolean;
|
|
147
|
+
/** Runs on a click, before the navigation (mark the row read). */
|
|
148
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
149
|
+
/** Runs on a middle click, which fires no `click` — keksdose marks a
|
|
150
|
+
* notification read here too, so a row opened into a background tab does not
|
|
151
|
+
* stay unread. */
|
|
152
|
+
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
153
|
+
} | {
|
|
154
|
+
onClick?: never;
|
|
155
|
+
href?: never;
|
|
156
|
+
renderLink?: never;
|
|
157
|
+
external?: never;
|
|
158
|
+
onAuxClick?: never;
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
162
|
+
* truncating), a trailing slot, and — beside the row, not inside it — its actions.
|
|
163
|
+
*
|
|
164
|
+
* All three apps draw this row by hand, a dozen times: kastlan's units
|
|
165
|
+
* (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
|
|
166
|
+
* feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)
|
|
167
|
+
* and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment
|
|
168
|
+
* downloads; lenkbank's profiles, projects and segments. The copies differ in the
|
|
169
|
+
* details that are easy to get wrong — which of them truncate, whether the actions
|
|
170
|
+
* are nested in the button, whether a link row is a link at all.
|
|
171
|
+
*/
|
|
172
|
+
declare const ListItem: react.ForwardRefExoticComponent<ListItemProps & react.RefAttributes<HTMLElement>>;
|
|
173
|
+
|
|
174
|
+
export { DEFAULT_LIST_LABELS, List, type ListDensity, ListItem, type ListItemLinkProps, type ListItemProps, type ListLabels, type ListProps, type ListSeparator };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, forwardRef, useContext } from "react";
|
|
4
|
+
import { ExternalLink } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Spinner } from "./ui.js";
|
|
7
|
+
import { StatusDot } from "./status-dot.js";
|
|
8
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
9
|
+
const DEFAULT_LIST_LABELS = {
|
|
10
|
+
unread: "Unread",
|
|
11
|
+
opensInNewTab: "opens in a new tab"
|
|
12
|
+
};
|
|
13
|
+
const ListContext = createContext({
|
|
14
|
+
density: "default",
|
|
15
|
+
separator: "gap"
|
|
16
|
+
});
|
|
17
|
+
const List = forwardRef(function List2({ as = "ul", density = "default", separator = "gap", className, children, ...rest }, ref) {
|
|
18
|
+
const Tag = as;
|
|
19
|
+
return /* @__PURE__ */ jsx(ListContext.Provider, { value: { density, separator }, children: /* @__PURE__ */ jsx(
|
|
20
|
+
Tag,
|
|
21
|
+
{
|
|
22
|
+
...rest,
|
|
23
|
+
ref,
|
|
24
|
+
role: "list",
|
|
25
|
+
className: cn(
|
|
26
|
+
"m-0 flex list-none flex-col p-0",
|
|
27
|
+
separator === "gap" && (density === "compact" ? "gap-0.5" : "gap-1"),
|
|
28
|
+
className
|
|
29
|
+
),
|
|
30
|
+
children
|
|
31
|
+
}
|
|
32
|
+
) });
|
|
33
|
+
});
|
|
34
|
+
const PAD = {
|
|
35
|
+
compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5" },
|
|
36
|
+
default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1" },
|
|
37
|
+
comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1" }
|
|
38
|
+
};
|
|
39
|
+
const TARGET_RING = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
40
|
+
const ListItem = forwardRef(function ListItem2({
|
|
41
|
+
title,
|
|
42
|
+
subtitle,
|
|
43
|
+
subtitleLines = 1,
|
|
44
|
+
meta,
|
|
45
|
+
icon: Icon,
|
|
46
|
+
leading,
|
|
47
|
+
trailing,
|
|
48
|
+
actions,
|
|
49
|
+
unread = false,
|
|
50
|
+
status,
|
|
51
|
+
selected = false,
|
|
52
|
+
disabled = false,
|
|
53
|
+
loading = false,
|
|
54
|
+
density: densityProp,
|
|
55
|
+
align = "center",
|
|
56
|
+
className,
|
|
57
|
+
targetClassName,
|
|
58
|
+
as = "li",
|
|
59
|
+
href,
|
|
60
|
+
renderLink,
|
|
61
|
+
external = false,
|
|
62
|
+
onClick,
|
|
63
|
+
onAuxClick,
|
|
64
|
+
...rest
|
|
65
|
+
}, ref) {
|
|
66
|
+
const list = useContext(ListContext);
|
|
67
|
+
const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
68
|
+
const density = densityProp ?? list.density;
|
|
69
|
+
const pad = PAD[density];
|
|
70
|
+
const isLink = href !== void 0 && !disabled;
|
|
71
|
+
const isButton = !isLink && onClick !== void 0 && href === void 0;
|
|
72
|
+
const interactive = isLink || isButton;
|
|
73
|
+
const inert = disabled || loading;
|
|
74
|
+
const mark = status ?? (unread ? /* @__PURE__ */ jsx(StatusDot, { tone: "brand", size: "sm", className: align === "start" ? "mt-1.5" : void 0 }) : null);
|
|
75
|
+
const body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
76
|
+
Icon && /* @__PURE__ */ jsx(
|
|
77
|
+
Icon,
|
|
78
|
+
{
|
|
79
|
+
className: cn("size-4 shrink-0 text-[var(--text-muted)]", align === "start" && "mt-0.5"),
|
|
80
|
+
"aria-hidden": true
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
leading,
|
|
84
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
85
|
+
/* @__PURE__ */ jsxs(
|
|
86
|
+
"span",
|
|
87
|
+
{
|
|
88
|
+
className: cn(
|
|
89
|
+
"block truncate text-sm text-[var(--text-primary)]",
|
|
90
|
+
unread ? "font-semibold" : "font-medium"
|
|
91
|
+
),
|
|
92
|
+
children: [
|
|
93
|
+
title,
|
|
94
|
+
unread && /* @__PURE__ */ jsxs("span", { className: "sr-only", children: [
|
|
95
|
+
" (",
|
|
96
|
+
labels.unread,
|
|
97
|
+
")"
|
|
98
|
+
] }),
|
|
99
|
+
external && href !== void 0 && /* @__PURE__ */ jsxs("span", { className: "sr-only", children: [
|
|
100
|
+
" (",
|
|
101
|
+
labels.opensInNewTab,
|
|
102
|
+
")"
|
|
103
|
+
] })
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
subtitle != null && /* @__PURE__ */ jsx(
|
|
108
|
+
"span",
|
|
109
|
+
{
|
|
110
|
+
className: cn(
|
|
111
|
+
"block text-xs text-[var(--text-muted)]",
|
|
112
|
+
subtitleLines === 2 ? "line-clamp-2" : "truncate"
|
|
113
|
+
),
|
|
114
|
+
children: subtitle
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
meta != null && /* @__PURE__ */ jsx("span", { className: "mt-0.5 block truncate text-[11px] leading-snug text-[var(--text-muted)]", children: meta })
|
|
118
|
+
] }),
|
|
119
|
+
trailing != null && /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center gap-2 text-xs text-[var(--text-muted)]", children: trailing }),
|
|
120
|
+
loading ? /* @__PURE__ */ jsx(Spinner, { label: null, className: "size-3.5 shrink-0" }) : external && href !== void 0 && /* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100", "aria-hidden": true }),
|
|
121
|
+
mark
|
|
122
|
+
] });
|
|
123
|
+
const target = cn(
|
|
124
|
+
"flex min-w-0 flex-1 text-start",
|
|
125
|
+
align === "start" ? "items-start" : "items-center",
|
|
126
|
+
pad.target,
|
|
127
|
+
"rounded-[inherit]",
|
|
128
|
+
interactive && TARGET_RING,
|
|
129
|
+
interactive && !inert && "cursor-pointer",
|
|
130
|
+
loading && "cursor-progress",
|
|
131
|
+
targetClassName
|
|
132
|
+
);
|
|
133
|
+
let main;
|
|
134
|
+
if (isLink) {
|
|
135
|
+
const linkProps = {
|
|
136
|
+
...rest,
|
|
137
|
+
ref,
|
|
138
|
+
href,
|
|
139
|
+
className: target,
|
|
140
|
+
"aria-current": selected ? "true" : void 0,
|
|
141
|
+
"aria-busy": loading || void 0,
|
|
142
|
+
onClick: loading ? (e) => e.preventDefault() : onClick,
|
|
143
|
+
onAuxClick,
|
|
144
|
+
children: body
|
|
145
|
+
};
|
|
146
|
+
const { children: linkBody, ...anchorProps } = linkProps;
|
|
147
|
+
main = renderLink && !external ? /* @__PURE__ */ jsx(RenderedLink, { render: renderLink, ...linkProps }) : /* @__PURE__ */ jsx("a", { ...anchorProps, ...external ? { target: "_blank", rel: "noopener noreferrer" } : null, children: linkBody });
|
|
148
|
+
} else if (isButton) {
|
|
149
|
+
main = /* @__PURE__ */ jsx(
|
|
150
|
+
"button",
|
|
151
|
+
{
|
|
152
|
+
...rest,
|
|
153
|
+
ref,
|
|
154
|
+
type: "button",
|
|
155
|
+
disabled,
|
|
156
|
+
"aria-disabled": loading || void 0,
|
|
157
|
+
"aria-busy": loading || void 0,
|
|
158
|
+
"aria-current": selected ? "true" : void 0,
|
|
159
|
+
onClick: loading ? void 0 : onClick,
|
|
160
|
+
className: target,
|
|
161
|
+
children: body
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
} else {
|
|
165
|
+
main = /* @__PURE__ */ jsx(
|
|
166
|
+
"div",
|
|
167
|
+
{
|
|
168
|
+
...rest,
|
|
169
|
+
ref,
|
|
170
|
+
"aria-disabled": disabled || void 0,
|
|
171
|
+
"aria-busy": loading || void 0,
|
|
172
|
+
className: target,
|
|
173
|
+
children: body
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const Tag = as;
|
|
178
|
+
return /* @__PURE__ */ jsx(
|
|
179
|
+
Tag,
|
|
180
|
+
{
|
|
181
|
+
className: cn(
|
|
182
|
+
list.separator === "divider" && "border-b border-[var(--border)] last:border-b-0",
|
|
183
|
+
list.separator === "divider" && as === "li" && "py-0.5"
|
|
184
|
+
),
|
|
185
|
+
children: /* @__PURE__ */ jsxs(
|
|
186
|
+
"div",
|
|
187
|
+
{
|
|
188
|
+
className: cn(
|
|
189
|
+
"flex min-w-0 items-center rounded-md border transition-colors",
|
|
190
|
+
selected ? "border-[var(--brand)] bg-[var(--bg-surface-2)]" : "border-transparent",
|
|
191
|
+
interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
192
|
+
disabled && "opacity-50",
|
|
193
|
+
className
|
|
194
|
+
),
|
|
195
|
+
children: [
|
|
196
|
+
main,
|
|
197
|
+
actions != null && /* @__PURE__ */ jsx("div", { className: cn("flex shrink-0 items-center", pad.gap, pad.actions), children: actions })
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
function RenderedLink({
|
|
205
|
+
render,
|
|
206
|
+
...props
|
|
207
|
+
}) {
|
|
208
|
+
return render(props);
|
|
209
|
+
}
|
|
210
|
+
export {
|
|
211
|
+
DEFAULT_LIST_LABELS,
|
|
212
|
+
List,
|
|
213
|
+
ListItem
|
|
214
|
+
};
|
|
215
|
+
//# sourceMappingURL=list.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/list.tsx"],"sourcesContent":["import { createContext, forwardRef, useContext } from \"react\";\nimport type { ComponentPropsWithoutRef, MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { ExternalLink } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Spinner } from \"./ui\";\nimport { StatusDot } from \"./status-dot\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\n/* ── Labels ───────────────────────────────────────────────────────────────── */\n\nexport interface ListLabels {\n /** Read after an unread row's title — the dot alone says nothing to a screen reader. */\n unread: string;\n /** Read after an external row's title: the row leaves the app in a new tab. */\n opensInNewTab: string;\n}\n\nexport const DEFAULT_LIST_LABELS: ListLabels = {\n unread: \"Unread\",\n opensInNewTab: \"opens in a new tab\",\n};\n\n/* ── List ─────────────────────────────────────────────────────────────────── */\n\n/**\n * `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px\n * row every site draws with `py-2.5`, `comfortable` for a list that is the page.\n */\nexport type ListDensity = \"compact\" | \"default\" | \"comfortable\";\n/** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space\n * between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */\nexport type ListSeparator = \"divider\" | \"gap\" | \"none\";\n\nconst ListContext = createContext<{ density: ListDensity; separator: ListSeparator }>({\n density: \"default\",\n separator: \"gap\",\n});\n\nexport interface ListProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"role\"> {\n /** `ol` when the order means something (a ranked list, numbered segments). */\n as?: \"ul\" | \"ol\";\n /** Every row's density, unless a row says otherwise. Default `default`. */\n density?: ListDensity;\n /** Default `gap`. */\n separator?: ListSeparator;\n}\n\n/**\n * The list of {@link ListItem}s.\n *\n * A `<ul>` with an explicit `role=\"list\"`: Safari drops a list's semantics once its\n * bullets are styled away, and \"list, 7 items\" is exactly what a screen-reader user\n * wants to hear before the first row.\n */\nexport const List = forwardRef<HTMLUListElement, ListProps>(function List(\n { as = \"ul\", density = \"default\", separator = \"gap\", className, children, ...rest },\n ref,\n) {\n const Tag = as;\n return (\n <ListContext.Provider value={{ density, separator }}>\n <Tag\n {...rest}\n ref={ref as Ref<HTMLUListElement & HTMLOListElement>}\n role=\"list\"\n className={cn(\n \"m-0 flex list-none flex-col p-0\",\n separator === \"gap\" && (density === \"compact\" ? \"gap-0.5\" : \"gap-1\"),\n className,\n )}\n >\n {children}\n </Tag>\n </ListContext.Provider>\n );\n});\n\n/* ── ListItem ─────────────────────────────────────────────────────────────── */\n\n/** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,\n * mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */\nexport interface ListItemLinkProps {\n href: string;\n /** The row's target look — keep it, or the row's padding and focus ring go. */\n className: string;\n children: ReactNode;\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: \"true\";\n \"aria-busy\"?: true;\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\nconst PAD: Record<ListDensity, { target: string; actions: string; gap: string }> = {\n compact: { target: \"gap-2 px-2 py-1.5\", actions: \"pe-1.5\", gap: \"gap-0.5\" },\n default: { target: \"gap-3 px-3 py-2.5\", actions: \"pe-2\", gap: \"gap-1\" },\n comfortable: { target: \"gap-3 px-4 py-3\", actions: \"pe-3\", gap: \"gap-1\" },\n};\n\nconst TARGET_RING =\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]\";\n\ninterface ListItemBaseProps {\n /** The row's name: one line, truncated. */\n title: ReactNode;\n /** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */\n subtitle?: ReactNode;\n /** `2` for a line that is a body rather than a label (keksdose's notification text). */\n subtitleLines?: 1 | 2;\n /** A third line in the caption type: a timestamp, \"Updated …\" (lenkbank's projects). */\n meta?: ReactNode;\n /** A Lucide icon before the text, muted, at 16px. */\n icon?: LucideIcon;\n /** Anything else before the text — an avatar, a file-type badge, a drag handle. */\n leading?: ReactNode;\n /**\n * Inside the main target, at its end: a status chip, a file size, a count. It is part\n * of what the row's click does and part of its accessible name — so never a control.\n * Controls go in {@link actions}.\n */\n trailing?: ReactNode;\n /**\n * Controls BESIDE the main target, never inside it: copy, delete, \"Open\".\n *\n * A button inside a button (or a link) is invalid HTML, and a screen reader folds the\n * inner one into the outer one's name where it cannot be reached. lenkbank found this\n * three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and\n * solved it the same way each time: the row's target and its actions are siblings in\n * one bordered box. That is this slot — and being siblings is also what stops a click\n * on \"Copy\" from selecting the row.\n */\n actions?: ReactNode;\n /**\n * The unread mark: a brand dot at the row's end, the title in semibold, and \"Unread\"\n * read after the title (keksdose notification-inbox.tsx:215). For another status,\n * pass your own mark in {@link status}.\n */\n unread?: boolean;\n /** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */\n status?: ReactNode;\n /**\n * The row is the chosen one — a brand border on the raised surface (lenkbank's\n * profile bar and segment list). `aria-current=\"true\"` on the target: the row is the\n * current one of a set, which is what those lists mean, not a pressed toggle.\n */\n selected?: boolean;\n disabled?: boolean;\n /**\n * Work under way on the row — keksdose's attachment download (support-attachments\n * .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a\n * second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so\n * a keyboard user is not dropped back to the top of the page mid-download.\n */\n loading?: boolean;\n /** Overrides the {@link List}'s density for this row. */\n density?: ListDensity;\n /** `start` for rows whose text runs to two or three lines, so the icon sits by the\n * title rather than the middle of the paragraph. Default `center`. */\n align?: \"center\" | \"start\";\n /** Classes on the visible row (the bordered box). */\n className?: string;\n /** Classes on the main target (the button, link or static block). */\n targetClassName?: string;\n /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */\n as?: \"li\" | \"div\";\n /** Reaches the main target — the element with the row's name and action. */\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\n/**\n * The row is ONE thing to activate, so the types allow one: a button, an in-app link,\n * an external link, or nothing (a static row, like kastlan's passkey and user lists).\n */\nexport type ListItemProps = ListItemBaseProps &\n (\n | {\n /** Makes the row a button — select, open, mark read. */\n onClick: (event: MouseEvent<HTMLButtonElement>) => void;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n }\n | {\n /**\n * Makes the row a link. A real `<a href>`, so a middle click opens it in a\n * background tab (keksdose feedback #451) — which a button calling `navigate`\n * cannot do.\n */\n href: string;\n /** Your router's link for an in-app `href`. Default `<a>`. The API `Chip`,\n * `StatTile` and `MenuItem` share. Ignored for an `external` row. */\n renderLink?: (props: ListItemLinkProps) => ReactElement;\n /**\n * The link leaves the app: a plain `<a target=\"_blank\" rel=\"noopener\n * noreferrer\">` with an external-link mark, and \"opens in a new tab\" read\n * after the title. keksdose's inbox rows whose URL is absolute.\n */\n external?: boolean;\n /** Runs on a click, before the navigation (mark the row read). */\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n /** Runs on a middle click, which fires no `click` — keksdose marks a\n * notification read here too, so a row opened into a background tab does not\n * stay unread. */\n onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n }\n | {\n onClick?: never;\n href?: never;\n renderLink?: never;\n external?: never;\n onAuxClick?: never;\n }\n );\n\n/**\n * One row of a {@link List}: leading icon or avatar, title and subtitle (both\n * truncating), a trailing slot, and — beside the row, not inside it — its actions.\n *\n * All three apps draw this row by hand, a dozen times: kastlan's units\n * (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity\n * feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)\n * and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment\n * downloads; lenkbank's profiles, projects and segments. The copies differ in the\n * details that are easy to get wrong — which of them truncate, whether the actions\n * are nested in the button, whether a link row is a link at all.\n */\nexport const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(\n {\n title,\n subtitle,\n subtitleLines = 1,\n meta,\n icon: Icon,\n leading,\n trailing,\n actions,\n unread = false,\n status,\n selected = false,\n disabled = false,\n loading = false,\n density: densityProp,\n align = \"center\",\n className,\n targetClassName,\n as = \"li\",\n href,\n renderLink,\n external = false,\n onClick,\n onAuxClick,\n ...rest\n },\n ref,\n) {\n const list = useContext(ListContext);\n const labels = useKitLabels(\"list\", DEFAULT_LIST_LABELS);\n const density = densityProp ?? list.density;\n const pad = PAD[density];\n const isLink = href !== undefined && !disabled;\n const isButton = !isLink && onClick !== undefined && href === undefined;\n const interactive = isLink || isButton;\n const inert = disabled || loading;\n\n const mark =\n status ??\n (unread ? <StatusDot tone=\"brand\" size=\"sm\" className={align === \"start\" ? \"mt-1.5\" : undefined} /> : null);\n\n const body = (\n <>\n {Icon && (\n <Icon\n className={cn(\"size-4 shrink-0 text-[var(--text-muted)]\", align === \"start\" && \"mt-0.5\")}\n aria-hidden\n />\n )}\n {leading}\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span\n className={cn(\n \"block truncate text-sm text-[var(--text-primary)]\",\n unread ? \"font-semibold\" : \"font-medium\",\n )}\n >\n {title}\n {unread && <span className=\"sr-only\"> ({labels.unread})</span>}\n {external && href !== undefined && <span className=\"sr-only\"> ({labels.opensInNewTab})</span>}\n </span>\n {subtitle != null && (\n <span\n className={cn(\n \"block text-xs text-[var(--text-muted)]\",\n subtitleLines === 2 ? \"line-clamp-2\" : \"truncate\",\n )}\n >\n {subtitle}\n </span>\n )}\n {meta != null && <span className=\"mt-0.5 block truncate text-[11px] leading-snug text-[var(--text-muted)]\">{meta}</span>}\n </span>\n {trailing != null && <span className=\"flex shrink-0 items-center gap-2 text-xs text-[var(--text-muted)]\">{trailing}</span>}\n {loading ? (\n <Spinner label={null} className=\"size-3.5 shrink-0\" />\n ) : (\n external && href !== undefined && (\n <ExternalLink className=\"size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100\" aria-hidden />\n )\n )}\n {mark}\n </>\n );\n\n const target = cn(\n \"flex min-w-0 flex-1 text-start\",\n align === \"start\" ? \"items-start\" : \"items-center\",\n pad.target,\n \"rounded-[inherit]\",\n interactive && TARGET_RING,\n interactive && !inert && \"cursor-pointer\",\n loading && \"cursor-progress\",\n targetClassName,\n );\n\n let main: ReactNode;\n if (isLink) {\n const linkProps: ListItemLinkProps = {\n ...rest,\n ref: ref as Ref<HTMLAnchorElement>,\n href,\n className: target,\n \"aria-current\": selected ? \"true\" : undefined,\n \"aria-busy\": loading || undefined,\n onClick: loading\n ? (e) => e.preventDefault()\n : (onClick as ListItemLinkProps[\"onClick\"]),\n onAuxClick,\n children: body,\n };\n const { children: linkBody, ...anchorProps } = linkProps;\n main = renderLink && !external ? (\n <RenderedLink render={renderLink} {...linkProps} />\n ) : (\n <a {...anchorProps} {...(external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : null)}>\n {linkBody}\n </a>\n );\n } else if (isButton) {\n main = (\n <button\n {...rest}\n ref={ref as Ref<HTMLButtonElement>}\n type=\"button\"\n // A disabled row is `disabled`: nothing to do there, and nothing to reach. A\n // loading row is only `aria-disabled` — see `loading`.\n disabled={disabled}\n aria-disabled={loading || undefined}\n aria-busy={loading || undefined}\n aria-current={selected ? \"true\" : undefined}\n onClick={loading ? undefined : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)}\n className={target}\n >\n {body}\n </button>\n );\n } else {\n main = (\n <div\n {...rest}\n ref={ref as Ref<HTMLDivElement>}\n aria-disabled={disabled || undefined}\n aria-busy={loading || undefined}\n className={target}\n >\n {body}\n </div>\n );\n }\n\n const Tag = as;\n return (\n <Tag\n className={cn(\n list.separator === \"divider\" && \"border-b border-[var(--border)] last:border-b-0\",\n list.separator === \"divider\" && as === \"li\" && \"py-0.5\",\n )}\n >\n <div\n className={cn(\n \"flex min-w-0 items-center rounded-md border transition-colors\",\n selected ? \"border-[var(--brand)] bg-[var(--bg-surface-2)]\" : \"border-transparent\",\n interactive && !inert && !selected && \"hover:bg-[var(--bg-hover)]\",\n disabled && \"opacity-50\",\n className,\n )}\n >\n {main}\n {actions != null && (\n <div className={cn(\"flex shrink-0 items-center\", pad.gap, pad.actions)}>{actions}</div>\n )}\n </div>\n </Tag>\n );\n});\n\nfunction RenderedLink({\n render,\n ...props\n}: ListItemLinkProps & { render: (props: ListItemLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":";AA8DM,SAsNF,UAtNE,KAsOe,YAtOf;AA9DN,SAAS,eAAe,YAAY,kBAAkB;AAEtD,SAAS,oBAAoB;AAE7B,SAAS,UAAU;AACnB,SAAS,eAAe;AACxB,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAWtB,MAAM,sBAAkC;AAAA,EAC7C,QAAQ;AAAA,EACR,eAAe;AACjB;AAaA,MAAM,cAAc,cAAkE;AAAA,EACpF,SAAS;AAAA,EACT,WAAW;AACb,CAAC;AAkBM,MAAM,OAAO,WAAwC,SAASA,MACnE,EAAE,KAAK,MAAM,UAAU,WAAW,YAAY,OAAO,WAAW,UAAU,GAAG,KAAK,GAClF,KACA;AACA,QAAM,MAAM;AACZ,SACE,oBAAC,YAAY,UAAZ,EAAqB,OAAO,EAAE,SAAS,UAAU,GAChD;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,MAAK;AAAA,MACL,WAAW;AAAA,QACT;AAAA,QACA,cAAc,UAAU,YAAY,YAAY,YAAY;AAAA,QAC5D;AAAA,MACF;AAAA,MAEC;AAAA;AAAA,EACH,GACF;AAEJ,CAAC;AAqBD,MAAM,MAA6E;AAAA,EACjF,SAAS,EAAE,QAAQ,qBAAqB,SAAS,UAAU,KAAK,UAAU;AAAA,EAC1E,SAAS,EAAE,QAAQ,qBAAqB,SAAS,QAAQ,KAAK,QAAQ;AAAA,EACtE,aAAa,EAAE,QAAQ,mBAAmB,SAAS,QAAQ,KAAK,QAAQ;AAC1E;AAEA,MAAM,cACJ;AAiIK,MAAM,WAAW,WAAuC,SAASC,UACtE;AAAA,EACE;AAAA,EACA;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AAAA,EACT,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,KAAK;AAAA,EACL;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,OAAO,WAAW,WAAW;AACnC,QAAM,SAAS,aAAa,QAAQ,mBAAmB;AACvD,QAAM,UAAU,eAAe,KAAK;AACpC,QAAM,MAAM,IAAI,OAAO;AACvB,QAAM,SAAS,SAAS,UAAa,CAAC;AACtC,QAAM,WAAW,CAAC,UAAU,YAAY,UAAa,SAAS;AAC9D,QAAM,cAAc,UAAU;AAC9B,QAAM,QAAQ,YAAY;AAE1B,QAAM,OACJ,WACC,SAAS,oBAAC,aAAU,MAAK,SAAQ,MAAK,MAAK,WAAW,UAAU,UAAU,WAAW,QAAW,IAAK;AAExG,QAAM,OACJ,iCACG;AAAA,YACC;AAAA,MAAC;AAAA;AAAA,QACC,WAAW,GAAG,4CAA4C,UAAU,WAAW,QAAQ;AAAA,QACvF,eAAW;AAAA;AAAA,IACb;AAAA,IAED;AAAA,IACD,qBAAC,UAAK,WAAU,gCACd;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,SAAS,kBAAkB;AAAA,UAC7B;AAAA,UAEC;AAAA;AAAA,YACA,UAAU,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,cAAG,OAAO;AAAA,cAAO;AAAA,eAAC;AAAA,YACtD,YAAY,SAAS,UAAa,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,cAAG,OAAO;AAAA,cAAc;AAAA,eAAC;AAAA;AAAA;AAAA,MACxF;AAAA,MACC,YAAY,QACX;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,kBAAkB,IAAI,iBAAiB;AAAA,UACzC;AAAA,UAEC;AAAA;AAAA,MACH;AAAA,MAED,QAAQ,QAAQ,oBAAC,UAAK,WAAU,2EAA2E,gBAAK;AAAA,OACnH;AAAA,IACC,YAAY,QAAQ,oBAAC,UAAK,WAAU,qEAAqE,oBAAS;AAAA,IAClH,UACC,oBAAC,WAAQ,OAAO,MAAM,WAAU,qBAAoB,IAEpD,YAAY,SAAS,UACnB,oBAAC,gBAAa,WAAU,+DAA8D,eAAW,MAAC;AAAA,IAGrG;AAAA,KACH;AAGF,QAAM,SAAS;AAAA,IACb;AAAA,IACA,UAAU,UAAU,gBAAgB;AAAA,IACpC,IAAI;AAAA,IACJ;AAAA,IACA,eAAe;AAAA,IACf,eAAe,CAAC,SAAS;AAAA,IACzB,WAAW;AAAA,IACX;AAAA,EACF;AAEA,MAAI;AACJ,MAAI,QAAQ;AACV,UAAM,YAA+B;AAAA,MACnC,GAAG;AAAA,MACH;AAAA,MACA;AAAA,MACA,WAAW;AAAA,MACX,gBAAgB,WAAW,SAAS;AAAA,MACpC,aAAa,WAAW;AAAA,MACxB,SAAS,UACL,CAAC,MAAM,EAAE,eAAe,IACvB;AAAA,MACL;AAAA,MACA,UAAU;AAAA,IACZ;AACA,UAAM,EAAE,UAAU,UAAU,GAAG,YAAY,IAAI;AAC/C,WAAO,cAAc,CAAC,WACpB,oBAAC,gBAAa,QAAQ,YAAa,GAAG,WAAW,IAEjD,oBAAC,OAAG,GAAG,aAAc,GAAI,WAAW,EAAE,QAAQ,UAAU,KAAK,sBAAsB,IAAI,MACpF,oBACH;AAAA,EAEJ,WAAW,UAAU;AACnB,WACE;AAAA,MAAC;AAAA;AAAA,QACE,GAAG;AAAA,QACJ;AAAA,QACA,MAAK;AAAA,QAGL;AAAA,QACA,iBAAe,WAAW;AAAA,QAC1B,aAAW,WAAW;AAAA,QACtB,gBAAc,WAAW,SAAS;AAAA,QAClC,SAAS,UAAU,SAAa;AAAA,QAChC,WAAW;AAAA,QAEV;AAAA;AAAA,IACH;AAAA,EAEJ,OAAO;AACL,WACE;AAAA,MAAC;AAAA;AAAA,QACE,GAAG;AAAA,QACJ;AAAA,QACA,iBAAe,YAAY;AAAA,QAC3B,aAAW,WAAW;AAAA,QACtB,WAAW;AAAA,QAEV;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,QAAM,MAAM;AACZ,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT,KAAK,cAAc,aAAa;AAAA,QAChC,KAAK,cAAc,aAAa,OAAO,QAAQ;AAAA,MACjD;AAAA,MAEA;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,WAAW,mDAAmD;AAAA,YAC9D,eAAe,CAAC,SAAS,CAAC,YAAY;AAAA,YACtC,YAAY;AAAA,YACZ;AAAA,UACF;AAAA,UAEC;AAAA;AAAA,YACA,WAAW,QACV,oBAAC,SAAI,WAAW,GAAG,8BAA8B,IAAI,KAAK,IAAI,OAAO,GAAI,mBAAQ;AAAA;AAAA;AAAA,MAErF;AAAA;AAAA,EACF;AAEJ,CAAC;AAED,SAAS,aAAa;AAAA,EACpB;AAAA,EACA,GAAG;AACL,GAA+E;AAC7E,SAAO,OAAO,KAAK;AACrB;","names":["List","ListItem"]}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, MouseEvent, Ref, ReactElement } from 'react';
|
|
3
|
+
import { LucideIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
type MenuItemTone = "default" | "danger";
|
|
6
|
+
/** What {@link MenuItemProps.renderLink} is handed. Spread it onto your router's link,
|
|
7
|
+
* mapping `href` to what the link calls it —
|
|
8
|
+
* `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
|
|
9
|
+
interface MenuItemLinkProps {
|
|
10
|
+
href: string;
|
|
11
|
+
className: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
role: "menuitem";
|
|
14
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
15
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
16
|
+
"aria-current"?: "page";
|
|
17
|
+
id?: string;
|
|
18
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
19
|
+
[key: `data-${string}`]: unknown;
|
|
20
|
+
}
|
|
21
|
+
interface MenuItemBaseProps {
|
|
22
|
+
/** The row's text. */
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
/** An icon before the label, drawn at the menu's 16px. */
|
|
25
|
+
icon?: LucideIcon;
|
|
26
|
+
/** Anything else before the label — an avatar, a flag — when a Lucide icon will not do. */
|
|
27
|
+
leading?: ReactNode;
|
|
28
|
+
/** After the label, at the row's end: a shortcut, a count, a badge. */
|
|
29
|
+
trailing?: ReactNode;
|
|
30
|
+
/** `danger` for the row that destroys or signs out. Colour is not the whole message:
|
|
31
|
+
* the label must still say what the row does. */
|
|
32
|
+
tone?: MenuItemTone;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
37
|
+
[key: `data-${string}`]: unknown;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A menu row is a button OR a link, and only the button can be CHECKED: a checked link
|
|
41
|
+
* is a link to where you already are, which is `current`, not a selection.
|
|
42
|
+
*/
|
|
43
|
+
type MenuItemProps = MenuItemBaseProps & ({
|
|
44
|
+
href?: never;
|
|
45
|
+
renderLink?: never;
|
|
46
|
+
current?: never;
|
|
47
|
+
/** Runs the row. Close the menu from here — `HoverMenu` hands its `close` to
|
|
48
|
+
* the render prop that draws the rows. */
|
|
49
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Makes the row a CHOICE and says whether it is chosen. Leave it undefined for a
|
|
52
|
+
* plain command; `false` is a choice that is not chosen, which is different.
|
|
53
|
+
* A check mark is drawn at the end when it is on.
|
|
54
|
+
*/
|
|
55
|
+
checked?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* What kind of choice, when `checked` is given:
|
|
58
|
+
*
|
|
59
|
+
* - `radio` (default): one of a set — keksdose's budget switcher
|
|
60
|
+
* (budget-switcher.tsx:51), the theme and language pickers. `menuitemradio`.
|
|
61
|
+
* - `checkbox`: an independent on/off — "Show archived". `menuitemcheckbox`.
|
|
62
|
+
*
|
|
63
|
+
* Not `aria-pressed`: that belongs to a toggle BUTTON, and inside a
|
|
64
|
+
* `role="menu"` a screen reader expects the menu roles and reads "checked"
|
|
65
|
+
* only from them. A button with `aria-pressed` in a menu is announced as a
|
|
66
|
+
* button in a menu, which is two widgets in one.
|
|
67
|
+
*/
|
|
68
|
+
checkable?: "radio" | "checkbox";
|
|
69
|
+
} | {
|
|
70
|
+
/** Makes the row a link — keksdose's "Manage budgets" and account-menu rows. */
|
|
71
|
+
href: string;
|
|
72
|
+
/** Renders the link — pass your router's `<Link>`, since a plain `<a>` reloads
|
|
73
|
+
* a single-page app. Default `<a>`. The same API as `Chip` and `StatTile`. */
|
|
74
|
+
renderLink?: (props: MenuItemLinkProps) => ReactElement;
|
|
75
|
+
/** Runs on the way (close the menu). */
|
|
76
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
77
|
+
/** The link to the page already open: `aria-current="page"` and a check mark. */
|
|
78
|
+
current?: boolean;
|
|
79
|
+
checked?: never;
|
|
80
|
+
checkable?: never;
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* One row of a `HoverMenu` / `TopBarActionMenu` panel.
|
|
84
|
+
*
|
|
85
|
+
* keksdose's account menu and budget switcher each rebuild this row by hand — the
|
|
86
|
+
* shell's class, an icon, a label, a badge, a check mark on the active budget — and the
|
|
87
|
+
* check mark was the only thing saying which budget was active, so a screen reader
|
|
88
|
+
* heard four identical buttons. This row says it (`menuitemradio` + `aria-checked`).
|
|
89
|
+
*
|
|
90
|
+
* The role is set HERE rather than left to `HoverMenu`, which promotes only role-less
|
|
91
|
+
* buttons and links to `menuitem`: a checked row must be `menuitemradio` or
|
|
92
|
+
* `menuitemcheckbox` from its first render, and `HoverMenu` leaves an element that
|
|
93
|
+
* already has a role alone — so its roving focus (↑/↓, Home/End) picks these rows up
|
|
94
|
+
* with the rest. Render it inside an `<li>` like the shell's own rows; `HoverMenu`
|
|
95
|
+
* turns the list markup into `role="none"`.
|
|
96
|
+
*/
|
|
97
|
+
declare const MenuItem: react.ForwardRefExoticComponent<MenuItemProps & react.RefAttributes<HTMLElement>>;
|
|
98
|
+
|
|
99
|
+
export { MenuItem, type MenuItemLinkProps, type MenuItemProps, type MenuItemTone };
|