@eifi1/ui-kit 0.20.1 → 0.21.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/dist/components/hover-menu.d.ts +3 -0
- package/dist/components/hover-menu.js +2 -2
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +31 -28
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/package.json +1 -1
- package/src/components/hover-menu.tsx +7 -4
- package/src/shell/topbar-action-menu.tsx +53 -29
|
@@ -10,9 +10,12 @@ import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
|
10
10
|
* the panel's `close`, not a `ReactNode`.
|
|
11
11
|
*/
|
|
12
12
|
interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
13
|
+
/** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —
|
|
14
|
+
* following it should shut a panel hover opened (TopBarActionMenu `href`). */
|
|
13
15
|
trigger: (state: {
|
|
14
16
|
open: boolean;
|
|
15
17
|
toggle: () => void;
|
|
18
|
+
close: () => void;
|
|
16
19
|
}) => ReactNode;
|
|
17
20
|
children: (close: () => void) => ReactNode;
|
|
18
21
|
/**
|
|
@@ -110,7 +110,7 @@ function HoverMenu({
|
|
|
110
110
|
if (!inMenu) {
|
|
111
111
|
if (target !== triggerEl()) return;
|
|
112
112
|
if (e.key === "Enter" || e.key === " ") {
|
|
113
|
-
if (!open) focusOnOpenRef.current = "first";
|
|
113
|
+
if (!open && !target.matches("a[href]")) focusOnOpenRef.current = "first";
|
|
114
114
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
115
115
|
e.preventDefault();
|
|
116
116
|
const edge = e.key === "ArrowDown" ? "first" : "last";
|
|
@@ -180,7 +180,7 @@ function HoverMenu({
|
|
|
180
180
|
onKeyDown(e);
|
|
181
181
|
},
|
|
182
182
|
children: [
|
|
183
|
-
trigger({ open, toggle }),
|
|
183
|
+
trigger({ open, toggle, close }),
|
|
184
184
|
open && /* @__PURE__ */ jsx(
|
|
185
185
|
"div",
|
|
186
186
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with. `start`/`end` follow the\n * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`\n * are physical and kept for existing callers. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL.\n */\n align?: \"start\" | \"end\" | \"left\" | \"right\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /**\n * @deprecated Use the DOM spelling `aria-label`, which names the `role=\"menu\"` panel. Kept working because keksdose, kastlan and lenkbank all pass\n * this today and a rename to save one line of resolution is not a patch release; it\n * loses to `aria-label` wherever both are given. Removal is a later minor.\n */\n ariaLabel?: string;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\", left: \"left-0\", right: \"right-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Escape closes, from anywhere — it did nothing at all before. Focus goes back to the\n // trigger only when it was inside the menu: a panel opened by hover while the user is\n // typing elsewhere must not yank the caret over here on the way out.\n useEscapeKey(() => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n }, open);\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard.\n if (!open) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin. Recomputed on each open; `shiftX` is\n // subtracted first so the measurement is against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const margin = 8;\n const naturalLeft = rect.left - shiftX;\n const naturalRight = rect.right - shiftX;\n let next = 0;\n if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;\n if (naturalLeft + next < margin) next = margin - naturalLeft;\n setShiftX(next);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={wrapperRef}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onKeyDown={(e) => {\n rest.onKeyDown?.(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n ALIGN_CLASS[align],\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name. The DOM spelling wins;\n // {@link HoverMenuProps.ariaLabel} is the deprecated alias three apps still\n // pass, so it stays as the fallback rather than as the answer.\n aria-label={ariaLabelAttr ?? ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/hover-menu.tsx"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useEscapeKey, useOutsideClick } from \"../hooks/use-dismiss\";\n\n/**\n * Exported and `<div>`-shaped: the top bar composes several of these side by side, and\n * telling them apart — in a test, in a guided tour, in an accessibility tree — needs an\n * attribute the kit does not know about. A closed prop list dropped every one of them.\n *\n * `children` is omitted from the div attributes because here it is a render prop taking\n * the panel's `close`, not a `ReactNode`.\n */\nexport interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —\n * following it should shut a panel hover opened (TopBarActionMenu `href`). */\n trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;\n children: (close: () => void) => ReactNode;\n /**\n * Which edge of the trigger the panel lines up with. `start`/`end` follow the\n * reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`\n * are physical and kept for existing callers. Default `end` — the same right-aligned\n * panel as before in LTR, mirrored in RTL.\n */\n align?: \"start\" | \"end\" | \"left\" | \"right\";\n panelClassName?: string;\n /** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */\n className?: string;\n /**\n * @deprecated Use the DOM spelling `aria-label`, which names the `role=\"menu\"` panel. Kept working because keksdose, kastlan and lenkbank all pass\n * this today and a rename to save one line of resolution is not a patch release; it\n * loses to `aria-label` wherever both are given. Removal is a later minor.\n */\n ariaLabel?: string;\n}\n\n// Hovering waits briefly before opening so sweeping the cursor across a row of\n// triggers doesn't flash menus; closing waits so the cursor can travel from\n// trigger to panel. Click/keyboard toggling is instant (no \"opening\" phase).\nconst OPEN_DELAY_MS = 120;\nconst CLOSE_DELAY_MS = 120;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst ALIGN_CLASS = { start: \"start-0\", end: \"end-0\", left: \"left-0\", right: \"right-0\" } as const;\n\n/** Explicit menu-item roles a caller may already have written. */\nconst ITEM_ROLES = '[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]';\n/** What the panel promotes to `menuitem` when the caller wrote plain controls. */\nconst PLAIN_ITEMS = \"button:not([role]),a[href]:not([role])\";\n/** The trigger: the first focusable in the wrapper that is not inside the panel. */\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * Give the caller's markup menu semantics.\n *\n * `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding\n * `<button>`s and router `<Link>`s — so the panel was a `role=\"menu\"` whose children\n * were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu\n * with nothing in it as far as a screen reader is concerned. Rather than make every\n * caller rewrite its rows, the panel upgrades them after render: controls become\n * `menuitem`s out of the Tab order (the arrow keys move between them), and the list\n * markup in between becomes `role=\"none\"` so the menu owns its items directly. An\n * element that already carries a role is the caller's decision and is left alone.\n */\nfunction applyMenuSemantics(menu: HTMLElement): HTMLElement[] {\n for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute(\"role\", \"menuitem\");\n const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));\n for (const item of items) {\n item.setAttribute(\"tabindex\", \"-1\");\n for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {\n if (!el.hasAttribute(\"role\")) el.setAttribute(\"role\", \"none\");\n }\n }\n for (const el of menu.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role]),li:not([role])\")) {\n el.setAttribute(\"role\", \"none\");\n }\n return items.filter((el) => !el.hasAttribute(\"disabled\") && el.getAttribute(\"aria-disabled\") !== \"true\");\n}\n\n// Only one hover menu may show its panel at a time across the app — opening\n// one closes whichever was open, so adjacent top-bar menus can't overlap\n// (feedback #251). Module-level is fine: there is one cursor per document.\nlet activeClose: (() => void) | null = null;\n\nexport function HoverMenu({\n trigger,\n children,\n align = \"end\",\n panelClassName,\n className,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n ...rest\n}: HoverMenuProps) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const [phase, setPhase] = useState<Phase>(\"closed\");\n const open = phase === \"open\" || phase === \"closing\";\n\n // Whether the panel is open *because hover opened it*, vs. an explicit\n // click/keyboard toggle. A real mouse click on the trigger is always\n // preceded by a real mouseenter — the cursor has to land on the button\n // before it can be clicked — so once hover has opened the panel, the click\n // that inevitably follows is the same physical gesture, not a request to\n // close what was just opened (feedback #19). Toggling closed on click stays\n // for keyboard/touch activation, which reaches \"open\" without going through\n // the hover path.\n //\n // State rather than a ref: `close` is handed to the caller's render prop, and a\n // function that writes a ref must not be reachable from render.\n const [openedByHover, setOpenedByHover] = useState(false);\n\n const close = useCallback(() => {\n setOpenedByHover(false);\n setPhase(\"closed\");\n }, []);\n\n const toggle = useCallback(() => {\n if (open) {\n if (!openedByHover) setPhase(\"closed\");\n return;\n }\n setOpenedByHover(false);\n setPhase(\"open\");\n }, [open, openedByHover]);\n\n const handleMouseEnter = useCallback(\n () => setPhase((p) => (p === \"closing\" ? \"open\" : p === \"closed\" ? \"opening\" : p)),\n [],\n );\n\n const handleMouseLeave = useCallback(\n () => setPhase((p) => (p === \"opening\" ? \"closed\" : p === \"open\" ? \"closing\" : p)),\n [],\n );\n\n // Drive the delayed transitions; unmount/phase changes cancel the timer.\n useEffect(() => {\n if (phase === \"opening\") {\n const id = setTimeout(() => {\n setOpenedByHover(true);\n setPhase(\"open\");\n }, OPEN_DELAY_MS);\n return () => clearTimeout(id);\n }\n if (phase === \"closing\") {\n const id = setTimeout(() => setPhase(\"closed\"), CLOSE_DELAY_MS);\n return () => clearTimeout(id);\n }\n }, [phase]);\n\n // Claim the \"active menu\" slot while our panel is visible, closing the\n // previous holder. Cleanup releases the slot only if we still hold it\n // (a successor that displaced us has already overwritten it).\n useEffect(() => {\n if (!open) return;\n if (activeClose !== close) activeClose?.();\n activeClose = close;\n return () => {\n if (activeClose === close) activeClose = null;\n };\n }, [open, close]);\n\n useOutsideClick(wrapperRef, close, open);\n\n const menuRef = useRef<HTMLDivElement>(null);\n /** Set by a keyboard press on the trigger, so the open that follows moves focus into\n * the menu. A hover or a mouse click opens it without taking focus off the page. */\n const focusOnOpenRef = useRef<\"first\" | \"last\" | null>(null);\n\n const triggerEl = () =>\n Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(\n (el) => !menuRef.current?.contains(el),\n ) ?? null;\n const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);\n\n // Escape closes, from anywhere — it did nothing at all before. Focus goes back to the\n // trigger only when it was inside the menu: a panel opened by hover while the user is\n // typing elsewhere must not yank the caret over here on the way out.\n useEscapeKey(() => {\n const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;\n close();\n if (hadFocus) triggerEl()?.focus();\n }, open);\n\n // Upgrade the markup every render while open (the caller's rows can change), then\n // honour a pending keyboard open.\n useLayoutEffect(() => {\n if (!open) return;\n const list = items();\n const want = focusOnOpenRef.current;\n focusOnOpenRef.current = null;\n if (want) (want === \"last\" ? list[list.length - 1] : list[0])?.focus();\n });\n\n // Trigger state for assistive technology, on whatever element the caller rendered.\n // Only where the caller has not said something of its own.\n useLayoutEffect(() => {\n const el = triggerEl();\n if (!el) return;\n if (!el.hasAttribute(\"aria-haspopup\")) el.setAttribute(\"aria-haspopup\", \"menu\");\n if (el.getAttribute(\"aria-haspopup\") === \"menu\") el.setAttribute(\"aria-expanded\", String(open));\n });\n\n /** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,\n * Enter/Space/↓ open onto the first item and ↑ onto the last. */\n const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n const target = e.target as HTMLElement;\n const inMenu = menuRef.current?.contains(target) ?? false;\n if (!inMenu) {\n if (target !== triggerEl()) return;\n if (e.key === \"Enter\" || e.key === \" \") {\n // The trigger's own click handler does the toggling; this only records that\n // the press came from a keyboard. Not for a link trigger: Enter follows it, and\n // a mark left here would pull focus into the panel the next time hover opens it.\n if (!open && !target.matches(\"a[href]\")) focusOnOpenRef.current = \"first\";\n } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const edge = e.key === \"ArrowDown\" ? \"first\" : \"last\";\n if (open) {\n const list = items();\n (edge === \"last\" ? list[list.length - 1] : list[0])?.focus();\n } else {\n focusOnOpenRef.current = edge;\n setOpenedByHover(false);\n setPhase(\"open\");\n }\n }\n return;\n }\n if (e.key === \"Tab\") {\n close();\n return;\n }\n const list = items();\n if (list.length === 0) return;\n const at = list.indexOf(document.activeElement as HTMLElement);\n let next: number | null = null;\n if (e.key === \"ArrowDown\") next = at < 0 ? 0 : (at + 1) % list.length;\n else if (e.key === \"ArrowUp\") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;\n else if (e.key === \"Home\") next = 0;\n else if (e.key === \"End\") next = list.length - 1;\n if (next === null) return;\n e.preventDefault();\n list[next].focus();\n };\n\n // Keep the panel inside the viewport (feedback #85): a wide panel anchored to a\n // trigger near a screen edge would otherwise spill off it (on mobile the guided-\n // tours menu ran off the left). Once open, measure and translate it horizontally\n // just enough to sit within an 8px margin. Recomputed on each open; `shiftX` is\n // subtracted first so the measurement is against the natural position.\n const panelRef = useRef<HTMLDivElement>(null);\n const [shiftX, setShiftX] = useState(0);\n // Back to 0 on close, adjusted while rendering against the previous `open`.\n const [wasOpen, setWasOpen] = useState(open);\n if (open !== wasOpen) {\n setWasOpen(open);\n if (!open) setShiftX(0);\n }\n useLayoutEffect(() => {\n if (!open) return;\n const el = panelRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n if (rect.width === 0) return; // unmeasured (e.g. jsdom) — leave as-is\n const margin = 8;\n const naturalLeft = rect.left - shiftX;\n const naturalRight = rect.right - shiftX;\n let next = 0;\n if (naturalRight > window.innerWidth - margin) next = window.innerWidth - margin - naturalRight;\n if (naturalLeft + next < margin) next = margin - naturalLeft;\n setShiftX(next);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- recompute only on open/close\n }, [open]);\n\n return (\n /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused\n itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`\n catches keys bubbling from the caller's trigger button and the menuitems, which\n are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */\n <div\n // `...rest` first: the hover handlers below are the whole component, and a caller\n // passing one of its own must not silently replace them.\n {...rest}\n ref={wrapperRef}\n className={cn(\"relative\", className)}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n onKeyDown={(e) => {\n rest.onKeyDown?.(e);\n onKeyDown(e);\n }}\n >\n {trigger({ open, toggle, close })}\n {open && (\n <div\n ref={panelRef}\n className={cn(\n \"absolute top-full z-40 pt-2\",\n ALIGN_CLASS[align],\n )}\n style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}\n >\n <div\n ref={menuRef}\n role=\"menu\"\n // On the element with the role. It used to sit on the role-less wrapper,\n // where no screen reader reads a name. The DOM spelling wins;\n // {@link HoverMenuProps.ariaLabel} is the deprecated alias three apps still\n // pass, so it stays as the fallback rather than as the answer.\n aria-label={ariaLabelAttr ?? ariaLabel}\n className={cn(\n \"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\",\n panelClassName,\n )}\n >\n {children(close)}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["list"]}
|
|
@@ -79,7 +79,7 @@ interface TopBarMenuHeader {
|
|
|
79
79
|
* custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a
|
|
80
80
|
* free-content `footer` (the legal links).
|
|
81
81
|
*/
|
|
82
|
-
declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, ariaLabel, heading, header, entries, children, footer, footerLabel, align, panelClassName, }: {
|
|
82
|
+
declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, href, ariaLabel, heading, header, entries, children, footer, footerLabel, align, panelClassName, }: {
|
|
83
83
|
/** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
|
|
84
84
|
icon?: ReactNode;
|
|
85
85
|
/**
|
|
@@ -105,6 +105,18 @@ declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName,
|
|
|
105
105
|
}) => ReactNode;
|
|
106
106
|
/** Extra classes for the trigger button — `rounded-full` round an avatar. */
|
|
107
107
|
triggerClassName?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Make the trigger a LINK to this route (keksdose dev #585, Marcel: "clicking on the
|
|
110
|
+
* guided tours icon shall lead to the tours page like the all tours element does").
|
|
111
|
+
* A click or Enter follows it and shuts the panel; hover still opens the panel after
|
|
112
|
+
* the usual delay, and ↓/↑ on the focused trigger still open it onto the first/last
|
|
113
|
+
* row, so the rows stay reachable by keyboard. A tap on a touch screen follows the
|
|
114
|
+
* link — so use it where the destination offers everything the menu does. Rendered as
|
|
115
|
+
* an `<a>` (the router's `Link`) with `aria-haspopup="menu"` and `aria-expanded`,
|
|
116
|
+
* named by `ariaLabel`: a link inside the kit's button would be interactive content
|
|
117
|
+
* inside interactive content.
|
|
118
|
+
*/
|
|
119
|
+
href?: string;
|
|
108
120
|
/** Names the trigger and the menu. */
|
|
109
121
|
ariaLabel: string;
|
|
110
122
|
heading?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useLayoutEffect, useRef } from "react";
|
|
4
4
|
import { Link } from "react-router";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
@@ -36,6 +36,7 @@ function TopBarActionMenu({
|
|
|
36
36
|
iconBadge,
|
|
37
37
|
trigger,
|
|
38
38
|
triggerClassName,
|
|
39
|
+
href,
|
|
39
40
|
ariaLabel,
|
|
40
41
|
heading,
|
|
41
42
|
header,
|
|
@@ -51,33 +52,35 @@ function TopBarActionMenu({
|
|
|
51
52
|
{
|
|
52
53
|
ariaLabel,
|
|
53
54
|
align,
|
|
54
|
-
trigger: ({ open, toggle }) =>
|
|
55
|
-
/* @__PURE__ */ jsx("
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
icon,
|
|
66
|
-
/* @__PURE__ */ jsx(StatusDot, { ring: true, tone: iconBadge.tone ?? "danger", className: "absolute -end-0.5 -top-0.5" })
|
|
67
|
-
] }),
|
|
68
|
-
" ",
|
|
69
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children: iconBadge.label })
|
|
70
|
-
] })
|
|
71
|
-
) : /* @__PURE__ */ jsx(
|
|
72
|
-
"button",
|
|
73
|
-
{
|
|
74
|
-
type: "button",
|
|
75
|
-
onClick: toggle,
|
|
76
|
-
"aria-label": ariaLabel,
|
|
77
|
-
className: cn(TOPBAR_TRIGGER_CLASS, triggerClassName),
|
|
78
|
-
children: icon
|
|
55
|
+
trigger: ({ open, toggle, close }) => {
|
|
56
|
+
const control = (className, children2, label) => href ? /* @__PURE__ */ jsx(Link, { to: href, onClick: close, "aria-label": label, className, children: children2 }) : /* @__PURE__ */ jsx("button", { type: "button", onClick: toggle, "aria-label": label, className, children: children2 });
|
|
57
|
+
if (trigger) {
|
|
58
|
+
return control(
|
|
59
|
+
cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName),
|
|
60
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
61
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: ariaLabel }),
|
|
62
|
+
" ",
|
|
63
|
+
trigger({ open })
|
|
64
|
+
] })
|
|
65
|
+
);
|
|
79
66
|
}
|
|
80
|
-
|
|
67
|
+
if (iconBadge) {
|
|
68
|
+
return control(
|
|
69
|
+
cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName),
|
|
70
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
71
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: ariaLabel }),
|
|
72
|
+
" ",
|
|
73
|
+
/* @__PURE__ */ jsxs("span", { "aria-hidden": true, className: "relative inline-flex", children: [
|
|
74
|
+
icon,
|
|
75
|
+
/* @__PURE__ */ jsx(StatusDot, { ring: true, tone: iconBadge.tone ?? "danger", className: "absolute -end-0.5 -top-0.5" })
|
|
76
|
+
] }),
|
|
77
|
+
" ",
|
|
78
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: iconBadge.label })
|
|
79
|
+
] })
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return control(cn(TOPBAR_TRIGGER_CLASS, triggerClassName), icon, ariaLabel);
|
|
83
|
+
},
|
|
81
84
|
children: (close) => {
|
|
82
85
|
const footerBody = typeof footer === "function" ? footer(close) : footer;
|
|
83
86
|
const headerExtra = typeof header?.extra === "function" ? header.extra(close) : header?.extra;
|
|
@@ -99,7 +102,7 @@ function TopBarActionMenu({
|
|
|
99
102
|
MenuItem,
|
|
100
103
|
{
|
|
101
104
|
href: entry.to,
|
|
102
|
-
renderLink: ({ href, ...p }) => /* @__PURE__ */ jsx(Link, { to:
|
|
105
|
+
renderLink: ({ href: href2, ...p }) => /* @__PURE__ */ jsx(Link, { to: href2, ...p }),
|
|
103
106
|
onClick: close,
|
|
104
107
|
leading: entry.icon,
|
|
105
108
|
trailing: entry.trailing,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/topbar-action-menu.tsx"],"sourcesContent":["import { useLayoutEffect, useRef } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { Link } from \"react-router\";\nimport { cn } from \"../lib/cn\";\nimport { HoverMenu } from \"../components/hover-menu\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { MenuItemTone } from \"../components/menu-item\";\nimport { StatusDot } from \"../components/status-dot\";\nimport type { UserAvatarBadge } from \"../components/user-avatar\";\nimport { TOPBAR_TRIGGER_CLASS } from \"./topbar-controls\";\n\n/**\n * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a\n * `link` navigates via react-router, and a `divider` draws a rule. Non-divider\n * rows close the menu automatically when chosen. `kind` may be omitted for\n * actions, keeping the common case terse.\n *\n * The rows are {@link MenuItem}s, so they take its options: `tone: \"danger\"` for the\n * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the\n * page already open, `disabled`.\n */\nexport type TopBarMenuEntry =\n | {\n kind?: \"action\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n onSelect: () => void;\n tone?: MenuItemTone;\n /** Makes the row a choice (`menuitemradio`, or `menuitemcheckbox` with\n * `checkable: \"checkbox\"`) and says whether it is chosen. */\n checked?: boolean;\n checkable?: \"radio\" | \"checkbox\";\n disabled?: boolean;\n }\n | {\n kind: \"link\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n to: string;\n tone?: MenuItemTone;\n /** The link to the page already open: `aria-current=\"page\"` and a check mark. */\n current?: boolean;\n disabled?: boolean;\n }\n | { kind: \"divider\"; key: string };\n\n/**\n * The non-interactive identity block at the top of an account menu — kastlan's name +\n * role, keksdose's display name + email + role chip. It is text, not a `menuitem`: the\n * arrow keys pass over it.\n */\nexport interface TopBarMenuHeader {\n /** The first line — the user's name. */\n title: ReactNode;\n /** The muted second line — an email, a role. */\n subtitle?: ReactNode;\n /**\n * Anything below — a role chip. A link or button here stays out of the arrow keys\n * and in the Tab order, like the footer's.\n *\n * Pass a function to get `close`, as `footer` does — keksdose F7: its admin role\n * chip links to the admin area, and a router link does not unmount the menu, so\n * without `close` the panel stayed open over the page it had just navigated to.\n */\n extra?: ReactNode | ((close: () => void) => ReactNode);\n}\n\n/** What a control is, as far as `HoverMenu` is concerned, when it lives in the header\n * or the footer: not a menu row. */\nconst SECTION_CONTROLS = \"a[href]:not([role]),button:not([role])\";\nconst TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';\n\n/**\n * A header or footer inside the menu panel.\n *\n * `HoverMenu` promotes EVERY role-less button and link in its panel to a `menuitem` out\n * of the Tab order, and turns every list into `role=\"none\"` — right for the rows, wrong\n * for a footer of legal links, which would join the arrow-key cycle and drop out of Tab.\n * So before it looks (a child's layout effect runs before its parent's), the controls\n * in here get their own role spelled out, which `HoverMenu` leaves alone, and any list\n * keeps its list semantics.\n */\nfunction MenuSection({ className, children }: { className: string; children: ReactNode }) {\n const ref = useRef<HTMLLIElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n for (const c of el.querySelectorAll<HTMLElement>(SECTION_CONTROLS)) {\n c.setAttribute(\"role\", c.tagName === \"A\" ? \"link\" : \"button\");\n }\n for (const l of el.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role])\")) l.setAttribute(\"role\", \"list\");\n for (const l of el.querySelectorAll<HTMLElement>(\"li:not([role])\")) l.setAttribute(\"role\", \"listitem\");\n });\n return (\n <li ref={ref} role=\"none\" className={className}>\n {children}\n </li>\n );\n}\n\n/**\n * `HoverMenu` closes on any Tab inside the panel — right when Tab leaves it, wrong when\n * Tab is on its way from a row to a footer link, or from one footer link to the next:\n * the panel would unmount under the focus. So a Tab whose next stop is still inside the\n * panel stops here and the browser moves focus; one that leaves reaches `HoverMenu`,\n * which closes.\n */\nfunction keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {\n if (e.key !== \"Tab\") return;\n const from = e.target as HTMLElement;\n const stops = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)).filter(\n (el) => el.getAttribute(\"tabindex\") !== \"-1\",\n );\n const way = e.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;\n if (stops.some((el) => el !== from && from.compareDocumentPosition(el) & way)) e.stopPropagation();\n}\n\n/**\n * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,\n * with an optional uppercase heading and divider rules. Generic glue over the\n * shared top-bar chrome — e.g. the feedback launcher or the guided-tours menu —\n * so those call sites describe their rows as data instead of rebuilding the\n * trigger + list markup. For a checkmarked value-switcher use\n * {@link OptionSwitcherMenu} instead.\n *\n * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a\n * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a\n * free-content `footer` (the legal links).\n */\nexport function TopBarActionMenu({\n icon,\n iconBadge,\n trigger,\n triggerClassName,\n ariaLabel,\n heading,\n header,\n entries,\n children,\n footer,\n footerLabel,\n align,\n panelClassName,\n}: {\n /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */\n icon?: ReactNode;\n /**\n * A status dot in the top-end corner of `icon` — keksdose's unread-support dot on the\n * feedback trigger. Like `UserAvatar`'s `badge`, its `label` joins the trigger's name\n * (\"Send feedback 2 unread\"), so the dot is not something only the eye is told about.\n * Ignored with `trigger`: give the face its own badge there.\n */\n iconBadge?: UserAvatarBadge | null;\n /**\n * A face of your own for the trigger — the account menu's `UserAvatar`. It is drawn\n * INSIDE the kit's button, which keeps the wiring: `aria-haspopup`, `aria-expanded`,\n * the toggle, the keyboard. `open` is there for a chevron that turns.\n *\n * The button is named by `ariaLabel` followed by any readable text in the face — a\n * `UserAvatar` `badge` label reaches a screen reader that way (\"Account menu 3\n * unread\"), where an `aria-label` on the button would have hidden it. A\n * `UserAvatar`'s initials are `aria-hidden`, so the person's name is only in the\n * name if `ariaLabel` carries it (\"Marcel Eifert, account menu\").\n */\n trigger?: (state: { open: boolean }) => ReactNode;\n /** Extra classes for the trigger button — `rounded-full` round an avatar. */\n triggerClassName?: string;\n /** Names the trigger and the menu. */\n ariaLabel: string;\n heading?: string;\n /** A non-interactive identity block above the rows. */\n header?: TopBarMenuHeader;\n entries: TopBarMenuEntry[];\n /**\n * Rows the entry data cannot describe (a `Disclosure` sub-list, an app's own\n * section), after `entries`. Render `<li>`s holding `MenuItem`s or plain controls;\n * `close` shuts the menu.\n */\n children?: (close: () => void) => ReactNode;\n /**\n * Free content below the rows, behind a rule — the legal links. Its links and\n * buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a\n * function to get `close` for the links' `onClick`.\n */\n footer?: ReactNode | ((close: () => void) => ReactNode);\n /** Wraps the footer in a `<nav>` with this name (\"Legal\"). Without it, a plain block. */\n footerLabel?: string;\n /** Which edge of the trigger the panel lines up with (see `HoverMenu`). Default `end`. */\n align?: \"start\" | \"end\" | \"left\" | \"right\";\n panelClassName?: string;\n}) {\n return (\n <HoverMenu\n ariaLabel={ariaLabel}\n align={align}\n trigger={({ open, toggle }) =>\n trigger ? (\n <button type=\"button\" onClick={toggle} className={cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName)}>\n <span className=\"sr-only\">{ariaLabel}</span>\n {/* The space keeps the name from running into the face's text (\"Account\n menu3 unread\"); as a flex item it takes no room. */}\n {\" \"}\n {trigger({ open })}\n </button>\n ) : iconBadge ? (\n // Named by its content, like the custom-trigger branch, so the badge label is\n // part of the name; an `aria-label` would have hidden it.\n <button type=\"button\" onClick={toggle} className={cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName)}>\n <span className=\"sr-only\">{ariaLabel}</span>{\" \"}\n <span aria-hidden className=\"relative inline-flex\">\n {icon}\n <StatusDot ring tone={iconBadge.tone ?? \"danger\"} className=\"absolute -end-0.5 -top-0.5\" />\n </span>{\" \"}\n <span className=\"sr-only\">{iconBadge.label}</span>\n </button>\n ) : (\n <button\n type=\"button\"\n onClick={toggle}\n aria-label={ariaLabel}\n className={cn(TOPBAR_TRIGGER_CLASS, triggerClassName)}\n >\n {icon}\n </button>\n )\n }\n >\n {(close) => {\n const footerBody = typeof footer === \"function\" ? footer(close) : footer;\n const headerExtra = typeof header?.extra === \"function\" ? header.extra(close) : header?.extra;\n const footerClass =\n \"flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]\";\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel\n <ul className={cn(\"py-1\", panelClassName)} onKeyDown={keepTabInsidePanel}>\n {header && (\n <MenuSection className=\"mb-1 border-b border-[var(--border)] px-3 py-2\">\n <div className=\"truncate text-sm font-medium text-[var(--text-primary)]\">{header.title}</div>\n {header.subtitle != null && (\n <div className=\"truncate text-xs text-[var(--text-muted)]\">{header.subtitle}</div>\n )}\n {headerExtra != null && headerExtra !== false && <div className=\"mt-1\">{headerExtra}</div>}\n </MenuSection>\n )}\n {heading && (\n <li className=\"px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]\">\n {heading}\n </li>\n )}\n {entries.map((entry) => {\n if (entry.kind === \"divider\") {\n return <li key={entry.key} className=\"my-1 border-t border-[var(--border)]\" />;\n }\n return (\n <li key={entry.key}>\n {entry.kind === \"link\" ? (\n <MenuItem\n href={entry.to}\n renderLink={({ href, ...p }) => <Link to={href} {...p} />}\n onClick={close}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n current={entry.current}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n ) : (\n <MenuItem\n onClick={() => {\n entry.onSelect();\n close();\n }}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n checked={entry.checked}\n checkable={entry.checkable}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n )}\n </li>\n );\n })}\n {children?.(close)}\n {footerBody != null && footerBody !== false && (\n <MenuSection className=\"mt-1 border-t border-[var(--border)] px-3 py-2\">\n {footerLabel ? (\n <nav aria-label={footerLabel} className={footerClass}>\n {footerBody}\n </nav>\n ) : (\n <div className={footerClass}>{footerBody}</div>\n )}\n </MenuSection>\n )}\n </ul>\n );\n }}\n </HoverMenu>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/topbar-action-menu.tsx"],"sourcesContent":["import { useLayoutEffect, useRef } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { Link } from \"react-router\";\nimport { cn } from \"../lib/cn\";\nimport { HoverMenu } from \"../components/hover-menu\";\nimport { MenuItem } from \"../components/menu-item\";\nimport type { MenuItemTone } from \"../components/menu-item\";\nimport { StatusDot } from \"../components/status-dot\";\nimport type { UserAvatarBadge } from \"../components/user-avatar\";\nimport { TOPBAR_TRIGGER_CLASS } from \"./topbar-controls\";\n\n/**\n * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a\n * `link` navigates via react-router, and a `divider` draws a rule. Non-divider\n * rows close the menu automatically when chosen. `kind` may be omitted for\n * actions, keeping the common case terse.\n *\n * The rows are {@link MenuItem}s, so they take its options: `tone: \"danger\"` for the\n * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the\n * page already open, `disabled`.\n */\nexport type TopBarMenuEntry =\n | {\n kind?: \"action\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n onSelect: () => void;\n tone?: MenuItemTone;\n /** Makes the row a choice (`menuitemradio`, or `menuitemcheckbox` with\n * `checkable: \"checkbox\"`) and says whether it is chosen. */\n checked?: boolean;\n checkable?: \"radio\" | \"checkbox\";\n disabled?: boolean;\n }\n | {\n kind: \"link\";\n key: string;\n icon?: ReactNode;\n label: ReactNode;\n trailing?: ReactNode;\n to: string;\n tone?: MenuItemTone;\n /** The link to the page already open: `aria-current=\"page\"` and a check mark. */\n current?: boolean;\n disabled?: boolean;\n }\n | { kind: \"divider\"; key: string };\n\n/**\n * The non-interactive identity block at the top of an account menu — kastlan's name +\n * role, keksdose's display name + email + role chip. It is text, not a `menuitem`: the\n * arrow keys pass over it.\n */\nexport interface TopBarMenuHeader {\n /** The first line — the user's name. */\n title: ReactNode;\n /** The muted second line — an email, a role. */\n subtitle?: ReactNode;\n /**\n * Anything below — a role chip. A link or button here stays out of the arrow keys\n * and in the Tab order, like the footer's.\n *\n * Pass a function to get `close`, as `footer` does — keksdose F7: its admin role\n * chip links to the admin area, and a router link does not unmount the menu, so\n * without `close` the panel stayed open over the page it had just navigated to.\n */\n extra?: ReactNode | ((close: () => void) => ReactNode);\n}\n\n/** What a control is, as far as `HoverMenu` is concerned, when it lives in the header\n * or the footer: not a menu row. */\nconst SECTION_CONTROLS = \"a[href]:not([role]),button:not([role])\";\nconst TABBABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]';\n\n/**\n * A header or footer inside the menu panel.\n *\n * `HoverMenu` promotes EVERY role-less button and link in its panel to a `menuitem` out\n * of the Tab order, and turns every list into `role=\"none\"` — right for the rows, wrong\n * for a footer of legal links, which would join the arrow-key cycle and drop out of Tab.\n * So before it looks (a child's layout effect runs before its parent's), the controls\n * in here get their own role spelled out, which `HoverMenu` leaves alone, and any list\n * keeps its list semantics.\n */\nfunction MenuSection({ className, children }: { className: string; children: ReactNode }) {\n const ref = useRef<HTMLLIElement>(null);\n useLayoutEffect(() => {\n const el = ref.current;\n if (!el) return;\n for (const c of el.querySelectorAll<HTMLElement>(SECTION_CONTROLS)) {\n c.setAttribute(\"role\", c.tagName === \"A\" ? \"link\" : \"button\");\n }\n for (const l of el.querySelectorAll<HTMLElement>(\"ul:not([role]),ol:not([role])\")) l.setAttribute(\"role\", \"list\");\n for (const l of el.querySelectorAll<HTMLElement>(\"li:not([role])\")) l.setAttribute(\"role\", \"listitem\");\n });\n return (\n <li ref={ref} role=\"none\" className={className}>\n {children}\n </li>\n );\n}\n\n/**\n * `HoverMenu` closes on any Tab inside the panel — right when Tab leaves it, wrong when\n * Tab is on its way from a row to a footer link, or from one footer link to the next:\n * the panel would unmount under the focus. So a Tab whose next stop is still inside the\n * panel stops here and the browser moves focus; one that leaves reaches `HoverMenu`,\n * which closes.\n */\nfunction keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {\n if (e.key !== \"Tab\") return;\n const from = e.target as HTMLElement;\n const stops = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(TABBABLE)).filter(\n (el) => el.getAttribute(\"tabindex\") !== \"-1\",\n );\n const way = e.shiftKey ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;\n if (stops.some((el) => el !== from && from.compareDocumentPosition(el) & way)) e.stopPropagation();\n}\n\n/**\n * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,\n * with an optional uppercase heading and divider rules. Generic glue over the\n * shared top-bar chrome — e.g. the feedback launcher or the guided-tours menu —\n * so those call sites describe their rows as data instead of rebuilding the\n * trigger + list markup. For a checkmarked value-switcher use\n * {@link OptionSwitcherMenu} instead.\n *\n * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a\n * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a\n * free-content `footer` (the legal links).\n */\nexport function TopBarActionMenu({\n icon,\n iconBadge,\n trigger,\n triggerClassName,\n href,\n ariaLabel,\n heading,\n header,\n entries,\n children,\n footer,\n footerLabel,\n align,\n panelClassName,\n}: {\n /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */\n icon?: ReactNode;\n /**\n * A status dot in the top-end corner of `icon` — keksdose's unread-support dot on the\n * feedback trigger. Like `UserAvatar`'s `badge`, its `label` joins the trigger's name\n * (\"Send feedback 2 unread\"), so the dot is not something only the eye is told about.\n * Ignored with `trigger`: give the face its own badge there.\n */\n iconBadge?: UserAvatarBadge | null;\n /**\n * A face of your own for the trigger — the account menu's `UserAvatar`. It is drawn\n * INSIDE the kit's button, which keeps the wiring: `aria-haspopup`, `aria-expanded`,\n * the toggle, the keyboard. `open` is there for a chevron that turns.\n *\n * The button is named by `ariaLabel` followed by any readable text in the face — a\n * `UserAvatar` `badge` label reaches a screen reader that way (\"Account menu 3\n * unread\"), where an `aria-label` on the button would have hidden it. A\n * `UserAvatar`'s initials are `aria-hidden`, so the person's name is only in the\n * name if `ariaLabel` carries it (\"Marcel Eifert, account menu\").\n */\n trigger?: (state: { open: boolean }) => ReactNode;\n /** Extra classes for the trigger button — `rounded-full` round an avatar. */\n triggerClassName?: string;\n /**\n * Make the trigger a LINK to this route (keksdose dev #585, Marcel: \"clicking on the\n * guided tours icon shall lead to the tours page like the all tours element does\").\n * A click or Enter follows it and shuts the panel; hover still opens the panel after\n * the usual delay, and ↓/↑ on the focused trigger still open it onto the first/last\n * row, so the rows stay reachable by keyboard. A tap on a touch screen follows the\n * link — so use it where the destination offers everything the menu does. Rendered as\n * an `<a>` (the router's `Link`) with `aria-haspopup=\"menu\"` and `aria-expanded`,\n * named by `ariaLabel`: a link inside the kit's button would be interactive content\n * inside interactive content.\n */\n href?: string;\n /** Names the trigger and the menu. */\n ariaLabel: string;\n heading?: string;\n /** A non-interactive identity block above the rows. */\n header?: TopBarMenuHeader;\n entries: TopBarMenuEntry[];\n /**\n * Rows the entry data cannot describe (a `Disclosure` sub-list, an app's own\n * section), after `entries`. Render `<li>`s holding `MenuItem`s or plain controls;\n * `close` shuts the menu.\n */\n children?: (close: () => void) => ReactNode;\n /**\n * Free content below the rows, behind a rule — the legal links. Its links and\n * buttons are NOT menu rows: the arrow keys skip them and Tab reaches them. Pass a\n * function to get `close` for the links' `onClick`.\n */\n footer?: ReactNode | ((close: () => void) => ReactNode);\n /** Wraps the footer in a `<nav>` with this name (\"Legal\"). Without it, a plain block. */\n footerLabel?: string;\n /** Which edge of the trigger the panel lines up with (see `HoverMenu`). Default `end`. */\n align?: \"start\" | \"end\" | \"left\" | \"right\";\n panelClassName?: string;\n}) {\n return (\n <HoverMenu\n ariaLabel={ariaLabel}\n align={align}\n trigger={({ open, toggle, close }) => {\n // One element for both kinds: the router's link when `href` is set, else the\n // kit's button. Same classes and face either way.\n const control = (className: string, children: ReactNode, label?: string) =>\n href ? (\n <Link to={href} onClick={close} aria-label={label} className={className}>\n {children}\n </Link>\n ) : (\n <button type=\"button\" onClick={toggle} aria-label={label} className={className}>\n {children}\n </button>\n );\n if (trigger) {\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>\n {/* The space keeps the name from running into the face's text (\"Account\n menu3 unread\"); as a flex item it takes no room. */}\n {\" \"}\n {trigger({ open })}\n </>,\n );\n }\n if (iconBadge) {\n // Named by its content, like the custom-trigger branch, so the badge label is\n // part of the name; an `aria-label` would have hidden it.\n return control(\n cn(TOPBAR_TRIGGER_CLASS, \"relative\", triggerClassName),\n <>\n <span className=\"sr-only\">{ariaLabel}</span>{\" \"}\n <span aria-hidden className=\"relative inline-flex\">\n {icon}\n <StatusDot ring tone={iconBadge.tone ?? \"danger\"} className=\"absolute -end-0.5 -top-0.5\" />\n </span>{\" \"}\n <span className=\"sr-only\">{iconBadge.label}</span>\n </>,\n );\n }\n return control(cn(TOPBAR_TRIGGER_CLASS, triggerClassName), icon, ariaLabel);\n }}\n >\n {(close) => {\n const footerBody = typeof footer === \"function\" ? footer(close) : footer;\n const headerExtra = typeof header?.extra === \"function\" ? header.extra(close) : header?.extra;\n const footerClass =\n \"flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]\";\n return (\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel\n <ul className={cn(\"py-1\", panelClassName)} onKeyDown={keepTabInsidePanel}>\n {header && (\n <MenuSection className=\"mb-1 border-b border-[var(--border)] px-3 py-2\">\n <div className=\"truncate text-sm font-medium text-[var(--text-primary)]\">{header.title}</div>\n {header.subtitle != null && (\n <div className=\"truncate text-xs text-[var(--text-muted)]\">{header.subtitle}</div>\n )}\n {headerExtra != null && headerExtra !== false && <div className=\"mt-1\">{headerExtra}</div>}\n </MenuSection>\n )}\n {heading && (\n <li className=\"px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]\">\n {heading}\n </li>\n )}\n {entries.map((entry) => {\n if (entry.kind === \"divider\") {\n return <li key={entry.key} className=\"my-1 border-t border-[var(--border)]\" />;\n }\n return (\n <li key={entry.key}>\n {entry.kind === \"link\" ? (\n <MenuItem\n href={entry.to}\n renderLink={({ href, ...p }) => <Link to={href} {...p} />}\n onClick={close}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n current={entry.current}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n ) : (\n <MenuItem\n onClick={() => {\n entry.onSelect();\n close();\n }}\n leading={entry.icon}\n trailing={entry.trailing}\n tone={entry.tone}\n checked={entry.checked}\n checkable={entry.checkable}\n disabled={entry.disabled}\n >\n {entry.label}\n </MenuItem>\n )}\n </li>\n );\n })}\n {children?.(close)}\n {footerBody != null && footerBody !== false && (\n <MenuSection className=\"mt-1 border-t border-[var(--border)] px-3 py-2\">\n {footerLabel ? (\n <nav aria-label={footerLabel} className={footerClass}>\n {footerBody}\n </nav>\n ) : (\n <div className={footerClass}>{footerBody}</div>\n )}\n </MenuSection>\n )}\n </ul>\n );\n }}\n </HoverMenu>\n );\n}\n"],"mappings":"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","names":["children","href"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -12,7 +12,9 @@ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
|
|
|
12
12
|
* the panel's `close`, not a `ReactNode`.
|
|
13
13
|
*/
|
|
14
14
|
export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
15
|
-
trigger
|
|
15
|
+
/** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —
|
|
16
|
+
* following it should shut a panel hover opened (TopBarActionMenu `href`). */
|
|
17
|
+
trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;
|
|
16
18
|
children: (close: () => void) => ReactNode;
|
|
17
19
|
/**
|
|
18
20
|
* Which edge of the trigger the panel lines up with. `start`/`end` follow the
|
|
@@ -209,8 +211,9 @@ export function HoverMenu({
|
|
|
209
211
|
if (target !== triggerEl()) return;
|
|
210
212
|
if (e.key === "Enter" || e.key === " ") {
|
|
211
213
|
// The trigger's own click handler does the toggling; this only records that
|
|
212
|
-
// the press came from a keyboard.
|
|
213
|
-
|
|
214
|
+
// the press came from a keyboard. Not for a link trigger: Enter follows it, and
|
|
215
|
+
// a mark left here would pull focus into the panel the next time hover opens it.
|
|
216
|
+
if (!open && !target.matches("a[href]")) focusOnOpenRef.current = "first";
|
|
214
217
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
215
218
|
e.preventDefault();
|
|
216
219
|
const edge = e.key === "ArrowDown" ? "first" : "last";
|
|
@@ -289,7 +292,7 @@ export function HoverMenu({
|
|
|
289
292
|
onKeyDown(e);
|
|
290
293
|
}}
|
|
291
294
|
>
|
|
292
|
-
{trigger({ open, toggle })}
|
|
295
|
+
{trigger({ open, toggle, close })}
|
|
293
296
|
{open && (
|
|
294
297
|
<div
|
|
295
298
|
ref={panelRef}
|
|
@@ -136,6 +136,7 @@ export function TopBarActionMenu({
|
|
|
136
136
|
iconBadge,
|
|
137
137
|
trigger,
|
|
138
138
|
triggerClassName,
|
|
139
|
+
href,
|
|
139
140
|
ariaLabel,
|
|
140
141
|
heading,
|
|
141
142
|
header,
|
|
@@ -169,6 +170,18 @@ export function TopBarActionMenu({
|
|
|
169
170
|
trigger?: (state: { open: boolean }) => ReactNode;
|
|
170
171
|
/** Extra classes for the trigger button — `rounded-full` round an avatar. */
|
|
171
172
|
triggerClassName?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Make the trigger a LINK to this route (keksdose dev #585, Marcel: "clicking on the
|
|
175
|
+
* guided tours icon shall lead to the tours page like the all tours element does").
|
|
176
|
+
* A click or Enter follows it and shuts the panel; hover still opens the panel after
|
|
177
|
+
* the usual delay, and ↓/↑ on the focused trigger still open it onto the first/last
|
|
178
|
+
* row, so the rows stay reachable by keyboard. A tap on a touch screen follows the
|
|
179
|
+
* link — so use it where the destination offers everything the menu does. Rendered as
|
|
180
|
+
* an `<a>` (the router's `Link`) with `aria-haspopup="menu"` and `aria-expanded`,
|
|
181
|
+
* named by `ariaLabel`: a link inside the kit's button would be interactive content
|
|
182
|
+
* inside interactive content.
|
|
183
|
+
*/
|
|
184
|
+
href?: string;
|
|
172
185
|
/** Names the trigger and the menu. */
|
|
173
186
|
ariaLabel: string;
|
|
174
187
|
heading?: string;
|
|
@@ -197,37 +210,48 @@ export function TopBarActionMenu({
|
|
|
197
210
|
<HoverMenu
|
|
198
211
|
ariaLabel={ariaLabel}
|
|
199
212
|
align={align}
|
|
200
|
-
trigger={({ open, toggle }) =>
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
trigger={({ open, toggle, close }) => {
|
|
214
|
+
// One element for both kinds: the router's link when `href` is set, else the
|
|
215
|
+
// kit's button. Same classes and face either way.
|
|
216
|
+
const control = (className: string, children: ReactNode, label?: string) =>
|
|
217
|
+
href ? (
|
|
218
|
+
<Link to={href} onClick={close} aria-label={label} className={className}>
|
|
219
|
+
{children}
|
|
220
|
+
</Link>
|
|
221
|
+
) : (
|
|
222
|
+
<button type="button" onClick={toggle} aria-label={label} className={className}>
|
|
223
|
+
{children}
|
|
224
|
+
</button>
|
|
225
|
+
);
|
|
226
|
+
if (trigger) {
|
|
227
|
+
return control(
|
|
228
|
+
cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName),
|
|
229
|
+
<>
|
|
230
|
+
<span className="sr-only">{ariaLabel}</span>
|
|
231
|
+
{/* The space keeps the name from running into the face's text ("Account
|
|
232
|
+
menu3 unread"); as a flex item it takes no room. */}
|
|
233
|
+
{" "}
|
|
234
|
+
{trigger({ open })}
|
|
235
|
+
</>,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (iconBadge) {
|
|
210
239
|
// Named by its content, like the custom-trigger branch, so the badge label is
|
|
211
240
|
// part of the name; an `aria-label` would have hidden it.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
>
|
|
227
|
-
{icon}
|
|
228
|
-
</button>
|
|
229
|
-
)
|
|
230
|
-
}
|
|
241
|
+
return control(
|
|
242
|
+
cn(TOPBAR_TRIGGER_CLASS, "relative", triggerClassName),
|
|
243
|
+
<>
|
|
244
|
+
<span className="sr-only">{ariaLabel}</span>{" "}
|
|
245
|
+
<span aria-hidden className="relative inline-flex">
|
|
246
|
+
{icon}
|
|
247
|
+
<StatusDot ring tone={iconBadge.tone ?? "danger"} className="absolute -end-0.5 -top-0.5" />
|
|
248
|
+
</span>{" "}
|
|
249
|
+
<span className="sr-only">{iconBadge.label}</span>
|
|
250
|
+
</>,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return control(cn(TOPBAR_TRIGGER_CLASS, triggerClassName), icon, ariaLabel);
|
|
254
|
+
}}
|
|
231
255
|
>
|
|
232
256
|
{(close) => {
|
|
233
257
|
const footerBody = typeof footer === "function" ? footer(close) : footer;
|