@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
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { createContext, forwardRef, useContext } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
|
+
import { ExternalLink } from "lucide-react";
|
|
4
|
+
import type { LucideIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { Spinner } from "./ui";
|
|
7
|
+
import { StatusDot } from "./status-dot";
|
|
8
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
9
|
+
|
|
10
|
+
/* ── Labels ───────────────────────────────────────────────────────────────── */
|
|
11
|
+
|
|
12
|
+
export interface ListLabels {
|
|
13
|
+
/** Read after an unread row's title — the dot alone says nothing to a screen reader. */
|
|
14
|
+
unread: string;
|
|
15
|
+
/** Read after an external row's title: the row leaves the app in a new tab. */
|
|
16
|
+
opensInNewTab: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_LIST_LABELS: ListLabels = {
|
|
20
|
+
unread: "Unread",
|
|
21
|
+
opensInNewTab: "opens in a new tab",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/* ── List ─────────────────────────────────────────────────────────────────── */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `compact` 36px-ish rows for a dense panel (an inbox in a menu), `default` the ~44px
|
|
28
|
+
* row every site draws with `py-2.5`, `comfortable` for a list that is the page.
|
|
29
|
+
*/
|
|
30
|
+
export type ListDensity = "compact" | "default" | "comfortable";
|
|
31
|
+
/** `divider`: a rule between rows (kastlan's `divide-y` lists); `gap`: a small space
|
|
32
|
+
* between rows (lenkbank's `space-y-2`, kastlan's `space-y-1`); `none`: flush. */
|
|
33
|
+
export type ListSeparator = "divider" | "gap" | "none";
|
|
34
|
+
|
|
35
|
+
const ListContext = createContext<{ density: ListDensity; separator: ListSeparator }>({
|
|
36
|
+
density: "default",
|
|
37
|
+
separator: "gap",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export interface ListProps extends Omit<ComponentPropsWithoutRef<"ul">, "role"> {
|
|
41
|
+
/** `ol` when the order means something (a ranked list, numbered segments). */
|
|
42
|
+
as?: "ul" | "ol";
|
|
43
|
+
/** Every row's density, unless a row says otherwise. Default `default`. */
|
|
44
|
+
density?: ListDensity;
|
|
45
|
+
/** Default `gap`. */
|
|
46
|
+
separator?: ListSeparator;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The list of {@link ListItem}s.
|
|
51
|
+
*
|
|
52
|
+
* A `<ul>` with an explicit `role="list"`: Safari drops a list's semantics once its
|
|
53
|
+
* bullets are styled away, and "list, 7 items" is exactly what a screen-reader user
|
|
54
|
+
* wants to hear before the first row.
|
|
55
|
+
*/
|
|
56
|
+
export const List = forwardRef<HTMLUListElement, ListProps>(function List(
|
|
57
|
+
{ as = "ul", density = "default", separator = "gap", className, children, ...rest },
|
|
58
|
+
ref,
|
|
59
|
+
) {
|
|
60
|
+
const Tag = as;
|
|
61
|
+
return (
|
|
62
|
+
<ListContext.Provider value={{ density, separator }}>
|
|
63
|
+
<Tag
|
|
64
|
+
{...rest}
|
|
65
|
+
ref={ref as Ref<HTMLUListElement & HTMLOListElement>}
|
|
66
|
+
role="list"
|
|
67
|
+
className={cn(
|
|
68
|
+
"m-0 flex list-none flex-col p-0",
|
|
69
|
+
separator === "gap" && (density === "compact" ? "gap-0.5" : "gap-1"),
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</Tag>
|
|
75
|
+
</ListContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/* ── ListItem ─────────────────────────────────────────────────────────────── */
|
|
80
|
+
|
|
81
|
+
/** What {@link ListItemProps.renderLink} is handed. Spread it onto your router's link,
|
|
82
|
+
* mapping `href` to what it calls it — `({ href, ...p }) => <Link to={href} {...p} />`. */
|
|
83
|
+
export interface ListItemLinkProps {
|
|
84
|
+
href: string;
|
|
85
|
+
/** The row's target look — keep it, or the row's padding and focus ring go. */
|
|
86
|
+
className: string;
|
|
87
|
+
children: ReactNode;
|
|
88
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
89
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
90
|
+
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
91
|
+
"aria-current"?: "true";
|
|
92
|
+
"aria-busy"?: true;
|
|
93
|
+
id?: string;
|
|
94
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
95
|
+
[key: `data-${string}`]: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const PAD: Record<ListDensity, { target: string; actions: string; gap: string }> = {
|
|
99
|
+
compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5" },
|
|
100
|
+
default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1" },
|
|
101
|
+
comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1" },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const TARGET_RING =
|
|
105
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
106
|
+
|
|
107
|
+
interface ListItemBaseProps {
|
|
108
|
+
/** The row's name: one line, truncated. */
|
|
109
|
+
title: ReactNode;
|
|
110
|
+
/** The line under it: one line, truncated — or two, with `subtitleLines={2}`. */
|
|
111
|
+
subtitle?: ReactNode;
|
|
112
|
+
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
113
|
+
subtitleLines?: 1 | 2;
|
|
114
|
+
/** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
|
|
115
|
+
meta?: ReactNode;
|
|
116
|
+
/** A Lucide icon before the text, muted, at 16px. */
|
|
117
|
+
icon?: LucideIcon;
|
|
118
|
+
/** Anything else before the text — an avatar, a file-type badge, a drag handle. */
|
|
119
|
+
leading?: ReactNode;
|
|
120
|
+
/**
|
|
121
|
+
* Inside the main target, at its end: a status chip, a file size, a count. It is part
|
|
122
|
+
* of what the row's click does and part of its accessible name — so never a control.
|
|
123
|
+
* Controls go in {@link actions}.
|
|
124
|
+
*/
|
|
125
|
+
trailing?: ReactNode;
|
|
126
|
+
/**
|
|
127
|
+
* Controls BESIDE the main target, never inside it: copy, delete, "Open".
|
|
128
|
+
*
|
|
129
|
+
* A button inside a button (or a link) is invalid HTML, and a screen reader folds the
|
|
130
|
+
* inner one into the outer one's name where it cannot be reached. lenkbank found this
|
|
131
|
+
* three times (profile-bar.tsx:110, projects-page.tsx:61, segment-list.tsx:165) and
|
|
132
|
+
* solved it the same way each time: the row's target and its actions are siblings in
|
|
133
|
+
* one bordered box. That is this slot — and being siblings is also what stops a click
|
|
134
|
+
* on "Copy" from selecting the row.
|
|
135
|
+
*/
|
|
136
|
+
actions?: ReactNode;
|
|
137
|
+
/**
|
|
138
|
+
* The unread mark: a brand dot at the row's end, the title in semibold, and "Unread"
|
|
139
|
+
* read after the title (keksdose notification-inbox.tsx:215). For another status,
|
|
140
|
+
* pass your own mark in {@link status}.
|
|
141
|
+
*/
|
|
142
|
+
unread?: boolean;
|
|
143
|
+
/** A mark in the unread dot's place — a `StatusDot` of another tone, a count. */
|
|
144
|
+
status?: ReactNode;
|
|
145
|
+
/**
|
|
146
|
+
* The row is the chosen one — a brand border on the raised surface (lenkbank's
|
|
147
|
+
* profile bar and segment list). `aria-current="true"` on the target: the row is the
|
|
148
|
+
* current one of a set, which is what those lists mean, not a pressed toggle.
|
|
149
|
+
*/
|
|
150
|
+
selected?: boolean;
|
|
151
|
+
disabled?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Work under way on the row — keksdose's attachment download (support-attachments
|
|
154
|
+
* .tsx:91). A spinner takes the trailing edge, the target is `aria-busy`, and a
|
|
155
|
+
* second click is ignored. The row keeps focus: `aria-disabled`, not `disabled`, so
|
|
156
|
+
* a keyboard user is not dropped back to the top of the page mid-download.
|
|
157
|
+
*/
|
|
158
|
+
loading?: boolean;
|
|
159
|
+
/** Overrides the {@link List}'s density for this row. */
|
|
160
|
+
density?: ListDensity;
|
|
161
|
+
/** `start` for rows whose text runs to two or three lines, so the icon sits by the
|
|
162
|
+
* title rather than the middle of the paragraph. Default `center`. */
|
|
163
|
+
align?: "center" | "start";
|
|
164
|
+
/** Classes on the visible row (the bordered box). */
|
|
165
|
+
className?: string;
|
|
166
|
+
/** Classes on the main target (the button, link or static block). */
|
|
167
|
+
targetClassName?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Extra attributes and handlers for the main target — e.g. HTML5 drag-and-drop
|
|
170
|
+
* (`draggable`, `onDragStart`, `onDragEnd`): lenkbank's segment list drags the WHOLE
|
|
171
|
+
* 44px row, and a grip inside the button cannot start a drag in Firefox. Spread first,
|
|
172
|
+
* so the row's own role, name, state and click handling always win.
|
|
173
|
+
*/
|
|
174
|
+
targetProps?: HTMLAttributes<HTMLElement>;
|
|
175
|
+
/**
|
|
176
|
+
* A visible border while unselected, instead of the transparent one. For rows laid out
|
|
177
|
+
* as a wrapping strip (lenkbank's profile bar), where borderless rows read as loose
|
|
178
|
+
* text. The selected row's brand border is unchanged.
|
|
179
|
+
*/
|
|
180
|
+
bordered?: boolean;
|
|
181
|
+
/** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
|
|
182
|
+
as?: "li" | "div";
|
|
183
|
+
/** Reaches the main target — the element with the row's name and action. */
|
|
184
|
+
id?: string;
|
|
185
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
186
|
+
[key: `data-${string}`]: unknown;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The row is ONE thing to activate, so the types allow one: a button, an in-app link,
|
|
191
|
+
* an external link, or nothing (a static row, like kastlan's passkey and user lists).
|
|
192
|
+
*/
|
|
193
|
+
export type ListItemProps = ListItemBaseProps &
|
|
194
|
+
(
|
|
195
|
+
| {
|
|
196
|
+
/** Makes the row a button — select, open, mark read. */
|
|
197
|
+
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
198
|
+
href?: never;
|
|
199
|
+
renderLink?: never;
|
|
200
|
+
external?: never;
|
|
201
|
+
onAuxClick?: never;
|
|
202
|
+
}
|
|
203
|
+
| {
|
|
204
|
+
/**
|
|
205
|
+
* Makes the row a link. A real `<a href>`, so a middle click opens it in a
|
|
206
|
+
* background tab (keksdose feedback #451) — which a button calling `navigate`
|
|
207
|
+
* cannot do.
|
|
208
|
+
*/
|
|
209
|
+
href: string;
|
|
210
|
+
/** Your router's link for an in-app `href`. Default `<a>`. The API `Chip`,
|
|
211
|
+
* `StatTile` and `MenuItem` share. Ignored for an `external` row. */
|
|
212
|
+
renderLink?: (props: ListItemLinkProps) => ReactElement;
|
|
213
|
+
/**
|
|
214
|
+
* The link leaves the app: a plain `<a target="_blank" rel="noopener
|
|
215
|
+
* noreferrer">` with an external-link mark, and "opens in a new tab" read
|
|
216
|
+
* after the title. keksdose's inbox rows whose URL is absolute.
|
|
217
|
+
*/
|
|
218
|
+
external?: boolean;
|
|
219
|
+
/** Runs on a click, before the navigation (mark the row read). */
|
|
220
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
221
|
+
/** Runs on a middle click, which fires no `click` — keksdose marks a
|
|
222
|
+
* notification read here too, so a row opened into a background tab does not
|
|
223
|
+
* stay unread. */
|
|
224
|
+
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
225
|
+
}
|
|
226
|
+
| {
|
|
227
|
+
onClick?: never;
|
|
228
|
+
href?: never;
|
|
229
|
+
renderLink?: never;
|
|
230
|
+
external?: never;
|
|
231
|
+
onAuxClick?: never;
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
237
|
+
* truncating), a trailing slot, and — beside the row, not inside it — its actions.
|
|
238
|
+
*
|
|
239
|
+
* All three apps draw this row by hand, a dozen times: kastlan's units
|
|
240
|
+
* (building-detail-page.tsx:402), open tickets (group-overview-page.tsx:160), activity
|
|
241
|
+
* feed (activity-feed.tsx:53), company users (platform-company-detail-page.tsx:140)
|
|
242
|
+
* and passkeys (passkeys-card.tsx:75); keksdose's notification inbox and attachment
|
|
243
|
+
* downloads; lenkbank's profiles, projects and segments. The copies differ in the
|
|
244
|
+
* details that are easy to get wrong — which of them truncate, whether the actions
|
|
245
|
+
* are nested in the button, whether a link row is a link at all.
|
|
246
|
+
*/
|
|
247
|
+
export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem(
|
|
248
|
+
{
|
|
249
|
+
title,
|
|
250
|
+
subtitle,
|
|
251
|
+
subtitleLines = 1,
|
|
252
|
+
meta,
|
|
253
|
+
icon: Icon,
|
|
254
|
+
leading,
|
|
255
|
+
trailing,
|
|
256
|
+
actions,
|
|
257
|
+
unread = false,
|
|
258
|
+
status,
|
|
259
|
+
selected = false,
|
|
260
|
+
disabled = false,
|
|
261
|
+
loading = false,
|
|
262
|
+
density: densityProp,
|
|
263
|
+
align = "center",
|
|
264
|
+
className,
|
|
265
|
+
targetClassName,
|
|
266
|
+
targetProps,
|
|
267
|
+
bordered = false,
|
|
268
|
+
as = "li",
|
|
269
|
+
href,
|
|
270
|
+
renderLink,
|
|
271
|
+
external = false,
|
|
272
|
+
onClick,
|
|
273
|
+
onAuxClick,
|
|
274
|
+
...rest
|
|
275
|
+
},
|
|
276
|
+
ref,
|
|
277
|
+
) {
|
|
278
|
+
const list = useContext(ListContext);
|
|
279
|
+
const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
280
|
+
const density = densityProp ?? list.density;
|
|
281
|
+
const pad = PAD[density];
|
|
282
|
+
const isLink = href !== undefined && !disabled;
|
|
283
|
+
const isButton = !isLink && onClick !== undefined && href === undefined;
|
|
284
|
+
const interactive = isLink || isButton;
|
|
285
|
+
const inert = disabled || loading;
|
|
286
|
+
|
|
287
|
+
const mark =
|
|
288
|
+
status ??
|
|
289
|
+
(unread ? <StatusDot tone="brand" size="sm" className={align === "start" ? "mt-1.5" : undefined} /> : null);
|
|
290
|
+
|
|
291
|
+
const body = (
|
|
292
|
+
<>
|
|
293
|
+
{Icon && (
|
|
294
|
+
<Icon
|
|
295
|
+
className={cn("size-4 shrink-0 text-[var(--text-muted)]", align === "start" && "mt-0.5")}
|
|
296
|
+
aria-hidden
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
{leading}
|
|
300
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
301
|
+
<span
|
|
302
|
+
className={cn(
|
|
303
|
+
"block truncate text-sm text-[var(--text-primary)]",
|
|
304
|
+
unread ? "font-semibold" : "font-medium",
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
{title}
|
|
308
|
+
{unread && <span className="sr-only"> ({labels.unread})</span>}
|
|
309
|
+
{external && href !== undefined && <span className="sr-only"> ({labels.opensInNewTab})</span>}
|
|
310
|
+
</span>
|
|
311
|
+
{subtitle != null && (
|
|
312
|
+
<span
|
|
313
|
+
className={cn(
|
|
314
|
+
"block text-xs text-[var(--text-muted)]",
|
|
315
|
+
subtitleLines === 2 ? "line-clamp-2" : "truncate",
|
|
316
|
+
)}
|
|
317
|
+
>
|
|
318
|
+
{subtitle}
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
{meta != null && <span className="mt-0.5 block truncate text-[11px] leading-snug text-[var(--text-muted)]">{meta}</span>}
|
|
322
|
+
</span>
|
|
323
|
+
{trailing != null && <span className="flex shrink-0 items-center gap-2 text-xs text-[var(--text-muted)]">{trailing}</span>}
|
|
324
|
+
{loading ? (
|
|
325
|
+
<Spinner label={null} className="size-3.5 shrink-0" />
|
|
326
|
+
) : (
|
|
327
|
+
external && href !== undefined && (
|
|
328
|
+
<ExternalLink className="size-3.5 shrink-0 text-[var(--text-muted)] rtl:-scale-x-100" aria-hidden />
|
|
329
|
+
)
|
|
330
|
+
)}
|
|
331
|
+
{mark}
|
|
332
|
+
</>
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const target = cn(
|
|
336
|
+
"flex min-w-0 flex-1 text-start",
|
|
337
|
+
align === "start" ? "items-start" : "items-center",
|
|
338
|
+
pad.target,
|
|
339
|
+
"rounded-[inherit]",
|
|
340
|
+
interactive && TARGET_RING,
|
|
341
|
+
interactive && !inert && "cursor-pointer",
|
|
342
|
+
loading && "cursor-progress",
|
|
343
|
+
targetClassName,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// `role` is the row's own (a button, a link, or nothing on a static block): a
|
|
347
|
+
// targetProps role would silently turn the row into something else.
|
|
348
|
+
const { role: _ignoredRole, ...targetExtras } = targetProps ?? {};
|
|
349
|
+
|
|
350
|
+
let main: ReactNode;
|
|
351
|
+
if (isLink) {
|
|
352
|
+
const linkProps: ListItemLinkProps = {
|
|
353
|
+
...(targetExtras as Partial<ListItemLinkProps>),
|
|
354
|
+
...rest,
|
|
355
|
+
ref: ref as Ref<HTMLAnchorElement>,
|
|
356
|
+
href,
|
|
357
|
+
className: target,
|
|
358
|
+
"aria-current": selected ? "true" : undefined,
|
|
359
|
+
"aria-busy": loading || undefined,
|
|
360
|
+
onClick: loading
|
|
361
|
+
? (e) => e.preventDefault()
|
|
362
|
+
: (onClick as ListItemLinkProps["onClick"]),
|
|
363
|
+
onAuxClick,
|
|
364
|
+
children: body,
|
|
365
|
+
};
|
|
366
|
+
const { children: linkBody, ...anchorProps } = linkProps;
|
|
367
|
+
main = renderLink && !external ? (
|
|
368
|
+
<RenderedLink render={renderLink} {...linkProps} />
|
|
369
|
+
) : (
|
|
370
|
+
<a {...anchorProps} {...(external ? { target: "_blank", rel: "noopener noreferrer" } : null)}>
|
|
371
|
+
{linkBody}
|
|
372
|
+
</a>
|
|
373
|
+
);
|
|
374
|
+
} else if (isButton) {
|
|
375
|
+
main = (
|
|
376
|
+
<button
|
|
377
|
+
{...(targetExtras as HTMLAttributes<HTMLButtonElement>)}
|
|
378
|
+
{...rest}
|
|
379
|
+
ref={ref as Ref<HTMLButtonElement>}
|
|
380
|
+
type="button"
|
|
381
|
+
// A disabled row is `disabled`: nothing to do there, and nothing to reach. A
|
|
382
|
+
// loading row is only `aria-disabled` — see `loading`.
|
|
383
|
+
disabled={disabled}
|
|
384
|
+
aria-disabled={loading || undefined}
|
|
385
|
+
aria-busy={loading || undefined}
|
|
386
|
+
aria-current={selected ? "true" : undefined}
|
|
387
|
+
onClick={loading ? undefined : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)}
|
|
388
|
+
className={target}
|
|
389
|
+
>
|
|
390
|
+
{body}
|
|
391
|
+
</button>
|
|
392
|
+
);
|
|
393
|
+
} else {
|
|
394
|
+
main = (
|
|
395
|
+
<div
|
|
396
|
+
{...(targetExtras as HTMLAttributes<HTMLDivElement>)}
|
|
397
|
+
{...rest}
|
|
398
|
+
ref={ref as Ref<HTMLDivElement>}
|
|
399
|
+
aria-disabled={disabled || undefined}
|
|
400
|
+
aria-busy={loading || undefined}
|
|
401
|
+
className={target}
|
|
402
|
+
>
|
|
403
|
+
{body}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const Tag = as;
|
|
409
|
+
return (
|
|
410
|
+
<Tag
|
|
411
|
+
className={cn(
|
|
412
|
+
list.separator === "divider" && "border-b border-[var(--border)] last:border-b-0",
|
|
413
|
+
list.separator === "divider" && as === "li" && "py-0.5",
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
<div
|
|
417
|
+
className={cn(
|
|
418
|
+
"flex min-w-0 items-center rounded-md border transition-colors",
|
|
419
|
+
selected
|
|
420
|
+
? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
|
|
421
|
+
: bordered
|
|
422
|
+
? "border-[var(--border)]"
|
|
423
|
+
: "border-transparent",
|
|
424
|
+
interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
|
|
425
|
+
disabled && "opacity-50",
|
|
426
|
+
className,
|
|
427
|
+
)}
|
|
428
|
+
>
|
|
429
|
+
{main}
|
|
430
|
+
{actions != null && (
|
|
431
|
+
<div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
</Tag>
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
function RenderedLink({
|
|
439
|
+
render,
|
|
440
|
+
...props
|
|
441
|
+
}: ListItemLinkProps & { render: (props: ListItemLinkProps) => ReactElement }) {
|
|
442
|
+
return render(props);
|
|
443
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { MouseEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
import type { LucideIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `TOPBAR_MENU_ITEM_CLASS`'s row, spelled out rather than imported: a component does
|
|
9
|
+
* not reach up into the shell (the same call `Disclosure`'s menu variant made). A test
|
|
10
|
+
* holds the two to the same classes, so a menu mixing `MenuItem`s with the shell's own
|
|
11
|
+
* rows (`TopBarActionMenu`, `OptionSwitcherMenu`) cannot drift into two looks.
|
|
12
|
+
*/
|
|
13
|
+
const MENU_ITEM_ROW =
|
|
14
|
+
"flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-start text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]";
|
|
15
|
+
|
|
16
|
+
/** HoverMenu moves focus onto items with the arrow keys; a pointer user never sees the
|
|
17
|
+
* ring, a keyboard user always does. Inset, because the panel clips its children. */
|
|
18
|
+
const MENU_ITEM_FOCUS =
|
|
19
|
+
"focus-visible:outline-none focus-visible:bg-[var(--bg-hover)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
|
|
20
|
+
|
|
21
|
+
export type MenuItemTone = "default" | "danger";
|
|
22
|
+
|
|
23
|
+
/** What {@link MenuItemProps.renderLink} is handed. Spread it onto your router's link,
|
|
24
|
+
* mapping `href` to what the link calls it —
|
|
25
|
+
* `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */
|
|
26
|
+
export interface MenuItemLinkProps {
|
|
27
|
+
href: string;
|
|
28
|
+
className: string;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
role: "menuitem";
|
|
31
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
32
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
33
|
+
"aria-current"?: "page";
|
|
34
|
+
id?: string;
|
|
35
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
36
|
+
[key: `data-${string}`]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface MenuItemBaseProps {
|
|
40
|
+
/** The row's text. */
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
/** An icon before the label, drawn at the menu's 16px. */
|
|
43
|
+
icon?: LucideIcon;
|
|
44
|
+
/** Anything else before the label — an avatar, a flag — when a Lucide icon will not do. */
|
|
45
|
+
leading?: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Right after the label text, before the gap that pushes {@link trailing} to the
|
|
48
|
+
* row's end: keksdose's budget switcher tags a budget "Guest" or "Shared" with a
|
|
49
|
+
* `Chip` that belongs to the NAME (budget-switcher.tsx:55/59), not to the row's end
|
|
50
|
+
* where a count or a check mark sits. Hand-placed inside `children` it was truncated
|
|
51
|
+
* with the name — a long budget name ate the chip first. Here the label truncates
|
|
52
|
+
* and the badge never shrinks, so it stays visible beside what is left of the name.
|
|
53
|
+
* It is part of the row's accessible name ("Household Shared"), so keep it text.
|
|
54
|
+
*/
|
|
55
|
+
badge?: ReactNode;
|
|
56
|
+
/** After the label, at the row's end: a shortcut, a count. (A tag that belongs to the name is {@link badge}.) */
|
|
57
|
+
trailing?: ReactNode;
|
|
58
|
+
/** `danger` for the row that destroys or signs out. Colour is not the whole message:
|
|
59
|
+
* the label must still say what the row does. */
|
|
60
|
+
tone?: MenuItemTone;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
className?: string;
|
|
63
|
+
id?: string;
|
|
64
|
+
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
65
|
+
[key: `data-${string}`]: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A menu row is a button OR a link, and only the button can be CHECKED: a checked link
|
|
70
|
+
* is a link to where you already are, which is `current`, not a selection.
|
|
71
|
+
*/
|
|
72
|
+
export type MenuItemProps = MenuItemBaseProps &
|
|
73
|
+
(
|
|
74
|
+
| {
|
|
75
|
+
href?: never;
|
|
76
|
+
renderLink?: never;
|
|
77
|
+
current?: never;
|
|
78
|
+
/** Runs the row. Close the menu from here — `HoverMenu` hands its `close` to
|
|
79
|
+
* the render prop that draws the rows. */
|
|
80
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Makes the row a CHOICE and says whether it is chosen. Leave it undefined for a
|
|
83
|
+
* plain command; `false` is a choice that is not chosen, which is different.
|
|
84
|
+
* A check mark is drawn at the end when it is on.
|
|
85
|
+
*/
|
|
86
|
+
checked?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* What kind of choice, when `checked` is given:
|
|
89
|
+
*
|
|
90
|
+
* - `radio` (default): one of a set — keksdose's budget switcher
|
|
91
|
+
* (budget-switcher.tsx:51), the theme and language pickers. `menuitemradio`.
|
|
92
|
+
* - `checkbox`: an independent on/off — "Show archived". `menuitemcheckbox`.
|
|
93
|
+
*
|
|
94
|
+
* Not `aria-pressed`: that belongs to a toggle BUTTON, and inside a
|
|
95
|
+
* `role="menu"` a screen reader expects the menu roles and reads "checked"
|
|
96
|
+
* only from them. A button with `aria-pressed` in a menu is announced as a
|
|
97
|
+
* button in a menu, which is two widgets in one.
|
|
98
|
+
*/
|
|
99
|
+
checkable?: "radio" | "checkbox";
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
/** Makes the row a link — keksdose's "Manage budgets" and account-menu rows. */
|
|
103
|
+
href: string;
|
|
104
|
+
/** Renders the link — pass your router's `<Link>`, since a plain `<a>` reloads
|
|
105
|
+
* a single-page app. Default `<a>`. The same API as `Chip` and `StatTile`. */
|
|
106
|
+
renderLink?: (props: MenuItemLinkProps) => ReactElement;
|
|
107
|
+
/** Runs on the way (close the menu). */
|
|
108
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
109
|
+
/** The link to the page already open: `aria-current="page"` and a check mark. */
|
|
110
|
+
current?: boolean;
|
|
111
|
+
checked?: never;
|
|
112
|
+
checkable?: never;
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One row of a `HoverMenu` / `TopBarActionMenu` panel.
|
|
118
|
+
*
|
|
119
|
+
* keksdose's account menu and budget switcher each rebuild this row by hand — the
|
|
120
|
+
* shell's class, an icon, a label, a badge, a check mark on the active budget — and the
|
|
121
|
+
* check mark was the only thing saying which budget was active, so a screen reader
|
|
122
|
+
* heard four identical buttons. This row says it (`menuitemradio` + `aria-checked`).
|
|
123
|
+
*
|
|
124
|
+
* The role is set HERE rather than left to `HoverMenu`, which promotes only role-less
|
|
125
|
+
* buttons and links to `menuitem`: a checked row must be `menuitemradio` or
|
|
126
|
+
* `menuitemcheckbox` from its first render, and `HoverMenu` leaves an element that
|
|
127
|
+
* already has a role alone — so its roving focus (↑/↓, Home/End) picks these rows up
|
|
128
|
+
* with the rest. Render it inside an `<li>` like the shell's own rows; `HoverMenu`
|
|
129
|
+
* turns the list markup into `role="none"`.
|
|
130
|
+
*/
|
|
131
|
+
export const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem(
|
|
132
|
+
{
|
|
133
|
+
children,
|
|
134
|
+
icon: Icon,
|
|
135
|
+
leading,
|
|
136
|
+
badge,
|
|
137
|
+
trailing,
|
|
138
|
+
tone = "default",
|
|
139
|
+
disabled = false,
|
|
140
|
+
className,
|
|
141
|
+
href,
|
|
142
|
+
renderLink,
|
|
143
|
+
onClick,
|
|
144
|
+
checked,
|
|
145
|
+
checkable,
|
|
146
|
+
current,
|
|
147
|
+
...rest
|
|
148
|
+
},
|
|
149
|
+
ref,
|
|
150
|
+
) {
|
|
151
|
+
const on = href !== undefined ? !!current : !!checked;
|
|
152
|
+
const look = cn(
|
|
153
|
+
MENU_ITEM_ROW,
|
|
154
|
+
MENU_ITEM_FOCUS,
|
|
155
|
+
tone === "danger" && "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:bg-[var(--danger-bg)]",
|
|
156
|
+
disabled && "cursor-default opacity-50 hover:bg-transparent",
|
|
157
|
+
className,
|
|
158
|
+
);
|
|
159
|
+
const body = (
|
|
160
|
+
<>
|
|
161
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
162
|
+
{Icon && <Icon className="size-4 shrink-0" aria-hidden />}
|
|
163
|
+
{leading}
|
|
164
|
+
<span className="min-w-0 truncate">{children}</span>
|
|
165
|
+
{badge != null && badge !== false && (
|
|
166
|
+
<>
|
|
167
|
+
{/* A space between the name and the badge in the accessible name ("Household
|
|
168
|
+
Shared", not "HouseholdShared"); whitespace between flex items is not
|
|
169
|
+
laid out, so it costs nothing on screen. */}{" "}
|
|
170
|
+
<span data-menu-item-badge="" className="flex shrink-0 items-center">
|
|
171
|
+
{badge}
|
|
172
|
+
</span>
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</span>
|
|
176
|
+
{(trailing != null || on) && (
|
|
177
|
+
<span className="flex shrink-0 items-center gap-2">
|
|
178
|
+
{trailing}
|
|
179
|
+
{on && <Check className="size-4 text-[var(--text-secondary)]" aria-hidden />}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (href !== undefined && !disabled) {
|
|
186
|
+
const linkProps: MenuItemLinkProps = {
|
|
187
|
+
...rest,
|
|
188
|
+
ref: ref as Ref<HTMLAnchorElement>,
|
|
189
|
+
href,
|
|
190
|
+
role: "menuitem",
|
|
191
|
+
className: look,
|
|
192
|
+
onClick: onClick as MenuItemLinkProps["onClick"],
|
|
193
|
+
"aria-current": current ? "page" : undefined,
|
|
194
|
+
children: body,
|
|
195
|
+
};
|
|
196
|
+
if (renderLink) return <RenderedLink render={renderLink} {...linkProps} />;
|
|
197
|
+
const { children: linkBody, ...anchorProps } = linkProps;
|
|
198
|
+
return <a {...anchorProps}>{linkBody}</a>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const role =
|
|
202
|
+
href === undefined && checked !== undefined
|
|
203
|
+
? checkable === "checkbox"
|
|
204
|
+
? "menuitemcheckbox"
|
|
205
|
+
: "menuitemradio"
|
|
206
|
+
: "menuitem";
|
|
207
|
+
return (
|
|
208
|
+
<button
|
|
209
|
+
{...rest}
|
|
210
|
+
ref={ref as Ref<HTMLButtonElement>}
|
|
211
|
+
type="button"
|
|
212
|
+
role={role}
|
|
213
|
+
aria-checked={role === "menuitem" ? undefined : !!checked}
|
|
214
|
+
// `aria-disabled` rather than `disabled`: HoverMenu's roving focus skips either,
|
|
215
|
+
// and the ARIA menu pattern keeps an unavailable item perceivable ("Delete,
|
|
216
|
+
// dimmed") rather than greyed to the point some browsers stop exposing it.
|
|
217
|
+
aria-disabled={disabled || undefined}
|
|
218
|
+
onClick={
|
|
219
|
+
disabled
|
|
220
|
+
? (e) => e.preventDefault()
|
|
221
|
+
: (onClick as ((event: MouseEvent<HTMLButtonElement>) => void) | undefined)
|
|
222
|
+
}
|
|
223
|
+
className={look}
|
|
224
|
+
>
|
|
225
|
+
{body}
|
|
226
|
+
</button>
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
function RenderedLink({
|
|
231
|
+
render,
|
|
232
|
+
...props
|
|
233
|
+
}: MenuItemLinkProps & { render: (props: MenuItemLinkProps) => ReactElement }) {
|
|
234
|
+
return render(props);
|
|
235
|
+
}
|