From 2028b4117445b0e3e275c17527dcefef8a89a5bc Mon Sep 17 00:00:00 2001 From: BlackPoretsky <84518407+BlackPoretsky@users.noreply.github.com> Date: Sat, 19 Apr 2025 21:51:47 +0000 Subject: [PATCH 01/16] chore: update pnpm workspace configuration and dependencies - Added new package paths for UI components and hooks in the pnpm workspace. - Updated dependency versions: - fast-glob from ^0.0.0 to ^3.3.3 - rimraf from ^0.0.0 to ^6.0.1 - Added @floating-ui/react dependency at version ^0.27.7 --- .../{ => headless/hooks}/eslint.config.js | 1 - packages/ui/uikit/headless/hooks/package.json | 52 ++ .../hooks/src/useAnimationFinished.ts | 53 ++ .../headless/hooks/src/useControlledState.ts | 78 ++ .../hooks/src/useEnhancedClickHandler.ts | 27 + .../headless/hooks/src/useEnhancedEffect.ts | 5 + .../headless/hooks/src/useEventCallback.ts | 16 + .../uikit/headless/hooks/src/useLatestRef.ts | 12 + .../ui/uikit/headless/hooks/src/useOnMount.ts | 6 + .../hooks/src/useOpenChangeComplete.ts | 31 + .../hooks/src/useOpenInteractionType.ts | 16 + .../headless/hooks/src/useTransitionStatus.ts | 50 ++ .../ui/uikit/headless/hooks/tsconfig.json | 11 + .../ui/uikit/headless/utils/eslint.config.js | 3 + packages/ui/uikit/headless/utils/package.json | 55 ++ .../headless/utils/src/FloatingPortalLite.tsx | 15 + .../uikit/headless/utils/src/contextBuild.tsx | 122 +++ .../headless/utils/src/visuallyHidden.ts | 14 + .../ui/uikit/headless/utils/tsconfig.json | 8 + packages/ui/uikit/package.json | 31 - packages/ui/uikit/tsconfig.json | 13 - pnpm-lock.yaml | 699 +++--------------- pnpm-workspace.yaml | 10 +- 23 files changed, 692 insertions(+), 636 deletions(-) rename packages/ui/uikit/{ => headless/hooks}/eslint.config.js (64%) create mode 100644 packages/ui/uikit/headless/hooks/package.json create mode 100644 packages/ui/uikit/headless/hooks/src/useAnimationFinished.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useControlledState.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useEnhancedClickHandler.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useEnhancedEffect.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useEventCallback.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useLatestRef.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useOnMount.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useOpenChangeComplete.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useOpenInteractionType.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useTransitionStatus.ts create mode 100644 packages/ui/uikit/headless/hooks/tsconfig.json create mode 100644 packages/ui/uikit/headless/utils/eslint.config.js create mode 100644 packages/ui/uikit/headless/utils/package.json create mode 100644 packages/ui/uikit/headless/utils/src/FloatingPortalLite.tsx create mode 100644 packages/ui/uikit/headless/utils/src/contextBuild.tsx create mode 100644 packages/ui/uikit/headless/utils/src/visuallyHidden.ts create mode 100644 packages/ui/uikit/headless/utils/tsconfig.json delete mode 100644 packages/ui/uikit/package.json delete mode 100644 packages/ui/uikit/tsconfig.json diff --git a/packages/ui/uikit/eslint.config.js b/packages/ui/uikit/headless/hooks/eslint.config.js similarity index 64% rename from packages/ui/uikit/eslint.config.js rename to packages/ui/uikit/headless/hooks/eslint.config.js index f8b2f29d..bed54eaa 100644 --- a/packages/ui/uikit/eslint.config.js +++ b/packages/ui/uikit/headless/hooks/eslint.config.js @@ -1,4 +1,3 @@ import { eslintReactConfig } from '@flippo/eslint'; -/** @type {import("@flippo/eslint").ESLintAntfuConfig[]} */ export default eslintReactConfig(import.meta.dirname); diff --git a/packages/ui/uikit/headless/hooks/package.json b/packages/ui/uikit/headless/hooks/package.json new file mode 100644 index 00000000..add4df64 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/package.json @@ -0,0 +1,52 @@ +{ + "name": "@internal_headless/hooks", + "type": "module", + "version": "1.0.0", + "source": "./src/index.ts", + "private": true, + "packageManager": "pnpm@10.7.0", + "description": "", + "author": "", + "license": "ISC", + "keywords": [], + "main": "./src/index.ts", + "module": "./src/index.ts", + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } + }, + "files": [ + "README.md", + "dist" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "devDependencies": { + "@flippo/eslint": "workspace:*", + "@flippo/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "eslint": "catalog:", + "eslint-plugin-format": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ui/uikit/headless/hooks/src/useAnimationFinished.ts b/packages/ui/uikit/headless/hooks/src/useAnimationFinished.ts new file mode 100644 index 00000000..79b2933a --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useAnimationFinished.ts @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { useEventCallback } from './useEventCallback'; + +export function useAnimationFinished(rootRef: React.RefObject, waitNextTick: boolean = false) { + const frameRef = React.useRef(-1); + const timerRef = React.useRef(-1); + + const cancelTasks = useEventCallback(() => { + cancelAnimationFrame(frameRef.current); + clearTimeout(timerRef.current); + }); + + React.useEffect(() => cancelTasks, [cancelTasks]); + return useEventCallback((fnToExecute: ()=> void, signal: AbortSignal | null = null) => { + cancelTasks(); + + const element = rootRef.current; + + if (!element) + return; + + if (typeof element.getAnimations !== 'function') { + fnToExecute(); + } + else { + frameRef.current = requestAnimationFrame(() => { + function exec() { + if (!element) { + return; + } + + Promise.allSettled(element.getAnimations().map((animation) => animation.finished)).then(() => { + if (signal && signal.aborted) + return; + + // eslint-disable-next-line react-dom/no-flush-sync + ReactDOM.flushSync(fnToExecute); + }); + } + + if (waitNextTick) { + timerRef.current = setTimeout(exec); + } + else { + exec(); + } + }); + } + }); +} diff --git a/packages/ui/uikit/headless/hooks/src/useControlledState.ts b/packages/ui/uikit/headless/hooks/src/useControlledState.ts new file mode 100644 index 00000000..8171889b --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useControlledState.ts @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; + +type TChangeHandler = (state: T)=> void; +type TSetState = React.Dispatch>; + +type TUseControlledStateParams = { + prop?: T; + defaultProp: T; + onChange: TChangeHandler; + caller?: string; +}; + +export function useControlledState(params: TUseControlledStateParams) { + const { prop, defaultProp, onChange, caller } = params; + const [uncontrolledProp, setUncontrolledSet, onChangeRef] = useUncontrolledState({ defaultProp, onChange }); + + const isControlled = prop !== undefined; + const state = isControlled ? prop : uncontrolledProp; + + /* eslint-disable react-hooks/rules-of-hooks */ + // eslint-disable-next-line node/prefer-global/process + if (process.env.NODE_ENV !== 'production') { + const isControlledRef = React.useRef(prop !== undefined); + React.useEffect(() => { + const wasControlled = isControlledRef.current; + if (wasControlled !== isControlled) { + const from = wasControlled ? 'controlled' : 'uncontrolled'; + const to = isControlled ? 'controlled' : 'uncontrolled'; + console.warn( + `${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.` + ); + } + + isControlledRef.current = isControlled; + }, [isControlled, caller]); + } + /* eslint-enable react-hooks/rules-of-hooks */ + + const setState = React.useCallback>((nextState: React.SetStateAction) => { + if (isControlled) { + const newState = isFunction(nextState) ? nextState(state) : nextState; + if (newState !== state) { + onChangeRef.current?.(state); + } + } + else { + setUncontrolledSet(nextState); + } + }, [state, isControlled, setUncontrolledSet, onChangeRef]); + + return [state, setState]; +} + +function useUncontrolledState(params: Omit, 'prop' | 'caller'>): [Value: T, SetValue: TSetState, OnChangeRef: React.RefObject | undefined>] { + const { defaultProp, onChange } = params; + const [state, setState] = React.useState(defaultProp); + const prevStateRef = React.useRef(state); + + const onChangeRef = React.useRef(onChange); + React.useInsertionEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + React.useEffect(() => { + if (prevStateRef.current !== state) { + onChangeRef.current?.(state); + prevStateRef.current = state; + } + }, [state, setState, onChangeRef]); + + return [state, setState, onChangeRef]; +} + +function isFunction(value: unknown): value is (...args: any[])=> any { + return typeof value === 'function'; +} diff --git a/packages/ui/uikit/headless/hooks/src/useEnhancedClickHandler.ts b/packages/ui/uikit/headless/hooks/src/useEnhancedClickHandler.ts new file mode 100644 index 00000000..e378a224 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useEnhancedClickHandler.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export type TInteraction = 'mouse' | 'touch' | 'pen' | 'keyboard' | ''; + +export function useEnhancedClickHandler(handler: (event: React.MouseEvent | React.PointerEvent, interactionType: TInteraction)=> void) { + const lastInteractionTypeRef = React.useRef(''); + + const onPointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.defaultPrevented) + return; + + lastInteractionTypeRef.current = event.pointerType; + }, []); + + const onClick = React.useCallback((event: React.MouseEvent | React.PointerEvent) => { + if (event.detail) + handler(event, 'keyboard'); + else if ('pointerType' in event) + handler(event, event.pointerType); + else + handler(event, lastInteractionTypeRef.current); + + lastInteractionTypeRef.current = ''; + }, [handler]); + + return { onClick, onPointerDown }; +} diff --git a/packages/ui/uikit/headless/hooks/src/useEnhancedEffect.ts b/packages/ui/uikit/headless/hooks/src/useEnhancedEffect.ts new file mode 100644 index 00000000..38690de3 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useEnhancedEffect.ts @@ -0,0 +1,5 @@ +'use client'; + +import * as React from 'react'; + +export const useEnhancedEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; diff --git a/packages/ui/uikit/headless/hooks/src/useEventCallback.ts b/packages/ui/uikit/headless/hooks/src/useEventCallback.ts new file mode 100644 index 00000000..e5e21e68 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useEventCallback.ts @@ -0,0 +1,16 @@ +'use client'; + +import * as React from 'react'; +import { useEnhancedEffect } from './useEnhancedEffect'; + +type AnyFunction = (...args: any[])=> any; + +export function useEventCallback(fn?: Fn) { + const fnRef = React.useRef(fn); + + useEnhancedEffect(() => { + fnRef.current = fn; + }); + + return React.useCallback((...args) => fnRef.current?.(...args), []) as Fn; +} diff --git a/packages/ui/uikit/headless/hooks/src/useLatestRef.ts b/packages/ui/uikit/headless/hooks/src/useLatestRef.ts new file mode 100644 index 00000000..05a62998 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useLatestRef.ts @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { useEnhancedEffect } from './useEnhancedEffect'; + +export function useLatestRef(value: T) { + const valueRef = React.useRef(value); + + useEnhancedEffect(() => { + valueRef.current = value; + }); + + return valueRef; +} diff --git a/packages/ui/uikit/headless/hooks/src/useOnMount.ts b/packages/ui/uikit/headless/hooks/src/useOnMount.ts new file mode 100644 index 00000000..4f79ce47 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useOnMount.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +export function useOnMount(effectCallback: React.EffectCallback) { + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(effectCallback, []); +} diff --git a/packages/ui/uikit/headless/hooks/src/useOpenChangeComplete.ts b/packages/ui/uikit/headless/hooks/src/useOpenChangeComplete.ts new file mode 100644 index 00000000..2590dc96 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useOpenChangeComplete.ts @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import { useAnimationFinished } from './useAnimationFinished'; +import { useEventCallback } from './useEventCallback'; +import { useLatestRef } from './useLatestRef'; + +type TUseOpenChangeCompleteParameters = { + enabled: boolean; + ref: React.RefObject; + onComplete: ()=> void; + open?: boolean; +}; + +export function useOpenChangeComplete(params: TUseOpenChangeCompleteParameters) { + const { enabled = true, open, ref, onComplete: onCompleteParam } = params; + + const openRef = useLatestRef(open); + const onComplete = useEventCallback(onCompleteParam); + const runOnAnimationFinished = useAnimationFinished(ref, open); + + React.useEffect(() => { + if (!enabled) + return; + + runOnAnimationFinished(() => { + if (open === openRef.current) + onComplete(); + }); + }, [open, enabled, onComplete, runOnAnimationFinished, openRef]); +} diff --git a/packages/ui/uikit/headless/hooks/src/useOpenInteractionType.ts b/packages/ui/uikit/headless/hooks/src/useOpenInteractionType.ts new file mode 100644 index 00000000..d14a0566 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useOpenInteractionType.ts @@ -0,0 +1,16 @@ +import type { TInteraction } from './useEnhancedClickHandler'; +import * as React from 'react'; +import { useEnhancedClickHandler } from './useEnhancedClickHandler'; + +export function useOpenInteractionType(open: boolean) { + const [openMethod, setOpenMethod] = React.useState(null); + + const triggerClick = React.useCallback((_: React.MouseEvent | React.PointerEvent, interactionType: TInteraction) => { + if (!open) + setOpenMethod(interactionType); + }, [open, setOpenMethod]); + + const { onClick, onPointerDown } = useEnhancedClickHandler(triggerClick); + + return React.useMemo(() => ({ onClick, triggerProps: { onPointerDown, openMethod } }), [onClick, onPointerDown, openMethod]); +} diff --git a/packages/ui/uikit/headless/hooks/src/useTransitionStatus.ts b/packages/ui/uikit/headless/hooks/src/useTransitionStatus.ts new file mode 100644 index 00000000..1101393c --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useTransitionStatus.ts @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import { useEnhancedEffect } from './useEnhancedEffect'; + +export type TransitionStatus = 'starting' | 'ending' | 'idle' | undefined; + +export function useTransitionStatus(open: boolean) { + const [transitionStatus, setTransitionStatus] = React.useState(open ? 'idle' : undefined); + const [mounted, setMounted] = React.useState(open); + + if (open && !mounted) { + setMounted(true); + setTransitionStatus('starting'); + } + + if (!open && mounted && transitionStatus !== 'ending') { + setTransitionStatus('ending'); + } + + if (!open && !mounted && transitionStatus === 'ending') { + setTransitionStatus(undefined); + } + + useEnhancedEffect(() => { + if (!open) { + return undefined; + } + if (open && mounted && transitionStatus !== 'idle') { + setTransitionStatus('starting'); + } + + const frame = requestAnimationFrame(() => { + setTransitionStatus('idle'); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [open, mounted, setTransitionStatus, transitionStatus]); + + return React.useMemo( + () => ({ + mounted, + setMounted, + transitionStatus + }), + [mounted, transitionStatus] + ); +} diff --git a/packages/ui/uikit/headless/hooks/tsconfig.json b/packages/ui/uikit/headless/hooks/tsconfig.json new file mode 100644 index 00000000..292ec952 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@flippo/tsconfig", + "compilerOptions": { + "jsx": "react", + + "lib": ["dom", "ES2024"], + "types": ["react", "react-dom", "node"] + }, + "include": ["src", "eslint.config.js"], + "exclude": ["node_modules"] +} diff --git a/packages/ui/uikit/headless/utils/eslint.config.js b/packages/ui/uikit/headless/utils/eslint.config.js new file mode 100644 index 00000000..bed54eaa --- /dev/null +++ b/packages/ui/uikit/headless/utils/eslint.config.js @@ -0,0 +1,3 @@ +import { eslintReactConfig } from '@flippo/eslint'; + +export default eslintReactConfig(import.meta.dirname); diff --git a/packages/ui/uikit/headless/utils/package.json b/packages/ui/uikit/headless/utils/package.json new file mode 100644 index 00000000..99885bc9 --- /dev/null +++ b/packages/ui/uikit/headless/utils/package.json @@ -0,0 +1,55 @@ +{ + "name": "@internal_headless/utils", + "type": "module", + "version": "1.0.0", + "source": "./src/index.ts", + "private": true, + "packageManager": "pnpm@10.7.0", + "description": "", + "author": "", + "license": "ISC", + "keywords": [], + "main": "./src/index.ts", + "module": "./src/index.ts", + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } + }, + "files": [ + "README.md", + "dist" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@floating-ui/react": "catalog:" + }, + "devDependencies": { + "@flippo/eslint": "workspace:*", + "@flippo/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "eslint": "catalog:", + "eslint-plugin-format": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ui/uikit/headless/utils/src/FloatingPortalLite.tsx b/packages/ui/uikit/headless/utils/src/FloatingPortalLite.tsx new file mode 100644 index 00000000..6475db81 --- /dev/null +++ b/packages/ui/uikit/headless/utils/src/FloatingPortalLite.tsx @@ -0,0 +1,15 @@ +import type * as React from 'react'; +import { useFloatingPortalNode } from '@floating-ui/react'; +import * as ReactDOM from 'react-dom'; + +type TFloatingPortalLiteProps = { + children?: React.ReactNode; + root?: HTMLElement | null | React.Ref; +}; + +export function FloatingPortalLite(props: TFloatingPortalLiteProps) { + const { root, children } = props; + const portalNode = useFloatingPortalNode({ root }); + + return portalNode && ReactDOM.createPortal(children, portalNode); +} diff --git a/packages/ui/uikit/headless/utils/src/contextBuild.tsx b/packages/ui/uikit/headless/utils/src/contextBuild.tsx new file mode 100644 index 00000000..ef38e6c5 --- /dev/null +++ b/packages/ui/uikit/headless/utils/src/contextBuild.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; + +type TProviderProps = React.PropsWithChildren; + +function contextBuild(rootComponentName: string, defaultContextValue?: TContextValue) { + const Context = React.createContext(defaultContextValue); + + function Provider(props: TProviderProps): React.ReactElement> { + const { children, ...context } = props; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const memorizedContext = React.useMemo(() => context, Object.values(context)) as TContextValue; + return {children}; + } + + const PROVIDER_NAME = `${rootComponentName}Provider`; + + Provider.displayName = PROVIDER_NAME; + + function useContext(ownerName: string) { + const context = React.use(Context); + + if (context) + return context; + + if (defaultContextValue) + return defaultContextValue; + + throw new Error(`\`${ownerName}\` must be used in ${PROVIDER_NAME}`); + } + + return { Provider, useContext } as const; +} + +type TScope = { [scopeName: string]: React.Context[] } | undefined; +type TScopeHook = (scope: TScope)=> { [__scopeProp: string]: TScope }; +type TScopeBuild = { + scopeName: string; + (): TScopeHook; +}; + +function contextScopeBuild(scopeName: string, contextScopeBuilderDeps: TScopeBuild[] = []) { + const defaultContexts: any[] = []; + + function contextBuild(rootComponentName: string, defaultContextValue?: TContextValue) { + const DefaultContext = React.createContext(defaultContextValue); + const index = defaultContexts.length; + defaultContexts.push(defaultContexts); + + function Provider(props: TProviderProps & { scope: TScope }): + React.ReactElement & { scope: TScope }> { + const { children, scope, ...context } = props; + const Context = scope?.[scopeName]?.[index] ?? DefaultContext; + + // eslint-disable-next-line react-hooks/exhaustive-deps + const memorizedContext = React.useMemo(() => context, Object.values(context)) as TContextValue; + return {children}; + } + + const PROVIDER_NAME = `${rootComponentName}Provider`; + + Provider.displayName = PROVIDER_NAME; + + function useContext(ownerName: string, scope: TScope) { + const Context = scope?.[scopeName]?.[index] ?? DefaultContext; + const context = React.use(Context); + + if (context) + return context; + + if (defaultContextValue) + return defaultContextValue; + + throw new Error(`\`${ownerName}\` must be used in ${PROVIDER_NAME}`); + } + + return { Provider, useContext } as const; + } + + const scopeBuild: TScopeBuild = () => { + const scopeContexts = defaultContexts.map((defaultContext) => React.createContext(defaultContext)); + + return function useScope(scope: TScope) { + const contexts = scope?.[scopeName] ?? scopeContexts; + + return React.useMemo(() => ({ [`__scope${scopeName}`]: { ...scope, [scopeName]: contexts } }), [scope, contexts]); + }; + }; + + scopeBuild.scopeName = scopeName; + + return { contextBuild, scopeBuild: composeContextScope(scopeBuild, ...contextScopeBuilderDeps) } as const; +} + +function composeContextScope(...scopes: [TScopeBuild, ...TScopeBuild[]]): TScopeBuild { + const baseScope = scopes[0]; + if (scopes.length === 1) + return baseScope; + + const scopeBuild: TScopeBuild = () => { + const scopeHooks = scopes.map((scopeBuild) => ({ useScope: scopeBuild(), scopeName: scopeBuild.scopeName })); + + return function useComposedScopes(overrideScopes) { + const nextScopes = scopeHooks.reduce((nextScopes, { useScope, scopeName }) => { + // We are calling a hook inside a callback which React warns against to avoid inconsistent + // renders, however, scoping doesn't have render side effects so we ignore the rule. + // eslint-disable-next-line react-hooks/rules-of-hooks + const scopeProps = useScope(overrideScopes); + const currentScope = scopeProps[`__scope${scopeName}`]; + return { ...nextScopes, ...currentScope }; + }, {}); + + return React.useMemo(() => ({ [`__scope${baseScope.scopeName}`]: nextScopes }), [nextScopes]); + }; + }; + + scopeBuild.scopeName = baseScope.scopeName; + return scopeBuild; +} + +export { contextBuild, contextScopeBuild }; +export type { TScope, TScopeBuild }; diff --git a/packages/ui/uikit/headless/utils/src/visuallyHidden.ts b/packages/ui/uikit/headless/utils/src/visuallyHidden.ts new file mode 100644 index 00000000..c5c09390 --- /dev/null +++ b/packages/ui/uikit/headless/utils/src/visuallyHidden.ts @@ -0,0 +1,14 @@ +import type * as React from 'react'; + +export const visuallyHidden: React.CSSProperties = { + clip: 'rect(0, 0, 0, 0)', + overflow: 'hidden', + position: 'fixed', + top: 0, + left: 0, + border: 0, + padding: 0, + margin: -1, + width: 1, + height: 1 +}; diff --git a/packages/ui/uikit/headless/utils/tsconfig.json b/packages/ui/uikit/headless/utils/tsconfig.json new file mode 100644 index 00000000..9329cd96 --- /dev/null +++ b/packages/ui/uikit/headless/utils/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@flippo/tsconfig", + "compilerOptions": { + "jsx": "react" + }, + "include": ["src", "eslint.config.js"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/packages/ui/uikit/package.json b/packages/ui/uikit/package.json deleted file mode 100644 index 4d11d1aa..00000000 --- a/packages/ui/uikit/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@flippo/ui", - "type": "module", - "version": "0.0.0", - "private": true, - "exports": { - "./*": "./src/*.tsx" - }, - "scripts": { - "lint": "eslint . --max-warnings 0", - "generate:component": "turbo gen react-component", - "check:types": "tsc --noEmit" - }, - "dependencies": { - "react": "catalog:", - "react-dom": "catalog:" - }, - "devDependencies": { - "@eslint-react/eslint-plugin": "catalog:", - "@flippo/eslint": "workspace:*", - "@flippo/tsconfig": "workspace:*", - "@turbo/gen": "catalog:", - "@types/node": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "eslint": "catalog:", - "eslint-plugin-react-hooks": "catalog:", - "eslint-plugin-react-refresh": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/ui/uikit/tsconfig.json b/packages/ui/uikit/tsconfig.json deleted file mode 100644 index 4a941ab0..00000000 --- a/packages/ui/uikit/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "@flippo/tsconfig", - "compilerOptions": { - "jsx": "react-jsx", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["node", "react", "react-dom"], - "allowJs": true, - "checkJs": false, - "isolatedDeclarations": false - }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["node_modules", "dist", "eslint.config.js"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9947ba3a..cadd9bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ catalogs: '@testplane/url-decorator': specifier: ^1.0.0 version: 1.0.0 - '@turbo/gen': - specifier: ^2.4.4 - version: 2.5.0 '@types/eslint': specifier: ^9.6.1 version: 9.6.1 @@ -138,6 +135,9 @@ catalogs: eslint-plugin-turbo: specifier: ^2.5.0 version: 2.5.0 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 framer-motion: specifier: ^12.6.2 version: 12.6.3 @@ -195,6 +195,9 @@ catalogs: react-use-measure: specifier: ^2.1.7 version: 2.1.7 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 sass-embedded: specifier: ^1.86.1 version: 1.86.3 @@ -218,7 +221,7 @@ catalogs: version: 8.27.2 typescript: specifier: ^5.8.2 - version: 5.8.3 + version: 5.7.3 vite: specifier: ^6.2.4 version: 6.2.5 @@ -624,7 +627,7 @@ importers: specifier: 'catalog:' version: 0.4.19(eslint@9.24.0(jiti@2.4.2)) fast-glob: - specifier: ^3.3.3 + specifier: 'catalog:' version: 3.3.3 is-svg: specifier: 'catalog:' @@ -636,7 +639,7 @@ importers: specifier: 'catalog:' version: 19.1.0(react@19.1.0) rimraf: - specifier: ^6.0.1 + specifier: 'catalog:' version: 6.0.1 svgo: specifier: 'catalog:' @@ -645,30 +648,57 @@ importers: specifier: 'catalog:' version: 5.7.3 - packages/ui/uikit: - dependencies: + packages/ui/uikit/headless/hooks: + devDependencies: + '@flippo/eslint': + specifier: workspace:* + version: link:../../../../eslint + '@flippo/tsconfig': + specifier: workspace:* + version: link:../../../../tsconfig + '@types/node': + specifier: 'catalog:' + version: 22.14.0 + '@types/react': + specifier: 'catalog:' + version: 19.0.12 + '@types/react-dom': + specifier: 'catalog:' + version: 19.0.4(@types/react@19.0.12) + eslint: + specifier: 'catalog:' + version: 9.24.0(jiti@2.4.2) + eslint-plugin-format: + specifier: 'catalog:' + version: 1.0.1(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 5.2.0(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-react-refresh: + specifier: 'catalog:' + version: 0.4.19(eslint@9.24.0(jiti@2.4.2)) react: specifier: 'catalog:' version: 19.1.0 react-dom: specifier: 'catalog:' version: 19.1.0(react@19.1.0) - devDependencies: - '@eslint-react/eslint-plugin': + typescript: specifier: 'catalog:' - version: 1.42.1(eslint@9.24.0(jiti@2.4.2))(ts-api-utils@2.1.0(typescript@5.8.3))(typescript@5.8.3) + version: 5.7.3 + + packages/ui/uikit/headless/utils: + dependencies: + '@floating-ui/react': + specifier: ^0.27.7 + version: 0.27.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + devDependencies: '@flippo/eslint': specifier: workspace:* - version: link:../../eslint + version: link:../../../../eslint '@flippo/tsconfig': specifier: workspace:* - version: link:../../tsconfig - '@flippo_ui/icons': - specifier: workspace:* - version: link:../icons - '@turbo/gen': - specifier: 'catalog:' - version: 2.5.0(@types/node@22.14.0)(typescript@5.8.3) + version: link:../../../../tsconfig '@types/node': specifier: 'catalog:' version: 22.14.0 @@ -681,15 +711,24 @@ importers: eslint: specifier: 'catalog:' version: 9.24.0(jiti@2.4.2) + eslint-plugin-format: + specifier: 'catalog:' + version: 1.0.1(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-react-hooks: specifier: 'catalog:' version: 5.2.0(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-react-refresh: specifier: 'catalog:' version: 0.4.19(eslint@9.24.0(jiti@2.4.2)) + react: + specifier: 'catalog:' + version: 19.1.0 + react-dom: + specifier: 'catalog:' + version: 19.1.0(react@19.1.0) typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.7.3 packages: @@ -827,10 +866,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime-corejs3@7.27.0': - resolution: {integrity: sha512-UWjX6t+v+0ckwZ50Y5ShZLnlk95pP5MyW/pon9tiYzl3+18pkTHTFNTKr7rQbfRXPkowt2QAn30o1b6oswszew==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.27.0': resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} @@ -1667,6 +1702,27 @@ packages: '@figma/rest-api-spec@0.24.0': resolution: {integrity: sha512-c/LHQNzfn8HSuo608TnfHJS8K3Ps61MvDbqTTL+qVx2FCIui7dI3RC2bG2/kSHmQXXKTbgbcAADyU6Rf8YkZbQ==} + '@floating-ui/core@1.6.9': + resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + + '@floating-ui/dom@1.6.13': + resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.7': + resolution: {integrity: sha512-5V9pwFeiv+95Jlowq/7oiGISSrdXMTs2jfoSy8k+WM6oI/Skm1WWjPdJWeporN2O4UGcsaCJdirKffKayMoPgw==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@gemini-testing/commander@2.15.4': resolution: {integrity: sha512-GIvIknEbJccKMv2KCgYOOZPy4QgR3/8csvds/WCUGEJPkghHz6VrziG7cBaB4n91PsFEpOwU+uJqXun5sEBpwg==} @@ -2292,14 +2348,6 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@turbo/gen@2.5.0': - resolution: {integrity: sha512-03vzgbv01lzadCCMaEUgXyqGhZuHaI+Umsrjisiyi81oCJF/HygxgHR0FBf21dVssSiEa2opmzcp4RHQmIz0Wg==} - hasBin: true - - '@turbo/workspaces@2.5.0': - resolution: {integrity: sha512-u/IOgWVJ6orFG0MDJ8UeIEOWjhfEBsMskhxC8pg2nRlu1qbEAGIerjhZFk+bEqSTHW1o+fxYr8ix0KEtX4M9Vg==} - hasBin: true - '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -2365,15 +2413,9 @@ packages: '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} - '@types/glob@7.2.0': - resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} - '@types/inquirer@6.5.0': - resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2404,9 +2446,6 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/minimatch@5.1.2': - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2471,12 +2510,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - - '@types/tinycolor2@1.4.6': - resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -2745,10 +2778,6 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -3076,9 +3105,6 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camel-case@3.0.0: - resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -3113,9 +3139,6 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - change-case@3.1.0: - resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} - character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -3178,10 +3201,6 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - clear-require@1.0.1: resolution: {integrity: sha512-CmocmREIWAY0uKBGb+5rl9pBYfAP8t1hXkSqM/uGdAzxjkBcJei1BJFjBel0xtOwVeOKbLTy/5q4ogKZGLltCA==} engines: {node: '>=0.10.0'} @@ -3260,10 +3279,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@10.0.1: - resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} - engines: {node: '>=14'} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3295,9 +3310,6 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - constant-case@2.0.0: - resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==} - content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3331,9 +3343,6 @@ packages: core-js-compat@3.41.0: resolution: {integrity: sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==} - core-js-pure@3.41.0: - resolution: {integrity: sha512-71Gzp96T9YPk63aUvE5Q5qP+DryB4ZloUZPSOebGM88VNw8VNfvdA7z6kGA8iGOTEzAomsRidp4jXSmUIJsL+Q==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3550,10 +3559,6 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} - del@5.1.0: - resolution: {integrity: sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA==} - engines: {node: '>=8'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3626,9 +3631,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dot-case@2.1.1: - resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} - dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -4120,10 +4122,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -4335,10 +4333,6 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - fs-extra@11.3.0: resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} engines: {node: '>=14.14'} @@ -4412,10 +4406,6 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-tsconfig@4.10.0: resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} @@ -4475,10 +4465,6 @@ packages: resolution: {integrity: sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==} engines: {node: '>=18'} - globby@10.0.2: - resolution: {integrity: sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==} - engines: {node: '>=8'} - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -4493,21 +4479,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gradient-string@2.0.2: - resolution: {integrity: sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==} - engines: {node: '>=10'} - grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4539,9 +4516,6 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - header-case@1.0.1: - resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -4606,10 +4580,6 @@ packages: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - i18next-browser-languagedetector@8.0.4: resolution: {integrity: sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==} @@ -4696,10 +4666,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inquirer@7.3.3: - resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==} - engines: {node: '>=8.0.0'} - inquirer@8.2.6: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} @@ -4789,9 +4755,6 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} - is-lower-case@1.1.3: - resolution: {integrity: sha512-+5A1e/WJpLLXZEDlgz4G//WYSHyQBD32qa4Jd3Lw06qQlv3fJHnp3YIHjTQSGzHMgzmVKz2ZP3rBxTHkPw/lxA==} - is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -4808,14 +4771,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-cwd@2.2.0: - resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} - engines: {node: '>=6'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -4868,9 +4823,6 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-upper-case@1.1.2: - resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} - is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -4893,10 +4845,6 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isbinaryfile@4.0.10: - resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} - engines: {node: '>= 8.0.0'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5080,10 +5028,6 @@ packages: lodash.flatmap@4.5.0: resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==} - lodash.get@4.4.2: - resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} - deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. - lodash.isfunction@3.0.9: resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} @@ -5108,10 +5052,6 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - log-symbols@3.0.0: - resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==} - engines: {node: '>=8'} - log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -5137,12 +5077,6 @@ packages: loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - lower-case-first@1.0.2: - resolution: {integrity: sha512-UuxaYakO7XeONbKrZf5FEgkantPf5DUqDayzP5VXZrtRPdH86s4kN47I8B3TW10S4QKiE3ziHNf3kRN//okHjA==} - - lower-case@1.1.4: - resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} - lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -5246,9 +5180,6 @@ packages: merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -5413,10 +5344,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} @@ -5471,9 +5398,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - nested-error-stacks@2.1.1: resolution: {integrity: sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==} @@ -5481,9 +5405,6 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} - no-case@2.3.2: - resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} - no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -5518,10 +5439,6 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true - node-plop@0.26.3: - resolution: {integrity: sha512-Cov028YhBZ5aB7MdMWJEmwyBig43aGL5WT4vdoB28Oitau1zZAcHUn8Sgfk9HM33TqhtLJ9PlM/O0Mv+QpV/4Q==} - engines: {node: '>=8.9.4'} - node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -5551,10 +5468,6 @@ packages: engines: {node: '>=0.8'} hasBin: true - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - npm-which@3.0.1: resolution: {integrity: sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==} engines: {node: '>=4.2.0'} @@ -5610,10 +5523,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@4.1.1: - resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==} - engines: {node: '>=8'} - ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -5653,10 +5562,6 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} - p-map@3.0.0: - resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==} - engines: {node: '>=8'} - p-queue@5.0.0: resolution: {integrity: sha512-6QfeouDf236N+MAxHch0CVIy8o/KBnmhttKjxZoOkUlzqU+u9rZgEyXH3OdckhTgawbqf5rpzmyR+07+Lv0+zg==} engines: {node: '>=8'} @@ -5686,9 +5591,6 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - param-case@2.1.1: - resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5725,12 +5627,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - pascal-case@2.0.1: - resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} - - path-case@2.1.1: - resolution: {integrity: sha512-Ou0N05MioItesaLr9q8TtHVWmJ6fxWdqKB2RohFmNWVyJ+2zeKIeDNWAN6B/Pe7wpzWChhZX6nONYmOnMeJQ/Q==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5779,9 +5675,6 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6265,13 +6158,6 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - registry-auth-token@3.3.2: - resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} - - registry-url@3.1.0: - resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} - engines: {node: '>=0.10.0'} - regjsparser@0.12.0: resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} hasBin: true @@ -6336,11 +6222,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -6367,10 +6248,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@6.6.7: - resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} - engines: {npm: '>=2.0.0'} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -6534,11 +6411,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.6.2: - resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.1: resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} engines: {node: '>=10'} @@ -6548,9 +6420,6 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} - sentence-case@2.1.1: - resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} - serialize-error@11.0.3: resolution: {integrity: sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==} engines: {node: '>=14.16'} @@ -6652,9 +6521,6 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - snake-case@2.1.0: - resolution: {integrity: sha512-FMR5YoPFwOLuh4rRz92dywJjyKYZNLpMn1R5ujVpIYkbA9p01fq8RMg0FkO4M+Yobt4MjHeLTJVm5xFFBHSV2Q==} - snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -6804,10 +6670,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -6880,9 +6742,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - swap-case@1.1.2: - resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} - sync-child-process@1.0.2: resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} engines: {node: '>=16.0.0'} @@ -6899,6 +6758,9 @@ packages: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -6959,9 +6821,6 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -6969,9 +6828,6 @@ packages: resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} engines: {node: '>=12.0.0'} - tinygradient@1.1.5: - resolution: {integrity: sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==} - tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -6980,9 +6836,6 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} - title-case@2.1.1: - resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} - tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -7046,9 +6899,6 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -7133,11 +6983,6 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} @@ -7199,15 +7044,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-check@1.5.4: - resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} - - upper-case-first@1.1.2: - resolution: {integrity: sha512-wINKYvI3Db8dtjikdAqoBbZoP6Q+PZUyfMR7pmwHzjC2quzSkUq5DmPrTtPEqHaz8AGtmsB4TqwapMTM1QAQOQ==} - - upper-case@1.1.3: - resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -7269,10 +7105,6 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - validate-npm-package-name@5.0.1: - resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -7435,9 +7267,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - worker-farm@1.7.0: resolution: {integrity: sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==} @@ -7739,11 +7568,6 @@ snapshots: '@babel/core': 7.26.10 '@babel/helper-plugin-utils': 7.26.5 - '@babel/runtime-corejs3@7.27.0': - dependencies: - core-js-pure: 3.41.0 - regenerator-runtime: 0.14.1 - '@babel/runtime@7.27.0': dependencies: regenerator-runtime: 0.14.1 @@ -8621,6 +8445,31 @@ snapshots: '@figma/rest-api-spec@0.24.0': {} + '@floating-ui/core@1.6.9': + dependencies: + '@floating-ui/utils': 0.2.9 + + '@floating-ui/dom@1.6.13': + dependencies: + '@floating-ui/core': 1.6.9 + '@floating-ui/utils': 0.2.9 + + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.6.13 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.27.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.9 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.9': {} + '@gemini-testing/commander@2.15.4': {} '@gemini-testing/sql.js@2.0.0': {} @@ -9418,40 +9267,6 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@turbo/gen@2.5.0(@types/node@22.14.0)(typescript@5.8.3)': - dependencies: - '@turbo/workspaces': 2.5.0 - commander: 10.0.1 - fs-extra: 10.1.0 - inquirer: 8.2.6 - minimatch: 9.0.5 - node-plop: 0.26.3 - picocolors: 1.0.1 - proxy-agent: 6.5.0 - ts-node: 10.9.2(@types/node@22.14.0)(typescript@5.8.3) - update-check: 1.5.4 - validate-npm-package-name: 5.0.1 - transitivePeerDependencies: - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - supports-color - - typescript - - '@turbo/workspaces@2.5.0': - dependencies: - commander: 10.0.1 - execa: 5.1.1 - fast-glob: 3.3.3 - fs-extra: 10.1.0 - gradient-string: 2.0.2 - inquirer: 8.2.6 - js-yaml: 4.1.0 - ora: 4.1.1 - picocolors: 1.0.1 - semver: 7.6.2 - update-check: 1.5.4 - '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -9540,18 +9355,8 @@ snapshots: '@types/qs': 6.9.18 '@types/serve-static': 1.15.7 - '@types/glob@7.2.0': - dependencies: - '@types/minimatch': 5.1.2 - '@types/node': 22.14.0 - '@types/http-errors@2.0.4': {} - '@types/inquirer@6.5.0': - dependencies: - '@types/through': 0.0.33 - rxjs: 6.6.7 - '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -9580,8 +9385,6 @@ snapshots: '@types/mime@1.3.5': {} - '@types/minimatch@5.1.2': {} - '@types/ms@2.1.0': {} '@types/node@12.20.55': {} @@ -9650,12 +9453,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/through@0.0.33': - dependencies: - '@types/node': 22.14.0 - - '@types/tinycolor2@1.4.6': {} - '@types/unist@3.0.3': {} '@types/uuid@9.0.8': {} @@ -9975,11 +9772,6 @@ snapshots: agent-base@7.1.3: {} - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -10308,11 +10100,6 @@ snapshots: callsites@3.1.0: {} - camel-case@3.0.0: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 - camelcase@6.3.0: {} camelcase@8.0.0: {} @@ -10347,27 +10134,6 @@ snapshots: chalk@5.4.1: {} - change-case@3.1.0: - dependencies: - camel-case: 3.0.0 - constant-case: 2.0.0 - dot-case: 2.1.1 - header-case: 1.0.1 - is-lower-case: 1.1.3 - is-upper-case: 1.1.2 - lower-case: 1.1.4 - lower-case-first: 1.0.2 - no-case: 2.3.2 - param-case: 2.1.1 - pascal-case: 2.0.1 - path-case: 2.1.1 - sentence-case: 2.1.1 - snake-case: 2.1.0 - swap-case: 1.1.2 - title-case: 2.1.1 - upper-case: 1.1.3 - upper-case-first: 1.1.2 - character-entities@2.0.2: {} chardet@0.7.0: {} @@ -10447,8 +10213,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - clean-stack@2.2.0: {} - clear-require@1.0.1: dependencies: caller-path: 0.1.0 @@ -10520,8 +10284,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@10.0.1: {} - commander@2.20.3: {} commander@7.2.0: {} @@ -10546,11 +10308,6 @@ snapshots: confbox@0.2.2: {} - constant-case@2.0.0: - dependencies: - snake-case: 2.1.0 - upper-case: 1.1.3 - content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -10576,8 +10333,6 @@ snapshots: dependencies: browserslist: 4.24.4 - core-js-pure@3.41.0: {} - core-util-is@1.0.3: {} cors@2.8.5: @@ -10778,17 +10533,6 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 - del@5.1.0: - dependencies: - globby: 10.0.2 - graceful-fs: 4.2.11 - is-glob: 4.0.3 - is-path-cwd: 2.2.0 - is-path-inside: 3.0.3 - p-map: 3.0.0 - rimraf: 3.0.2 - slash: 3.0.0 - delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -10845,10 +10589,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dot-case@2.1.1: - dependencies: - no-case: 2.3.2 - dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -11572,18 +11312,6 @@ snapshots: events@3.3.0: {} - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - expand-template@2.0.3: {} expect-webdriverio@3.6.0: @@ -11818,12 +11546,6 @@ snapshots: fs-constants@1.0.0: {} - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - fs-extra@11.3.0: dependencies: graceful-fs: 4.2.11 @@ -11923,8 +11645,6 @@ snapshots: dependencies: pump: 3.0.2 - get-stream@6.0.1: {} - get-tsconfig@4.10.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -12001,17 +11721,6 @@ snapshots: globals@16.0.0: {} - globby@10.0.2: - dependencies: - '@types/glob': 7.2.0 - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - glob: 7.2.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - globby@11.1.0: dependencies: array-union: 2.1.0 @@ -12027,24 +11736,10 @@ snapshots: graceful-fs@4.2.11: {} - gradient-string@2.0.2: - dependencies: - chalk: 4.1.2 - tinygradient: 1.1.5 - grapheme-splitter@1.0.4: {} graphemer@1.4.0: {} - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -12067,11 +11762,6 @@ snapshots: he@1.2.0: {} - header-case@1.0.1: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 - help-me@5.0.0: {} history@5.3.0: @@ -12166,8 +11856,6 @@ snapshots: human-id@4.1.1: {} - human-signals@2.1.0: {} - i18next-browser-languagedetector@8.0.4: dependencies: '@babel/runtime': 7.27.0 @@ -12236,22 +11924,6 @@ snapshots: ini@1.3.8: {} - inquirer@7.3.3: - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.17.21 - mute-stream: 0.0.8 - run-async: 2.4.1 - rxjs: 6.6.7 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - inquirer@8.2.6: dependencies: ansi-escapes: 4.3.2 @@ -12355,10 +12027,6 @@ snapshots: is-interactive@1.0.0: {} - is-lower-case@1.1.3: - dependencies: - lower-case: 1.1.4 - is-map@2.0.3: {} is-network-error@1.1.0: {} @@ -12370,10 +12038,6 @@ snapshots: is-number@7.0.0: {} - is-path-cwd@2.2.0: {} - - is-path-inside@3.0.3: {} - is-plain-obj@2.1.0: {} is-plain-obj@4.1.0: {} @@ -12420,10 +12084,6 @@ snapshots: is-unicode-supported@0.1.0: {} - is-upper-case@1.1.2: - dependencies: - upper-case: 1.1.3 - is-weakmap@2.0.2: {} is-weakset@2.0.4: @@ -12441,8 +12101,6 @@ snapshots: isarray@2.0.5: {} - isbinaryfile@4.0.10: {} - isexe@2.0.0: {} isexe@3.1.1: {} @@ -12629,8 +12287,6 @@ snapshots: lodash.flatmap@4.5.0: {} - lodash.get@4.4.2: {} - lodash.isfunction@3.0.9: {} lodash.merge@4.6.2: {} @@ -12647,10 +12303,6 @@ snapshots: lodash@4.17.21: {} - log-symbols@3.0.0: - dependencies: - chalk: 2.4.2 - log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -12686,12 +12338,6 @@ snapshots: loupe@3.1.3: {} - lower-case-first@1.0.2: - dependencies: - lower-case: 1.1.4 - - lower-case@1.1.4: {} - lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -12846,8 +12492,6 @@ snapshots: merge-descriptors@1.0.3: {} - merge-stream@2.0.0: {} - merge2@1.4.1: {} methods@1.1.2: {} @@ -13101,10 +12745,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mlly@1.7.4: dependencies: acorn: 8.14.1 @@ -13164,16 +12804,10 @@ snapshots: negotiator@0.6.3: {} - neo-async@2.6.2: {} - nested-error-stacks@2.1.1: {} netmask@2.0.2: {} - no-case@2.3.2: - dependencies: - lower-case: 1.1.4 - no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -13202,20 +12836,6 @@ snapshots: node-gyp-build@4.8.4: optional: true - node-plop@0.26.3: - dependencies: - '@babel/runtime-corejs3': 7.27.0 - '@types/inquirer': 6.5.0 - change-case: 3.1.0 - del: 5.1.0 - globby: 10.0.2 - handlebars: 4.7.8 - inquirer: 7.3.3 - isbinaryfile: 4.0.10 - lodash.get: 4.4.2 - mkdirp: 0.5.6 - resolve: 1.22.10 - node-releases@2.0.19: {} nodemailer@6.10.0: {} @@ -13247,10 +12867,6 @@ snapshots: dependencies: which: 1.3.1 - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - npm-which@3.0.1: dependencies: commander: 2.20.3 @@ -13312,17 +12928,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@4.1.1: - dependencies: - chalk: 3.0.0 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - log-symbols: 3.0.0 - mute-stream: 0.0.8 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - ora@5.4.1: dependencies: bl: 4.1.0 @@ -13365,10 +12970,6 @@ snapshots: p-map@2.1.0: {} - p-map@3.0.0: - dependencies: - aggregate-error: 3.1.0 - p-queue@5.0.0: dependencies: eventemitter3: 3.1.2 @@ -13407,10 +13008,6 @@ snapshots: pako@1.0.11: {} - param-case@2.1.1: - dependencies: - no-case: 2.3.2 - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13454,15 +13051,6 @@ snapshots: parseurl@1.3.3: {} - pascal-case@2.0.1: - dependencies: - camel-case: 3.0.0 - upper-case-first: 1.1.2 - - path-case@2.1.1: - dependencies: - no-case: 2.3.2 - path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -13497,8 +13085,6 @@ snapshots: pend@1.2.0: {} - picocolors@1.0.1: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -14112,15 +13698,6 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - registry-auth-token@3.3.2: - dependencies: - rc: 1.2.8 - safe-buffer: 5.2.1 - - registry-url@3.1.0: - dependencies: - rc: 1.2.8 - regjsparser@0.12.0: dependencies: jsesc: 3.0.2 @@ -14166,10 +13743,6 @@ snapshots: dependencies: glob: 7.2.3 - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - rimraf@6.0.1: dependencies: glob: 11.0.1 @@ -14226,10 +13799,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@6.6.7: - dependencies: - tslib: 1.14.1 - rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -14354,8 +13923,6 @@ snapshots: semver@6.3.1: {} - semver@7.6.2: {} - semver@7.7.1: {} send@0.19.0: @@ -14376,11 +13943,6 @@ snapshots: transitivePeerDependencies: - supports-color - sentence-case@2.1.1: - dependencies: - no-case: 2.3.2 - upper-case-first: 1.1.2 - serialize-error@11.0.3: dependencies: type-fest: 2.19.0 @@ -14520,10 +14082,6 @@ snapshots: smart-buffer@4.2.0: {} - snake-case@2.1.0: - dependencies: - no-case: 2.3.2 - snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -14705,8 +14263,6 @@ snapshots: strip-bom@3.0.0: {} - strip-final-newline@2.0.0: {} - strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -14821,11 +14377,6 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swap-case@1.1.2: - dependencies: - lower-case: 1.1.4 - upper-case: 1.1.3 - sync-child-process@1.0.2: dependencies: sync-message-port: 1.1.3 @@ -14842,6 +14393,8 @@ snapshots: '@pkgr/core': 0.1.2 tslib: 2.8.1 + tabbable@6.2.0: {} + table@6.9.0: dependencies: ajv: 8.17.1 @@ -14977,8 +14530,6 @@ snapshots: tiny-invariant@1.3.3: {} - tinycolor2@1.6.0: {} - tinyexec@0.3.2: {} tinyglobby@0.2.12: @@ -14986,20 +14537,10 @@ snapshots: fdir: 6.4.3(picomatch@4.0.2) picomatch: 4.0.2 - tinygradient@1.1.5: - dependencies: - '@types/tinycolor2': 1.4.6 - tinycolor2: 1.6.0 - tinyrainbow@1.2.0: {} tinyspy@3.0.2: {} - title-case@2.1.1: - dependencies: - no-case: 2.3.2 - upper-case: 1.1.3 - tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -15059,8 +14600,6 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tslib@1.14.1: {} - tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -15121,9 +14660,6 @@ snapshots: ufo@1.6.1: {} - uglify-js@3.19.3: - optional: true - unbzip2-stream@1.4.3: dependencies: buffer: 5.7.1 @@ -15195,17 +14731,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-check@1.5.4: - dependencies: - registry-auth-token: 3.3.2 - registry-url: 3.1.0 - - upper-case-first@1.1.2: - dependencies: - upper-case: 1.1.3 - - upper-case@1.1.3: {} - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -15256,8 +14781,6 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - validate-npm-package-name@5.0.1: {} - varint@6.0.0: {} vary@1.1.2: {} @@ -15421,8 +14944,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - worker-farm@1.7.0: dependencies: errno: 0.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e16ec53e..71fa0b0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,11 @@ packages: - "frontend" - "packages/*" - "packages/ui/*" + - "packages/ui/uikit/*" + - "packages/ui/uikit/headless/*" + - "packages/ui/uikit/headless/components/*" + - "packages/ui/uikit/headless/hooks/*" + - "packages/ui/uikit/headless/utils/*" catalog: "@antfu/eslint-config": ^4.11.0 @@ -11,6 +16,7 @@ catalog: "@eslint-react/eslint-plugin": ^1.40.3 "@farfetched/core": ^0.13.1 "@figma-export/core": ^6.2.0 + "@floating-ui/react": ^0.27.7 "@storybook/addon-essentials": 8.6.11 "@storybook/addon-interactions": 8.6.11 "@storybook/addon-onboarding": 8.6.11 @@ -51,7 +57,7 @@ catalog: eslint-plugin-react-refresh: ^0.4.19 eslint-plugin-storybook: ^0.12.0 eslint-plugin-turbo: ^2.5.0 - fast-glob: ^0.0.0 + fast-glob: ^3.3.3 framer-motion: ^12.6.2 globals: ^16.0.0 history: ^5.3.0 @@ -69,7 +75,7 @@ catalog: postcss-preset-env: ^10.1.5 react-i18next: ^15.4.1 react-use-measure: ^2.1.7 - rimraf: ^0.0.0 + rimraf: ^6.0.1 sass-embedded: ^1.86.1 storybook: 8.6.11 storybook-react-i18next: ^3.2.1 From 87488668b0e3bb32ae7276d5c6b14bdcb41533b4 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 15 Jun 2025 00:12:18 +0300 Subject: [PATCH 02/16] feat(package-headless-ui): add useDidUpdate, useIsFirstRender, useMount, useUnmount --- packages/eslint/tsconfig.json | 2 +- .../uikit/headless/hooks/src/useDidUpdate.ts | 18 ++++++++++++++++++ .../headless/hooks/src/useIsFirstRender.ts | 12 ++++++++++++ .../hooks/src/{useOnMount.ts => useMount.ts} | 2 +- .../ui/uikit/headless/hooks/src/useUnmount.ts | 10 ++++++++++ pnpm-lock.yaml | 5 ++++- 6 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 packages/ui/uikit/headless/hooks/src/useDidUpdate.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useIsFirstRender.ts rename packages/ui/uikit/headless/hooks/src/{useOnMount.ts => useMount.ts} (66%) create mode 100644 packages/ui/uikit/headless/hooks/src/useUnmount.ts diff --git a/packages/eslint/tsconfig.json b/packages/eslint/tsconfig.json index 19c2cde9..10026686 100644 --- a/packages/eslint/tsconfig.json +++ b/packages/eslint/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "@flippo/tsconfig", "compilerOptions": { + "composite": false, "types": ["node"], "allowImportingTsExtensions": false, "allowJs": true, "declaration": false, "declarationMap": false, - "composite": false, "emitDeclarationOnly": false, "isolatedDeclarations": false }, diff --git a/packages/ui/uikit/headless/hooks/src/useDidUpdate.ts b/packages/ui/uikit/headless/hooks/src/useDidUpdate.ts new file mode 100644 index 00000000..47288af7 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useDidUpdate.ts @@ -0,0 +1,18 @@ +import type { DependencyList, EffectCallback } from 'react'; +import React from 'react'; +import { useEnhancedEffect } from './useEnhancedEffect'; + +export function useDidUpdate(callback: EffectCallback, deps?: DependencyList) { + const isMountedRef = React.useRef(false); + + useEnhancedEffect(() => () => { isMountedRef.current = false; }, []); + + useEnhancedEffect(() => { + if (isMountedRef.current) { + return callback(); + } + + isMountedRef.current = true; + return undefined; + }, [deps]); +} diff --git a/packages/ui/uikit/headless/hooks/src/useIsFirstRender.ts b/packages/ui/uikit/headless/hooks/src/useIsFirstRender.ts new file mode 100644 index 00000000..b3181f26 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useIsFirstRender.ts @@ -0,0 +1,12 @@ +import React from 'react'; + +export function useIsFirstRender() { + const flagRef = React.useRef(true); + + if (flagRef.current === true) { + flagRef.current = false; + return true; + } + + return flagRef.current; +} diff --git a/packages/ui/uikit/headless/hooks/src/useOnMount.ts b/packages/ui/uikit/headless/hooks/src/useMount.ts similarity index 66% rename from packages/ui/uikit/headless/hooks/src/useOnMount.ts rename to packages/ui/uikit/headless/hooks/src/useMount.ts index 4f79ce47..d68ad413 100644 --- a/packages/ui/uikit/headless/hooks/src/useOnMount.ts +++ b/packages/ui/uikit/headless/hooks/src/useMount.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -export function useOnMount(effectCallback: React.EffectCallback) { +export function useMount(effectCallback: React.EffectCallback) { // eslint-disable-next-line react-hooks/exhaustive-deps React.useEffect(effectCallback, []); } diff --git a/packages/ui/uikit/headless/hooks/src/useUnmount.ts b/packages/ui/uikit/headless/hooks/src/useUnmount.ts new file mode 100644 index 00000000..2ebd9a17 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/useUnmount.ts @@ -0,0 +1,10 @@ +import React from 'react'; + +export function useUnmount(callback: ()=> void) { + const callbackRef = React.useRef(callback); + callbackRef.current = callback; + + React.useEffect(() => () => { + callbackRef.current(); + }, []); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cadd9bd1..83c04064 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@figma-export/core': specifier: ^6.2.0 version: 6.2.0 + '@floating-ui/react': + specifier: ^0.27.7 + version: 0.27.7 '@storybook/addon-essentials': specifier: 8.6.11 version: 8.6.11 @@ -690,7 +693,7 @@ importers: packages/ui/uikit/headless/utils: dependencies: '@floating-ui/react': - specifier: ^0.27.7 + specifier: 'catalog:' version: 0.27.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: '@flippo/eslint': From 43c285b522c7d9926238eb5b7899dc51af30aebf Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Wed, 13 Aug 2025 23:56:08 +0300 Subject: [PATCH 03/16] feat(hooks, components): --- .vscode/settings.json | 49 +- package.json | 4 +- packages/eslint/src/index.ts | 229 +- .../headless/components/eslint.config.js | 74 + .../ui/uikit/headless/components/package.json | 64 + .../src/components/Composite/composite.ts | 210 ++ .../src/components/Composite/constants.ts | 1 + .../Composite/item/CompositeItem.tsx | 50 + .../Composite/item/useCompositeItem.ts | 53 + .../Composite/list/CompositeList.tsx | 135 + .../Composite/list/CompositeListContext.ts | 24 + .../Composite/list/useCompositeListItem.ts | 119 + .../Composite/root/CompositeRoot.tsx | 129 + .../Composite/root/CompositeRootContext.ts | 27 + .../Composite/root/useCompositeRoot.ts | 382 +++ .../src/components/Separator/Separator.ts | 46 + .../src/components/Separator/index.ts | 0 .../src/components/Tabs/index.parts.ts | 0 .../components/src/components/Tabs/index.ts | 0 .../Tabs/indicator/TabsIndicator.tsx | 213 ++ .../Tabs/indicator/TabsIndicatorCssVars.ts | 32 + .../indicator/TabsIndicatorDataAttributes.ts | 12 + .../src/components/Tabs/list/TabsList.tsx | 231 ++ .../components/Tabs/list/TabsListContext.ts | 25 + .../Tabs/list/TabsListDataAttributes.ts | 12 + .../src/components/Tabs/panel/TabsPanel.tsx | 115 + .../Tabs/panel/TabsPanelDataAttributes.ts | 20 + .../src/components/Tabs/root/TabsRoot.tsx | 212 ++ .../components/Tabs/root/TabsRootContext.ts | 63 + .../Tabs/root/TabsRootDataAttributes.ts | 12 + .../src/components/Tabs/root/styleHooks.ts | 13 + .../src/components/Tabs/tab/TabsTab.tsx | 233 ++ .../Tabs/tab/TabsTabDataAttributes.ts | 24 + .../components/Toast/action/ToastAction.tsx | 75 + .../Toast/action/ToastActionDataAttributes.ts | 7 + .../src/components/Toast/close/ToastClose.tsx | 68 + .../Toast/close/ToastCloseDataAttributes.ts | 7 + .../components/Toast/createToastManager.ts | 99 + .../Toast/description/ToastDescription.tsx | 84 + .../ToastDescriptionDataAttributes.ts | 7 + .../components/Toast/portal/ToastPortal.tsx | 17 + .../Toast/provider/ToastProvider.tsx | 415 +++ .../Toast/provider/ToastProviderContext.ts | 40 + .../src/components/Toast/root/ToastRoot.tsx | 624 +++++ .../components/Toast/root/ToastRootContext.ts | 26 + .../components/Toast/root/ToastRootCssVars.ts | 22 + .../Toast/root/ToastRootDataAttributes.ts | 35 + .../src/components/Toast/title/ToastTitle.tsx | 83 + .../Toast/title/ToastTitleDataAttributes.ts | 7 + .../src/components/Toast/useToastManager.ts | 132 + .../components/Toast/utils/focusVisible.ts | 1 + .../Toast/utils/resolvePromiseOptions.ts | 22 + .../Toast/viewport/ToastViewport.tsx | 291 +++ .../Toast/viewport/ToastViewportContext.ts | 20 + .../viewport/ToastViewportDataAttributes.ts | 7 + .../src/components/Toggle/Toggle.tsx | 141 + .../components/Toggle/ToggleDataAttributes.ts | 6 + .../components/src/components/Toggle/index.ts | 1 + .../components/ToggleGroup/ToggleGroup.tsx | 168 ++ .../ToggleGroup/ToggleGroupContext.ts | 26 + .../ToggleGroup/ToggleGroupDataAttributes.ts | 15 + .../src/components/ToggleGroup/index.ts | 1 + .../components/Tooltip/arrow/TooltipArrow.tsx | 74 + .../arrow/TooltipArrowDataAttributes.ts | 10 + .../src/components/Tooltip/index.ts | 0 .../components/Tooltip/popup/TooltipPopup.tsx | 94 + .../popup/TooltipPopupDataAttributes.ts | 12 + .../Tooltip/portal/TooltipPortal.tsx | 34 + .../Tooltip/portal/TooltipPortalContext.ts | 15 + .../Tooltip/positioner/TooltipPositioner.tsx | 127 + .../positioner/TooltipPositionerContext.ts | 26 + .../positioner/TooltipPositionerCssVars.ts | 7 + .../TooltipPositionerDataAttributes.ts | 26 + .../positioner/useTooltipPositioner.ts | 66 + .../Tooltip/provider/TooltipProvider.tsx | 38 + .../provider/TooltipProviderContext.ts | 20 + .../components/Tooltip/root/TooltipRoot.tsx | 52 + .../Tooltip/root/TooltipRootContext.ts | 47 + .../components/Tooltip/root/useTooltipRoot.ts | 260 ++ .../Tooltip/trigger/TooltipTrigger.tsx | 44 + .../trigger/TooltipTriggerDataAttributes.ts | 5 + .../src/components/use-button/index.ts | 1 + .../src/components/use-button/useButton.ts | 236 ++ .../components/src/lib/FloatingPortalLite.tsx | 25 + .../components/src/lib/FocusGuard.tsx | 36 + .../headless/components/src/lib/constants.ts | 15 + .../components/src/lib/detectBrowser.ts | 78 + .../headless/components/src/lib/error.ts | 14 + .../headless/components/src/lib/generateId.ts | 5 + .../components/src/lib/getStyleHookProps.ts | 32 + .../components/src/lib/hooks/index.ts | 5 + .../src/lib/hooks/useAnchorPositioning.ts | 536 ++++ .../src/lib/hooks/useAnchorPositioningDoc.md | 143 + .../components/src/lib/hooks/useDirection.ts | 19 + .../src/lib/hooks/useFocusableWhenDisabled.ts | 83 + .../src/lib/hooks/useHeadlessUiId.ts | 12 + .../src/lib/hooks/useRenderElement.tsx | 234 ++ .../components/src/lib/isElementDisabled.ts | 7 + .../headless/components/src/lib/merge.ts | 243 ++ .../headless/components/src/lib/owner.ts | 2 + .../components/src/lib/popupStateMapping.ts | 70 + .../components/src/lib/resolveClassName.ts | 6 + .../components/src/lib/styleHookMapping.ts | 23 + .../src/lib/translateOpenChangeReason.ts | 38 + .../headless/components/src/lib/types.ts | 91 + .../components/src/lib/visuallyHidden.ts | 14 + .../components/FloatingDelayGroup.test.tsx | 253 ++ .../components/FloatingDelayGroup.tsx | 229 ++ .../components/FloatingFocusManager.test.tsx | 1817 +++++++++++++ .../components/FloatingFocusManager.tsx | 763 ++++++ .../components/FloatingPortal.test.tsx | 106 + .../components/FloatingPortal.tsx | 279 ++ .../components/FloatingTree.tsx | 117 + .../floating-ui-react/hooks/useClick.ts | 126 + .../hooks/useClientPoint.test.tsx | 293 +++ .../floating-ui-react/hooks/useClientPoint.ts | 250 ++ .../hooks/useDismiss.test.tsx | 1002 +++++++ .../floating-ui-react/hooks/useDismiss.ts | 658 +++++ .../floating-ui-react/hooks/useFloating.ts | 147 ++ .../hooks/useFloatingRootContext.ts | 85 + .../floating-ui-react/hooks/useFocus.ts | 181 ++ .../floating-ui-react/hooks/useHover.test.tsx | 372 +++ .../floating-ui-react/hooks/useHover.ts | 518 ++++ .../hooks/useInteractions.test.tsx | 149 ++ .../hooks/useInteractions.ts | 134 + .../hooks/useListNavigation.test.tsx | 1153 ++++++++ .../hooks/useListNavigation.ts | 933 +++++++ .../floating-ui-react/hooks/useRole.ts | 118 + .../hooks/useTypeahead.test.tsx | 249 ++ .../floating-ui-react/hooks/useTypeahead.ts | 214 ++ .../src/packages/floating-ui-react/index.ts | 39 + .../floating-ui-react/middleware/arrow.ts | 123 + .../packages/floating-ui-react/safePolygon.ts | 350 +++ .../src/packages/floating-ui-react/types.ts | 226 ++ .../src/packages/floating-ui-react/utils.ts | 5 + .../floating-ui-react/utils/composite.ts | 352 +++ .../floating-ui-react/utils/constants.ts | 10 + .../utils/createAttribute.ts | 3 + .../utils/createEventEmitter.ts | 17 + .../floating-ui-react/utils/detectBrowser.ts | 78 + .../floating-ui-react/utils/element.ts | 115 + .../floating-ui-react/utils/enqueueFocus.ts | 22 + .../packages/floating-ui-react/utils/event.ts | 56 + .../floating-ui-react/utils/markOthers.ts | 173 ++ .../floating-ui-react/utils/nodes.test.ts | 101 + .../packages/floating-ui-react/utils/nodes.ts | 56 + .../floating-ui-react/utils/tabbable.ts | 71 + .../uikit/headless/components/tsconfig.json | 16 + .../uikit/headless/components/vite.config.ts | 39 + packages/ui/uikit/headless/hooks/package.json | 26 +- packages/ui/uikit/headless/hooks/src/index.ts | 19 + .../headless/hooks/src/useControlledState.ts | 120 +- .../hooks/src/useForcedRerendering.ts | 13 + packages/ui/uikit/headless/hooks/src/useId.ts | 14 + .../headless/hooks/src/useIsoLayoutEffect.ts | 7 + .../ui/uikit/headless/hooks/src/useLazyRef.ts | 22 + .../uikit/headless/hooks/src/useMergedRef.ts | 110 + .../ui/uikit/headless/hooks/src/useMount.ts | 6 - .../ui/uikit/headless/hooks/src/useOnMount.ts | 6 + .../hooks/src/useOpenChangeComplete.ts | 43 +- .../ui/uikit/headless/hooks/src/useTimeout.ts | 52 + pnpm-lock.yaml | 2325 ++++++++++------- pnpm-workspace.yaml | 91 +- 163 files changed, 21758 insertions(+), 1279 deletions(-) create mode 100644 packages/ui/uikit/headless/components/eslint.config.js create mode 100644 packages/ui/uikit/headless/components/package.json create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/composite.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/constants.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Composite/root/useCompositeRoot.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Separator/Separator.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Separator/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/index.parts.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicator.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorCssVars.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/list/TabsList.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanel.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanelDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRoot.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/root/styleHooks.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/tab/TabsTab.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tabs/tab/TabsTabDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/action/ToastAction.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/action/ToastActionDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/close/ToastClose.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/close/ToastCloseDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/createToastManager.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/description/ToastDescription.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/description/ToastDescriptionDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/portal/ToastPortal.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/provider/ToastProvider.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/provider/ToastProviderContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/root/ToastRoot.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/root/ToastRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/root/ToastRootCssVars.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/root/ToastRootDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/title/ToastTitle.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/title/ToastTitleDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/useToastManager.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/utils/focusVisible.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/utils/resolvePromiseOptions.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/viewport/ToastViewport.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/viewport/ToastViewportContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toast/viewport/ToastViewportDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toggle/Toggle.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Toggle/ToggleDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Toggle/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/ToggleGroup/ToggleGroup.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/ToggleGroup/ToggleGroupContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/ToggleGroup/ToggleGroupDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/ToggleGroup/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrow.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrowDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopupDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/portal/TooltipPortal.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/portal/TooltipPortalContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositioner.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositionerContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositionerCssVars.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositionerDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/positioner/useTooltipPositioner.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProvider.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProviderContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/root/useTooltipRoot.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTriggerDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/use-button/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/use-button/useButton.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/FloatingPortalLite.tsx create mode 100644 packages/ui/uikit/headless/components/src/lib/FocusGuard.tsx create mode 100644 packages/ui/uikit/headless/components/src/lib/constants.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/detectBrowser.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/error.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/generateId.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/getStyleHookProps.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/index.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useAnchorPositioning.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useAnchorPositioningDoc.md create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useDirection.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useFocusableWhenDisabled.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useHeadlessUiId.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/hooks/useRenderElement.tsx create mode 100644 packages/ui/uikit/headless/components/src/lib/isElementDisabled.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/merge.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/owner.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/popupStateMapping.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/resolveClassName.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/styleHookMapping.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/translateOpenChangeReason.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/types.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/visuallyHidden.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingDelayGroup.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingDelayGroup.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingFocusManager.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingFocusManager.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingPortal.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingPortal.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingTree.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useClick.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useClientPoint.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useClientPoint.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useDismiss.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useDismiss.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloating.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloatingRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFocus.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useListNavigation.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useListNavigation.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useRole.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useTypeahead.test.tsx create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useTypeahead.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/index.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/middleware/arrow.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/safePolygon.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/types.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/composite.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/constants.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/createAttribute.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/createEventEmitter.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/detectBrowser.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/element.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/enqueueFocus.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/event.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/markOthers.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/nodes.test.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/nodes.ts create mode 100644 packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/tabbable.ts create mode 100644 packages/ui/uikit/headless/components/tsconfig.json create mode 100644 packages/ui/uikit/headless/components/vite.config.ts create mode 100644 packages/ui/uikit/headless/hooks/src/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useForcedRerendering.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useId.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useIsoLayoutEffect.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useLazyRef.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useMergedRef.ts delete mode 100644 packages/ui/uikit/headless/hooks/src/useMount.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useOnMount.ts create mode 100644 packages/ui/uikit/headless/hooks/src/useTimeout.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e0ffa241..47a2ef9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,12 +2,8 @@ "i18n-ally.localesPaths": [ "**/locales" ], - - "eslint.enable": true, - "biome.enabled": false, - //"eslint.useFlatConfig": true, - //"eslint.useESLintClass": false, // важно! + "editor.tabSize": 4, // Disable the default formatter, use eslint instead "prettier.enable": false, @@ -15,26 +11,22 @@ // Auto fix "editor.codeActionsOnSave": { - //"source.fixAll.biome": "explicit", - //"source.organizeImports.biome": "explicit", - "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, - //"eslint.runtime": "node", - // Silent the stylistic rules in you IDE, but still auto fix them "eslint.rules.customizations": [ - { "rule": "style/*", "severity": "info", "fixable": true }, - { "rule": "*-indent", "severity": "info", "fixable": true }, - { "rule": "*-spacing", "severity": "info", "fixable": true }, - { "rule": "*-spaces", "severity": "info", "fixable": true }, - { "rule": "*-order", "severity": "info", "fixable": true }, - { "rule": "*-dangle", "severity": "info", "fixable": true }, - { "rule": "*-newline", "severity": "info", "fixable": true }, - { "rule": "*quotes", "severity": "info", "fixable": true }, - { "rule": "*semi", "severity": "info", "fixable": true } + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "format/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } ], // Enable eslint for all supported languages @@ -47,17 +39,18 @@ "html", "markdown", "json", - "json5", "jsonc", "yaml", "toml", - "xml" - ], - - "pair-diff.patterns": [ - { - "source": "./fixtures/output/**/*.*", - "target": "./fixtures/input/" - } + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" ] } \ No newline at end of file diff --git a/package.json b/package.json index 9df6a9ad..45e9e83a 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "@changesets/cli": "^2.28.1", "turbo": "^2.5.0" }, - "packageManager": "pnpm@10.7.0", + "packageManager": "pnpm@10.14.0", "engines": { "node": ">=20", - "pnpm": ">=10.7.0" + "pnpm": ">=10.14.0" } } diff --git a/packages/eslint/src/index.ts b/packages/eslint/src/index.ts index 9a31d583..bf042445 100644 --- a/packages/eslint/src/index.ts +++ b/packages/eslint/src/index.ts @@ -5,83 +5,100 @@ import turboPlugin from 'eslint-plugin-turbo'; import globals from 'globals'; export const overridesStylisticConfig: Exclude['overrides'] = { - /* comma */ - 'style/comma-dangle': ['error', 'never'], + /* comma */ + 'style/comma-dangle': ['error', 'never'], - /* spacing rules */ - 'style/type-annotation-spacing': ['error', { before: false, after: true, overrides: { arrow: { before: false, after: true } } }], - 'style/type-generic-spacing': ['error'], - 'style/type-named-tuple-spacing': ['error'], - 'style/template-tag-spacing': ['error'], + /* spacing rules */ + 'style/type-annotation-spacing': ['error', { + before: false, + after: true, + overrides: { arrow: { before: true, after: true } } + }], + 'style/type-generic-spacing': ['error'], + 'style/type-named-tuple-spacing': ['error'], + 'style/template-tag-spacing': ['error'], - /* misc */ - 'style/max-len': ['warn', { code: 140, tabWidth: 2, ignoreTrailingComments: true, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true, ignoreRegExpLiterals: true, ignorePattern: '^\\s*var\\s.+=\\s*require\\s*\\(' }], - 'style/one-var-declaration-per-line': ['error', 'always'], - 'style/max-statements-per-line': ['error', { max: 3 }], + /* misc */ + 'style/max-len': ['warn', { + code: 140, + tabWidth: 4, + ignoreTrailingComments: true, + ignoreUrls: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true + }], + 'style/indent': ['warn', 4], + 'style/one-var-declaration-per-line': ['error', 'always'], + 'style/max-statements-per-line': ['error', { max: 3 }], + 'style/newline-per-chained-call': ['error', { ignoreChainWithDepth: 3 }], + 'style/object-curly-newline': ['warn', { consistent: true, minProperties: 4 }], + 'style/array-bracket-newline': ['warn', { minItems: 4 }], - /* jsx */ - 'style/jsx-quotes': ['error', 'prefer-single'], - 'style/jsx-curly-brace-presence': ['warn', 'always'], - 'style/jsx-curly-spacing': [2, { when: 'always' }], + /* jsx */ + 'style/jsx-quotes': ['error', 'prefer-single'], + 'style/jsx-curly-brace-presence': ['warn', 'always'], + 'style/jsx-curly-spacing': [2, { when: 'never' }], - /* semis */ - 'style/no-extra-semi': 'error', + /* semis */ + 'style/no-extra-semi': 'error', - /* bracket */ - 'style/arrow-parens': ['warn', 'always'] + /* bracket */ + 'style/arrow-parens': ['warn', 'always'] }; export const overridesTsConfig: Exclude['overrides'] = { - 'ts/consistent-type-exports': 'error', - 'ts/consistent-type-imports': 'error', - 'ts/consistent-type-definitions': [ - 'error', - 'type' - ], - 'ts/naming-convention': [ - 'warn', - { - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - selector: 'variable' - }, - { - format: ['PascalCase'], - selector: 'typeLike' - } - ] + 'ts/consistent-type-exports': 'error', + 'ts/consistent-type-imports': 'error', + 'ts/consistent-type-definitions': ['error', 'type'], + 'ts/naming-convention': [ + 'warn', + { + format: [ + 'camelCase', + 'UPPER_CASE', + 'PascalCase' + ], + selector: 'variable' + }, + { + format: ['PascalCase'], + selector: 'typeLike' + } + ] }; export const general: TypedFlatConfigItem[] = [ - { - plugins: { - turbo: turboPlugin + { + plugins: { + turbo: turboPlugin + }, + rules: { + 'turbo/no-undeclared-env-vars': 'warn', + 'ts/consistent-type-definitions': ['error', 'type'], + 'no-console': ['warn'], + 'antfu/no-top-level-await': ['off'], + 'node/prefer-global/process': ['off'], + 'node/no-process-env': ['error'], + 'perfectionist/sort-imports': ['error', { + tsconfigRootDir: import.meta.dirname + }] + } }, - rules: { - 'turbo/no-undeclared-env-vars': 'warn', - 'ts/consistent-type-definitions': ['error', 'type'], - 'no-console': ['warn'], - 'antfu/no-top-level-await': ['off'], - 'node/prefer-global/process': ['off'], - 'node/no-process-env': ['error'], - 'perfectionist/sort-imports': ['error', { - tsconfigRootDir: import.meta.dirname - }] - } - }, - { - languageOptions: { - globals: { - ...globals.browser, - ...globals.chai, - ...globals.mocha, - ...globals.node, - ...globals.es2024 - } + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.chai, + ...globals.mocha, + ...globals.node, + ...globals.es2024 + } + } + }, + { + ignores: ['dist/**'] } - }, - { - ignores: ['dist/**'] - } ]; export type ESLintAntfuConfig = ReturnType; @@ -99,27 +116,27 @@ export const createEslintConfig: typeof antfu = antfu; * @param {string} dirname - The directory name to use for the config. */ export function eslintReactConfig(dirname: string): ESLintAntfuConfig { - return antfu( - { - pnpm: true, - react: true, - typescript: { - parserOptions: { - projectService: true, - tsconfigRootDir: dirname - }, - overrides: overridesTsConfig - }, - stylistic: { - jsx: true, - semi: true, - overrides: overridesStylisticConfig - }, - jsx: true, - formatters: true, - ...general - } - ); + return antfu( + { + pnpm: true, + react: true, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: dirname + }, + overrides: overridesTsConfig + }, + stylistic: { + jsx: true, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: true, + formatters: true, + ...general + } + ); } /** @@ -129,25 +146,25 @@ export function eslintReactConfig(dirname: string): ESLintAntfuConfig { * @param {string} dirname - The directory name to use for the config. */ export function eslintNodeConfig(dirname: string): ESLintAntfuConfig { - return antfu( - { - pnpm: true, - react: false, - typescript: { - parserOptions: { - projectService: true, - tsconfigRootDir: dirname - }, - overrides: overridesTsConfig - }, - stylistic: { - jsx: false, - semi: true, - overrides: overridesStylisticConfig - }, - jsx: false, - formatters: true, - ...general - } - ); + return antfu( + { + pnpm: true, + react: false, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: dirname + }, + overrides: overridesTsConfig + }, + stylistic: { + jsx: false, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: false, + formatters: true, + ...general + } + ); } diff --git a/packages/ui/uikit/headless/components/eslint.config.js b/packages/ui/uikit/headless/components/eslint.config.js new file mode 100644 index 00000000..a3d5d78d --- /dev/null +++ b/packages/ui/uikit/headless/components/eslint.config.js @@ -0,0 +1,74 @@ +import { + createEslintConfig, + general, + overridesStylisticConfig, + overridesTsConfig +} from '@flippo/eslint'; + +export default createEslintConfig( + { + pnpm: true, + react: true, + typescript: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + }, + overrides: { + ...overridesTsConfig, + 'ts/no-namespace': 'off', + 'ts/prefer-literal-enum-member': 'off', + 'ts/no-unsafe-function-type': 'off' + } + }, + stylistic: { + jsx: true, + semi: true, + overrides: overridesStylisticConfig + }, + jsx: true, + formatters: true, + ...general, + ignores: ['**/*.md/*.ts'] + }, + { + rules: { + 'react-dom/no-flush-sync': 'off', + 'unused-imports/no-unused-vars': ['warn', { + varsIgnorePattern: '^_', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + }], + 'node/prefer-global/process': ['error', 'always'], + 'perfectionist/sort-imports': ['error', { + type: 'natural', + order: 'asc', + newlinesBetween: 'always', + internalPattern: ['^~/.+', '^@/.+'], + groups: [ + 'react', + 'builtin', + 'builtin-type', + 'external', + 'external-type', + 'internal', + 'internal-type', + 'parent', + 'parent-type', + 'sibling', + 'sibling-type', + 'unknown', + 'index', + 'index-type', + 'object', + 'type' + ], + tsconfigRootDir: './tsconfig.json', + customGroups: [{ + groupName: 'react', + elementNamePattern: ['^react$', '^react-.+'] + }] + }] + } + } +); diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json new file mode 100644 index 00000000..e9b23f02 --- /dev/null +++ b/packages/ui/uikit/headless/components/package.json @@ -0,0 +1,64 @@ +{ + "name": "@flippo_ui/headless_components", + "type": "module", + "version": "1.0.0", + "source": "./src/index.ts", + "private": true, + "packageManager": "pnpm@10.7.0", + "description": "", + "author": "", + "license": "ISC", + "keywords": [], + "main": "./src/index.ts", + "module": "./src/index.ts", + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } + }, + "files": [ + "README.md", + "dist" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "peerDependencies": { + "@flippo_ui/hooks": "workspace:*" + }, + "dependencies": { + "@floating-ui/react": "catalog:", + "@floating-ui/react-dom": "catalog:", + "@floating-ui/utils": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tabbable": "catalog:", + "vite": "catalog:" + }, + "devDependencies": { + "@eslint-react/eslint-plugin": "catalog:", + "@flippo/eslint": "workspace:*", + "@flippo/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "eslint": "catalog:", + "eslint-plugin-format": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-react-refresh": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/composite.ts b/packages/ui/uikit/headless/components/src/components/Composite/composite.ts new file mode 100644 index 00000000..c8a463af --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/composite.ts @@ -0,0 +1,210 @@ +import type { TTextDirection } from '@lib/hooks/useDirection'; + +export { + createGridCellMap, + findNonDisabledListIndex, + getGridCellIndexOfCorner, + getGridCellIndices, + getGridNavigatedIndex, + getMaxListIndex, + getMinListIndex, + isIndexOutOfListBounds, + isListIndexDisabled, + stopEvent +} from '@packages/floating-ui-react/utils'; + +export type Dimensions = { + width: number; + height: number; +}; + +export const ARROW_UP = 'ArrowUp'; +export const ARROW_DOWN = 'ArrowDown'; +export const ARROW_LEFT = 'ArrowLeft'; +export const ARROW_RIGHT = 'ArrowRight'; +export const HOME = 'Home'; +export const END = 'End'; + +export const HORIZONTAL_KEYS = new Set([ARROW_LEFT, ARROW_RIGHT]); +export const HORIZONTAL_KEYS_WITH_EXTRA_KEYS = new Set([ + ARROW_LEFT, + ARROW_RIGHT, + HOME, + END +]); +export const VERTICAL_KEYS = new Set([ARROW_UP, ARROW_DOWN]); +export const VERTICAL_KEYS_WITH_EXTRA_KEYS = new Set([ + ARROW_UP, + ARROW_DOWN, + HOME, + END +]); +export const ARROW_KEYS = new Set([...HORIZONTAL_KEYS, ...VERTICAL_KEYS]); +export const ALL_KEYS = new Set([...ARROW_KEYS, HOME, END]); +export const COMPOSITE_KEYS = new Set([ + ARROW_UP, + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + HOME, + END +]); + +export const SHIFT = 'Shift' as const; +export const CONTROL = 'Control' as const; +export const ALT = 'Alt' as const; +export const META = 'Meta' as const; +export const MODIFIER_KEYS = new Set([ + SHIFT, + CONTROL, + ALT, + META +] as const); +export type ModifierKey = typeof MODIFIER_KEYS extends Set ? Keys : never; + +export function isNativeInput( + element: EventTarget +): element is HTMLElement & (HTMLInputElement | HTMLTextAreaElement) { + if (element instanceof HTMLInputElement && element.selectionStart != null) { + return true; + } + if (element instanceof HTMLTextAreaElement) { + return true; + } + return false; +} + +export function scrollIntoViewIfNeeded( + scrollContainer: HTMLElement | null, + element: HTMLElement | null, + direction: TTextDirection, + orientation: 'horizontal' | 'vertical' | 'both' +) { + if (!scrollContainer || !element || !element.scrollTo) { + return; + } + + let targetX = scrollContainer.scrollLeft; + let targetY = scrollContainer.scrollTop; + + const isOverflowingX = scrollContainer.clientWidth < scrollContainer.scrollWidth; + const isOverflowingY = scrollContainer.clientHeight < scrollContainer.scrollHeight; + + if (isOverflowingX && orientation !== 'vertical') { + const elementOffsetLeft = getOffset(scrollContainer, element, 'left'); + const containerStyles = getStyles(scrollContainer); + const elementStyles = getStyles(element); + + if (direction === 'ltr') { + if ( + elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight + > scrollContainer.scrollLeft + + scrollContainer.clientWidth + - containerStyles.scrollPaddingRight + ) { + // overflow to the right, scroll to align right edges + targetX + = elementOffsetLeft + + element.offsetWidth + + elementStyles.scrollMarginRight + - scrollContainer.clientWidth + + containerStyles.scrollPaddingRight; + } + else if ( + elementOffsetLeft - elementStyles.scrollMarginLeft + < scrollContainer.scrollLeft + containerStyles.scrollPaddingLeft + ) { + // overflow to the left, scroll to align left edges + targetX + = elementOffsetLeft - elementStyles.scrollMarginLeft - containerStyles.scrollPaddingLeft; + } + } + + if (direction === 'rtl') { + if ( + elementOffsetLeft - elementStyles.scrollMarginRight + < scrollContainer.scrollLeft + containerStyles.scrollPaddingLeft + ) { + // overflow to the left, scroll to align left edges + targetX + = elementOffsetLeft - elementStyles.scrollMarginLeft - containerStyles.scrollPaddingLeft; + } + else if ( + elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight + > scrollContainer.scrollLeft + + scrollContainer.clientWidth + - containerStyles.scrollPaddingRight + ) { + // overflow to the right, scroll to align right edges + targetX + = elementOffsetLeft + + element.offsetWidth + + elementStyles.scrollMarginRight + - scrollContainer.clientWidth + + containerStyles.scrollPaddingRight; + } + } + } + + if (isOverflowingY && orientation !== 'horizontal') { + const elementOffsetTop = getOffset(scrollContainer, element, 'top'); + const containerStyles = getStyles(scrollContainer); + const elementStyles = getStyles(element); + + if ( + elementOffsetTop - elementStyles.scrollMarginTop + < scrollContainer.scrollTop + containerStyles.scrollPaddingTop + ) { + // overflow upwards, align top edges + targetY = elementOffsetTop - elementStyles.scrollMarginTop - containerStyles.scrollPaddingTop; + } + else if ( + elementOffsetTop + element.offsetHeight + elementStyles.scrollMarginBottom + > scrollContainer.scrollTop + scrollContainer.clientHeight - containerStyles.scrollPaddingBottom + ) { + // overflow downwards, align bottom edges + targetY + = elementOffsetTop + + element.offsetHeight + + elementStyles.scrollMarginBottom + - scrollContainer.clientHeight + + containerStyles.scrollPaddingBottom; + } + } + + scrollContainer.scrollTo({ + left: targetX, + top: targetY, + behavior: 'auto' + }); +} + +function getOffset(ancestor: HTMLElement, element: HTMLElement, side: 'left' | 'top') { + const propName = side === 'left' ? 'offsetLeft' : 'offsetTop'; + + let result = 0; + + while (element.offsetParent) { + result += element[propName]; + if (element.offsetParent === ancestor) { + break; + } + element = element.offsetParent as HTMLElement; + } + + return result; +} + +function getStyles(element: HTMLElement) { + const styles = getComputedStyle(element); + return { + scrollMarginTop: Number.parseFloat(styles.scrollMarginTop) || 0, + scrollMarginRight: Number.parseFloat(styles.scrollMarginRight) || 0, + scrollMarginBottom: Number.parseFloat(styles.scrollMarginBottom) || 0, + scrollMarginLeft: Number.parseFloat(styles.scrollMarginLeft) || 0, + scrollPaddingTop: Number.parseFloat(styles.scrollPaddingTop) || 0, + scrollPaddingRight: Number.parseFloat(styles.scrollPaddingRight) || 0, + scrollPaddingBottom: Number.parseFloat(styles.scrollPaddingBottom) || 0, + scrollPaddingLeft: Number.parseFloat(styles.scrollPaddingLeft) || 0 + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/constants.ts b/packages/ui/uikit/headless/components/src/components/Composite/constants.ts new file mode 100644 index 00000000..6129a00c --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/constants.ts @@ -0,0 +1 @@ +export const ACTIVE_COMPOSITE_ITEM = 'data-composite-item-active'; diff --git a/packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx b/packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx new file mode 100644 index 00000000..94bc9c24 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx @@ -0,0 +1,50 @@ +'use client'; + +import type React from 'react'; + +import { EMPTY_ARRAY, EMPTY_OBJECT } from '@lib/constants'; +import { useRenderElement } from '@lib/hooks'; + +import type { CustomStyleHookMapping } from '@lib/getStyleHookProps'; +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { useCompositeItem } from './useCompositeItem'; + +export function CompositeItem>( + componentProps: CompositeItem.Props +) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + render, + className, + /* eslint-enable unused-imports/no-unused-vars */ + state = EMPTY_OBJECT as State, + props = EMPTY_ARRAY, + refs = EMPTY_ARRAY, + metadata, + customStyleHookMapping, + tag = 'div', + ...elementProps + } = componentProps; + + const { compositeProps, compositeRef } = useCompositeItem({ metadata }); + + return useRenderElement(tag, componentProps, { + state, + ref: [...refs, compositeRef], + props: [compositeProps, ...props, elementProps], + customStyleHookMapping + }); +} + +export namespace CompositeItem { + export type Props> = { + children?: React.ReactNode; + metadata?: Metadata; + refs?: (React.Ref | undefined)[]; + props?: Array | (() => Record)>; + state?: State; + customStyleHookMapping?: CustomStyleHookMapping; + tag?: keyof React.JSX.IntrinsicElements; + } & Pick, 'render' | 'className'>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts b/packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts new file mode 100644 index 00000000..d4760df2 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts @@ -0,0 +1,53 @@ +'use client'; + +import React from 'react'; + +import { useMergedRef } from '@flippo_ui/hooks'; + +import type { HTMLProps } from '@lib/types'; + +import { useCompositeListItem } from '../list/useCompositeListItem'; +import { useCompositeRootContext } from '../root/CompositeRootContext'; + +export type UseCompositeItemParams = { + metadata?: Metadata; +}; + +export function useCompositeItem(params: UseCompositeItemParams = {}) { + const { highlightItemOnHover, highlightedIndex, onHighlightedIndexChange } = useCompositeRootContext(); + const { ref, index } = useCompositeListItem(params); + + const isHighlighted = highlightedIndex === index; + + const itemRef = React.useRef(null); + const mergedRef = useMergedRef(ref, itemRef); + + const compositeProps = React.useMemo(() => ({ + tabIndex: isHighlighted ? 0 : -1, + onFocus() { + onHighlightedIndexChange(index); + }, + onMouseMove() { + const item = itemRef.current; + if (!highlightItemOnHover || !item) { + return; + } + + const disabled = item.hasAttribute('disabled') || item.ariaDisabled === 'true'; + if (!isHighlighted && !disabled) { + item.focus(); + } + } + }), [ + highlightItemOnHover, + index, + isHighlighted, + onHighlightedIndexChange + ]); + + return { + compositeProps, + compositeRef: mergedRef as React.RefCallback, + index + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx new file mode 100644 index 00000000..42f6958e --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx @@ -0,0 +1,135 @@ +'use client'; + +import React from 'react'; + +import { useEnhancedEffect, useEventCallback, useLazyRef } from '@flippo_ui/hooks'; + +import { CompositeListContext } from './CompositeListContext'; + +export type CompositeMetadata = { index?: number | null } & CustomMetadata; + +export function CompositeList(props: NCompositeList.Props) { + const { + children, + elementsRef, + labelsRef, + onMapChange + } = props; + + const nextIndexRef = React.useRef(0); + const listeners = useLazyRef(createListeners).current; + + const map = useLazyRef(createMap).current; + const [mapTick, setMapTick] = React.useState(0); + const lastTickRef = React.useRef(mapTick); + + const register = useEventCallback((node: Element, metadata: Metadata) => { + map.set(node, metadata ?? null); + lastTickRef.current += 1; + setMapTick(lastTickRef.current); + }); + + const unregister = useEventCallback((node: Element) => { + map.delete(node); + lastTickRef.current += 1; + setMapTick(lastTickRef.current); + }); + + const sortedMap = React.useMemo(() => { + disableEslintWarning(mapTick); + + const newMap = new Map>(); + const sortedNodes = Array.from(map.keys()).sort(sortByDocumentPosition); + + sortedNodes.forEach((node, index) => { + const metadata = map.get(node) ?? ({} as CompositeMetadata); + newMap.set(node, { ...metadata, index }); + }); + + return newMap; + }, [map, mapTick]); + + useEnhancedEffect(() => { + const shouldUpdateLangth = lastTickRef.current === mapTick; + if (shouldUpdateLangth) { + if (elementsRef.current.length !== sortedMap.size) { + elementsRef.current.length = sortedMap.size; + } + + if (labelsRef && labelsRef.current.length !== sortedMap.size) { + labelsRef.current.length = sortedMap.size; + } + } + + onMapChange?.(sortedMap); + }); + + useEnhancedEffect(() => { + listeners.forEach((l) => l(sortedMap)); + }, [listeners, sortedMap]); + + const subscribeMapChange = useEventCallback((fn) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }); + + const contextValue = React.useMemo(() => ({ + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + }), [ + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + ]); + + return ( + + {children} + + ); +} + +function createMap() { + return new Map | null>(); +} + +function createListeners() { + return new Set(); +} + +function sortByDocumentPosition(a: Element, b: Element) { + const position = a.compareDocumentPosition(b); + + if ( + position & Node.DOCUMENT_POSITION_FOLLOWING + || position & Node.DOCUMENT_POSITION_CONTAINED_BY + ) { + return -1; + } + + if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1; + } + + return 0; +} + +function disableEslintWarning(_: any) {} + +export namespace NCompositeList { + export type Props = { + children: React.ReactNode; + elementsRef: React.RefObject>; + labelsRef?: React.RefObject>; + onMapChange?: (newMap: Map | null>) => void; + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts new file mode 100644 index 00000000..1913595d --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; + +export type TCompositeListContext = { + register: (node: Element, metadata: Metadata) => void; + unregister: (node: Element) => void; + subscribeMapChange: (fn: (map: Map) => void) => () => void; + elementsRef: React.RefObject>; + labelsRef?: React.RefObject>; + nextIndexRef: React.RefObject; +}; + +export const CompositeListContext = React.createContext>({ + register: () => {}, + unregister: () => {}, + subscribeMapChange: () => () => {}, + elementsRef: { current: [] }, + nextIndexRef: { current: 0 } +}); + +export function useCompositeListContext() { + return React.use(CompositeListContext); +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts b/packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts new file mode 100644 index 00000000..b2c8d665 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts @@ -0,0 +1,119 @@ +'use client'; + +import React from 'react'; + +import { useEnhancedEffect } from '@flippo_ui/hooks'; + +import { useCompositeListContext } from './CompositeListContext'; + +export enum IndexGuessBehavior { + None, + GuessFromOrder +} + +export function useCompositeListItem(params: NUseCompositeListItem.Params) { + const { + label, + metadata, + textRef, + indexGuessBehavior + } = params; + const { + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + } + = useCompositeListContext(); + + const indexRef = React.useRef(-1); + const [index, setIndex] = React.useState( + indexGuessBehavior === IndexGuessBehavior.GuessFromOrder + ? () => { + if (indexRef.current === -1) { + const newIndex = nextIndexRef.current; + nextIndexRef.current += 1; + indexRef.current = newIndex; + } + return indexRef.current; + } + : -1 + ); + + const componentRef = React.useRef(null); + + const ref = React.useCallback( + (node: HTMLElement | null) => { + componentRef.current = node; + + if (index !== -1 && node !== null) { + elementsRef.current[index] = node; + + if (labelsRef) { + const isLabelDefined = label !== undefined; + labelsRef.current[index] = isLabelDefined + ? label + : (textRef?.current?.textContent ?? node.textContent); + } + } + }, + [ + index, + elementsRef, + labelsRef, + label, + textRef + ] + ); + + useEnhancedEffect(() => { + const node = componentRef.current; + if (node) { + register(node, metadata); + return () => { + unregister(node); + }; + } + + return undefined; + }, [register, unregister, metadata]); + + useEnhancedEffect(() => { + return subscribeMapChange((map) => { + const i = componentRef.current ? map.get(componentRef.current)?.index : null; + + if (i != null) { + setIndex(i); + } + }); + }, [subscribeMapChange, setIndex]); + + return React.useMemo( + () => ({ + ref, + index + }), + [index, ref] + ); +} + +export namespace NUseCompositeListItem { + export type Params = { + label?: string | null; + metadata?: Metadata; + textRef?: React.RefObject; + /** + * Enables guessing the indexes. This avoids a re-render after mount, which is useful for + * large lists. This should be used for lists that are likely flat and vertical, other cases + * might trigger a re-render anyway. + */ + indexGuessBehavior?: IndexGuessBehavior; + }; + + export type ReturnValue = { + ref: (node: HTMLElement | null) => void; + index: number; + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx new file mode 100644 index 00000000..5ba076fc --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx @@ -0,0 +1,129 @@ +'use client'; + +import React from 'react'; + +import { useEventCallback } from '@flippo_ui/hooks'; + +import { EMPTY_ARRAY, EMPTY_OBJECT } from '@lib/constants'; +import { useDirection, useRenderElement } from '@lib/hooks'; + +import type { CustomStyleHookMapping } from '@lib/getStyleHookProps'; +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { CompositeList } from '../list/CompositeList'; + +import type { Dimensions, ModifierKey } from '../composite'; +import type { CompositeMetadata } from '../list/CompositeList'; + +import { CompositeRootContext } from './CompositeRootContext'; +import { useCompositeRoot } from './useCompositeRoot'; + +import type { TCompositeRootContext } from './CompositeRootContext'; + +/** + * @internal + */ +export function CompositeRoot>( + componentProps: CompositeRoot.Props +) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + refs = EMPTY_ARRAY, + props = EMPTY_ARRAY, + state = EMPTY_OBJECT as State, + customStyleHookMapping, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, + orientation, + dense, + itemSizes, + loop, + cols, + enableHomeAndEndKeys, + onMapChange: onMapChangeProp, + stopEventPropagation, + rootRef, + disabledIndices, + modifierKeys, + highlightItemOnHover = false, + ...elementProps + } = componentProps; + + const direction = useDirection(); + + const { + props: defaultProps, + highlightedIndex, + onHighlightedIndexChange, + elementsRef, + onMapChange: onMapChangeUnwrapped + } = useCompositeRoot({ + itemSizes, + cols, + loop, + dense, + orientation, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, + rootRef, + stopEventPropagation, + enableHomeAndEndKeys, + direction, + disabledIndices, + modifierKeys + }); + + const onMapChange = useEventCallback( + (newMap: Map | null>) => { + onMapChangeProp?.(newMap); + onMapChangeUnwrapped(newMap); + } + ); + + const element = useRenderElement('div', componentProps, { + state, + ref: refs, + props: [defaultProps, ...props, elementProps], + customStyleHookMapping + }); + + const contextValue: TCompositeRootContext = React.useMemo( + () => ({ highlightedIndex, onHighlightedIndexChange, highlightItemOnHover }), + [highlightedIndex, onHighlightedIndexChange, highlightItemOnHover] + ); + + return ( + + elementsRef={elementsRef} onMapChange={onMapChange}> + {element} + + + ); +} + +export namespace CompositeRoot { + export type Props> = { + props?: Array | (() => Record)>; + state?: State; + customStyleHookMapping?: CustomStyleHookMapping; + refs?: (React.Ref | undefined)[]; + tag?: keyof React.JSX.IntrinsicElements; + orientation?: 'horizontal' | 'vertical' | 'both'; + cols?: number; + loop?: boolean; + highlightedIndex?: number; + onHighlightedIndexChange?: (index: number) => void; + itemSizes?: Dimensions[]; + dense?: boolean; + enableHomeAndEndKeys?: boolean; + onMapChange?: (newMap: Map | null>) => void; + stopEventPropagation?: boolean; + rootRef?: React.RefObject; + disabledIndices?: number[]; + modifierKeys?: ModifierKey[]; + highlightItemOnHover?: boolean; + } & Pick, 'render' | 'className' | 'children'>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts new file mode 100644 index 00000000..2d76ae60 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts @@ -0,0 +1,27 @@ +'use client'; + +import React from 'react'; + +export type TCompositeRootContext = { + highlightedIndex: number; + onHighlightedIndexChange: (index: number, shouldScrollIntoView?: boolean) => void; + highlightItemOnHover: boolean; +}; + +export const CompositeRootContext = React.createContext( + undefined +); + +export function useCompositeRootContext(optional: true): TCompositeRootContext | undefined; +export function useCompositeRootContext(optional?: false): TCompositeRootContext; +export function useCompositeRootContext(optional = false) { + const context = React.use(CompositeRootContext); + + if (context === undefined && !optional) { + throw new Error( + 'Headless UI: CompositeRootContext is missing. Composite parts must be placed within .' + ); + } + + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Composite/root/useCompositeRoot.ts b/packages/ui/uikit/headless/components/src/components/Composite/root/useCompositeRoot.ts new file mode 100644 index 00000000..b0efffdc --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Composite/root/useCompositeRoot.ts @@ -0,0 +1,382 @@ +'use client'; + +import React from 'react'; + +import { useEventCallback, useIsoLayoutEffect, useMergedRef } from '@flippo_ui/hooks'; + +import { EMPTY_ARRAY } from '@lib/constants'; +import { isElementDisabled } from '@lib/isElementDisabled'; +import { ownerDocument } from '@lib/owner'; +import { activeElement } from '@packages/floating-ui-react/utils'; + +import type { TTextDirection } from '@lib/hooks'; +import type { HTMLProps } from '@lib/types'; + +import { + ALL_KEYS, + ARROW_DOWN, + ARROW_KEYS, + ARROW_LEFT, + ARROW_RIGHT, + ARROW_UP, + createGridCellMap, + END, + findNonDisabledListIndex, + getGridCellIndexOfCorner, + getGridCellIndices, + getGridNavigatedIndex, + getMaxListIndex, + getMinListIndex, + HOME, + HORIZONTAL_KEYS, + HORIZONTAL_KEYS_WITH_EXTRA_KEYS, + isIndexOutOfListBounds, + isListIndexDisabled, + isNativeInput, + MODIFIER_KEYS, + scrollIntoViewIfNeeded, + VERTICAL_KEYS, + VERTICAL_KEYS_WITH_EXTRA_KEYS +} from '../composite'; +import { ACTIVE_COMPOSITE_ITEM } from '../constants'; + +import type { Dimensions, ModifierKey } from '../composite'; +import type { CompositeMetadata } from '../list/CompositeList'; + +export type UseCompositeRootParameters = { + orientation?: 'horizontal' | 'vertical' | 'both'; + cols?: number; + loop?: boolean; + highlightedIndex?: number; + onHighlightedIndexChange?: (index: number) => void; + dense?: boolean; + direction: TTextDirection; + itemSizes?: Array; + rootRef?: React.Ref; + /** + * When `true`, pressing the Home key moves focus to the first item, + * and pressing the End key moves focus to the last item. + * @default false + */ + enableHomeAndEndKeys?: boolean; + /** + * When `true`, keypress events on Composite's navigation keys + * be stopped with event.stopPropagation() + * @default false + */ + stopEventPropagation?: boolean; + /** + * Array of item indices to be considered disabled. + * Used for composite items that are focusable when disabled. + */ + disabledIndices?: number[]; + /** + * Array of [modifier key values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#modifier_keys) that should allow normal keyboard actions + * when pressed. By default, all modifier keys prevent normal actions. + * @default [] + */ + modifierKeys?: ModifierKey[]; +}; + +export function useCompositeRoot(params: UseCompositeRootParameters) { + const { + itemSizes, + cols = 1, + loop = true, + dense = false, + orientation = 'both', + direction, + highlightedIndex: externalHighlightedIndex, + onHighlightedIndexChange: externalSetHighlightedIndex, + rootRef: externalRef, + enableHomeAndEndKeys = false, + stopEventPropagation = false, + disabledIndices, + modifierKeys = EMPTY_ARRAY + } = params; + + const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0); + + const isGrid = cols > 1; + + const rootRef = React.useRef(null); + const mergedRef = useMergedRef(rootRef, externalRef); + + const elementsRef = React.useRef>([]); + const hasSetDefaultIndexRef = React.useRef(false); + + const highlightedIndex = externalHighlightedIndex ?? internalHighlightedIndex; + const onHighlightedIndexChange = useEventCallback((index, shouldScrollIntoView = false) => { + (externalSetHighlightedIndex ?? internalSetHighlightedIndex)(index); + if (shouldScrollIntoView) { + const newActiveItem = elementsRef.current[index] || null; + scrollIntoViewIfNeeded(rootRef.current, newActiveItem, direction, orientation); + } + }); + + useIsoLayoutEffect(() => { + const activeEl = activeElement(ownerDocument(rootRef.current)) as HTMLDivElement | null; + if (elementsRef.current.includes(activeEl)) { + const focusedItem = elementsRef.current[highlightedIndex]; + if (focusedItem && focusedItem !== activeEl) { + focusedItem.focus(); + } + } + }, [highlightedIndex]); + + const onMapChange = useEventCallback((map: Map>) => { + if (map.size === 0 || hasSetDefaultIndexRef.current) { + return; + } + hasSetDefaultIndexRef.current = true; + const sortedElements = Array.from(map.keys()); + const activeItem = (sortedElements.find((compositeElement) => + compositeElement?.hasAttribute(ACTIVE_COMPOSITE_ITEM) + ) ?? null) as HTMLElement | null; + // Set the default highlighted index of an arbitrary composite item. + const activeIndex = activeItem ? sortedElements.indexOf(activeItem) : -1; + + if (activeIndex !== -1) { + onHighlightedIndexChange(activeIndex); + } + + scrollIntoViewIfNeeded(rootRef.current, activeItem, direction, orientation); + }); + + const props = React.useMemo( + () => ({ + 'aria-orientation': orientation === 'both' ? undefined : orientation, + 'ref': mergedRef, + onFocus(event) { + const element = rootRef.current; + if (!element || !isNativeInput(event.target)) { + return; + } + event.target.setSelectionRange(0, event.target.value.length ?? 0); + }, + onKeyDown(event) { + const RELEVANT_KEYS = enableHomeAndEndKeys ? ALL_KEYS : ARROW_KEYS; + if (!RELEVANT_KEYS.has(event.key)) { + return; + } + + if (isModifierKeySet(event, modifierKeys)) { + return; + } + + const element = rootRef.current; + if (!element) { + return; + } + const isRtl = direction === 'rtl'; + + const horizontalForwardKey = isRtl ? ARROW_LEFT : ARROW_RIGHT; + const forwardKey = { + horizontal: horizontalForwardKey, + vertical: ARROW_DOWN, + both: horizontalForwardKey + }[orientation]; + const horizontalBackwardKey = isRtl ? ARROW_RIGHT : ARROW_LEFT; + const backwardKey = { + horizontal: horizontalBackwardKey, + vertical: ARROW_UP, + both: horizontalBackwardKey + }[orientation]; + + if (isNativeInput(event.target) && !isElementDisabled(event.target)) { + const selectionStart = event.target.selectionStart; + const selectionEnd = event.target.selectionEnd; + const textContent = event.target.value ?? ''; + // return to native textbox behavior when + // 1 - Shift is held to make a text selection, or if there already is a text selection + if (selectionStart == null || event.shiftKey || selectionStart !== selectionEnd) { + return; + } + // 2 - arrow-ing forward and not in the last position of the text + if (event.key !== backwardKey && selectionStart < textContent.length) { + return; + } + // 3 -arrow-ing backward and not in the first position of the text + if (event.key !== forwardKey && selectionStart > 0) { + return; + } + } + + let nextIndex = highlightedIndex; + const minIndex = getMinListIndex(elementsRef, disabledIndices); + const maxIndex = getMaxListIndex(elementsRef, disabledIndices); + + if (isGrid) { + const sizes + = itemSizes + || Array.from({ length: elementsRef.current.length }, () => ({ + width: 1, + height: 1 + })); + // To calculate movements on the grid, we use hypothetical cell indices + // as if every item was 1x1, then convert back to real indices. + const cellMap = createGridCellMap(sizes, cols, dense); + const minGridIndex = cellMap.findIndex( + (index) => index != null && !isListIndexDisabled(elementsRef, index, disabledIndices) + ); + // last enabled index + const maxGridIndex = cellMap.findLastIndex( + (index) => index != null && !isListIndexDisabled(elementsRef, index, disabledIndices) + ); + + nextIndex = cellMap[ + getGridNavigatedIndex( + { + current: cellMap.map((itemIndex) => + itemIndex ? elementsRef.current[itemIndex] || null : null + ) + }, + { + event, + orientation, + loop, + cols, + // treat undefined (empty grid spaces) as disabled indices so we + // don't end up in them + disabledIndices: getGridCellIndices( + [...(disabledIndices + || elementsRef.current.map((_, index) => + isListIndexDisabled(elementsRef, index) ? index : undefined + )), undefined], + cellMap + ), + minIndex: minGridIndex, + maxIndex: maxGridIndex, + prevIndex: getGridCellIndexOfCorner( + highlightedIndex > maxIndex ? minIndex : highlightedIndex, + sizes, + cellMap, + cols, + // use a corner matching the edge closest to the direction we're + // moving in so we don't end up in the same item. Prefer + // top/left over bottom/right. + + event.key === ARROW_DOWN ? 'bl' : event.key === ARROW_RIGHT ? 'tr' : 'tl' + ), + rtl: isRtl + } + ) + ] as number; // navigated cell will never be nullish + } + + const forwardKeys = { + horizontal: [horizontalForwardKey], + vertical: [ARROW_DOWN], + both: [horizontalForwardKey, ARROW_DOWN] + }[orientation]; + + const backwardKeys = { + horizontal: [horizontalBackwardKey], + vertical: [ARROW_UP], + both: [horizontalBackwardKey, ARROW_UP] + }[orientation]; + + const preventedKeys = isGrid + ? RELEVANT_KEYS + : { + horizontal: enableHomeAndEndKeys ? HORIZONTAL_KEYS_WITH_EXTRA_KEYS : HORIZONTAL_KEYS, + vertical: enableHomeAndEndKeys ? VERTICAL_KEYS_WITH_EXTRA_KEYS : VERTICAL_KEYS, + both: RELEVANT_KEYS + }[orientation]; + + if (enableHomeAndEndKeys) { + if (event.key === HOME) { + nextIndex = minIndex; + } + else if (event.key === END) { + nextIndex = maxIndex; + } + } + + if ( + nextIndex === highlightedIndex + && (forwardKeys.includes(event.key) || backwardKeys.includes(event.key)) + ) { + if (loop && nextIndex === maxIndex && forwardKeys.includes(event.key)) { + nextIndex = minIndex; + } + else if (loop && nextIndex === minIndex && backwardKeys.includes(event.key)) { + nextIndex = maxIndex; + } + else { + nextIndex = findNonDisabledListIndex(elementsRef, { + startingIndex: nextIndex, + decrement: backwardKeys.includes(event.key), + disabledIndices + }); + } + } + + if (nextIndex !== highlightedIndex && !isIndexOutOfListBounds(elementsRef, nextIndex)) { + if (stopEventPropagation) { + event.stopPropagation(); + } + + if (preventedKeys.has(event.key)) { + event.preventDefault(); + } + onHighlightedIndexChange(nextIndex, true); + + // Wait for FocusManager `returnFocus` to execute. + queueMicrotask(() => { + elementsRef.current[nextIndex]?.focus(); + }); + } + } + }), + [ + cols, + dense, + direction, + disabledIndices, + elementsRef, + enableHomeAndEndKeys, + highlightedIndex, + isGrid, + itemSizes, + loop, + mergedRef, + modifierKeys, + onHighlightedIndexChange, + orientation, + stopEventPropagation + ] + ); + + return React.useMemo( + () => ({ + props, + highlightedIndex, + onHighlightedIndexChange, + elementsRef, + disabledIndices, + onMapChange + }), + [ + props, + highlightedIndex, + onHighlightedIndexChange, + elementsRef, + disabledIndices, + onMapChange + ] + ); +} + +function isModifierKeySet(event: React.KeyboardEvent, ignoredModifierKeys: ModifierKey[]) { + for (const key of MODIFIER_KEYS.values()) { + if (ignoredModifierKeys.includes(key)) { + continue; + } + if (event.getModifierState(key)) { + return true; + } + } + + return false; +} diff --git a/packages/ui/uikit/headless/components/src/components/Separator/Separator.ts b/packages/ui/uikit/headless/components/src/components/Separator/Separator.ts new file mode 100644 index 00000000..1b137118 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Separator/Separator.ts @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; + +import { useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps, Orientation } from '@lib/types'; + +export function Separator(componentProps: Separator.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + orientation = 'horizontal', + ref, + ...elementProps + } = componentProps; + + const state: Separator.State = React.useMemo(() => ({ orientation }), [orientation]); + + const element = useRenderElement('div', componentProps, { + state, + ref, + props: [{ 'role': 'separator', 'aria-orientation': orientation }, elementProps] + }); + + return element; +} + +export namespace Separator { + export type Props = { + /** + * The orientation of the separator. + * @default 'horizontal' + */ + orientation?: Orientation; + } & HeadlessUIComponentProps<'div', State>; + + export type State = { + /** + * The orientation of the separator. + */ + orientation: Orientation; + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/Separator/index.ts b/packages/ui/uikit/headless/components/src/components/Separator/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/index.parts.ts b/packages/ui/uikit/headless/components/src/components/Tabs/index.parts.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/index.ts b/packages/ui/uikit/headless/components/src/components/Tabs/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicator.tsx b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicator.tsx new file mode 100644 index 00000000..5f00b8fb --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicator.tsx @@ -0,0 +1,213 @@ +'use client'; + +import React from 'react'; + +import { useForcedRerendering } from '@flippo_ui/hooks'; + +import { generateId } from '@lib/generateId'; +import { useDirection, useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps, Orientation } from '@lib/types'; + +import { useTabsListContext } from '../list/TabsListContext'; +import { tabsStyleHookMapping } from '../root/styleHooks'; +import { useTabsRootContext } from '../root/TabsRootContext'; + +import type { TabsRoot } from '../root/TabsRoot'; +import type { TabsTab } from '../tab/TabsTab'; + +import { TabsIndicatorCssVars } from './TabsIndicatorCssVars'; + +const customStyleHookMapping = { + ...tabsStyleHookMapping, + selectedTabPosition: () => null, + selectedTabSize: () => null +}; + +/** + * A visual indicator that can be styled to match the position of the currently active tab. + * Renders a `` element. + * + * Documentation: [Base UI Tabs](https://base-ui.com/react/components/tabs) + */ +export function TabsIndicator(componentProps: TabsIndicator.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + ref, + ...elementProps + } = componentProps; + + const { + getTabElementBySelectedValue, + orientation, + tabActivationDirection, + value + } + = useTabsRootContext(); + + const { tabsListRef } = useTabsListContext(); + + const [instanceId] = React.useState(() => generateId('tab')); + const { value: activeTabValue } = useTabsRootContext(); + + const direction = useDirection(); + + const rerender = useForcedRerendering(); + + React.useEffect(() => { + if (value != null && tabsListRef.current != null && typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver(() => { + rerender(); + }); + + resizeObserver.observe(tabsListRef.current); + + return () => { + resizeObserver.disconnect(); + }; + } + + return undefined; + }, [value, tabsListRef, rerender]); + + let left = 0; + let right = 0; + let top = 0; + let bottom = 0; + let width = 0; + let height = 0; + + let isTabSelected = false; + + if (value != null && tabsListRef.current != null) { + const selectedTab = getTabElementBySelectedValue(value); + const tabsList = tabsListRef.current; + isTabSelected = true; + + if (selectedTab != null) { + left = selectedTab.offsetLeft - tabsList.clientLeft; + right + = direction === 'ltr' + ? tabsList.scrollWidth + - selectedTab.offsetLeft + - selectedTab.offsetWidth + - tabsList.clientLeft + : selectedTab.offsetLeft - tabsList.clientLeft; + top = selectedTab.offsetTop - tabsList.clientTop; + bottom + = tabsList.scrollHeight + - selectedTab.offsetTop + - selectedTab.offsetHeight + - tabsList.clientTop; + width = selectedTab.offsetWidth; + height = selectedTab.offsetHeight; + } + } + + const selectedTabPosition = React.useMemo( + () => + isTabSelected + ? { + left, + right, + top, + bottom + } + : null, + [ + left, + right, + top, + bottom, + isTabSelected + ] + ); + + const selectedTabSize = React.useMemo( + () => + isTabSelected + ? { + width, + height + } + : null, + [width, height, isTabSelected] + ); + + const style = React.useMemo(() => { + if (!isTabSelected) { + return undefined; + } + + return { + [TabsIndicatorCssVars.activeTabLeft]: `${left}px`, + [TabsIndicatorCssVars.activeTabRight]: `${right}px`, + [TabsIndicatorCssVars.activeTabTop]: `${top}px`, + [TabsIndicatorCssVars.activeTabBottom]: `${bottom}px`, + [TabsIndicatorCssVars.activeTabWidth]: `${width}px`, + [TabsIndicatorCssVars.activeTabHeight]: `${height}px` + } as React.CSSProperties; + }, [ + left, + right, + top, + bottom, + width, + height, + isTabSelected + ]); + + const displayIndicator = isTabSelected && width > 0 && height > 0; + + const state: TabsIndicator.State = React.useMemo( + () => ({ + orientation, + selectedTabPosition, + selectedTabSize, + tabActivationDirection + }), + [ + orientation, + selectedTabPosition, + selectedTabSize, + tabActivationDirection + ] + ); + + const element = useRenderElement('span', componentProps, { + state, + ref, + props: [{ + role: 'presentation', + style, + hidden: !displayIndicator // do not display the indicator before the layout is settled + }, elementProps, { + ['data-instance-id' as string]: instanceId, + suppressHydrationWarning: true + }], + customStyleHookMapping + }); + + if (activeTabValue == null) { + return null; + } + + return ( + + {element} + + ); +} + +export namespace TabsIndicator { + export type State = { + selectedTabPosition: TabsTab.Position | null; + selectedTabSize: TabsTab.Size | null; + orientation: Orientation; + } & TabsRoot.State; + + export type Props = HeadlessUIComponentProps<'span', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorCssVars.ts b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorCssVars.ts new file mode 100644 index 00000000..92be94a6 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorCssVars.ts @@ -0,0 +1,32 @@ +export enum TabsIndicatorCssVars { + /** + * Indicates the distance on the left side from the parent's container if the tab is active. + * @type {number} + */ + activeTabLeft = '--active-tab-left', + /** + * Indicates the distance on the right side from the parent's container if the tab is active. + * @type {number} + */ + activeTabRight = '--active-tab-right', + /** + * Indicates the distance on the top side from the parent's container if the tab is active. + * @type {number} + */ + activeTabTop = '--active-tab-top', + /** + * Indicates the distance on the bottom side from the parent's container if the tab is active. + * @type {number} + */ + activeTabBottom = '--active-tab-bottom', + /** + * Indicates the width of the tab if it is active. + * @type {number} + */ + activeTabWidth = '--active-tab-width', + /** + * Indicates the width of the tab if it is active. + * @type {number} + */ + activeTabHeight = '--active-tab-height' +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorDataAttributes.ts new file mode 100644 index 00000000..37a214c8 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/indicator/TabsIndicatorDataAttributes.ts @@ -0,0 +1,12 @@ +export enum TabsIndicatorDataAttributes { + /** + * Indicates the direction of the activation (based on the previous selected tab). + * @type {'left' | 'right' | 'up' | 'down' | 'none'} + */ + activationDirection = 'data-activation-direction', + /** + * Indicates the orientation of the tabs. + * @type {'horizontal' | 'vertical'} + */ + orientation = 'data-orientation' +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsList.tsx b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsList.tsx new file mode 100644 index 00000000..b2b5270b --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsList.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React from 'react'; + +import { useEventCallback, useIsoLayoutEffect } from '@flippo_ui/hooks'; + +import { EMPTY_ARRAY } from '@lib/constants'; + +import type { HeadlessUIComponentProps, HTMLProps, Orientation } from '@lib/types'; + +import { CompositeRoot } from '../../Composite/root/CompositeRoot'; +import { tabsStyleHookMapping } from '../root/styleHooks'; +import { useTabsRootContext } from '../root/TabsRootContext'; + +import type { TabsRoot } from '../root/TabsRoot'; +import type { TabsTab } from '../tab/TabsTab'; + +import { TabsListContext } from './TabsListContext'; + +import type { TTabsListContext } from './TabsListContext'; + +/** + * Groups the individual tab buttons. + * Renders a `
` element. + * + * Documentation: [Base UI Tabs](https://base-ui.com/react/components/tabs) + */ +export function TabsList(componentProps: TabsList.Props) { + const { + activateOnFocus = true, + className, + ref, + loop = true, + render, + ...elementProps + } = componentProps; + + const { + getTabElementBySelectedValue, + onValueChange, + orientation, + value, + setTabMap, + tabActivationDirection + } = useTabsRootContext(); + + const [highlightedTabIndex, setHighlightedTabIndex] = React.useState(0); + + const tabsListRef = React.useRef(null); + + const detectActivationDirection = useActivationDirectionDetector( + value, // the old value + orientation, + tabsListRef, + getTabElementBySelectedValue + ); + + const onTabActivation = useEventCallback((newValue: any, event: Event) => { + if (newValue !== value) { + const activationDirection = detectActivationDirection(newValue); + onValueChange(newValue, activationDirection, event); + } + }); + + const state: TabsList.State = React.useMemo( + () => ({ + orientation, + tabActivationDirection + }), + [orientation, tabActivationDirection] + ); + + const defaultProps: HTMLProps = { + 'aria-orientation': orientation === 'vertical' ? 'vertical' : undefined, + 'role': 'tablist' + }; + + const tabsListContextValue: TTabsListContext = React.useMemo( + () => ({ + activateOnFocus, + highlightedTabIndex, + onTabActivation, + setHighlightedTabIndex, + tabsListRef, + value + }), + [ + activateOnFocus, + highlightedTabIndex, + onTabActivation, + setHighlightedTabIndex, + tabsListRef, + value + ] + ); + + return ( + + + + ); +} + +function getInset(tab: HTMLElement, tabsList: HTMLElement) { + const { left: tabLeft, top: tabTop } = tab.getBoundingClientRect(); + const { left: listLeft, top: listTop } = tabsList.getBoundingClientRect(); + + const left = tabLeft - listLeft; + const top = tabTop - listTop; + + return { left, top }; +} + +function useActivationDirectionDetector( + // the old value + selectedTabValue: any, + orientation: Orientation, + tabsListRef: React.RefObject, + getTabElement: (selectedValue: any) => HTMLElement | null +): (newValue: any) => TabsTab.ActivationDirection { + const previousTabEdge = React.useRef(null); + + useIsoLayoutEffect(() => { + // Whenever orientation changes, reset the state. + if (selectedTabValue == null || tabsListRef.current == null) { + previousTabEdge.current = null; + return; + } + + const activeTab = getTabElement(selectedTabValue); + if (activeTab == null) { + previousTabEdge.current = null; + return; + } + + const { left, top } = getInset(activeTab, tabsListRef.current); + previousTabEdge.current = orientation === 'horizontal' ? left : top; + }, [ + orientation, + getTabElement, + tabsListRef, + selectedTabValue + ]); + + return React.useCallback( + (newValue: any) => { + if (newValue === selectedTabValue) { + return 'none'; + } + + if (newValue == null) { + previousTabEdge.current = null; + return 'none'; + } + + if (newValue != null && tabsListRef.current != null) { + const selectedTabElement = getTabElement(newValue); + + if (selectedTabElement != null) { + const { left, top } = getInset(selectedTabElement, tabsListRef.current); + + if (previousTabEdge.current == null) { + previousTabEdge.current = orientation === 'horizontal' ? left : top; + return 'none'; + } + + if (orientation === 'horizontal') { + if (left < previousTabEdge.current) { + previousTabEdge.current = left; + return 'left'; + } + if (left > previousTabEdge.current) { + previousTabEdge.current = left; + return 'right'; + } + } + else if (top < previousTabEdge.current) { + previousTabEdge.current = top; + return 'up'; + } + else if (top > previousTabEdge.current) { + previousTabEdge.current = top; + return 'down'; + } + } + } + + return 'none'; + }, + [ + getTabElement, + orientation, + previousTabEdge, + tabsListRef, + selectedTabValue + ] + ); +} + +export namespace TabsList { + export type State = TabsRoot.State; + + export type Props = { + /** + * Whether to automatically change the active tab on arrow key focus. + * Otherwise, tabs will be activated using Enter or Spacebar key press. + * @default true + */ + activateOnFocus?: boolean; + /** + * Whether to loop keyboard focus back to the first item + * when the end of the list is reached while using the arrow keys. + * @default true + */ + loop?: boolean; + } & HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListContext.ts b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListContext.ts new file mode 100644 index 00000000..63029482 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListContext.ts @@ -0,0 +1,25 @@ +'use client'; + +import React from 'react'; + +export type TTabsListContext = { + activateOnFocus: boolean; + highlightedTabIndex: number; + onTabActivation: (newValue: any, event: Event) => void; + setHighlightedTabIndex: (index: number) => void; + tabsListRef: React.RefObject; +}; + +export const TabsListContext = React.createContext(undefined); + +export function useTabsListContext() { + const context = React.use(TabsListContext); + + if (context === undefined) { + throw new Error( + 'Headless UI: TabsListContext is missing. TabsList parts must be placed within .' + ); + } + + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListDataAttributes.ts new file mode 100644 index 00000000..a4b28cd5 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/list/TabsListDataAttributes.ts @@ -0,0 +1,12 @@ +export enum TabsListDataAttributes { + /** + * Indicates the direction of the activation (based on the previous selected tab). + * @type {'left' | 'right' | 'up' | 'down' | 'none'} + */ + activationDirection = 'data-activation-direction', + /** + * Indicates the orientation of the tabs. + * @type {'horizontal' | 'vertical'} + */ + orientation = 'data-orientation' +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanel.tsx b/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanel.tsx new file mode 100644 index 00000000..93e9d1fc --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React from 'react'; + +import { useHeadlessUiId, useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps } from '@lib/types'; + +import { useCompositeListItem } from '../../Composite/list/useCompositeListItem'; +import { tabsStyleHookMapping } from '../root/styleHooks'; +import { useTabsRootContext } from '../root/TabsRootContext'; + +import type { TabsRoot } from '../root/TabsRoot'; +import type { TabsTab } from '../tab/TabsTab'; + +import { TabsPanelDataAttributes } from './TabsPanelDataAttributes'; + +/** + * A panel displayed when the corresponding tab is active. + * Renders a `
` element. + * + * Documentation: [Base UI Tabs](https://base-ui.com/react/components/tabs) + */ +export function TabsPanel(componentProps: TabsPanel.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + children, + value: valueProp, + keepMounted = false, + ref, + ...elementProps + } = componentProps; + + const { + value: selectedValue, + getTabIdByPanelValueOrIndex, + orientation, + tabActivationDirection + } = useTabsRootContext(); + + const id = useHeadlessUiId(); + + const metadata = React.useMemo( + () => ({ + id, + value: valueProp + }), + [id, valueProp] + ); + + const { ref: listItemRef, index } = useCompositeListItem({ + metadata + }); + + const tabPanelValue = valueProp ?? index; + + const hidden = tabPanelValue !== selectedValue; + + const correspondingTabId = React.useMemo(() => { + return getTabIdByPanelValueOrIndex(valueProp, index); + }, [getTabIdByPanelValueOrIndex, index, valueProp]); + + const state: TabsPanel.State = React.useMemo( + () => ({ + hidden, + orientation, + tabActivationDirection + }), + [hidden, orientation, tabActivationDirection] + ); + + const element = useRenderElement('div', componentProps, { + state, + ref: [ref, listItemRef], + props: [{ + 'aria-labelledby': correspondingTabId, + hidden, + 'id': id ?? undefined, + 'role': 'tabpanel', + 'tabIndex': hidden ? -1 : 0, + [TabsPanelDataAttributes.index as string]: index + }, elementProps, { children: hidden && !keepMounted ? undefined : children }], + customStyleHookMapping: tabsStyleHookMapping + }); + + return element; +} + +export namespace TabsPanel { + export type Metadata = { + id?: string; + value: TabsTab.Value; + }; + + export type State = { + hidden: boolean; + } & TabsRoot.State; + + export type Props = { + /** + * The value of the TabPanel. It will be shown when the Tab with the corresponding value is selected. + * If not provided, it will fall back to the index of the panel. + * It is recommended to explicitly provide it, as it's required for the tab panel to be rendered on the server. + */ + value?: TabsTab.Value; + /** + * Whether to keep the HTML element in the DOM while the panel is hidden. + * @default false + */ + keepMounted?: boolean; + } & HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanelDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanelDataAttributes.ts new file mode 100644 index 00000000..b09ffe0d --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/panel/TabsPanelDataAttributes.ts @@ -0,0 +1,20 @@ +export enum TabsPanelDataAttributes { + /** + * Indicates the index of the tab panel. + */ + index = 'data-index', + /** + * Indicates the direction of the activation (based on the previous selected tab). + * @type {'left' | 'right' | 'up' | 'down' | 'none'} + */ + activationDirection = 'data-activation-direction', + /** + * Indicates the orientation of the tabs. + * @type {'horizontal' | 'vertical'} + */ + orientation = 'data-orientation', + /** + * Present when the panel is hidden. + */ + hidden = 'data-hidden' +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRoot.tsx b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRoot.tsx new file mode 100644 index 00000000..fe4482e1 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRoot.tsx @@ -0,0 +1,212 @@ +'use client'; + +import React from 'react'; + +import { useControlledState, useEventCallback } from '@flippo_ui/hooks'; + +import { useDirection, useRenderElement } from '@lib/hooks'; + +import type { HeadlessUIComponentProps, Orientation } from '@lib/types'; + +import { CompositeList } from '../../Composite/list/CompositeList'; + +import type { CompositeMetadata } from '../../Composite/list/CompositeList'; +import type { TabsPanel } from '../panel/TabsPanel'; +import type { TabsTab } from '../tab/TabsTab'; + +import { tabsStyleHookMapping } from './styleHooks'; +import { TabsRootContext } from './TabsRootContext'; + +import type { TTabsRootContext } from './TabsRootContext'; + +export function TabsRoot(componentProps: TabsRoot.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + value: valueProp, + defaultValue = 0, + orientation = 'horizontal', + ref, + onValueChange: onValueChangeProp, + ...elementProps + } = componentProps; + + const direction = useDirection(); + + const tabPanelRefs = React.useRef<(HTMLElement | null)[]>([]); + + const [value, setValue] = useControlledState({ + prop: valueProp, + defaultProp: defaultValue, + caller: 'TabsRoot' + }); + + const [tabPanelMap, setTabPanelMap] = React.useState( + () => new Map | null>() + ); + const [tabMap, setTabMap] = React.useState( + () => new Map | null>() + ); + + const [tabActivationDirection, setTabActivationDirection] + = React.useState('none'); + + const onValueChange = useEventCallback( + ( + newValue: TabsTab.Value, + activationDirection: TabsTab.ActivationDirection, + event: Event | undefined + ) => { + setValue(newValue); + setTabActivationDirection(activationDirection); + onValueChangeProp?.(newValue, event); + } + ); + + const getTabPanelIdByTabValueOrIndex = React.useCallback(( + tabValue: TabsTab.Value | undefined, + index: number + ) => { + if (tabValue === undefined && index < 0) + return undefined; + + for (const tabPanelMetadata of tabPanelMap.values()) { + if (tabValue !== undefined && tabPanelMetadata && tabValue === tabPanelMetadata?.value) { + return tabPanelMetadata.id; + } + + if ( + tabValue === undefined + && tabPanelMetadata?.index + && tabPanelMetadata?.index === index + ) { + return tabPanelMetadata.id; + } + } + + return undefined; + }, [tabPanelMap]); + + const getTabIdByPanelValueOrIndex = React.useCallback( + (tabPanelValue: TabsTab.Value | undefined, index: number) => { + if (tabPanelValue === undefined && index < 0) { + return undefined; + } + + for (const tabMetadata of tabMap.values()) { + if ( + tabPanelValue !== undefined + && index > -1 + && tabPanelValue === (tabMetadata?.value ?? tabMetadata?.index ?? undefined) + ) { + return tabMetadata?.id; + } + + if ( + tabPanelValue === undefined + && index > -1 + && index === (tabMetadata?.value ?? tabMetadata?.index ?? undefined) + ) { + return tabMetadata?.id; + } + } + + return undefined; + }, + [tabMap] + ); + + const getTabElementBySelectedValue = React.useCallback( + (selectedValue: TabsTab.Value | undefined): HTMLElement | null => { + if (selectedValue === undefined) { + return null; + } + + for (const [tabElement, tabMetadata] of tabMap.entries()) { + if (tabMetadata != null && selectedValue === (tabMetadata.value ?? tabMetadata.index)) { + return tabElement as HTMLElement; + } + } + + return null; + }, + [tabMap] + ); + + const tabsContextValue: TTabsRootContext = React.useMemo( + () => ({ + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + orientation, + setTabMap, + tabActivationDirection, + value + }), + [ + direction, + getTabElementBySelectedValue, + getTabIdByPanelValueOrIndex, + getTabPanelIdByTabValueOrIndex, + onValueChange, + orientation, + setTabMap, + tabActivationDirection, + value + ] + ); + + const state: TabsRoot.State = React.useMemo(() => ({ + orientation, + tabActivationDirection + }), [orientation, tabActivationDirection]); + + const element = useRenderElement('div', componentProps, { + state, + ref, + props: elementProps, + customStyleHookMapping: tabsStyleHookMapping + }); + + return ( + + elementsRef={tabPanelRefs} onMapChange={setTabPanelMap}> + {element} + + + ); +} + +export namespace TabsRoot { + export type State = { + orientation: Orientation; + tabActivationDirection: TabsTab.ActivationDirection; + }; + + export type Props = { + /** + * The value of the currently selected `Tab`. Use when the component is controlled. + * When the value is `null`, no Tab will be selected. + */ + value?: TabsTab.Value; + /** + * The default value. Use when the component is not controlled. + * When the value is `null`, no Tab will be selected. + * @default 0 + */ + defaultValue?: TabsTab.Value; + /** + * The component orientation (layout flow direction). + * @default 'horizontal' + */ + orientation?: Orientation; + /** + * Callback invoked when new value is being set. + */ + onValueChange?: (value: TabsTab.Value, event?: Event) => void; + } & HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootContext.ts b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootContext.ts new file mode 100644 index 00000000..512f3ee3 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootContext.ts @@ -0,0 +1,63 @@ +'use client'; + +import React from 'react'; + +import type { TabsTab } from '../tab/TabsTab'; + +export type TTabsRootContext = { + /** + * The currently selected tab's value. + */ + value: TabsTab.Value; + /** + * Callback for setting new value. + */ + onValueChange: ( + value: TabsTab.Value, + activationDirection: TabsTab.ActivationDirection, + event: Event, + ) => void; + /** + * The component orientation (layout flow direction). + */ + orientation: 'horizontal' | 'vertical'; + /** + * Gets the element of the Tab with the given value. + * @param {any | undefined} value Value to find the tab for. + */ + getTabElementBySelectedValue: (selectedValue: TabsTab.Value | undefined) => HTMLElement | null; + /** + * Gets the `id` attribute of the Tab that corresponds to the given TabPanel value or index. + * @param (any | undefined) panelValue Value to find the Tab for. + * @param (number) index The index of the TabPanel to look for. + */ + getTabIdByPanelValueOrIndex: ( + panelValue: TabsTab.Value | undefined, + index: number, + ) => string | undefined; + /** + * Gets the `id` attribute of the TabPanel that corresponds to the given Tab value or index. + * @param (any | undefined) tabValue Value to find the Tab for. + * @param (number) index The index of the Tab to look for. + */ + getTabPanelIdByTabValueOrIndex: (tabValue: any, index: number) => string | undefined; + setTabMap: (map: Map) => void; + /** + * The position of the active tab relative to the previously active tab. + */ + tabActivationDirection: TabsTab.ActivationDirection; +}; + +export const TabsRootContext = React.createContext(undefined); + +export function useTabsRootContext() { + const context = React.use(TabsRootContext); + + if (context === undefined) { + throw new Error( + 'Headless UI: TabsRootContext is missing. Tabs parts must be placed within .' + ); + } + + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootDataAttributes.ts new file mode 100644 index 00000000..164a2721 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/root/TabsRootDataAttributes.ts @@ -0,0 +1,12 @@ +export enum TabsRootDataAttributes { + /** + * Indicates the direction of the activation (based on the previous selected tab). + * @type {'left' | 'right' | 'up' | 'down' | 'none'} + */ + activationDirection = 'data-activation-direction', + /** + * Indicates the orientation of the tabs. + * @type {'horizontal' | 'vertical'} + */ + orientation = 'data-orientation' +} diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/root/styleHooks.ts b/packages/ui/uikit/headless/components/src/components/Tabs/root/styleHooks.ts new file mode 100644 index 00000000..e54ba513 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/root/styleHooks.ts @@ -0,0 +1,13 @@ +import type { CustomStyleHookMapping } from '@lib/getStyleHookProps'; + +import type { TabsTab } from '../tab/TabsTab'; + +import { TabsRootDataAttributes } from './TabsRootDataAttributes'; + +import type { TabsRoot } from './TabsRoot'; + +export const tabsStyleHookMapping: CustomStyleHookMapping = { + tabActivationDirection: (dir: TabsTab.ActivationDirection) => ({ + [TabsRootDataAttributes.activationDirection]: dir + }) +}; diff --git a/packages/ui/uikit/headless/components/src/components/Tabs/tab/TabsTab.tsx b/packages/ui/uikit/headless/components/src/components/Tabs/tab/TabsTab.tsx new file mode 100644 index 00000000..17ee2606 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tabs/tab/TabsTab.tsx @@ -0,0 +1,233 @@ +'use client'; + +import React from 'react'; + +import { useEventCallback, useIsoLayoutEffect } from '@flippo_ui/hooks'; + +import { useHeadlessUiId, useRenderElement } from '@lib/hooks'; +import { ownerDocument } from '@lib/owner'; + +import type { HeadlessUIComponentProps, NativeButtonProps, Orientation } from '@lib/types'; + +import { ACTIVE_COMPOSITE_ITEM } from '../../Composite/constants'; +import { useCompositeItem } from '../../Composite/item/useCompositeItem'; +import { useButton } from '../../use-button'; +import { useTabsListContext } from '../list/TabsListContext'; +import { useTabsRootContext } from '../root/TabsRootContext'; + +/** + * An individual interactive tab button that toggles the corresponding panel. + * Renders a ` + + {isPositioned && !anchorHidden && ( +
+ {'Tooltip content'} +
+
+ )} + + ); +} +``` + +## Key Implementation Details + +1. **Middleware Pipeline**: + + - `offset`: Handles main/cross axis offsets + - `flip`: Handles collision avoidance + - `shift`: Ensures content stays in viewport + - `size`: Applies dimension CSS variables + - `arrow`: Positions arrow element + - `hide`: Handles hidden anchors + - Custom `transformOrigin`: Calculates transform origin + +2. **RTL Handling**: + + - Converts logical directions (`inline-start`/`inline-end`) to physical directions based on text direction + +3. **Dynamic Updates**: + + - Uses `autoUpdate` from Floating UI to reposition on: + - Anchor resizing + - Viewport changes + - Scroll events + +4. **CSS Variables**: + - `--available-width`: Available viewport width + - `--available-height`: Available viewport height + - `--anchor-width`: Anchor element width + - `--anchor-height`: Anchor element height + - `--transform-origin`: Transform origin for animations + +## Best Practices + +1. Always conditionally render floating content using `isPositioned` and `anchorHidden` +2. Use the `update` method when anchor content changes dynamically +3. For performance, set `trackAnchor: false` for static elements +4. Use arrow element for visual connection between anchor and floating content +5. Handle RTL languages using the `side` parameter's logical values diff --git a/packages/ui/uikit/headless/components/src/lib/hooks/useDirection.ts b/packages/ui/uikit/headless/components/src/lib/hooks/useDirection.ts new file mode 100644 index 00000000..c7fb85eb --- /dev/null +++ b/packages/ui/uikit/headless/components/src/lib/hooks/useDirection.ts @@ -0,0 +1,19 @@ +'use client'; +import * as React from 'react'; + +export type TTextDirection = 'ltr' | 'rtl'; + +export type TDirectionContext = { + direction: TTextDirection; +}; + +export const DirectionContext = React.createContext(undefined); + +export function useDirection(optional = true) { + const context = React.use(DirectionContext); + if (context === undefined && !optional) { + throw new Error('Flippo headless UI: DirectionContext is missing.'); + } + + return context?.direction ?? 'ltr'; +} diff --git a/packages/ui/uikit/headless/components/src/lib/hooks/useFocusableWhenDisabled.ts b/packages/ui/uikit/headless/components/src/lib/hooks/useFocusableWhenDisabled.ts new file mode 100644 index 00000000..3e50b94c --- /dev/null +++ b/packages/ui/uikit/headless/components/src/lib/hooks/useFocusableWhenDisabled.ts @@ -0,0 +1,83 @@ +'use client'; + +import * as React from 'react'; + +export function useFocusableWhenDisabled( + parameters: NUseFocusableWhenDisabled.Parameters +): NUseFocusableWhenDisabled.ReturnValue { + const { + focusableWhenDisabled, + disabled, + composite = false, + tabIndex: tabIndexProp = 0, + isNativeButton + } = parameters; + + const isFocusableComposite = composite && focusableWhenDisabled !== false; + const isNonFocusableComposite = composite && focusableWhenDisabled === false; + + // we can't explicitly assign `undefined` to any of these props because it + // would otherwise prevent subsequently merged props from setting them + const props = React.useMemo(() => { + const additionalProps = { + // allow Tabbing away from focusableWhenDisabled elements + onKeyDown(event: React.KeyboardEvent) { + if (disabled && focusableWhenDisabled && event.key !== 'Tab') { + event.preventDefault(); + } + } + } as NUseFocusableWhenDisabled.FocusableWhenDisabledProps; + + if (!composite) { + additionalProps.tabIndex = tabIndexProp; + + if (!isNativeButton && disabled) { + additionalProps.tabIndex = focusableWhenDisabled ? tabIndexProp : -1; + } + } + + if ( + (isNativeButton && (focusableWhenDisabled || isFocusableComposite)) + || (!isNativeButton && disabled) + ) { + additionalProps['aria-disabled'] = disabled; + } + + if (isNativeButton && (!focusableWhenDisabled || isNonFocusableComposite)) { + additionalProps.disabled = disabled; + } + + return additionalProps; + }, [ + composite, + disabled, + focusableWhenDisabled, + isFocusableComposite, + isNonFocusableComposite, + isNativeButton, + tabIndexProp + ]); + + return { props }; +} + +export namespace NUseFocusableWhenDisabled { + export type FocusableWhenDisabledProps = { + 'aria-disabled'?: boolean; + 'disabled'?: boolean; + 'onKeyDown': (event: React.KeyboardEvent) => void; + 'tabIndex': number; + }; + + export type Parameters = { + focusableWhenDisabled?: boolean | undefined; + disabled: boolean; + composite?: boolean; + tabIndex?: number; + isNativeButton: boolean; + }; + + export type ReturnValue = { + props: FocusableWhenDisabledProps; + }; +} diff --git a/packages/ui/uikit/headless/components/src/lib/hooks/useHeadlessUiId.ts b/packages/ui/uikit/headless/components/src/lib/hooks/useHeadlessUiId.ts new file mode 100644 index 00000000..6e3a98fe --- /dev/null +++ b/packages/ui/uikit/headless/components/src/lib/hooks/useHeadlessUiId.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useId } from '@flippo_ui/hooks'; + +/** + * Wraps `useId` and prefixes generated `id`s with `base-ui-` + * @param {string | undefined} idOverride overrides the generated id when provided + * @returns {string | undefined} + */ +export function useHeadlessUiId(idOverride?: string): string | undefined { + return useId(idOverride, 'headless-ui'); +} diff --git a/packages/ui/uikit/headless/components/src/lib/hooks/useRenderElement.tsx b/packages/ui/uikit/headless/components/src/lib/hooks/useRenderElement.tsx new file mode 100644 index 00000000..085989d5 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/lib/hooks/useRenderElement.tsx @@ -0,0 +1,234 @@ +import type { CustomStyleHookMapping } from '../getStyleHookProps'; + +import type { ComponentRenderFn, HeadlessUIComponentProps, HTMLProps } from '../types'; +import { useMergedRef } from '@flippo_ui/hooks'; +import React from 'react'; +import { EMPTY_OBJECT } from '../constants'; +import { getStyleHookProps } from '../getStyleHookProps'; +import { + mergeClassNames, + mergeObjects, + mergeProps, + mergePropsN +} from '../merge'; +import { resolveClassName } from '../resolveClassName'; + +type IntrinsicTagName = keyof React.JSX.IntrinsicElements; + +/** + * Renders a Base UI element. + * + * @param element The default HTML element to render. Can be overridden by the `render` prop. + * @param componentProps An object containing the `render` and `className` props to be used for element customization. Other props are ignored. + * @param params Additional parameters for rendering the element. + */ +export function useRenderElement< + State extends Record, + RenderedElementType extends Element, + TagName extends IntrinsicTagName | undefined, + Enabled extends boolean | undefined = undefined +>( + element: TagName, + componentProps: useRenderElement.ComponentProps, + params: useRenderElement.Parameters = {} +): Enabled extends false ? null : React.ReactElement> { + const renderProp = componentProps.render; + const outProps = useRenderElementProps(componentProps, params); + if (params.enabled === false) { + return null as Enabled extends false ? null : React.ReactElement>; + } + + const state = params.state ?? (EMPTY_OBJECT as State); + return evaluateRenderProp(element, renderProp, outProps, state) as Enabled extends false + ? null + : React.ReactElement>; +} + +/** + * Computes render element final props. + */ +function useRenderElementProps< + State extends Record, + RenderedElementType extends Element, + TagName extends IntrinsicTagName | undefined, + Enabled extends boolean | undefined +>( + componentProps: useRenderElement.ComponentProps, + params: useRenderElement.Parameters = {} +): React.HTMLAttributes & React.RefAttributes { + const { className: classNameProp, render: renderProp } = componentProps; + + const { + state = EMPTY_OBJECT as State, + ref, + props, + disableStyleHooks, + customStyleHookMapping, + enabled = true + } = params; + + const className = enabled ? resolveClassName(classNameProp, state) : undefined; + + let styleHooks: Record | undefined; + if (disableStyleHooks !== true) { + // SAFETY: We use typings to ensure `disableStyleHooks` is either always set or + // always unset, so this `if` block is stable across renders. + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + styleHooks = React.useMemo( + () => (enabled ? getStyleHookProps(state, customStyleHookMapping) : EMPTY_OBJECT), + [state, customStyleHookMapping, enabled] + ); + } + + const outProps: React.HTMLAttributes & React.RefAttributes = enabled + ? (mergeObjects(styleHooks, Array.isArray(props) ? mergePropsN(props) : props) ?? EMPTY_OBJECT) + : EMPTY_OBJECT; + + // SAFETY: The `useForkRef` functions use a single hook to store the same value, + // switching between them at runtime is safe. If this assertion fails, React will + // throw at runtime anyway. + // This also skips the `useForkRef` call on the server, which is fine because + // refs are not used on the server side. + /* eslint-disable react-hooks/rules-of-hooks */ + if (typeof document !== 'undefined') { + if (!enabled) { + useMergedRef(null, null); + } + else if (Array.isArray(ref)) { + outProps.ref = useMergedRef(outProps.ref, getChildRef(renderProp), ...ref); + } + else { + outProps.ref = useMergedRef(outProps.ref, getChildRef(renderProp), ref); + } + } + /* eslint-enable react-hooks/rules-of-hooks */ + + if (!enabled) { + return EMPTY_OBJECT; + } + + if (className !== undefined) { + outProps.className = mergeClassNames(outProps.className, className); + } + + return outProps; +} + +function evaluateRenderProp( + element: IntrinsicTagName | undefined, + render: HeadlessUIComponentProps['render'], + props: React.HTMLAttributes & React.RefAttributes, + state: S +): React.ReactElement> { + if (render) { + if (typeof render === 'function') { + return render(props, state); + } + + const mergedProps = mergeProps(props, render.props); + mergedProps.ref = props.ref; + + return React.cloneElement(render, mergedProps); + } + + if (element) { + if (typeof element === 'string') { + return renderTag(element, props); + } + } + // Unreachable, but the typings on `useRenderElement` need to be reworked + // to annotate it correctly. + throw new Error('Base UI: Render element or function are not defined.'); +} + +function renderTag(Tag: string, props: Record) { + if (Tag === 'button') { + return + + + {props.children} +
+ + )} +
+ outside +
+ + ); +} + +interface DialogProps { + open?: boolean; + render: (props: { close: () => void }) => React.ReactNode; + children: React.JSX.Element; +} + +function Dialog({ render, open: passedOpen = false, children }: DialogProps) { + const [open, setOpen] = React.useState(passedOpen); + const nodeId = useFloatingNodeId(); + + const { refs, context } = useFloating({ + open, + onOpenChange: setOpen, + nodeId, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + useClick(context), + useDismiss(context, { bubbles: false }), + ]); + + return ( + + {React.cloneElement( + children, + getReferenceProps({ ref: refs.setReference, ...children.props }), + )} + + {open && ( + +
+ {render({ + close: () => setOpen(false), + })} +
+
+ )} +
+
+ ); +} + +describe.skipIf(!isJSDOM)('FloatingFocusManager', () => { + describe('initialFocus', () => { + test('number', async () => { + const { rerender } = render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('one')).toHaveFocus(); + + rerender(); + expect(screen.getByTestId('two')).not.toHaveFocus(); + + rerender(); + expect(screen.getByTestId('three')).not.toHaveFocus(); + }); + + test('ref', async () => { + render(); + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('two')).toHaveFocus(); + }); + + test('respects autoFocus', async () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + expect(screen.getByTestId('input')).toHaveFocus(); + }); + }); + + describe('returnFocus', () => { + test('true', async () => { + const { rerender } = render(); + + screen.getByTestId('reference').focus(); + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('one')).toHaveFocus(); + + act(() => screen.getByTestId('two').focus()); + + rerender(); + + expect(screen.getByTestId('two')).toHaveFocus(); + + fireEvent.click(screen.getByTestId('three')); + expect(screen.getByTestId('reference')).not.toHaveFocus(); + }); + + test('false', async () => { + render(); + + screen.getByTestId('reference').focus(); + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('one')).toHaveFocus(); + + fireEvent.click(screen.getByTestId('three')); + expect(screen.getByTestId('reference')).not.toHaveFocus(); + }); + + test('ref', async () => { + function Test() { + const ref = React.useRef(null); + return ( +
+ + + + +
+ ); + } + + render(); + screen.getByTestId('reference').focus(); + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + fireEvent.click(screen.getByTestId('three')); + await flushMicrotasks(); + expect(screen.getByTestId('focus-target')).toHaveFocus(); + }); + + test('always returns to the reference for nested elements', async () => { + const NestedDialog: React.FC = (props) => { + const parentId = useFloatingParentNodeId(); + + if (parentId == null) { + return ( + + + + ); + } + + return ; + }; + + render( + ( + <> +
+ , + ); + screen.getByTestId('open-dialog').focus(); + await userEvent.keyboard('{Enter}'); + + expect(screen.getByTestId('close-dialog')).toBeInTheDocument(); + + await userEvent.keyboard('{Esc}'); + + expect(screen.queryByTestId('close-dialog')).not.toBeInTheDocument(); + + expect(screen.getByTestId('open-dialog')).toHaveFocus(); + }); + + test('preserves tabbable context next to reference element if removed (modal)', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + const [removed, setRemoved] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const click = useClick(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click]); + + return ( + <> + {!removed && ( + +
+ + + )} + + + + + )} + + {isOpen && ( + +
+ + )} + + ); + } + + render(); + + await userEvent.click(screen.getByText('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('floating')).toHaveFocus(); + + await userEvent.click(document.body); + await flushMicrotasks(); + + expect(screen.getByText('reference')).not.toHaveFocus(); + }, + ); + + test('returns focus to reference on outside press when preventScroll is supported', async () => { + const originalFocus = HTMLElement.prototype.focus; + Object.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value(options: any) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + options && options.preventScroll; + return originalFocus.call(this, options); + }, + }); + + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + return ( + <> + + {isOpen && ( + +
+ + )} + + ); + } + + render(); + + await userEvent.click(screen.getByText('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('floating')).toHaveFocus(); + + await userEvent.click(document.body); + await flushMicrotasks(); + + expect(screen.getByText('reference')).toHaveFocus(); + + HTMLElement.prototype.focus = originalFocus; + }); + }); + + describe('iframe focus navigation', () => { + function App({ iframe }: { iframe: HTMLElement }) { + return ( + + ); + } + + function Popover({ + children, + render, + portalRef, + }: { + children: React.ReactElement; + render: () => React.ReactNode; + portalRef?: HTMLElement; + }) { + const [open, setOpen] = React.useState(false); + + const { floatingStyles, refs, context } = useFloating({ + open, + onOpenChange: setOpen, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + return ( + <> + {React.cloneElement(children, getReferenceProps({ ref: refs.setReference }))} + {open && ( + + +
+ {render()} +
+
+
+ )} + + ); + } + + function IframeApp() { + React.useEffect(() => { + function createIframe() { + const container = document.querySelector('#innerRoot'); + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-testid', 'iframe'); + iframe.src = 'about:blank'; + iframe.style.height = '300px'; + + container?.appendChild(iframe); + + // Properly open, write, and close the iframe document. + const iframeDoc = iframe.contentWindow?.document; + if (iframeDoc) { + iframeDoc.open(); + iframeDoc.write(`
`); + iframeDoc.close(); + } + + const rootIframe = iframe.contentWindow?.document.getElementById('rootIframe'); + return rootIframe; + } + + const root = createIframe(); + if (root) { + ReactDOMClient.createRoot(root).render(); + } + }, []); + + return ( + <> + Outside link 1 +
+ Outside link 2 + + ); + } + + // "Should not already be working"(?) when trying to click within the iframe + // https://github.com/facebook/react/pull/32441 + test.skipIf(!isJSDOM)('tabs from the popover to the next element in the iframe', async () => { + render(); + + const iframe: HTMLIFrameElement = await screen.findByTestId('iframe'); + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + const iframeWithin = iframeDoc ? within(iframeDoc.body) : screen; + + const user = userEvent.setup({ document: iframeDoc }); + + await user.click(iframeWithin.getByRole('button', { name: 'Open' })); + + expect(iframeWithin.getByTestId('popover')).toBeInTheDocument(); + + await user.tab(); + await user.tab(); + + expect(iframeWithin.getByText('next iframe link')).toHaveFocus(); + }); + + // "Should not already be working"(?) when trying to click within the iframe + // https://github.com/facebook/react/pull/32441 + test.skipIf(!isJSDOM)( + 'shift+tab from the popover to the previous element in the iframe', + async () => { + render(); + + const iframe: HTMLIFrameElement = await screen.findByTestId('iframe'); + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + const iframeWithin = iframeDoc ? within(iframeDoc.body) : screen; + + const user = userEvent.setup({ document: iframeDoc }); + + await user.click(iframeWithin.getByRole('button', { name: 'Open' })); + + expect(iframeWithin.getByTestId('popover')).toBeInTheDocument(); + + await user.tab({ shift: true }); + + expect(iframeWithin.getByRole('button', { name: 'Open' })).toHaveFocus(); + }, + ); + }); + + describe('modal', () => { + test('true', async () => { + render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + await userEvent.tab(); + expect(screen.getByTestId('two')).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByTestId('three')).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByTestId('one')).toHaveFocus(); + + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('three')).toHaveFocus(); + + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('two')).toHaveFocus(); + + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('one')).toHaveFocus(); + + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('three')).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByTestId('one')).toHaveFocus(); + }); + + test('false', async () => { + render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + await userEvent.tab(); + expect(screen.getByTestId('two')).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByTestId('three')).toHaveFocus(); + + await userEvent.tab(); + + // Wait for the setTimeout that wraps onOpenChange(false). + await act(() => new Promise((resolve) => setTimeout(resolve))); + + // Focus leaving the floating element closes it. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + expect(screen.getByTestId('last')).toHaveFocus(); + }); + + test('false — shift tabbing does not trap focus when reference is in order', async () => { + render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + await userEvent.tab(); + await userEvent.tab({ shift: true }); + await userEvent.tab({ shift: true }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + test('false - comboboxes do not hide all other nodes', async () => { + function App() { + const [open, setOpen] = React.useState(false); + const { refs, context } = useFloating({ + open, + onOpenChange: setOpen, + }); + + return ( + <> + setOpen(true)} + /> +
+ {isOpen && ( + +
+ + )} + + ); + } + + render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('reference')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByTestId('floating')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('aria-live')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('btn-1')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByTestId('btn-2')).toHaveAttribute('aria-hidden', 'true'); + + fireEvent.click(screen.getByTestId('reference')); + + expect(screen.getByTestId('reference')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('aria-live')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('btn-1')).not.toHaveAttribute('aria-hidden'); + expect(screen.getByTestId('btn-2')).not.toHaveAttribute('aria-hidden'); + }); + + test('false - does not apply inert to outside nodes', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + return ( + <> + setIsOpen((v) => !v)} + /> +
+
+
+ {isOpen && ( + +
+ + )} + + ); + } + + render(); + + fireEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('floating')).not.toHaveAttribute('inert'); + expect(screen.getByTestId('aria-live')).not.toHaveAttribute('inert'); + expect(screen.getByTestId('btn-1')).not.toHaveAttribute('inert'); + expect(screen.getByTestId('btn-2')).not.toHaveAttribute('inert'); + expect(screen.getByTestId('reference')).toHaveAttribute('data-base-ui-inert'); + expect(screen.getByTestId('btn-1')).toHaveAttribute('data-base-ui-inert'); + expect(screen.getByTestId('btn-2')).toHaveAttribute('data-base-ui-inert'); + + fireEvent.click(screen.getByTestId('reference')); + + expect(screen.getByTestId('reference')).not.toHaveAttribute('data-base-ui-inert'); + expect(screen.getByTestId('btn-1')).not.toHaveAttribute('data-base-ui-inert'); + expect(screen.getByTestId('btn-2')).not.toHaveAttribute('data-base-ui-inert'); + }); + }); + + describe('disabled', () => { + test('true -> false', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + const [disabled, setDisabled] = React.useState(true); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + return ( + <> +
+
+ + + {!removed && } + +
+ + )} + + ); + } + + test.skipIf(isJSDOM)( + 'true: restores focus to nearest tabbable element if currently focused element is removed', + async () => { + render(); + + await userEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + const two = screen.getByRole('button', { name: 'two' }); + const three = screen.getByRole('button', { name: 'three' }); + const remove = screen.getByText('remove'); + + expect(two).toHaveFocus(); + + fireEvent.click(remove); + + await waitFor(() => { + expect(three).toHaveFocus(); + }); + }, + ); + + test.skipIf(isJSDOM)( + 'false: does not restore focus to nearest tabbable element if currently focused element is removed', + async () => { + render(); + + await userEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + const two = screen.getByRole('button', { name: 'two' }); + const remove = screen.getByText('remove'); + + expect(two).toHaveFocus(); + + fireEvent.click(remove); + await flushMicrotasks(); + + await waitFor(() => { + expect(document.body).toHaveFocus(); + }); + }, + ); + }); + + test('trapped combobox prevents focus moving outside floating element', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const role = useRole(context); + const dismiss = useDismiss(context); + const click = useClick(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([role, dismiss, click]); + + return ( +
+ + {isOpen && ( + +
+ + +
+
+ )} +
+ ); + } + + render(); + await userEvent.click(screen.getByTestId('input')); + await flushMicrotasks(); + expect(screen.getByTestId('input')).not.toHaveFocus(); + expect(screen.getByRole('button', { name: 'one' })).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'two' })).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'one' })).toHaveFocus(); + await flushMicrotasks(); + }); + + test('untrapped combobox creates non-modal focus management', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const role = useRole(context); + const dismiss = useDismiss(context); + const click = useClick(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([role, dismiss, click]); + + return ( + <> + + {isOpen && ( + + +
+ + +
+
+
+ )} + + + ); + } + + render(); + await userEvent.click(screen.getByTestId('input')); + await flushMicrotasks(); + expect(screen.getByTestId('input')).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'one' })).toHaveFocus(); + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('input')).toHaveFocus(); + }); + + test('returns focus to last connected element', async () => { + function Drawer({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) { + const { refs, context } = useFloating({ open, onOpenChange }); + const dismiss = useDismiss(context); + const { getFloatingProps } = useInteractions([dismiss]); + + return ( + +
+
+
+ ); + } + + function Parent() { + const [isOpen, setIsOpen] = React.useState(false); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const dismiss = useDismiss(context); + const click = useClick(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + return ( + <> +
+
+ )} + {isDrawerOpen && } + + ); + } + + render(); + await userEvent.click(screen.getByTestId('parent-reference')); + await flushMicrotasks(); + expect(screen.getByTestId('parent-floating-reference')).toHaveFocus(); + await userEvent.click(screen.getByTestId('parent-floating-reference')); + await flushMicrotasks(); + expect(screen.getByTestId('child-reference')).toHaveFocus(); + await userEvent.keyboard('{Escape}'); + expect(screen.getByTestId('parent-reference')).toHaveFocus(); + }); + + test('focus is placed on element with floating props when floating element is a wrapper', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const role = useRole(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([role]); + + return ( + <> + +
+ + )} + + + ); + } + + render(); + + await userEvent.hover(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByText('outside')).not.toHaveAttribute('inert'); + expect(screen.getByText('outside')).toHaveAttribute('aria-hidden', 'true'); + }); + + describe('getInsideElements', () => { + test('returns a list of elements that should be considered part of the floating element', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + const click = useClick(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click]); + + return ( + <> + + {isOpen && ( + +
+ floating +
+
+ )} + + ); + } + + render(); + await userEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('floating')).toHaveAttribute('tabindex', '-1'); + }); + + test('handles manual tabindex on dialog floating element', async () => { + function App() { + const [isOpen, setIsOpen] = React.useState(false); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + }); + + return ( + <> + +
+ + + )} + + ); + } + render(); + + await userEvent.click(screen.getByTestId('reference')); + await flushMicrotasks(); + + expect(screen.getByTestId('inner')).toHaveFocus(); + await userEvent.tab({ shift: true }); + expect(screen.getByTestId('reference')).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByTestId('inner')).toHaveFocus(); + }); +}); diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingFocusManager.tsx b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingFocusManager.tsx new file mode 100644 index 00000000..45a7423e --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingFocusManager.tsx @@ -0,0 +1,763 @@ +import * as React from 'react'; +import { tabbable, isTabbable, focusable, type FocusableElement } from 'tabbable'; +import { getNodeName, isHTMLElement } from '@floating-ui/utils/dom'; +import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; +import { useLatestRef } from '@base-ui-components/utils/useLatestRef'; +import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; +import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; +import { visuallyHidden } from '@base-ui-components/utils/visuallyHidden'; +import { useTimeout } from '@base-ui-components/utils/useTimeout'; +import { FocusGuard } from '../../utils/FocusGuard'; +import { + activeElement, + contains, + getDocument, + getTarget, + isTypeableCombobox, + isVirtualClick, + isVirtualPointerEvent, + stopEvent, + getNodeAncestors, + getNodeChildren, + getFloatingFocusElement, + getTabbableOptions, + isOutsideEvent, + getNextTabbable, + getPreviousTabbable, +} from '../utils'; +import type { FloatingRootContext, OpenChangeReason } from '../types'; +import { createAttribute } from '../utils/createAttribute'; +import { enqueueFocus } from '../utils/enqueueFocus'; +import { markOthers } from '../utils/markOthers'; +import { usePortalContext } from './FloatingPortal'; +import { useFloatingTree } from './FloatingTree'; +import { CLICK_TRIGGER_IDENTIFIER } from '../../utils/constants'; + +const LIST_LIMIT = 20; +let previouslyFocusedElements: Element[] = []; + +function clearDisconnectedPreviouslyFocusedElements() { + previouslyFocusedElements = previouslyFocusedElements.filter((el) => el.isConnected); +} + +function addPreviouslyFocusedElement(element: Element | null) { + clearDisconnectedPreviouslyFocusedElements(); + if (element && getNodeName(element) !== 'body') { + previouslyFocusedElements.push(element); + if (previouslyFocusedElements.length > LIST_LIMIT) { + previouslyFocusedElements = previouslyFocusedElements.slice(-LIST_LIMIT); + } + } +} + +function getPreviouslyFocusedElement() { + clearDisconnectedPreviouslyFocusedElements(); + return previouslyFocusedElements[previouslyFocusedElements.length - 1]; +} + +function getFirstTabbableElement(container: Element) { + const tabbableOptions = getTabbableOptions(); + if (isTabbable(container, tabbableOptions)) { + return container; + } + + return tabbable(container, tabbableOptions)[0] || container; +} + +function handleTabIndex( + floatingFocusElement: HTMLElement, + orderRef: React.RefObject>, +) { + if ( + !orderRef.current.includes('floating') && + !floatingFocusElement.getAttribute('role')?.includes('dialog') + ) { + return; + } + + const options = getTabbableOptions(); + const focusableElements = focusable(floatingFocusElement, options); + const tabbableContent = focusableElements.filter((element) => { + const dataTabIndex = element.getAttribute('data-tabindex') || ''; + return ( + isTabbable(element, options) || + (element.hasAttribute('data-tabindex') && !dataTabIndex.startsWith('-')) + ); + }); + const tabIndex = floatingFocusElement.getAttribute('tabindex'); + + if (orderRef.current.includes('floating') || tabbableContent.length === 0) { + if (tabIndex !== '0') { + floatingFocusElement.setAttribute('tabindex', '0'); + } + } else if ( + tabIndex !== '-1' || + (floatingFocusElement.hasAttribute('data-tabindex') && + floatingFocusElement.getAttribute('data-tabindex') !== '-1') + ) { + floatingFocusElement.setAttribute('tabindex', '-1'); + floatingFocusElement.setAttribute('data-tabindex', '-1'); + } +} + +export interface FloatingFocusManagerProps { + children: React.JSX.Element; + /** + * The floating context returned from `useFloatingRootContext`. + */ + context: FloatingRootContext; + /** + * Whether or not the focus manager should be disabled. Useful to delay focus + * management until after a transition completes or some other conditional + * state. + * @default false + */ + disabled?: boolean; + /** + * The order in which focus cycles. + * @default ['content'] + */ + order?: Array<'reference' | 'floating' | 'content'>; + /** + * Which element to initially focus. Can be either a number (tabbable index as + * specified by the `order`) or a ref. + * @default 0 + */ + initialFocus?: number | React.RefObject; + /** + * Determines if focus should be returned to the reference element once the + * floating element closes/unmounts (or if that is not available, the + * previously focused element). This prop is ignored if the floating element + * lost focus. + * It can be also set to a ref to explicitly control the element to return focus to. + * @default true + */ + returnFocus?: boolean | React.RefObject; + /** + * Determines if focus should be restored to the nearest tabbable element if + * focus inside the floating element is lost (such as due to the removal of + * the currently focused element from the DOM). + * @default false + */ + restoreFocus?: boolean; + /** + * Determines if focus is “modal”, meaning focus is fully trapped inside the + * floating element and outside content cannot be accessed. This includes + * screen reader virtual cursors. + * @default true + */ + modal?: boolean; + /** + * Determines whether `focusout` event listeners that control whether the + * floating element should be closed if the focus moves outside of it are + * attached to the reference and floating elements. This affects non-modal + * focus management. + * @default true + */ + closeOnFocusOut?: boolean; + /** + * Returns a list of elements that should be considered part of the + * floating element. + */ + getInsideElements?: () => Element[]; +} + +/** + * Provides focus management for the floating element. + * @see https://floating-ui.com/docs/FloatingFocusManager + * @internal + */ +export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JSX.Element { + const { + context, + children, + disabled = false, + order = ['content'], + initialFocus = 0, + returnFocus = true, + restoreFocus = false, + modal = true, + closeOnFocusOut = true, + getInsideElements: getInsideElementsProp = () => [], + } = props; + const { + open, + onOpenChange, + events, + dataRef, + elements: { domReference, floating }, + } = context; + + const getNodeId = useEventCallback(() => dataRef.current.floatingContext?.nodeId); + const getInsideElements = useEventCallback(getInsideElementsProp); + + const ignoreInitialFocus = typeof initialFocus === 'number' && initialFocus < 0; + // If the reference is a combobox and is typeable (e.g. input/textarea), + // there are different focus semantics. The guards should not be rendered, but + // aria-hidden should be applied to all nodes still. Further, the visually + // hidden dismiss button should only appear at the end of the list, not the + // start. + const isUntrappedTypeableCombobox = isTypeableCombobox(domReference) && ignoreInitialFocus; + + const orderRef = useLatestRef(order); + const initialFocusRef = useLatestRef(initialFocus); + const returnFocusRef = useLatestRef(returnFocus); + + const tree = useFloatingTree(); + const portalContext = usePortalContext(); + + const startDismissButtonRef = React.useRef(null); + const endDismissButtonRef = React.useRef(null); + const preventReturnFocusRef = React.useRef(false); + const isPointerDownRef = React.useRef(false); + const tabbableIndexRef = React.useRef(-1); + + const blurTimeout = useTimeout(); + + const isInsidePortal = portalContext != null; + const floatingFocusElement = getFloatingFocusElement(floating); + + const getTabbableContent = useEventCallback( + (container: Element | null = floatingFocusElement) => { + return container ? tabbable(container, getTabbableOptions()) : []; + }, + ); + + const getTabbableElements = useEventCallback((container?: Element) => { + const content = getTabbableContent(container); + + return orderRef.current + .map(() => content) + .filter(Boolean) + .flat() as Array; + }); + + React.useEffect(() => { + if (disabled) { + return undefined; + } + if (!modal) { + return undefined; + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Tab') { + // The focus guards have nothing to focus, so we need to stop the event. + if ( + contains(floatingFocusElement, activeElement(getDocument(floatingFocusElement))) && + getTabbableContent().length === 0 && + !isUntrappedTypeableCombobox + ) { + stopEvent(event); + } + } + } + + const doc = getDocument(floatingFocusElement); + doc.addEventListener('keydown', onKeyDown); + return () => { + doc.removeEventListener('keydown', onKeyDown); + }; + }, [ + disabled, + domReference, + floatingFocusElement, + modal, + orderRef, + isUntrappedTypeableCombobox, + getTabbableContent, + getTabbableElements, + ]); + + React.useEffect(() => { + if (disabled) { + return undefined; + } + if (!floating) { + return undefined; + } + + function handleFocusIn(event: FocusEvent) { + const target = getTarget(event) as Element | null; + const tabbableContent = getTabbableContent() as Array; + const tabbableIndex = tabbableContent.indexOf(target); + if (tabbableIndex !== -1) { + tabbableIndexRef.current = tabbableIndex; + } + } + + floating.addEventListener('focusin', handleFocusIn); + + return () => { + floating.removeEventListener('focusin', handleFocusIn); + }; + }, [disabled, floating, getTabbableContent]); + + React.useEffect(() => { + if (disabled) { + return undefined; + } + if (!closeOnFocusOut) { + return undefined; + } + + // In Safari, buttons lose focus when pressing them. + function handlePointerDown() { + isPointerDownRef.current = true; + } + + function handleFocusOutside(event: FocusEvent) { + const relatedTarget = event.relatedTarget as HTMLElement | null; + const currentTarget = event.currentTarget; + const target = getTarget(event) as HTMLElement | null; + + queueMicrotask(() => { + const nodeId = getNodeId(); + const movedToUnrelatedNode = !( + contains(domReference, relatedTarget) || + contains(floating, relatedTarget) || + contains(relatedTarget, floating) || + contains(portalContext?.portalNode, relatedTarget) || + relatedTarget?.hasAttribute(createAttribute('focus-guard')) || + (tree && + (getNodeChildren(tree.nodesRef.current, nodeId).find( + (node) => + contains(node.context?.elements.floating, relatedTarget) || + contains(node.context?.elements.domReference, relatedTarget), + ) || + getNodeAncestors(tree.nodesRef.current, nodeId).find( + (node) => + [ + node.context?.elements.floating, + getFloatingFocusElement(node.context?.elements.floating), + ].includes(relatedTarget) || + node.context?.elements.domReference === relatedTarget, + ))) + ); + + if (currentTarget === domReference && floatingFocusElement) { + handleTabIndex(floatingFocusElement, orderRef); + } + + // Restore focus to the previous tabbable element index to prevent + // focus from being lost outside the floating tree. + if ( + restoreFocus && + currentTarget !== domReference && + !target?.isConnected && + activeElement(getDocument(floatingFocusElement)) === + getDocument(floatingFocusElement).body + ) { + // Let `FloatingPortal` effect knows that focus is still inside the + // floating tree. + if (isHTMLElement(floatingFocusElement)) { + floatingFocusElement.focus(); + } + + const prevTabbableIndex = tabbableIndexRef.current; + const tabbableContent = getTabbableContent() as Array; + const nodeToFocus = + tabbableContent[prevTabbableIndex] || + tabbableContent[tabbableContent.length - 1] || + floatingFocusElement; + + if (isHTMLElement(nodeToFocus)) { + nodeToFocus.focus(); + } + } + + // https://github.com/floating-ui/floating-ui/issues/3060 + if (dataRef.current.insideReactTree) { + dataRef.current.insideReactTree = false; + return; + } + + if (isPointerDownRef.current) { + isPointerDownRef.current = false; + return; + } + + // Focus did not move inside the floating tree, and there are no tabbable + // portal guards to handle closing. + if ( + (isUntrappedTypeableCombobox ? true : !modal) && + relatedTarget && + movedToUnrelatedNode && + // Fix React 18 Strict Mode returnFocus due to double rendering. + relatedTarget !== getPreviouslyFocusedElement() + ) { + preventReturnFocusRef.current = true; + onOpenChange(false, event, 'focus-out'); + } + }); + } + + const shouldHandleBlurCapture = Boolean(!tree && portalContext); + + function markInsideReactTree() { + dataRef.current.insideReactTree = true; + blurTimeout.start(0, () => { + dataRef.current.insideReactTree = false; + }); + } + + if (floating && isHTMLElement(domReference)) { + domReference.addEventListener('focusout', handleFocusOutside); + domReference.addEventListener('pointerdown', handlePointerDown); + floating.addEventListener('focusout', handleFocusOutside); + + if (shouldHandleBlurCapture) { + floating.addEventListener('focusout', markInsideReactTree, true); + } + + return () => { + domReference.removeEventListener('focusout', handleFocusOutside); + domReference.removeEventListener('pointerdown', handlePointerDown); + floating.removeEventListener('focusout', handleFocusOutside); + + if (shouldHandleBlurCapture) { + floating.removeEventListener('focusout', markInsideReactTree, true); + } + }; + } + return undefined; + }, [ + disabled, + domReference, + floating, + floatingFocusElement, + modal, + tree, + portalContext, + onOpenChange, + closeOnFocusOut, + restoreFocus, + getTabbableContent, + isUntrappedTypeableCombobox, + getNodeId, + orderRef, + dataRef, + blurTimeout, + ]); + + const beforeGuardRef = React.useRef(null); + const afterGuardRef = React.useRef(null); + + const mergedBeforeGuardRef = useMergedRefs(beforeGuardRef, portalContext?.beforeInsideRef); + const mergedAfterGuardRef = useMergedRefs(afterGuardRef, portalContext?.afterInsideRef); + + React.useEffect(() => { + if (disabled) { + return undefined; + } + if (!floating) { + return undefined; + } + + // Don't hide portals nested within the parent portal. + const portalNodes = Array.from( + portalContext?.portalNode?.querySelectorAll(`[${createAttribute('portal')}]`) || [], + ); + + const ancestors = tree ? getNodeAncestors(tree.nodesRef.current, getNodeId()) : []; + const rootAncestorComboboxDomReference = ancestors.find((node) => + isTypeableCombobox(node.context?.elements.domReference || null), + )?.context?.elements.domReference; + + const insideElements = [ + floating, + rootAncestorComboboxDomReference, + ...portalNodes, + ...getInsideElements(), + startDismissButtonRef.current, + endDismissButtonRef.current, + beforeGuardRef.current, + afterGuardRef.current, + portalContext?.beforeOutsideRef.current, + portalContext?.afterOutsideRef.current, + isUntrappedTypeableCombobox ? domReference : null, + ].filter((x): x is Element => x != null); + + const cleanup = markOthers(insideElements, modal || isUntrappedTypeableCombobox); + + return () => { + cleanup(); + }; + }, [ + disabled, + domReference, + floating, + modal, + orderRef, + portalContext, + isUntrappedTypeableCombobox, + tree, + getNodeId, + getInsideElements, + ]); + + useIsoLayoutEffect(() => { + if (disabled || !isHTMLElement(floatingFocusElement)) { + return; + } + + const doc = getDocument(floatingFocusElement); + const previouslyFocusedElement = activeElement(doc); + + // Wait for any layout effect state setters to execute to set `tabIndex`. + queueMicrotask(() => { + const focusableElements = getTabbableElements(floatingFocusElement); + const initialFocusValue = initialFocusRef.current; + const elToFocus = + (typeof initialFocusValue === 'number' + ? focusableElements[initialFocusValue] + : initialFocusValue.current) || floatingFocusElement; + const focusAlreadyInsideFloatingEl = contains(floatingFocusElement, previouslyFocusedElement); + + if (!ignoreInitialFocus && !focusAlreadyInsideFloatingEl && open) { + enqueueFocus(elToFocus, { + preventScroll: elToFocus === floatingFocusElement, + }); + } + }); + }, [ + disabled, + open, + floatingFocusElement, + ignoreInitialFocus, + getTabbableElements, + initialFocusRef, + ]); + + useIsoLayoutEffect(() => { + if (disabled || !floatingFocusElement) { + return undefined; + } + + const doc = getDocument(floatingFocusElement); + const previouslyFocusedElement = activeElement(doc); + + addPreviouslyFocusedElement(previouslyFocusedElement); + + // Dismissing via outside press should always ignore `returnFocus` to + // prevent unwanted scrolling. + function onOpenChangeLocal({ + reason, + event, + nested, + }: { + open: boolean; + reason: OpenChangeReason; + event: Event; + nested: boolean; + }) { + if (['hover', 'safe-polygon'].includes(reason) && event.type === 'mouseleave') { + preventReturnFocusRef.current = true; + } + + if (reason !== 'outside-press') { + return; + } + + if (nested) { + preventReturnFocusRef.current = false; + } else if ( + isVirtualClick(event as MouseEvent) || + isVirtualPointerEvent(event as PointerEvent) + ) { + preventReturnFocusRef.current = false; + } else { + let isPreventScrollSupported = false; + document.createElement('div').focus({ + get preventScroll() { + isPreventScrollSupported = true; + return false; + }, + }); + + if (isPreventScrollSupported) { + preventReturnFocusRef.current = false; + } else { + preventReturnFocusRef.current = true; + } + } + } + + events.on('openchange', onOpenChangeLocal); + + const fallbackEl = doc.createElement('span'); + fallbackEl.setAttribute('tabindex', '-1'); + fallbackEl.setAttribute('aria-hidden', 'true'); + Object.assign(fallbackEl.style, visuallyHidden); + + if (isInsidePortal && domReference) { + domReference.insertAdjacentElement('afterend', fallbackEl); + } + + function getReturnElement() { + if (typeof returnFocusRef.current === 'boolean') { + const el = domReference || getPreviouslyFocusedElement(); + return el && el.isConnected ? el : fallbackEl; + } + + return returnFocusRef.current.current || fallbackEl; + } + + return () => { + events.off('openchange', onOpenChangeLocal); + + const activeEl = activeElement(doc); + const isFocusInsideFloatingTree = + contains(floating, activeEl) || + (tree && + getNodeChildren(tree.nodesRef.current, getNodeId(), false).some((node) => + contains(node.context?.elements.floating, activeEl), + )); + + const returnElement = getReturnElement(); + + queueMicrotask(() => { + // This is `returnElement`, if it's tabbable, or its first tabbable child. + const tabbableReturnElement = getFirstTabbableElement(returnElement); + if ( + // eslint-disable-next-line react-hooks/exhaustive-deps + returnFocusRef.current && + !preventReturnFocusRef.current && + isHTMLElement(tabbableReturnElement) && + // If the focus moved somewhere else after mount, avoid returning focus + // since it likely entered a different element which should be + // respected: https://github.com/floating-ui/floating-ui/issues/2607 + (tabbableReturnElement !== activeEl && activeEl !== doc.body + ? isFocusInsideFloatingTree + : true) + ) { + tabbableReturnElement.focus({ preventScroll: true }); + } + + fallbackEl.remove(); + }); + }; + }, [ + disabled, + floating, + floatingFocusElement, + returnFocusRef, + dataRef, + events, + tree, + isInsidePortal, + domReference, + getNodeId, + ]); + + React.useEffect(() => { + // The `returnFocus` cleanup behavior is inside a microtask; ensure we + // wait for it to complete before resetting the flag. + queueMicrotask(() => { + preventReturnFocusRef.current = false; + }); + }, [disabled]); + + React.useEffect(() => { + if (disabled || !open) { + return undefined; + } + + function handlePointerDown(event: MouseEvent) { + const target = getTarget(event) as Element | null; + if (target?.closest(`[${CLICK_TRIGGER_IDENTIFIER}]`)) { + isPointerDownRef.current = true; + } + } + + const doc = getDocument(floatingFocusElement); + doc.addEventListener('pointerdown', handlePointerDown, true); + return () => { + doc.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [disabled, open, floatingFocusElement]); + + // Synchronize the `context` & `modal` value to the FloatingPortal context. + // It will decide whether or not it needs to render its own guards. + useIsoLayoutEffect(() => { + if (disabled) { + return undefined; + } + if (!portalContext) { + return undefined; + } + + portalContext.setFocusManagerState({ + modal, + closeOnFocusOut, + open, + onOpenChange, + domReference, + }); + + return () => { + portalContext.setFocusManagerState(null); + }; + }, [disabled, portalContext, modal, open, onOpenChange, closeOnFocusOut, domReference]); + + useIsoLayoutEffect(() => { + if (disabled || !floatingFocusElement) { + return undefined; + } + handleTabIndex(floatingFocusElement, orderRef); + return () => { + queueMicrotask(clearDisconnectedPreviouslyFocusedElements); + }; + }, [disabled, floatingFocusElement, orderRef]); + + const shouldRenderGuards = + !disabled && (modal ? !isUntrappedTypeableCombobox : true) && (isInsidePortal || modal); + + return ( + + {shouldRenderGuards && ( + { + if (modal) { + const els = getTabbableElements(); + enqueueFocus(els[els.length - 1]); + } else if (portalContext?.preserveTabOrder && portalContext.portalNode) { + preventReturnFocusRef.current = false; + if (isOutsideEvent(event, portalContext.portalNode)) { + const nextTabbable = getNextTabbable(domReference); + nextTabbable?.focus(); + } else { + portalContext.beforeOutsideRef.current?.focus(); + } + } + }} + /> + )} + {children} + {shouldRenderGuards && ( + { + if (modal) { + enqueueFocus(getTabbableElements()[0]); + } else if (portalContext?.preserveTabOrder && portalContext.portalNode) { + if (closeOnFocusOut) { + preventReturnFocusRef.current = true; + } + + if (isOutsideEvent(event, portalContext.portalNode)) { + const prevTabbable = getPreviousTabbable(domReference); + prevTabbable?.focus(); + } else { + portalContext.afterOutsideRef.current?.focus(); + } + } + }} + /> + )} + + ); +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingPortal.test.tsx b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingPortal.test.tsx new file mode 100644 index 00000000..a4ee6c68 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingPortal.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils'; +import * as React from 'react'; + +import { isJSDOM } from '@base-ui-components/utils/detectBrowser'; +import { FloatingPortal, useFloating } from '../index'; + +function App(props: { + root?: HTMLElement | null | React.RefObject; + id?: string; +}) { + const [open, setOpen] = React.useState(false); + const { refs } = useFloating({ + open, + onOpenChange: setOpen, + }); + + return ( + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + + test('false', async () => { + render( + + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + + test('mixed', async () => { + render( + + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + + fireEvent.pointerDown(document.body); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + }); + + describe('escapeKey', () => { + test('without FloatingTree', async () => { + function App() { + const [popoverOpen, setPopoverOpen] = React.useState(true); + const [tooltipOpen, setTooltipOpen] = React.useState(false); + + const popover = useFloating({ + open: popoverOpen, + onOpenChange: setPopoverOpen, + }); + const tooltip = useFloating({ + open: tooltipOpen, + onOpenChange: setTooltipOpen, + }); + + const popoverInteractions = useInteractions([useDismiss(popover.context)]); + const tooltipInteractions = useInteractions([ + useFocus(tooltip.context, { visibleOnly: false }), + useDismiss(tooltip.context), + ]); + + return ( + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + test('false', async () => { + render( + + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + + test('mixed', async () => { + render( + + + + + , + ); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(screen.getByTestId('outer')).toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + + expect(screen.queryByTestId('outer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('inner')).not.toBeInTheDocument(); + cleanup(); + }); + }); + }); + + describe('capture', () => { + describe('prop resolution', () => { + test('undefined', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp(); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(true); + }); + + test('{}', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp( + {}, + ); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(true); + }); + + test('true', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp(true); + + expect(escapeKeyCapture).toBe(true); + expect(outsidePressCapture).toBe(true); + }); + + test('false', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = + normalizeProp(false); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(false); + }); + + test('{ escapeKey: true }', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp({ + escapeKey: true, + }); + + expect(escapeKeyCapture).toBe(true); + expect(outsidePressCapture).toBe(true); + }); + + test('{ outsidePress: false }', () => { + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp({ + outsidePress: false, + }); + + expect(escapeKeyCapture).toBe(false); + expect(outsidePressCapture).toBe(false); + }); + }); + + function Overlay({ children }: { children: React.ReactNode }) { + return ( +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + } + }} + > + outside + {children} +
+ ); + } + + function Dialog({ + id, + children, + ...props + }: UseDismissProps & { id: string; children: React.ReactNode }) { + const [open, setOpen] = React.useState(true); + const nodeId = useFloatingNodeId(); + + const { refs, context } = useFloating({ + open, + onOpenChange: setOpen, + nodeId, + }); + + const { getReferenceProps, getFloatingProps } = useInteractions([useDismiss(context, props)]); + + return ( + + + {open && ( + + +
+ {children} +
+
+
+ )} +
+ ); + } + + function App() { + const [otherContainer, setOtherContainer] = React.useState(); + + const portal1 = undefined; + const portal2 = otherContainer; + + return ( + + + + + + +
+ + ); + } + + render(); + + await userEvent.click(screen.getByText('open 1')); + expect(screen.getByText('open 2')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('open 2')); + await flushMicrotasks(); + + expect(screen.getByText('open 1')).toBeInTheDocument(); + expect(screen.getByText('open 2')).toBeInTheDocument(); + expect(screen.getByText('nested')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useDismiss.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useDismiss.ts new file mode 100644 index 00000000..969144e5 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useDismiss.ts @@ -0,0 +1,658 @@ +import * as React from 'react'; +import { getOverflowAncestors } from '@floating-ui/react-dom'; +import { + getComputedStyle, + getParentNode, + isElement, + isHTMLElement, + isLastTraversableNode, + isWebKit, +} from '@floating-ui/utils/dom'; +import { Timeout, useTimeout } from '@base-ui-components/utils/useTimeout'; +import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; +import { + contains, + getDocument, + getTarget, + isEventTargetWithin, + isReactEvent, + isRootElement, + getNodeChildren, +} from '../utils'; + +/* eslint-disable no-underscore-dangle */ + +import { useFloatingTree } from '../components/FloatingTree'; +import type { ElementProps, FloatingRootContext } from '../types'; +import { createAttribute } from '../utils/createAttribute'; + +type PressType = 'intentional' | 'sloppy'; + +const bubbleHandlerKeys = { + intentional: 'onClick', + sloppy: 'onPointerDown', +} as const; + +export function normalizeProp( + normalizable?: boolean | { escapeKey?: boolean; outsidePress?: boolean }, +) { + return { + escapeKey: + typeof normalizable === 'boolean' ? normalizable : (normalizable?.escapeKey ?? false), + outsidePress: + typeof normalizable === 'boolean' ? normalizable : (normalizable?.outsidePress ?? true), + }; +} + +export interface UseDismissProps { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean; + /** + * Whether to dismiss the floating element upon pressing the `esc` key. + * @default true + */ + escapeKey?: boolean; + /** + * Whether to dismiss the floating element upon pressing the reference + * element. You likely want to ensure the `move` option in the `useHover()` + * Hook has been disabled when this is in use. + * @default false + */ + referencePress?: boolean; + /** + * The type of event to use to determine a "press". + * - `down` is `pointerdown` on mouse input, but special iOS-like touch handling on touch input. + * - `up` is lazy on both mouse + touch input (equivalent to `click`). + * @default 'down' + */ + referencePressEvent?: PressType; + /** + * Whether to dismiss the floating element upon pressing outside of the + * floating element. + * If you have another element, like a toast, that is rendered outside the + * floating element's React tree and don't want the floating element to close + * when pressing it, you can guard the check like so: + * ```jsx + * useDismiss(context, { + * outsidePress: (event) => !event.target.closest('.toast'), + * }); + * ``` + * @default true + */ + outsidePress?: boolean | ((event: MouseEvent) => boolean); + /** + * The type of event to use to determine an outside "press". + * - `intentional` requires the user to click outside intentionally, firing on `pointerup` for mouse, and requiring minimal `touchmove`s for touch. + * - `sloppy` fires on `pointerdown` for mouse, while for touch it fires on `touchend` (within 1 second) or while scrolling away after `touchstart`. + */ + outsidePressEvent?: + | PressType + | { + mouse: PressType; + touch: PressType; + }; + /** + * Whether to dismiss the floating element upon scrolling an overflow + * ancestor. + * @default false + */ + ancestorScroll?: boolean; + /** + * Determines whether event listeners bubble upwards through a tree of + * floating elements. + */ + bubbles?: boolean | { escapeKey?: boolean; outsidePress?: boolean }; + /** + * Determines whether to use capture phase event listeners. + */ + capture?: boolean | { escapeKey?: boolean; outsidePress?: boolean }; +} + +/** + * Closes the floating element when a dismissal is requested — by default, when + * the user presses the `escape` key or outside of the floating element. + * @see https://floating-ui.com/docs/useDismiss + */ +export function useDismiss( + context: FloatingRootContext, + props: UseDismissProps = {}, +): ElementProps { + const { open, onOpenChange, elements, dataRef } = context; + const { + enabled = true, + escapeKey = true, + outsidePress: outsidePressProp = true, + outsidePressEvent = 'sloppy', + referencePress = false, + referencePressEvent = 'sloppy', + ancestorScroll = false, + bubbles, + capture, + } = props; + + const tree = useFloatingTree(); + const outsidePressFn = useEventCallback( + typeof outsidePressProp === 'function' ? outsidePressProp : () => false, + ); + const outsidePress = typeof outsidePressProp === 'function' ? outsidePressFn : outsidePressProp; + + const endedOrStartedInsideRef = React.useRef(false); + const { escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles } = normalizeProp(bubbles); + const { escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture } = normalizeProp(capture); + + const touchStateRef = React.useRef<{ + startTime: number; + startX: number; + startY: number; + dismissOnPointerUp: boolean; + dismissOnMouseDown: boolean; + } | null>(null); + const cancelDismissOnEndTimeout = useTimeout(); + const insideReactTreeTimeout = useTimeout(); + + const isComposingRef = React.useRef(false); + const currentPointerTypeRef = React.useRef(''); + + const trackPointerType = useEventCallback((event: PointerEvent) => { + currentPointerTypeRef.current = event.pointerType; + }); + + const getOutsidePressEvent = useEventCallback(() => { + const type = currentPointerTypeRef.current as 'pen' | 'mouse' | 'touch' | ''; + const computedType = type === 'pen' || !type ? 'mouse' : type; + + if (typeof outsidePressEvent === 'string') { + return outsidePressEvent; + } + + return outsidePressEvent[computedType]; + }); + + const closeOnEscapeKeyDown = useEventCallback( + (event: React.KeyboardEvent | KeyboardEvent) => { + if (!open || !enabled || !escapeKey || event.key !== 'Escape') { + return; + } + + // Wait until IME is settled. Pressing `Escape` while composing should + // close the compose menu, but not the floating element. + if (isComposingRef.current) { + return; + } + + const nodeId = dataRef.current.floatingContext?.nodeId; + + const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; + + if (!escapeKeyBubbles) { + event.stopPropagation(); + + if (children.length > 0) { + let shouldDismiss = true; + + children.forEach((child) => { + if (child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles) { + shouldDismiss = false; + } + }); + + if (!shouldDismiss) { + return; + } + } + } + + onOpenChange(false, isReactEvent(event) ? event.nativeEvent : event, 'escape-key'); + }, + ); + + const shouldIgnoreEvent = useEventCallback((event: Event) => { + const computedOutsidePressEvent = getOutsidePressEvent(); + return ( + (computedOutsidePressEvent === 'intentional' && event.type !== 'click') || + (computedOutsidePressEvent === 'sloppy' && event.type === 'click') + ); + }); + + const closeOnEscapeKeyDownCapture = useEventCallback((event: KeyboardEvent) => { + const callback = () => { + closeOnEscapeKeyDown(event); + getTarget(event)?.removeEventListener('keydown', callback); + }; + getTarget(event)?.addEventListener('keydown', callback); + }); + + const closeOnPressOutside = useEventCallback((event: MouseEvent) => { + if (shouldIgnoreEvent(event)) { + return; + } + + // Given developers can stop the propagation of the synthetic event, + // we can only be confident with a positive value. + const insideReactTree = dataRef.current.insideReactTree; + dataRef.current.insideReactTree = false; + + // When click outside is lazy (`up` event), handle dragging. + // Don't close if: + // - The click started inside the floating element. + // - The click ended inside the floating element. + const endedOrStartedInside = endedOrStartedInsideRef.current; + endedOrStartedInsideRef.current = false; + + if (getOutsidePressEvent() === 'intentional' && endedOrStartedInside) { + return; + } + + if (insideReactTree) { + return; + } + + if (typeof outsidePress === 'function' && !outsidePress(event)) { + return; + } + + const target = getTarget(event); + const inertSelector = `[${createAttribute('inert')}]`; + const markers = getDocument(elements.floating).querySelectorAll(inertSelector); + + let targetRootAncestor = isElement(target) ? target : null; + while (targetRootAncestor && !isLastTraversableNode(targetRootAncestor)) { + const nextParent = getParentNode(targetRootAncestor); + if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { + break; + } + + targetRootAncestor = nextParent; + } + + // Check if the click occurred on a third-party element injected after the + // floating element rendered. + if ( + markers.length && + isElement(target) && + !isRootElement(target) && + // Clicked on a direct ancestor (e.g. FloatingOverlay). + !contains(target, elements.floating) && + // If the target root element contains none of the markers, then the + // element was injected after the floating element rendered. + Array.from(markers).every((marker) => !contains(targetRootAncestor, marker)) + ) { + return; + } + + // Check if the click occurred on the scrollbar + if (isHTMLElement(target)) { + const lastTraversableNode = isLastTraversableNode(target); + const style = getComputedStyle(target); + const scrollRe = /auto|scroll/; + const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX); + const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY); + + const canScrollX = + isScrollableX && target.clientWidth > 0 && target.scrollWidth > target.clientWidth; + const canScrollY = + isScrollableY && target.clientHeight > 0 && target.scrollHeight > target.clientHeight; + + const isRTL = style.direction === 'rtl'; + + // Check click position relative to scrollbar. + // In some browsers it is possible to change the (or window) + // scrollbar to the left side, but is very rare and is difficult to + // check for. Plus, for modal dialogs with backdrops, it is more + // important that the backdrop is checked but not so much the window. + const pressedVerticalScrollbar = + canScrollY && + (isRTL + ? event.offsetX <= target.offsetWidth - target.clientWidth + : event.offsetX > target.clientWidth); + + const pressedHorizontalScrollbar = canScrollX && event.offsetY > target.clientHeight; + + if (pressedVerticalScrollbar || pressedHorizontalScrollbar) { + return; + } + } + + const nodeId = dataRef.current.floatingContext?.nodeId; + + const targetIsInsideChildren = + tree && + getNodeChildren(tree.nodesRef.current, nodeId).some((node) => + isEventTargetWithin(event, node.context?.elements.floating), + ); + + if ( + isEventTargetWithin(event, elements.floating) || + isEventTargetWithin(event, elements.domReference) || + targetIsInsideChildren + ) { + return; + } + + const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; + if (children.length > 0) { + let shouldDismiss = true; + + children.forEach((child) => { + if (child.context?.open && !child.context.dataRef.current.__outsidePressBubbles) { + shouldDismiss = false; + } + }); + + if (!shouldDismiss) { + return; + } + } + + onOpenChange(false, event, 'outside-press'); + }); + + const handlePointerDown = useEventCallback((event: PointerEvent) => { + if ( + getOutsidePressEvent() !== 'sloppy' || + !open || + !enabled || + isEventTargetWithin(event, elements.floating) || + isEventTargetWithin(event, elements.domReference) + ) { + return; + } + + if (event.pointerType === 'touch') { + touchStateRef.current = { + startTime: Date.now(), + startX: event.clientX, + startY: event.clientY, + dismissOnPointerUp: false, + dismissOnMouseDown: true, + }; + + cancelDismissOnEndTimeout.start(1000, () => { + if (touchStateRef.current) { + touchStateRef.current.dismissOnPointerUp = false; + touchStateRef.current.dismissOnMouseDown = false; + } + }); + return; + } + + closeOnPressOutside(event); + }); + + const closeOnPressOutsideCapture = useEventCallback((event: PointerEvent | MouseEvent) => { + if (shouldIgnoreEvent(event)) { + return; + } + + cancelDismissOnEndTimeout.clear(); + + if ( + event.type === 'mousedown' && + touchStateRef.current && + !touchStateRef.current.dismissOnMouseDown + ) { + return; + } + + const callback = () => { + if (event.type === 'pointerdown') { + handlePointerDown(event as PointerEvent); + } else { + closeOnPressOutside(event as MouseEvent); + } + getTarget(event)?.removeEventListener(event.type, callback); + }; + getTarget(event)?.addEventListener(event.type, callback); + }); + + const handlePointerMove = useEventCallback((event: PointerEvent) => { + if ( + getOutsidePressEvent() !== 'sloppy' || + event.pointerType !== 'touch' || + !touchStateRef.current || + isEventTargetWithin(event, elements.floating) || + isEventTargetWithin(event, elements.domReference) + ) { + return; + } + + const deltaX = Math.abs(event.clientX - touchStateRef.current.startX); + const deltaY = Math.abs(event.clientY - touchStateRef.current.startY); + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > 5) { + touchStateRef.current.dismissOnPointerUp = true; + } + + if (distance > 10) { + closeOnPressOutside(event); + cancelDismissOnEndTimeout.clear(); + touchStateRef.current = null; + } + }); + + const handlePointerUp = useEventCallback((event: PointerEvent) => { + if ( + getOutsidePressEvent() !== 'sloppy' || + event.pointerType !== 'touch' || + !touchStateRef.current || + isEventTargetWithin(event, elements.floating) || + isEventTargetWithin(event, elements.domReference) + ) { + return; + } + + if (touchStateRef.current.dismissOnPointerUp) { + closeOnPressOutside(event); + } + + cancelDismissOnEndTimeout.clear(); + touchStateRef.current = null; + }); + + React.useEffect(() => { + if (!open || !enabled) { + return undefined; + } + + dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; + dataRef.current.__outsidePressBubbles = outsidePressBubbles; + + const compositionTimeout = new Timeout(); + + function onScroll(event: Event) { + onOpenChange(false, event, 'ancestor-scroll'); + } + + function handleCompositionStart() { + compositionTimeout.clear(); + isComposingRef.current = true; + } + + function handleCompositionEnd() { + // Safari fires `compositionend` before `keydown`, so we need to wait + // until the next tick to set `isComposing` to `false`. + // https://bugs.webkit.org/show_bug.cgi?id=165004 + compositionTimeout.start( + // 0ms or 1ms don't work in Safari. 5ms appears to consistently work. + // Only apply to WebKit for the test to remain 0ms. + isWebKit() ? 5 : 0, + () => { + isComposingRef.current = false; + }, + ); + } + + const doc = getDocument(elements.floating); + + doc.addEventListener('pointerdown', trackPointerType, true); + + if (escapeKey) { + doc.addEventListener( + 'keydown', + escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown, + escapeKeyCapture, + ); + doc.addEventListener('compositionstart', handleCompositionStart); + doc.addEventListener('compositionend', handleCompositionEnd); + } + + if (outsidePress) { + doc.addEventListener( + 'click', + outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside, + outsidePressCapture, + ); + doc.addEventListener( + 'pointerdown', + outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside, + outsidePressCapture, + ); + doc.addEventListener('pointermove', handlePointerMove, outsidePressCapture); + doc.addEventListener('pointerup', handlePointerUp, outsidePressCapture); + doc.addEventListener('mousedown', closeOnPressOutsideCapture, outsidePressCapture); + } + + let ancestors: (Element | Window | VisualViewport)[] = []; + + if (ancestorScroll) { + if (isElement(elements.domReference)) { + ancestors = getOverflowAncestors(elements.domReference); + } + + if (isElement(elements.floating)) { + ancestors = ancestors.concat(getOverflowAncestors(elements.floating)); + } + + if ( + !isElement(elements.reference) && + elements.reference && + elements.reference.contextElement + ) { + ancestors = ancestors.concat(getOverflowAncestors(elements.reference.contextElement)); + } + } + + // Ignore the visual viewport for scrolling dismissal (allow pinch-zoom) + ancestors = ancestors.filter((ancestor) => ancestor !== doc.defaultView?.visualViewport); + + ancestors.forEach((ancestor) => { + ancestor.addEventListener('scroll', onScroll, { passive: true }); + }); + + return () => { + doc.removeEventListener('pointerdown', trackPointerType, true); + + if (escapeKey) { + doc.removeEventListener( + 'keydown', + escapeKeyCapture ? closeOnEscapeKeyDownCapture : closeOnEscapeKeyDown, + escapeKeyCapture, + ); + doc.removeEventListener('compositionstart', handleCompositionStart); + doc.removeEventListener('compositionend', handleCompositionEnd); + } + + if (outsidePress) { + doc.removeEventListener( + 'click', + outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside, + outsidePressCapture, + ); + doc.removeEventListener( + 'pointerdown', + outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside, + outsidePressCapture, + ); + doc.removeEventListener('pointermove', handlePointerMove, outsidePressCapture); + doc.removeEventListener('pointerup', handlePointerUp, outsidePressCapture); + doc.removeEventListener('mousedown', closeOnPressOutsideCapture, outsidePressCapture); + } + + ancestors.forEach((ancestor) => { + ancestor.removeEventListener('scroll', onScroll); + }); + + compositionTimeout.clear(); + }; + }, [ + dataRef, + elements, + escapeKey, + outsidePress, + outsidePressEvent, + open, + onOpenChange, + ancestorScroll, + enabled, + escapeKeyBubbles, + outsidePressBubbles, + closeOnEscapeKeyDown, + escapeKeyCapture, + closeOnEscapeKeyDownCapture, + closeOnPressOutside, + outsidePressCapture, + closeOnPressOutsideCapture, + handlePointerDown, + handlePointerMove, + handlePointerUp, + trackPointerType, + ]); + + React.useEffect(() => { + dataRef.current.insideReactTree = false; + }, [dataRef, outsidePress]); + + const reference: ElementProps['reference'] = React.useMemo( + () => ({ + onKeyDown: closeOnEscapeKeyDown, + ...(referencePress && { + [bubbleHandlerKeys[referencePressEvent]]: (event: React.SyntheticEvent) => { + onOpenChange(false, event.nativeEvent, 'reference-press'); + }, + ...(referencePressEvent !== 'intentional' && { + onClick(event) { + onOpenChange(false, event.nativeEvent, 'reference-press'); + }, + }), + }), + }), + [closeOnEscapeKeyDown, onOpenChange, referencePress, referencePressEvent], + ); + + const handlePressedInside = useEventCallback((event: React.MouseEvent) => { + const target = getTarget(event.nativeEvent) as Element | null; + if (!contains(elements.floating, target)) { + return; + } + endedOrStartedInsideRef.current = true; + }); + + const handleCaptureInside = useEventCallback(() => { + dataRef.current.insideReactTree = true; + insideReactTreeTimeout.start(0, () => { + dataRef.current.insideReactTree = false; + }); + }); + + const floating: ElementProps['floating'] = React.useMemo( + () => ({ + onKeyDown: closeOnEscapeKeyDown, + onMouseDown: handlePressedInside, + onMouseUp: handlePressedInside, + onPointerDownCapture: handleCaptureInside, + onMouseDownCapture: handleCaptureInside, + onClickCapture: handleCaptureInside, + }), + [closeOnEscapeKeyDown, handlePressedInside, handleCaptureInside], + ); + + return React.useMemo( + () => (enabled ? { reference, floating } : {}), + [enabled, reference, floating], + ); +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloating.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloating.ts new file mode 100644 index 00000000..7acd36f7 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloating.ts @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { useFloating as usePosition, type VirtualElement } from '@floating-ui/react-dom'; +import { isElement } from '@floating-ui/utils/dom'; +import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; + +import { useFloatingTree } from '../components/FloatingTree'; +import type { + FloatingContext, + NarrowedElement, + ReferenceType, + UseFloatingOptions, + UseFloatingReturn, +} from '../types'; +import { useFloatingRootContext } from './useFloatingRootContext'; + +/** + * Provides data to position a floating element and context to add interactions. + * @see https://floating-ui.com/docs/useFloating + */ +export function useFloating( + options: UseFloatingOptions = {}, +): UseFloatingReturn { + const { nodeId } = options; + + const internalRootContext = useFloatingRootContext({ + ...options, + elements: { + reference: null, + floating: null, + ...options.elements, + }, + }); + + const rootContext = options.rootContext || internalRootContext; + const computedElements = rootContext.elements; + + const [domReferenceState, setDomReference] = React.useState | null>(null); + const [positionReference, setPositionReferenceRaw] = React.useState(null); + + const optionDomReference = computedElements?.domReference; + const domReference = (optionDomReference || domReferenceState) as NarrowedElement; + const domReferenceRef = React.useRef | null>(null); + + const tree = useFloatingTree(); + + useIsoLayoutEffect(() => { + if (domReference) { + domReferenceRef.current = domReference; + } + }, [domReference]); + + const position = usePosition({ + ...options, + elements: { + ...computedElements, + ...(positionReference && { reference: positionReference }), + }, + }); + + const setPositionReference = React.useCallback( + (node: ReferenceType | null) => { + const computedPositionReference = isElement(node) + ? ({ + getBoundingClientRect: () => node.getBoundingClientRect(), + getClientRects: () => node.getClientRects(), + contextElement: node, + } satisfies VirtualElement) + : node; + // Store the positionReference in state if the DOM reference is specified externally via the + // `elements.reference` option. This ensures that it won't be overridden on future renders. + setPositionReferenceRaw(computedPositionReference); + position.refs.setReference(computedPositionReference); + }, + [position.refs], + ); + + const setReference = React.useCallback( + (node: RT | null) => { + if (isElement(node) || node === null) { + (domReferenceRef as React.MutableRefObject).current = node; + setDomReference(node as NarrowedElement | null); + } + + // Backwards-compatibility for passing a virtual element to `reference` + // after it has set the DOM reference. + if ( + isElement(position.refs.reference.current) || + position.refs.reference.current === null || + // Don't allow setting virtual elements using the old technique back to + // `null` to support `positionReference` + an unstable `reference` + // callback ref. + (node !== null && !isElement(node)) + ) { + position.refs.setReference(node); + } + }, + [position.refs], + ); + + const refs = React.useMemo( + () => ({ + ...position.refs, + setReference, + setPositionReference, + domReference: domReferenceRef, + }), + [position.refs, setReference, setPositionReference], + ); + + const elements = React.useMemo( + () => ({ + ...position.elements, + domReference, + }), + [position.elements, domReference], + ); + + const context = React.useMemo>( + () => ({ + ...position, + ...rootContext, + refs, + elements, + nodeId, + }), + [position, refs, elements, nodeId, rootContext], + ); + + useIsoLayoutEffect(() => { + rootContext.dataRef.current.floatingContext = context; + + const node = tree?.nodesRef.current.find((n) => n.id === nodeId); + if (node) { + node.context = context; + } + }); + + return React.useMemo( + () => ({ + ...position, + context, + refs, + elements, + }), + [position, refs, elements, context], + ) as UseFloatingReturn; +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloatingRootContext.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloatingRootContext.ts new file mode 100644 index 00000000..3e957745 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFloatingRootContext.ts @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { isElement } from '@floating-ui/utils/dom'; +import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; +import { useId } from '@base-ui-components/utils/useId'; + +import type { + FloatingRootContext, + ReferenceElement, + ContextData, + OpenChangeReason, +} from '../types'; +import { createEventEmitter } from '../utils/createEventEmitter'; +import { useFloatingParentNodeId } from '../components/FloatingTree'; + +export interface UseFloatingRootContextOptions { + open?: boolean; + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; + elements: { + reference: Element | null; + floating: HTMLElement | null; + }; +} + +export function useFloatingRootContext( + options: UseFloatingRootContextOptions, +): FloatingRootContext { + const { open = false, onOpenChange: onOpenChangeProp, elements: elementsProp } = options; + + const floatingId = useId(); + const dataRef = React.useRef({}); + const [events] = React.useState(() => createEventEmitter()); + const nested = useFloatingParentNodeId() != null; + + if (process.env.NODE_ENV !== 'production') { + const optionDomReference = elementsProp.reference; + if (optionDomReference && !isElement(optionDomReference)) { + console.error( + 'Cannot pass a virtual element to the `elements.reference` option,', + 'as it must be a real DOM element. Use `refs.setPositionReference()`', + 'instead.', + ); + } + } + + const [positionReference, setPositionReference] = React.useState( + elementsProp.reference, + ); + + const onOpenChange = useEventCallback( + (newOpen: boolean, event?: Event, reason?: OpenChangeReason) => { + dataRef.current.openEvent = newOpen ? event : undefined; + events.emit('openchange', { open: newOpen, event, reason, nested }); + onOpenChangeProp?.(newOpen, event, reason); + }, + ); + + const refs = React.useMemo( + () => ({ + setPositionReference, + }), + [], + ); + + const elements = React.useMemo( + () => ({ + reference: positionReference || elementsProp.reference || null, + floating: elementsProp.floating || null, + domReference: elementsProp.reference as Element | null, + }), + [positionReference, elementsProp.reference, elementsProp.floating], + ); + + return React.useMemo( + () => ({ + dataRef, + open, + onOpenChange, + elements, + events, + floatingId, + refs, + }), + [open, onOpenChange, elements, events, floatingId, refs], + ); +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFocus.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFocus.ts new file mode 100644 index 00000000..2a529971 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useFocus.ts @@ -0,0 +1,181 @@ +import * as React from 'react'; +import { getWindow, isElement, isHTMLElement } from '@floating-ui/utils/dom'; +import { isMac, isSafari } from '@base-ui-components/utils/detectBrowser'; +import { useTimeout } from '@base-ui-components/utils/useTimeout'; +import { + activeElement, + contains, + getDocument, + getTarget, + isTypeableElement, + matchesFocusVisible, +} from '../utils'; + +import type { ElementProps, FloatingRootContext, OpenChangeReason } from '../types'; +import { createAttribute } from '../utils/createAttribute'; + +const isMacSafari = isMac && isSafari; + +export interface UseFocusProps { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean; + /** + * Whether the open state only changes if the focus event is considered + * visible (`:focus-visible` CSS selector). + * @default true + */ + visibleOnly?: boolean; +} + +/** + * Opens the floating element while the reference element has focus, like CSS + * `:focus`. + * @see https://floating-ui.com/docs/useFocus + */ +export function useFocus(context: FloatingRootContext, props: UseFocusProps = {}): ElementProps { + const { open, onOpenChange, events, dataRef, elements } = context; + const { enabled = true, visibleOnly = true } = props; + + const blockFocusRef = React.useRef(false); + const timeout = useTimeout(); + const keyboardModalityRef = React.useRef(true); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + const win = getWindow(elements.domReference); + + // If the reference was focused and the user left the tab/window, and the + // floating element was not open, the focus should be blocked when they + // return to the tab/window. + function onBlur() { + if ( + !open && + isHTMLElement(elements.domReference) && + elements.domReference === activeElement(getDocument(elements.domReference)) + ) { + blockFocusRef.current = true; + } + } + + function onKeyDown() { + keyboardModalityRef.current = true; + } + + function onPointerDown() { + keyboardModalityRef.current = false; + } + + win.addEventListener('blur', onBlur); + + if (isMacSafari) { + win.addEventListener('keydown', onKeyDown, true); + win.addEventListener('pointerdown', onPointerDown, true); + } + + return () => { + win.removeEventListener('blur', onBlur); + + if (isMacSafari) { + win.removeEventListener('keydown', onKeyDown, true); + win.removeEventListener('pointerdown', onPointerDown, true); + } + }; + }, [elements.domReference, open, enabled]); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + function onOpenChangeLocal({ reason }: { reason: OpenChangeReason }) { + if (reason === 'reference-press' || reason === 'escape-key') { + blockFocusRef.current = true; + } + } + + events.on('openchange', onOpenChangeLocal); + return () => { + events.off('openchange', onOpenChangeLocal); + }; + }, [events, enabled]); + + const reference: ElementProps['reference'] = React.useMemo( + () => ({ + onMouseLeave() { + blockFocusRef.current = false; + }, + onFocus(event) { + if (blockFocusRef.current) { + return; + } + + const target = getTarget(event.nativeEvent); + + if (visibleOnly && isElement(target)) { + // Safari fails to match `:focus-visible` if focus was initially + // outside the document. + if (isMacSafari && !event.relatedTarget) { + if (!keyboardModalityRef.current && !isTypeableElement(target)) { + return; + } + } else if (!matchesFocusVisible(target)) { + return; + } + } + + onOpenChange(true, event.nativeEvent, 'focus'); + }, + onBlur(event) { + blockFocusRef.current = false; + const relatedTarget = event.relatedTarget; + const nativeEvent = event.nativeEvent; + + // Hit the non-modal focus management portal guard. Focus will be + // moved into the floating element immediately after. + const movedToFocusGuard = + isElement(relatedTarget) && + relatedTarget.hasAttribute(createAttribute('focus-guard')) && + relatedTarget.getAttribute('data-type') === 'outside'; + + // Wait for the window blur listener to fire. + timeout.start(0, () => { + const activeEl = activeElement( + elements.domReference ? elements.domReference.ownerDocument : document, + ); + + // Focus left the page, keep it open. + if (!relatedTarget && activeEl === elements.domReference) { + return; + } + + // When focusing the reference element (e.g. regular click), then + // clicking into the floating element, prevent it from hiding. + // Note: it must be focusable, e.g. `tabindex="-1"`. + // We can not rely on relatedTarget to point to the correct element + // as it will only point to the shadow host of the newly focused element + // and not the element that actually has received focus if it is located + // inside a shadow root. + if ( + contains(dataRef.current.floatingContext?.refs.floating.current, activeEl) || + contains(elements.domReference, activeEl) || + movedToFocusGuard + ) { + return; + } + + onOpenChange(false, nativeEvent, 'focus'); + }); + }, + }), + [dataRef, elements.domReference, onOpenChange, visibleOnly, timeout], + ); + + return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.test.tsx b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.test.tsx new file mode 100644 index 00000000..df98c1b2 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.test.tsx @@ -0,0 +1,372 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import { + act, + cleanup, + fireEvent, + flushMicrotasks, + render, + screen, + waitFor, +} from '@mui/internal-test-utils'; +import * as React from 'react'; +import { vi, test } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { isJSDOM } from '@base-ui-components/utils/detectBrowser'; +import { useFloating, useHover, useInteractions } from '../index'; +import type { UseHoverProps } from './useHover'; +import { Popover } from '../../../test/floating-ui-tests/Popover'; + +vi.useFakeTimers(); + +function App({ showReference = true, ...props }: UseHoverProps & { showReference?: boolean }) { + const [open, setOpen] = React.useState(false); + const { refs, context } = useFloating({ + open, + onOpenChange: setOpen, + }); + const { getReferenceProps, getFloatingProps } = useInteractions([useHover(context, props)]); + + return ( + + {showReference && + + )} + > + + + + + )} + > + + , + ); + + await user.click(screen.getByText('Open parent')); + expect(screen.getByText('Parent title')).toBeInTheDocument(); + await user.click(screen.getByText('Open child')); + expect(screen.getByText('Child title')).toBeInTheDocument(); + await user.click(screen.getByText('Child title')); + // clean up blockPointerEvents + // userEvent.unhover does not work because of the pointer-events + fireEvent.mouseLeave(screen.getByRole('dialog', { name: 'Child title' })); + expect(screen.getByText('Child title')).toBeInTheDocument(); + await user.click(screen.getByText('Parent title')); + // screen.debug(); + expect(screen.getByText('Parent title')).toBeInTheDocument(); + + vi.useFakeTimers(); + }); +}); diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.ts new file mode 100644 index 00000000..f9cf2e43 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHover.ts @@ -0,0 +1,518 @@ +import * as React from 'react'; +import { isElement } from '@floating-ui/utils/dom'; +import { useTimeout } from '@base-ui-components/utils/useTimeout'; +import { useLatestRef } from '@base-ui-components/utils/useLatestRef'; +import { useEventCallback } from '@base-ui-components/utils/useEventCallback'; +import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; +import { contains, getDocument, isMouseLikePointerType } from '../utils'; + +import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; +import type { + Delay, + ElementProps, + FloatingContext, + FloatingRootContext, + FloatingTreeType, + OpenChangeReason, + SafePolygonOptions, +} from '../types'; +import { createAttribute } from '../utils/createAttribute'; + +const safePolygonIdentifier = createAttribute('safe-polygon'); + +export interface HandleCloseContext extends FloatingContext { + onClose: () => void; + tree?: FloatingTreeType | null; + leave?: boolean; +} + +export interface HandleClose { + (context: HandleCloseContext): (event: MouseEvent) => void; + __options?: SafePolygonOptions; +} + +export function getDelay( + value: UseHoverProps['delay'], + prop: 'open' | 'close', + pointerType?: PointerEvent['pointerType'], +) { + if (pointerType && !isMouseLikePointerType(pointerType)) { + return 0; + } + + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'function') { + const result = value(); + if (typeof result === 'number') { + return result; + } + return result?.[prop]; + } + + return value?.[prop]; +} + +function getRestMs(value: number | (() => number)) { + if (typeof value === 'function') { + return value(); + } + return value; +} + +export interface UseHoverProps { + /** + * Whether the Hook is enabled, including all internal Effects and event + * handlers. + * @default true + */ + enabled?: boolean; + /** + * Accepts an event handler that runs on `mousemove` to control when the + * floating element closes once the cursor leaves the reference element. + * @default null + */ + handleClose?: HandleClose | null; + /** + * Waits until the user’s cursor is at “rest” over the reference element + * before changing the `open` state. + * @default 0 + */ + restMs?: number | (() => number); + /** + * Waits for the specified time when the event listener runs before changing + * the `open` state. + * @default 0 + */ + delay?: Delay | (() => Delay); + /** + * Whether the logic only runs for mouse input, ignoring touch input. + * Note: due to a bug with Linux Chrome, "pen" inputs are considered "mouse". + * @default false + */ + mouseOnly?: boolean; + /** + * Whether moving the cursor over the floating element will open it, without a + * regular hover event required. + * @default true + */ + move?: boolean; +} + +/** + * Opens the floating element while hovering over the reference element, like + * CSS `:hover`. + * @see https://floating-ui.com/docs/useHover + */ +export function useHover(context: FloatingRootContext, props: UseHoverProps = {}): ElementProps { + const { open, onOpenChange, dataRef, events, elements } = context; + const { + enabled = true, + delay = 0, + handleClose = null, + mouseOnly = false, + restMs = 0, + move = true, + } = props; + + const tree = useFloatingTree(); + const parentId = useFloatingParentNodeId(); + const handleCloseRef = useLatestRef(handleClose); + const delayRef = useLatestRef(delay); + const openRef = useLatestRef(open); + const restMsRef = useLatestRef(restMs); + + const pointerTypeRef = React.useRef(undefined); + const timeout = useTimeout(); + const handlerRef = React.useRef<(event: MouseEvent) => void>(undefined); + const restTimeout = useTimeout(); + const blockMouseMoveRef = React.useRef(true); + const performedPointerEventsMutationRef = React.useRef(false); + const unbindMouseMoveRef = React.useRef(() => {}); + const restTimeoutPendingRef = React.useRef(false); + + const isHoverOpen = useEventCallback(() => { + const type = dataRef.current.openEvent?.type; + return type?.includes('mouse') && type !== 'mousedown'; + }); + + // When closing before opening, clear the delay timeouts to cancel it + // from showing. + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + function onOpenChangeLocal({ open: newOpen }: { open: boolean }) { + if (!newOpen) { + timeout.clear(); + restTimeout.clear(); + blockMouseMoveRef.current = true; + restTimeoutPendingRef.current = false; + } + } + + events.on('openchange', onOpenChangeLocal); + return () => { + events.off('openchange', onOpenChangeLocal); + }; + }, [enabled, events, timeout, restTimeout]); + + React.useEffect(() => { + if (!enabled) { + return undefined; + } + if (!handleCloseRef.current) { + return undefined; + } + if (!open) { + return undefined; + } + + function onLeave(event: MouseEvent) { + if (isHoverOpen()) { + onOpenChange(false, event, 'hover'); + } + } + + const html = getDocument(elements.floating).documentElement; + html.addEventListener('mouseleave', onLeave); + return () => { + html.removeEventListener('mouseleave', onLeave); + }; + }, [elements.floating, open, onOpenChange, enabled, handleCloseRef, isHoverOpen]); + + const closeWithDelay = React.useCallback( + (event: Event, runElseBranch = true, reason: OpenChangeReason = 'hover') => { + const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current); + if (closeDelay && !handlerRef.current) { + timeout.start(closeDelay, () => onOpenChange(false, event, reason)); + } else if (runElseBranch) { + timeout.clear(); + onOpenChange(false, event, reason); + } + }, + [delayRef, onOpenChange, timeout], + ); + + const cleanupMouseMoveHandler = useEventCallback(() => { + unbindMouseMoveRef.current(); + handlerRef.current = undefined; + }); + + const clearPointerEvents = useEventCallback(() => { + if (performedPointerEventsMutationRef.current) { + const body = getDocument(elements.floating).body; + body.style.pointerEvents = ''; + body.removeAttribute(safePolygonIdentifier); + performedPointerEventsMutationRef.current = false; + } + }); + + const isClickLikeOpenEvent = useEventCallback(() => { + return dataRef.current.openEvent + ? ['click', 'mousedown'].includes(dataRef.current.openEvent.type) + : false; + }); + + // Registering the mouse events on the reference directly to bypass React's + // delegation system. If the cursor was on a disabled element and then entered + // the reference (no gap), `mouseenter` doesn't fire in the delegation system. + React.useEffect(() => { + if (!enabled) { + return undefined; + } + + function onReferenceMouseEnter(event: MouseEvent) { + timeout.clear(); + blockMouseMoveRef.current = false; + + if ( + (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) || + (getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open')) + ) { + return; + } + + const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current); + + if (openDelay) { + timeout.start(openDelay, () => { + if (!openRef.current) { + onOpenChange(true, event, 'hover'); + } + }); + } else if (!open) { + onOpenChange(true, event, 'hover'); + } + } + + function onReferenceMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) { + clearPointerEvents(); + return; + } + + unbindMouseMoveRef.current(); + + const doc = getDocument(elements.floating); + restTimeout.clear(); + restTimeoutPendingRef.current = false; + + if (handleCloseRef.current && dataRef.current.floatingContext) { + // Prevent clearing `onScrollMouseLeave` timeout. + if (!open) { + timeout.clear(); + } + + handlerRef.current = handleCloseRef.current({ + ...dataRef.current.floatingContext, + tree, + x: event.clientX, + y: event.clientY, + onClose() { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, true, 'safe-polygon'); + } + }, + }); + + const handler = handlerRef.current; + + doc.addEventListener('mousemove', handler); + unbindMouseMoveRef.current = () => { + doc.removeEventListener('mousemove', handler); + }; + + return; + } + + // Allow interactivity without `safePolygon` on touch devices. With a + // pointer, a short close delay is an alternative, so it should work + // consistently. + const shouldClose = + pointerTypeRef.current === 'touch' + ? !contains(elements.floating, event.relatedTarget as Element | null) + : true; + if (shouldClose) { + closeWithDelay(event); + } + } + + // Ensure the floating element closes after scrolling even if the pointer + // did not move. + // https://github.com/floating-ui/floating-ui/discussions/1692 + function onScrollMouseLeave(event: MouseEvent) { + if (isClickLikeOpenEvent()) { + return; + } + if (!dataRef.current.floatingContext) { + return; + } + + handleCloseRef.current?.({ + ...dataRef.current.floatingContext, + tree, + x: event.clientX, + y: event.clientY, + onClose() { + clearPointerEvents(); + cleanupMouseMoveHandler(); + if (!isClickLikeOpenEvent()) { + closeWithDelay(event); + } + }, + })(event); + } + + function onFloatingMouseEnter() { + timeout.clear(); + } + + function onFloatingMouseLeave(event: MouseEvent) { + if (!isClickLikeOpenEvent()) { + closeWithDelay(event, false); + } + } + + if (isElement(elements.domReference)) { + const reference = elements.domReference as unknown as HTMLElement; + const floating = elements.floating; + + if (open) { + reference.addEventListener('mouseleave', onScrollMouseLeave); + } + + if (move) { + reference.addEventListener('mousemove', onReferenceMouseEnter, { + once: true, + }); + } + + reference.addEventListener('mouseenter', onReferenceMouseEnter); + reference.addEventListener('mouseleave', onReferenceMouseLeave); + + if (floating) { + floating.addEventListener('mouseleave', onScrollMouseLeave); + floating.addEventListener('mouseenter', onFloatingMouseEnter); + floating.addEventListener('mouseleave', onFloatingMouseLeave); + } + + return () => { + if (open) { + reference.removeEventListener('mouseleave', onScrollMouseLeave); + } + + if (move) { + reference.removeEventListener('mousemove', onReferenceMouseEnter); + } + + reference.removeEventListener('mouseenter', onReferenceMouseEnter); + reference.removeEventListener('mouseleave', onReferenceMouseLeave); + + if (floating) { + floating.removeEventListener('mouseleave', onScrollMouseLeave); + floating.removeEventListener('mouseenter', onFloatingMouseEnter); + floating.removeEventListener('mouseleave', onFloatingMouseLeave); + } + }; + } + + return undefined; + }, [ + elements, + enabled, + context, + mouseOnly, + move, + closeWithDelay, + cleanupMouseMoveHandler, + clearPointerEvents, + onOpenChange, + open, + openRef, + tree, + delayRef, + handleCloseRef, + dataRef, + isClickLikeOpenEvent, + restMsRef, + timeout, + restTimeout, + ]); + + // Block pointer-events of every element other than the reference and floating + // while the floating element is open and has a `handleClose` handler. Also + // handles nested floating elements. + // https://github.com/floating-ui/floating-ui/issues/1722 + useIsoLayoutEffect(() => { + if (!enabled) { + return undefined; + } + + // eslint-disable-next-line no-underscore-dangle + if (open && handleCloseRef.current?.__options?.blockPointerEvents && isHoverOpen()) { + performedPointerEventsMutationRef.current = true; + const floatingEl = elements.floating; + + if (isElement(elements.domReference) && floatingEl) { + const body = getDocument(elements.floating).body; + body.setAttribute(safePolygonIdentifier, ''); + + const ref = elements.domReference as unknown as HTMLElement | SVGSVGElement; + + const parentFloating = tree?.nodesRef.current.find((node) => node.id === parentId)?.context + ?.elements.floating; + + if (parentFloating) { + parentFloating.style.pointerEvents = ''; + } + + body.style.pointerEvents = 'none'; + ref.style.pointerEvents = 'auto'; + floatingEl.style.pointerEvents = 'auto'; + + return () => { + body.style.pointerEvents = ''; + ref.style.pointerEvents = ''; + floatingEl.style.pointerEvents = ''; + }; + } + } + + return undefined; + }, [enabled, open, parentId, elements, tree, handleCloseRef, isHoverOpen]); + + useIsoLayoutEffect(() => { + if (!open) { + pointerTypeRef.current = undefined; + restTimeoutPendingRef.current = false; + cleanupMouseMoveHandler(); + clearPointerEvents(); + } + }, [open, cleanupMouseMoveHandler, clearPointerEvents]); + + React.useEffect(() => { + return () => { + cleanupMouseMoveHandler(); + timeout.clear(); + restTimeout.clear(); + clearPointerEvents(); + }; + }, [ + enabled, + elements.domReference, + cleanupMouseMoveHandler, + clearPointerEvents, + timeout, + restTimeout, + ]); + + const reference: ElementProps['reference'] = React.useMemo(() => { + function setPointerRef(event: React.PointerEvent) { + pointerTypeRef.current = event.pointerType; + } + + return { + onPointerDown: setPointerRef, + onPointerEnter: setPointerRef, + onMouseMove(event) { + const { nativeEvent } = event; + + function handleMouseMove() { + if (!blockMouseMoveRef.current && !openRef.current) { + onOpenChange(true, nativeEvent, 'hover'); + } + } + + if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { + return; + } + + if (open || getRestMs(restMsRef.current) === 0) { + return; + } + + // Ignore insignificant movements to account for tremors. + if (restTimeoutPendingRef.current && event.movementX ** 2 + event.movementY ** 2 < 2) { + return; + } + + restTimeout.clear(); + + if (pointerTypeRef.current === 'touch') { + handleMouseMove(); + } else { + restTimeoutPendingRef.current = true; + restTimeout.start(getRestMs(restMsRef.current), handleMouseMove); + } + }, + }; + }, [mouseOnly, onOpenChange, open, openRef, restMsRef, restTimeout]); + + return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.test.tsx b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.test.tsx new file mode 100644 index 00000000..3b1a7974 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.test.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { + useClick, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useListNavigation, + useRole, + useTypeahead, +} from '../index'; + +describe('useInteractions', () => { + it('correctly merges functions', () => { + const firstInteractionOnClick = vi.fn(); + const secondInteractionOnClick = vi.fn(); + const secondInteractionOnKeyDown = vi.fn(); + const userOnClick = vi.fn(); + + function App() { + const { getReferenceProps } = useInteractions([ + { reference: { onClick: firstInteractionOnClick } }, + { + reference: { + onClick: secondInteractionOnClick, + onKeyDown: secondInteractionOnKeyDown, + }, + }, + ]); + + const { onClick, onKeyDown } = getReferenceProps({ onClick: userOnClick }); + + // @ts-expect-error + onClick(); + // @ts-expect-error + onKeyDown(); + + return null; + } + + render(); + + expect(firstInteractionOnClick).toHaveBeenCalledTimes(1); + expect(secondInteractionOnClick).toHaveBeenCalledTimes(1); + expect(userOnClick).toHaveBeenCalledTimes(1); + expect(secondInteractionOnKeyDown).toHaveBeenCalledTimes(1); + }); + + it('does not error with undefined user supplied functions', () => { + function App() { + const { getReferenceProps } = useInteractions([{ reference: { onClick() {} } }]); + expect(() => + // @ts-expect-error + getReferenceProps({ onClick: undefined }).onClick(), + ).not.toThrowError(); + return null; + } + + render(); + }); + + it('does not break props that start with `on`', () => { + function App() { + const { getReferenceProps } = useInteractions([]); + + const props = getReferenceProps({ + // @ts-expect-error + onlyShowVotes: true, + onyx: () => {}, + }); + + expect(props.onlyShowVotes).toBe(true); + expect(typeof props.onyx).toBe('function'); + + return null; + } + + render(); + }); + + it('does not break props that return values', () => { + function App() { + const { getReferenceProps } = useInteractions([]); + + const props = getReferenceProps({ + // @ts-expect-error + onyx: () => 'returned value', + }); + + // @ts-expect-error + expect(props.onyx()).toBe('returned value'); + + return null; + } + + render(); + }); + + it('prop getters are memoized', () => { + function App() { + const [open, setOpen] = React.useState(false); + const [, setCount] = React.useState(0); + + const handleClose = () => () => {}; + // eslint-disable-next-line + handleClose.__options = { blockPointerEvents: true }; + + const listRef = React.useRef([]); + const { context } = useFloating({ open, onOpenChange: setOpen }); + + // NOTE: if `ref`-related props are not memoized, this will cause + // an infinite loop as they must be memoized externally (as done by React). + // Other non-primitives like functions and arrays get memoized by the hooks. + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + useHover(context, { handleClose }), + useFocus(context), + useClick(context), + useRole(context), + useDismiss(context), + useListNavigation(context, { + listRef, + activeIndex: 0, + onNavigate: () => {}, + disabledIndices: [], + }), + useTypeahead(context, { + listRef, + activeIndex: 0, + ignoreKeys: [], + onMatch: () => {}, + findMatch: () => '', + }), + ]); + + React.useEffect(() => { + // Should NOT cause an infinite loop as the prop getters are memoized. + setCount((c) => c + 1); + }, [getReferenceProps, getFloatingProps, getItemProps]); + + return null; + } + + render(); + }); +}); diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.ts new file mode 100644 index 00000000..0e06803a --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useInteractions.ts @@ -0,0 +1,134 @@ +import * as React from 'react'; + +import type { ElementProps } from '../types'; +import { ACTIVE_KEY, FOCUSABLE_ATTRIBUTE, SELECTED_KEY } from '../utils/constants'; + +export type ExtendedUserProps = { + [ACTIVE_KEY]?: boolean; + [SELECTED_KEY]?: boolean; +}; + +export interface UseInteractionsReturn { + getReferenceProps: (userProps?: React.HTMLProps) => Record; + getFloatingProps: (userProps?: React.HTMLProps) => Record; + getItemProps: ( + userProps?: Omit, 'selected' | 'active'> & ExtendedUserProps, + ) => Record; +} + +/** + * Merges an array of interaction hooks' props into prop getters, allowing + * event handler functions to be composed together without overwriting one + * another. + * @see https://floating-ui.com/docs/useInteractions + */ +export function useInteractions(propsList: Array = []): UseInteractionsReturn { + const referenceDeps = propsList.map((key) => key?.reference); + const floatingDeps = propsList.map((key) => key?.floating); + const itemDeps = propsList.map((key) => key?.item); + + const getReferenceProps = React.useCallback( + (userProps?: React.HTMLProps) => mergeProps(userProps, propsList, 'reference'), + // eslint-disable-next-line react-hooks/exhaustive-deps + referenceDeps, + ); + + const getFloatingProps = React.useCallback( + (userProps?: React.HTMLProps) => mergeProps(userProps, propsList, 'floating'), + // eslint-disable-next-line react-hooks/exhaustive-deps + floatingDeps, + ); + + const getItemProps = React.useCallback( + (userProps?: Omit, 'selected' | 'active'> & ExtendedUserProps) => + mergeProps(userProps, propsList, 'item'), + // eslint-disable-next-line react-hooks/exhaustive-deps + itemDeps, + ); + + return React.useMemo( + () => ({ getReferenceProps, getFloatingProps, getItemProps }), + [getReferenceProps, getFloatingProps, getItemProps], + ); +} + +/* eslint-disable guard-for-in */ + +function mergeProps( + userProps: (React.HTMLProps & ExtendedUserProps) | undefined, + propsList: Array, + elementKey: Key, +): Record { + const eventHandlers = new Map void>>(); + const isItem = elementKey === 'item'; + + const outputProps = {} as Record; + + if (elementKey === 'floating') { + outputProps.tabIndex = -1; + outputProps[FOCUSABLE_ATTRIBUTE] = ''; + } + + for (const key in userProps) { + if (isItem && userProps) { + if (key === ACTIVE_KEY || key === SELECTED_KEY) { + continue; + } + } + outputProps[key] = (userProps as any)[key]; + } + + for (let i = 0; i < propsList.length; i += 1) { + let props; + + const propsOrGetProps = propsList[i]?.[elementKey]; + if (typeof propsOrGetProps === 'function') { + props = userProps ? propsOrGetProps(userProps) : null; + } else { + props = propsOrGetProps; + } + if (!props) { + continue; + } + + mutablyMergeProps(outputProps, props, isItem, eventHandlers); + } + + mutablyMergeProps(outputProps, userProps, isItem, eventHandlers); + + return outputProps; +} + +function mutablyMergeProps( + outputProps: Record, + props: any, + isItem: boolean, + eventHandlers: Map void>>, +) { + for (const key in props) { + const value = (props as any)[key]; + + if (isItem && (key === ACTIVE_KEY || key === SELECTED_KEY)) { + continue; + } + + if (!key.startsWith('on')) { + outputProps[key] = value; + } else { + if (!eventHandlers.has(key)) { + eventHandlers.set(key, []); + } + + if (typeof value === 'function') { + eventHandlers.get(key)?.push(value); + + outputProps[key] = (...args: unknown[]) => { + return eventHandlers + .get(key) + ?.map((fn) => fn(...args)) + .find((val) => val !== undefined); + }; + } + } + } +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useListNavigation.test.tsx b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useListNavigation.test.tsx new file mode 100644 index 00000000..d7b629f7 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useListNavigation.test.tsx @@ -0,0 +1,1153 @@ +import * as React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, it, describe } from 'vitest'; + +import { isJSDOM } from '@base-ui-components/utils/detectBrowser'; +import { useClick, useDismiss, useFloating, useInteractions, useListNavigation } from '../index'; +import type { UseListNavigationProps } from '../types'; +import { Main as ComplexGrid } from '../../../test/floating-ui-tests/ComplexGrid'; +import { Main as Grid } from '../../../test/floating-ui-tests/Grid'; +import { Main as EmojiPicker } from '../../../test/floating-ui-tests/EmojiPicker'; +import { Main as ListboxFocus } from '../../../test/floating-ui-tests/ListboxFocus'; +import { Main as NestedMenu } from '../../../test/floating-ui-tests/Menu'; +import { HorizontalMenu } from '../../../test/floating-ui-tests/MenuOrientation'; + +/* eslint-disable testing-library/no-unnecessary-act */ + +function App(props: Omit, 'listRef'>) { + const [open, setOpen] = React.useState(false); + const listRef = React.useRef>([]); + const [activeIndex, setActiveIndex] = React.useState(null); + const { refs, context } = useFloating({ + open, + onOpenChange: setOpen, + }); + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + useClick(context), + useListNavigation(context, { + ...props, + listRef, + activeIndex, + onNavigate(index) { + setActiveIndex(index); + props.onNavigate?.(index); + }, + }), + ]); + + return ( + +