@eifi1/ui-kit 0.8.1 → 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.
Files changed (204) hide show
  1. package/README.md +25 -11
  2. package/dist/components/alert-banner.d.ts +32 -5
  3. package/dist/components/alert-banner.js +57 -14
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +6 -0
  6. package/dist/components/breadcrumbs.d.ts +60 -0
  7. package/dist/components/breadcrumbs.js +88 -0
  8. package/dist/components/breadcrumbs.js.map +1 -0
  9. package/dist/components/bulk-action-bar.d.ts +66 -0
  10. package/dist/components/bulk-action-bar.js +149 -0
  11. package/dist/components/bulk-action-bar.js.map +1 -0
  12. package/dist/components/calculator.d.ts +6 -0
  13. package/dist/components/chip.d.ts +58 -10
  14. package/dist/components/chip.js +91 -7
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/copy-button.d.ts +13 -4
  17. package/dist/components/copy-button.js +17 -4
  18. package/dist/components/copy-button.js.map +1 -1
  19. package/dist/components/data-table-filter-popover.d.ts +1 -1
  20. package/dist/components/data-table-filters.d.ts +1 -1
  21. package/dist/components/data-table.d.ts +1 -1
  22. package/dist/components/data-table.js +122 -91
  23. package/dist/components/data-table.js.map +1 -1
  24. package/dist/components/date-picker.d.ts +89 -3
  25. package/dist/components/date-picker.js +163 -68
  26. package/dist/components/date-picker.js.map +1 -1
  27. package/dist/components/description-list.d.ts +43 -6
  28. package/dist/components/description-list.js +91 -11
  29. package/dist/components/description-list.js.map +1 -1
  30. package/dist/components/dialog-frame.d.ts +7 -0
  31. package/dist/components/dialog-frame.js.map +1 -1
  32. package/dist/components/disclosure.d.ts +68 -4
  33. package/dist/components/disclosure.js +33 -15
  34. package/dist/components/disclosure.js.map +1 -1
  35. package/dist/components/file-dropzone.d.ts +3 -4
  36. package/dist/components/file-dropzone.js +2 -1
  37. package/dist/components/file-dropzone.js.map +1 -1
  38. package/dist/components/floating-panel.d.ts +29 -2
  39. package/dist/components/floating-panel.js +16 -2
  40. package/dist/components/floating-panel.js.map +1 -1
  41. package/dist/components/full-bleed-dialog.d.ts +23 -4
  42. package/dist/components/full-bleed-dialog.js +11 -2
  43. package/dist/components/full-bleed-dialog.js.map +1 -1
  44. package/dist/components/list.d.ts +174 -0
  45. package/dist/components/list.js +215 -0
  46. package/dist/components/list.js.map +1 -0
  47. package/dist/components/menu-item.d.ts +99 -0
  48. package/dist/components/menu-item.js +83 -0
  49. package/dist/components/menu-item.js.map +1 -0
  50. package/dist/components/modal.d.ts +23 -1
  51. package/dist/components/modal.js +36 -10
  52. package/dist/components/modal.js.map +1 -1
  53. package/dist/components/number-field.d.ts +6 -0
  54. package/dist/components/number-input.d.ts +6 -0
  55. package/dist/components/numpad-sheet.d.ts +6 -0
  56. package/dist/components/page-contents.js +1 -2
  57. package/dist/components/page-contents.js.map +1 -1
  58. package/dist/components/page-header.d.ts +37 -0
  59. package/dist/components/page-header.js +36 -0
  60. package/dist/components/page-header.js.map +1 -0
  61. package/dist/components/progress-bar.d.ts +16 -2
  62. package/dist/components/progress-bar.js +4 -2
  63. package/dist/components/progress-bar.js.map +1 -1
  64. package/dist/components/series-chart-labels.d.ts +3 -0
  65. package/dist/components/series-chart-labels.js +2 -1
  66. package/dist/components/series-chart-labels.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +16 -0
  68. package/dist/components/series-chart.js +137 -3
  69. package/dist/components/series-chart.js.map +1 -1
  70. package/dist/components/status-dot.d.ts +49 -0
  71. package/dist/components/status-dot.js +52 -0
  72. package/dist/components/status-dot.js.map +1 -0
  73. package/dist/components/table.d.ts +39 -6
  74. package/dist/components/table.js +43 -4
  75. package/dist/components/table.js.map +1 -1
  76. package/dist/components/text.d.ts +63 -0
  77. package/dist/components/text.js +23 -0
  78. package/dist/components/text.js.map +1 -0
  79. package/dist/components/toast.d.ts +167 -0
  80. package/dist/components/toast.js +226 -0
  81. package/dist/components/toast.js.map +1 -0
  82. package/dist/components/toggle-group.d.ts +29 -1
  83. package/dist/components/toggle-group.js +52 -3
  84. package/dist/components/toggle-group.js.map +1 -1
  85. package/dist/components/tooltip.d.ts +38 -15
  86. package/dist/components/tooltip.js +85 -47
  87. package/dist/components/tooltip.js.map +1 -1
  88. package/dist/components/ui.d.ts +139 -16
  89. package/dist/components/ui.js +136 -26
  90. package/dist/components/ui.js.map +1 -1
  91. package/dist/components/use-table-state.d.ts +1 -1
  92. package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
  93. package/dist/data-table.d.ts +1 -1
  94. package/dist/hooks/use-close-transition.d.ts +7 -1
  95. package/dist/hooks/use-close-transition.js +5 -2
  96. package/dist/hooks/use-close-transition.js.map +1 -1
  97. package/dist/hooks/use-copy-to-clipboard.js +1 -1
  98. package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
  99. package/dist/hooks/use-overlay-history.js +84 -4
  100. package/dist/hooks/use-overlay-history.js.map +1 -1
  101. package/dist/i18n/defaults.d.ts +6 -0
  102. package/dist/i18n/defaults.js +11 -1
  103. package/dist/i18n/defaults.js.map +1 -1
  104. package/dist/i18n/kit-labels.d.ts +11 -0
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
  107. package/dist/i18n/locales/de-CH.d.ts +6 -0
  108. package/dist/i18n/locales/de-informal.d.ts +6 -0
  109. package/dist/i18n/locales/de.d.ts +6 -0
  110. package/dist/i18n/locales/de.js +31 -1
  111. package/dist/i18n/locales/de.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +6 -0
  113. package/dist/i18n/locales/es.js +31 -1
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +6 -0
  116. package/dist/i18n/locales/fr.js +31 -1
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +6 -0
  119. package/dist/i18n/locales/hu.js +31 -1
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +6 -0
  122. package/dist/i18n/locales/it.js +31 -1
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +6 -0
  125. package/dist/i18n/locales/zh.js +31 -1
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/index.d.ts +18 -8
  128. package/dist/index.js +14 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/lib/clipping.d.ts +9 -0
  131. package/dist/lib/clipping.js +14 -0
  132. package/dist/lib/clipping.js.map +1 -0
  133. package/dist/search/command-palette.d.ts +67 -2
  134. package/dist/search/command-palette.js +147 -33
  135. package/dist/search/command-palette.js.map +1 -1
  136. package/dist/search/global-search.d.ts +166 -0
  137. package/dist/search/global-search.js +293 -0
  138. package/dist/search/global-search.js.map +1 -0
  139. package/dist/search/search-index.d.ts +105 -0
  140. package/dist/search/search-index.js +142 -0
  141. package/dist/search/search-index.js.map +1 -0
  142. package/dist/search.d.ts +3 -1
  143. package/dist/search.js +2 -0
  144. package/dist/search.js.map +1 -1
  145. package/dist/wizard/stepper-nav.d.ts +10 -1
  146. package/dist/wizard/stepper-nav.js +3 -2
  147. package/dist/wizard/stepper-nav.js.map +1 -1
  148. package/dist/wizard/types.d.ts +2 -2
  149. package/dist/wizard/types.js.map +1 -1
  150. package/dist/wizard/use-wizard.js +1 -1
  151. package/dist/wizard/use-wizard.js.map +1 -1
  152. package/dist/wizard/wizard-summary.js +3 -3
  153. package/dist/wizard/wizard-summary.js.map +1 -1
  154. package/package.json +25 -12
  155. package/src/components/alert-banner.tsx +117 -18
  156. package/src/components/breadcrumbs.tsx +168 -0
  157. package/src/components/bulk-action-bar.tsx +256 -0
  158. package/src/components/chip.tsx +164 -13
  159. package/src/components/copy-button.tsx +29 -7
  160. package/src/components/data-table.tsx +81 -5
  161. package/src/components/date-picker.tsx +377 -118
  162. package/src/components/description-list.tsx +168 -15
  163. package/src/components/dialog-frame.tsx +7 -0
  164. package/src/components/disclosure.tsx +116 -28
  165. package/src/components/file-dropzone.tsx +9 -10
  166. package/src/components/floating-panel.tsx +46 -2
  167. package/src/components/full-bleed-dialog.tsx +42 -5
  168. package/src/components/list.tsx +417 -0
  169. package/src/components/menu-item.tsx +214 -0
  170. package/src/components/modal.tsx +80 -17
  171. package/src/components/page-contents.tsx +4 -4
  172. package/src/components/page-header.tsx +68 -0
  173. package/src/components/progress-bar.tsx +27 -3
  174. package/src/components/series-chart-labels.ts +4 -0
  175. package/src/components/series-chart.tsx +253 -3
  176. package/src/components/status-dot.tsx +99 -0
  177. package/src/components/table.tsx +91 -6
  178. package/src/components/text.tsx +89 -0
  179. package/src/components/toast.tsx +441 -0
  180. package/src/components/toggle-group.tsx +82 -3
  181. package/src/components/tooltip.tsx +186 -100
  182. package/src/components/ui.tsx +321 -41
  183. package/src/hooks/use-close-transition.ts +14 -5
  184. package/src/hooks/use-copy-to-clipboard.ts +1 -1
  185. package/src/hooks/use-overlay-history.ts +181 -4
  186. package/src/i18n/defaults.ts +10 -0
  187. package/src/i18n/kit-labels.tsx +10 -0
  188. package/src/i18n/locales/de.ts +30 -0
  189. package/src/i18n/locales/es.ts +30 -0
  190. package/src/i18n/locales/fr.ts +30 -0
  191. package/src/i18n/locales/hu.ts +30 -0
  192. package/src/i18n/locales/it.ts +30 -0
  193. package/src/i18n/locales/zh.ts +30 -0
  194. package/src/index.ts +30 -0
  195. package/src/lib/clipping.ts +16 -0
  196. package/src/search/command-palette.tsx +241 -31
  197. package/src/search/global-search.tsx +525 -0
  198. package/src/search/search-index.ts +295 -0
  199. package/src/search.ts +2 -0
  200. package/src/wizard/stepper-nav.tsx +13 -2
  201. package/src/wizard/types.ts +2 -2
  202. package/src/wizard/use-wizard.ts +3 -3
  203. package/src/wizard/wizard-summary.tsx +14 -10
  204. package/tokens.css +97 -0
