{ "name": "action-bar", "type": "registry:ui", "files": [ { "path": "ui/action-bar.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { cn } from \"@/lib/utils\";\nimport { useAsRef } from \"@/registry/bases/base/hooks/use-as-ref\";\nimport { useIsomorphicLayoutEffect } from \"@/registry/bases/base/hooks/use-isomorphic-layout-effect\";\nimport { Button } from \"@/registry/bases/base/ui/button\";\nimport { useDirection } from \"@/registry/bases/base/ui/direction\";\n\nconst ROOT_NAME = \"ActionBar\";\nconst GROUP_NAME = \"ActionBarGroup\";\nconst ITEM_NAME = \"ActionBarItem\";\nconst CLOSE_NAME = \"ActionBarClose\";\nconst SEPARATOR_NAME = \"ActionBarSeparator\";\nconst ITEM_SELECT = \"actionbar.itemSelect\";\nconst ENTRY_FOCUS = \"actionbarFocusGroup.onEntryFocus\";\nconst EVENT_OPTIONS = { bubbles: false, cancelable: true };\n\ntype Direction = \"ltr\" | \"rtl\";\ntype Orientation = \"horizontal\" | \"vertical\";\n\ninterface DivProps\n extends useRender.ComponentProps<\"div\">,\n React.ComponentProps<\"div\"> {}\n\ntype RootElement = React.ComponentRef;\ntype ItemElement = React.ComponentRef;\n\nfunction focusFirst(\n candidates: React.RefObject[],\n preventScroll = false,\n) {\n const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;\n for (const candidateRef of candidates) {\n const candidate = candidateRef.current;\n if (!candidate) continue;\n if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;\n candidate.focus({ preventScroll });\n if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;\n }\n}\n\nfunction wrapArray(array: T[], startIndex: number) {\n return array.map(\n (_, index) => array[(startIndex + index) % array.length] as T,\n );\n}\n\nfunction getDirectionAwareKey(key: string, dir?: Direction) {\n if (dir !== \"rtl\") return key;\n return key === \"ArrowLeft\"\n ? \"ArrowRight\"\n : key === \"ArrowRight\"\n ? \"ArrowLeft\"\n : key;\n}\n\ninterface ItemData {\n id: string;\n ref: React.RefObject;\n disabled: boolean;\n}\n\ninterface ActionBarContextValue {\n onOpenChange?: (open: boolean) => void;\n dir: Direction;\n orientation: Orientation;\n loop: boolean;\n}\n\nconst ActionBarContext = React.createContext(\n null,\n);\n\nfunction useActionBarContext(consumerName: string) {\n const context = React.useContext(ActionBarContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\ninterface FocusContextValue {\n tabStopId: string | null;\n onItemFocus: (tabStopId: string) => void;\n onItemShiftTab: () => void;\n onFocusableItemAdd: () => void;\n onFocusableItemRemove: () => void;\n onItemRegister: (item: ItemData) => void;\n onItemUnregister: (id: string) => void;\n getItems: () => ItemData[];\n}\n\nconst FocusContext = React.createContext(null);\n\nfunction useFocusContext(consumerName: string) {\n const context = React.useContext(FocusContext);\n if (!context) {\n throw new Error(\n `\\`${consumerName}\\` must be used within \\`FocusProvider\\``,\n );\n }\n return context;\n}\n\ninterface ActionBarProps extends DivProps {\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n onEscapeKeyDown?: (event: KeyboardEvent) => void;\n align?: \"start\" | \"center\" | \"end\";\n alignOffset?: number;\n side?: \"top\" | \"bottom\";\n sideOffset?: number;\n portalContainer?: Element | DocumentFragment | null;\n dir?: Direction;\n orientation?: Orientation;\n loop?: boolean;\n}\n\nfunction ActionBar(props: ActionBarProps) {\n const {\n open = false,\n onOpenChange,\n onEscapeKeyDown,\n side = \"bottom\",\n alignOffset = 0,\n align = \"center\",\n sideOffset = 16,\n portalContainer: portalContainerProp,\n dir: dirProp,\n orientation = \"horizontal\",\n loop = true,\n className,\n style,\n ref,\n render,\n ...rootProps\n } = props;\n\n const [mounted, setMounted] = React.useState(false);\n\n const rootRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, rootRef);\n\n const propsRef = useAsRef({\n onEscapeKeyDown,\n onOpenChange,\n });\n\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n\n React.useLayoutEffect(() => {\n setMounted(true);\n }, []);\n\n React.useEffect(() => {\n if (!open) return;\n\n const ownerDocument = rootRef.current?.ownerDocument ?? document;\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") {\n propsRef.current.onEscapeKeyDown?.(event);\n if (!event.defaultPrevented) {\n propsRef.current.onOpenChange?.(false);\n }\n }\n }\n\n ownerDocument.addEventListener(\"keydown\", onKeyDown);\n return () => ownerDocument.removeEventListener(\"keydown\", onKeyDown);\n }, [open, propsRef]);\n\n const contextValue = React.useMemo(\n () => ({\n onOpenChange,\n dir,\n orientation,\n loop,\n }),\n [onOpenChange, dir, orientation, loop],\n );\n\n const portalContainer =\n portalContainerProp ?? (mounted ? globalThis.document?.body : null);\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"toolbar\" as const,\n \"aria-orientation\": orientation,\n dir,\n ref: composedRef,\n className: cn(\n \"fixed z-50 rounded-lg border bg-card shadow-lg outline-none\",\n \"fade-in-0 zoom-in-95 animate-in duration-250 [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]\",\n \"data-[side=bottom]:slide-in-from-bottom-4 data-[side=top]:slide-in-from-top-4\",\n \"motion-reduce:animate-none motion-reduce:transition-none\",\n orientation === \"horizontal\"\n ? \"flex flex-row items-center gap-2 px-2 py-1.5\"\n : \"flex flex-col items-start gap-2 px-1.5 py-2\",\n className,\n ),\n style: {\n [side]: `${sideOffset}px`,\n ...(align === \"center\" && {\n left: \"50%\",\n translate: \"-50% 0\",\n }),\n ...(align === \"start\" && { left: `${alignOffset}px` }),\n ...(align === \"end\" && { right: `${alignOffset}px` }),\n ...style,\n },\n },\n rootProps,\n ),\n render,\n state: {\n slot: \"action-bar\",\n side,\n align,\n orientation,\n },\n });\n\n if (!portalContainer || !open) return null;\n\n return (\n \n {ReactDOM.createPortal(element, portalContainer)}\n \n );\n}\n\nfunction ActionBarSelection(props: DivProps) {\n const { className, render, ...selectionProps } = props;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: cn(\n \"flex items-center gap-1 rounded-sm border px-2 py-1 font-medium text-sm tabular-nums\",\n className,\n ),\n },\n selectionProps,\n ),\n render,\n state: {\n slot: \"action-bar-selection\",\n },\n });\n}\n\nfunction ActionBarGroup(props: DivProps) {\n const {\n onBlur: onBlurProp,\n onFocus: onFocusProp,\n onMouseDown: onMouseDownProp,\n className,\n render,\n ref,\n ...groupProps\n } = props;\n\n const [tabStopId, setTabStopId] = React.useState(null);\n const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false);\n const [focusableItemCount, setFocusableItemCount] = React.useState(0);\n\n const groupRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, groupRef);\n const isClickFocusRef = React.useRef(false);\n const itemsRef = React.useRef>(new Map());\n\n const { dir, orientation } = useActionBarContext(GROUP_NAME);\n\n const onItemFocus = React.useCallback((tabStopId: string) => {\n setTabStopId(tabStopId);\n }, []);\n\n const onItemShiftTab = React.useCallback(() => {\n setIsTabbingBackOut(true);\n }, []);\n\n const onFocusableItemAdd = React.useCallback(() => {\n setFocusableItemCount((prevCount) => prevCount + 1);\n }, []);\n\n const onFocusableItemRemove = React.useCallback(() => {\n setFocusableItemCount((prevCount) => prevCount - 1);\n }, []);\n\n const onItemRegister = React.useCallback((item: ItemData) => {\n itemsRef.current.set(item.id, item);\n }, []);\n\n const onItemUnregister = React.useCallback((id: string) => {\n itemsRef.current.delete(id);\n }, []);\n\n const getItems = React.useCallback(() => {\n return Array.from(itemsRef.current.values())\n .filter((item) => item.ref.current)\n .sort((a, b) => {\n const elementA = a.ref.current;\n const elementB = b.ref.current;\n if (!elementA || !elementB) return 0;\n const position = elementA.compareDocumentPosition(elementB);\n if (position & Node.DOCUMENT_POSITION_FOLLOWING) {\n return -1;\n }\n if (position & Node.DOCUMENT_POSITION_PRECEDING) {\n return 1;\n }\n return 0;\n });\n }, []);\n\n const onBlur = React.useCallback(\n (event: React.FocusEvent) => {\n onBlurProp?.(event);\n if (event.defaultPrevented) return;\n\n setIsTabbingBackOut(false);\n },\n [onBlurProp],\n );\n\n const onFocus = React.useCallback(\n (event: React.FocusEvent) => {\n onFocusProp?.(event);\n if (event.defaultPrevented) return;\n\n const isKeyboardFocus = !isClickFocusRef.current;\n if (\n event.target === event.currentTarget &&\n isKeyboardFocus &&\n !isTabbingBackOut\n ) {\n const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);\n event.currentTarget.dispatchEvent(entryFocusEvent);\n\n if (!entryFocusEvent.defaultPrevented) {\n const items = Array.from(itemsRef.current.values()).filter(\n (item) => !item.disabled,\n );\n const currentItem = items.find((item) => item.id === tabStopId);\n\n const candidateItems = [currentItem, ...items].filter(\n Boolean,\n ) as ItemData[];\n const candidateRefs = candidateItems.map((item) => item.ref);\n focusFirst(candidateRefs, false);\n }\n }\n isClickFocusRef.current = false;\n },\n [onFocusProp, isTabbingBackOut, tabStopId],\n );\n\n const onMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n onMouseDownProp?.(event);\n if (event.defaultPrevented) return;\n\n isClickFocusRef.current = true;\n },\n [onMouseDownProp],\n );\n\n const focusContextValue = React.useMemo(\n () => ({\n tabStopId,\n onItemFocus,\n onItemShiftTab,\n onFocusableItemAdd,\n onFocusableItemRemove,\n onItemRegister,\n onItemUnregister,\n getItems,\n }),\n [\n tabStopId,\n onItemFocus,\n onItemShiftTab,\n onFocusableItemAdd,\n onFocusableItemRemove,\n onItemRegister,\n onItemUnregister,\n getItems,\n ],\n );\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"group\" as const,\n dir,\n tabIndex: isTabbingBackOut || focusableItemCount === 0 ? -1 : 0,\n ref: composedRef,\n className: cn(\n \"flex gap-2 outline-none\",\n orientation === \"horizontal\"\n ? \"items-center\"\n : \"w-full flex-col items-start\",\n className,\n ),\n onBlur,\n onFocus,\n onMouseDown,\n },\n groupProps,\n ),\n render,\n state: {\n slot: \"action-bar-group\",\n orientation,\n },\n });\n\n return (\n \n {element}\n \n );\n}\n\ninterface ActionBarItemProps\n extends Omit, \"onSelect\"> {\n onSelect?: (event: Event) => void;\n}\n\nfunction ActionBarItem(props: ActionBarItemProps) {\n const {\n onSelect,\n onClick: onClickProp,\n onFocus: onFocusProp,\n onKeyDown: onKeyDownProp,\n onMouseDown: onMouseDownProp,\n className,\n disabled,\n ref,\n ...itemProps\n } = props;\n\n const itemRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, itemRef);\n const isMouseClickRef = React.useRef(false);\n\n const { onOpenChange, dir, orientation, loop } =\n useActionBarContext(ITEM_NAME);\n const focusContext = useFocusContext(ITEM_NAME);\n\n const itemId = React.useId();\n const isTabStop = focusContext.tabStopId === itemId;\n\n useIsomorphicLayoutEffect(() => {\n focusContext.onItemRegister({\n id: itemId,\n ref: itemRef,\n disabled: !!disabled,\n });\n\n if (!disabled) {\n focusContext.onFocusableItemAdd();\n }\n\n return () => {\n focusContext.onItemUnregister(itemId);\n if (!disabled) {\n focusContext.onFocusableItemRemove();\n }\n };\n }, [focusContext, itemId, disabled]);\n\n const onClick: ActionBarItemProps[\"onClick\"] = React.useCallback(\n (event) => {\n onClickProp?.(event);\n if (event.defaultPrevented) return;\n\n const item = itemRef.current;\n if (!item) return;\n\n const itemSelectEvent = new CustomEvent(ITEM_SELECT, {\n bubbles: true,\n cancelable: true,\n });\n\n item.addEventListener(ITEM_SELECT, (evt) => onSelect?.(evt), {\n once: true,\n });\n\n item.dispatchEvent(itemSelectEvent);\n\n if (!itemSelectEvent.defaultPrevented) {\n onOpenChange?.(false);\n }\n },\n [onClickProp, onOpenChange, onSelect],\n );\n\n const onFocus: ActionBarItemProps[\"onFocus\"] = React.useCallback(\n (event) => {\n onFocusProp?.(event);\n if (event.defaultPrevented) return;\n\n focusContext.onItemFocus(itemId);\n isMouseClickRef.current = false;\n },\n [onFocusProp, focusContext, itemId],\n );\n\n const onKeyDown: ActionBarItemProps[\"onKeyDown\"] = React.useCallback(\n (event) => {\n onKeyDownProp?.(event);\n if (event.defaultPrevented) return;\n\n if (event.key === \"Tab\" && event.shiftKey) {\n focusContext.onItemShiftTab();\n return;\n }\n\n if (event.target !== event.currentTarget) return;\n\n const key = getDirectionAwareKey(event.key, dir);\n let focusIntent: \"first\" | \"last\" | \"prev\" | \"next\" | undefined;\n\n if (orientation === \"horizontal\") {\n if (key === \"ArrowLeft\") focusIntent = \"prev\";\n else if (key === \"ArrowRight\") focusIntent = \"next\";\n else if (key === \"Home\") focusIntent = \"first\";\n else if (key === \"End\") focusIntent = \"last\";\n } else {\n if (key === \"ArrowUp\") focusIntent = \"prev\";\n else if (key === \"ArrowDown\") focusIntent = \"next\";\n else if (key === \"Home\") focusIntent = \"first\";\n else if (key === \"End\") focusIntent = \"last\";\n }\n\n if (focusIntent !== undefined) {\n if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)\n return;\n event.preventDefault();\n\n const items = focusContext.getItems().filter((item) => !item.disabled);\n let candidateRefs = items.map((item) => item.ref);\n\n if (focusIntent === \"last\") {\n candidateRefs.reverse();\n } else if (focusIntent === \"prev\" || focusIntent === \"next\") {\n if (focusIntent === \"prev\") candidateRefs.reverse();\n const currentIndex = candidateRefs.findIndex(\n (ref) => ref.current === event.currentTarget,\n );\n candidateRefs = loop\n ? wrapArray(candidateRefs, currentIndex + 1)\n : candidateRefs.slice(currentIndex + 1);\n }\n\n queueMicrotask(() => focusFirst(candidateRefs));\n }\n },\n [onKeyDownProp, focusContext, dir, orientation, loop],\n );\n\n const onMouseDown: ActionBarItemProps[\"onMouseDown\"] = React.useCallback(\n (event) => {\n onMouseDownProp?.(event);\n if (event.defaultPrevented) return;\n\n isMouseClickRef.current = true;\n\n if (disabled) {\n event.preventDefault();\n } else {\n focusContext.onItemFocus(itemId);\n }\n },\n [onMouseDownProp, focusContext, itemId, disabled],\n );\n\n return (\n \n );\n}\n\ninterface ActionBarCloseProps\n extends useRender.ComponentProps<\"button\">,\n React.ComponentProps<\"button\"> {}\n\nfunction ActionBarClose({\n render,\n className,\n onClick,\n ...props\n}: ActionBarCloseProps) {\n const { onOpenChange } = useActionBarContext(CLOSE_NAME);\n\n const onCloseClick: ActionBarCloseProps[\"onClick\"] = React.useCallback(\n (event) => {\n onClick?.(event);\n if (event.defaultPrevented) return;\n\n onOpenChange?.(false);\n },\n [onOpenChange, onClick],\n );\n\n return useRender({\n defaultTagName: \"button\",\n props: mergeProps<\"button\">(\n {\n type: \"button\" as const,\n className: cn(\n \"rounded-xs opacity-70 outline-none hover:opacity-100 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n className,\n ),\n onClick: onCloseClick,\n },\n props,\n ),\n render,\n state: {\n slot: \"action-bar-close\",\n },\n });\n}\n\ninterface ActionBarSeparatorProps extends DivProps {\n orientation?: Orientation;\n}\n\nfunction ActionBarSeparator(props: ActionBarSeparatorProps) {\n const {\n orientation: orientationProp,\n render,\n className,\n ...separatorProps\n } = props;\n\n const context = useActionBarContext(SEPARATOR_NAME);\n const orientation = orientationProp ?? context.orientation;\n\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"separator\" as const,\n \"aria-orientation\": orientation,\n \"aria-hidden\": \"true\" as const,\n className: cn(\n \"in-data-[slot=action-bar-selection]:ml-0.5 in-data-[slot=action-bar-selection]:h-4 in-data-[slot=action-bar-selection]:w-px bg-border\",\n orientation === \"horizontal\" ? \"h-6 w-px\" : \"h-px w-full\",\n className,\n ),\n },\n separatorProps,\n ),\n render,\n state: {\n slot: \"action-bar-separator\",\n orientation,\n },\n });\n}\n\nexport {\n ActionBar,\n ActionBarClose,\n ActionBarGroup,\n ActionBarItem,\n type ActionBarProps,\n ActionBarSelection,\n ActionBarSeparator,\n};\n", "target": "" } ], "registryDependencies": [ "button", "direction", "@diceui/use-as-ref", "@diceui/use-isomorphic-layout-effect" ], "dependencies": [ "@base-ui/react" ] }