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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContextMenu.cjs","names":[],"sources":["../../../src/components/ContextMenu/ContextMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines function-lines — the body owns one anchor point and\n * everything that reads from it: the three ways a menu opens (right click, long\n * press, click), the clamp that keeps it inside the viewport, and roving focus\n * for the keyboard path. Splitting them would hand the same geometry to three\n * files.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"@/components/Portal\";\nimport styles from \"./ContextMenu.module.css\";\n\nexport type ContextMenuItem =\n | {\n label: ReactNode;\n onSelect?: () => void;\n disabled?: boolean;\n danger?: boolean;\n }\n | { separator: true };\n\n/** How the menu is opened by a pointer. Touch always gets the long press. */\nexport type ContextMenuTrigger = \"contextmenu\" | \"click\" | \"both\";\n\nexport interface ContextMenuProps {\n /** Menu entries — selectable items and separators. */\n items: ContextMenuItem[];\n /** Trigger area. Right-clicking anywhere within opens the menu at the cursor. */\n children: ReactNode;\n /** Extra class names forwarded to the menu element. */\n className?: string;\n /**\n * Which pointer gesture opens the menu. Default `\"contextmenu\"`.\n *\n * `\"click\"` is the `⋮` button case, where a right click is the wrong gesture\n * on the desktop and impossible on touch. `\"both\"` keeps the right click and\n * adds the left one.\n */\n trigger?: ContextMenuTrigger;\n /**\n * Hold, in ms, that opens the menu from a coarse pointer. Default `500`.\n *\n * Set `0` to turn the long press off. It is on by default because touch has\n * no right click: measured in Chrome with touch emulation (Pixel 7 and\n * iPhone 13 profiles), a 900 ms hold fires `pointerdown`, `touchstart`,\n * `pointerup`, `touchend` and `click` — and no `contextmenu` at all. Every\n * action behind this menu was unreachable on a phone.\n */\n longPressDelay?: number;\n /**\n * Gap, in px, kept between the menu and the edge of the viewport. Default `8`.\n */\n viewportMargin?: number;\n}\n\ninterface Position {\n x: number;\n y: number;\n}\n\n/** Movement, in px, that reads as a scroll rather than a hold. */\nconst LONG_PRESS_MOVE_TOLERANCE = 10;\n\nfunction isSeparator(item: ContextMenuItem): item is { separator: true } {\n return \"separator\" in item && item.separator === true;\n}\n\n/**\n * Context menu — right click, long press, or click.\n *\n * - Opens at the pointer on `contextmenu`, and on a long press from touch or pen,\n * which is the gesture those pointers have instead of a right click. `trigger`\n * adds or replaces the mouse gesture; the long press is independent of it.\n * - Clamped inside the viewport after it mounts, so a menu opened near an edge\n * moves in rather than overflowing — measured before the clamp: a 180 px menu\n * opened at `x=235` in a 320 px window ran 95 px off screen, cutting the right\n * edge off every label.\n * - Rendered through a {@link Portal} so it escapes parent overflow/stacking\n * contexts.\n * - Closes on outside click, Escape, scroll, resize, or item selection.\n * - Arrow Up/Down move focus across selectable items and wrap; Home/End jump to\n * the ends; Enter activates the focused item. The menu itself takes focus when\n * it opens, so a screen reader announces it instead of staying on the trigger.\n *\n * @param props - The context menu props.\n * @returns The trigger wrapper plus the portalled menu when open.\n */\nexport function ContextMenu({\n items,\n children,\n className,\n trigger = \"contextmenu\",\n longPressDelay = 500,\n viewportMargin = 8,\n}: ContextMenuProps) {\n const [open, setOpen] = useState(false);\n const [position, setPosition] = useState<Position>({ x: 0, y: 0 });\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const longPressOrigin = useRef<Position | null>(null);\n\n const selectableIndexes = items\n .map((item, index) => (!isSeparator(item) && !item.disabled ? index : -1))\n .filter((i) => i !== -1);\n\n const close = useCallback((): void => {\n setOpen(false);\n setActiveIndex(-1);\n }, []);\n\n const openAt = useCallback((point: Position): void => {\n setPosition(point);\n setActiveIndex(-1);\n setOpen(true);\n }, []);\n\n const cancelLongPress = useCallback((): void => {\n if (longPressTimer.current !== null) {\n clearTimeout(longPressTimer.current);\n longPressTimer.current = null;\n }\n longPressOrigin.current = null;\n }, []);\n\n useEffect(() => cancelLongPress, [cancelLongPress]);\n\n const handleContextMenu = (event: React.MouseEvent): void => {\n if (trigger === \"click\") return;\n event.preventDefault();\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n const handleClick = (event: React.MouseEvent): void => {\n if (trigger === \"contextmenu\") return;\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n /**\n * Start the hold that stands in for a right click on a coarse pointer.\n *\n * A mouse is excluded: it has `contextmenu`, and holding the left button is\n * how a drag starts.\n */\n const handlePointerDown = (event: ReactPointerEvent): void => {\n if (longPressDelay <= 0 || event.pointerType === \"mouse\") return;\n const point = { x: event.clientX, y: event.clientY };\n longPressOrigin.current = point;\n cancelLongPress();\n longPressOrigin.current = point;\n longPressTimer.current = setTimeout(() => {\n longPressTimer.current = null;\n longPressOrigin.current = null;\n suppressNextClick();\n openAt(point);\n }, longPressDelay);\n };\n\n /**\n * Cancel the hold once the finger travels — otherwise every scroll that\n * starts on the trigger opens a menu, which is the difference between a\n * usable long press and an infuriating one.\n */\n const handlePointerMove = (event: ReactPointerEvent): void => {\n const origin = longPressOrigin.current;\n if (!origin || longPressTimer.current === null) return;\n if (\n Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n LONG_PRESS_MOVE_TOLERANCE\n ) {\n cancelLongPress();\n }\n };\n\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n close();\n return;\n }\n const move = (next: number): void => {\n event.preventDefault();\n setActiveIndex(next);\n itemRefs.current[next]?.focus();\n };\n if (selectableIndexes.length === 0) return;\n const current = selectableIndexes.indexOf(activeIndex);\n if (event.key === \"ArrowDown\") {\n move(selectableIndexes[(current + 1) % selectableIndexes.length] ?? -1);\n }\n if (event.key === \"ArrowUp\") {\n move(\n selectableIndexes[\n (current - 1 + selectableIndexes.length) % selectableIndexes.length\n ] ?? -1,\n );\n }\n if (event.key === \"Home\") {\n move(selectableIndexes[0] ?? -1);\n }\n if (event.key === \"End\") {\n move(selectableIndexes[selectableIndexes.length - 1] ?? -1);\n }\n };\n const onDown = (event: MouseEvent): void => {\n if (menuNode && !menuNode.contains(event.target as Node)) close();\n };\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"resize\", close);\n window.addEventListener(\"scroll\", close, true);\n return () => {\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"resize\", close);\n window.removeEventListener(\"scroll\", close, true);\n };\n }, [open, activeIndex, selectableIndexes, close, menuNode]);\n\n /**\n * Pull the menu inside the viewport, and take focus.\n *\n * Keyed off the node rather than off `open`, because {@link Portal} renders\n * `null` on its first pass and mounts on an effect — an effect that reads a\n * ref when `open` flips runs while the menu does not exist yet, measures\n * nothing and focuses nothing. A callback ref in state is what makes this\n * run on the render that has the element.\n *\n * `useLayoutEffect` runs before paint, so the menu is never painted at the\n * overflowing position first.\n *\n * The size comes from `offsetWidth`/`offsetHeight` rather than from\n * `getBoundingClientRect`, which reports the **transformed** box: the menu\n * enters at `scale(0.96)`, so the rect is 4% small and the clamp lets the\n * grown menu back over the edge. Measured in Chrome at 320 px wide, a 180 px\n * menu came back at `right: 316` against a margin that asked for 312.\n */\n useLayoutEffect(() => {\n if (!open || !menuNode) return;\n menuNode.focus({ preventScroll: true });\n const { offsetWidth, offsetHeight } = menuNode;\n if (offsetWidth === 0 && offsetHeight === 0) return;\n const maxLeft = window.innerWidth - offsetWidth - viewportMargin;\n const maxTop = window.innerHeight - offsetHeight - viewportMargin;\n const left = Math.max(viewportMargin, Math.min(position.x, maxLeft));\n const top = Math.max(viewportMargin, Math.min(position.y, maxTop));\n if (left !== position.x || top !== position.y) setPosition({ x: left, y: top });\n }, [open, menuNode, position.x, position.y, viewportMargin]);\n\n const handleSelect = (item: Extract<ContextMenuItem, { label: ReactNode }>): void => {\n item.onSelect?.();\n close();\n };\n\n return (\n <>\n <span\n className={styles.root}\n onContextMenu={handleContextMenu}\n onClick={handleClick}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={cancelLongPress}\n onPointerCancel={cancelLongPress}\n >\n {children}\n </span>\n {open && (\n <Portal>\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabIndex={-1}\n className={cn(styles.menu, className)}\n style={{ top: position.y, left: position.x }}\n >\n {items.map((item, index) => {\n if (isSeparator(item)) {\n return (\n <li\n key={`separator-${index}`}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n return (\n <li key={`item-${index}`} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role=\"menuitem\"\n className={cn(\n styles.item,\n item.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={item.disabled}\n onClick={() => handleSelect(item)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </Portal>\n )}\n </>\n );\n}\n\n/**\n * Swallow the click a finger leaves behind after a long press.\n *\n * The hold fires while the finger is still down, so the browser goes on to\n * deliver `pointerup` and then `click` to whatever is under it — measured, that\n * is the same element the menu was just opened from. Without this, opening the\n * menu on a chat bubble also opens the bubble.\n *\n * Capture phase and `once`, so the listener never outlives the gesture that\n * installed it, and a later genuine click is untouched.\n */\nfunction suppressNextClick(): void {\n if (typeof window === \"undefined\") return;\n window.addEventListener(\n \"click\",\n (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n },\n { capture: true, once: true },\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ContextMenu.cjs","names":[],"sources":["../../../src/components/ContextMenu/ContextMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines function-lines — the body owns one anchor point and\n * everything that reads from it: the three ways a menu opens (right click, long\n * press, click), the clamp that keeps it inside the viewport, and roving focus\n * for the keyboard path. Splitting them would hand the same geometry to three\n * files.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"@/components/Portal\";\nimport { useEscapeLayer } from \"@/components/Portal/escape-layer\";\nimport styles from \"./ContextMenu.module.css\";\n\nexport type ContextMenuItem =\n | {\n label: ReactNode;\n onSelect?: () => void;\n disabled?: boolean;\n danger?: boolean;\n }\n | { separator: true };\n\n/** How the menu is opened by a pointer. Touch always gets the long press. */\nexport type ContextMenuTrigger = \"contextmenu\" | \"click\" | \"both\";\n\nexport interface ContextMenuProps {\n /** Menu entries — selectable items and separators. */\n items: ContextMenuItem[];\n /** Trigger area. Right-clicking anywhere within opens the menu at the cursor. */\n children: ReactNode;\n /** Extra class names forwarded to the menu element. */\n className?: string;\n /**\n * Which pointer gesture opens the menu. Default `\"contextmenu\"`.\n *\n * `\"click\"` is the `⋮` button case, where a right click is the wrong gesture\n * on the desktop and impossible on touch. `\"both\"` keeps the right click and\n * adds the left one.\n */\n trigger?: ContextMenuTrigger;\n /**\n * Hold, in ms, that opens the menu from a coarse pointer. Default `500`.\n *\n * Set `0` to turn the long press off. It is on by default because touch has\n * no right click: measured in Chrome with touch emulation (Pixel 7 and\n * iPhone 13 profiles), a 900 ms hold fires `pointerdown`, `touchstart`,\n * `pointerup`, `touchend` and `click` — and no `contextmenu` at all. Every\n * action behind this menu was unreachable on a phone.\n */\n longPressDelay?: number;\n /**\n * Gap, in px, kept between the menu and the edge of the viewport. Default `8`.\n */\n viewportMargin?: number;\n}\n\ninterface Position {\n x: number;\n y: number;\n}\n\n/** Movement, in px, that reads as a scroll rather than a hold. */\nconst LONG_PRESS_MOVE_TOLERANCE = 10;\n\nfunction isSeparator(item: ContextMenuItem): item is { separator: true } {\n return \"separator\" in item && item.separator === true;\n}\n\n/**\n * Context menu — right click, long press, or click.\n *\n * - Opens at the pointer on `contextmenu`, and on a long press from touch or pen,\n * which is the gesture those pointers have instead of a right click. `trigger`\n * adds or replaces the mouse gesture; the long press is independent of it.\n * - Clamped inside the viewport after it mounts, so a menu opened near an edge\n * moves in rather than overflowing — measured before the clamp: a 180 px menu\n * opened at `x=235` in a 320 px window ran 95 px off screen, cutting the right\n * edge off every label.\n * - Rendered through a {@link Portal} so it escapes parent overflow/stacking\n * contexts.\n * - Closes on outside click, Escape, scroll, resize, or item selection.\n * - Arrow Up/Down move focus across selectable items and wrap; Home/End jump to\n * the ends; Enter activates the focused item. The menu itself takes focus when\n * it opens, so a screen reader announces it instead of staying on the trigger.\n *\n * @param props - The context menu props.\n * @returns The trigger wrapper plus the portalled menu when open.\n */\nexport function ContextMenu({\n items,\n children,\n className,\n trigger = \"contextmenu\",\n longPressDelay = 500,\n viewportMargin = 8,\n}: ContextMenuProps) {\n const [open, setOpen] = useState(false);\n const [position, setPosition] = useState<Position>({ x: 0, y: 0 });\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const longPressOrigin = useRef<Position | null>(null);\n\n const selectableIndexes = items\n .map((item, index) => (!isSeparator(item) && !item.disabled ? index : -1))\n .filter((i) => i !== -1);\n\n const close = useCallback((): void => {\n setOpen(false);\n setActiveIndex(-1);\n }, []);\n\n const openAt = useCallback((point: Position): void => {\n setPosition(point);\n setActiveIndex(-1);\n setOpen(true);\n }, []);\n\n const cancelLongPress = useCallback((): void => {\n if (longPressTimer.current !== null) {\n clearTimeout(longPressTimer.current);\n longPressTimer.current = null;\n }\n longPressOrigin.current = null;\n }, []);\n\n useEffect(() => cancelLongPress, [cancelLongPress]);\n\n const handleContextMenu = (event: React.MouseEvent): void => {\n if (trigger === \"click\") return;\n event.preventDefault();\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n const handleClick = (event: React.MouseEvent): void => {\n if (trigger === \"contextmenu\") return;\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n /**\n * Start the hold that stands in for a right click on a coarse pointer.\n *\n * A mouse is excluded: it has `contextmenu`, and holding the left button is\n * how a drag starts.\n */\n const handlePointerDown = (event: ReactPointerEvent): void => {\n if (longPressDelay <= 0 || event.pointerType === \"mouse\") return;\n const point = { x: event.clientX, y: event.clientY };\n longPressOrigin.current = point;\n cancelLongPress();\n longPressOrigin.current = point;\n longPressTimer.current = setTimeout(() => {\n longPressTimer.current = null;\n longPressOrigin.current = null;\n suppressNextClick();\n openAt(point);\n }, longPressDelay);\n };\n\n /**\n * Cancel the hold once the finger travels — otherwise every scroll that\n * starts on the trigger opens a menu, which is the difference between a\n * usable long press and an infuriating one.\n */\n const handlePointerMove = (event: ReactPointerEvent): void => {\n const origin = longPressOrigin.current;\n if (!origin || longPressTimer.current === null) return;\n if (\n Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n LONG_PRESS_MOVE_TOLERANCE\n ) {\n cancelLongPress();\n }\n };\n\n useEscapeLayer(open, close);\n\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent): void => {\n const move = (next: number): void => {\n event.preventDefault();\n setActiveIndex(next);\n itemRefs.current[next]?.focus();\n };\n if (selectableIndexes.length === 0) return;\n const current = selectableIndexes.indexOf(activeIndex);\n if (event.key === \"ArrowDown\") {\n move(selectableIndexes[(current + 1) % selectableIndexes.length] ?? -1);\n }\n if (event.key === \"ArrowUp\") {\n move(\n selectableIndexes[\n (current - 1 + selectableIndexes.length) % selectableIndexes.length\n ] ?? -1,\n );\n }\n if (event.key === \"Home\") {\n move(selectableIndexes[0] ?? -1);\n }\n if (event.key === \"End\") {\n move(selectableIndexes[selectableIndexes.length - 1] ?? -1);\n }\n };\n const onDown = (event: MouseEvent): void => {\n if (menuNode && !menuNode.contains(event.target as Node)) close();\n };\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"resize\", close);\n window.addEventListener(\"scroll\", close, true);\n return () => {\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"resize\", close);\n window.removeEventListener(\"scroll\", close, true);\n };\n }, [open, activeIndex, selectableIndexes, close, menuNode]);\n\n /**\n * Pull the menu inside the viewport, and take focus.\n *\n * Keyed off the node rather than off `open`, because {@link Portal} renders\n * `null` on its first pass and mounts on an effect — an effect that reads a\n * ref when `open` flips runs while the menu does not exist yet, measures\n * nothing and focuses nothing. A callback ref in state is what makes this\n * run on the render that has the element.\n *\n * `useLayoutEffect` runs before paint, so the menu is never painted at the\n * overflowing position first.\n *\n * The size comes from `offsetWidth`/`offsetHeight` rather than from\n * `getBoundingClientRect`, which reports the **transformed** box: the menu\n * enters at `scale(0.96)`, so the rect is 4% small and the clamp lets the\n * grown menu back over the edge. Measured in Chrome at 320 px wide, a 180 px\n * menu came back at `right: 316` against a margin that asked for 312.\n */\n useLayoutEffect(() => {\n if (!open || !menuNode) return;\n menuNode.focus({ preventScroll: true });\n const { offsetWidth, offsetHeight } = menuNode;\n if (offsetWidth === 0 && offsetHeight === 0) return;\n const maxLeft = window.innerWidth - offsetWidth - viewportMargin;\n const maxTop = window.innerHeight - offsetHeight - viewportMargin;\n const left = Math.max(viewportMargin, Math.min(position.x, maxLeft));\n const top = Math.max(viewportMargin, Math.min(position.y, maxTop));\n if (left !== position.x || top !== position.y) setPosition({ x: left, y: top });\n }, [open, menuNode, position.x, position.y, viewportMargin]);\n\n const handleSelect = (item: Extract<ContextMenuItem, { label: ReactNode }>): void => {\n item.onSelect?.();\n close();\n };\n\n return (\n <>\n <span\n className={styles.root}\n onContextMenu={handleContextMenu}\n onClick={handleClick}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={cancelLongPress}\n onPointerCancel={cancelLongPress}\n >\n {children}\n </span>\n {open && (\n <Portal>\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabIndex={-1}\n className={cn(styles.menu, className)}\n style={{ top: position.y, left: position.x }}\n >\n {items.map((item, index) => {\n if (isSeparator(item)) {\n return (\n <li\n key={`separator-${index}`}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n return (\n <li key={`item-${index}`} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role=\"menuitem\"\n className={cn(\n styles.item,\n item.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={item.disabled}\n onClick={() => handleSelect(item)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </Portal>\n )}\n </>\n );\n}\n\n/**\n * Swallow the click a finger leaves behind after a long press.\n *\n * The hold fires while the finger is still down, so the browser goes on to\n * deliver `pointerup` and then `click` to whatever is under it — measured, that\n * is the same element the menu was just opened from. Without this, opening the\n * menu on a chat bubble also opens the bubble.\n *\n * Capture phase and `once`, so the listener never outlives the gesture that\n * installed it, and a later genuine click is untouched.\n */\nfunction suppressNextClick(): void {\n if (typeof window === \"undefined\") return;\n window.addEventListener(\n \"click\",\n (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n },\n { capture: true, once: true },\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,135 +1,132 @@
|
|
|
1
1
|
import { cn as e } from "../../utils/cn.js";
|
|
2
|
-
import {
|
|
3
|
-
import n from "
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
2
|
+
import { useEscapeLayer as t } from "../Portal/escape-layer.js";
|
|
3
|
+
import { Portal as n } from "../Portal/Portal.js";
|
|
4
|
+
import r from "./ContextMenu.module.js";
|
|
5
|
+
import { useCallback as i, useEffect as a, useId as o, useLayoutEffect as s, useRef as c, useState as l } from "react";
|
|
6
|
+
import { Fragment as u, jsx as d, jsxs as f } from "react/jsx-runtime";
|
|
6
7
|
//#region src/components/ContextMenu/ContextMenu.tsx
|
|
7
|
-
var
|
|
8
|
-
function
|
|
8
|
+
var p = 10;
|
|
9
|
+
function m(e) {
|
|
9
10
|
return "separator" in e && e.separator === !0;
|
|
10
11
|
}
|
|
11
|
-
function
|
|
12
|
-
let [
|
|
12
|
+
function h({ items: h, children: _, className: v, trigger: y = "contextmenu", longPressDelay: b = 500, viewportMargin: x = 8 }) {
|
|
13
|
+
let [S, C] = l(!1), [w, T] = l({
|
|
13
14
|
x: 0,
|
|
14
15
|
y: 0
|
|
15
|
-
}), [
|
|
16
|
-
|
|
17
|
-
}, []),
|
|
18
|
-
|
|
19
|
-
}, []),
|
|
20
|
-
|
|
16
|
+
}), [E, D] = l(-1), O = o(), [k, A] = l(null), j = c([]), M = c(null), N = c(null), P = h.map((e, t) => !m(e) && !e.disabled ? t : -1).filter((e) => e !== -1), F = i(() => {
|
|
17
|
+
C(!1), D(-1);
|
|
18
|
+
}, []), I = i((e) => {
|
|
19
|
+
T(e), D(-1), C(!0);
|
|
20
|
+
}, []), L = i(() => {
|
|
21
|
+
M.current !== null && (clearTimeout(M.current), M.current = null), N.current = null;
|
|
21
22
|
}, []);
|
|
22
|
-
|
|
23
|
-
let
|
|
24
|
-
|
|
23
|
+
a(() => L, [L]);
|
|
24
|
+
let R = (e) => {
|
|
25
|
+
y !== "click" && (e.preventDefault(), I({
|
|
25
26
|
x: e.clientX,
|
|
26
27
|
y: e.clientY
|
|
27
28
|
}));
|
|
28
|
-
},
|
|
29
|
-
|
|
29
|
+
}, z = (e) => {
|
|
30
|
+
y !== "contextmenu" && I({
|
|
30
31
|
x: e.clientX,
|
|
31
32
|
y: e.clientY
|
|
32
33
|
});
|
|
33
|
-
},
|
|
34
|
-
if (
|
|
34
|
+
}, B = (e) => {
|
|
35
|
+
if (b <= 0 || e.pointerType === "mouse") return;
|
|
35
36
|
let t = {
|
|
36
37
|
x: e.clientX,
|
|
37
38
|
y: e.clientY
|
|
38
39
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
let t =
|
|
44
|
-
t &&
|
|
40
|
+
N.current = t, L(), N.current = t, M.current = setTimeout(() => {
|
|
41
|
+
M.current = null, N.current = null, g(), I(t);
|
|
42
|
+
}, b);
|
|
43
|
+
}, V = (e) => {
|
|
44
|
+
let t = N.current;
|
|
45
|
+
t && M.current !== null && Math.hypot(e.clientX - t.x, e.clientY - t.y) > p && L();
|
|
45
46
|
};
|
|
46
|
-
|
|
47
|
-
if (!
|
|
47
|
+
t(S, F), a(() => {
|
|
48
|
+
if (!S) return;
|
|
48
49
|
let e = (e) => {
|
|
49
|
-
if (e.key === "Escape") {
|
|
50
|
-
P();
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
50
|
let t = (t) => {
|
|
54
|
-
e.preventDefault(),
|
|
51
|
+
e.preventDefault(), D(t), j.current[t]?.focus();
|
|
55
52
|
};
|
|
56
|
-
if (
|
|
57
|
-
let n =
|
|
58
|
-
e.key === "ArrowDown" && t(
|
|
53
|
+
if (P.length === 0) return;
|
|
54
|
+
let n = P.indexOf(E);
|
|
55
|
+
e.key === "ArrowDown" && t(P[(n + 1) % P.length] ?? -1), e.key === "ArrowUp" && t(P[(n - 1 + P.length) % P.length] ?? -1), e.key === "Home" && t(P[0] ?? -1), e.key === "End" && t(P[P.length - 1] ?? -1);
|
|
59
56
|
}, t = (e) => {
|
|
60
|
-
|
|
57
|
+
k && !k.contains(e.target) && F();
|
|
61
58
|
};
|
|
62
|
-
return window.addEventListener("keydown", e), window.addEventListener("mousedown", t), window.addEventListener("resize",
|
|
63
|
-
window.removeEventListener("keydown", e), window.removeEventListener("mousedown", t), window.removeEventListener("resize",
|
|
59
|
+
return window.addEventListener("keydown", e), window.addEventListener("mousedown", t), window.addEventListener("resize", F), window.addEventListener("scroll", F, !0), () => {
|
|
60
|
+
window.removeEventListener("keydown", e), window.removeEventListener("mousedown", t), window.removeEventListener("resize", F), window.removeEventListener("scroll", F, !0);
|
|
64
61
|
};
|
|
65
62
|
}, [
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
N,
|
|
63
|
+
S,
|
|
64
|
+
E,
|
|
69
65
|
P,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
F,
|
|
67
|
+
k
|
|
68
|
+
]), s(() => {
|
|
69
|
+
if (!S || !k) return;
|
|
70
|
+
k.focus({ preventScroll: !0 });
|
|
71
|
+
let { offsetWidth: e, offsetHeight: t } = k;
|
|
75
72
|
if (e === 0 && t === 0) return;
|
|
76
|
-
let n = window.innerWidth - e -
|
|
77
|
-
(i !==
|
|
73
|
+
let n = window.innerWidth - e - x, r = window.innerHeight - t - x, i = Math.max(x, Math.min(w.x, n)), a = Math.max(x, Math.min(w.y, r));
|
|
74
|
+
(i !== w.x || a !== w.y) && T({
|
|
78
75
|
x: i,
|
|
79
76
|
y: a
|
|
80
77
|
});
|
|
81
78
|
}, [
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
S,
|
|
80
|
+
k,
|
|
81
|
+
w.x,
|
|
82
|
+
w.y,
|
|
83
|
+
x
|
|
87
84
|
]);
|
|
88
|
-
let
|
|
89
|
-
e.onSelect?.(),
|
|
85
|
+
let H = (e) => {
|
|
86
|
+
e.onSelect?.(), F();
|
|
90
87
|
};
|
|
91
|
-
return /* @__PURE__ */
|
|
92
|
-
className:
|
|
93
|
-
onContextMenu:
|
|
94
|
-
onClick:
|
|
95
|
-
onPointerDown:
|
|
96
|
-
onPointerMove:
|
|
97
|
-
onPointerUp:
|
|
98
|
-
onPointerCancel:
|
|
99
|
-
children:
|
|
100
|
-
}),
|
|
101
|
-
ref:
|
|
102
|
-
id:
|
|
88
|
+
return /* @__PURE__ */ f(u, { children: [/* @__PURE__ */ d("span", {
|
|
89
|
+
className: r.root,
|
|
90
|
+
onContextMenu: R,
|
|
91
|
+
onClick: z,
|
|
92
|
+
onPointerDown: B,
|
|
93
|
+
onPointerMove: V,
|
|
94
|
+
onPointerUp: L,
|
|
95
|
+
onPointerCancel: L,
|
|
96
|
+
children: _
|
|
97
|
+
}), S && /* @__PURE__ */ d(n, { children: /* @__PURE__ */ d("ul", {
|
|
98
|
+
ref: A,
|
|
99
|
+
id: O,
|
|
103
100
|
role: "menu",
|
|
104
101
|
"aria-orientation": "vertical",
|
|
105
102
|
tabIndex: -1,
|
|
106
|
-
className: e(
|
|
103
|
+
className: e(r.menu, v),
|
|
107
104
|
style: {
|
|
108
|
-
top:
|
|
109
|
-
left:
|
|
105
|
+
top: w.y,
|
|
106
|
+
left: w.x
|
|
110
107
|
},
|
|
111
|
-
children:
|
|
108
|
+
children: h.map((t, n) => m(t) ? /* @__PURE__ */ d("li", {
|
|
112
109
|
role: "separator",
|
|
113
|
-
className:
|
|
110
|
+
className: r.separator,
|
|
114
111
|
"aria-hidden": !0
|
|
115
|
-
}, `separator-${
|
|
112
|
+
}, `separator-${n}`) : /* @__PURE__ */ d("li", {
|
|
116
113
|
role: "none",
|
|
117
|
-
children: /* @__PURE__ */
|
|
114
|
+
children: /* @__PURE__ */ d("button", {
|
|
118
115
|
ref: (e) => {
|
|
119
|
-
|
|
116
|
+
j.current[n] = e;
|
|
120
117
|
},
|
|
121
118
|
type: "button",
|
|
122
119
|
role: "menuitem",
|
|
123
|
-
className: e(
|
|
120
|
+
className: e(r.item, t.danger && r.danger, E === n && r.active),
|
|
124
121
|
disabled: t.disabled,
|
|
125
|
-
onClick: () =>
|
|
126
|
-
onMouseEnter: () =>
|
|
122
|
+
onClick: () => H(t),
|
|
123
|
+
onMouseEnter: () => D(n),
|
|
127
124
|
children: t.label
|
|
128
125
|
})
|
|
129
|
-
}, `item-${
|
|
126
|
+
}, `item-${n}`))
|
|
130
127
|
}) })] });
|
|
131
128
|
}
|
|
132
|
-
function
|
|
129
|
+
function g() {
|
|
133
130
|
typeof window > "u" || window.addEventListener("click", (e) => {
|
|
134
131
|
e.preventDefault(), e.stopPropagation();
|
|
135
132
|
}, {
|
|
@@ -138,6 +135,6 @@ function h() {
|
|
|
138
135
|
});
|
|
139
136
|
}
|
|
140
137
|
//#endregion
|
|
141
|
-
export {
|
|
138
|
+
export { h as ContextMenu };
|
|
142
139
|
|
|
143
140
|
//# sourceMappingURL=ContextMenu.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContextMenu.js","names":[],"sources":["../../../src/components/ContextMenu/ContextMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines function-lines — the body owns one anchor point and\n * everything that reads from it: the three ways a menu opens (right click, long\n * press, click), the clamp that keeps it inside the viewport, and roving focus\n * for the keyboard path. Splitting them would hand the same geometry to three\n * files.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"@/components/Portal\";\nimport styles from \"./ContextMenu.module.css\";\n\nexport type ContextMenuItem =\n | {\n label: ReactNode;\n onSelect?: () => void;\n disabled?: boolean;\n danger?: boolean;\n }\n | { separator: true };\n\n/** How the menu is opened by a pointer. Touch always gets the long press. */\nexport type ContextMenuTrigger = \"contextmenu\" | \"click\" | \"both\";\n\nexport interface ContextMenuProps {\n /** Menu entries — selectable items and separators. */\n items: ContextMenuItem[];\n /** Trigger area. Right-clicking anywhere within opens the menu at the cursor. */\n children: ReactNode;\n /** Extra class names forwarded to the menu element. */\n className?: string;\n /**\n * Which pointer gesture opens the menu. Default `\"contextmenu\"`.\n *\n * `\"click\"` is the `⋮` button case, where a right click is the wrong gesture\n * on the desktop and impossible on touch. `\"both\"` keeps the right click and\n * adds the left one.\n */\n trigger?: ContextMenuTrigger;\n /**\n * Hold, in ms, that opens the menu from a coarse pointer. Default `500`.\n *\n * Set `0` to turn the long press off. It is on by default because touch has\n * no right click: measured in Chrome with touch emulation (Pixel 7 and\n * iPhone 13 profiles), a 900 ms hold fires `pointerdown`, `touchstart`,\n * `pointerup`, `touchend` and `click` — and no `contextmenu` at all. Every\n * action behind this menu was unreachable on a phone.\n */\n longPressDelay?: number;\n /**\n * Gap, in px, kept between the menu and the edge of the viewport. Default `8`.\n */\n viewportMargin?: number;\n}\n\ninterface Position {\n x: number;\n y: number;\n}\n\n/** Movement, in px, that reads as a scroll rather than a hold. */\nconst LONG_PRESS_MOVE_TOLERANCE = 10;\n\nfunction isSeparator(item: ContextMenuItem): item is { separator: true } {\n return \"separator\" in item && item.separator === true;\n}\n\n/**\n * Context menu — right click, long press, or click.\n *\n * - Opens at the pointer on `contextmenu`, and on a long press from touch or pen,\n * which is the gesture those pointers have instead of a right click. `trigger`\n * adds or replaces the mouse gesture; the long press is independent of it.\n * - Clamped inside the viewport after it mounts, so a menu opened near an edge\n * moves in rather than overflowing — measured before the clamp: a 180 px menu\n * opened at `x=235` in a 320 px window ran 95 px off screen, cutting the right\n * edge off every label.\n * - Rendered through a {@link Portal} so it escapes parent overflow/stacking\n * contexts.\n * - Closes on outside click, Escape, scroll, resize, or item selection.\n * - Arrow Up/Down move focus across selectable items and wrap; Home/End jump to\n * the ends; Enter activates the focused item. The menu itself takes focus when\n * it opens, so a screen reader announces it instead of staying on the trigger.\n *\n * @param props - The context menu props.\n * @returns The trigger wrapper plus the portalled menu when open.\n */\nexport function ContextMenu({\n items,\n children,\n className,\n trigger = \"contextmenu\",\n longPressDelay = 500,\n viewportMargin = 8,\n}: ContextMenuProps) {\n const [open, setOpen] = useState(false);\n const [position, setPosition] = useState<Position>({ x: 0, y: 0 });\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const longPressOrigin = useRef<Position | null>(null);\n\n const selectableIndexes = items\n .map((item, index) => (!isSeparator(item) && !item.disabled ? index : -1))\n .filter((i) => i !== -1);\n\n const close = useCallback((): void => {\n setOpen(false);\n setActiveIndex(-1);\n }, []);\n\n const openAt = useCallback((point: Position): void => {\n setPosition(point);\n setActiveIndex(-1);\n setOpen(true);\n }, []);\n\n const cancelLongPress = useCallback((): void => {\n if (longPressTimer.current !== null) {\n clearTimeout(longPressTimer.current);\n longPressTimer.current = null;\n }\n longPressOrigin.current = null;\n }, []);\n\n useEffect(() => cancelLongPress, [cancelLongPress]);\n\n const handleContextMenu = (event: React.MouseEvent): void => {\n if (trigger === \"click\") return;\n event.preventDefault();\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n const handleClick = (event: React.MouseEvent): void => {\n if (trigger === \"contextmenu\") return;\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n /**\n * Start the hold that stands in for a right click on a coarse pointer.\n *\n * A mouse is excluded: it has `contextmenu`, and holding the left button is\n * how a drag starts.\n */\n const handlePointerDown = (event: ReactPointerEvent): void => {\n if (longPressDelay <= 0 || event.pointerType === \"mouse\") return;\n const point = { x: event.clientX, y: event.clientY };\n longPressOrigin.current = point;\n cancelLongPress();\n longPressOrigin.current = point;\n longPressTimer.current = setTimeout(() => {\n longPressTimer.current = null;\n longPressOrigin.current = null;\n suppressNextClick();\n openAt(point);\n }, longPressDelay);\n };\n\n /**\n * Cancel the hold once the finger travels — otherwise every scroll that\n * starts on the trigger opens a menu, which is the difference between a\n * usable long press and an infuriating one.\n */\n const handlePointerMove = (event: ReactPointerEvent): void => {\n const origin = longPressOrigin.current;\n if (!origin || longPressTimer.current === null) return;\n if (\n Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n LONG_PRESS_MOVE_TOLERANCE\n ) {\n cancelLongPress();\n }\n };\n\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n close();\n return;\n }\n const move = (next: number): void => {\n event.preventDefault();\n setActiveIndex(next);\n itemRefs.current[next]?.focus();\n };\n if (selectableIndexes.length === 0) return;\n const current = selectableIndexes.indexOf(activeIndex);\n if (event.key === \"ArrowDown\") {\n move(selectableIndexes[(current + 1) % selectableIndexes.length] ?? -1);\n }\n if (event.key === \"ArrowUp\") {\n move(\n selectableIndexes[\n (current - 1 + selectableIndexes.length) % selectableIndexes.length\n ] ?? -1,\n );\n }\n if (event.key === \"Home\") {\n move(selectableIndexes[0] ?? -1);\n }\n if (event.key === \"End\") {\n move(selectableIndexes[selectableIndexes.length - 1] ?? -1);\n }\n };\n const onDown = (event: MouseEvent): void => {\n if (menuNode && !menuNode.contains(event.target as Node)) close();\n };\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"resize\", close);\n window.addEventListener(\"scroll\", close, true);\n return () => {\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"resize\", close);\n window.removeEventListener(\"scroll\", close, true);\n };\n }, [open, activeIndex, selectableIndexes, close, menuNode]);\n\n /**\n * Pull the menu inside the viewport, and take focus.\n *\n * Keyed off the node rather than off `open`, because {@link Portal} renders\n * `null` on its first pass and mounts on an effect — an effect that reads a\n * ref when `open` flips runs while the menu does not exist yet, measures\n * nothing and focuses nothing. A callback ref in state is what makes this\n * run on the render that has the element.\n *\n * `useLayoutEffect` runs before paint, so the menu is never painted at the\n * overflowing position first.\n *\n * The size comes from `offsetWidth`/`offsetHeight` rather than from\n * `getBoundingClientRect`, which reports the **transformed** box: the menu\n * enters at `scale(0.96)`, so the rect is 4% small and the clamp lets the\n * grown menu back over the edge. Measured in Chrome at 320 px wide, a 180 px\n * menu came back at `right: 316` against a margin that asked for 312.\n */\n useLayoutEffect(() => {\n if (!open || !menuNode) return;\n menuNode.focus({ preventScroll: true });\n const { offsetWidth, offsetHeight } = menuNode;\n if (offsetWidth === 0 && offsetHeight === 0) return;\n const maxLeft = window.innerWidth - offsetWidth - viewportMargin;\n const maxTop = window.innerHeight - offsetHeight - viewportMargin;\n const left = Math.max(viewportMargin, Math.min(position.x, maxLeft));\n const top = Math.max(viewportMargin, Math.min(position.y, maxTop));\n if (left !== position.x || top !== position.y) setPosition({ x: left, y: top });\n }, [open, menuNode, position.x, position.y, viewportMargin]);\n\n const handleSelect = (item: Extract<ContextMenuItem, { label: ReactNode }>): void => {\n item.onSelect?.();\n close();\n };\n\n return (\n <>\n <span\n className={styles.root}\n onContextMenu={handleContextMenu}\n onClick={handleClick}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={cancelLongPress}\n onPointerCancel={cancelLongPress}\n >\n {children}\n </span>\n {open && (\n <Portal>\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabIndex={-1}\n className={cn(styles.menu, className)}\n style={{ top: position.y, left: position.x }}\n >\n {items.map((item, index) => {\n if (isSeparator(item)) {\n return (\n <li\n key={`separator-${index}`}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n return (\n <li key={`item-${index}`} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role=\"menuitem\"\n className={cn(\n styles.item,\n item.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={item.disabled}\n onClick={() => handleSelect(item)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </Portal>\n )}\n </>\n );\n}\n\n/**\n * Swallow the click a finger leaves behind after a long press.\n *\n * The hold fires while the finger is still down, so the browser goes on to\n * deliver `pointerup` and then `click` to whatever is under it — measured, that\n * is the same element the menu was just opened from. Without this, opening the\n * menu on a chat bubble also opens the bubble.\n *\n * Capture phase and `once`, so the listener never outlives the gesture that\n * installed it, and a later genuine click is untouched.\n */\nfunction suppressNextClick(): void {\n if (typeof window === \"undefined\") return;\n window.addEventListener(\n \"click\",\n (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n },\n { capture: true, once: true },\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ContextMenu.js","names":[],"sources":["../../../src/components/ContextMenu/ContextMenu.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines function-lines — the body owns one anchor point and\n * everything that reads from it: the three ways a menu opens (right click, long\n * press, click), the clamp that keeps it inside the viewport, and roving focus\n * for the keyboard path. Splitting them would hand the same geometry to three\n * files.\n */\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Portal } from \"@/components/Portal\";\nimport { useEscapeLayer } from \"@/components/Portal/escape-layer\";\nimport styles from \"./ContextMenu.module.css\";\n\nexport type ContextMenuItem =\n | {\n label: ReactNode;\n onSelect?: () => void;\n disabled?: boolean;\n danger?: boolean;\n }\n | { separator: true };\n\n/** How the menu is opened by a pointer. Touch always gets the long press. */\nexport type ContextMenuTrigger = \"contextmenu\" | \"click\" | \"both\";\n\nexport interface ContextMenuProps {\n /** Menu entries — selectable items and separators. */\n items: ContextMenuItem[];\n /** Trigger area. Right-clicking anywhere within opens the menu at the cursor. */\n children: ReactNode;\n /** Extra class names forwarded to the menu element. */\n className?: string;\n /**\n * Which pointer gesture opens the menu. Default `\"contextmenu\"`.\n *\n * `\"click\"` is the `⋮` button case, where a right click is the wrong gesture\n * on the desktop and impossible on touch. `\"both\"` keeps the right click and\n * adds the left one.\n */\n trigger?: ContextMenuTrigger;\n /**\n * Hold, in ms, that opens the menu from a coarse pointer. Default `500`.\n *\n * Set `0` to turn the long press off. It is on by default because touch has\n * no right click: measured in Chrome with touch emulation (Pixel 7 and\n * iPhone 13 profiles), a 900 ms hold fires `pointerdown`, `touchstart`,\n * `pointerup`, `touchend` and `click` — and no `contextmenu` at all. Every\n * action behind this menu was unreachable on a phone.\n */\n longPressDelay?: number;\n /**\n * Gap, in px, kept between the menu and the edge of the viewport. Default `8`.\n */\n viewportMargin?: number;\n}\n\ninterface Position {\n x: number;\n y: number;\n}\n\n/** Movement, in px, that reads as a scroll rather than a hold. */\nconst LONG_PRESS_MOVE_TOLERANCE = 10;\n\nfunction isSeparator(item: ContextMenuItem): item is { separator: true } {\n return \"separator\" in item && item.separator === true;\n}\n\n/**\n * Context menu — right click, long press, or click.\n *\n * - Opens at the pointer on `contextmenu`, and on a long press from touch or pen,\n * which is the gesture those pointers have instead of a right click. `trigger`\n * adds or replaces the mouse gesture; the long press is independent of it.\n * - Clamped inside the viewport after it mounts, so a menu opened near an edge\n * moves in rather than overflowing — measured before the clamp: a 180 px menu\n * opened at `x=235` in a 320 px window ran 95 px off screen, cutting the right\n * edge off every label.\n * - Rendered through a {@link Portal} so it escapes parent overflow/stacking\n * contexts.\n * - Closes on outside click, Escape, scroll, resize, or item selection.\n * - Arrow Up/Down move focus across selectable items and wrap; Home/End jump to\n * the ends; Enter activates the focused item. The menu itself takes focus when\n * it opens, so a screen reader announces it instead of staying on the trigger.\n *\n * @param props - The context menu props.\n * @returns The trigger wrapper plus the portalled menu when open.\n */\nexport function ContextMenu({\n items,\n children,\n className,\n trigger = \"contextmenu\",\n longPressDelay = 500,\n viewportMargin = 8,\n}: ContextMenuProps) {\n const [open, setOpen] = useState(false);\n const [position, setPosition] = useState<Position>({ x: 0, y: 0 });\n const [activeIndex, setActiveIndex] = useState<number>(-1);\n const id = useId();\n const [menuNode, setMenuNode] = useState<HTMLUListElement | null>(null);\n const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const longPressOrigin = useRef<Position | null>(null);\n\n const selectableIndexes = items\n .map((item, index) => (!isSeparator(item) && !item.disabled ? index : -1))\n .filter((i) => i !== -1);\n\n const close = useCallback((): void => {\n setOpen(false);\n setActiveIndex(-1);\n }, []);\n\n const openAt = useCallback((point: Position): void => {\n setPosition(point);\n setActiveIndex(-1);\n setOpen(true);\n }, []);\n\n const cancelLongPress = useCallback((): void => {\n if (longPressTimer.current !== null) {\n clearTimeout(longPressTimer.current);\n longPressTimer.current = null;\n }\n longPressOrigin.current = null;\n }, []);\n\n useEffect(() => cancelLongPress, [cancelLongPress]);\n\n const handleContextMenu = (event: React.MouseEvent): void => {\n if (trigger === \"click\") return;\n event.preventDefault();\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n const handleClick = (event: React.MouseEvent): void => {\n if (trigger === \"contextmenu\") return;\n openAt({ x: event.clientX, y: event.clientY });\n };\n\n /**\n * Start the hold that stands in for a right click on a coarse pointer.\n *\n * A mouse is excluded: it has `contextmenu`, and holding the left button is\n * how a drag starts.\n */\n const handlePointerDown = (event: ReactPointerEvent): void => {\n if (longPressDelay <= 0 || event.pointerType === \"mouse\") return;\n const point = { x: event.clientX, y: event.clientY };\n longPressOrigin.current = point;\n cancelLongPress();\n longPressOrigin.current = point;\n longPressTimer.current = setTimeout(() => {\n longPressTimer.current = null;\n longPressOrigin.current = null;\n suppressNextClick();\n openAt(point);\n }, longPressDelay);\n };\n\n /**\n * Cancel the hold once the finger travels — otherwise every scroll that\n * starts on the trigger opens a menu, which is the difference between a\n * usable long press and an infuriating one.\n */\n const handlePointerMove = (event: ReactPointerEvent): void => {\n const origin = longPressOrigin.current;\n if (!origin || longPressTimer.current === null) return;\n if (\n Math.hypot(event.clientX - origin.x, event.clientY - origin.y) >\n LONG_PRESS_MOVE_TOLERANCE\n ) {\n cancelLongPress();\n }\n };\n\n useEscapeLayer(open, close);\n\n useEffect(() => {\n if (!open) return;\n const onKey = (event: KeyboardEvent): void => {\n const move = (next: number): void => {\n event.preventDefault();\n setActiveIndex(next);\n itemRefs.current[next]?.focus();\n };\n if (selectableIndexes.length === 0) return;\n const current = selectableIndexes.indexOf(activeIndex);\n if (event.key === \"ArrowDown\") {\n move(selectableIndexes[(current + 1) % selectableIndexes.length] ?? -1);\n }\n if (event.key === \"ArrowUp\") {\n move(\n selectableIndexes[\n (current - 1 + selectableIndexes.length) % selectableIndexes.length\n ] ?? -1,\n );\n }\n if (event.key === \"Home\") {\n move(selectableIndexes[0] ?? -1);\n }\n if (event.key === \"End\") {\n move(selectableIndexes[selectableIndexes.length - 1] ?? -1);\n }\n };\n const onDown = (event: MouseEvent): void => {\n if (menuNode && !menuNode.contains(event.target as Node)) close();\n };\n window.addEventListener(\"keydown\", onKey);\n window.addEventListener(\"mousedown\", onDown);\n window.addEventListener(\"resize\", close);\n window.addEventListener(\"scroll\", close, true);\n return () => {\n window.removeEventListener(\"keydown\", onKey);\n window.removeEventListener(\"mousedown\", onDown);\n window.removeEventListener(\"resize\", close);\n window.removeEventListener(\"scroll\", close, true);\n };\n }, [open, activeIndex, selectableIndexes, close, menuNode]);\n\n /**\n * Pull the menu inside the viewport, and take focus.\n *\n * Keyed off the node rather than off `open`, because {@link Portal} renders\n * `null` on its first pass and mounts on an effect — an effect that reads a\n * ref when `open` flips runs while the menu does not exist yet, measures\n * nothing and focuses nothing. A callback ref in state is what makes this\n * run on the render that has the element.\n *\n * `useLayoutEffect` runs before paint, so the menu is never painted at the\n * overflowing position first.\n *\n * The size comes from `offsetWidth`/`offsetHeight` rather than from\n * `getBoundingClientRect`, which reports the **transformed** box: the menu\n * enters at `scale(0.96)`, so the rect is 4% small and the clamp lets the\n * grown menu back over the edge. Measured in Chrome at 320 px wide, a 180 px\n * menu came back at `right: 316` against a margin that asked for 312.\n */\n useLayoutEffect(() => {\n if (!open || !menuNode) return;\n menuNode.focus({ preventScroll: true });\n const { offsetWidth, offsetHeight } = menuNode;\n if (offsetWidth === 0 && offsetHeight === 0) return;\n const maxLeft = window.innerWidth - offsetWidth - viewportMargin;\n const maxTop = window.innerHeight - offsetHeight - viewportMargin;\n const left = Math.max(viewportMargin, Math.min(position.x, maxLeft));\n const top = Math.max(viewportMargin, Math.min(position.y, maxTop));\n if (left !== position.x || top !== position.y) setPosition({ x: left, y: top });\n }, [open, menuNode, position.x, position.y, viewportMargin]);\n\n const handleSelect = (item: Extract<ContextMenuItem, { label: ReactNode }>): void => {\n item.onSelect?.();\n close();\n };\n\n return (\n <>\n <span\n className={styles.root}\n onContextMenu={handleContextMenu}\n onClick={handleClick}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={cancelLongPress}\n onPointerCancel={cancelLongPress}\n >\n {children}\n </span>\n {open && (\n <Portal>\n <ul\n ref={setMenuNode}\n id={id}\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabIndex={-1}\n className={cn(styles.menu, className)}\n style={{ top: position.y, left: position.x }}\n >\n {items.map((item, index) => {\n if (isSeparator(item)) {\n return (\n <li\n key={`separator-${index}`}\n role=\"separator\"\n className={styles.separator}\n aria-hidden\n />\n );\n }\n return (\n <li key={`item-${index}`} role=\"none\">\n <button\n ref={(el) => {\n itemRefs.current[index] = el;\n }}\n type=\"button\"\n role=\"menuitem\"\n className={cn(\n styles.item,\n item.danger && styles.danger,\n activeIndex === index && styles.active,\n )}\n disabled={item.disabled}\n onClick={() => handleSelect(item)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </Portal>\n )}\n </>\n );\n}\n\n/**\n * Swallow the click a finger leaves behind after a long press.\n *\n * The hold fires while the finger is still down, so the browser goes on to\n * deliver `pointerup` and then `click` to whatever is under it — measured, that\n * is the same element the menu was just opened from. Without this, opening the\n * menu on a chat bubble also opens the bubble.\n *\n * Capture phase and `once`, so the listener never outlives the gesture that\n * installed it, and a later genuine click is untouched.\n */\nfunction suppressNextClick(): void {\n if (typeof window === \"undefined\") return;\n window.addEventListener(\n \"click\",\n (event: MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n },\n { capture: true, once: true },\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../utils/cn.cjs"),t=require("../../hooks/use-breakpoint.cjs"),n=require("../Portal/portal-host.cjs"),
|
|
1
|
+
const e=require("../../utils/cn.cjs"),t=require("../../hooks/use-breakpoint.cjs"),n=require("../Portal/escape-layer.cjs"),r=require("../Portal/portal-host.cjs"),i=require("./Drawer.module.cjs");let a=require("react"),o=require("react/jsx-runtime"),s=require("react-dom");function c({open:c,onClose:u,placement:d=`right`,title:f,children:p,footer:m,closeOnBackdrop:h=!0,closeOnEsc:g=!0,hideCloseButton:_=!1,className:v,mobilePlacement:y,showHandle:b=!1}){let{isMobile:x}=t.useBreakpoint(),S=x&&y?y:d;n.useEscapeLayer(c,g?u:null),(0,a.useEffect)(()=>{if(!c)return;let e=document.body.style.overflow;return document.body.style.overflow=`hidden`,()=>{document.body.style.overflow=e}},[c,g,u]);let C=r.usePortalHost();return!c||!C?null:(0,s.createPortal)((0,o.jsxs)(o.Fragment,{children:[(0,o.jsx)(`div`,{className:i.default.overlay,onClick:()=>{h&&u()}}),(0,o.jsxs)(`aside`,{role:`dialog`,"aria-modal":`true`,className:e.cn(i.default.panel,i.default[S],v),children:[b&&(S===`bottom`||S===`top`)&&(0,o.jsx)(`div`,{className:e.cn(i.default.handle,S===`top`&&i.default.handleTop),"aria-hidden":!0}),(f||!_)&&(0,o.jsxs)(`header`,{className:i.default.header,children:[(0,o.jsx)(`h3`,{className:i.default.title,children:f}),!_&&(0,o.jsx)(`button`,{type:`button`,"aria-label":`Fechar`,className:i.default.close,onClick:u,children:(0,o.jsx)(l,{})})]}),(0,o.jsx)(`div`,{className:i.default.body,children:p}),m&&(0,o.jsx)(`footer`,{className:i.default.footer,children:m})]})]}),C)}function l(){return(0,o.jsx)(`svg`,{width:`18`,height:`18`,viewBox:`0 0 24 24`,fill:`none`,children:(0,o.jsx)(`path`,{d:`M6 6l12 12M6 18L18 6`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`})})}exports.Drawer=c;
|
|
2
2
|
//# sourceMappingURL=Drawer.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Drawer.cjs","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — placement plus mobilePlacement is\n * the whole point — a drawer that comes from the side on desktop and from the bottom\n * on a phone — and the rest is the dialog contract the SDK's Modal also has (open,\n * onClose, title, children, footer, closeOnBackdrop, closeOnEsc, hideCloseButton)\n * plus showHandle for the sheet affordance.\n */\nimport { useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/utils/cn\";\nimport { useBreakpoint } from \"@/hooks/use-breakpoint\";\nimport styles from \"./Drawer.module.css\";\nimport { usePortalHost } from \"../Portal/portal-host\";\n\nexport type DrawerPlacement = \"right\" | \"left\" | \"top\" | \"bottom\";\n\nexport interface DrawerProps {\n open: boolean;\n onClose: () => void;\n placement?: DrawerPlacement;\n title?: ReactNode;\n children?: ReactNode;\n footer?: ReactNode;\n closeOnBackdrop?: boolean;\n closeOnEsc?: boolean;\n hideCloseButton?: boolean;\n className?: string;\n /**\n * Auto-switch to bottom-sheet placement on mobile viewports (< md).\n * Modern mobile apps default to bottom drawers; on desktop the original\n * `placement` is preserved.\n */\n mobilePlacement?: DrawerPlacement;\n /** Render a drag handle indicator at the leading edge (bottom-sheet style). */\n showHandle?: boolean;\n}\n\n/**\n * Sliding side panel. Same building blocks as {@link Modal} but anchored to\n * an edge. Locks body scroll while open.\n */\nexport function Drawer({\n open,\n onClose,\n placement = \"right\",\n title,\n children,\n footer,\n closeOnBackdrop = true,\n closeOnEsc = true,\n hideCloseButton = false,\n className,\n mobilePlacement,\n showHandle = false,\n}: DrawerProps) {\n const { isMobile } = useBreakpoint();\n const effectivePlacement: DrawerPlacement =\n isMobile && mobilePlacement ? mobilePlacement : placement;\n\n useEffect(() => {\n if (!open) return;\n const previousOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n
|
|
1
|
+
{"version":3,"file":"Drawer.cjs","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — placement plus mobilePlacement is\n * the whole point — a drawer that comes from the side on desktop and from the bottom\n * on a phone — and the rest is the dialog contract the SDK's Modal also has (open,\n * onClose, title, children, footer, closeOnBackdrop, closeOnEsc, hideCloseButton)\n * plus showHandle for the sheet affordance.\n */\nimport { useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useEscapeLayer } from \"@/components/Portal/escape-layer\";\nimport { cn } from \"@/utils/cn\";\nimport { useBreakpoint } from \"@/hooks/use-breakpoint\";\nimport styles from \"./Drawer.module.css\";\nimport { usePortalHost } from \"../Portal/portal-host\";\n\nexport type DrawerPlacement = \"right\" | \"left\" | \"top\" | \"bottom\";\n\nexport interface DrawerProps {\n open: boolean;\n onClose: () => void;\n placement?: DrawerPlacement;\n title?: ReactNode;\n children?: ReactNode;\n footer?: ReactNode;\n closeOnBackdrop?: boolean;\n closeOnEsc?: boolean;\n hideCloseButton?: boolean;\n className?: string;\n /**\n * Auto-switch to bottom-sheet placement on mobile viewports (< md).\n * Modern mobile apps default to bottom drawers; on desktop the original\n * `placement` is preserved.\n */\n mobilePlacement?: DrawerPlacement;\n /** Render a drag handle indicator at the leading edge (bottom-sheet style). */\n showHandle?: boolean;\n}\n\n/**\n * Sliding side panel. Same building blocks as {@link Modal} but anchored to\n * an edge. Locks body scroll while open.\n */\nexport function Drawer({\n open,\n onClose,\n placement = \"right\",\n title,\n children,\n footer,\n closeOnBackdrop = true,\n closeOnEsc = true,\n hideCloseButton = false,\n className,\n mobilePlacement,\n showHandle = false,\n}: DrawerProps) {\n const { isMobile } = useBreakpoint();\n const effectivePlacement: DrawerPlacement =\n isMobile && mobilePlacement ? mobilePlacement : placement;\n\n useEscapeLayer(open, closeOnEsc ? onClose : null);\n useEffect(() => {\n if (!open) return;\n const previousOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = previousOverflow;\n };\n }, [open, closeOnEsc, onClose]);\n\n const portalHost = usePortalHost();\n\n if (!open || !portalHost) return null;\n\n return createPortal(\n <>\n <div\n className={styles.overlay}\n onClick={() => {\n if (closeOnBackdrop) onClose();\n }}\n />\n <aside\n role=\"dialog\"\n aria-modal=\"true\"\n className={cn(styles.panel, styles[effectivePlacement], className)}\n >\n {showHandle &&\n (effectivePlacement === \"bottom\" || effectivePlacement === \"top\") && (\n <div\n className={cn(\n styles.handle,\n effectivePlacement === \"top\" && styles.handleTop,\n )}\n aria-hidden\n />\n )}\n {(title || !hideCloseButton) && (\n <header className={styles.header}>\n <h3 className={styles.title}>{title}</h3>\n {!hideCloseButton && (\n <button\n type=\"button\"\n aria-label=\"Fechar\"\n className={styles.close}\n onClick={onClose}\n >\n <CloseIcon />\n </button>\n )}\n </header>\n )}\n <div className={styles.body}>{children}</div>\n {footer && <footer className={styles.footer}>{footer}</footer>}\n </aside>\n </>,\n portalHost,\n );\n}\n\nfunction CloseIcon() {\n return (\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path\n d=\"M6 6l12 12M6 18L18 6\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n"],"mappings":"+QA2CA,SAAgB,EAAO,CACnB,OACA,UACA,YAAY,QACZ,QACA,WACA,SACA,kBAAkB,GAClB,aAAa,GACb,kBAAkB,GAClB,YACA,kBACA,aAAa,IACD,CACZ,GAAM,CAAE,YAAa,EAAA,cAAc,EAC7B,EACF,GAAY,EAAkB,EAAkB,EAEpD,EAAA,eAAe,EAAM,EAAa,EAAU,IAAI,GAChD,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,CAAC,EAAM,OACX,IAAM,EAAmB,SAAS,KAAK,MAAM,SAE7C,MADA,UAAS,KAAK,MAAM,SAAW,aAClB,CACT,SAAS,KAAK,MAAM,SAAW,CACnC,CACJ,EAAG,CAAC,EAAM,EAAY,CAAO,CAAC,EAE9B,IAAM,EAAa,EAAA,cAAc,EAIjC,MAFI,CAAC,GAAQ,CAAC,EAAmB,MAEjC,EAAO,EAAA,aAAA,EACH,EAAA,EAAA,KAAA,CAAA,EAAA,SAAA,CAAA,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,UAAW,EAAA,QAAO,QAClB,YAAe,CACP,GAAiB,EAAQ,CACjC,CACH,CAAA,GACD,EAAA,EAAA,KAAA,CAAC,QAAD,CACI,KAAK,SACL,aAAW,OACX,UAAW,EAAA,GAAG,EAAA,QAAO,MAAO,EAAA,QAAO,GAAqB,CAAS,EAHrE,SAAA,CAKK,IACI,IAAuB,UAAY,IAAuB,SACvD,EAAA,EAAA,IAAA,CAAC,MAAD,CACI,UAAW,EAAA,GACP,EAAA,QAAO,OACP,IAAuB,OAAS,EAAA,QAAO,SAC3C,EACA,cAAA,EACH,CAAA,GAEP,GAAS,CAAC,KACR,EAAA,EAAA,KAAA,CAAC,SAAD,CAAQ,UAAW,EAAA,QAAO,OAA1B,SAAA,EACI,EAAA,EAAA,IAAA,CAAC,KAAD,CAAI,UAAW,EAAA,QAAO,MAAQ,SAAA,CAAU,CAAA,EACvC,CAAC,IACE,EAAA,EAAA,IAAA,CAAC,SAAD,CACI,KAAK,SACL,aAAW,SACX,UAAW,EAAA,QAAO,MAClB,QAAS,EAET,UAAA,EAAA,EAAA,IAAA,CAAC,EAAD,CAAY,CAAA,CACR,CAAA,CAER,KAEZ,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,UAAW,EAAA,QAAO,KAAO,UAAc,CAAA,EAC3C,IAAU,EAAA,EAAA,IAAA,CAAC,SAAD,CAAQ,UAAW,EAAA,QAAO,OAAS,SAAA,CAAe,CAAA,CAC1D,CACT,CAAA,CAAA,CAAA,CAAA,EACF,CACJ,CACJ,CAEA,SAAS,GAAY,CACjB,OACI,EAAA,EAAA,IAAA,CAAC,MAAD,CAAK,MAAM,KAAK,OAAO,KAAK,QAAQ,YAAY,KAAK,OACjD,UAAA,EAAA,EAAA,IAAA,CAAC,OAAD,CACI,EAAE,uBACF,OAAO,eACP,YAAY,IACZ,cAAc,OACjB,CAAA,CACA,CAAA,CAEb"}
|
|
@@ -1,74 +1,71 @@
|
|
|
1
1
|
import { cn as e } from "../../utils/cn.js";
|
|
2
2
|
import { useBreakpoint as t } from "../../hooks/use-breakpoint.js";
|
|
3
|
-
import {
|
|
4
|
-
import r from "
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import { useEscapeLayer as n } from "../Portal/escape-layer.js";
|
|
4
|
+
import { usePortalHost as r } from "../Portal/portal-host.js";
|
|
5
|
+
import i from "./Drawer.module.js";
|
|
6
|
+
import { useEffect as a } from "react";
|
|
7
|
+
import { Fragment as o, jsx as s, jsxs as c } from "react/jsx-runtime";
|
|
8
|
+
import { createPortal as l } from "react-dom";
|
|
8
9
|
//#region src/components/Drawer/Drawer.tsx
|
|
9
|
-
function
|
|
10
|
-
let { isMobile:
|
|
11
|
-
|
|
12
|
-
if (!
|
|
10
|
+
function u({ open: u, onClose: f, placement: p = "right", title: m, children: h, footer: g, closeOnBackdrop: _ = !0, closeOnEsc: v = !0, hideCloseButton: y = !1, className: b, mobilePlacement: x, showHandle: S = !1 }) {
|
|
11
|
+
let { isMobile: C } = t(), w = C && x ? x : p;
|
|
12
|
+
n(u, v ? f : null), a(() => {
|
|
13
|
+
if (!u) return;
|
|
13
14
|
let e = document.body.style.overflow;
|
|
14
|
-
document.body.style.overflow = "hidden"
|
|
15
|
-
|
|
16
|
-
_ && e.key === "Escape" && d();
|
|
17
|
-
};
|
|
18
|
-
return window.addEventListener("keydown", t), () => {
|
|
19
|
-
document.body.style.overflow = e, window.removeEventListener("keydown", t);
|
|
15
|
+
return document.body.style.overflow = "hidden", () => {
|
|
16
|
+
document.body.style.overflow = e;
|
|
20
17
|
};
|
|
21
18
|
}, [
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
u,
|
|
20
|
+
v,
|
|
21
|
+
f
|
|
25
22
|
]);
|
|
26
|
-
let
|
|
27
|
-
return !
|
|
28
|
-
className:
|
|
23
|
+
let T = r();
|
|
24
|
+
return !u || !T ? null : l(/* @__PURE__ */ c(o, { children: [/* @__PURE__ */ s("div", {
|
|
25
|
+
className: i.overlay,
|
|
29
26
|
onClick: () => {
|
|
30
|
-
|
|
27
|
+
_ && f();
|
|
31
28
|
}
|
|
32
|
-
}), /* @__PURE__ */
|
|
29
|
+
}), /* @__PURE__ */ c("aside", {
|
|
33
30
|
role: "dialog",
|
|
34
31
|
"aria-modal": "true",
|
|
35
|
-
className: e(
|
|
32
|
+
className: e(i.panel, i[w], b),
|
|
36
33
|
children: [
|
|
37
|
-
|
|
38
|
-
className: e(
|
|
34
|
+
S && (w === "bottom" || w === "top") && /* @__PURE__ */ s("div", {
|
|
35
|
+
className: e(i.handle, w === "top" && i.handleTop),
|
|
39
36
|
"aria-hidden": !0
|
|
40
37
|
}),
|
|
41
|
-
(
|
|
42
|
-
className:
|
|
43
|
-
children: [/* @__PURE__ */
|
|
44
|
-
className:
|
|
45
|
-
children:
|
|
46
|
-
}), !
|
|
38
|
+
(m || !y) && /* @__PURE__ */ c("header", {
|
|
39
|
+
className: i.header,
|
|
40
|
+
children: [/* @__PURE__ */ s("h3", {
|
|
41
|
+
className: i.title,
|
|
42
|
+
children: m
|
|
43
|
+
}), !y && /* @__PURE__ */ s("button", {
|
|
47
44
|
type: "button",
|
|
48
45
|
"aria-label": "Fechar",
|
|
49
|
-
className:
|
|
50
|
-
onClick:
|
|
51
|
-
children: /* @__PURE__ */
|
|
46
|
+
className: i.close,
|
|
47
|
+
onClick: f,
|
|
48
|
+
children: /* @__PURE__ */ s(d, {})
|
|
52
49
|
})]
|
|
53
50
|
}),
|
|
54
|
-
/* @__PURE__ */
|
|
55
|
-
className:
|
|
56
|
-
children: m
|
|
57
|
-
}),
|
|
58
|
-
h && /* @__PURE__ */ o("footer", {
|
|
59
|
-
className: r.footer,
|
|
51
|
+
/* @__PURE__ */ s("div", {
|
|
52
|
+
className: i.body,
|
|
60
53
|
children: h
|
|
54
|
+
}),
|
|
55
|
+
g && /* @__PURE__ */ s("footer", {
|
|
56
|
+
className: i.footer,
|
|
57
|
+
children: g
|
|
61
58
|
})
|
|
62
59
|
]
|
|
63
|
-
})] }),
|
|
60
|
+
})] }), T);
|
|
64
61
|
}
|
|
65
|
-
function
|
|
66
|
-
return /* @__PURE__ */
|
|
62
|
+
function d() {
|
|
63
|
+
return /* @__PURE__ */ s("svg", {
|
|
67
64
|
width: "18",
|
|
68
65
|
height: "18",
|
|
69
66
|
viewBox: "0 0 24 24",
|
|
70
67
|
fill: "none",
|
|
71
|
-
children: /* @__PURE__ */
|
|
68
|
+
children: /* @__PURE__ */ s("path", {
|
|
72
69
|
d: "M6 6l12 12M6 18L18 6",
|
|
73
70
|
stroke: "currentColor",
|
|
74
71
|
strokeWidth: "2",
|
|
@@ -77,6 +74,6 @@ function u() {
|
|
|
77
74
|
});
|
|
78
75
|
}
|
|
79
76
|
//#endregion
|
|
80
|
-
export {
|
|
77
|
+
export { u as Drawer };
|
|
81
78
|
|
|
82
79
|
//# sourceMappingURL=Drawer.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Drawer.js","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — placement plus mobilePlacement is\n * the whole point — a drawer that comes from the side on desktop and from the bottom\n * on a phone — and the rest is the dialog contract the SDK's Modal also has (open,\n * onClose, title, children, footer, closeOnBackdrop, closeOnEsc, hideCloseButton)\n * plus showHandle for the sheet affordance.\n */\nimport { useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/utils/cn\";\nimport { useBreakpoint } from \"@/hooks/use-breakpoint\";\nimport styles from \"./Drawer.module.css\";\nimport { usePortalHost } from \"../Portal/portal-host\";\n\nexport type DrawerPlacement = \"right\" | \"left\" | \"top\" | \"bottom\";\n\nexport interface DrawerProps {\n open: boolean;\n onClose: () => void;\n placement?: DrawerPlacement;\n title?: ReactNode;\n children?: ReactNode;\n footer?: ReactNode;\n closeOnBackdrop?: boolean;\n closeOnEsc?: boolean;\n hideCloseButton?: boolean;\n className?: string;\n /**\n * Auto-switch to bottom-sheet placement on mobile viewports (< md).\n * Modern mobile apps default to bottom drawers; on desktop the original\n * `placement` is preserved.\n */\n mobilePlacement?: DrawerPlacement;\n /** Render a drag handle indicator at the leading edge (bottom-sheet style). */\n showHandle?: boolean;\n}\n\n/**\n * Sliding side panel. Same building blocks as {@link Modal} but anchored to\n * an edge. Locks body scroll while open.\n */\nexport function Drawer({\n open,\n onClose,\n placement = \"right\",\n title,\n children,\n footer,\n closeOnBackdrop = true,\n closeOnEsc = true,\n hideCloseButton = false,\n className,\n mobilePlacement,\n showHandle = false,\n}: DrawerProps) {\n const { isMobile } = useBreakpoint();\n const effectivePlacement: DrawerPlacement =\n isMobile && mobilePlacement ? mobilePlacement : placement;\n\n useEffect(() => {\n if (!open) return;\n const previousOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n
|
|
1
|
+
{"version":3,"file":"Drawer.js","names":[],"sources":["../../../src/components/Drawer/Drawer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — placement plus mobilePlacement is\n * the whole point — a drawer that comes from the side on desktop and from the bottom\n * on a phone — and the rest is the dialog contract the SDK's Modal also has (open,\n * onClose, title, children, footer, closeOnBackdrop, closeOnEsc, hideCloseButton)\n * plus showHandle for the sheet affordance.\n */\nimport { useEffect } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useEscapeLayer } from \"@/components/Portal/escape-layer\";\nimport { cn } from \"@/utils/cn\";\nimport { useBreakpoint } from \"@/hooks/use-breakpoint\";\nimport styles from \"./Drawer.module.css\";\nimport { usePortalHost } from \"../Portal/portal-host\";\n\nexport type DrawerPlacement = \"right\" | \"left\" | \"top\" | \"bottom\";\n\nexport interface DrawerProps {\n open: boolean;\n onClose: () => void;\n placement?: DrawerPlacement;\n title?: ReactNode;\n children?: ReactNode;\n footer?: ReactNode;\n closeOnBackdrop?: boolean;\n closeOnEsc?: boolean;\n hideCloseButton?: boolean;\n className?: string;\n /**\n * Auto-switch to bottom-sheet placement on mobile viewports (< md).\n * Modern mobile apps default to bottom drawers; on desktop the original\n * `placement` is preserved.\n */\n mobilePlacement?: DrawerPlacement;\n /** Render a drag handle indicator at the leading edge (bottom-sheet style). */\n showHandle?: boolean;\n}\n\n/**\n * Sliding side panel. Same building blocks as {@link Modal} but anchored to\n * an edge. Locks body scroll while open.\n */\nexport function Drawer({\n open,\n onClose,\n placement = \"right\",\n title,\n children,\n footer,\n closeOnBackdrop = true,\n closeOnEsc = true,\n hideCloseButton = false,\n className,\n mobilePlacement,\n showHandle = false,\n}: DrawerProps) {\n const { isMobile } = useBreakpoint();\n const effectivePlacement: DrawerPlacement =\n isMobile && mobilePlacement ? mobilePlacement : placement;\n\n useEscapeLayer(open, closeOnEsc ? onClose : null);\n useEffect(() => {\n if (!open) return;\n const previousOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = previousOverflow;\n };\n }, [open, closeOnEsc, onClose]);\n\n const portalHost = usePortalHost();\n\n if (!open || !portalHost) return null;\n\n return createPortal(\n <>\n <div\n className={styles.overlay}\n onClick={() => {\n if (closeOnBackdrop) onClose();\n }}\n />\n <aside\n role=\"dialog\"\n aria-modal=\"true\"\n className={cn(styles.panel, styles[effectivePlacement], className)}\n >\n {showHandle &&\n (effectivePlacement === \"bottom\" || effectivePlacement === \"top\") && (\n <div\n className={cn(\n styles.handle,\n effectivePlacement === \"top\" && styles.handleTop,\n )}\n aria-hidden\n />\n )}\n {(title || !hideCloseButton) && (\n <header className={styles.header}>\n <h3 className={styles.title}>{title}</h3>\n {!hideCloseButton && (\n <button\n type=\"button\"\n aria-label=\"Fechar\"\n className={styles.close}\n onClick={onClose}\n >\n <CloseIcon />\n </button>\n )}\n </header>\n )}\n <div className={styles.body}>{children}</div>\n {footer && <footer className={styles.footer}>{footer}</footer>}\n </aside>\n </>,\n portalHost,\n );\n}\n\nfunction CloseIcon() {\n return (\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path\n d=\"M6 6l12 12M6 18L18 6\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n"],"mappings":";;;;;;;;;AA2CA,SAAgB,EAAO,EACnB,SACA,YACA,eAAY,SACZ,UACA,aACA,WACA,qBAAkB,IAClB,gBAAa,IACb,qBAAkB,IAClB,cACA,oBACA,gBAAa,MACD;CACZ,IAAM,EAAE,gBAAa,EAAc,GAC7B,IACF,KAAY,IAAkB,IAAkB;CAGpD,AADA,EAAe,GAAM,IAAa,IAAU,IAAI,GAChD,QAAgB;EACZ,IAAI,CAAC,GAAM;EACX,IAAM,IAAmB,SAAS,KAAK,MAAM;EAE7C,OADA,SAAS,KAAK,MAAM,WAAW,gBAClB;GACT,SAAS,KAAK,MAAM,WAAW;EACnC;CACJ,GAAG;EAAC;EAAM;EAAY;CAAO,CAAC;CAE9B,IAAM,IAAa,EAAc;CAIjC,OAFI,CAAC,KAAQ,CAAC,IAAmB,OAE1B,EACH,kBAAA,GAAA,EAAA,UAAA,CACI,kBAAC,OAAD;EACI,WAAW,EAAO;EAClB,eAAe;GACX,AAAI,KAAiB,EAAQ;EACjC;CACH,CAAA,GACD,kBAAC,SAAD;EACI,MAAK;EACL,cAAW;EACX,WAAW,EAAG,EAAO,OAAO,EAAO,IAAqB,CAAS;EAHrE,UAAA;GAKK,MACI,MAAuB,YAAY,MAAuB,UACvD,kBAAC,OAAD;IACI,WAAW,EACP,EAAO,QACP,MAAuB,SAAS,EAAO,SAC3C;IACA,eAAA;GACH,CAAA;IAEP,KAAS,CAAC,MACR,kBAAC,UAAD;IAAQ,WAAW,EAAO;IAA1B,UAAA,CACI,kBAAC,MAAD;KAAI,WAAW,EAAO;KAAQ,UAAA;IAAU,CAAA,GACvC,CAAC,KACE,kBAAC,UAAD;KACI,MAAK;KACL,cAAW;KACX,WAAW,EAAO;KAClB,SAAS;KAET,UAAA,kBAAC,GAAD,CAAY,CAAA;IACR,CAAA,CAER;;GAEZ,kBAAC,OAAD;IAAK,WAAW,EAAO;IAAO;GAAc,CAAA;GAC3C,KAAU,kBAAC,UAAD;IAAQ,WAAW,EAAO;IAAS,UAAA;GAAe,CAAA;EAC1D;CACT,CAAA,CAAA,EAAA,CAAA,GACF,CACJ;AACJ;AAEA,SAAS,IAAY;CACjB,OACI,kBAAC,OAAD;EAAK,OAAM;EAAK,QAAO;EAAK,SAAQ;EAAY,MAAK;EACjD,UAAA,kBAAC,QAAD;GACI,GAAE;GACF,QAAO;GACP,aAAY;GACZ,eAAc;EACjB,CAAA;CACA,CAAA;AAEb"}
|