@@ -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":"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","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 };
@@ -0,0 +1,83 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { forwardRef } from "react";
4
+ import { Check } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ const MENU_ITEM_ROW = "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)]";
7
+ const MENU_ITEM_FOCUS = "focus-visible:outline-none focus-visible:bg-[var(--bg-hover)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
8
+ const MenuItem = forwardRef(function MenuItem2({
9
+ children,
10
+ icon: Icon,
11
+ leading,
12
+ trailing,
13
+ tone = "default",
14
+ disabled = false,
15
+ className,
16
+ href,
17
+ renderLink,
18
+ onClick,
19
+ checked,
20
+ checkable,
21
+ current,
22
+ ...rest
23
+ }, ref) {
24
+ const on = href !== void 0 ? !!current : !!checked;
25
+ const look = cn(
26
+ MENU_ITEM_ROW,
27
+ MENU_ITEM_FOCUS,
28
+ tone === "danger" && "text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:bg-[var(--danger-bg)]",
29
+ disabled && "cursor-default opacity-50 hover:bg-transparent",
30
+ className
31
+ );
32
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
33
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [
34
+ Icon && /* @__PURE__ */ jsx(Icon, { className: "size-4 shrink-0", "aria-hidden": true }),
35
+ leading,
36
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children })
37
+ ] }),
38
+ (trailing != null || on) && /* @__PURE__ */ jsxs("span", { className: "flex shrink-0 items-center gap-2", children: [
39
+ trailing,
40
+ on && /* @__PURE__ */ jsx(Check, { className: "size-4 text-[var(--text-secondary)]", "aria-hidden": true })
41
+ ] })
42
+ ] });
43
+ if (href !== void 0 && !disabled) {
44
+ const linkProps = {
45
+ ...rest,
46
+ ref,
47
+ href,
48
+ role: "menuitem",
49
+ className: look,
50
+ onClick,
51
+ "aria-current": current ? "page" : void 0,
52
+ children: body
53
+ };
54
+ if (renderLink) return /* @__PURE__ */ jsx(RenderedLink, { render: renderLink, ...linkProps });
55
+ const { children: linkBody, ...anchorProps } = linkProps;
56
+ return /* @__PURE__ */ jsx("a", { ...anchorProps, children: linkBody });
57
+ }
58
+ const role = href === void 0 && checked !== void 0 ? checkable === "checkbox" ? "menuitemcheckbox" : "menuitemradio" : "menuitem";
59
+ return /* @__PURE__ */ jsx(
60
+ "button",
61
+ {
62
+ ...rest,
63
+ ref,
64
+ type: "button",
65
+ role,
66
+ "aria-checked": role === "menuitem" ? void 0 : !!checked,
67
+ "aria-disabled": disabled || void 0,
68
+ onClick: disabled ? (e) => e.preventDefault() : onClick,
69
+ className: look,
70
+ children: body
71
+ }
72
+ );
73
+ });
74
+ function RenderedLink({
75
+ render,
76
+ ...props
77
+ }) {
78
+ return render(props);
79
+ }
80
+ export {
81
+ MenuItem
82
+ };
83
+ //# sourceMappingURL=menu-item.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/menu-item.tsx"],"sourcesContent":["import { forwardRef } from \"react\";\nimport type { MouseEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { Check } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\n\n/**\n * `TOPBAR_MENU_ITEM_CLASS`'s row, spelled out rather than imported: a component does\n * not reach up into the shell (the same call `Disclosure`'s menu variant made). A test\n * holds the two to the same classes, so a menu mixing `MenuItem`s with the shell's own\n * rows (`TopBarActionMenu`, `OptionSwitcherMenu`) cannot drift into two looks.\n */\nconst MENU_ITEM_ROW =\n \"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)]\";\n\n/** HoverMenu moves focus onto items with the arrow keys; a pointer user never sees the\n * ring, a keyboard user always does. Inset, because the panel clips its children. */\nconst MENU_ITEM_FOCUS =\n \"focus-visible:outline-none focus-visible:bg-[var(--bg-hover)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]\";\n\nexport type MenuItemTone = \"default\" | \"danger\";\n\n/** What {@link MenuItemProps.renderLink} is handed. Spread it onto your router's link,\n * mapping `href` to what the link calls it —\n * `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`. */\nexport interface MenuItemLinkProps {\n href: string;\n className: string;\n children: ReactNode;\n role: \"menuitem\";\n ref?: Ref<HTMLAnchorElement>;\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n \"aria-current\"?: \"page\";\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\ninterface MenuItemBaseProps {\n /** The row's text. */\n children: ReactNode;\n /** An icon before the label, drawn at the menu's 16px. */\n icon?: LucideIcon;\n /** Anything else before the label — an avatar, a flag — when a Lucide icon will not do. */\n leading?: ReactNode;\n /** After the label, at the row's end: a shortcut, a count, a badge. */\n trailing?: ReactNode;\n /** `danger` for the row that destroys or signs out. Colour is not the whole message:\n * the label must still say what the row does. */\n tone?: MenuItemTone;\n disabled?: boolean;\n className?: string;\n id?: string;\n [key: `aria-${string}`]: string | boolean | number | undefined;\n [key: `data-${string}`]: unknown;\n}\n\n/**\n * A menu row is a button OR a link, and only the button can be CHECKED: a checked link\n * is a link to where you already are, which is `current`, not a selection.\n */\nexport type MenuItemProps = MenuItemBaseProps &\n (\n | {\n href?: never;\n renderLink?: never;\n current?: never;\n /** Runs the row. Close the menu from here — `HoverMenu` hands its `close` to\n * the render prop that draws the rows. */\n onClick?: (event: MouseEvent<HTMLButtonElement>) => void;\n /**\n * Makes the row a CHOICE and says whether it is chosen. Leave it undefined for a\n * plain command; `false` is a choice that is not chosen, which is different.\n * A check mark is drawn at the end when it is on.\n */\n checked?: boolean;\n /**\n * What kind of choice, when `checked` is given:\n *\n * - `radio` (default): one of a set — keksdose's budget switcher\n * (budget-switcher.tsx:51), the theme and language pickers. `menuitemradio`.\n * - `checkbox`: an independent on/off — \"Show archived\". `menuitemcheckbox`.\n *\n * Not `aria-pressed`: that belongs to a toggle BUTTON, and inside a\n * `role=\"menu\"` a screen reader expects the menu roles and reads \"checked\"\n * only from them. A button with `aria-pressed` in a menu is announced as a\n * button in a menu, which is two widgets in one.\n */\n checkable?: \"radio\" | \"checkbox\";\n }\n | {\n /** Makes the row a link — keksdose's \"Manage budgets\" and account-menu rows. */\n href: string;\n /** Renders the link — pass your router's `<Link>`, since a plain `<a>` reloads\n * a single-page app. Default `<a>`. The same API as `Chip` and `StatTile`. */\n renderLink?: (props: MenuItemLinkProps) => ReactElement;\n /** Runs on the way (close the menu). */\n onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n /** The link to the page already open: `aria-current=\"page\"` and a check mark. */\n current?: boolean;\n checked?: never;\n checkable?: never;\n }\n );\n\n/**\n * One row of a `HoverMenu` / `TopBarActionMenu` panel.\n *\n * keksdose's account menu and budget switcher each rebuild this row by hand — the\n * shell's class, an icon, a label, a badge, a check mark on the active budget — and the\n * check mark was the only thing saying which budget was active, so a screen reader\n * heard four identical buttons. This row says it (`menuitemradio` + `aria-checked`).\n *\n * The role is set HERE rather than left to `HoverMenu`, which promotes only role-less\n * buttons and links to `menuitem`: a checked row must be `menuitemradio` or\n * `menuitemcheckbox` from its first render, and `HoverMenu` leaves an element that\n * already has a role alone — so its roving focus (↑/↓, Home/End) picks these rows up\n * with the rest. Render it inside an `<li>` like the shell's own rows; `HoverMenu`\n * turns the list markup into `role=\"none\"`.\n */\nexport const MenuItem = forwardRef<HTMLElement, MenuItemProps>(function MenuItem(\n {\n children,\n icon: Icon,\n leading,\n trailing,\n tone = \"default\",\n disabled = false,\n className,\n href,\n renderLink,\n onClick,\n checked,\n checkable,\n current,\n ...rest\n },\n ref,\n) {\n const on = href !== undefined ? !!current : !!checked;\n const look = cn(\n MENU_ITEM_ROW,\n MENU_ITEM_FOCUS,\n tone === \"danger\" && \"text-[var(--danger)] hover:bg-[var(--danger-bg)] focus-visible:bg-[var(--danger-bg)]\",\n disabled && \"cursor-default opacity-50 hover:bg-transparent\",\n className,\n );\n const body = (\n <>\n <span className=\"flex min-w-0 items-center gap-2\">\n {Icon && <Icon className=\"size-4 shrink-0\" aria-hidden />}\n {leading}\n <span className=\"min-w-0 truncate\">{children}</span>\n </span>\n {(trailing != null || on) && (\n <span className=\"flex shrink-0 items-center gap-2\">\n {trailing}\n {on && <Check className=\"size-4 text-[var(--text-secondary)]\" aria-hidden />}\n </span>\n )}\n </>\n );\n\n if (href !== undefined && !disabled) {\n const linkProps: MenuItemLinkProps = {\n ...rest,\n ref: ref as Ref<HTMLAnchorElement>,\n href,\n role: \"menuitem\",\n className: look,\n onClick: onClick as MenuItemLinkProps[\"onClick\"],\n \"aria-current\": current ? \"page\" : undefined,\n children: body,\n };\n if (renderLink) return <RenderedLink render={renderLink} {...linkProps} />;\n const { children: linkBody, ...anchorProps } = linkProps;\n return <a {...anchorProps}>{linkBody}</a>;\n }\n\n const role =\n href === undefined && checked !== undefined\n ? checkable === \"checkbox\"\n ? \"menuitemcheckbox\"\n : \"menuitemradio\"\n : \"menuitem\";\n return (\n <button\n {...rest}\n ref={ref as Ref<HTMLButtonElement>}\n type=\"button\"\n role={role}\n aria-checked={role === \"menuitem\" ? undefined : !!checked}\n // `aria-disabled` rather than `disabled`: HoverMenu's roving focus skips either,\n // and the ARIA menu pattern keeps an unavailable item perceivable (\"Delete,\n // dimmed\") rather than greyed to the point some browsers stop exposing it.\n aria-disabled={disabled || undefined}\n onClick={\n disabled\n ? (e) => e.preventDefault()\n : (onClick as ((event: MouseEvent<HTMLButtonElement>) => void) | undefined)\n }\n className={look}\n >\n {body}\n </button>\n );\n});\n\nfunction RenderedLink({\n render,\n ...props\n}: MenuItemLinkProps & { render: (props: MenuItemLinkProps) => ReactElement }) {\n return render(props);\n}\n"],"mappings":";AAoJI,mBAEa,KADX,YADF;AApJJ,SAAS,kBAAkB;AAE3B,SAAS,aAAa;AAEtB,SAAS,UAAU;AAQnB,MAAM,gBACJ;AAIF,MAAM,kBACJ;AAsGK,MAAM,WAAW,WAAuC,SAASA,UACtE;AAAA,EACE;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GACA,KACA;AACA,QAAM,KAAK,SAAS,SAAY,CAAC,CAAC,UAAU,CAAC,CAAC;AAC9C,QAAM,OAAO;AAAA,IACX;AAAA,IACA;AAAA,IACA,SAAS,YAAY;AAAA,IACrB,YAAY;AAAA,IACZ;AAAA,EACF;AACA,QAAM,OACJ,iCACE;AAAA,yBAAC,UAAK,WAAU,mCACb;AAAA,cAAQ,oBAAC,QAAK,WAAU,mBAAkB,eAAW,MAAC;AAAA,MACtD;AAAA,MACD,oBAAC,UAAK,WAAU,oBAAoB,UAAS;AAAA,OAC/C;AAAA,KACE,YAAY,QAAQ,OACpB,qBAAC,UAAK,WAAU,oCACb;AAAA;AAAA,MACA,MAAM,oBAAC,SAAM,WAAU,uCAAsC,eAAW,MAAC;AAAA,OAC5E;AAAA,KAEJ;AAGF,MAAI,SAAS,UAAa,CAAC,UAAU;AACnC,UAAM,YAA+B;AAAA,MACnC,GAAG;AAAA,MACH;AAAA,MACA;AAAA,MACA,MAAM;AAAA,MACN,WAAW;AAAA,MACX;AAAA,MACA,gBAAgB,UAAU,SAAS;AAAA,MACnC,UAAU;AAAA,IACZ;AACA,QAAI,WAAY,QAAO,oBAAC,gBAAa,QAAQ,YAAa,GAAG,WAAW;AACxE,UAAM,EAAE,UAAU,UAAU,GAAG,YAAY,IAAI;AAC/C,WAAO,oBAAC,OAAG,GAAG,aAAc,oBAAS;AAAA,EACvC;AAEA,QAAM,OACJ,SAAS,UAAa,YAAY,SAC9B,cAAc,aACZ,qBACA,kBACF;AACN,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ;AAAA,MACA,MAAK;AAAA,MACL;AAAA,MACA,gBAAc,SAAS,aAAa,SAAY,CAAC,CAAC;AAAA,MAIlD,iBAAe,YAAY;AAAA,MAC3B,SACE,WACI,CAAC,MAAM,EAAE,eAAe,IACvB;AAAA,MAEP,WAAW;AAAA,MAEV;AAAA;AAAA,EACH;AAEJ,CAAC;AAED,SAAS,aAAa;AAAA,EACpB;AAAA,EACA,GAAG;AACL,GAA+E;AAC7E,SAAO,OAAO,KAAK;AACrB;","names":["MenuItem"]}
@@ -28,6 +28,28 @@ declare function useBackdropClose(onClose: () => void): {
28
28
  interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
29
29
  /** Invoked on backdrop click and on Escape. */
30
30
  onClose: () => void;
31
+ /**
32
+ * Who decides whether the dialog is up, when the caller wants to keep it MOUNTED.
33
+ *
34
+ * Left out, the dialog is open for as long as it is mounted — `{open && <Modal/>}`,
35
+ * which is every caller before this prop and stays exactly as it was. That idiom has
36
+ * one blind spot: a close the CALLER decides on (a save that succeeded, a
37
+ * `setOpen(false)` from outside) unmounts the panel on the spot, with no exit,
38
+ * because there is nothing left to animate. kastlan's `FormModal` was nothing but an
39
+ * `if (!open) return null` gate in front of this.
40
+ *
41
+ * Passed, the component stays mounted while closed and renders nothing, and a flip
42
+ * to `false` plays the same exit a dismissal does before the panel goes. The focus
43
+ * trap, the scroll lock and the Back-gesture history entry are held only while
44
+ * `open` is true — they are released the moment it turns false, so focus is back on
45
+ * the trigger while the panel is still lowering. A dismissal (Escape, the backdrop,
46
+ * Back, the frame's X) animates first and then calls `onClose`; the `false` that
47
+ * comes back from it is not animated a second time.
48
+ *
49
+ * The body is unmounted once the exit has finished, as it is today, so a form inside
50
+ * still starts fresh on the next open.
51
+ */
52
+ open?: boolean;
31
53
  /** `"alertdialog"` for a dialog that interrupts to ask a question the user must
32
54
  * answer (a confirmation) — screen readers announce it with more urgency. Only the
33
55
  * two dialog roles are allowed: the panel stays modal either way. */
@@ -88,6 +110,6 @@ interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"> {
88
110
  * into the panel on open and back to the trigger on close, a Tab focus trap, and
89
111
  * `role="dialog"`/`aria-modal`.
90
112
  */
91
- declare function Modal({ onClose, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, style, role, ...rest }: ModalProps): react.ReactPortal;
113
+ declare function Modal({ onClose, open, children, size, className, labelledBy, onKeyDown, fullBleed, draggable, style, role, ...rest }: ModalProps): react.ReactPortal | null;
92
114
 
93
115
  export { Modal, ModalCloseContext, type ModalProps, useBackdropClose };
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import { createContext, useRef, useState } from "react";
3
+ import { createContext, useEffect, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { useFocusTrap } from "../hooks/use-focus-trap.js";
7
7
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
8
8
  import { useOverlayHistory } from "../hooks/use-overlay-history.js";
9
- import { useCloseTransition } from "../hooks/use-close-transition.js";
9
+ import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition.js";
10
10
  function useDragOffset(enabled) {
11
11
  const [offset, setOffset] = useState(null);
12
12
  const from = useRef(null);
@@ -14,6 +14,9 @@ function useDragOffset(enabled) {
14
14
  from.current = null;
15
15
  };
16
16
  return {
17
+ /** Back to where the panel opens. A `Modal` with an `open` prop outlives its own
18
+ * closes, so "the position resets when the dialog closes" needs saying. */
19
+ reset: () => setOffset(null),
17
20
  style: offset ? { transform: `translate(${offset.x}px, ${offset.y}px)` } : void 0,
18
21
  handlers: !enabled ? void 0 : {
19
22
  onPointerDown: (e) => {
@@ -56,6 +59,7 @@ function useBackdropClose(onClose) {
56
59
  }
57
60
  function Modal({
58
61
  onClose,
62
+ open,
59
63
  children,
60
64
  size = "md",
61
65
  className,
@@ -69,29 +73,51 @@ function Modal({
69
73
  }) {
70
74
  const panelRef = useRef(null);
71
75
  const drag = useDragOffset(Boolean(draggable));
72
- const { closing, requestClose } = useCloseTransition(onClose);
73
- const backdropClose = useBackdropClose(requestClose);
74
- useOverlayHistory(true, requestClose);
75
- useBodyScrollLock(true);
76
- useFocusTrap(panelRef, { active: true, initialFocus: "container" });
76
+ const active = open ?? true;
77
+ const [dismissed, setDismissed] = useState(false);
78
+ const [exiting, setExiting] = useState(false);
79
+ const [prevOpen, setPrevOpen] = useState(open);
80
+ if (open !== prevOpen) {
81
+ setPrevOpen(open);
82
+ setDismissed(false);
83
+ setExiting(open === false && prevOpen === true && !dismissed && !prefersReducedMotion());
84
+ if (open) drag.reset();
85
+ }
86
+ useEffect(() => {
87
+ if (!exiting) return;
88
+ const timer = setTimeout(() => setExiting(false), OVERLAY_EXIT_MS);
89
+ return () => clearTimeout(timer);
90
+ }, [exiting]);
91
+ const { closing, requestClose } = useCloseTransition(() => {
92
+ setDismissed(true);
93
+ onClose();
94
+ });
95
+ const backdropClose = useBackdropClose(active ? requestClose : () => {
96
+ });
97
+ useOverlayHistory(active, requestClose);
98
+ useBodyScrollLock(active);
99
+ useFocusTrap(panelRef, { active, initialFocus: "container" });
100
+ const leaving = closing || exiting;
77
101
  const handleKeyDown = (e) => {
78
102
  onKeyDown?.(e);
79
103
  if (e.defaultPrevented) return;
80
104
  if (e.key === "Escape") {
81
105
  e.stopPropagation();
82
- requestClose();
106
+ if (active) requestClose();
83
107
  return;
84
108
  }
85
109
  };
110
+ if (!active && !exiting) return null;
86
111
  return createPortal(
87
112
  /* @__PURE__ */ jsx(
88
113
  "div",
89
114
  {
115
+ "aria-hidden": exiting || void 0,
90
116
  className: cn(
91
117
  // Live #320. The panel below rises only where it is bottom-anchored: from md up
92
118
  // it is centred, and a centred box sliding up from off-screen reads as a
93
119
  // different component arriving rather than as the same one settling.
94
- closing ? "animate-overlay-out" : "animate-overlay",
120
+ leaving ? "animate-overlay-out" : "animate-overlay",
95
121
  "fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center",
96
122
  fullBleed ? "p-0 md:p-4" : "p-4"
97
123
  ),
@@ -109,7 +135,7 @@ function Modal({
109
135
  ...drag.handlers,
110
136
  style: { ...style, ...drag.style },
111
137
  className: cn(
112
- closing ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
138
+ leaving ? "animate-sheet-out md:animate-none" : "animate-sheet md:animate-none",
113
139
  "w-full rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm outline-none",
114
140
  { md: "max-w-md", lg: "max-w-lg", xl: "max-w-3xl" }[size],
115
141
  // A panel taller than the screen has to scroll ITSELF. The backdrop is