tempest-react-sdk 0.66.0 → 0.67.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/BottomSheet/BottomSheet.cjs +1 -1
- package/dist/components/BottomSheet/BottomSheet.cjs.map +1 -1
- package/dist/components/BottomSheet/BottomSheet.js +31 -40
- package/dist/components/BottomSheet/BottomSheet.js.map +1 -1
- package/dist/components/Combobox/Combobox.cjs +1 -1
- package/dist/components/Combobox/Combobox.cjs.map +1 -1
- package/dist/components/Combobox/Combobox.js +94 -73
- package/dist/components/Combobox/Combobox.js.map +1 -1
- package/dist/components/Combobox/Combobox.module.cjs +1 -1
- package/dist/components/Combobox/Combobox.module.cjs.map +1 -1
- package/dist/components/Combobox/Combobox.module.js +4 -3
- package/dist/components/Combobox/Combobox.module.js.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.cjs +1 -1
- package/dist/components/ContextMenu/ContextMenu.cjs.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.js +80 -83
- package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
- package/dist/components/Drawer/Drawer.cjs +1 -1
- package/dist/components/Drawer/Drawer.cjs.map +1 -1
- package/dist/components/Drawer/Drawer.js +43 -46
- package/dist/components/Drawer/Drawer.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.js +129 -105
- package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.module.cjs +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.module.cjs.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.module.js +12 -11
- package/dist/components/DropdownMenu/DropdownMenu.module.js.map +1 -1
- package/dist/components/HoverCard/HoverCard.cjs +1 -1
- package/dist/components/HoverCard/HoverCard.cjs.map +1 -1
- package/dist/components/HoverCard/HoverCard.js +54 -29
- package/dist/components/HoverCard/HoverCard.js.map +1 -1
- package/dist/components/HoverCard/HoverCard.module.cjs +1 -1
- package/dist/components/HoverCard/HoverCard.module.cjs.map +1 -1
- package/dist/components/HoverCard/HoverCard.module.js +4 -3
- package/dist/components/HoverCard/HoverCard.module.js.map +1 -1
- package/dist/components/Lightbox/Lightbox.cjs +1 -1
- package/dist/components/Lightbox/Lightbox.cjs.map +1 -1
- package/dist/components/Lightbox/Lightbox.js +86 -89
- package/dist/components/Lightbox/Lightbox.js.map +1 -1
- package/dist/components/Menubar/Menubar.cjs +1 -1
- package/dist/components/Menubar/Menubar.cjs.map +1 -1
- package/dist/components/Menubar/Menubar.js +86 -65
- package/dist/components/Menubar/Menubar.js.map +1 -1
- package/dist/components/Menubar/Menubar.module.cjs +1 -1
- package/dist/components/Menubar/Menubar.module.cjs.map +1 -1
- package/dist/components/Menubar/Menubar.module.js +4 -3
- package/dist/components/Menubar/Menubar.module.js.map +1 -1
- package/dist/components/Modal/Modal.cjs +1 -1
- package/dist/components/Modal/Modal.cjs.map +1 -1
- package/dist/components/Modal/Modal.js +44 -51
- package/dist/components/Modal/Modal.js.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.cjs +1 -1
- package/dist/components/MultiSelect/MultiSelect.cjs.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.js +113 -96
- package/dist/components/MultiSelect/MultiSelect.js.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.module.cjs +1 -1
- package/dist/components/MultiSelect/MultiSelect.module.cjs.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.module.js +4 -3
- package/dist/components/MultiSelect/MultiSelect.module.js.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js +83 -61
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.module.cjs +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.module.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.module.js +4 -3
- package/dist/components/NavigationMenu/NavigationMenu.module.js.map +1 -1
- package/dist/components/Popover/Popover.cjs +1 -1
- package/dist/components/Popover/Popover.cjs.map +1 -1
- package/dist/components/Popover/Popover.js +57 -36
- package/dist/components/Popover/Popover.js.map +1 -1
- package/dist/components/Popover/Popover.module.cjs +1 -1
- package/dist/components/Popover/Popover.module.cjs.map +1 -1
- package/dist/components/Popover/Popover.module.js +7 -6
- package/dist/components/Popover/Popover.module.js.map +1 -1
- package/dist/components/Portal/anchor-position.cjs +2 -0
- package/dist/components/Portal/anchor-position.cjs.map +1 -0
- package/dist/components/Portal/anchor-position.js +99 -0
- package/dist/components/Portal/anchor-position.js.map +1 -0
- package/dist/components/Portal/escape-layer.cjs +2 -0
- package/dist/components/Portal/escape-layer.cjs.map +1 -0
- package/dist/components/Portal/escape-layer.js +30 -0
- package/dist/components/Portal/escape-layer.js.map +1 -0
- package/dist/components/Portal/tab-bridge.cjs +2 -0
- package/dist/components/Portal/tab-bridge.cjs.map +1 -0
- package/dist/components/Portal/tab-bridge.js +39 -0
- package/dist/components/Portal/tab-bridge.js.map +1 -0
- package/dist/components/Tooltip/Tooltip.cjs +1 -1
- package/dist/components/Tooltip/Tooltip.cjs.map +1 -1
- package/dist/components/Tooltip/Tooltip.js +43 -29
- package/dist/components/Tooltip/Tooltip.js.map +1 -1
- package/dist/components/Tooltip/Tooltip.module.cjs +1 -1
- package/dist/components/Tooltip/Tooltip.module.cjs.map +1 -1
- package/dist/components/Tooltip/Tooltip.module.js +4 -3
- package/dist/components/Tooltip/Tooltip.module.js.map +1 -1
- package/dist/hooks/use-focus-trap.cjs +1 -1
- package/dist/hooks/use-focus-trap.cjs.map +1 -1
- package/dist/hooks/use-focus-trap.js +2 -2
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-sortable.cjs +1 -1
- package/dist/hooks/use-sortable.cjs.map +1 -1
- package/dist/hooks/use-sortable.js +3 -3
- package/dist/hooks/use-sortable.js.map +1 -1
- package/dist/http/describe-api-error.cjs +1 -1
- package/dist/http/describe-api-error.cjs.map +1 -1
- package/dist/http/describe-api-error.js +4 -2
- package/dist/http/describe-api-error.js.map +1 -1
- package/dist/styles/actions.css +3 -0
- package/dist/styles/advanced.css +1 -0
- package/dist/styles/component/Combobox.css +1 -0
- package/dist/styles/component/DropdownMenu.css +1 -0
- package/dist/styles/component/HoverCard.css +1 -0
- package/dist/styles/component/Menubar.css +1 -0
- package/dist/styles/component/MultiSelect.css +1 -0
- package/dist/styles/component/NavigationMenu.css +1 -0
- package/dist/styles/component/Popover.css +1 -0
- package/dist/styles/component/Tooltip.css +1 -0
- package/dist/styles/forms.css +2 -0
- package/dist/styles/navigation.css +2 -0
- package/dist/styles.css +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +147 -13
- package/dist/tempest-react-sdk.js +134 -134
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../utils/cn.cjs"),t=require("./DropdownMenu.module.cjs");let
|
|
1
|
+
const e=require("../../utils/cn.cjs"),t=require("../Portal/Portal.cjs"),n=require("../Portal/anchor-position.cjs"),r=require("./DropdownMenu.module.cjs");let i=require("react"),a=require("react/jsx-runtime");function o(e){switch(e){case`bottom-end`:return r.default.bottomEnd;case`top-start`:return r.default.topStart;case`top-end`:return r.default.topEnd;default:return r.default.bottomStart}}var s={"bottom-start":[`bottom`,`start`],"bottom-end":[`bottom`,`end`],"top-start":[`top`,`start`],"top-end":[`top`,`end`]},c=4;function l(e){return(e.type===`item`||e.type===`checkbox`)&&!e.disabled}function u({trigger:u,items:d,placement:f=`bottom-start`,portal:p=!0,className:m}){let[h,g]=(0,i.useState)(!1),[_,v]=(0,i.useState)(-1),y=(0,i.useId)(),[b,x]=(0,i.useState)(null),[S,C]=(0,i.useState)(null),w=(0,i.useRef)([]),T=(0,i.useRef)(null),E=d.map((e,t)=>l(e)?t:-1).filter(e=>e!==-1),D=(0,i.useCallback)(()=>{b?.querySelector(`[aria-haspopup='menu']`)?.focus()},[b]),O=(0,i.useCallback)(e=>{g(!1),v(-1),e&&D()},[D]),k=(0,i.useCallback)(e=>{v(e),w.current[e]?.focus()},[]),A=e=>{if(E.length===0)return;let t=(E.indexOf(_)+e+E.length)%E.length;k(E[t]??-1)},[j,M]=s[f],N=n.useAnchorPosition({anchor:b,floating:S,side:j,align:M,offset:c,enabled:p&&h});(0,i.useEffect)(()=>{if(!h||!S||T.current===null)return;let e=T.current===`last`?E.at(-1):E[0];T.current=null,e!==void 0&&k(e)},[h,S,E,k]),(0,i.useEffect)(()=>{if(!h)return;let e=e=>{let t=e.target;b?.contains(t)||S?.contains(t)||O(!1)};return window.addEventListener(`mousedown`,e),()=>window.removeEventListener(`mousedown`,e)},[h,O,b,S]);let P=e=>{T.current=e,g(!0)},F=e=>{if(u.props.onKeyDown?.(e),!e.defaultPrevented){if(e.key===`ArrowDown`||!h&&(e.key===`Enter`||e.key===` `)){e.preventDefault(),P(`first`);return}e.key===`ArrowUp`&&(e.preventDefault(),P(`last`))}},I=e=>{switch(e.key){case`ArrowDown`:e.preventDefault(),A(1);break;case`ArrowUp`:e.preventDefault(),A(-1);break;case`Home`:e.preventDefault(),E[0]!==void 0&&k(E[0]);break;case`End`:{e.preventDefault();let t=E.at(-1);t!==void 0&&k(t);break}case`Escape`:e.preventDefault(),O(!0);break;case`Tab`:O(!0)}},L=e=>{if(u.props.onClick?.(e),!e.defaultPrevented){if(h){O(!1);return}P(`first`)}},R={...u,props:{...u.props,onClick:L,onKeyDown:F,"aria-expanded":h,"aria-controls":y,"aria-haspopup":`menu`}},z=e=>{e.onSelect(),e.type!==`checkbox`&&O(!0)},B=(0,a.jsx)(`ul`,{ref:C,id:y,role:`menu`,className:e.cn(r.default.menu,p?r.default.portalled:o(f),m),style:N,onKeyDown:I,children:d.map((t,n)=>{if(t.type===`separator`)return(0,a.jsx)(`li`,{role:`separator`,className:r.default.separator,"aria-hidden":!0},t.id);if(t.type===`label`)return(0,a.jsx)(`li`,{role:`presentation`,className:r.default.label,children:t.label},t.id);let i=t.type===`checkbox`;return(0,a.jsx)(`li`,{role:`none`,children:(0,a.jsxs)(`button`,{ref:e=>{w.current[n]=e},type:`button`,role:i?`menuitemcheckbox`:`menuitem`,"aria-checked":i?t.checked:void 0,tabIndex:_===n?0:-1,className:e.cn(r.default.item,t.type===`item`&&t.danger&&r.default.danger,_===n&&r.default.active),disabled:t.disabled,onClick:()=>z(t),onMouseEnter:()=>v(n),children:[t.icon&&(0,a.jsx)(`span`,{"aria-hidden":!0,children:t.icon}),t.label]})},t.id)})});return(0,a.jsxs)(`span`,{ref:x,className:r.default.root,children:[R,h&&(p?(0,a.jsx)(t.Portal,{children:B}):B)]})}exports.DropdownMenu=u;
|
|
2
2
|
//# sourceMappingURL=DropdownMenu.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenu.cjs","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — the body owns placement, outside-\n * click, and the whole APG menu-button keyboard model, which has to see the entire\n * entry list to know where the next stop is. Splitting it would move the focus\n * bookkeeping away from the list it indexes into.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./DropdownMenu.module.css\";\n\nexport type DropdownMenuPlacement = \"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\";\n\nexport type DropdownMenuEntry =\n | {\n type: \"item\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n danger?: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | {\n /**\n * An entry that carries on/off state.\n *\n * Rendered as `role=\"menuitemcheckbox\"` with `aria-checked`, which is how\n * a screen reader announces \"checked\" instead of leaving the state\n * invisible. Selecting it does **not** close the menu: toggling two\n * settings in a row is the ordinary case, and closing after the first\n * would make the second a second trip.\n */\n type: \"checkbox\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n checked: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | { type: \"separator\"; id: string }\n | { type: \"label\"; id: string; label: ReactNode };\n\n/** The entry kinds a user can move focus to and activate. */\ntype SelectableEntry = Extract<DropdownMenuEntry, { type: \"item\" | \"checkbox\" }>;\n\nexport interface DropdownMenuProps {\n trigger: ReactElement<{\n onClick?: (e: React.MouseEvent) => void;\n onKeyDown?: (e: ReactKeyboardEvent) => void;\n \"aria-expanded\"?: boolean;\n \"aria-controls\"?: string;\n \"aria-haspopup\"?: boolean | \"menu\";\n }>;\n items: DropdownMenuEntry[];\n placement?: DropdownMenuPlacement;\n className?: string;\n}\n\nfunction placementClass(placement: DropdownMenuPlacement): string {\n switch (placement) {\n case \"bottom-end\":\n return styles.bottomEnd;\n case \"top-start\":\n return styles.topStart;\n case \"top-end\":\n return styles.topEnd;\n case \"bottom-start\":\n default:\n return styles.bottomStart;\n }\n}\n\n/** Whether an entry can take focus — an item or checkbox that is not disabled. */\nfunction isSelectable(entry: DropdownMenuEntry): entry is SelectableEntry {\n return (entry.type === \"item\" || entry.type === \"checkbox\") && !entry.disabled;\n}\n\n/**\n * Dropdown menu — a list of actions anchored to a trigger, following the\n * [APG menu button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).\n *\n * `role=\"menu\"` is a promise about the keyboard, and this component keeps it:\n *\n * - `Enter`, `Space` or `ArrowDown` on the trigger opens and focuses the first\n * entry; `ArrowUp` opens and focuses the last.\n * - `ArrowUp` / `ArrowDown` move with wrap, `Home` / `End` jump to the ends.\n * - `Escape` closes and returns focus to the trigger, so the next `Tab` continues\n * from where the user was rather than from the top of the document.\n * - `Tab` closes the menu and lets the page's own tab order take over.\n * - Focus is managed: entries carry `tabIndex={-1}` and only the active one is\n * `0`, which is what stops `Tab` from walking the menu one entry at a time.\n *\n * Disabled entries, separators and labels are skipped by every movement.\n */\nexport function DropdownMenu({\n trigger,\n items,\n placement = \"bottom-start\",\n className,\n}: DropdownMenuProps) {\n const [open, setOpen] = useState(false);\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const rootRef = useRef<HTMLSpanElement>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const pendingFocus = useRef<\"first\" | \"last\" | null>(null);\n\n const selectable = items\n .map((entry, index) => (isSelectable(entry) ? index : -1))\n .filter((index) => index !== -1);\n\n /**\n * Focus the trigger again.\n *\n * Found through the `aria-haspopup` this component puts on it, rather than\n * through a ref: `ref` lives in different places in React 18 and 19, and a\n * consumer's custom trigger is under no obligation to forward one. The\n * attribute is on the element either way.\n *\n * @returns Nothing.\n */\n const focusTrigger = useCallback((): void => {\n rootRef.current?.querySelector<HTMLElement>(\"[aria-haspopup='menu']\")?.focus();\n }, []);\n\n const close = useCallback(\n (restoreFocus: boolean): void => {\n setOpen(false);\n setActiveIndex(-1);\n if (restoreFocus) focusTrigger();\n },\n [focusTrigger],\n );\n\n const focusIndex = useCallback((index: number): void => {\n setActiveIndex(index);\n itemRefs.current[index]?.focus();\n }, []);\n\n /**\n * Move focus to the entry `step` positions away, wrapping at both ends.\n *\n * Walks the selectable positions rather than the raw list, so separators,\n * labels and disabled entries are never a stop.\n */\n const move = (step: number): void => {\n if (selectable.length === 0) return;\n const current = selectable.indexOf(activeIndex);\n const next = (current + step + selectable.length) % selectable.length;\n focusIndex(selectable[next] ?? -1);\n };\n\n useEffect(() => {\n if (!open || pendingFocus.current === null) return;\n const target = pendingFocus.current === \"last\" ? selectable.at(-1) : selectable[0];\n pendingFocus.current = null;\n if (target !== undefined) focusIndex(target);\n }, [open, selectable, focusIndex]);\n\n useEffect(() => {\n if (!open) return;\n const onDown = (event: MouseEvent): void => {\n if (rootRef.current && !rootRef.current.contains(event.target as Node)) close(false);\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n }, [open, close]);\n\n const openWith = (edge: \"first\" | \"last\"): void => {\n pendingFocus.current = edge;\n setOpen(true);\n };\n\n const handleTriggerKeyDown = (event: ReactKeyboardEvent): void => {\n trigger.props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || (!open && (event.key === \"Enter\" || event.key === \" \"))) {\n event.preventDefault();\n openWith(\"first\");\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n openWith(\"last\");\n }\n };\n\n /**\n * Keyboard model of the open menu.\n *\n * Handled on the list rather than on `window`: focus is inside the menu by\n * then, so the keys arrive here by bubbling and cannot be pre-empted by an\n * unrelated global listener — which is how the arrows used to go missing in a\n * host application.\n */\n const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>): void => {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n move(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n move(-1);\n break;\n case \"Home\":\n event.preventDefault();\n if (selectable[0] !== undefined) focusIndex(selectable[0]);\n break;\n case \"End\": {\n event.preventDefault();\n const last = selectable.at(-1);\n if (last !== undefined) focusIndex(last);\n break;\n }\n case \"Escape\":\n event.preventDefault();\n close(true);\n break;\n case \"Tab\":\n close(false);\n break;\n default:\n break;\n }\n };\n\n const handleTriggerClick = (event: React.MouseEvent): void => {\n trigger.props.onClick?.(event);\n if (event.defaultPrevented) return;\n if (open) {\n close(false);\n return;\n }\n openWith(\"first\");\n };\n\n const triggerClone = {\n ...trigger,\n props: {\n ...trigger.props,\n onClick: handleTriggerClick,\n onKeyDown: handleTriggerKeyDown,\n \"aria-expanded\": open,\n \"aria-controls\": id,\n \"aria-haspopup\": \"menu\" as const,\n },\n } as ReactElement;\n\n const handleSelect = (entry: SelectableEntry): void => {\n entry.onSelect();\n if (entry.type === \"checkbox\") return;\n close(true);\n };\n\n return (\n <span ref={rootRef} className={styles.root}>\n {triggerClone}\n {open && (\n <ul\n id={id}\n role=\"menu\"\n className={cn(styles.menu, placementClass(placement), className)}\n onKeyDown={handleMenuKeyDown}\n >\n {items.map((entry, index) => {\n if (entry.type === \"separator\") {\n return (\n <li\n key={entry.id}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n if (entry.type === \"label\") {\n return (\n <li key={entry.id} role=\"presentation\" className={styles.label}>\n {entry.label}\n </li>\n );\n }\n const checkbox = entry.type === \"checkbox\";\n return (\n <li key={entry.id} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role={checkbox ? \"menuitemcheckbox\" : \"menuitem\"}\n aria-checked={checkbox ? entry.checked : undefined}\n tabIndex={activeIndex === index ? 0 : -1}\n className={cn(\n styles.item,\n entry.type === \"item\" && entry.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={entry.disabled}\n onClick={() => handleSelect(entry)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {entry.icon && <span aria-hidden>{entry.icon}</span>}\n {entry.label}\n </button>\n </li>\n );\n })}\n </ul>\n )}\n </span>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"DropdownMenu.cjs","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — the body owns placement, outside-\n * click, and the whole APG menu-button keyboard model, which has to see the entire\n * entry list to know where the next stop is. Splitting it would move the focus\n * bookkeeping away from the list it indexes into.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { Portal } from \"@/components/Portal\";\nimport { useAnchorPosition } from \"@/components/Portal/anchor-position\";\nimport type { AnchorAlign, AnchorSide } from \"@/components/Portal/anchor-position\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./DropdownMenu.module.css\";\n\nexport type DropdownMenuPlacement = \"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\";\n\nexport type DropdownMenuEntry =\n | {\n type: \"item\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n danger?: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | {\n /**\n * An entry that carries on/off state.\n *\n * Rendered as `role=\"menuitemcheckbox\"` with `aria-checked`, which is how\n * a screen reader announces \"checked\" instead of leaving the state\n * invisible. Selecting it does **not** close the menu: toggling two\n * settings in a row is the ordinary case, and closing after the first\n * would make the second a second trip.\n */\n type: \"checkbox\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n checked: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | { type: \"separator\"; id: string }\n | { type: \"label\"; id: string; label: ReactNode };\n\n/** The entry kinds a user can move focus to and activate. */\ntype SelectableEntry = Extract<DropdownMenuEntry, { type: \"item\" | \"checkbox\" }>;\n\nexport interface DropdownMenuProps {\n trigger: ReactElement<{\n onClick?: (e: React.MouseEvent) => void;\n onKeyDown?: (e: ReactKeyboardEvent) => void;\n \"aria-expanded\"?: boolean;\n \"aria-controls\"?: string;\n \"aria-haspopup\"?: boolean | \"menu\";\n }>;\n items: DropdownMenuEntry[];\n placement?: DropdownMenuPlacement;\n /**\n * Render the menu in a portal, positioned against the trigger. Default `true`.\n *\n * Without it the menu is `position: absolute` inside the trigger's wrapper,\n * and any ancestor with `overflow` other than `visible` clips it. The ordinary\n * victim is the action column of a `DataTable`: its wrapper scrolls\n * horizontally, `overflow-x: auto` forces `overflow-y` to `auto` too, and the\n * menu of the last row showed one of three entries (measured in Chrome at\n * 1440 px). In a portal the menu escapes the clip, flips above the trigger\n * when there is no room below, and follows the trigger on scroll and resize.\n *\n * The portalled menu stacks at `--tempest-z-popover`, above\n * `--tempest-z-modal`, so a menu opened inside a `Modal` still paints on top.\n * `false` keeps the in-flow menu, for a container that must own the menu's\n * stacking or clipping on purpose.\n */\n portal?: boolean;\n className?: string;\n}\n\nfunction placementClass(placement: DropdownMenuPlacement): string {\n switch (placement) {\n case \"bottom-end\":\n return styles.bottomEnd;\n case \"top-start\":\n return styles.topStart;\n case \"top-end\":\n return styles.topEnd;\n case \"bottom-start\":\n default:\n return styles.bottomStart;\n }\n}\n\nconst PLACEMENT_GEOMETRY: Record<DropdownMenuPlacement, [AnchorSide, AnchorAlign]> = {\n \"bottom-start\": [\"bottom\", \"start\"],\n \"bottom-end\": [\"bottom\", \"end\"],\n \"top-start\": [\"top\", \"start\"],\n \"top-end\": [\"top\", \"end\"],\n};\n\n/** Gap between trigger and menu, in px — the in-flow CSS uses the same 4 px. */\nconst MENU_OFFSET = 4;\n\n/** Whether an entry can take focus — an item or checkbox that is not disabled. */\nfunction isSelectable(entry: DropdownMenuEntry): entry is SelectableEntry {\n return (entry.type === \"item\" || entry.type === \"checkbox\") && !entry.disabled;\n}\n\n/**\n * Dropdown menu — a list of actions anchored to a trigger, following the\n * [APG menu button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).\n *\n * `role=\"menu\"` is a promise about the keyboard, and this component keeps it:\n *\n * - `Enter`, `Space` or `ArrowDown` on the trigger opens and focuses the first\n * entry; `ArrowUp` opens and focuses the last.\n * - `ArrowUp` / `ArrowDown` move with wrap, `Home` / `End` jump to the ends.\n * - `Escape` closes and returns focus to the trigger, so the next `Tab` continues\n * from where the user was rather than from the top of the document.\n * - `Tab` closes the menu and lets the page's own tab order take over, counted\n * from the trigger: focus goes back to the trigger synchronously and the key's\n * default action then moves on from there. Without that, a portalled menu —\n * the last thing in `body` — would hand `Tab` to whatever follows it, which\n * inside a `Modal` is outside the dialog.\n * - Focus is managed: entries carry `tabIndex={-1}` and only the active one is\n * `0`, which is what stops `Tab` from walking the menu one entry at a time.\n *\n * Disabled entries, separators and labels are skipped by every movement.\n */\nexport function DropdownMenu({\n trigger,\n items,\n placement = \"bottom-start\",\n portal = true,\n className,\n}: DropdownMenuProps) {\n const [open, setOpen] = useState(false);\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [rootNode, setRootNode] = useState<HTMLSpanElement | null>(null);\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const pendingFocus = useRef<\"first\" | \"last\" | null>(null);\n\n const selectable = items\n .map((entry, index) => (isSelectable(entry) ? index : -1))\n .filter((index) => index !== -1);\n\n /**\n * Focus the trigger again.\n *\n * Found through the `aria-haspopup` this component puts on it, rather than\n * through a ref: `ref` lives in different places in React 18 and 19, and a\n * consumer's custom trigger is under no obligation to forward one. The\n * attribute is on the element either way.\n *\n * @returns Nothing.\n */\n const focusTrigger = useCallback((): void => {\n rootNode?.querySelector<HTMLElement>(\"[aria-haspopup='menu']\")?.focus();\n }, [rootNode]);\n\n const close = useCallback(\n (restoreFocus: boolean): void => {\n setOpen(false);\n setActiveIndex(-1);\n if (restoreFocus) focusTrigger();\n },\n [focusTrigger],\n );\n\n const focusIndex = useCallback((index: number): void => {\n setActiveIndex(index);\n itemRefs.current[index]?.focus();\n }, []);\n\n /**\n * Move focus to the entry `step` positions away, wrapping at both ends.\n *\n * Walks the selectable positions rather than the raw list, so separators,\n * labels and disabled entries are never a stop.\n */\n const move = (step: number): void => {\n if (selectable.length === 0) return;\n const current = selectable.indexOf(activeIndex);\n const next = (current + step + selectable.length) % selectable.length;\n focusIndex(selectable[next] ?? -1);\n };\n\n const [side, align] = PLACEMENT_GEOMETRY[placement];\n const floatingStyle = useAnchorPosition({\n anchor: rootNode,\n floating: menuNode,\n side,\n align,\n offset: MENU_OFFSET,\n enabled: portal && open,\n });\n\n /**\n * Focus the entry the opening key asked for.\n *\n * Keyed off the menu node rather than off `open` alone: in a portal the menu\n * mounts one commit after `open` flips, and an effect that ran on `open` would\n * find no entries to focus.\n */\n useEffect(() => {\n if (!open || !menuNode || pendingFocus.current === null) return;\n const target = pendingFocus.current === \"last\" ? selectable.at(-1) : selectable[0];\n pendingFocus.current = null;\n if (target !== undefined) focusIndex(target);\n }, [open, menuNode, selectable, focusIndex]);\n\n /**\n * Close on a press outside both the trigger and the menu.\n *\n * The menu is checked separately because in a portal it is not inside the\n * trigger's wrapper: testing the wrapper alone would treat a press on an entry\n * as outside and close the menu before the entry's click lands.\n */\n useEffect(() => {\n if (!open) return;\n const onDown = (event: MouseEvent): void => {\n const target = event.target as Node;\n if (rootNode?.contains(target) || menuNode?.contains(target)) return;\n close(false);\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n }, [open, close, rootNode, menuNode]);\n\n const openWith = (edge: \"first\" | \"last\"): void => {\n pendingFocus.current = edge;\n setOpen(true);\n };\n\n const handleTriggerKeyDown = (event: ReactKeyboardEvent): void => {\n trigger.props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || (!open && (event.key === \"Enter\" || event.key === \" \"))) {\n event.preventDefault();\n openWith(\"first\");\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n openWith(\"last\");\n }\n };\n\n /**\n * Keyboard model of the open menu.\n *\n * Handled on the list rather than on `window`: focus is inside the menu by\n * then, so the keys arrive here by bubbling and cannot be pre-empted by an\n * unrelated global listener — which is how the arrows used to go missing in a\n * host application.\n */\n const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>): void => {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n move(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n move(-1);\n break;\n case \"Home\":\n event.preventDefault();\n if (selectable[0] !== undefined) focusIndex(selectable[0]);\n break;\n case \"End\": {\n event.preventDefault();\n const last = selectable.at(-1);\n if (last !== undefined) focusIndex(last);\n break;\n }\n case \"Escape\":\n event.preventDefault();\n close(true);\n break;\n case \"Tab\":\n close(true);\n break;\n default:\n break;\n }\n };\n\n const handleTriggerClick = (event: React.MouseEvent): void => {\n trigger.props.onClick?.(event);\n if (event.defaultPrevented) return;\n if (open) {\n close(false);\n return;\n }\n openWith(\"first\");\n };\n\n const triggerClone = {\n ...trigger,\n props: {\n ...trigger.props,\n onClick: handleTriggerClick,\n onKeyDown: handleTriggerKeyDown,\n \"aria-expanded\": open,\n \"aria-controls\": id,\n \"aria-haspopup\": \"menu\" as const,\n },\n } as ReactElement;\n\n const handleSelect = (entry: SelectableEntry): void => {\n entry.onSelect();\n if (entry.type === \"checkbox\") return;\n close(true);\n };\n\n const menu = (\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n className={cn(\n styles.menu,\n portal ? styles.portalled : placementClass(placement),\n className,\n )}\n style={floatingStyle}\n onKeyDown={handleMenuKeyDown}\n >\n {items.map((entry, index) => {\n if (entry.type === \"separator\") {\n return (\n <li\n key={entry.id}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n if (entry.type === \"label\") {\n return (\n <li key={entry.id} role=\"presentation\" className={styles.label}>\n {entry.label}\n </li>\n );\n }\n const checkbox = entry.type === \"checkbox\";\n return (\n <li key={entry.id} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role={checkbox ? \"menuitemcheckbox\" : \"menuitem\"}\n aria-checked={checkbox ? entry.checked : undefined}\n tabIndex={activeIndex === index ? 0 : -1}\n className={cn(\n styles.item,\n entry.type === \"item\" && entry.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={entry.disabled}\n onClick={() => handleSelect(entry)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {entry.icon && <span aria-hidden>{entry.icon}</span>}\n {entry.label}\n </button>\n </li>\n );\n })}\n </ul>\n );\n\n return (\n <span ref={setRootNode} className={styles.root}>\n {triggerClone}\n {open && (portal ? <Portal>{menu}</Portal> : menu)}\n </span>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,144 +1,168 @@
|
|
|
1
1
|
import { cn as e } from "../../utils/cn.js";
|
|
2
|
-
import t from "
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { Portal as t } from "../Portal/Portal.js";
|
|
3
|
+
import { useAnchorPosition as n } from "../Portal/anchor-position.js";
|
|
4
|
+
import r from "./DropdownMenu.module.js";
|
|
5
|
+
import { useCallback as i, useEffect as a, useId as o, useRef as s, useState as c } from "react";
|
|
6
|
+
import { jsx as l, jsxs as u } from "react/jsx-runtime";
|
|
5
7
|
//#region src/components/DropdownMenu/DropdownMenu.tsx
|
|
6
|
-
function
|
|
8
|
+
function d(e) {
|
|
7
9
|
switch (e) {
|
|
8
|
-
case "bottom-end": return
|
|
9
|
-
case "top-start": return
|
|
10
|
-
case "top-end": return
|
|
11
|
-
default: return
|
|
10
|
+
case "bottom-end": return r.bottomEnd;
|
|
11
|
+
case "top-start": return r.topStart;
|
|
12
|
+
case "top-end": return r.topEnd;
|
|
13
|
+
default: return r.bottomStart;
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
|
-
|
|
16
|
+
var f = {
|
|
17
|
+
"bottom-start": ["bottom", "start"],
|
|
18
|
+
"bottom-end": ["bottom", "end"],
|
|
19
|
+
"top-start": ["top", "start"],
|
|
20
|
+
"top-end": ["top", "end"]
|
|
21
|
+
}, p = 4;
|
|
22
|
+
function m(e) {
|
|
15
23
|
return (e.type === "item" || e.type === "checkbox") && !e.disabled;
|
|
16
24
|
}
|
|
17
|
-
function
|
|
18
|
-
let [
|
|
19
|
-
|
|
20
|
-
}, []),
|
|
21
|
-
|
|
22
|
-
}, [
|
|
23
|
-
|
|
24
|
-
}, []),
|
|
25
|
-
if (
|
|
26
|
-
let t = (
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
function h({ trigger: h, items: g, placement: _ = "bottom-start", portal: v = !0, className: y }) {
|
|
26
|
+
let [b, x] = c(!1), [S, C] = c(-1), w = o(), [T, E] = c(null), [D, O] = c(null), k = s([]), A = s(null), j = g.map((e, t) => m(e) ? t : -1).filter((e) => e !== -1), M = i(() => {
|
|
27
|
+
T?.querySelector("[aria-haspopup='menu']")?.focus();
|
|
28
|
+
}, [T]), N = i((e) => {
|
|
29
|
+
x(!1), C(-1), e && M();
|
|
30
|
+
}, [M]), P = i((e) => {
|
|
31
|
+
C(e), k.current[e]?.focus();
|
|
32
|
+
}, []), F = (e) => {
|
|
33
|
+
if (j.length === 0) return;
|
|
34
|
+
let t = (j.indexOf(S) + e + j.length) % j.length;
|
|
35
|
+
P(j[t] ?? -1);
|
|
36
|
+
}, [I, L] = f[_], R = n({
|
|
37
|
+
anchor: T,
|
|
38
|
+
floating: D,
|
|
39
|
+
side: I,
|
|
40
|
+
align: L,
|
|
41
|
+
offset: p,
|
|
42
|
+
enabled: v && b
|
|
43
|
+
});
|
|
44
|
+
a(() => {
|
|
45
|
+
if (!b || !D || A.current === null) return;
|
|
46
|
+
let e = A.current === "last" ? j.at(-1) : j[0];
|
|
47
|
+
A.current = null, e !== void 0 && P(e);
|
|
33
48
|
}, [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
b,
|
|
50
|
+
D,
|
|
51
|
+
j,
|
|
52
|
+
P
|
|
53
|
+
]), a(() => {
|
|
54
|
+
if (!b) return;
|
|
39
55
|
let e = (e) => {
|
|
40
|
-
|
|
56
|
+
let t = e.target;
|
|
57
|
+
T?.contains(t) || D?.contains(t) || N(!1);
|
|
41
58
|
};
|
|
42
59
|
return window.addEventListener("mousedown", e), () => window.removeEventListener("mousedown", e);
|
|
43
|
-
}, [
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
}, [
|
|
61
|
+
b,
|
|
62
|
+
N,
|
|
63
|
+
T,
|
|
64
|
+
D
|
|
65
|
+
]);
|
|
66
|
+
let z = (e) => {
|
|
67
|
+
A.current = e, x(!0);
|
|
68
|
+
}, B = (e) => {
|
|
69
|
+
if (h.props.onKeyDown?.(e), !e.defaultPrevented) {
|
|
70
|
+
if (e.key === "ArrowDown" || !b && (e.key === "Enter" || e.key === " ")) {
|
|
71
|
+
e.preventDefault(), z("first");
|
|
50
72
|
return;
|
|
51
73
|
}
|
|
52
|
-
e.key === "ArrowUp" && (e.preventDefault(),
|
|
74
|
+
e.key === "ArrowUp" && (e.preventDefault(), z("last"));
|
|
53
75
|
}
|
|
54
|
-
},
|
|
76
|
+
}, V = (e) => {
|
|
55
77
|
switch (e.key) {
|
|
56
78
|
case "ArrowDown":
|
|
57
|
-
e.preventDefault(),
|
|
79
|
+
e.preventDefault(), F(1);
|
|
58
80
|
break;
|
|
59
81
|
case "ArrowUp":
|
|
60
|
-
e.preventDefault(),
|
|
82
|
+
e.preventDefault(), F(-1);
|
|
61
83
|
break;
|
|
62
84
|
case "Home":
|
|
63
|
-
e.preventDefault(),
|
|
85
|
+
e.preventDefault(), j[0] !== void 0 && P(j[0]);
|
|
64
86
|
break;
|
|
65
87
|
case "End": {
|
|
66
88
|
e.preventDefault();
|
|
67
|
-
let t =
|
|
68
|
-
t !== void 0 &&
|
|
89
|
+
let t = j.at(-1);
|
|
90
|
+
t !== void 0 && P(t);
|
|
69
91
|
break;
|
|
70
92
|
}
|
|
71
93
|
case "Escape":
|
|
72
|
-
e.preventDefault(),
|
|
94
|
+
e.preventDefault(), N(!0);
|
|
73
95
|
break;
|
|
74
|
-
case "Tab":
|
|
96
|
+
case "Tab": N(!0);
|
|
75
97
|
}
|
|
76
|
-
},
|
|
77
|
-
if (
|
|
78
|
-
if (
|
|
79
|
-
|
|
98
|
+
}, H = (e) => {
|
|
99
|
+
if (h.props.onClick?.(e), !e.defaultPrevented) {
|
|
100
|
+
if (b) {
|
|
101
|
+
N(!1);
|
|
80
102
|
return;
|
|
81
103
|
}
|
|
82
|
-
|
|
104
|
+
z("first");
|
|
83
105
|
}
|
|
84
|
-
},
|
|
85
|
-
...
|
|
106
|
+
}, U = {
|
|
107
|
+
...h,
|
|
86
108
|
props: {
|
|
87
|
-
...
|
|
88
|
-
onClick:
|
|
89
|
-
onKeyDown:
|
|
90
|
-
"aria-expanded":
|
|
91
|
-
"aria-controls":
|
|
109
|
+
...h.props,
|
|
110
|
+
onClick: H,
|
|
111
|
+
onKeyDown: B,
|
|
112
|
+
"aria-expanded": b,
|
|
113
|
+
"aria-controls": w,
|
|
92
114
|
"aria-haspopup": "menu"
|
|
93
115
|
}
|
|
94
|
-
},
|
|
95
|
-
e.onSelect(), e.type !== "checkbox" &&
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
children:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
116
|
+
}, W = (e) => {
|
|
117
|
+
e.onSelect(), e.type !== "checkbox" && N(!0);
|
|
118
|
+
}, G = /* @__PURE__ */ l("ul", {
|
|
119
|
+
ref: O,
|
|
120
|
+
id: w,
|
|
121
|
+
role: "menu",
|
|
122
|
+
className: e(r.menu, v ? r.portalled : d(_), y),
|
|
123
|
+
style: R,
|
|
124
|
+
onKeyDown: V,
|
|
125
|
+
children: g.map((t, n) => {
|
|
126
|
+
if (t.type === "separator") return /* @__PURE__ */ l("li", {
|
|
127
|
+
role: "separator",
|
|
128
|
+
className: r.separator,
|
|
129
|
+
"aria-hidden": !0
|
|
130
|
+
}, t.id);
|
|
131
|
+
if (t.type === "label") return /* @__PURE__ */ l("li", {
|
|
132
|
+
role: "presentation",
|
|
133
|
+
className: r.label,
|
|
134
|
+
children: t.label
|
|
135
|
+
}, t.id);
|
|
136
|
+
let i = t.type === "checkbox";
|
|
137
|
+
return /* @__PURE__ */ l("li", {
|
|
138
|
+
role: "none",
|
|
139
|
+
children: /* @__PURE__ */ u("button", {
|
|
140
|
+
ref: (e) => {
|
|
141
|
+
k.current[n] = e;
|
|
142
|
+
},
|
|
143
|
+
type: "button",
|
|
144
|
+
role: i ? "menuitemcheckbox" : "menuitem",
|
|
145
|
+
"aria-checked": i ? t.checked : void 0,
|
|
146
|
+
tabIndex: S === n ? 0 : -1,
|
|
147
|
+
className: e(r.item, t.type === "item" && t.danger && r.danger, S === n && r.active),
|
|
148
|
+
disabled: t.disabled,
|
|
149
|
+
onClick: () => W(t),
|
|
150
|
+
onMouseEnter: () => C(n),
|
|
151
|
+
children: [t.icon && /* @__PURE__ */ l("span", {
|
|
152
|
+
"aria-hidden": !0,
|
|
153
|
+
children: t.icon
|
|
154
|
+
}), t.label]
|
|
155
|
+
})
|
|
156
|
+
}, t.id);
|
|
157
|
+
})
|
|
158
|
+
});
|
|
159
|
+
return /* @__PURE__ */ u("span", {
|
|
160
|
+
ref: E,
|
|
161
|
+
className: r.root,
|
|
162
|
+
children: [U, b && (v ? /* @__PURE__ */ l(t, { children: G }) : G)]
|
|
139
163
|
});
|
|
140
164
|
}
|
|
141
165
|
//#endregion
|
|
142
|
-
export {
|
|
166
|
+
export { h as DropdownMenu };
|
|
143
167
|
|
|
144
168
|
//# sourceMappingURL=DropdownMenu.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenu.js","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — the body owns placement, outside-\n * click, and the whole APG menu-button keyboard model, which has to see the entire\n * entry list to know where the next stop is. Splitting it would move the focus\n * bookkeeping away from the list it indexes into.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./DropdownMenu.module.css\";\n\nexport type DropdownMenuPlacement = \"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\";\n\nexport type DropdownMenuEntry =\n | {\n type: \"item\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n danger?: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | {\n /**\n * An entry that carries on/off state.\n *\n * Rendered as `role=\"menuitemcheckbox\"` with `aria-checked`, which is how\n * a screen reader announces \"checked\" instead of leaving the state\n * invisible. Selecting it does **not** close the menu: toggling two\n * settings in a row is the ordinary case, and closing after the first\n * would make the second a second trip.\n */\n type: \"checkbox\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n checked: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | { type: \"separator\"; id: string }\n | { type: \"label\"; id: string; label: ReactNode };\n\n/** The entry kinds a user can move focus to and activate. */\ntype SelectableEntry = Extract<DropdownMenuEntry, { type: \"item\" | \"checkbox\" }>;\n\nexport interface DropdownMenuProps {\n trigger: ReactElement<{\n onClick?: (e: React.MouseEvent) => void;\n onKeyDown?: (e: ReactKeyboardEvent) => void;\n \"aria-expanded\"?: boolean;\n \"aria-controls\"?: string;\n \"aria-haspopup\"?: boolean | \"menu\";\n }>;\n items: DropdownMenuEntry[];\n placement?: DropdownMenuPlacement;\n className?: string;\n}\n\nfunction placementClass(placement: DropdownMenuPlacement): string {\n switch (placement) {\n case \"bottom-end\":\n return styles.bottomEnd;\n case \"top-start\":\n return styles.topStart;\n case \"top-end\":\n return styles.topEnd;\n case \"bottom-start\":\n default:\n return styles.bottomStart;\n }\n}\n\n/** Whether an entry can take focus — an item or checkbox that is not disabled. */\nfunction isSelectable(entry: DropdownMenuEntry): entry is SelectableEntry {\n return (entry.type === \"item\" || entry.type === \"checkbox\") && !entry.disabled;\n}\n\n/**\n * Dropdown menu — a list of actions anchored to a trigger, following the\n * [APG menu button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).\n *\n * `role=\"menu\"` is a promise about the keyboard, and this component keeps it:\n *\n * - `Enter`, `Space` or `ArrowDown` on the trigger opens and focuses the first\n * entry; `ArrowUp` opens and focuses the last.\n * - `ArrowUp` / `ArrowDown` move with wrap, `Home` / `End` jump to the ends.\n * - `Escape` closes and returns focus to the trigger, so the next `Tab` continues\n * from where the user was rather than from the top of the document.\n * - `Tab` closes the menu and lets the page's own tab order take over.\n * - Focus is managed: entries carry `tabIndex={-1}` and only the active one is\n * `0`, which is what stops `Tab` from walking the menu one entry at a time.\n *\n * Disabled entries, separators and labels are skipped by every movement.\n */\nexport function DropdownMenu({\n trigger,\n items,\n placement = \"bottom-start\",\n className,\n}: DropdownMenuProps) {\n const [open, setOpen] = useState(false);\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const rootRef = useRef<HTMLSpanElement>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const pendingFocus = useRef<\"first\" | \"last\" | null>(null);\n\n const selectable = items\n .map((entry, index) => (isSelectable(entry) ? index : -1))\n .filter((index) => index !== -1);\n\n /**\n * Focus the trigger again.\n *\n * Found through the `aria-haspopup` this component puts on it, rather than\n * through a ref: `ref` lives in different places in React 18 and 19, and a\n * consumer's custom trigger is under no obligation to forward one. The\n * attribute is on the element either way.\n *\n * @returns Nothing.\n */\n const focusTrigger = useCallback((): void => {\n rootRef.current?.querySelector<HTMLElement>(\"[aria-haspopup='menu']\")?.focus();\n }, []);\n\n const close = useCallback(\n (restoreFocus: boolean): void => {\n setOpen(false);\n setActiveIndex(-1);\n if (restoreFocus) focusTrigger();\n },\n [focusTrigger],\n );\n\n const focusIndex = useCallback((index: number): void => {\n setActiveIndex(index);\n itemRefs.current[index]?.focus();\n }, []);\n\n /**\n * Move focus to the entry `step` positions away, wrapping at both ends.\n *\n * Walks the selectable positions rather than the raw list, so separators,\n * labels and disabled entries are never a stop.\n */\n const move = (step: number): void => {\n if (selectable.length === 0) return;\n const current = selectable.indexOf(activeIndex);\n const next = (current + step + selectable.length) % selectable.length;\n focusIndex(selectable[next] ?? -1);\n };\n\n useEffect(() => {\n if (!open || pendingFocus.current === null) return;\n const target = pendingFocus.current === \"last\" ? selectable.at(-1) : selectable[0];\n pendingFocus.current = null;\n if (target !== undefined) focusIndex(target);\n }, [open, selectable, focusIndex]);\n\n useEffect(() => {\n if (!open) return;\n const onDown = (event: MouseEvent): void => {\n if (rootRef.current && !rootRef.current.contains(event.target as Node)) close(false);\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n }, [open, close]);\n\n const openWith = (edge: \"first\" | \"last\"): void => {\n pendingFocus.current = edge;\n setOpen(true);\n };\n\n const handleTriggerKeyDown = (event: ReactKeyboardEvent): void => {\n trigger.props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || (!open && (event.key === \"Enter\" || event.key === \" \"))) {\n event.preventDefault();\n openWith(\"first\");\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n openWith(\"last\");\n }\n };\n\n /**\n * Keyboard model of the open menu.\n *\n * Handled on the list rather than on `window`: focus is inside the menu by\n * then, so the keys arrive here by bubbling and cannot be pre-empted by an\n * unrelated global listener — which is how the arrows used to go missing in a\n * host application.\n */\n const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>): void => {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n move(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n move(-1);\n break;\n case \"Home\":\n event.preventDefault();\n if (selectable[0] !== undefined) focusIndex(selectable[0]);\n break;\n case \"End\": {\n event.preventDefault();\n const last = selectable.at(-1);\n if (last !== undefined) focusIndex(last);\n break;\n }\n case \"Escape\":\n event.preventDefault();\n close(true);\n break;\n case \"Tab\":\n close(false);\n break;\n default:\n break;\n }\n };\n\n const handleTriggerClick = (event: React.MouseEvent): void => {\n trigger.props.onClick?.(event);\n if (event.defaultPrevented) return;\n if (open) {\n close(false);\n return;\n }\n openWith(\"first\");\n };\n\n const triggerClone = {\n ...trigger,\n props: {\n ...trigger.props,\n onClick: handleTriggerClick,\n onKeyDown: handleTriggerKeyDown,\n \"aria-expanded\": open,\n \"aria-controls\": id,\n \"aria-haspopup\": \"menu\" as const,\n },\n } as ReactElement;\n\n const handleSelect = (entry: SelectableEntry): void => {\n entry.onSelect();\n if (entry.type === \"checkbox\") return;\n close(true);\n };\n\n return (\n <span ref={rootRef} className={styles.root}>\n {triggerClone}\n {open && (\n <ul\n id={id}\n role=\"menu\"\n className={cn(styles.menu, placementClass(placement), className)}\n onKeyDown={handleMenuKeyDown}\n >\n {items.map((entry, index) => {\n if (entry.type === \"separator\") {\n return (\n <li\n key={entry.id}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n if (entry.type === \"label\") {\n return (\n <li key={entry.id} role=\"presentation\" className={styles.label}>\n {entry.label}\n </li>\n );\n }\n const checkbox = entry.type === \"checkbox\";\n return (\n <li key={entry.id} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role={checkbox ? \"menuitemcheckbox\" : \"menuitem\"}\n aria-checked={checkbox ? entry.checked : undefined}\n tabIndex={activeIndex === index ? 0 : -1}\n className={cn(\n styles.item,\n entry.type === \"item\" && entry.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={entry.disabled}\n onClick={() => handleSelect(entry)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {entry.icon && <span aria-hidden>{entry.icon}</span>}\n {entry.label}\n </button>\n </li>\n );\n })}\n </ul>\n )}\n </span>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"DropdownMenu.js","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — the body owns placement, outside-\n * click, and the whole APG menu-button keyboard model, which has to see the entire\n * entry list to know where the next stop is. Splitting it would move the focus\n * bookkeeping away from the list it indexes into.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactElement, ReactNode } from \"react\";\nimport { Portal } from \"@/components/Portal\";\nimport { useAnchorPosition } from \"@/components/Portal/anchor-position\";\nimport type { AnchorAlign, AnchorSide } from \"@/components/Portal/anchor-position\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./DropdownMenu.module.css\";\n\nexport type DropdownMenuPlacement = \"bottom-start\" | \"bottom-end\" | \"top-start\" | \"top-end\";\n\nexport type DropdownMenuEntry =\n | {\n type: \"item\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n danger?: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | {\n /**\n * An entry that carries on/off state.\n *\n * Rendered as `role=\"menuitemcheckbox\"` with `aria-checked`, which is how\n * a screen reader announces \"checked\" instead of leaving the state\n * invisible. Selecting it does **not** close the menu: toggling two\n * settings in a row is the ordinary case, and closing after the first\n * would make the second a second trip.\n */\n type: \"checkbox\";\n id: string;\n label: ReactNode;\n icon?: ReactNode;\n checked: boolean;\n disabled?: boolean;\n onSelect: () => void;\n }\n | { type: \"separator\"; id: string }\n | { type: \"label\"; id: string; label: ReactNode };\n\n/** The entry kinds a user can move focus to and activate. */\ntype SelectableEntry = Extract<DropdownMenuEntry, { type: \"item\" | \"checkbox\" }>;\n\nexport interface DropdownMenuProps {\n trigger: ReactElement<{\n onClick?: (e: React.MouseEvent) => void;\n onKeyDown?: (e: ReactKeyboardEvent) => void;\n \"aria-expanded\"?: boolean;\n \"aria-controls\"?: string;\n \"aria-haspopup\"?: boolean | \"menu\";\n }>;\n items: DropdownMenuEntry[];\n placement?: DropdownMenuPlacement;\n /**\n * Render the menu in a portal, positioned against the trigger. Default `true`.\n *\n * Without it the menu is `position: absolute` inside the trigger's wrapper,\n * and any ancestor with `overflow` other than `visible` clips it. The ordinary\n * victim is the action column of a `DataTable`: its wrapper scrolls\n * horizontally, `overflow-x: auto` forces `overflow-y` to `auto` too, and the\n * menu of the last row showed one of three entries (measured in Chrome at\n * 1440 px). In a portal the menu escapes the clip, flips above the trigger\n * when there is no room below, and follows the trigger on scroll and resize.\n *\n * The portalled menu stacks at `--tempest-z-popover`, above\n * `--tempest-z-modal`, so a menu opened inside a `Modal` still paints on top.\n * `false` keeps the in-flow menu, for a container that must own the menu's\n * stacking or clipping on purpose.\n */\n portal?: boolean;\n className?: string;\n}\n\nfunction placementClass(placement: DropdownMenuPlacement): string {\n switch (placement) {\n case \"bottom-end\":\n return styles.bottomEnd;\n case \"top-start\":\n return styles.topStart;\n case \"top-end\":\n return styles.topEnd;\n case \"bottom-start\":\n default:\n return styles.bottomStart;\n }\n}\n\nconst PLACEMENT_GEOMETRY: Record<DropdownMenuPlacement, [AnchorSide, AnchorAlign]> = {\n \"bottom-start\": [\"bottom\", \"start\"],\n \"bottom-end\": [\"bottom\", \"end\"],\n \"top-start\": [\"top\", \"start\"],\n \"top-end\": [\"top\", \"end\"],\n};\n\n/** Gap between trigger and menu, in px — the in-flow CSS uses the same 4 px. */\nconst MENU_OFFSET = 4;\n\n/** Whether an entry can take focus — an item or checkbox that is not disabled. */\nfunction isSelectable(entry: DropdownMenuEntry): entry is SelectableEntry {\n return (entry.type === \"item\" || entry.type === \"checkbox\") && !entry.disabled;\n}\n\n/**\n * Dropdown menu — a list of actions anchored to a trigger, following the\n * [APG menu button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).\n *\n * `role=\"menu\"` is a promise about the keyboard, and this component keeps it:\n *\n * - `Enter`, `Space` or `ArrowDown` on the trigger opens and focuses the first\n * entry; `ArrowUp` opens and focuses the last.\n * - `ArrowUp` / `ArrowDown` move with wrap, `Home` / `End` jump to the ends.\n * - `Escape` closes and returns focus to the trigger, so the next `Tab` continues\n * from where the user was rather than from the top of the document.\n * - `Tab` closes the menu and lets the page's own tab order take over, counted\n * from the trigger: focus goes back to the trigger synchronously and the key's\n * default action then moves on from there. Without that, a portalled menu —\n * the last thing in `body` — would hand `Tab` to whatever follows it, which\n * inside a `Modal` is outside the dialog.\n * - Focus is managed: entries carry `tabIndex={-1}` and only the active one is\n * `0`, which is what stops `Tab` from walking the menu one entry at a time.\n *\n * Disabled entries, separators and labels are skipped by every movement.\n */\nexport function DropdownMenu({\n trigger,\n items,\n placement = \"bottom-start\",\n portal = true,\n className,\n}: DropdownMenuProps) {\n const [open, setOpen] = useState(false);\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [rootNode, setRootNode] = useState<HTMLSpanElement | null>(null);\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const pendingFocus = useRef<\"first\" | \"last\" | null>(null);\n\n const selectable = items\n .map((entry, index) => (isSelectable(entry) ? index : -1))\n .filter((index) => index !== -1);\n\n /**\n * Focus the trigger again.\n *\n * Found through the `aria-haspopup` this component puts on it, rather than\n * through a ref: `ref` lives in different places in React 18 and 19, and a\n * consumer's custom trigger is under no obligation to forward one. The\n * attribute is on the element either way.\n *\n * @returns Nothing.\n */\n const focusTrigger = useCallback((): void => {\n rootNode?.querySelector<HTMLElement>(\"[aria-haspopup='menu']\")?.focus();\n }, [rootNode]);\n\n const close = useCallback(\n (restoreFocus: boolean): void => {\n setOpen(false);\n setActiveIndex(-1);\n if (restoreFocus) focusTrigger();\n },\n [focusTrigger],\n );\n\n const focusIndex = useCallback((index: number): void => {\n setActiveIndex(index);\n itemRefs.current[index]?.focus();\n }, []);\n\n /**\n * Move focus to the entry `step` positions away, wrapping at both ends.\n *\n * Walks the selectable positions rather than the raw list, so separators,\n * labels and disabled entries are never a stop.\n */\n const move = (step: number): void => {\n if (selectable.length === 0) return;\n const current = selectable.indexOf(activeIndex);\n const next = (current + step + selectable.length) % selectable.length;\n focusIndex(selectable[next] ?? -1);\n };\n\n const [side, align] = PLACEMENT_GEOMETRY[placement];\n const floatingStyle = useAnchorPosition({\n anchor: rootNode,\n floating: menuNode,\n side,\n align,\n offset: MENU_OFFSET,\n enabled: portal && open,\n });\n\n /**\n * Focus the entry the opening key asked for.\n *\n * Keyed off the menu node rather than off `open` alone: in a portal the menu\n * mounts one commit after `open` flips, and an effect that ran on `open` would\n * find no entries to focus.\n */\n useEffect(() => {\n if (!open || !menuNode || pendingFocus.current === null) return;\n const target = pendingFocus.current === \"last\" ? selectable.at(-1) : selectable[0];\n pendingFocus.current = null;\n if (target !== undefined) focusIndex(target);\n }, [open, menuNode, selectable, focusIndex]);\n\n /**\n * Close on a press outside both the trigger and the menu.\n *\n * The menu is checked separately because in a portal it is not inside the\n * trigger's wrapper: testing the wrapper alone would treat a press on an entry\n * as outside and close the menu before the entry's click lands.\n */\n useEffect(() => {\n if (!open) return;\n const onDown = (event: MouseEvent): void => {\n const target = event.target as Node;\n if (rootNode?.contains(target) || menuNode?.contains(target)) return;\n close(false);\n };\n window.addEventListener(\"mousedown\", onDown);\n return () => window.removeEventListener(\"mousedown\", onDown);\n }, [open, close, rootNode, menuNode]);\n\n const openWith = (edge: \"first\" | \"last\"): void => {\n pendingFocus.current = edge;\n setOpen(true);\n };\n\n const handleTriggerKeyDown = (event: ReactKeyboardEvent): void => {\n trigger.props.onKeyDown?.(event);\n if (event.defaultPrevented) return;\n if (event.key === \"ArrowDown\" || (!open && (event.key === \"Enter\" || event.key === \" \"))) {\n event.preventDefault();\n openWith(\"first\");\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n openWith(\"last\");\n }\n };\n\n /**\n * Keyboard model of the open menu.\n *\n * Handled on the list rather than on `window`: focus is inside the menu by\n * then, so the keys arrive here by bubbling and cannot be pre-empted by an\n * unrelated global listener — which is how the arrows used to go missing in a\n * host application.\n */\n const handleMenuKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>): void => {\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n move(1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n move(-1);\n break;\n case \"Home\":\n event.preventDefault();\n if (selectable[0] !== undefined) focusIndex(selectable[0]);\n break;\n case \"End\": {\n event.preventDefault();\n const last = selectable.at(-1);\n if (last !== undefined) focusIndex(last);\n break;\n }\n case \"Escape\":\n event.preventDefault();\n close(true);\n break;\n case \"Tab\":\n close(true);\n break;\n default:\n break;\n }\n };\n\n const handleTriggerClick = (event: React.MouseEvent): void => {\n trigger.props.onClick?.(event);\n if (event.defaultPrevented) return;\n if (open) {\n close(false);\n return;\n }\n openWith(\"first\");\n };\n\n const triggerClone = {\n ...trigger,\n props: {\n ...trigger.props,\n onClick: handleTriggerClick,\n onKeyDown: handleTriggerKeyDown,\n \"aria-expanded\": open,\n \"aria-controls\": id,\n \"aria-haspopup\": \"menu\" as const,\n },\n } as ReactElement;\n\n const handleSelect = (entry: SelectableEntry): void => {\n entry.onSelect();\n if (entry.type === \"checkbox\") return;\n close(true);\n };\n\n const menu = (\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n className={cn(\n styles.menu,\n portal ? styles.portalled : placementClass(placement),\n className,\n )}\n style={floatingStyle}\n onKeyDown={handleMenuKeyDown}\n >\n {items.map((entry, index) => {\n if (entry.type === \"separator\") {\n return (\n <li\n key={entry.id}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n if (entry.type === \"label\") {\n return (\n <li key={entry.id} role=\"presentation\" className={styles.label}>\n {entry.label}\n </li>\n );\n }\n const checkbox = entry.type === \"checkbox\";\n return (\n <li key={entry.id} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role={checkbox ? \"menuitemcheckbox\" : \"menuitem\"}\n aria-checked={checkbox ? entry.checked : undefined}\n tabIndex={activeIndex === index ? 0 : -1}\n className={cn(\n styles.item,\n entry.type === \"item\" && entry.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={entry.disabled}\n onClick={() => handleSelect(entry)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {entry.icon && <span aria-hidden>{entry.icon}</span>}\n {entry.label}\n </button>\n </li>\n );\n })}\n </ul>\n );\n\n return (\n <span ref={setRootNode} className={styles.root}>\n {triggerClone}\n {open && (portal ? <Portal>{menu}</Portal> : menu)}\n </span>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
require("../../styles/component/DropdownMenu.css");
|
|
2
|
-
var e=`tempest_root_5ZetV`,t=`tempest_menu_-RiRW`,n=`tempest_tempest-menu-in_uCs9q`,r=`
|
|
2
|
+
var e=`tempest_root_5ZetV`,t=`tempest_menu_-RiRW`,n=`tempest_tempest-menu-in_uCs9q`,r=`tempest_portalled_b7vCf`,i=`tempest_bottomStart_z60h9`,a=`tempest_bottomEnd_b1tmU`,o=`tempest_topStart_A0em2`,s=`tempest_topEnd_cFA5-`,c=`tempest_item_T-QPZ`,l=`tempest_active_WNqoX`,u=`tempest_danger_C0jN3`,d=`tempest_separator_RimQa`,f=`tempest_label_6yP76`,p={root:e,menu:t,tempestMenuIn:n,portalled:r,bottomStart:i,bottomEnd:a,topStart:o,topEnd:s,item:c,active:l,danger:u,separator:d,label:f};exports.active=l,exports.bottomEnd=a,exports.bottomStart=i,exports.danger=u,exports.default=p,exports.item=c,exports.label=f,exports.menu=t,exports.portalled=r,exports.root=e,exports.separator=d,exports.tempestMenuIn=n,exports.topEnd=s,exports.topStart=o;
|
|
3
3
|
//# sourceMappingURL=DropdownMenu.module.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenu.module.cjs","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.module.css"],"sourcesContent":[".root {\n position: relative;\n display: inline-flex;\n}\n\n.menu {\n position: absolute;\n z-index: var(--tempest-z-dropdown);\n min-width: 180px;\n max-width: 320px;\n background-color: var(--tempest-bg);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n box-shadow: var(--tempest-shadow-lg);\n padding: var(--tempest-space-1);\n font-family: var(--tempest-font-sans);\n font-size: var(--tempest-text-base);\n list-style: none;\n margin: 0;\n animation: tempest-menu-in var(--tempest-duration-fast) var(--tempest-ease-out);\n}\n\n.bottomStart {\n top: calc(100% + 4px);\n left: 0;\n}\n.bottomEnd {\n top: calc(100% + 4px);\n right: 0;\n}\n.topStart {\n bottom: calc(100% + 4px);\n left: 0;\n}\n.topEnd {\n bottom: calc(100% + 4px);\n right: 0;\n}\n\n.item {\n display: flex;\n align-items: center;\n gap: var(--tempest-space-2);\n width: 100%;\n padding: var(--tempest-space-2) var(--tempest-space-3);\n background: none;\n border: none;\n border-radius: var(--tempest-radius-sm);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: inherit;\n line-height: var(--tempest-leading-snug);\n text-align: left;\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .item:hover:not(:disabled) {\n background-color: var(--tempest-surface);\n }\n}\n\n.item:focus,\n.item.active {\n outline: none;\n background-color: var(--tempest-primary-soft);\n color: var(--tempest-primary-on-soft);\n box-shadow: inset 3px 0 0 0 var(--tempest-selected-indicator);\n}\n\n.item:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n\n.item.danger {\n color: var(--tempest-danger);\n}\n\n.separator {\n height: 1px;\n background-color: var(--tempest-border);\n margin: var(--tempest-space-1) 0;\n}\n\n.label {\n padding: var(--tempest-space-1) var(--tempest-space-3);\n font-size: var(--tempest-text-xs);\n font-weight: var(--tempest-weight-semibold);\n color: var(--tempest-text-muted);\n text-transform: uppercase;\n letter-spacing: var(--tempest-tracking-wider);\n}\n\n@keyframes tempest-menu-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .menu {\n animation: none;\n }\n}\n"],"mappings":";"}
|
|
1
|
+
{"version":3,"file":"DropdownMenu.module.cjs","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.module.css"],"sourcesContent":[".root {\n position: relative;\n display: inline-flex;\n}\n\n.menu {\n position: absolute;\n z-index: var(--tempest-z-dropdown);\n min-width: 180px;\n max-width: 320px;\n background-color: var(--tempest-bg);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n box-shadow: var(--tempest-shadow-lg);\n padding: var(--tempest-space-1);\n font-family: var(--tempest-font-sans);\n font-size: var(--tempest-text-base);\n list-style: none;\n margin: 0;\n animation: tempest-menu-in var(--tempest-duration-fast) var(--tempest-ease-out);\n}\n\n.portalled {\n z-index: var(--tempest-z-popover);\n}\n\n.bottomStart {\n top: calc(100% + 4px);\n left: 0;\n}\n.bottomEnd {\n top: calc(100% + 4px);\n right: 0;\n}\n.topStart {\n bottom: calc(100% + 4px);\n left: 0;\n}\n.topEnd {\n bottom: calc(100% + 4px);\n right: 0;\n}\n\n.item {\n display: flex;\n align-items: center;\n gap: var(--tempest-space-2);\n width: 100%;\n padding: var(--tempest-space-2) var(--tempest-space-3);\n background: none;\n border: none;\n border-radius: var(--tempest-radius-sm);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: inherit;\n line-height: var(--tempest-leading-snug);\n text-align: left;\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .item:hover:not(:disabled) {\n background-color: var(--tempest-surface);\n }\n}\n\n.item:focus,\n.item.active {\n outline: none;\n background-color: var(--tempest-primary-soft);\n color: var(--tempest-primary-on-soft);\n box-shadow: inset 3px 0 0 0 var(--tempest-selected-indicator);\n}\n\n.item:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n\n.item.danger {\n color: var(--tempest-danger);\n}\n\n.separator {\n height: 1px;\n background-color: var(--tempest-border);\n margin: var(--tempest-space-1) 0;\n}\n\n.label {\n padding: var(--tempest-space-1) var(--tempest-space-3);\n font-size: var(--tempest-text-xs);\n font-weight: var(--tempest-weight-semibold);\n color: var(--tempest-text-muted);\n text-transform: uppercase;\n letter-spacing: var(--tempest-tracking-wider);\n}\n\n@keyframes tempest-menu-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .menu {\n animation: none;\n }\n}\n"],"mappings":";"}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import "../../styles/component/DropdownMenu.css";
|
|
2
2
|
//#region src/components/DropdownMenu/DropdownMenu.module.css
|
|
3
|
-
var e = "tempest_root_5ZetV", t = "tempest_menu_-RiRW", n = "tempest_tempest-menu-in_uCs9q", r = "
|
|
3
|
+
var e = "tempest_root_5ZetV", t = "tempest_menu_-RiRW", n = "tempest_tempest-menu-in_uCs9q", r = "tempest_portalled_b7vCf", i = "tempest_bottomStart_z60h9", a = "tempest_bottomEnd_b1tmU", o = "tempest_topStart_A0em2", s = "tempest_topEnd_cFA5-", c = "tempest_item_T-QPZ", l = "tempest_active_WNqoX", u = "tempest_danger_C0jN3", d = "tempest_separator_RimQa", f = "tempest_label_6yP76", p = {
|
|
4
4
|
root: e,
|
|
5
5
|
menu: t,
|
|
6
6
|
tempestMenuIn: n,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
portalled: r,
|
|
8
|
+
bottomStart: i,
|
|
9
|
+
bottomEnd: a,
|
|
10
|
+
topStart: o,
|
|
11
|
+
topEnd: s,
|
|
12
|
+
item: c,
|
|
13
|
+
active: l,
|
|
14
|
+
danger: u,
|
|
15
|
+
separator: d,
|
|
16
|
+
label: f
|
|
16
17
|
};
|
|
17
18
|
//#endregion
|
|
18
|
-
export {
|
|
19
|
+
export { l as active, a as bottomEnd, i as bottomStart, u as danger, p as default, c as item, f as label, t as menu, r as portalled, e as root, d as separator, n as tempestMenuIn, s as topEnd, o as topStart };
|
|
19
20
|
|
|
20
21
|
//# sourceMappingURL=DropdownMenu.module.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenu.module.js","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.module.css"],"sourcesContent":[".root {\n position: relative;\n display: inline-flex;\n}\n\n.menu {\n position: absolute;\n z-index: var(--tempest-z-dropdown);\n min-width: 180px;\n max-width: 320px;\n background-color: var(--tempest-bg);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n box-shadow: var(--tempest-shadow-lg);\n padding: var(--tempest-space-1);\n font-family: var(--tempest-font-sans);\n font-size: var(--tempest-text-base);\n list-style: none;\n margin: 0;\n animation: tempest-menu-in var(--tempest-duration-fast) var(--tempest-ease-out);\n}\n\n.bottomStart {\n top: calc(100% + 4px);\n left: 0;\n}\n.bottomEnd {\n top: calc(100% + 4px);\n right: 0;\n}\n.topStart {\n bottom: calc(100% + 4px);\n left: 0;\n}\n.topEnd {\n bottom: calc(100% + 4px);\n right: 0;\n}\n\n.item {\n display: flex;\n align-items: center;\n gap: var(--tempest-space-2);\n width: 100%;\n padding: var(--tempest-space-2) var(--tempest-space-3);\n background: none;\n border: none;\n border-radius: var(--tempest-radius-sm);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: inherit;\n line-height: var(--tempest-leading-snug);\n text-align: left;\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .item:hover:not(:disabled) {\n background-color: var(--tempest-surface);\n }\n}\n\n.item:focus,\n.item.active {\n outline: none;\n background-color: var(--tempest-primary-soft);\n color: var(--tempest-primary-on-soft);\n box-shadow: inset 3px 0 0 0 var(--tempest-selected-indicator);\n}\n\n.item:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n\n.item.danger {\n color: var(--tempest-danger);\n}\n\n.separator {\n height: 1px;\n background-color: var(--tempest-border);\n margin: var(--tempest-space-1) 0;\n}\n\n.label {\n padding: var(--tempest-space-1) var(--tempest-space-3);\n font-size: var(--tempest-text-xs);\n font-weight: var(--tempest-weight-semibold);\n color: var(--tempest-text-muted);\n text-transform: uppercase;\n letter-spacing: var(--tempest-tracking-wider);\n}\n\n@keyframes tempest-menu-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .menu {\n animation: none;\n }\n}\n"],"mappings":";"}
|
|
1
|
+
{"version":3,"file":"DropdownMenu.module.js","names":[],"sources":["../../../src/components/DropdownMenu/DropdownMenu.module.css"],"sourcesContent":[".root {\n position: relative;\n display: inline-flex;\n}\n\n.menu {\n position: absolute;\n z-index: var(--tempest-z-dropdown);\n min-width: 180px;\n max-width: 320px;\n background-color: var(--tempest-bg);\n border: 1px solid var(--tempest-border);\n border-radius: var(--tempest-radius-lg);\n box-shadow: var(--tempest-shadow-lg);\n padding: var(--tempest-space-1);\n font-family: var(--tempest-font-sans);\n font-size: var(--tempest-text-base);\n list-style: none;\n margin: 0;\n animation: tempest-menu-in var(--tempest-duration-fast) var(--tempest-ease-out);\n}\n\n.portalled {\n z-index: var(--tempest-z-popover);\n}\n\n.bottomStart {\n top: calc(100% + 4px);\n left: 0;\n}\n.bottomEnd {\n top: calc(100% + 4px);\n right: 0;\n}\n.topStart {\n bottom: calc(100% + 4px);\n left: 0;\n}\n.topEnd {\n bottom: calc(100% + 4px);\n right: 0;\n}\n\n.item {\n display: flex;\n align-items: center;\n gap: var(--tempest-space-2);\n width: 100%;\n padding: var(--tempest-space-2) var(--tempest-space-3);\n background: none;\n border: none;\n border-radius: var(--tempest-radius-sm);\n color: var(--tempest-text);\n font-family: inherit;\n font-size: inherit;\n line-height: var(--tempest-leading-snug);\n text-align: left;\n cursor: pointer;\n transition: var(--tempest-transition-color);\n}\n\n@media (hover: hover) and (pointer: fine) {\n .item:hover:not(:disabled) {\n background-color: var(--tempest-surface);\n }\n}\n\n.item:focus,\n.item.active {\n outline: none;\n background-color: var(--tempest-primary-soft);\n color: var(--tempest-primary-on-soft);\n box-shadow: inset 3px 0 0 0 var(--tempest-selected-indicator);\n}\n\n.item:disabled {\n opacity: 0.55;\n cursor: not-allowed;\n}\n\n.item.danger {\n color: var(--tempest-danger);\n}\n\n.separator {\n height: 1px;\n background-color: var(--tempest-border);\n margin: var(--tempest-space-1) 0;\n}\n\n.label {\n padding: var(--tempest-space-1) var(--tempest-space-3);\n font-size: var(--tempest-text-xs);\n font-weight: var(--tempest-weight-semibold);\n color: var(--tempest-text-muted);\n text-transform: uppercase;\n letter-spacing: var(--tempest-tracking-wider);\n}\n\n@keyframes tempest-menu-in {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .menu {\n animation: none;\n }\n}\n"],"mappings":";"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../utils/cn.cjs"),t=require("./HoverCard.module.cjs");let
|
|
1
|
+
const e=require("../../utils/cn.cjs"),t=require("../Portal/Portal.cjs"),n=require("../Portal/anchor-position.cjs"),r=require("../Portal/tab-bridge.cjs"),i=require("./HoverCard.module.cjs");let a=require("react"),o=require("react/jsx-runtime");var s=8;function c({trigger:c,children:l,openDelay:u=300,closeDelay:d=150,placement:f=`bottom`,portal:p=!0,className:m,style:h,...g}){let[_,v]=(0,a.useState)(!1),[y,b]=(0,a.useState)(null),[x,S]=(0,a.useState)(null),C=n.useAnchorPosition({anchor:y,floating:x,side:f,align:`center`,offset:s,enabled:p&&_});r.usePortalTabOrder({enabled:p&&_,anchor:y,panel:x});let w=(0,a.useId)(),T=(0,a.useRef)(null),E=(0,a.useCallback)(()=>{T.current!==null&&(clearTimeout(T.current),T.current=null)},[]),D=(0,a.useCallback)(()=>{E(),T.current=setTimeout(()=>v(!0),u)},[E,u]),O=(0,a.useCallback)(()=>{E(),T.current=setTimeout(()=>v(!1),d)},[E,d]);(0,a.useEffect)(()=>E,[E]);let k=(0,o.jsx)(`div`,{ref:S,id:w,role:`dialog`,className:e.cn(i.default.card,i.default[f],p&&i.default.portalled,m),style:{...h,...C},...g,children:l});return(0,o.jsxs)(`span`,{ref:b,className:i.default.root,onMouseEnter:D,onMouseLeave:O,onFocus:D,onBlur:O,children:[(0,o.jsx)(`span`,{"aria-describedby":_?w:void 0,className:i.default.trigger,children:c}),_&&(p?(0,o.jsx)(t.Portal,{children:k}):k)]})}exports.HoverCard=c;
|
|
2
2
|
//# sourceMappingURL=HoverCard.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HoverCard.cjs","names":[],"sources":["../../../src/components/HoverCard/HoverCard.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./HoverCard.module.css\";\n\nexport type HoverCardPlacement = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport interface HoverCardProps extends HTMLAttributes<HTMLDivElement> {\n /** Element the user hovers/focuses to reveal the card. */\n trigger: ReactNode;\n /** Card content shown while hovering/focusing the trigger or the card itself. */\n children: ReactNode;\n /** Delay in ms before opening on mouseenter/focus. Default 300. */\n openDelay?: number;\n /** Delay in ms before closing on mouseleave/blur. Default 150. */\n closeDelay?: number;\n /** Where the card is anchored relative to the trigger. Default \"bottom\". */\n placement?: HoverCardPlacement;\n}\n\n/**\n * Content preview shown when the trigger is hovered or focused.\n *\n * - Opens after `openDelay` on `mouseenter`/`focus`.\n * - Closes after `closeDelay` on `mouseleave`/`blur`.\n * - Positioned relative to the trigger via `placement
|
|
1
|
+
{"version":3,"file":"HoverCard.cjs","names":[],"sources":["../../../src/components/HoverCard/HoverCard.tsx"],"sourcesContent":["import { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { Portal } from \"@/components/Portal\";\nimport { useAnchorPosition } from \"@/components/Portal/anchor-position\";\nimport { usePortalTabOrder } from \"@/components/Portal/tab-bridge\";\nimport { cn } from \"@/utils/cn\";\nimport styles from \"./HoverCard.module.css\";\n\nexport type HoverCardPlacement = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport interface HoverCardProps extends HTMLAttributes<HTMLDivElement> {\n /** Element the user hovers/focuses to reveal the card. */\n trigger: ReactNode;\n /** Card content shown while hovering/focusing the trigger or the card itself. */\n children: ReactNode;\n /** Delay in ms before opening on mouseenter/focus. Default 300. */\n openDelay?: number;\n /** Delay in ms before closing on mouseleave/blur. Default 150. */\n closeDelay?: number;\n /** Where the card is anchored relative to the trigger. Default \"bottom\". */\n placement?: HoverCardPlacement;\n /**\n * Render the card in a portal, positioned against the trigger. Default `true`.\n *\n * In flow, an ancestor with `overflow` other than `visible` clips the card — a\n * user handle in the last row of a `DataTable` showed 17% of it (measured in\n * Chrome at 1440 px). In a portal it escapes the clip and flips to the\n * opposite side at the viewport edge.\n */\n portal?: boolean;\n}\n\n/** Gap between trigger and card, in px — the in-flow CSS uses the same 8 px. */\nconst CARD_OFFSET = 8;\n\n/**\n * Content preview shown when the trigger is hovered or focused.\n *\n * - Opens after `openDelay` on `mouseenter`/`focus`.\n * - Closes after `closeDelay` on `mouseleave`/`blur`.\n * - Positioned relative to the trigger via `placement`, in a portal by default.\n * - Moving the pointer from the trigger into the card keeps it open: React\n * dispatches `mouseenter`/`mouseleave` along the component tree, and the\n * portalled card is still inside the trigger's wrapper there.\n * - A link or button in the card is the next `Tab` stop after the trigger, as it\n * was in flow.\n * - The card is rendered as a labelled `role=\"dialog\"` region; the trigger stays keyboard focusable.\n *\n * @param props - The hover card props.\n * @returns The trigger wrapper and the anchored card when open.\n */\nexport function HoverCard({\n trigger,\n children,\n openDelay = 300,\n closeDelay = 150,\n placement = \"bottom\",\n portal = true,\n className,\n style,\n ...rest\n}: HoverCardProps) {\n const [open, setOpen] = useState(false);\n const [rootNode, setRootNode] = useState<HTMLSpanElement | null>(null);\n const [cardNode, setCardNode] = useState<HTMLDivElement | null>(null);\n const floatingStyle = useAnchorPosition({\n anchor: rootNode,\n floating: cardNode,\n side: placement,\n align: \"center\",\n offset: CARD_OFFSET,\n enabled: portal && open,\n });\n usePortalTabOrder({ enabled: portal && open, anchor: rootNode, panel: cardNode });\n const id = useId();\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const clearTimer = useCallback((): void => {\n if (timerRef.current !== null) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }, []);\n\n const scheduleOpen = useCallback((): void => {\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(true), openDelay);\n }, [clearTimer, openDelay]);\n\n const scheduleClose = useCallback((): void => {\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(false), closeDelay);\n }, [clearTimer, closeDelay]);\n\n useEffect(() => clearTimer, [clearTimer]);\n\n const card = (\n <div\n ref={setCardNode}\n id={id}\n role=\"dialog\"\n className={cn(styles.card, styles[placement], portal && styles.portalled, className)}\n style={{ ...style, ...floatingStyle }}\n {...rest}\n >\n {children}\n </div>\n );\n\n return (\n <span\n ref={setRootNode}\n className={styles.root}\n onMouseEnter={scheduleOpen}\n onMouseLeave={scheduleClose}\n onFocus={scheduleOpen}\n onBlur={scheduleClose}\n >\n <span aria-describedby={open ? id : undefined} className={styles.trigger}>\n {trigger}\n </span>\n {open && (portal ? <Portal>{card}</Portal> : card)}\n </span>\n );\n}\n"],"mappings":"mPAiCA,IAAM,EAAc,EAkBpB,SAAgB,EAAU,CACtB,UACA,WACA,YAAY,IACZ,aAAa,IACb,YAAY,SACZ,SAAS,GACT,YACA,QACA,GAAG,GACY,CACf,GAAM,CAAC,EAAM,IAAA,EAAW,EAAA,SAAA,CAAS,EAAK,EAChC,CAAC,EAAU,IAAA,EAAe,EAAA,SAAA,CAAiC,IAAI,EAC/D,CAAC,EAAU,IAAA,EAAe,EAAA,SAAA,CAAgC,IAAI,EAC9D,EAAgB,EAAA,kBAAkB,CACpC,OAAQ,EACR,SAAU,EACV,KAAM,EACN,MAAO,SACP,OAAQ,EACR,QAAS,GAAU,CACvB,CAAC,EACD,EAAA,kBAAkB,CAAE,QAAS,GAAU,EAAM,OAAQ,EAAU,MAAO,CAAS,CAAC,EAChF,IAAM,GAAA,EAAK,EAAA,MAAA,CAAM,EACX,GAAA,EAAW,EAAA,OAAA,CAA6C,IAAI,EAE5D,GAAA,EAAa,EAAA,YAAA,KAAwB,CACnC,EAAS,UAAY,OACrB,aAAa,EAAS,OAAO,EAC7B,EAAS,QAAU,KAE3B,EAAG,CAAC,CAAC,EAEC,GAAA,EAAe,EAAA,YAAA,KAAwB,CACzC,EAAW,EACX,EAAS,QAAU,eAAiB,EAAQ,EAAI,EAAG,CAAS,CAChE,EAAG,CAAC,EAAY,CAAS,CAAC,EAEpB,GAAA,EAAgB,EAAA,YAAA,KAAwB,CAC1C,EAAW,EACX,EAAS,QAAU,eAAiB,EAAQ,EAAK,EAAG,CAAU,CAClE,EAAG,CAAC,EAAY,CAAU,CAAC,GAE3B,EAAA,EAAA,UAAA,KAAgB,EAAY,CAAC,CAAU,CAAC,EAExC,IAAM,GACF,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,IAAK,EACD,KACJ,KAAK,SACL,UAAW,EAAA,GAAG,EAAA,QAAO,KAAM,EAAA,QAAO,GAAY,GAAU,EAAA,QAAO,UAAW,CAAS,EACnF,MAAO,CAAE,GAAG,EAAO,GAAG,CAAc,EACpC,GAAI,EAEH,UACA,CAAA,EAGT,OACI,EAAA,EAAA,KAAA,CAAC,OAAD,CACI,IAAK,EACL,UAAW,EAAA,QAAO,KAClB,aAAc,EACd,aAAc,EACd,QAAS,EACT,OAAQ,EANZ,SAAA,EAQI,EAAA,EAAA,IAAA,CAAC,OAAD,CAAM,mBAAkB,EAAO,EAAK,IAAA,GAAW,UAAW,EAAA,QAAO,QAC5D,SAAA,CACC,CAAA,EACL,IAAS,GAAS,EAAA,EAAA,IAAA,CAAC,EAAA,OAAD,CAAA,SAAS,CAAa,CAAA,EAAI,EAC3C,GAEd"}
|