From b64c663368ad6aec1b9069a460a686803df8400d Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sat, 13 Dec 2025 19:59:31 +0300 Subject: [PATCH 01/16] feat(headless/components): implement TooltipMultiple for synchronized tooltip management with safe polygon tracking --- .../src/components/Tooltip/index.parts.ts | 4 + .../Tooltip/story/Tooltip.stories.tsx | 57 ++- .../src/components/Tooltip/index.parts.ts | 1 + .../Tooltip/multiple/TooltipMultiple.tsx | 75 ++++ .../multiple/TooltipMultipleContext.ts | 13 + .../Tooltip/multiple/TooltipMultipleStore.ts | 243 ++++++++++++ .../Tooltip/multiple/multipleSafePolygon.ts | 359 ++++++++++++++++++ .../components/Tooltip/popup/TooltipPopup.tsx | 18 +- .../Tooltip/provider/TooltipProvider.tsx | 6 +- .../components/Tooltip/root/TooltipRoot.tsx | 10 + .../Tooltip/store/TooltipHandle.tsx | 2 +- .../Tooltip/trigger/TooltipTrigger.tsx | 59 ++- .../components/src/lib/createEventEmitter.ts | 78 ++++ .../src/lib/popups/popupStoreUtils.ts | 36 +- .../src/lib/popups/popupTriggerMap.ts | 2 +- .../components/src/lib/popups/store.ts | 13 +- 16 files changed, 962 insertions(+), 14 deletions(-) create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/multiple/multipleSafePolygon.ts create mode 100644 packages/ui/uikit/headless/components/src/lib/createEventEmitter.ts diff --git a/packages/ui/uikit/flippo/components/src/components/Tooltip/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/Tooltip/index.parts.ts index 6e46b8ee..a156bb84 100644 --- a/packages/ui/uikit/flippo/components/src/components/Tooltip/index.parts.ts +++ b/packages/ui/uikit/flippo/components/src/components/Tooltip/index.parts.ts @@ -1,3 +1,5 @@ +import { Tooltip } from '@flippo-ui/headless-components/tooltip'; + export { TooltipArrow as Arrow } from './ui/arrow/TooltipArrow'; export { TooltipPopup as Popup } from './ui/popup/TooltipPopup'; export { TooltipPortal as Portal } from './ui/portal/TooltipPortal'; @@ -5,3 +7,5 @@ export { TooltipPositioner as Positioner } from './ui/positioner/TooltipPosition export { TooltipProvider as Provider } from './ui/provider/TooltipProvider'; export { TooltipRoot as Root } from './ui/root/TooltipRoot'; export { TooltipTrigger as Trigger } from './ui/trigger/TooltipTrigger'; + +export const Multiple = Tooltip.Multiple; diff --git a/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx index 547bdbba..c81c4975 100644 --- a/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { FormatBoldIcon } from '@flippo-ui/icons'; +import { FormatBoldIcon, FormatItalicIcon, FormatUndelineIcon } from '@flippo-ui/icons'; import type { Meta, StoryObj } from '@storybook/react'; @@ -35,3 +35,58 @@ export const Default: TooltipStory = { ) } }; + +export const Multiple: TooltipStory = { + render: () => ( + + + + + + + + + + + + {'Bold'} + + + + + + + + + + + + + + + + {'Italic'} + + + + + + + + + + + + + + + + {'Underline'} + + + + + + + ) +}; diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/index.parts.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/index.parts.ts index c6a0a112..cdd7b5d8 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/index.parts.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/index.parts.ts @@ -1,4 +1,5 @@ export { TooltipArrow as Arrow } from './arrow/TooltipArrow'; +export { TooltipMultiple as Multiple } from './multiple/TooltipMultiple'; export { TooltipPopup as Popup } from './popup/TooltipPopup'; export { TooltipPortal as Portal } from './portal/TooltipPortal'; export { TooltipPositioner as Positioner } from './positioner/TooltipPositioner'; diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx new file mode 100644 index 00000000..fe43399b --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import type { TooltipRoot } from '../root/TooltipRoot'; + +import { TooltipMultipleContext } from './TooltipMultipleContext'; +import { TooltipMultipleStore } from './TooltipMultipleStore'; + +/** + * Wrapper component that synchronizes multiple tooltips. + * All tooltips within this wrapper will open and close together. + * + * The store registers all tooltip contexts, providing access to their + * triggers and popups for safePolygon tracking. + * + * @example + * ```tsx + * + * + * Trigger 1 + * + * + * Tooltip 1 content + * + * + * + * + * + * Trigger 2 + * + * + * Tooltip 2 content + * + * + * + * + * ``` + */ +export function TooltipMultiple(props: TooltipMultiple.Props) { + const { defaultOpen = false, onOpenChange, children } = props; + + const store = TooltipMultipleStore.useStore(defaultOpen); + + // Set up callback + React.useEffect(() => { + store.context.onOpenChange = onOpenChange; + }, [store, onOpenChange]); + + const contextValue = React.useMemo(() => ({ store }), [store]); + + return ( + + {children} + + ); +} + +export type TooltipMultipleProps = { + /** + * Whether the tooltips are initially open. + * @default false + */ + defaultOpen?: boolean; + /** + * Callback when the shared open state changes. + */ + onOpenChange?: (open: boolean, eventDetails: TooltipRoot.ChangeEventDetails) => void; + /** + * The tooltip roots to synchronize. + */ + children?: React.ReactNode; +}; + +export namespace TooltipMultiple { + export type Props = TooltipMultipleProps; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts new file mode 100644 index 00000000..2d7ffa99 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +import type { TooltipMultipleStore } from './TooltipMultipleStore'; + +export type TooltipMultipleContextValue = { + store: TooltipMultipleStore; +}; + +export const TooltipMultipleContext = React.createContext(null); + +export function useTooltipMultipleContext(): TooltipMultipleContextValue | null { + return React.use(TooltipMultipleContext); +} diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts new file mode 100644 index 00000000..e887dd12 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts @@ -0,0 +1,243 @@ +import { useLazyRef } from '@flippo-ui/hooks/use-lazy-ref'; +import { createSelector, ReactStore } from '@flippo-ui/hooks/use-store'; + +import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import { REASONS } from '~@lib/reason'; + +import type { TooltipRoot } from '../root/TooltipRoot'; +import type { TooltipStore } from '../store/TooltipStore'; + +export type MultipleState = { + /** + * Shared open state for all tooltips in the group. + */ + open: boolean; +}; + +export type MultipleContext = { + /** + * Set of registered tooltip stores. + * Provides access to each tooltip's triggers and popups. + */ + stores: Set>; + /** + * Original setOpen functions for each store (before override). + */ + originalSetOpenMap: WeakMap, TooltipStore['setOpen']>; + /** + * Set of registered popup elements. + */ + popupElements: Set; + /** + * Callback when open state changes. + */ + onOpenChange?: (open: boolean, eventDetails: TooltipRoot.ChangeEventDetails) => void; +}; + +const selectors = { + open: createSelector((state: MultipleState) => state.open) +}; + +/** + * Store that manages shared open state for multiple tooltips. + * Registers tooltip contexts to provide access to all triggers and popups + * for safePolygon tracking. + */ +export class TooltipMultipleStore extends ReactStore< + Readonly, + MultipleContext, + typeof selectors +> { + constructor(defaultOpen = false) { + super( + { open: defaultOpen }, + { + stores: new Set(), + originalSetOpenMap: new WeakMap(), + popupElements: new Set() + }, + selectors + ); + } + + /** + * Sets the shared open state and syncs all registered stores. + */ + public setOpen = ( + nextOpen: boolean, + eventDetails: Omit + ): void => { + if (this.state.open === nextOpen) { + return; + } + + // Notify callback + const details = eventDetails as TooltipRoot.ChangeEventDetails; + details.preventUnmountOnClose = () => {}; + this.context.onOpenChange?.(nextOpen, details); + + if (details.isCanceled) { + return; + } + + // Update shared state + this.set('open', nextOpen); + + // Sync all registered stores + for (const store of this.context.stores) { + const originalSetOpen = this.context.originalSetOpenMap.get(store); + if (!originalSetOpen) { + continue; + } + + // Get the primary trigger for positioning (if set) + const primaryTriggerId = store.select('primaryTriggerId'); + const primaryTriggerElement = primaryTriggerId + ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined + : undefined; + + // Create event details with proper trigger for positioning + const storeDetails = createChangeEventDetails( + details.reason as TooltipRoot.ChangeEventReason, + details.event + ) as TooltipRoot.ChangeEventDetails; + storeDetails.preventUnmountOnClose = () => { + store.set('preventUnmountingOnClose', true); + }; + storeDetails.trigger = primaryTriggerElement; + + originalSetOpen(nextOpen, storeDetails); + } + }; + + /** + * Registers a tooltip store with this multiple group. + * The store's setOpen is overridden to sync with the group. + */ + public registerStore = (store: TooltipStore): (() => void) => { + if (this.context.stores.has(store)) { + return () => {}; + } + + // Save original setOpen + this.context.originalSetOpenMap.set(store, store.setOpen); + this.context.stores.add(store); + + // Override setOpen to sync through this store + store.setOpen = this.setOpen; + + // Sync initial state if already open + if (this.state.open) { + const primaryTriggerId = store.select('primaryTriggerId'); + const primaryTriggerElement = primaryTriggerId + ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined + : undefined; + + const details = createChangeEventDetails( + REASONS.none as TooltipRoot.ChangeEventReason + ) as TooltipRoot.ChangeEventDetails; + details.preventUnmountOnClose = () => { + store.set('preventUnmountingOnClose', true); + }; + details.trigger = primaryTriggerElement; + + const originalSetOpen = this.context.originalSetOpenMap.get(store); + originalSetOpen?.(true, details); + } + + // Return cleanup function + return () => { + this.unregisterStore(store); + }; + }; + + /** + * Registers a popup element for hover tracking. + */ + public registerPopup = (element: HTMLElement): void => { + this.context.popupElements.add(element); + }; + + /** + * Unregisters a popup element. + */ + public unregisterPopup = (element: HTMLElement): void => { + this.context.popupElements.delete(element); + }; + + /** + * Unregisters a tooltip store from this multiple group. + */ + public unregisterStore = (store: TooltipStore): void => { + const originalSetOpen = this.context.originalSetOpenMap.get(store); + if (originalSetOpen) { + store.setOpen = originalSetOpen; + } + + this.context.originalSetOpenMap.delete(store); + this.context.stores.delete(store); + }; + + /** + * Gets all trigger elements from all registered tooltips. + */ + public getAllTriggerElements = (): HTMLElement[] => { + const triggers: HTMLElement[] = []; + for (const store of this.context.stores) { + for (const [_, element] of store.context.triggerElements.entries()) { + triggers.push(element as HTMLElement); + } + } + + return triggers; + }; + + /** + * Gets all popup elements from all registered tooltips. + */ + public getAllPopupElements = (): HTMLElement[] => { + const popups: HTMLElement[] = []; + for (const store of this.context.stores) { + const popupElement = store.select('popupElement'); + if (popupElement) { + popups.push(popupElement); + } + } + return popups; + }; + + /** + * Checks if an element is a trigger or popup in this group. + */ + public isElementInGroup = (element: Element | null): boolean => { + if (!element) { + return false; + } + + for (const store of this.context.stores) { + // Check triggers + if (store.context.triggerElements.hasElement(element)) { + return true; + } + // Check if inside a trigger + if (store.context.triggerElements.hasMatchingElement((el) => el.contains(element))) { + return true; + } + // Check popup + const popupElement = store.select('popupElement'); + if (popupElement && (popupElement === element || popupElement.contains(element))) { + return true; + } + } + + return false; + }; + + /** + * React hook to create and manage the store. + */ + public static useStore(defaultOpen = false): TooltipMultipleStore { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useLazyRef(() => new TooltipMultipleStore(defaultOpen)).current; + } +} diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/multipleSafePolygon.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/multipleSafePolygon.ts new file mode 100644 index 00000000..c64a0158 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/multipleSafePolygon.ts @@ -0,0 +1,359 @@ +import { Timeout } from '@flippo-ui/hooks/use-timeout'; + +import type { HandleClose } from '~@packages/floating-ui-react/hooks/useHover'; + +import type { TooltipMultipleStore } from './TooltipMultipleStore'; + +type Point = [number, number]; +type Polygon = Point[]; + +/** + * Checks if a point is inside a polygon using ray casting algorithm. + */ +function isPointInPolygon(point: Point, polygon: Polygon): boolean { + if (polygon.length < 3) { + return false; + } + + const [x, y] = point; + let isInside = false; + const length = polygon.length; + + for (let i = 0, j = length - 1; i < length; j = i++) { + const [xi, yi] = polygon[i] || [0, 0]; + const [xj, yj] = polygon[j] || [0, 0]; + const intersect = (yi >= y) !== (yj >= y) && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) { + isInside = !isInside; + } + } + + return isInside; +} + +/** + * Gets the center point of a rectangle. + */ +function getRectCenter(rect: DOMRect): Point { + return [rect.left + rect.width / 2, rect.top + rect.height / 2]; +} + +/** + * Creates a convex hull from a set of points using Graham scan. + */ +function convexHull(points: Point[]): Point[] { + if (points.length < 3) { + return points; + } + + // Find the point with lowest y (and leftmost if tie) + let start = 0; + for (let i = 1; i < points.length; i++) { + const current = points[i]!; + const startP = points[start]!; + if (current[1] < startP[1] || (current[1] === startP[1] && current[0] < startP[0])) { + start = i; + } + } + + const startPoint = points[start]!; + + // Sort by polar angle with respect to start point + const sorted = points + .filter((_, i) => i !== start) + .sort((a, b) => { + const angleA = Math.atan2(a[1] - startPoint[1], a[0] - startPoint[0]); + const angleB = Math.atan2(b[1] - startPoint[1], b[0] - startPoint[0]); + if (angleA !== angleB) { + return angleA - angleB; + } + // If same angle, closer point first + const distA = (a[0] - startPoint[0]) ** 2 + (a[1] - startPoint[1]) ** 2; + const distB = (b[0] - startPoint[0]) ** 2 + (b[1] - startPoint[1]) ** 2; + return distA - distB; + }); + + // Build hull + const hull: Point[] = [startPoint]; + + for (const point of sorted) { + while (hull.length > 1) { + const prev = hull[hull.length - 2]!; + const last = hull[hull.length - 1]!; + if (cross(prev, last, point) <= 0) { + hull.pop(); + } + else { + break; + } + } + hull.push(point); + } + + return hull; +} + +/** + * Cross product of vectors OA and OB where O is origin. + */ +function cross(o: Point, a: Point, b: Point): number { + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); +} + +/** + * Expands a polygon by a buffer amount. + */ +function expandPolygon(polygon: Point[], buffer: number): Point[] { + if (polygon.length < 3) { + return polygon; + } + + const centroid: Point = [polygon.reduce((sum, p) => sum + p[0], 0) / polygon.length, polygon.reduce((sum, p) => sum + p[1], 0) / polygon.length]; + + return polygon.map((point) => { + const dx = point[0] - centroid[0]; + const dy = point[1] - centroid[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist === 0) { + return point; + } + const scale = (dist + buffer) / dist; + return [centroid[0] + dx * scale, centroid[1] + dy * scale] as Point; + }); +} + +/** + * Creates a safe polygon between a trigger and its popup. + */ +function createTriggerPopupPolygon( + triggerRect: DOMRect, + popupRect: DOMRect, + buffer: number +): Polygon { + // Determine the side where popup is relative to trigger + const triggerCenter = getRectCenter(triggerRect); + const popupCenter = getRectCenter(popupRect); + + const dx = popupCenter[0] - triggerCenter[0]; + const dy = popupCenter[1] - triggerCenter[1]; + + // Create a polygon that covers the area between trigger and popup + const points: Point[] = []; + + if (Math.abs(dx) > Math.abs(dy)) { + // Popup is to the left or right + if (dx > 0) { + // Popup is to the right + points.push([triggerRect.right - buffer, triggerRect.top - buffer]); + points.push([triggerRect.right - buffer, triggerRect.bottom + buffer]); + points.push([popupRect.left + buffer, popupRect.bottom + buffer]); + points.push([popupRect.left + buffer, popupRect.top - buffer]); + } + else { + // Popup is to the left + points.push([triggerRect.left + buffer, triggerRect.top - buffer]); + points.push([triggerRect.left + buffer, triggerRect.bottom + buffer]); + points.push([popupRect.right - buffer, popupRect.bottom + buffer]); + points.push([popupRect.right - buffer, popupRect.top - buffer]); + } + } + else { + // Popup is above or below + if (dy > 0) { + // Popup is below + points.push([triggerRect.left - buffer, triggerRect.bottom - buffer]); + points.push([triggerRect.right + buffer, triggerRect.bottom - buffer]); + points.push([popupRect.right + buffer, popupRect.top + buffer]); + points.push([popupRect.left - buffer, popupRect.top + buffer]); + } + else { + // Popup is above + points.push([triggerRect.left - buffer, triggerRect.top + buffer]); + points.push([triggerRect.right + buffer, triggerRect.top + buffer]); + points.push([popupRect.right + buffer, popupRect.bottom - buffer]); + points.push([popupRect.left - buffer, popupRect.bottom - buffer]); + } + } + + return points; +} + +/** + * Gets corner points of a rectangle with buffer. + */ +function getRectCorners(rect: DOMRect, buffer: number): Point[] { + return [[rect.left - buffer, rect.top - buffer], [rect.right + buffer, rect.top - buffer], [rect.right + buffer, rect.bottom + buffer], [rect.left - buffer, rect.bottom + buffer]]; +} + +export type MultipleSafePolygonOptions = { + /** + * Buffer around elements. + * @default 5 + */ + buffer?: number; + /** + * Whether to block pointer events on body. + * @default false + */ + blockPointerEvents?: boolean; + /** + * Enable debug mode to visualize polygons. + * @default false + */ + debug?: boolean; +}; + +// Debug visualization +let debugSvg: SVGSVGElement | null = null; + +function drawDebugPolygons(polygons: Polygon[], colors: string[]): void { + if (!debugSvg) { + debugSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + debugSvg.style.cssText = ` + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 999999; + `; + document.body.appendChild(debugSvg); + } + + debugSvg.innerHTML = ''; + + polygons.forEach((polygon, i) => { + if (polygon.length < 3) { + return; + } + const color = colors[i % colors.length] ?? 'rgba(255, 0, 0, 0.2)'; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + path.setAttribute('points', polygon.map((p) => p.join(',')).join(' ')); + path.setAttribute('fill', color); + path.setAttribute('stroke', color.replace('0.2', '1')); + path.setAttribute('stroke-width', '2'); + debugSvg!.appendChild(path); + }); +} + +function clearDebugPolygons(): void { + if (debugSvg) { + debugSvg.remove(); + debugSvg = null; + } +} + +/** + * Creates a safe polygon handler for multiple tooltips. + * + * This builds: + * 1. A convex hull connecting all triggers + * 2. Individual trigger-popup connection polygons + * 3. Rectangles around each popup + * + * The cursor is safe if it's inside any of these polygons. + */ +export function multipleSafePolygon( + multipleStore: TooltipMultipleStore, + options: MultipleSafePolygonOptions = {} +): HandleClose { + const { buffer = 5, blockPointerEvents = false, debug = false } = options; + + const timeout = new Timeout(); + + const fn: HandleClose = ({ onClose }) => { + return function onMouseMove(event: MouseEvent) { + function close() { + timeout.clear(); + if (debug) { + clearDebugPolygons(); + } + onClose(); + } + + timeout.clear(); + + const { clientX, clientY } = event; + const cursorPoint: Point = [clientX, clientY]; + + // Get all triggers and popups + const triggers = multipleStore.getAllTriggerElements(); + const popups = multipleStore.getAllPopupElements(); + + if (triggers.length === 0) { + return close(); + } + + const allPolygons: Polygon[] = []; + const debugColors = [ + 'rgba(255, 0, 0, 0.2)', + 'rgba(0, 255, 0, 0.2)', + 'rgba(0, 0, 255, 0.2)', + 'rgba(255, 255, 0, 0.2)', + 'rgba(255, 0, 255, 0.2)', + 'rgba(0, 255, 255, 0.2)' + ]; + + // 1. Create convex hull of all trigger centers + corners + const allTriggerPoints: Point[] = []; + for (const trigger of triggers) { + const rect = trigger.getBoundingClientRect(); + allTriggerPoints.push(...getRectCorners(rect, buffer)); + } + + if (allTriggerPoints.length >= 3) { + const triggersHull = convexHull(allTriggerPoints); + const expandedHull = expandPolygon(triggersHull, buffer); + allPolygons.push(expandedHull); + } + + // 2. Create trigger-popup connection polygons + for (const trigger of triggers) { + const triggerRect = trigger.getBoundingClientRect(); + + // Find the popup for this trigger (by checking which store it belongs to) + for (const store of multipleStore.context.stores) { + if (store.context.triggerElements.hasElement(trigger)) { + const popupElement = store.select('popupElement'); + if (popupElement) { + const popupRect = popupElement.getBoundingClientRect(); + const connectionPoly = createTriggerPopupPolygon(triggerRect, popupRect, buffer); + allPolygons.push(connectionPoly); + } + break; + } + } + } + + // 3. Add popup rectangles + for (const popup of popups) { + const rect = popup.getBoundingClientRect(); + const popupPoly = getRectCorners(rect, buffer); + allPolygons.push(popupPoly); + } + + // Debug visualization + if (debug) { + drawDebugPolygons(allPolygons, debugColors); + } + + // Check if cursor is in any polygon + for (const polygon of allPolygons) { + if (isPointInPolygon(cursorPoint, polygon)) { + return undefined; // Safe - don't close + } + } + + // Cursor is outside all safe zones - close with small delay + timeout.start(50, close); + + return undefined; + }; + }; + + fn.__options = { + blockPointerEvents + }; + + return fn; +} diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx index e20bb33e..5efa7ef7 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx @@ -14,6 +14,7 @@ import type { StateAttributesMapping } from '~@lib/getStyleHookProps'; import type { Align, Side } from '~@lib/hooks'; import type { HeadlessUIComponentProps } from '~@lib/types'; +import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { useTooltipPositionerContext } from '../positioner/TooltipPositionerContext'; import { useTooltipRootContext } from '../root/TooltipRootContext'; @@ -86,9 +87,24 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { const disabled = store.useState('disabled'); const closeDelay = store.useState('closeDelay'); + // Register popup element with Multiple store for safePolygon tracking + const multipleContext = useTooltipMultipleContext(); + React.useEffect(() => { + if (!multipleContext || !popupElement) { + return; + } + multipleContext.store.registerPopup(popupElement); + return () => { + multipleContext.store.unregisterPopup(popupElement); + }; + }, [multipleContext, popupElement]); + + // For Multiple: use larger closeDelay to allow mouse movement between elements + const effectiveCloseDelay = multipleContext ? Math.max(closeDelay, 150) : closeDelay; + useHoverFloatingInteraction(floatingContext, { enabled: !disabled, - closeDelay + closeDelay: effectiveCloseDelay }); const state: TooltipPopup.State = React.useMemo( diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProvider.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProvider.tsx index ab9c5e30..228128e5 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProvider.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/provider/TooltipProvider.tsx @@ -7,7 +7,11 @@ import { TooltipProviderContext } from './TooltipProviderContext'; import type { TooltipProviderContextValue } from './TooltipProviderContext'; export function TooltipProvider(props: TooltipProviderProps) { - const { delay, closeDelay, timeout = 400 } = props; + const { + delay, + closeDelay, + timeout = 400 + } = props; const contextValue: TooltipProviderContextValue = React.useMemo( () => ({ diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx index 0ee4ce85..7946829c 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx @@ -18,6 +18,7 @@ import { import type { HeadlessUIChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; import type { PayloadChildRenderFunction } from '~@lib/popups'; +import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { TooltipStore } from '../store/TooltipStore'; import type { TooltipHandle } from '../store/TooltipHandle'; @@ -47,6 +48,15 @@ export function TooltipRoot(props: TooltipRoot.Props) { activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp }); + // Register with TooltipMultiple if inside one + const multipleContext = useTooltipMultipleContext(); + React.useEffect(() => { + if (!multipleContext) { + return; + } + return multipleContext.store.registerStore(store); + }, [multipleContext, store]); + store.useControlledProp('open', openProp, defaultOpen); store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipHandle.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipHandle.tsx index f0d27216..8e383f3c 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipHandle.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipHandle.tsx @@ -31,7 +31,7 @@ export class TooltipHandle { : undefined; if (triggerId && !triggerElement) { - throw new Error(`Base UI: TooltipHandle.open: No trigger found with id "${triggerId}".`); + throw new Error(`Headless UI: TooltipHandle.open: No trigger found with id "${triggerId}".`); } this.store.setOpen( diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx index d4cf00c4..cc61f48e 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useIsoLayoutEffect } from '@flippo-ui/hooks/use-iso-layout-effect'; + import { OPEN_DELAY } from '~@lib/constants'; import { useHeadlessUiId, useRenderElement } from '~@lib/hooks/'; import { useTriggerDataForwarding } from '~@lib/popups'; @@ -8,6 +10,8 @@ import { safePolygon, useDelayGroup, useHoverReferenceInteraction } from '~@pack import type { HeadlessUIComponentProps } from '~@lib/types'; +import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; +import { multipleSafePolygon } from '../multiple/multipleSafePolygon'; import { useTooltipProviderContext } from '../provider/TooltipProviderContext'; import { useTooltipRootContext } from '../root/TooltipRootContext'; @@ -23,6 +27,7 @@ export function TooltipTrigger( /* eslint-enable unused-imports/no-unused-vars */ handle, payload, + primary = false, disabled: disabledProp, delay, closeDelay, @@ -44,6 +49,36 @@ export function TooltipTrigger( const floatingRootContext = store.useState('floatingRootContext'); const isOpenedByThisTrigger = store.useState('isOpenedByTrigger', thisTriggerId); + // Register this trigger as primary if marked + useIsoLayoutEffect(() => { + if (!primary || !thisTriggerId) { + return; + } + + const currentPrimaryId = store.select('primaryTriggerId'); + + // First wins strategy: if there's already a primary, warn and ignore + if (currentPrimaryId !== null && currentPrimaryId !== thisTriggerId) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[Tooltip] Multiple triggers marked as primary. ` + + `Trigger "${currentPrimaryId}" is already primary. ` + + `Ignoring primary on trigger "${thisTriggerId}".` + ); + } + return; + } + + store.set('primaryTriggerId', thisTriggerId); + + // Cleanup: reset if this trigger unmounts + return () => { + if (store.select('primaryTriggerId') === thisTriggerId) { + store.set('primaryTriggerId', null); + } + }; + }, [primary, store, thisTriggerId]); + const [triggerElement, setTriggerElement] = React.useState(null); const delayWithDefault = delay ?? OPEN_DELAY; @@ -60,7 +95,11 @@ export function TooltipTrigger( ); const providerContext = useTooltipProviderContext(); + const multipleContext = useTooltipMultipleContext(); + + // Disable delay group when inside Multiple to allow all tooltips open simultaneously const { delayRef, isInstantPhase, hasProvider } = useDelayGroup(floatingRootContext, { + enabled: !multipleContext, open: isOpenedByThisTrigger }); @@ -71,11 +110,22 @@ export function TooltipTrigger( const trackCursorAxis = store.useState('trackCursorAxis'); const disableHoverablePopup = store.useState('disableHoverablePopup'); + // Use multipleSafePolygon for Multiple, regular safePolygon otherwise + const handleClose = React.useMemo(() => { + if (disableHoverablePopup || trackCursorAxis === 'both') { + return null; + } + if (multipleContext) { + return multipleSafePolygon(multipleContext.store); + } + return safePolygon(); + }, [disableHoverablePopup, trackCursorAxis, multipleContext]); + const hoverProps = useHoverReferenceInteraction(floatingRootContext, { enabled: !disabled, mouseOnly: true, move: false, - handleClose: !disableHoverablePopup && trackCursorAxis !== 'both' ? safePolygon() : null, + handleClose, restMs() { const providerDelay = providerContext?.delay; const groupOpenValue @@ -142,6 +192,13 @@ export type TooltipTriggerProps = { * A payload to pass to the tooltip when it is opened. */ payload?: Payload; + /** + * Marks this trigger as the primary trigger for positioning. + * Used when the tooltip is opened via TooltipMultiple sync. + * If multiple triggers are marked as primary, the first one wins. + * @default false + */ + primary?: boolean; /** * How long to wait before opening the tooltip. Specified in milliseconds. * @default 600 diff --git a/packages/ui/uikit/headless/components/src/lib/createEventEmitter.ts b/packages/ui/uikit/headless/components/src/lib/createEventEmitter.ts new file mode 100644 index 00000000..aac4664b --- /dev/null +++ b/packages/ui/uikit/headless/components/src/lib/createEventEmitter.ts @@ -0,0 +1,78 @@ +export type EventMap = Record; + +export class EventEmitter { + private map; + + constructor() { + this.map = new Map void>>(); + } + + /** + * Регистрирует подписчика на событие. + * @param event Имя события. + * @param listener Функция-обработчик. + * @returns Функция для отписки данного подписчика. + */ + public on(event: K, listener: (data: T[K]) => void): () => void { + if (!this.map.has(event)) { + this.map.set(event, new Set()); + } + + const listeners = this.map.get(event)!; + // Безопасно приводим тип, так как `emit` будет передавать правильные данные. + listeners.add(listener as (data: unknown) => void); + + // Возвращаем функцию-деструктор для удобной отписки. + return () => this.off(event, listener); + } + + /** + * Удаляет подписчика с события. + * @param event Имя события. + * @param listener Функция-обработчик, которую нужно удалить. + */ + public off(event: K, listener: (data: T[K]) => void): void { + this.map.get(event)?.delete(listener as (data: unknown) => void); + } + + /** + * Генерирует событие, вызывая всех его подписчиков. + * @param event Имя события. + * @param data Данные, которые будут переданы подписчикам. + */ + public emit(event: K, data: T[K]): void { + this.map.get(event)?.forEach((listener) => { + // `data` здесь имеет тип T[K], а `listener` ожидает `unknown`. + // Любой тип можно передать в `unknown`, так что это безопасно. + listener(data); + }); + } + + /** + * Регистрирует одноразового подписчика. Он будет удален после первого же вызова. + * @param event Имя события. + * @param listener Функция-обработчик. + * @returns Функция для отписки данного подписчика до его вызова. + */ + public once(event: K, listener: (data: T[K]) => void): () => void { + const removeListener = this.on(event, (data) => { + // Сначала отписываемся, потом вызываем оригинальный listener. + removeListener(); + listener(data); + }); + return removeListener; + } + + /** + * Удаляет всех подписчиков для указанного события, или для всех событий, если имя не указано. + * @param event (Опционально) Имя события. + */ + public removeAllListeners(event?: K): void { + if (event) { + this.map.delete(event); + } + else { + this.map.clear(); + } + } +} diff --git a/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts b/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts index 4b14758b..0219b91d 100644 --- a/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts +++ b/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts @@ -108,21 +108,45 @@ export type PayloadChildRenderFunction = (arg: { }) => React.ReactNode; /** - * Ensures that when there's only one trigger element registered, it is set as the active trigger. - * This allows controlled popups to work correctly without an explicit triggerId, maintaining compatibility - * with the contained triggers. + * Ensures that when there's no active trigger, one is selected implicitly. + * Priority order: + * 1. Primary trigger (if set via `primary` prop) + * 2. First trigger (if only one trigger is registered) + * + * This allows controlled popups to work correctly without an explicit triggerId, + * and enables TooltipMultiple to sync open state while each tooltip positions + * relative to its own primary trigger. * * This should be called on the Root part. * - * @param open Whether the popup is open. * @param store The Store instance managing the popup state. */ export function useImplicitActiveTrigger>( store: ReactStore, typeof popupStoreSelectors> ) { const open = store.useState('open'); + const primaryTriggerId = store.useState('primaryTriggerId'); + useIsoLayoutEffect(() => { - if (open && !store.select('activeTriggerId') && store.context.triggerElements.size === 1) { + // if (open && !store.select('activeTriggerId') && store.context.triggerElements.size === 1) { + if (!open || store.select('activeTriggerId')) { + return; + } + + // 1. Try primary trigger first + if (primaryTriggerId) { + const primaryElement = store.context.triggerElements.getById(primaryTriggerId); + if (primaryElement) { + store.update({ + activeTriggerId: primaryTriggerId, + activeTriggerElement: primaryElement + } as Partial); + return; + } + } + + // 2. Fallback: first trigger if only one exists + if (store.context.triggerElements.size === 1) { const iteratorResult = store.context.triggerElements.entries().next(); if (!iteratorResult.done) { const [implicitTriggerId, implicitTriggerElement] = iteratorResult.value; @@ -132,7 +156,7 @@ export function useImplicitActiveTrigger>( } as Partial); } } - }, [open, store]); + }, [open, primaryTriggerId, store]); } /** diff --git a/packages/ui/uikit/headless/components/src/lib/popups/popupTriggerMap.ts b/packages/ui/uikit/headless/components/src/lib/popups/popupTriggerMap.ts index c41c8fe7..12be2bbd 100644 --- a/packages/ui/uikit/headless/components/src/lib/popups/popupTriggerMap.ts +++ b/packages/ui/uikit/headless/components/src/lib/popups/popupTriggerMap.ts @@ -35,7 +35,7 @@ export class PopupTriggerMap { if (process.env.NODE_ENV !== 'production') { if (this.elements.size !== this.idMap.size) { throw new Error( - 'Base UI: A trigger element cannot be registered under multiple IDs in PopupTriggerMap.' + 'Headless UI: A trigger element cannot be registered under multiple IDs in PopupTriggerMap.' ); } } diff --git a/packages/ui/uikit/headless/components/src/lib/popups/store.ts b/packages/ui/uikit/headless/components/src/lib/popups/store.ts index aea4bbdc..589f5845 100644 --- a/packages/ui/uikit/headless/components/src/lib/popups/store.ts +++ b/packages/ui/uikit/headless/components/src/lib/popups/store.ts @@ -71,6 +71,12 @@ export type PopupStoreState = { * Props to spread onto the popup element. */ popupProps: HTMLProps; + + /** + * ID of the primary trigger used for positioning when opened via sync (e.g., TooltipMultiple). + * When activeTriggerId is null but primaryTriggerId is set, the primary trigger is used. + */ + primaryTriggerId: string | null; }; export function createInitialPopupStoreState(): PopupStoreState { @@ -87,7 +93,8 @@ export function createInitialPopupStoreState(): PopupStoreState) => state.popupProps), popupElement: createSelector((state: PopupStoreState) => state.popupElement), - positionerElement: createSelector((state: PopupStoreState) => state.positionerElement) + positionerElement: createSelector((state: PopupStoreState) => state.positionerElement), + + primaryTriggerId: createSelector((state: PopupStoreState) => state.primaryTriggerId) }; export type PopupStoreSelectors = typeof popupStoreSelectors; From d48d31b17691ebc2c92cf8a41a7b33368aebffe5 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 14 Dec 2025 15:09:50 +0300 Subject: [PATCH 02/16] feat(headless/components): enhance TooltipMultiple with additional props for delay and disabled state management --- .../Tooltip/multiple/TooltipMultiple.tsx | 34 ++++++- .../multiple/TooltipMultipleContext.ts | 12 +++ .../Tooltip/multiple/TooltipMultipleStore.ts | 98 +++++++++++-------- .../components/Tooltip/popup/TooltipPopup.tsx | 4 +- .../Tooltip/positioner/TooltipPositioner.tsx | 2 +- .../components/Tooltip/root/TooltipRoot.tsx | 21 +++- .../components/Tooltip/store/TooltipStore.tsx | 14 +++ .../Tooltip/trigger/TooltipTrigger.tsx | 30 +++--- .../src/lib/popups/popupStoreUtils.ts | 10 +- 9 files changed, 158 insertions(+), 67 deletions(-) diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx index fe43399b..53484d62 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx @@ -36,7 +36,14 @@ import { TooltipMultipleStore } from './TooltipMultipleStore'; * ``` */ export function TooltipMultiple(props: TooltipMultiple.Props) { - const { defaultOpen = false, onOpenChange, children } = props; + const { + defaultOpen = false, + onOpenChange, + disabled, + delay, + closeDelay, + children + } = props; const store = TooltipMultipleStore.useStore(defaultOpen); @@ -45,7 +52,15 @@ export function TooltipMultiple(props: TooltipMultiple.Props) { store.context.onOpenChange = onOpenChange; }, [store, onOpenChange]); - const contextValue = React.useMemo(() => ({ store }), [store]); + const contextValue = React.useMemo( + () => ({ + store, + disabled, + delay, + closeDelay + }), + [store, disabled, delay, closeDelay] + ); return ( @@ -64,6 +79,21 @@ export type TooltipMultipleProps = { * Callback when the shared open state changes. */ onOpenChange?: (open: boolean, eventDetails: TooltipRoot.ChangeEventDetails) => void; + /** + * Whether all tooltips in the group are disabled. + * Individual triggers can override this. + */ + disabled?: boolean; + /** + * Common open delay for all triggers in the group (in milliseconds). + * Individual triggers can override this. + */ + delay?: number; + /** + * Common close delay for all triggers in the group (in milliseconds). + * Individual triggers can override this. + */ + closeDelay?: number; /** * The tooltip roots to synchronize. */ diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts index 2d7ffa99..4fc5dd3f 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleContext.ts @@ -4,6 +4,18 @@ import type { TooltipMultipleStore } from './TooltipMultipleStore'; export type TooltipMultipleContextValue = { store: TooltipMultipleStore; + /** + * Common disabled state for all tooltips in the group. + */ + disabled?: boolean; + /** + * Common open delay for all triggers in the group. + */ + delay?: number; + /** + * Common close delay for all triggers in the group. + */ + closeDelay?: number; }; export const TooltipMultipleContext = React.createContext(null); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts index e887dd12..89e9fd8f 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts @@ -84,30 +84,30 @@ export class TooltipMultipleStore extends ReactStore< this.set('open', nextOpen); // Sync all registered stores - for (const store of this.context.stores) { - const originalSetOpen = this.context.originalSetOpenMap.get(store); - if (!originalSetOpen) { - continue; - } - - // Get the primary trigger for positioning (if set) - const primaryTriggerId = store.select('primaryTriggerId'); - const primaryTriggerElement = primaryTriggerId - ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined - : undefined; - - // Create event details with proper trigger for positioning - const storeDetails = createChangeEventDetails( - details.reason as TooltipRoot.ChangeEventReason, - details.event - ) as TooltipRoot.ChangeEventDetails; - storeDetails.preventUnmountOnClose = () => { - store.set('preventUnmountingOnClose', true); - }; - storeDetails.trigger = primaryTriggerElement; - - originalSetOpen(nextOpen, storeDetails); - } + // for (const store of this.context.stores) { + // const originalSetOpen = this.context.originalSetOpenMap.get(store); + // if (!originalSetOpen) { + // continue; + // } + + // // Get the primary trigger for positioning (if set) + // const primaryTriggerId = store.select('primaryTriggerId'); + // const primaryTriggerElement = primaryTriggerId + // ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined + // : undefined; + + // // Create event details with proper trigger for positioning + // const storeDetails = createChangeEventDetails( + // details.reason as TooltipRoot.ChangeEventReason, + // details.event + // ) as TooltipRoot.ChangeEventDetails; + // storeDetails.preventUnmountOnClose = () => { + // store.set('preventUnmountingOnClose', true); + // }; + // storeDetails.trigger = primaryTriggerElement; + + // originalSetOpen(nextOpen, storeDetails); + // } }; /** @@ -126,24 +126,8 @@ export class TooltipMultipleStore extends ReactStore< // Override setOpen to sync through this store store.setOpen = this.setOpen; - // Sync initial state if already open - if (this.state.open) { - const primaryTriggerId = store.select('primaryTriggerId'); - const primaryTriggerElement = primaryTriggerId - ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined - : undefined; - - const details = createChangeEventDetails( - REASONS.none as TooltipRoot.ChangeEventReason - ) as TooltipRoot.ChangeEventDetails; - details.preventUnmountOnClose = () => { - store.set('preventUnmountingOnClose', true); - }; - details.trigger = primaryTriggerElement; - - const originalSetOpen = this.context.originalSetOpenMap.get(store); - originalSetOpen?.(true, details); - } + // Sync initial state to match Multiple's open state + this.syncStoreOpenState(store, this.state.open); // Return cleanup function return () => { @@ -151,6 +135,36 @@ export class TooltipMultipleStore extends ReactStore< }; }; + /** + * Syncs a single store's open state with the given value. + */ + private syncStoreOpenState = (store: TooltipStore, open: boolean): void => { + const originalSetOpen = this.context.originalSetOpenMap.get(store); + if (!originalSetOpen) { + return; + } + + // Only sync if store's open state differs + if (store.select('open') === open) { + return; + } + + const primaryTriggerId = store.select('primaryTriggerId'); + const primaryTriggerElement = primaryTriggerId + ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined + : undefined; + + const details = createChangeEventDetails( + REASONS.none as TooltipRoot.ChangeEventReason + ) as TooltipRoot.ChangeEventDetails; + details.preventUnmountOnClose = () => { + store.set('preventUnmountingOnClose', true); + }; + details.trigger = primaryTriggerElement; + + originalSetOpen(open, details); + }; + /** * Registers a popup element for hover tracking. */ diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx index 5efa7ef7..a206d6d2 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx @@ -34,9 +34,10 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { } = componentProps; const store = useTooltipRootContext(); + const multipleContext = useTooltipMultipleContext(); const { side, align } = useTooltipPositionerContext(); - const open = store.useState('open'); + const open = store.useOpen(); const mounted = store.useState('mounted'); const instantType = store.useState('instantType'); const transitionStatus = store.useState('transitionStatus'); @@ -88,7 +89,6 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { const closeDelay = store.useState('closeDelay'); // Register popup element with Multiple store for safePolygon tracking - const multipleContext = useTooltipMultipleContext(); React.useEffect(() => { if (!multipleContext || !popupElement) { return; diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositioner.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositioner.tsx index 3376e232..da0d69b5 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositioner.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/positioner/TooltipPositioner.tsx @@ -47,7 +47,7 @@ export function TooltipPositioner(componentProps: TooltipPositionerProps) { const store = useTooltipRootContext(); const keepMounted = useTooltipPortalContext(); - const open = store.useState('open'); + const open = store.useOpen(); const mounted = store.useState('mounted'); const trackCursorAxis = store.useState('trackCursorAxis'); const disableHoverablePopup = store.useState('disableHoverablePopup'); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx index 7946829c..6eba7edc 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx @@ -43,13 +43,21 @@ export function TooltipRoot(props: TooltipRoot.Props) { children } = props; + // Check if inside TooltipMultiple - if so, ignore local open/defaultOpen + const multipleContext = useTooltipMultipleContext(); + const isInsideMultiple = multipleContext !== null; + + // When inside Multiple, use Multiple's defaultOpen for initial state + const effectiveDefaultOpen = isInsideMultiple + ? multipleContext.store.select('open') + : (openProp ?? defaultOpen); + const store = TooltipStore.useStore(handle?.store, { - open: openProp ?? defaultOpen, + open: effectiveDefaultOpen, activeTriggerId: triggerIdProp !== undefined ? triggerIdProp : defaultTriggerIdProp }); // Register with TooltipMultiple if inside one - const multipleContext = useTooltipMultipleContext(); React.useEffect(() => { if (!multipleContext) { return; @@ -57,13 +65,15 @@ export function TooltipRoot(props: TooltipRoot.Props) { return multipleContext.store.registerStore(store); }, [multipleContext, store]); - store.useControlledProp('open', openProp, defaultOpen); + // When inside Multiple, don't use controlled prop for open - Multiple handles it + store.useControlledProp('open', isInsideMultiple ? undefined : openProp, isInsideMultiple ? false : defaultOpen); store.useControlledProp('activeTriggerId', triggerIdProp, defaultTriggerIdProp); store.useContextCallback('onOpenChange', onOpenChange); store.useContextCallback('onOpenChangeComplete', onOpenChangeComplete); - const openState = store.useState('open'); + // When inside Multiple, use Multiple's open state + const openState = store.useOpen(); const activeTriggerId = store.useState('activeTriggerId'); const payload = store.useState('payload') as Payload | undefined; @@ -83,7 +93,8 @@ export function TooltipRoot(props: TooltipRoot.Props) { store.useSyncedValue('disabled', disabled); - useImplicitActiveTrigger(store); + // Pass the correct open state (from Multiple if inside, otherwise from store) + useImplicitActiveTrigger(store, openState); const { forceUnmount, transitionStatus } = useOpenStateTransitions(open, store); const isInstantPhase = store.useState('isInstantPhase'); const instantType = store.useState('instantType'); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx index d7869d8a..18cd56d9 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx @@ -13,6 +13,8 @@ import { REASONS } from '~@lib/reason'; import type { PopupStoreContext, PopupStoreState } from '~@lib/popups'; +import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; + import type { TooltipRoot } from '../root/TooltipRoot'; export type State = PopupStoreState & { @@ -114,6 +116,18 @@ export class TooltipStore extends ReactStore< } }; + public useOpen = () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const multipleContext = useTooltipMultipleContext(); + const isInsideMultiple = multipleContext !== null; + + // When inside Multiple, use Multiple's open state + const localOpenState = this.useState('open'); + const multipleOpenState = multipleContext?.store.useState('open'); + + return isInsideMultiple ? (multipleOpenState ?? false) : localOpenState; + }; + public static useStore( externalStore: TooltipStore | undefined, initialState?: Partial> diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx index cc61f48e..88eb48bb 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx @@ -10,8 +10,8 @@ import { safePolygon, useDelayGroup, useHoverReferenceInteraction } from '~@pack import type { HeadlessUIComponentProps } from '~@lib/types'; -import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { multipleSafePolygon } from '../multiple/multipleSafePolygon'; +import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { useTooltipProviderContext } from '../provider/TooltipProviderContext'; import { useTooltipRootContext } from '../root/TooltipRootContext'; @@ -81,8 +81,12 @@ export function TooltipTrigger( const [triggerElement, setTriggerElement] = React.useState(null); - const delayWithDefault = delay ?? OPEN_DELAY; - const closeDelayWithDefault = closeDelay ?? 0; + const providerContext = useTooltipProviderContext(); + const multipleContext = useTooltipMultipleContext(); + + // Priority: trigger prop > multiple context > provider context > default + const effectiveDelay = delay ?? multipleContext?.delay ?? OPEN_DELAY; + const effectiveCloseDelay = closeDelay ?? multipleContext?.closeDelay ?? 0; const { registerTrigger, isMountedByThisTrigger } = useTriggerDataForwarding( thisTriggerId, @@ -90,13 +94,10 @@ export function TooltipTrigger( store, { payload, - closeDelay: closeDelayWithDefault + closeDelay: effectiveCloseDelay } ); - const providerContext = useTooltipProviderContext(); - const multipleContext = useTooltipMultipleContext(); - // Disable delay group when inside Multiple to allow all tooltips open simultaneously const { delayRef, isInstantPhase, hasProvider } = useDelayGroup(floatingRootContext, { enabled: !multipleContext, @@ -106,7 +107,8 @@ export function TooltipTrigger( store.useSyncedValue('isInstantPhase', isInstantPhase); const rootDisabled = store.useState('disabled'); - const disabled = disabledProp ?? rootDisabled; + // Priority: trigger prop > multiple context > root prop + const disabled = disabledProp ?? multipleContext?.disabled ?? rootDisabled; const trackCursorAxis = store.useState('trackCursorAxis'); const disableHoverablePopup = store.useState('disableHoverablePopup'); @@ -115,9 +117,11 @@ export function TooltipTrigger( if (disableHoverablePopup || trackCursorAxis === 'both') { return null; } + if (multipleContext) { return multipleSafePolygon(multipleContext.store); } + return safePolygon(); }, [disableHoverablePopup, trackCursorAxis, multipleContext]); @@ -131,10 +135,10 @@ export function TooltipTrigger( const groupOpenValue = typeof delayRef.current === 'object' ? delayRef.current.open : undefined; - let computedRestMs = delayWithDefault; - if (hasProvider) { + let computedRestMs = effectiveDelay; + if (hasProvider && !multipleContext) { if (groupOpenValue !== 0) { - computedRestMs = delay ?? providerDelay ?? delayWithDefault; + computedRestMs = delay ?? providerDelay ?? effectiveDelay; } else { computedRestMs = 0; @@ -146,8 +150,8 @@ export function TooltipTrigger( delay() { const closeValue = typeof delayRef.current === 'object' ? delayRef.current.close : undefined; - let computedCloseDelay: number | undefined = closeDelayWithDefault; - if (closeDelay == null && hasProvider) { + let computedCloseDelay: number | undefined = effectiveCloseDelay; + if (closeDelay == null && hasProvider && !multipleContext) { computedCloseDelay = closeValue; } diff --git a/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts b/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts index 0219b91d..5e733f4f 100644 --- a/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts +++ b/packages/ui/uikit/headless/components/src/lib/popups/popupStoreUtils.ts @@ -122,9 +122,15 @@ export type PayloadChildRenderFunction = (arg: { * @param store The Store instance managing the popup state. */ export function useImplicitActiveTrigger>( - store: ReactStore, typeof popupStoreSelectors> + store: ReactStore, typeof popupStoreSelectors>, + /** + * Optional external open state (e.g., from Multiple context). + * If provided, uses this instead of store's open state. + */ + externalOpen?: boolean ) { - const open = store.useState('open'); + const storeOpen = store.useState('open'); + const open = externalOpen ?? storeOpen; const primaryTriggerId = store.useState('primaryTriggerId'); useIsoLayoutEffect(() => { From 56d0c9eca28c8febd5f2568c120b10228922de54 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 21 Dec 2025 13:03:16 +0300 Subject: [PATCH 03/16] feat(headless/components): enhance Tooltip and TooltipMultiple with composite management, state attributes, and styling improvements --- .../Tooltip/story/Tooltip.stories.tsx | 97 +++--- .../Tooltip/ui/arrow/TooltipArrow.module.scss | 15 + .../Tooltip/ui/popup/TooltipPopup.module.scss | 7 +- .../components/scripts/generate-exports.js | 2 +- .../Composite/item/CompositeItem.tsx | 67 ++-- .../Composite/item/useCompositeItem.ts | 94 +++--- .../Composite/list/CompositeList.tsx | 289 +++++++++--------- .../Composite/list/CompositeListContext.ts | 37 ++- .../Composite/list/useCompositeListItem.ts | 220 ++++++------- .../Composite/root/CompositeRoot.tsx | 183 ++++++----- .../Composite/root/CompositeRootContext.ts | 33 +- .../components/Tooltip/arrow/TooltipArrow.tsx | 25 +- .../arrow/TooltipArrowDataAttributes.ts | 5 +- .../src/components/Tooltip/composite.ts | 20 ++ .../Tooltip/multiple/TooltipMultiple.tsx | 24 +- .../Tooltip/multiple/TooltipMultipleStore.ts | 37 +-- .../components/Tooltip/popup/TooltipPopup.tsx | 25 +- .../popup/TooltipPopupDataAttributes.ts | 5 +- .../components/Tooltip/root/TooltipRoot.tsx | 28 +- .../components/Tooltip/store/TooltipStore.tsx | 20 +- .../Tooltip/trigger/TooltipTrigger.tsx | 50 ++- .../trigger/TooltipTriggerDataAttributes.ts | 5 +- .../Tooltip/utils/stateAttributes.ts | 13 + .../components/FloatingRootStore.ts | 16 +- .../floating-ui-react/hooks/useFocus.ts | 21 +- .../hooks/useSyncedFloatingRootContext.ts | 2 +- .../uikit/headless/components/vite.config.ts | 3 +- .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../hooks/src/hooks/useMediaQuery/index.ts | 1 + .../src/hooks/useMediaQuery/useMediaQuery.ts | 95 ++++++ 30 files changed, 916 insertions(+), 524 deletions(-) create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/composite.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Tooltip/utils/stateAttributes.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts diff --git a/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx index c81c4975..d7f907cc 100644 --- a/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Tooltip/story/Tooltip.stories.tsx @@ -38,55 +38,58 @@ export const Default: TooltipStory = { export const Multiple: TooltipStory = { render: () => ( - - - - - - - - - - - - {'Bold'} - - - - +
+ + + + + + + + + + + + {'Bold'} + + + + - - - - - - - - - - - {'Italic'} - - - - + + + + + + + + + + + {'Italic'} + + + + - - - - - - - - - - - {'Underline'} - - - - + + + + + + + + + + + {'Underline'} + + + + + + +
-
) }; diff --git a/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/arrow/TooltipArrow.module.scss b/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/arrow/TooltipArrow.module.scss index 70e16992..bf37a412 100644 --- a/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/arrow/TooltipArrow.module.scss +++ b/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/arrow/TooltipArrow.module.scss @@ -24,12 +24,27 @@ .ArrowFill { fill: var(--f-color-bg-2-hover); + transition: fill 150ms; + + [data-multiple-active] & { + fill: var(--f-color-brand-35); + } } .ArrowOuterStroke { fill: var(--f-color-bg-2-hover); + transition: fill 150ms; + + [data-multiple-active] & { + fill: var(--f-color-brand-35); + } } .ArrowInnerStroke { fill: var(--f-color-bg-2-hover); + transition: fill 150ms; + + [data-multiple-active] & { + fill: var(--f-color-brand-35); + } } diff --git a/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/popup/TooltipPopup.module.scss b/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/popup/TooltipPopup.module.scss index f28a7f4e..3e43392d 100644 --- a/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/popup/TooltipPopup.module.scss +++ b/packages/ui/uikit/flippo/components/src/components/Tooltip/ui/popup/TooltipPopup.module.scss @@ -14,7 +14,8 @@ transform-origin: var(--transform-origin); transition: transform 150ms, - opacity 150ms; + opacity 150ms, + background-color 150ms; &[data-starting-style], &[data-ending-style] { @@ -25,4 +26,8 @@ &[data-instant] { transition-duration: 0ms; } + + &[data-multiple-active] { + background-color: var(--f-color-brand-35); + } } diff --git a/packages/ui/uikit/headless/components/scripts/generate-exports.js b/packages/ui/uikit/headless/components/scripts/generate-exports.js index d3c0e069..acb0e3bf 100644 --- a/packages/ui/uikit/headless/components/scripts/generate-exports.js +++ b/packages/ui/uikit/headless/components/scripts/generate-exports.js @@ -161,7 +161,7 @@ function generateExports() { } // Добавляем дополнительные exports для утилит - const utilExports = [{ key: './merge-props', path: './dist/lib/merge' }, { key: './direction-provider', path: './dist/lib/hooks/useDirection' }]; + const utilExports = [{ key: './merge-props', path: './dist/lib/merge' }, { key: './direction-provider', path: './dist/lib/hooks/useDirection' }, { key: './createHeadlessUIEventDetails', path: './dist/lib/createHeadlessUIEventDetails' }]; console.log('📝 Generating utility exports...'); for (const util of utilExports) { 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 index 38135b97..d2721d28 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx +++ b/packages/ui/uikit/headless/components/src/components/Composite/item/CompositeItem.tsx @@ -6,35 +6,51 @@ import { useRenderElement } from '~@lib/hooks'; import type { StateAttributesMapping } from '~@lib/getStyleHookProps'; import type { HeadlessUIComponentProps } from '~@lib/types'; +import type { CompositeMetadata } from '../list/CompositeList'; + 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, - stateAttributesMapping, - tag = 'div', - ...elementProps - } = componentProps; - - const { compositeProps, compositeRef } = useCompositeItem({ metadata }); - - return useRenderElement(tag, componentProps, { - state, - ref: [...refs, compositeRef], - props: [compositeProps, ...props, elementProps], - customStyleHookMapping: stateAttributesMapping - }); +import type { UseCompositeItemParameters, UseCompositeItemReturnValue } from './useCompositeItem'; + +export type CreateCompositeItemParameters = { + useCompositeItem: (params: UseCompositeItemParameters) => UseCompositeItemReturnValue; +}; + +export function createCompositeItem(params: CreateCompositeItemParameters) { + const { useCompositeItem } = params; + + return 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, + stateAttributesMapping, + tag = 'div', + ...elementProps + } = componentProps; + + const { compositeProps, compositeRef } = useCompositeItem({ metadata }); + + return useRenderElement(tag, componentProps, { + state, + ref: [...refs, compositeRef], + props: [compositeProps, ...props, elementProps], + customStyleHookMapping: stateAttributesMapping + }); + }; } +export const CompositeItem = createCompositeItem>({ + useCompositeItem +}); + export type CompositeItemProps> = { children?: React.ReactNode; metadata?: Metadata; @@ -45,6 +61,7 @@ export type CompositeItemProps> = { tag?: keyof React.JSX.IntrinsicElements; } & Pick, 'render' | 'className'>; +// eslint-disable-next-line ts/no-redeclare export namespace CompositeItem { export type Props> = CompositeItemProps< Metadata, 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 index 5a5d0a00..b6a22a8e 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts +++ b/packages/ui/uikit/headless/components/src/components/Composite/item/useCompositeItem.ts @@ -7,44 +7,68 @@ import type { HTMLProps } from '~@lib/types'; import { useCompositeListItem } from '../list/useCompositeListItem'; import { useCompositeRootContext } from '../root/CompositeRootContext'; -export type UseCompositeItemParameters = { - metadata?: Metadata; +import type { CompositeMetadata } from '../list/CompositeList'; +import type { UseCompositeListItemParameters, UseCompositeListItemReturnValue } from '../list/useCompositeListItem'; +import type { CompositeRootContextValue } from '../root/CompositeRootContext'; + +export type CreateUseCompositeItemParameters = { + useCompositeRootContext: () => CompositeRootContextValue; + useCompositeListItem: (params: UseCompositeListItemParameters) => UseCompositeListItemReturnValue; }; -export function useCompositeItem(params: UseCompositeItemParameters = {}) { - 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; - } +export function createUseCompositeItem(params: CreateUseCompositeItemParameters) { + const { useCompositeRootContext, useCompositeListItem } = params; + + return function useCompositeItem(params: UseCompositeItemParameters = {}) { + 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(); + const disabled = item.hasAttribute('disabled') || item.ariaDisabled === 'true'; + if (!isHighlighted && !disabled) { + item.focus(); + } } - } - }), - [isHighlighted, onHighlightedIndexChange, index, highlightItemOnHover] - ); - - return { - compositeProps, - compositeRef: mergedRef as React.RefCallback, - index + }), + [isHighlighted, onHighlightedIndexChange, index, highlightItemOnHover] + ); + + return { + compositeProps, + compositeRef: mergedRef as React.RefCallback, + index + } as const; }; } + +export const useCompositeItem = createUseCompositeItem>({ + useCompositeRootContext, + useCompositeListItem +}); + +export type UseCompositeItemParameters = { + metadata?: Metadata; +}; + +export type UseCompositeItemReturnValue = { + compositeProps: HTMLProps; + compositeRef: React.RefCallback; + index: number; +}; 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 index a4968663..24a2118e 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeList.tsx @@ -8,164 +8,178 @@ import { useStableCallback } from '@flippo-ui/hooks/use-stable-callback'; import { CompositeListContext } from './CompositeListContext'; +import type { CompositeListContextValue } from './CompositeListContext'; + export type CompositeMetadata = { index?: number | null } & CustomMetadata; +export type CreateCompositeListParameters = { + CompositeListContext: React.Context>; +}; + /** * Provides context for a list of items in a composite component. * @internal */ -export function CompositeList(props: CompositeList.Props) { - const { - children, - elementsRef, - labelsRef, - onMapChange: onMapChangeProp - } = props; - - const onMapChange = useStableCallback(onMapChangeProp); - - const nextIndexRef = React.useRef(0); - const listeners = useLazyRef(createListeners).current; - - // We use a stable `map` to avoid O(n^2) re-allocation costs for large lists. - // `mapTick` is our re-render trigger mechanism. We also need to update the - // elements and label refs, but there's a lot of async work going on and sometimes - // the effect that handles `onMapChange` gets called after those refs have been - // filled, and we don't want to lose those values by setting their lengths to `0`. - // We also need to have them at the proper length because floating-ui uses that - // information for list navigation. - - const map = useLazyRef(createMap).current; - // `mapTick` uses a counter rather than objects for low precision-loss risk and better memory efficiency - const [mapTick, setMapTick] = React.useState(0); - const lastTickRef = React.useRef(mapTick); - - const register = useStableCallback((node: Element, metadata: Metadata) => { - map.set(node, metadata ?? null); - lastTickRef.current += 1; - setMapTick(lastTickRef.current); - }); - - const unregister = useStableCallback((node: Element) => { - map.delete(node); - lastTickRef.current += 1; - setMapTick(lastTickRef.current); - }); - - const sortedMap = React.useMemo(() => { - // `mapTick` is the `useMemo` trigger as `map` is stable. - 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 }); +export function createCompositeList(params: CreateCompositeListParameters) { + const { CompositeListContext } = params; + + return function CompositeList(props: CompositeList.Props) { + const { + children, + elementsRef, + labelsRef, + onMapChange: onMapChangeProp + } = props; + + const onMapChange = useStableCallback(onMapChangeProp); + + const nextIndexRef = React.useRef(0); + const listeners = useLazyRef(createListeners).current; + + // We use a stable `map` to avoid O(n^2) re-allocation costs for large lists. + // `mapTick` is our re-render trigger mechanism. We also need to update the + // elements and label refs, but there's a lot of async work going on and sometimes + // the effect that handles `onMapChange` gets called after those refs have been + // filled, and we don't want to lose those values by setting their lengths to `0`. + // We also need to have them at the proper length because floating-ui uses that + // information for list navigation. + + const map = useLazyRef(createMap).current; + // `mapTick` uses a counter rather than objects for low precision-loss risk and better memory efficiency + const [mapTick, setMapTick] = React.useState(0); + const lastTickRef = React.useRef(mapTick); + + const register = useStableCallback((node: Element, metadata: Metadata) => { + map.set(node, metadata ?? null); + lastTickRef.current += 1; + setMapTick(lastTickRef.current); }); - return newMap; - }, [map, mapTick]); + const unregister = useStableCallback((node: Element) => { + map.delete(node); + lastTickRef.current += 1; + setMapTick(lastTickRef.current); + }); + + const sortedMap = React.useMemo(() => { + // `mapTick` is the `useMemo` trigger as `map` is stable. + disableEslintWarning(mapTick); - useIsoLayoutEffect(() => { - if (typeof MutationObserver !== 'function' || sortedMap.size === 0) { - return undefined; - } + const newMap = new Map>(); + const sortedNodes = Array.from(map.keys()).sort(sortByDocumentPosition); - const mutationObserver = new MutationObserver((entries) => { - const diff = new Set(); - const updateDiff = (node: Node) => (diff.has(node) ? diff.delete(node) : diff.add(node)); - entries.forEach((entry) => { - entry.removedNodes.forEach(updateDiff); - entry.addedNodes.forEach(updateDiff); + sortedNodes.forEach((node, index) => { + const metadata = map.get(node) ?? ({} as CompositeMetadata); + newMap.set(node, { ...metadata, index }); }); - if (diff.size === 0) { - lastTickRef.current += 1; - setMapTick(lastTickRef.current); - } - }); - sortedMap.forEach((_, node) => { - if (node.parentElement) { - mutationObserver.observe(node.parentElement, { childList: true }); + return newMap; + }, [map, mapTick]); + + useIsoLayoutEffect(() => { + if (typeof MutationObserver !== 'function' || sortedMap.size === 0) { + return undefined; } - }); - return () => { - mutationObserver.disconnect(); - }; - }, [sortedMap]); + const mutationObserver = new MutationObserver((entries) => { + const diff = new Set(); + const updateDiff = (node: Node) => (diff.has(node) ? diff.delete(node) : diff.add(node)); + entries.forEach((entry) => { + entry.removedNodes.forEach(updateDiff); + entry.addedNodes.forEach(updateDiff); + }); + if (diff.size === 0) { + lastTickRef.current += 1; + setMapTick(lastTickRef.current); + } + }); - useIsoLayoutEffect(() => { - const shouldUpdateLengths = lastTickRef.current === mapTick; - if (shouldUpdateLengths) { - if (elementsRef.current.length !== sortedMap.size) { - elementsRef.current.length = sortedMap.size; - } - if (labelsRef && labelsRef.current.length !== sortedMap.size) { - labelsRef.current.length = sortedMap.size; - } - nextIndexRef.current = sortedMap.size; - } - - onMapChange(sortedMap); - }, [ - onMapChange, - sortedMap, - elementsRef, - labelsRef, - mapTick - ]); - - useIsoLayoutEffect(() => { - return () => { - elementsRef.current = []; - }; - }, [elementsRef]); - - useIsoLayoutEffect(() => { - return () => { - if (labelsRef) { - labelsRef.current = []; + sortedMap.forEach((_, node) => { + if (node.parentElement) { + mutationObserver.observe(node.parentElement, { childList: true }); + } + }); + + return () => { + mutationObserver.disconnect(); + }; + }, [sortedMap]); + + useIsoLayoutEffect(() => { + const shouldUpdateLengths = lastTickRef.current === mapTick; + if (shouldUpdateLengths) { + if (elementsRef.current.length !== sortedMap.size) { + elementsRef.current.length = sortedMap.size; + } + if (labelsRef && labelsRef.current.length !== sortedMap.size) { + labelsRef.current.length = sortedMap.size; + } + nextIndexRef.current = sortedMap.size; } - }; - }, [labelsRef]); - - const subscribeMapChange = useStableCallback((fn) => { - listeners.add(fn); - return () => { - listeners.delete(fn); - }; - }); - - useIsoLayoutEffect(() => { - listeners.forEach((l) => l(sortedMap)); - }, [listeners, sortedMap]); - - const contextValue = React.useMemo( - () => ({ - register, - unregister, - subscribeMapChange, - elementsRef, - labelsRef, - nextIndexRef - }), - [ - register, - unregister, - subscribeMapChange, + + onMapChange(sortedMap); + }, [ + onMapChange, + sortedMap, elementsRef, labelsRef, - nextIndexRef - ] - ); + mapTick + ]); + + useIsoLayoutEffect(() => { + return () => { + elementsRef.current = []; + }; + }, [elementsRef]); + + useIsoLayoutEffect(() => { + return () => { + if (labelsRef) { + labelsRef.current = []; + } + }; + }, [labelsRef]); + + const subscribeMapChange = useStableCallback((fn) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }); - return ( - {children} - ); + useIsoLayoutEffect(() => { + listeners.forEach((l) => l(sortedMap)); + }, [listeners, sortedMap]); + + const contextValue = React.useMemo( + () => ({ + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + }), + [ + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + ] + ); + + return ( + {children} + ); + }; } +export const CompositeList = createCompositeList({ + CompositeListContext +}); + function createMap() { return new Map | null>(); } @@ -208,6 +222,7 @@ export type CompositeListProps = { onMapChange?: (newMap: Map | null>) => void; }; +// eslint-disable-next-line ts/no-redeclare export namespace CompositeList { export type Props = CompositeListProps; } 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 index 004d88bd..5deac5b2 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/CompositeListContext.ts @@ -1,24 +1,35 @@ import React from 'react'; +import type { CompositeMetadata } from './CompositeList'; + export type CompositeListContextValue = { - register: (node: Element, metadata: Metadata) => void; + register: (node: Element, metadata: Metadata | null | undefined) => void; unregister: (node: Element) => void; - subscribeMapChange: (fn: (map: Map) => void) => () => void; + subscribeMapChange: (fn: (map: Map | null>) => void) => () => void; elementsRef: React.RefObject>; labelsRef?: React.RefObject>; nextIndexRef: React.RefObject; }; -export const CompositeListContext = React.createContext>({ - register: () => {}, - unregister: () => {}, - subscribeMapChange: () => { - return () => {}; - }, - elementsRef: { current: [] }, - nextIndexRef: { current: 0 } -}); +export function createCompositeListContext() { + const CompositeListContext = React.createContext>({ + register: () => {}, + unregister: () => {}, + subscribeMapChange: () => { + return () => {}; + }, + elementsRef: { current: [] }, + nextIndexRef: { current: 0 } + }); + + function useCompositeListContext() { + return React.use(CompositeListContext); + } -export function useCompositeListContext() { - return React.use(CompositeListContext); + return { + CompositeListContext, + useCompositeListContext + } as const; } + +export const { CompositeListContext, useCompositeListContext } = createCompositeListContext>(); 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 index 8a92f056..ec975477 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts +++ b/packages/ui/uikit/headless/components/src/components/Composite/list/useCompositeListItem.ts @@ -4,23 +4,7 @@ import { useIsoLayoutEffect } from '@flippo-ui/hooks/use-iso-layout-effect'; import { useCompositeListContext } from './CompositeListContext'; -export type UseCompositeListItemParameters = { - index?: number; - 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; -}; - -type UseCompositeListItemReturnValue = { - ref: (node: HTMLElement | null) => void; - index: number; -}; +import type { CompositeListContextValue } from './CompositeListContext'; export enum IndexGuessBehavior { None, @@ -30,102 +14,132 @@ export enum IndexGuessBehavior { /** * Used to register a list item and its index (DOM position) in the `CompositeList`. */ -export function useCompositeListItem( - params: UseCompositeListItemParameters = {} -): UseCompositeListItemReturnValue { - const { - label, - metadata, - textRef, - indexGuessBehavior, - index: externalIndex - } = params; - - const { - register, - unregister, - subscribeMapChange, - elementsRef, - labelsRef, - nextIndexRef - } - = useCompositeListContext(); - - const indexRef = React.useRef(-1); - const [index, setIndex] = React.useState( - externalIndex - ?? (indexGuessBehavior === IndexGuessBehavior.GuessFromOrder - ? () => { - if (indexRef.current === -1) { - const newIndex = nextIndexRef.current; - nextIndexRef.current += 1; - indexRef.current = newIndex; +export type CreateUseCompositeListItemParameters = { + useCompositeListContext: () => CompositeListContextValue; +}; + +export function createUseCompositeListItem(params: CreateUseCompositeListItemParameters) { + const { useCompositeListContext } = params; + + return function useCompositeListItem( + params: UseCompositeListItemParameters = {} + ): UseCompositeListItemReturnValue { + const { + label, + metadata, + textRef, + indexGuessBehavior, + index: externalIndex + } = params; + + const { + register, + unregister, + subscribeMapChange, + elementsRef, + labelsRef, + nextIndexRef + } + = useCompositeListContext(); + + const indexRef = React.useRef(-1); + const [index, setIndex] = React.useState( + externalIndex + ?? (indexGuessBehavior === IndexGuessBehavior.GuessFromOrder + ? () => { + if (indexRef.current === -1) { + const newIndex = nextIndexRef.current; + nextIndexRef.current += 1; + indexRef.current = newIndex; + } + return indexRef.current; } - return indexRef.current; - } - : -1) - ); + : -1) + ); - const componentRef = React.useRef(null); + const componentRef = React.useRef(null); - const ref = React.useCallback( - (node: HTMLElement | null) => { - componentRef.current = node; + const ref = React.useCallback( + (node: HTMLElement | null) => { + componentRef.current = node; - if (index !== -1 && node !== null) { - elementsRef.current[index] = 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); + if (labelsRef) { + const isLabelDefined = label !== undefined; + labelsRef.current[index] = isLabelDefined + ? label + : (textRef?.current?.textContent ?? node.textContent); + } } + }, + [ + index, + elementsRef, + labelsRef, + label, + textRef + ] + ); + + useIsoLayoutEffect(() => { + if (externalIndex != null) { + return undefined; } - }, - [ - index, - elementsRef, - labelsRef, - label, - textRef - ] - ); - useIsoLayoutEffect(() => { - if (externalIndex != null) { + const node = componentRef.current; + if (node) { + register(node, metadata); + return () => { + unregister(node); + }; + } return undefined; - } - - const node = componentRef.current; - if (node) { - register(node, metadata); - return () => { - unregister(node); - }; - } - return undefined; - }, [externalIndex, register, unregister, metadata]); + }, [externalIndex, register, unregister, metadata]); - useIsoLayoutEffect(() => { - if (externalIndex != null) { - return undefined; - } + useIsoLayoutEffect(() => { + if (externalIndex != null) { + return undefined; + } - return subscribeMapChange((map) => { - const i = componentRef.current ? map.get(componentRef.current)?.index : null; + return subscribeMapChange((map) => { + const i = componentRef.current ? map.get(componentRef.current)?.index : null; - if (i != null) { - setIndex(i); - } - }); - }, [externalIndex, subscribeMapChange, setIndex]); - - return React.useMemo( - () => ({ - ref, - index - }), - [index, ref] - ); + if (i != null) { + setIndex(i); + } + }); + }, [externalIndex, subscribeMapChange, setIndex]); + + return React.useMemo( + () => ({ + ref, + index + }), + [index, ref] + ); + }; } + +export const useCompositeListItem = createUseCompositeListItem({ + useCompositeListContext +}); + +export type UseCompositeListItemParameters = { + index?: number; + 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 UseCompositeListItemReturnValue = { + 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 index dc084fac..57fb106d 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRoot.tsx @@ -9,7 +9,7 @@ import type { HeadlessUIComponentProps } from '~@lib/types'; import { CompositeList } from '../list/CompositeList'; import type { Dimensions, ModifierKey } from '../composite'; -import type { CompositeMetadata } from '../list/CompositeList'; +import type { CompositeListProps, CompositeMetadata } from '../list/CompositeList'; import { CompositeRootContext } from './CompositeRootContext'; import { useCompositeRoot } from './useCompositeRoot'; @@ -19,93 +19,109 @@ import type { CompositeRootContextValue } from './CompositeRootContext'; /** * @internal */ -export function CompositeRoot>( - componentProps: CompositeRoot.Props -) { - const { - /* eslint-disable unused-imports/no-unused-vars */ - render, - className, - /* eslint-enable unused-imports/no-unused-vars */ - refs = EMPTY_ARRAY, - props = EMPTY_ARRAY, - state = EMPTY_OBJECT as State, - stateAttributesMapping, - highlightedIndex: highlightedIndexProp, - onHighlightedIndexChange: onHighlightedIndexChangeProp, - orientation, - dense, - itemSizes, - loopFocus, - cols, - enableHomeAndEndKeys, - onMapChange: onMapChangeProp, - stopEventPropagation = true, - rootRef, - disabledIndices, - modifierKeys, - highlightItemOnHover = false, - tag = 'div', - ...elementProps - } = componentProps; - - const direction = useDirection(); - - const { - props: defaultProps, - highlightedIndex, - onHighlightedIndexChange, - elementsRef, - onMapChange: onMapChangeUnwrapped, - relayKeyboardEvent - } = useCompositeRoot({ - itemSizes, - cols, - loopFocus, - dense, - orientation, - highlightedIndex: highlightedIndexProp, - onHighlightedIndexChange: onHighlightedIndexChangeProp, - rootRef, - stopEventPropagation, - enableHomeAndEndKeys, - direction, - disabledIndices, - modifierKeys - }); - - const element = useRenderElement(tag, componentProps, { - state, - ref: refs, - props: [defaultProps, ...props, elementProps], - customStyleHookMapping: stateAttributesMapping - }); - - const contextValue: CompositeRootContextValue = React.useMemo( - () => ({ +export type CreateCompositeRootParameters = { + CompositeRootContext: React.Context; + CompositeList: ( + props: CompositeListProps + ) => React.ReactElement | null; +}; + +export function createCompositeRoot(params: CreateCompositeRootParameters) { + const { CompositeRootContext, CompositeList } = params; + + return function CompositeRoot>( + componentProps: CompositeRoot.Props + ) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + render, + className, + /* eslint-enable unused-imports/no-unused-vars */ + refs = EMPTY_ARRAY, + props = EMPTY_ARRAY, + state = EMPTY_OBJECT as State, + stateAttributesMapping, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, + orientation, + dense, + itemSizes, + loopFocus, + cols, + enableHomeAndEndKeys, + onMapChange: onMapChangeProp, + stopEventPropagation = true, + rootRef, + disabledIndices, + modifierKeys, + highlightItemOnHover = false, + tag = 'div', + ...elementProps + } = componentProps; + + const direction = useDirection(); + + const { + props: defaultProps, highlightedIndex, onHighlightedIndexChange, - highlightItemOnHover, + elementsRef, + onMapChange: onMapChangeUnwrapped, relayKeyboardEvent - }), - [highlightedIndex, onHighlightedIndexChange, highlightItemOnHover, relayKeyboardEvent] - ); - - return ( - - - elementsRef={elementsRef} - onMapChange={(newMap) => { - onMapChangeProp?.(newMap); - onMapChangeUnwrapped(newMap); - }} - > - {element} - - - ); + } = useCompositeRoot({ + itemSizes, + cols, + loopFocus, + dense, + orientation, + highlightedIndex: highlightedIndexProp, + onHighlightedIndexChange: onHighlightedIndexChangeProp, + rootRef, + stopEventPropagation, + enableHomeAndEndKeys, + direction, + disabledIndices, + modifierKeys + }); + + const element = useRenderElement(tag, componentProps, { + state, + ref: refs, + props: [defaultProps, ...props, elementProps], + customStyleHookMapping: stateAttributesMapping + }); + + const contextValue: CompositeRootContextValue = React.useMemo( + () => ({ + highlightedIndex, + onHighlightedIndexChange, + highlightItemOnHover, + relayKeyboardEvent + }), + [highlightedIndex, onHighlightedIndexChange, highlightItemOnHover, relayKeyboardEvent] + ); + + return ( + + + elementsRef={elementsRef} + onMapChange={(newMap) => { + onMapChangeProp?.(newMap); + onMapChangeUnwrapped(newMap); + }} + > + {element} + + + ); + }; } +export const CompositeRoot = createCompositeRoot>({ + CompositeRootContext, + CompositeList +}); + export type CompositeRootProps> = { props?: Array | (() => Record)>; state?: State; @@ -128,6 +144,7 @@ export type CompositeRootProps> = { highlightItemOnHover?: boolean; } & Pick, 'render' | 'className' | 'children'>; +// eslint-disable-next-line ts/no-redeclare export namespace CompositeRoot { export type Props> = CompositeRootProps< Metadata, 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 index 63951738..1d62a381 100644 --- a/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Composite/root/CompositeRootContext.ts @@ -13,20 +13,29 @@ export type CompositeRootContextValue = { relayKeyboardEvent: (event: React.KeyboardEvent) => void; }; -export const CompositeRootContext = React.createContext( - undefined -); +export function createCompositeRootContext() { + const CompositeRootContext = React.createContext( + undefined + ); -export function useCompositeRootContext(optional: true): CompositeRootContextValue | undefined; -export function useCompositeRootContext(optional?: false): CompositeRootContextValue; -export function useCompositeRootContext(optional = false) { - const context = React.use(CompositeRootContext); + function useCompositeRootContext(optional: true): CompositeRootContextValue | undefined; + function useCompositeRootContext(optional?: false): CompositeRootContextValue; + 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 .' - ); + if (context === undefined && !optional) { + throw new Error( + 'Headless UI: CompositeRootContext is missing. Composite parts must be placed within .' + ); + } + + return context; } - return context; + return { + CompositeRootContext, + useCompositeRootContext + } as const; } + +export const { CompositeRootContext, useCompositeRootContext } = createCompositeRootContext(); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrow.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrow.tsx index ec95a529..b35d8b5b 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrow.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrow.tsx @@ -3,10 +3,18 @@ import React from 'react'; import { useRenderElement } from '~@lib/hooks'; import { popupStateMapping } from '~@lib/popupStateMapping'; +import type { StateAttributesMapping } from '~@lib/getStyleHookProps'; import type { Align, Side } from '~@lib/hooks'; import type { HeadlessUIComponentProps } from '~@lib/types'; import { useTooltipPositionerContext } from '../positioner/TooltipPositionerContext'; +import { useTooltipRootContext } from '../root/TooltipRootContext'; +import { multipleActive } from '../utils/stateAttributes'; + +const stateAttributesMapping: StateAttributesMapping = { + ...popupStateMapping, + multipleActive +}; /** * Displays an element positioned against the tooltip anchor. @@ -32,22 +40,32 @@ export function TooltipArrow({ ref: forwardedRef, ...componentProps }: TooltipAr arrowStyles } = useTooltipPositionerContext(); + const store = useTooltipRootContext(); + + const multipleActive = store.useMultipleActive(); const state: TooltipArrow.State = React.useMemo( () => ({ open, side, align, - uncentered: arrowUncentered + uncentered: arrowUncentered, + multipleActive }), - [open, side, align, arrowUncentered] + [ + open, + side, + align, + arrowUncentered, + multipleActive + ] ); const element = useRenderElement('div', componentProps, { state, ref: [forwardedRef, arrowRef], props: [{ 'style': arrowStyles, 'aria-hidden': true }, elementProps], - customStyleHookMapping: popupStateMapping + customStyleHookMapping: stateAttributesMapping }); return element; @@ -59,6 +77,7 @@ export namespace TooltipArrow { side: Side; align: Align; uncentered: boolean; + multipleActive: boolean; }; export type Props = HeadlessUIComponentProps<'div', State> & { diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrowDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrowDataAttributes.ts index 5fab527f..072d47ec 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrowDataAttributes.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/arrow/TooltipArrowDataAttributes.ts @@ -1,10 +1,13 @@ import { CommonPopupDataAttributes } from '~@lib/popupStateMapping'; +import { MultipleActiveAttributes } from '../utils/stateAttributes'; + export enum TooltipArrowDataAttributes { open = CommonPopupDataAttributes.open, closed = CommonPopupDataAttributes.closed, anchorHidden = CommonPopupDataAttributes.anchorHidden, side = 'data-side', align = 'data-align', - uncentered = 'data-uncentered' + uncentered = 'data-uncentered', + multipleActive = MultipleActiveAttributes.multipleActive } diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/composite.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/composite.ts new file mode 100644 index 00000000..8f6dcc3e --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/composite.ts @@ -0,0 +1,20 @@ +import { createCompositeList, createUseCompositeListItem } from '../Composite'; +import { createCompositeListContext } from '../Composite/list/CompositeListContext'; + +import type { CompositeMetadata } from '../Composite'; +import type { CompositeListContextValue } from '../Composite/list/CompositeListContext'; + +export type CompositeTooltipMetadata = CompositeMetadata<{}>; + +export type CompositeTooltipListContextValue = CompositeListContextValue; + +export const { CompositeListContext: CompositeTooltipListContext, useCompositeListContext: useCompositeTooltipListContext } += createCompositeListContext(); + +export const CompositeTooltipList = createCompositeList({ + CompositeListContext: CompositeTooltipListContext +}); + +export const useCompositeTooltipListItem = createUseCompositeListItem({ + useCompositeListContext: useCompositeTooltipListContext +}); diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx index 53484d62..b3cd75d8 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultiple.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { CompositeTooltipList } from '../composite'; + import type { TooltipRoot } from '../root/TooltipRoot'; import { TooltipMultipleContext } from './TooltipMultipleContext'; @@ -37,6 +39,7 @@ import { TooltipMultipleStore } from './TooltipMultipleStore'; */ export function TooltipMultiple(props: TooltipMultiple.Props) { const { + open = false, defaultOpen = false, onOpenChange, disabled, @@ -45,12 +48,10 @@ export function TooltipMultiple(props: TooltipMultiple.Props) { children } = props; - const store = TooltipMultipleStore.useStore(defaultOpen); + const store = TooltipMultipleStore.useStore(open ?? defaultOpen); + const elementsRef = React.useRef<(HTMLElement | null)[]>([]); - // Set up callback - React.useEffect(() => { - store.context.onOpenChange = onOpenChange; - }, [store, onOpenChange]); + store.useContextCallback('onOpenChange', onOpenChange); const contextValue = React.useMemo( () => ({ @@ -63,13 +64,20 @@ export function TooltipMultiple(props: TooltipMultiple.Props) { ); return ( - - {children} - + + + {children} + + ); } export type TooltipMultipleProps = { + /** + * Whether the tooltips are open. + * @default false + */ + open?: boolean; /** * Whether the tooltips are initially open. * @default false diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts index 89e9fd8f..1594ea5d 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/multiple/TooltipMultipleStore.ts @@ -12,6 +12,10 @@ export type MultipleState = { * Shared open state for all tooltips in the group. */ open: boolean; + /** + * The index of the active tooltip in the group. + */ + activeIndex: number | null; }; export type MultipleContext = { @@ -35,7 +39,8 @@ export type MultipleContext = { }; const selectors = { - open: createSelector((state: MultipleState) => state.open) + open: createSelector((state: MultipleState) => state.open), + activeIndex: createSelector((state: MultipleState) => state.activeIndex) }; /** @@ -50,7 +55,7 @@ export class TooltipMultipleStore extends ReactStore< > { constructor(defaultOpen = false) { super( - { open: defaultOpen }, + { open: defaultOpen, activeIndex: null }, { stores: new Set(), originalSetOpenMap: new WeakMap(), @@ -83,31 +88,9 @@ export class TooltipMultipleStore extends ReactStore< // Update shared state this.set('open', nextOpen); - // Sync all registered stores - // for (const store of this.context.stores) { - // const originalSetOpen = this.context.originalSetOpenMap.get(store); - // if (!originalSetOpen) { - // continue; - // } - - // // Get the primary trigger for positioning (if set) - // const primaryTriggerId = store.select('primaryTriggerId'); - // const primaryTriggerElement = primaryTriggerId - // ? store.context.triggerElements.getById(primaryTriggerId) as HTMLElement | undefined - // : undefined; - - // // Create event details with proper trigger for positioning - // const storeDetails = createChangeEventDetails( - // details.reason as TooltipRoot.ChangeEventReason, - // details.event - // ) as TooltipRoot.ChangeEventDetails; - // storeDetails.preventUnmountOnClose = () => { - // store.set('preventUnmountingOnClose', true); - // }; - // storeDetails.trigger = primaryTriggerElement; - - // originalSetOpen(nextOpen, storeDetails); - // } + if (details.multipleItemIndex !== undefined) { + this.set('activeIndex', details.multipleItemIndex); + } }; /** diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx index a206d6d2..4e126b73 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopup.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useOpenChangeComplete } from '@flippo-ui/hooks'; +import { useIsoLayoutEffect, useOpenChangeComplete } from '@flippo-ui/hooks'; import type { TransitionStatus } from '@flippo-ui/hooks'; @@ -17,10 +17,12 @@ import type { HeadlessUIComponentProps } from '~@lib/types'; import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { useTooltipPositionerContext } from '../positioner/TooltipPositionerContext'; import { useTooltipRootContext } from '../root/TooltipRootContext'; +import { multipleActive } from '../utils/stateAttributes'; const stateAttributesMapping: StateAttributesMapping = { ...baseMapping, - ...transitionStatusMapping + ...transitionStatusMapping, + multipleActive }; export function TooltipPopup(componentProps: TooltipPopupProps) { @@ -89,7 +91,7 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { const closeDelay = store.useState('closeDelay'); // Register popup element with Multiple store for safePolygon tracking - React.useEffect(() => { + useIsoLayoutEffect(() => { if (!multipleContext || !popupElement) { return; } @@ -107,27 +109,34 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { closeDelay: effectiveCloseDelay }); + const multipleActive = store.useMultipleActive(); + + // Hide from screen readers when not active in multiple mode + const isHiddenFromScreenReader = multipleContext !== null && !multipleActive; + const state: TooltipPopup.State = React.useMemo( () => ({ open, side, align, instant: instantType, - transitionStatus + transitionStatus, + multipleActive }), [ open, side, align, instantType, - transitionStatus + transitionStatus, + multipleActive ] ); const element = useRenderElement('div', componentProps, { state, ref: [ref, store.context.popupRef, store.useStateSetter('popupElement')], - props: [popupProps, getDisabledMountTransitionStyles(transitionStatus), elementProps], + props: [popupProps, getDisabledMountTransitionStyles(transitionStatus), isHiddenFromScreenReader ? { 'aria-hidden': true } : undefined, elementProps], customStyleHookMapping: stateAttributesMapping }); @@ -135,6 +144,10 @@ export function TooltipPopup(componentProps: TooltipPopupProps) { } export type TooltipPopupState = { + /** + * Whether the tooltip is currently active in the multiple context. + */ + multipleActive: boolean; /** * Whether the tooltip is currently open. */ diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopupDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopupDataAttributes.ts index 8f3c53b7..5e721f05 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopupDataAttributes.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/popup/TooltipPopupDataAttributes.ts @@ -1,11 +1,14 @@ import { CommonPopupDataAttributes } from '~@lib/popupStateMapping'; -export enum TooltipArrowDataAttributes { +import { MultipleActiveAttributes } from '../utils/stateAttributes'; + +export enum TooltipPopupDataAttributes { open = CommonPopupDataAttributes.open, closed = CommonPopupDataAttributes.closed, startingStyle = CommonPopupDataAttributes.startingStyle, endingStyle = CommonPopupDataAttributes.endingStyle, anchorHidden = CommonPopupDataAttributes.anchorHidden, + multipleActive = MultipleActiveAttributes.multipleActive, side = 'data-side', align = 'data-align', instant = 'data-instant' diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx index 6eba7edc..e2f74d07 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/root/TooltipRoot.tsx @@ -7,6 +7,7 @@ import { import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; import { useImplicitActiveTrigger, useOpenStateTransitions } from '~@lib/popups'; import { REASONS } from '~@lib/reason'; +import { visuallyHidden } from '~@lib/visuallyHidden'; import { useClientPoint, useDismiss, @@ -18,6 +19,7 @@ import { import type { HeadlessUIChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; import type { PayloadChildRenderFunction } from '~@lib/popups'; +import { useCompositeTooltipListItem } from '../composite'; import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { TooltipStore } from '../store/TooltipStore'; @@ -43,6 +45,8 @@ export function TooltipRoot(props: TooltipRoot.Props) { children } = props; + const { ref: multipleItemRef, index: multipleItemIndex } = useCompositeTooltipListItem(); + // Check if inside TooltipMultiple - if so, ignore local open/defaultOpen const multipleContext = useTooltipMultipleContext(); const isInsideMultiple = multipleContext !== null; @@ -80,7 +84,9 @@ export function TooltipRoot(props: TooltipRoot.Props) { store.useSyncedValues({ trackCursorAxis, - disableHoverablePopup + disableHoverablePopup, + multipleItemIndex, + multipleItemRef }); const open = !disabled && openState; @@ -154,7 +160,22 @@ export function TooltipRoot(props: TooltipRoot.Props) { onOpenChange: store.setOpen }); - const focus = useFocus(floatingRootContext, { enabled: !disabled }); + // For Multiple: block blur close if focus moves to another element in the group + const shouldBlockBlurClose = React.useCallback( + (relatedTarget: Element | null) => { + if (!multipleContext || !relatedTarget) { + return false; + } + + return multipleContext.store.isElementInGroup(relatedTarget); + }, + [multipleContext] + ); + + const focus = useFocus(floatingRootContext, { + enabled: !disabled, + shouldBlockBlurClose: isInsideMultiple ? shouldBlockBlurClose : undefined + }); const dismiss = useDismiss(floatingRootContext, { enabled: !disabled, referencePress: true }); const clientPoint = useClientPoint(floatingRootContext, { enabled: !disabled && trackCursorAxis !== 'none', @@ -176,6 +197,8 @@ export function TooltipRoot(props: TooltipRoot.Props) { return ( + {/* Hidden marker for CompositeList registration inside Tooltip.Multiple */} + {isInsideMultiple && } {typeof children === 'function' ? children({ payload }) : children} ); @@ -280,6 +303,7 @@ export type TooltipRootChangeEventReason export type TooltipRootChangeEventDetails = HeadlessUIChangeEventDetails & { preventUnmountOnClose: () => void; + multipleItemIndex?: number | null; }; export namespace TooltipRoot { diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx index 18cd56d9..fb83f477 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/store/TooltipStore.tsx @@ -25,6 +25,8 @@ export type State = PopupStoreState & { disableHoverablePopup: boolean; openChangeReason: TooltipRoot.ChangeEventReason | null; closeDelay: number; + multipleItemRef: ((node: HTMLElement | null) => void) | null; + multipleItemIndex: number | null; }; export type Context = PopupStoreContext & { @@ -39,7 +41,9 @@ const selectors = { trackCursorAxis: createSelector((state: State) => state.trackCursorAxis), disableHoverablePopup: createSelector((state: State) => state.disableHoverablePopup), lastOpenChangeReason: createSelector((state: State) => state.openChangeReason), - closeDelay: createSelector((state: State) => state.closeDelay) + closeDelay: createSelector((state: State) => state.closeDelay), + multipleItemRef: createSelector((state: State) => state.multipleItemRef), + multipleItemIndex: createSelector((state: State) => state.multipleItemIndex) }; export class TooltipStore extends ReactStore< @@ -128,6 +132,16 @@ export class TooltipStore extends ReactStore< return isInsideMultiple ? (multipleOpenState ?? false) : localOpenState; }; + public useMultipleActive = () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const multipleContext = useTooltipMultipleContext(); + const multipleItemIndex = this.useState('multipleItemIndex'); + + const isInsideMultiple = multipleContext !== null; + + return isInsideMultiple ? multipleItemIndex === (multipleContext?.store.useState('activeIndex') ?? null) : false; + }; + public static useStore( externalStore: TooltipStore | undefined, initialState?: Partial> @@ -148,6 +162,8 @@ function createInitialState(): State { trackCursorAxis: 'none', disableHoverablePopup: false, openChangeReason: null, - closeDelay: 0 + closeDelay: 0, + multipleItemRef: null, + multipleItemIndex: null }; } diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx index 88eb48bb..ccf0e58a 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTrigger.tsx @@ -8,15 +8,22 @@ import { useTriggerDataForwarding } from '~@lib/popups'; import { triggerOpenStateMapping } from '~@lib/popupStateMapping'; import { safePolygon, useDelayGroup, useHoverReferenceInteraction } from '~@packages/floating-ui-react'; +import type { StateAttributesMapping } from '~@lib/getStyleHookProps'; import type { HeadlessUIComponentProps } from '~@lib/types'; import { multipleSafePolygon } from '../multiple/multipleSafePolygon'; import { useTooltipMultipleContext } from '../multiple/TooltipMultipleContext'; import { useTooltipProviderContext } from '../provider/TooltipProviderContext'; import { useTooltipRootContext } from '../root/TooltipRootContext'; +import { multipleActive } from '../utils/stateAttributes'; import type { TooltipHandle } from '../store/TooltipHandle'; +const stateAttributesMapping: StateAttributesMapping = { + ...triggerOpenStateMapping, + multipleActive +}; + export function TooltipTrigger( componentProps: TooltipTrigger.Props ) { @@ -49,6 +56,8 @@ export function TooltipTrigger( const floatingRootContext = store.useState('floatingRootContext'); const isOpenedByThisTrigger = store.useState('isOpenedByTrigger', thisTriggerId); + const multipleItemIndex = store.useState('multipleItemIndex'); + // Register this trigger as primary if marked useIsoLayoutEffect(() => { if (!primary || !thisTriggerId) { @@ -163,9 +172,36 @@ export function TooltipTrigger( isActiveTrigger: isTriggerActive }); + useIsoLayoutEffect(() => { + if (!triggerElement || !multipleContext || multipleItemIndex == null || multipleItemIndex < 0) + return; + + const onSetActive = () => { + multipleContext.store.set('activeIndex', multipleItemIndex); + }; + + const onSetInactive = () => { + multipleContext.store.set('activeIndex', null); + }; + + triggerElement.addEventListener('mouseenter', onSetActive); + triggerElement.addEventListener('mouseleave', onSetInactive); + triggerElement.addEventListener('focus', onSetActive); + triggerElement.addEventListener('blur', onSetInactive); + + return () => { + triggerElement.removeEventListener('mouseenter', onSetActive); + triggerElement.removeEventListener('mouseleave', onSetInactive); + triggerElement.removeEventListener('focus', onSetActive); + triggerElement.removeEventListener('blur', onSetInactive); + }; + }, [triggerElement, multipleItemIndex, store]); + + const multipleActive = store.useMultipleActive(); + const state: TooltipTrigger.State = React.useMemo( - () => ({ open: isOpenedByThisTrigger }), - [isOpenedByThisTrigger] + () => ({ open: isOpenedByThisTrigger, multipleActive }), + [isOpenedByThisTrigger, multipleActive] ); const rootTriggerProps = store.useState('triggerProps', isMountedByThisTrigger); @@ -173,8 +209,10 @@ export function TooltipTrigger( const element = useRenderElement('button', componentProps, { state, ref: [ref, registerTrigger, setTriggerElement], - props: [hoverProps, rootTriggerProps, { id: thisTriggerId }, elementProps], - customStyleHookMapping: triggerOpenStateMapping + props: [hoverProps, rootTriggerProps, { + id: thisTriggerId + }, elementProps], + customStyleHookMapping: stateAttributesMapping }); return element; @@ -185,6 +223,10 @@ export type TooltipTriggerState = { * Whether the tooltip is currently open. */ open: boolean; + /** + * Whether the tooltip is currently active in the multiple context. + */ + multipleActive: boolean; }; export type TooltipTriggerProps = { diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTriggerDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTriggerDataAttributes.ts index af4844db..8762747a 100644 --- a/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTriggerDataAttributes.ts +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/trigger/TooltipTriggerDataAttributes.ts @@ -1,5 +1,8 @@ import { CommonTriggerDataAttributes } from '~@lib/popupStateMapping'; +import { MultipleActiveAttributes } from '../utils/stateAttributes'; + export enum TooltipTriggerDataAttributes { - popupOpen = CommonTriggerDataAttributes.popupOpen + popupOpen = CommonTriggerDataAttributes.popupOpen, + multipleActive = MultipleActiveAttributes.multipleActive } diff --git a/packages/ui/uikit/headless/components/src/components/Tooltip/utils/stateAttributes.ts b/packages/ui/uikit/headless/components/src/components/Tooltip/utils/stateAttributes.ts new file mode 100644 index 00000000..0bc732f9 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Tooltip/utils/stateAttributes.ts @@ -0,0 +1,13 @@ +export enum MultipleActiveAttributes { + multipleActive = 'data-multiple-active' +} + +export function multipleActive(value: boolean) { + if (value) { + return { + [MultipleActiveAttributes.multipleActive]: '' + }; + } + + return null; +} diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingRootStore.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingRootStore.ts index 97bc7c4a..02e8b588 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingRootStore.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/components/FloatingRootStore.ts @@ -22,9 +22,9 @@ export type FloatingRootState = { floatingId: string | undefined; }; -export type FloatingRootStoreContext = { +export type FloatingRootStoreContext = { onOpenChange: - | ((open: boolean, eventDetails: HeadlessUIChangeEventDetails) => void) + | ((open: boolean, eventDetails: HeadlessUIChangeEventDetails) => void) | undefined; readonly dataRef: React.RefObject; readonly events: FloatingEvents; @@ -43,7 +43,7 @@ const selectors = { floatingId: createSelector((state: FloatingRootState) => state.floatingId) }; -type FloatingRootStoreOptions = { +type FloatingRootStoreOptions = { open: boolean; referenceElement: ReferenceType | null; floatingElement: HTMLElement | null; @@ -52,16 +52,16 @@ type FloatingRootStoreOptions = { nested: boolean; noEmit: boolean; onOpenChange: - | ((open: boolean, eventDetails: HeadlessUIChangeEventDetails) => void) + | ((open: boolean, eventDetails: HeadlessUIChangeEventDetails) => void) | undefined; }; -export class FloatingRootStore extends ReactStore< +export class FloatingRootStore extends ReactStore< Readonly, - FloatingRootStoreContext, + FloatingRootStoreContext, typeof selectors > { - constructor(options: FloatingRootStoreOptions) { + constructor(options: FloatingRootStoreOptions) { const { nested, noEmit, @@ -94,7 +94,7 @@ export class FloatingRootStore extends ReactStor * @param newOpen The new open state. * @param eventDetails Details about the event that triggered the open state change. */ - setOpen = (newOpen: boolean, eventDetails: HeadlessUIChangeEventDetails) => { + setOpen = (newOpen: boolean, eventDetails: HeadlessUIChangeEventDetails) => { this.context.dataRef.current.openEvent = newOpen ? eventDetails.event : undefined; if (!this.context.noEmit) { const details: FloatingUIOpenChangeDetails = { 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 index ba6296da..7497afde 100644 --- 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 @@ -36,6 +36,12 @@ export type UseFocusProps = { * @default true */ visibleOnly?: boolean; + /** + * Additional check to determine if blur should be blocked. + * Return true to prevent closing on blur. + * Useful for grouped elements like Tooltip.Multiple. + */ + shouldBlockBlurClose?: (relatedTarget: Element | null) => boolean; }; /** @@ -50,7 +56,7 @@ export function useFocus( const store = 'rootStore' in context ? context.rootStore : context; const { events, dataRef } = store.context; - const { enabled = true, visibleOnly = true } = props; + const { enabled = true, visibleOnly = true, shouldBlockBlurClose } = props; const blockFocusRef = React.useRef(false); const timeout = useTimeout(); @@ -197,11 +203,22 @@ export function useFocus( return; } + // Additional check for grouped elements (e.g., Tooltip.Multiple) + if (shouldBlockBlurClose?.(event.relatedTarget)) { + return; + } + store.setOpen(false, createChangeEventDetails(REASONS.triggerFocus, nativeEvent)); }); } }), - [dataRef, store, visibleOnly, timeout] + [ + dataRef, + store, + visibleOnly, + timeout, + shouldBlockBlurClose + ] ); return React.useMemo( diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useSyncedFloatingRootContext.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useSyncedFloatingRootContext.ts index d5ecb009..267e710a 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useSyncedFloatingRootContext.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useSyncedFloatingRootContext.ts @@ -24,7 +24,7 @@ export type UseSyncedFloatingRootContextOptions< * Whether the Popup element is passed to Floating UI as the floating element instead of the default Positioner. */ treatPopupAsFloatingElement?: boolean; - onOpenChange: (open: boolean, eventDetails: HeadlessUIChangeEventDetails) => void; + onOpenChange: (open: boolean, eventDetails: HeadlessUIChangeEventDetails & Record) => void; }; /** diff --git a/packages/ui/uikit/headless/components/vite.config.ts b/packages/ui/uikit/headless/components/vite.config.ts index fae02f78..b82b7cfc 100644 --- a/packages/ui/uikit/headless/components/vite.config.ts +++ b/packages/ui/uikit/headless/components/vite.config.ts @@ -22,7 +22,8 @@ const entryPoints: Record = { ...componentEntries, // Additional exports 'lib/merge': path.resolve('src/lib/merge.ts'), - 'lib/hooks/useDirection': path.resolve('src/lib/hooks/useDirection.ts') + 'lib/hooks/useDirection': path.resolve('src/lib/hooks/useDirection.ts'), + 'lib/createHeadlessUIEventDetails': path.resolve('src/lib/createHeadlessUIEventDetails.ts') }; type VitestConfigExport = { diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index b0df4167..1dc146de 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -14,6 +14,7 @@ export * from './useIsFirstRender'; export * from './useIsoLayoutEffect'; export * from './useLatestRef'; export * from './useLazyRef'; +export * from './useMediaQuery'; export * from './useMergedRef'; export * from './useOnFirstRender'; export * from './useOnMount'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/index.ts new file mode 100644 index 00000000..4248015e --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/index.ts @@ -0,0 +1 @@ +export * from './useMediaQuery'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts new file mode 100644 index 00000000..af2cd6fa --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts @@ -0,0 +1,95 @@ +import * as React from 'react'; + +import { useSyncExternalStore } from 'use-sync-external-store/shim'; + +export function useMediaQuery(query: string, options: useMediaQuery.Options): boolean { + // Wait for jsdom to support the match media feature. + // All the browsers Base UI support have this built-in. + // This defensive check is here for simplicity. + // Most of the time, the match media logic isn't central to people tests. + const supportMatchMedia + = typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined'; + + query = query.replace(/^@media ?/m, ''); + + const { + defaultMatches = false, + matchMedia = supportMatchMedia ? window.matchMedia : null, + ssrMatchMedia = null, + noSsr = false + } = options; + + const getDefaultSnapshot = React.useCallback(() => defaultMatches, [defaultMatches]); + + const getServerSnapshot = React.useMemo(() => { + if (noSsr && matchMedia) { + return () => matchMedia(query).matches; + } + + if (ssrMatchMedia !== null) { + const { matches } = ssrMatchMedia(query); + return () => matches; + } + return getDefaultSnapshot; + }, [ + getDefaultSnapshot, + query, + ssrMatchMedia, + noSsr, + matchMedia + ]); + + const [getSnapshot, subscribe] = React.useMemo(() => { + if (matchMedia === null) { + return [getDefaultSnapshot, () => () => {}]; + } + + const mediaQueryList = matchMedia(query); + + return [() => mediaQueryList.matches, (notify: () => void) => { + mediaQueryList.addEventListener('change', notify); + return () => { + mediaQueryList.removeEventListener('change', notify); + }; + }]; + }, [getDefaultSnapshot, matchMedia, query]); + + const match = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useDebugValue({ query, match }); + } + + return match; +} + +export type UseMediaQueryOptions = { + /** + * As `window.matchMedia()` is unavailable on the server, + * it returns a default matches during the first mount. + * @default false + */ + defaultMatches?: boolean; + /** + * You can provide your own implementation of matchMedia. + * This can be used for handling an iframe content window. + */ + matchMedia?: typeof window.matchMedia; + /** + * To perform the server-side hydration, the hook needs to render twice. + * A first time with `defaultMatches`, the value of the server, and a second time with the resolved value. + * This double pass rendering cycle comes with a drawback: it's slower. + * You can set this option to `true` if you use the returned value **only** client-side. + * @default false + */ + noSsr?: boolean; + /** + * You can provide your own implementation of `matchMedia`, it's used when rendering server-side. + */ + ssrMatchMedia?: (query: string) => { matches: boolean }; +}; + +export namespace useMediaQuery { + export type Options = UseMediaQueryOptions; +} From 217822e547b0625548735af59e4669068dc87b26 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 21 Dec 2025 13:21:59 +0300 Subject: [PATCH 04/16] feat(headless/components): add NoSsr component for server-side rendering control and enhance hooks with useSsr --- .../ui/uikit/headless/components/package.json | 10 ++++ .../components/src/components/NoSsr/NoSsr.tsx | 52 ++++++++++++++++++ .../components/src/components/NoSsr/index.ts | 1 + .../components/src/components/index.ts | 1 + packages/ui/uikit/headless/hooks/package.json | 10 ++++ .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../headless/hooks/src/hooks/useSsr/index.ts | 1 + .../headless/hooks/src/hooks/useSsr/useSsr.ts | 53 +++++++++++++++++++ 8 files changed, 129 insertions(+) create mode 100644 packages/ui/uikit/headless/components/src/components/NoSsr/NoSsr.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/NoSsr/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useSsr/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useSsr/useSsr.ts diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json index 273f69e6..f3cc1c17 100644 --- a/packages/ui/uikit/headless/components/package.json +++ b/packages/ui/uikit/headless/components/package.json @@ -130,6 +130,11 @@ "import": "./dist/components/NavigationMenu/index.es.js", "require": "./dist/components/NavigationMenu/index.cjs.js" }, + "./no-ssr": { + "types": "./dist/components/NoSsr/index.d.ts", + "import": "./dist/components/NoSsr/index.es.js", + "require": "./dist/components/NoSsr/index.cjs.js" + }, "./number-field": { "types": "./dist/components/NumberField/index.d.ts", "import": "./dist/components/NumberField/index.es.js", @@ -254,6 +259,11 @@ "types": "./dist/lib/hooks/useDirection.d.ts", "import": "./dist/lib/hooks/useDirection.es.js", "require": "./dist/lib/hooks/useDirection.cjs.js" + }, + "./createHeadlessUIEventDetails": { + "types": "./dist/lib/createHeadlessUIEventDetails.d.ts", + "import": "./dist/lib/createHeadlessUIEventDetails.es.js", + "require": "./dist/lib/createHeadlessUIEventDetails.cjs.js" } }, "main": "./dist/index.cjs.js", diff --git a/packages/ui/uikit/headless/components/src/components/NoSsr/NoSsr.tsx b/packages/ui/uikit/headless/components/src/components/NoSsr/NoSsr.tsx new file mode 100644 index 00000000..b2dd764b --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/NoSsr/NoSsr.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '@flippo-ui/hooks/use-iso-layout-effect'; + +/** + * NoSsr purposely removes components from the subject of Server Side Rendering (SSR). + * + * This component can be useful in a variety of situations: + * + * Escape hatch for broken dependencies not supporting SSR. + * Improve the time-to-first paint on the client by only rendering above the fold. + * Reduce the rendering time on the server. + * Under too heavy server load, you can turn on service degradation. + */ +export function NoSsr(props: NoSsr.Props) { + const { children, defer = false, fallback = null } = props; + const [mountedState, setMountedState] = React.useState(false); + + useIsoLayoutEffect(() => { + if (!defer) { + setMountedState(true); + } + }, [defer]); + + React.useEffect(() => { + if (defer) { + setMountedState(true); + } + }, [defer]); + + return (mountedState ? children : fallback); +} + +export namespace NoSsr { + export type Props = { + /** + * You can wrap a node. + */ + children?: React.ReactNode; + /** + * If `true`, the component will not only prevent server-side rendering. + * It will also defer the rendering of the children into a different screen frame. + * @default false + */ + defer?: boolean; + /** + * The fallback content to display. + * @default null + */ + fallback?: React.ReactNode; + }; +} diff --git a/packages/ui/uikit/headless/components/src/components/NoSsr/index.ts b/packages/ui/uikit/headless/components/src/components/NoSsr/index.ts new file mode 100644 index 00000000..fc4ef5e8 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/NoSsr/index.ts @@ -0,0 +1 @@ +export * from './NoSsr'; diff --git a/packages/ui/uikit/headless/components/src/components/index.ts b/packages/ui/uikit/headless/components/src/components/index.ts index 6726d8b3..0e017140 100644 --- a/packages/ui/uikit/headless/components/src/components/index.ts +++ b/packages/ui/uikit/headless/components/src/components/index.ts @@ -18,6 +18,7 @@ export * from './Menu'; export * from './Menubar'; export * from './Meter'; export * from './NavigationMenu'; +export * from './NoSsr'; export * from './NumberField'; export * from './PinInput'; export * from './Popover'; diff --git a/packages/ui/uikit/headless/hooks/package.json b/packages/ui/uikit/headless/hooks/package.json index b913959e..8a5ca3e6 100644 --- a/packages/ui/uikit/headless/hooks/package.json +++ b/packages/ui/uikit/headless/hooks/package.json @@ -95,6 +95,11 @@ "import": "./dist/hooks/useLazyRef/index.es.js", "require": "./dist/hooks/useLazyRef/index.cjs.js" }, + "./use-media-query": { + "types": "./dist/hooks/useMediaQuery/index.d.ts", + "import": "./dist/hooks/useMediaQuery/index.es.js", + "require": "./dist/hooks/useMediaQuery/index.cjs.js" + }, "./use-merged-ref": { "types": "./dist/hooks/useMergedRef/index.d.ts", "import": "./dist/hooks/useMergedRef/index.es.js", @@ -130,6 +135,11 @@ "import": "./dist/hooks/useScrollLock/index.es.js", "require": "./dist/hooks/useScrollLock/index.cjs.js" }, + "./use-ssr": { + "types": "./dist/hooks/useSsr/index.d.ts", + "import": "./dist/hooks/useSsr/index.es.js", + "require": "./dist/hooks/useSsr/index.cjs.js" + }, "./use-stable-callback": { "types": "./dist/hooks/useStableCallback/index.d.ts", "import": "./dist/hooks/useStableCallback/index.es.js", diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 1dc146de..867b4b8d 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -22,6 +22,7 @@ export * from './useOpenChangeComplete'; export * from './useOpenInteractionType'; export * from './usePreviousValue'; export * from './useScrollLock'; +export * from './useSsr'; export * from './useStatusTransition'; export * from './useStore'; export * from './useTimeout'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useSsr/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useSsr/index.ts new file mode 100644 index 00000000..549b8b1f --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useSsr/index.ts @@ -0,0 +1 @@ +export * from './useSsr'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useSsr/useSsr.ts b/packages/ui/uikit/headless/hooks/src/hooks/useSsr/useSsr.ts new file mode 100644 index 00000000..33787c3f --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useSsr/useSsr.ts @@ -0,0 +1,53 @@ +type UseSSRReturn = { + isBrowser: boolean; + isServer: boolean; + isNative: boolean; + device: Device; + canUseWorkers: boolean; + canUseEventListeners: boolean; + canUseViewport: boolean; +}; + +export enum Device { + Browser = 'browser', + Server = 'server', + Native = 'native' +} + +const { Browser, Server, Native } = Device; + +const canUseDOM: boolean = !!( + typeof window !== 'undefined' + && window.document + && window.document.createElement +); + +const canUseNative: boolean = typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; + +const device = canUseNative ? Native : canUseDOM ? Browser : Server; + +const SSRObject = { + isBrowser: device === Browser, + isServer: device === Server, + isNative: device === Native, + device, + canUseWorkers: typeof Worker !== 'undefined', + canUseEventListeners: device === Browser && !!window.addEventListener, + canUseViewport: device === Browser && !!window.screen +}; + +// TODO: instead of this, do a polyfill for `Object.assign` https://www.npmjs.com/package/es6-object-assign +const assign = (...args: any[]) => args.reduce((acc, obj) => ({ ...acc, ...obj }), {}); +const values = (obj: any) => Object.keys(obj).map((key) => obj[key]); +const toArrayObject = (): UseSSRReturn => assign((values(SSRObject), SSRObject)); + +let useSSRObject = toArrayObject(); + +export function weAreServer() { + SSRObject.isServer = true; + useSSRObject = toArrayObject(); +} + +export function useSSR(): UseSSRReturn { + return useSSRObject; +} From 251b0a46a06584f7428e6f1c446631b30180120e Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 00:07:20 +0300 Subject: [PATCH 05/16] feat(headless/components): add Marquee component with root and track subcomponents, styles, and stories --- .../src/components/Marquee/index.parts.ts | 2 + .../src/components/Marquee/index.ts | 2 + .../Marquee/story/Marquee.stories.tsx | 99 +++++++ .../Marquee/ui/root/MarqueeRoot.module.scss | 23 ++ .../Marquee/ui/root/MarqueeRoot.tsx | 16 + .../Marquee/ui/track/MarqueeTrack.module.scss | 38 +++ .../Marquee/ui/track/MarqueeTrack.tsx | 16 + .../ui/uikit/flippo/components/src/index.ts | 1 + .../ui/uikit/headless/components/package.json | 5 + .../src/components/Marquee/index.parts.ts | 2 + .../src/components/Marquee/index.ts | 1 + .../components/Marquee/root/MarqueeRoot.tsx | 274 ++++++++++++++++++ .../Marquee/root/MarqueeRootContext.ts | 39 +++ .../Marquee/root/MarqueeRootCssVars.ts | 11 + .../components/Marquee/track/MarqueeTrack.tsx | 117 ++++++++ .../components/src/components/index.ts | 1 + 16 files changed, 647 insertions(+) create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/index.parts.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/index.parts.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRoot.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootContext.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootCssVars.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Marquee/track/MarqueeTrack.tsx diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/Marquee/index.parts.ts new file mode 100644 index 00000000..c7ba0435 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/index.parts.ts @@ -0,0 +1,2 @@ +export { MarqueeRoot as Root } from './ui/root/MarqueeRoot'; +export { MarqueeTrack as Track } from './ui/track/MarqueeTrack'; diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/index.ts b/packages/ui/uikit/flippo/components/src/components/Marquee/index.ts new file mode 100644 index 00000000..c357998b --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/index.ts @@ -0,0 +1,2 @@ +export * as Marquee from './index.parts'; + diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx new file mode 100644 index 00000000..600ad2c7 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx @@ -0,0 +1,99 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { Marquee } from '../index'; + +const meta = { + title: 'Components/Marquee', + component: Marquee.Root, + parameters: { + layout: 'padded' + }, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + {'🚀'} + {'⭐'} + {'🎉'} + + + ) +}; + +export const NotAutoFill: Story = { + render: () => ( + + + {'Item 1'} + {'Item 2'} + {'Item 3'} + {'Item 4'} + {'Item 5'} + + + ) +}; + +export const Reverse: Story = { + render: () => ( + + + {'Item 1'} + {'Item 2'} + {'Item 3'} + + + ) +}; + +export const Vertical: Story = { + render: () => ( +
+ + +
{'Row 1'}
+
{'Row 2'}
+
{'Row 3'}
+
{'Row 4'}
+
+
+
+ ) +}; + +export const PauseOnHover: Story = { + render: () => ( + + + {'Hover to pause'} + {'Move away to resume'} + + + ) +}; + +export const CustomSpeed: Story = { + render: () => ( +
+ + + {'Slow (20px/s)'} + + + + + {'Fast (100px/s)'} + + +
+ ) +}; diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.module.scss b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.module.scss new file mode 100644 index 00000000..bf3947bb --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.module.scss @@ -0,0 +1,23 @@ +@use 'mixins/_common.scss' as common; + +.MarqueeRoot { + @include common.reset-appearance; + + // Horizontal: stretch to full width + // Vertical: fit to content width + width: 100%; + + &[data-orientation='vertical'] { + width: fit-content; + } + + // Pause on hover + &:hover div { + --marquee-play: var(--marquee-pause-on-hover); + } + + // Pause on click + &:active div { + --marquee-play: var(--marquee-pause-on-click); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.tsx b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.tsx new file mode 100644 index 00000000..d712b64d --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/root/MarqueeRoot.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Marquee as MarqueeHeadless } from '@flippo-ui/headless-components/marquee'; +import { cx } from 'class-variance-authority'; + +import styles from './MarqueeRoot.module.scss'; + +export function MarqueeRoot(props: MarqueeRoot.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace MarqueeRoot { + export type Props = MarqueeHeadless.Root.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.module.scss b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.module.scss new file mode 100644 index 00000000..4f7bfafc --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.module.scss @@ -0,0 +1,38 @@ +@use 'mixins/_common.scss' as common; + +@keyframes marquee-scroll { + from { + transform: translateX(0) translateY(0); + } + + to { + transform: translateX(calc(-100% * var(--marquee-orientation-x, 1))) + translateY(calc(-100% * var(--marquee-orientation-y, 0))); + } +} + +.MarqueeTrack { + @include common.reset-appearance; + + // Sizing - ensures proper animation calculation (horizontal only) + // For vertical mode, tracks stack naturally without forced dimensions + min-width: var(--marquee-min-width, auto); + + // Animation properties controlled by CSS variables from Root + animation-name: marquee-scroll; + animation-timing-function: linear; + animation-play-state: var(--marquee-play, running); + animation-direction: var(--marquee-direction, normal); + animation-duration: var(--marquee-duration, 10s); + animation-delay: var(--marquee-delay, 0s); + animation-iteration-count: var(--marquee-iteration-count, infinite); + + // Orientation-based translation + --marquee-orientation-x: 1; + --marquee-orientation-y: 0; + + [data-orientation='vertical'] & { + --marquee-orientation-x: 0; + --marquee-orientation-y: 1; + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.tsx b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.tsx new file mode 100644 index 00000000..e2ca5f09 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/ui/track/MarqueeTrack.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Marquee as MarqueeHeadless } from '@flippo-ui/headless-components/marquee'; +import { cx } from 'class-variance-authority'; + +import styles from './MarqueeTrack.module.scss'; + +export function MarqueeTrack(props: MarqueeTrack.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace MarqueeTrack { + export type Props = MarqueeHeadless.Track.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/index.ts b/packages/ui/uikit/flippo/components/src/index.ts index 26374efd..3633da97 100644 --- a/packages/ui/uikit/flippo/components/src/index.ts +++ b/packages/ui/uikit/flippo/components/src/index.ts @@ -12,6 +12,7 @@ export * as Fieldset from './components/Fieldset'; export * as Form from './components/Form'; export * as Input from './components/Input'; export * as Link from './components/Link'; +export * as Marquee from './components/Marquee'; export * as Menu from './components/Menu'; export * as Menubar from './components/Menubar'; export * as Meter from './components/Meter'; diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json index f3cc1c17..113eed3b 100644 --- a/packages/ui/uikit/headless/components/package.json +++ b/packages/ui/uikit/headless/components/package.json @@ -110,6 +110,11 @@ "import": "./dist/components/List/index.es.js", "require": "./dist/components/List/index.cjs.js" }, + "./marquee": { + "types": "./dist/components/Marquee/index.d.ts", + "import": "./dist/components/Marquee/index.es.js", + "require": "./dist/components/Marquee/index.cjs.js" + }, "./menu": { "types": "./dist/components/Menu/index.d.ts", "import": "./dist/components/Menu/index.es.js", diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/index.parts.ts b/packages/ui/uikit/headless/components/src/components/Marquee/index.parts.ts new file mode 100644 index 00000000..ae99d3bc --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/index.parts.ts @@ -0,0 +1,2 @@ +export { MarqueeRoot as Root } from './root/MarqueeRoot'; +export { MarqueeTrack as Track } from './track/MarqueeTrack'; diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/index.ts b/packages/ui/uikit/headless/components/src/components/Marquee/index.ts new file mode 100644 index 00000000..0c6f21fa --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/index.ts @@ -0,0 +1 @@ +export * as Marquee from './index.parts'; diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRoot.tsx b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRoot.tsx new file mode 100644 index 00000000..2a2eb5f0 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRoot.tsx @@ -0,0 +1,274 @@ +import React from 'react'; + +import { useIsoLayoutEffect, useOnMount } from '@flippo-ui/hooks'; + +import { useRenderElement } from '~@lib/hooks'; + +import type { HeadlessUIComponentProps, Orientation } from '~@lib/types'; + +import { MarqueeRootContext } from './MarqueeRootContext'; +import { MarqueeRootCssVars } from './MarqueeRootCssVars'; + +import type { MarqueeRootContextValue } from './MarqueeRootContext'; + +export function MarqueeRoot( + componentProps: MarqueeRoot.Props +) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + autoFill = true, + play = true, + pauseOnHover = true, + pauseOnClick = false, + orientation = 'horizontal', + direction = 'normal', + speed = 50, + delay = 0, + loop = 0, + onFinish, + onCycleComplete, + onMount, + children, + ref, + ...elementProps + } = componentProps; + + const vertical = orientation === 'vertical'; + + // Refs + const rootRef = React.useRef(null); + const contentRef = React.useRef(null); + + // State + const [containerSize, setContainerSize] = React.useState(0); + const [marqueeSize, setMarqueeSize] = React.useState(0); + const [multiplier, setMultiplier] = React.useState(1); + const [isMounted, setIsMounted] = React.useState(false); + + // Calculate size of container and marquee content + const calculateSize = React.useCallback(() => { + if (!contentRef.current || !rootRef.current) { + return; + } + + const containerRect = rootRef.current.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + + const newContainerSize = vertical ? containerRect.height : containerRect.width; + const newMarqueeSize = vertical ? contentRect.height : contentRect.width; + + if (autoFill && newContainerSize && newMarqueeSize) { + setMultiplier( + newMarqueeSize < newContainerSize + ? Math.ceil(newContainerSize / newMarqueeSize) + : 1 + ); + } + else { + setMultiplier(1); + } + + setContainerSize(newContainerSize); + setMarqueeSize(newMarqueeSize); + }, [autoFill, vertical]); + + // Calculate size on mount and resize + useIsoLayoutEffect(() => { + if (!isMounted || !contentRef.current || !rootRef.current) { + return; + } + + calculateSize(); + + const resizeObserver = new ResizeObserver(calculateSize); + resizeObserver.observe(rootRef.current); + resizeObserver.observe(contentRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [calculateSize, isMounted]); + + // Recalculate when children change + useIsoLayoutEffect(() => { + calculateSize(); + }, [calculateSize, children]); + + useIsoLayoutEffect(() => { + setIsMounted(true); + }, []); + + useOnMount(() => onMount?.()); + + // Animation duration calculation + const duration = React.useMemo(() => { + if (autoFill) { + return (marqueeSize * multiplier) / speed; + } + return marqueeSize < containerSize + ? containerSize / speed + : marqueeSize / speed; + }, [ + autoFill, + containerSize, + marqueeSize, + multiplier, + speed + ]); + + // Container styles - functional styles for marquee behavior + const containerStyle = React.useMemo( + () => ({ + // Functional styles (required for marquee to work) + overflow: 'hidden', + display: 'flex', + flexDirection: vertical ? 'column' : 'row', + position: 'relative', + + // CSS variables for animation control + [MarqueeRootCssVars.PauseOnHover]: !play || pauseOnHover ? 'paused' : 'running', + [MarqueeRootCssVars.PauseOnClick]: + !play || (pauseOnHover && !pauseOnClick) || pauseOnClick ? 'paused' : 'running', + [MarqueeRootCssVars.Play]: play ? 'running' : 'paused', + [MarqueeRootCssVars.Direction]: direction, + [MarqueeRootCssVars.Orientation]: orientation, + [MarqueeRootCssVars.Duration]: `${duration}s`, + [MarqueeRootCssVars.Delay]: `${delay}s`, + [MarqueeRootCssVars.IterationCount]: loop ? `${loop}` : 'infinite', + [MarqueeRootCssVars.MinWidth]: vertical ? undefined : (autoFill ? 'auto' : '100%') + }), + [ + play, + pauseOnHover, + pauseOnClick, + direction, + orientation, + duration, + delay, + loop, + autoFill, + vertical + ] + ); + + const state: MarqueeRoot.State = React.useMemo( + () => ({ + playing: play, + orientation, + direction + }), + [play, orientation, direction] + ); + + const element = useRenderElement('div', componentProps, { + enabled: isMounted, + state, + ref: [ref, rootRef], + props: [{ style: containerStyle, children }, elementProps] + }); + + const contextValue = React.useMemo( + () => ({ + contentRef, + multiplier, + orientation, + onAnimationIteration: onCycleComplete, + onAnimationEnd: onFinish + }), + [multiplier, orientation, onCycleComplete, onFinish] + ); + + if (!isMounted) { + return null; + } + + return ( + + {element} + + ); +} + +export namespace MarqueeRoot { + export type Direction = 'normal' | 'reverse'; + + export type State = { + /** + * Whether the marquee is currently playing. + */ + playing: boolean; + /** + * The orientation of the marquee (horizontal or vertical). + */ + orientation: Orientation; + /** + * The animation direction (normal or reverse). + */ + direction: Direction; + }; + + export type Props = { + /** + * Whether to automatically fill blank space in the marquee with copies of the children. + * @default false + */ + autoFill?: boolean; + /** + * Whether to play or pause the marquee. + * @default true + */ + play?: boolean; + /** + * Whether to pause the marquee when hovered. + * @default false + */ + pauseOnHover?: boolean; + /** + * Whether to pause the marquee when clicked. + * @default false + */ + pauseOnClick?: boolean; + /** + * The orientation of the marquee. + * @default "horizontal" + */ + orientation?: Orientation; + /** + * The animation direction. + * - `normal`: left-to-right (horizontal) or top-to-bottom (vertical) + * - `reverse`: right-to-left (horizontal) or bottom-to-top (vertical) + * @default "normal" + */ + direction?: Direction; + /** + * Speed calculated as pixels/second. + * @default 50 + */ + speed?: number; + /** + * Duration to delay the animation after render, in seconds. + * @default 0 + */ + delay?: number; + /** + * The number of times the marquee should loop, 0 is equivalent to infinite. + * @default 0 + */ + loop?: number; + /** + * Callback for when the marquee finishes scrolling and stops. Only calls if loop is non-zero. + */ + onFinish?: () => void; + /** + * Callback for when the marquee finishes a loop. + */ + onCycleComplete?: () => void; + /** + * Callback invoked once the marquee has finished mounting. + */ + onMount?: () => void; + } & HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootContext.ts b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootContext.ts new file mode 100644 index 00000000..205d6ac0 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootContext.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +import type { Orientation } from '~@lib/types'; + +export type MarqueeRootContextValue = { + /** + * Ref to measure the initial content size. + */ + contentRef: React.RefObject; + /** + * Number of times to multiply the content. + */ + multiplier: number; + /** + * The orientation of the marquee (horizontal or vertical). + */ + orientation: Orientation; + /** + * Callback for animation iteration. + */ + onAnimationIteration?: () => void; + /** + * Callback for animation end. + */ + onAnimationEnd?: () => void; +}; + +export const MarqueeRootContext = React.createContext(undefined); + +export function useMarqueeRootContext(): MarqueeRootContextValue { + const context = React.use(MarqueeRootContext); + if (context === undefined) { + throw new Error( + 'Headless UI: MarqueeRootContext is missing. Marquee parts must be placed within .' + ); + } + + return context; +} diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootCssVars.ts b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootCssVars.ts new file mode 100644 index 00000000..058e7446 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/root/MarqueeRootCssVars.ts @@ -0,0 +1,11 @@ +export enum MarqueeRootCssVars { + PauseOnHover = '--marquee-pause-on-hover', + PauseOnClick = '--marquee-pause-on-click', + Play = '--marquee-play', + Direction = '--marquee-direction', + Orientation = '--marquee-orientation', + Duration = '--marquee-duration', + Delay = '--marquee-delay', + IterationCount = '--marquee-iteration-count', + MinWidth = '--marquee-min-width' +} diff --git a/packages/ui/uikit/headless/components/src/components/Marquee/track/MarqueeTrack.tsx b/packages/ui/uikit/headless/components/src/components/Marquee/track/MarqueeTrack.tsx new file mode 100644 index 00000000..ad84784e --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Marquee/track/MarqueeTrack.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import { useRenderElement } from '~@lib/hooks'; +import { mergeProps } from '~@lib/merge'; + +import type { HeadlessUIComponentProps } from '~@lib/types'; + +import { useMarqueeRootContext } from '../root/MarqueeRootContext'; + +/** + * Animated track for marquee content that handles the duplication for seamless scrolling. + * Renders two marquee tracks for infinite scroll effect. + * Must be placed within ``. + */ +export function MarqueeTrack(componentProps: MarqueeTrack.Props) { + const { + /* eslint-disable unused-imports/no-unused-vars */ + className, + render, + /* eslint-enable unused-imports/no-unused-vars */ + ref, + children: childrenProp, + ...elementProps + } = componentProps; + + const { + contentRef, + multiplier, + orientation, + onAnimationIteration, + onAnimationEnd + } = useMarqueeRootContext(); + + const vertical = orientation === 'vertical'; + + // Functional styles based on orientation + const trackStyle = React.useMemo( + () => ({ + display: 'flex', + flexDirection: vertical ? 'column' : 'row', + alignItems: vertical ? undefined : 'center', + flex: '0 0 auto' + }), + [vertical] + ); + + // Hidden container for size measurement - removed from document flow + // but still measurable via getBoundingClientRect() + const measureContainerStyle = React.useMemo( + () => ({ + display: 'flex', + flexDirection: vertical ? 'column' : 'row', + alignItems: vertical ? undefined : 'center', + flex: '0 0 auto', + // Remove from document flow but keep measurable + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + // Prevent affecting parent size + top: 0, + left: 0 + }), + [vertical] + ); + + // Multiply children for seamless scrolling + const multiplyChildren = React.useCallback( + (count: number) => { + const length = Number.isFinite(count) && count >= 0 ? count : 0; + return Array.from({ length }, (_, i) => ( + + {React.Children.map(childrenProp, (child) => child)} + + )); + }, + [childrenProp] + ); + + const children = React.useMemo(() => ( + <> + {multiplyChildren(multiplier)} + + ), [multiplier, multiplyChildren]); + + const marqueeProps = React.useMemo(() => mergeProps({ + style: trackStyle + }, elementProps), [trackStyle, elementProps]); + + const element = useRenderElement('div', componentProps, { + ref, + props: [{ + children, + onAnimationIteration, + onAnimationEnd + + }, marqueeProps, elementProps] + }); + + return ( + <> + {/* Hidden container for size measurement - not visible, doesn't affect layout */} +
+ {childrenProp} +
+ {element} +
+ {children} +
+ + ); +} + +export namespace MarqueeTrack { + export type State = {}; + + export type Props = HeadlessUIComponentProps<'div', State>; +} diff --git a/packages/ui/uikit/headless/components/src/components/index.ts b/packages/ui/uikit/headless/components/src/components/index.ts index 0e017140..6db36857 100644 --- a/packages/ui/uikit/headless/components/src/components/index.ts +++ b/packages/ui/uikit/headless/components/src/components/index.ts @@ -14,6 +14,7 @@ export * from './Fieldset'; export * from './Form'; export * from './Input'; export * from './List'; +export * from './Marquee'; export * from './Menu'; export * from './Menubar'; export * from './Meter'; From 964bb8e5bffc6898b41456e170cd16b17a63ff13 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 23:13:52 +0300 Subject: [PATCH 06/16] feat(headless/hooks): add useEyeDropper hook for color picking functionality and update package structure --- .../Marquee/story/Marquee.stories.tsx | 2 +- packages/ui/uikit/headless/hooks/package.json | 5 + .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../hooks/src/hooks/useEyeDropper/index.ts | 1 + .../src/hooks/useEyeDropper/useEyeDropper.ts | 35 +++ pnpm-lock.yaml | 248 +++--------------- 6 files changed, 77 insertions(+), 215 deletions(-) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/useEyeDropper.ts diff --git a/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx index 600ad2c7..583e52e3 100644 --- a/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Marquee/story/Marquee.stories.tsx @@ -5,7 +5,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Marquee } from '../index'; const meta = { - title: 'Components/Marquee', + title: 'Widgets/Marquee', component: Marquee.Root, parameters: { layout: 'padded' diff --git a/packages/ui/uikit/headless/hooks/package.json b/packages/ui/uikit/headless/hooks/package.json index 8a5ca3e6..ad668b70 100644 --- a/packages/ui/uikit/headless/hooks/package.json +++ b/packages/ui/uikit/headless/hooks/package.json @@ -60,6 +60,11 @@ "import": "./dist/hooks/useEventCallback/index.es.js", "require": "./dist/hooks/useEventCallback/index.cjs.js" }, + "./use-eye-dropper": { + "types": "./dist/hooks/useEyeDropper/index.d.ts", + "import": "./dist/hooks/useEyeDropper/index.es.js", + "require": "./dist/hooks/useEyeDropper/index.cjs.js" + }, "./use-forced-rerendering": { "types": "./dist/hooks/useForcedRerendering/index.d.ts", "import": "./dist/hooks/useForcedRerendering/index.es.js", diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 867b4b8d..42afa797 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -7,6 +7,7 @@ export * from './useDragGesture'; export * from './useEnhancedClickHandler'; export * from './useEnhancedEffect'; export * from './useEventCallback'; +export * from './useEyeDropper'; export * from './useForcedRerendering'; export * from './useId'; export * from './useInterval'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/index.ts new file mode 100644 index 00000000..059ac552 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/index.ts @@ -0,0 +1 @@ +export { useEyeDropper } from './useEyeDropper'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/useEyeDropper.ts b/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/useEyeDropper.ts new file mode 100644 index 00000000..a4f58b6b --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useEyeDropper/useEyeDropper.ts @@ -0,0 +1,35 @@ +import { useStableCallback } from '../useStableCallback'; + +export type EyeDropperOpenOptions = { + signal?: AbortSignal; +}; + +export type EyeDropperOpenReturnType = { + sRGBHex: string; +}; + +export type UseEyeDropperReturnValue = { + isSupported: boolean; + open: (options?: EyeDropperOpenOptions) => Promise; +}; + +const isSupported = typeof window !== 'undefined' && !isOpera() && 'EyeDropper' in window; + +export function useEyeDropper(): UseEyeDropperReturnValue { + const open = useStableCallback( + (options: EyeDropperOpenOptions = {}): Promise => { + if (isSupported) { + const eyeDropper = new (window as any).EyeDropper(); + return eyeDropper.open(options); + } + + return Promise.resolve(undefined); + } + ); + + return { isSupported, open }; +} + +function isOpera() { + return navigator.userAgent.includes('OPR'); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 919cfb04..700eadf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,33 +6,9 @@ settings: catalogs: default: - '@antfu/eslint-config': - specifier: ^5.2.1 - version: 5.2.1 - '@biomejs/biome': - specifier: ^2.0.0-beta.1 - version: 2.0.0-beta.1 - '@chromatic-com/storybook': - specifier: ^3.2.6 - version: 3.2.6 '@eslint-react/eslint-plugin': specifier: ^1.52.8 version: 1.52.8 - '@farfetched/core': - specifier: ^0.13.2 - version: 0.13.2 - '@figma-export/core': - specifier: ^6.2.2 - version: 6.2.2 - '@floating-ui/react': - specifier: ^0.27.16 - version: 0.27.16 - '@floating-ui/react-dom': - specifier: ^2.1.6 - version: 2.1.6 - '@floating-ui/utils': - specifier: ^0.2.10 - version: 0.2.10 '@storybook/addon-essentials': specifier: ^8.6.14 version: 8.6.14 @@ -63,12 +39,6 @@ catalogs: '@storybook/theming': specifier: 8.6.14 version: 8.6.14 - '@svgr/core': - specifier: ^8.1.0 - version: 8.1.0 - '@svgr/plugin-jsx': - specifier: ^8.1.0 - version: 8.1.0 '@testing-library/jest-dom': specifier: ^6.8.0 version: 6.8.0 @@ -78,81 +48,27 @@ catalogs: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1 - '@testing-library/webdriverio': - specifier: ^3.2.1 - version: 3.2.1 - '@testplane/global-hook': - specifier: ^1.0.0 - version: 1.0.0 - '@testplane/storybook': - specifier: ^1.7.3 - version: 1.7.3 - '@testplane/test-filter': - specifier: ^1.1.0 - version: 1.1.0 - '@testplane/url-decorator': - specifier: ^1.0.0 - version: 1.0.0 - '@types/eslint': - specifier: ^9.6.1 - version: 9.6.1 - '@types/js-cookie': - specifier: ^3.0.6 - version: 3.0.6 '@types/node': specifier: ^22.18.0 version: 22.18.0 - '@types/qrcode': - specifier: ^1.5.5 - version: 1.5.5 '@types/react': specifier: ^19.0.12 version: 19.0.12 '@types/react-dom': specifier: ^19.0.4 version: 19.0.4 - '@types/use-sync-external-store': - specifier: ^1.5.0 - version: 1.5.0 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4 '@vitest/ui': specifier: ^3.2.4 version: 3.2.4 - '@withease/i18next': - specifier: ^24.0.0 - version: 24.0.0 - '@withease/web-api': - specifier: ^1.3.0 - version: 1.3.0 - atomic-router: - specifier: ^0.11.1 - version: 0.11.1 - atomic-router-react: - specifier: ^0.10.0 - version: 0.10.0 - axios: - specifier: ^1.11.0 - version: 1.11.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - effector: - specifier: ^23.4.2 - version: 23.4.2 - effector-react: - specifier: ^23.3.0 - version: 23.3.0 eslint: specifier: ^9.33.0 version: 9.33.0 - eslint-plugin-effector: - specifier: ^0.15.0 - version: 0.15.0 eslint-plugin-format: specifier: ^1.0.1 version: 1.0.1 @@ -162,93 +78,15 @@ catalogs: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20 - eslint-plugin-storybook: - specifier: ^0.12.0 - version: 0.12.0 - eslint-plugin-turbo: - specifier: ^2.5.6 - version: 2.5.6 - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - framer-motion: - specifier: ^12.23.12 - version: 12.23.12 - glob: - specifier: ^11.0.3 - version: 11.0.3 - globals: - specifier: ^16.3.0 - version: 16.3.0 - globby: - specifier: ^16.0.0 - version: 16.0.0 - history: - specifier: ^5.3.0 - version: 5.3.0 - html-reporter: - specifier: ^10.19.0 - version: 10.19.0 - i18next: - specifier: ^24.2.3 - version: 24.2.3 - i18next-browser-languagedetector: - specifier: ^8.2.0 - version: 8.2.0 - i18next-hmr: - specifier: ^3.1.4 - version: 3.1.4 - i18next-http-backend: - specifier: ^3.0.2 - version: 3.0.2 - is-svg: - specifier: ^5.1.0 - version: 5.1.0 - jiti: - specifier: ^2.5.1 - version: 2.5.1 - js-cookie: - specifier: ^3.0.5 - version: 3.0.5 jsdom: specifier: ^24.1.3 version: 24.1.3 - patronum: - specifier: ^2.3.0 - version: 2.3.0 - postcss: - specifier: ^8.5.6 - version: 8.5.6 - postcss-flexbugs-fixes: - specifier: ^5.0.2 - version: 5.0.2 - postcss-preset-env: - specifier: ^10.3.1 - version: 10.3.1 - qr-code-styling: - specifier: ^1.9.2 - version: 1.9.2 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 react: specifier: ^19.1.1 version: 19.1.1 react-dom: specifier: ^19.1.1 version: 19.1.1 - react-i18next: - specifier: ^15.7.3 - version: 15.7.3 - react-use-measure: - specifier: ^2.1.7 - version: 2.1.7 - reselect: - specifier: ^5.1.1 - version: 5.1.1 - rimraf: - specifier: ^6.0.1 - version: 6.0.1 sass: specifier: ^1.91.0 version: 1.91.0 @@ -258,51 +96,15 @@ catalogs: storybook: specifier: ^8.6.14 version: 8.6.14 - storybook-react-i18next: - specifier: ^3.3.1 - version: 3.3.1 - stylelint: - specifier: ^16.23.1 - version: 16.23.1 - surrealdb: - specifier: ^1.3.2 - version: 1.3.2 - svgo: - specifier: ^3.3.2 - version: 3.3.2 - tabbable: - specifier: ^6.2.0 - version: 6.2.0 - terser: - specifier: ^5.44.1 - version: 5.44.1 - testplane: - specifier: ^8.31.0 - version: 8.31.0 - tsup: - specifier: ^8.4.0 - version: 8.5.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: ^5.9.2 version: 5.9.2 - use-sync-external-store: - specifier: ^1.5.0 - version: 1.5.0 vite: specifier: 6.2.5 version: 6.2.5 - vite-plugin-dts: - specifier: ^4.5.4 - version: 4.5.4 vitest: specifier: ^3.2.4 version: 3.2.4 - zod: - specifier: ^3.25.76 - version: 3.25.76 importers: @@ -828,10 +630,10 @@ importers: version: 6.8.0 '@testing-library/react': specifier: 'catalog:' - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: 'catalog:' - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.1(@testing-library/dom@10.4.0) '@types/node': specifier: 'catalog:' version: 22.18.0 @@ -9604,7 +9406,7 @@ snapshots: '@babel/traverse': 7.28.3 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -9697,7 +9499,7 @@ snapshots: '@babel/parser': 7.28.3 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -10633,7 +10435,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -10647,7 +10449,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -11786,6 +11588,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.3 + '@testing-library/dom': 10.4.0 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.28.3 @@ -11800,6 +11612,10 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -12259,7 +12075,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) '@typescript-eslint/types': 8.41.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -12278,7 +12094,7 @@ snapshots: '@typescript-eslint/types': 8.41.0 '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 eslint: 9.33.0(jiti@2.5.1) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -12293,7 +12109,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) '@typescript-eslint/types': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -12408,7 +12224,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) '@vitest/utils@2.0.5': dependencies: @@ -13353,6 +13169,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -13660,7 +13480,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -14193,7 +14013,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -14839,7 +14659,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -14851,7 +14671,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -18052,7 +17872,7 @@ snapshots: vite-node@3.2.4(@types/node@22.18.0)(jiti@2.5.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.3(@types/node@22.18.0)(jiti@2.5.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -18144,7 +17964,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.1 expect-type: 1.2.2 magic-string: 0.30.18 pathe: 2.0.3 From 76d52dc2f041a36cf5eb495c12412b1d8257c4dd Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 23:27:27 +0300 Subject: [PATCH 07/16] feat(headless/hooks): introduce useEventListener hook for managing event listeners and update package structure --- packages/ui/uikit/headless/hooks/package.json | 5 +++ .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../hooks/src/hooks/useEventListener/index.ts | 1 + .../useEventListener/useEventListener.ts | 40 +++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useEventListener/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useEventListener/useEventListener.ts diff --git a/packages/ui/uikit/headless/hooks/package.json b/packages/ui/uikit/headless/hooks/package.json index ad668b70..6dabd12a 100644 --- a/packages/ui/uikit/headless/hooks/package.json +++ b/packages/ui/uikit/headless/hooks/package.json @@ -60,6 +60,11 @@ "import": "./dist/hooks/useEventCallback/index.es.js", "require": "./dist/hooks/useEventCallback/index.cjs.js" }, + "./use-event-listener": { + "types": "./dist/hooks/useEventListener/index.d.ts", + "import": "./dist/hooks/useEventListener/index.es.js", + "require": "./dist/hooks/useEventListener/index.cjs.js" + }, "./use-eye-dropper": { "types": "./dist/hooks/useEyeDropper/index.d.ts", "import": "./dist/hooks/useEyeDropper/index.es.js", diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 42afa797..777ba113 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -7,6 +7,7 @@ export * from './useDragGesture'; export * from './useEnhancedClickHandler'; export * from './useEnhancedEffect'; export * from './useEventCallback'; +export * from './useEventListener'; export * from './useEyeDropper'; export * from './useForcedRerendering'; export * from './useId'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/index.ts new file mode 100644 index 00000000..16d23272 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/index.ts @@ -0,0 +1 @@ +export { useEventListener } from './useEventListener'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/useEventListener.ts b/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/useEventListener.ts new file mode 100644 index 00000000..ca708ac6 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useEventListener/useEventListener.ts @@ -0,0 +1,40 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export function useEventListener( + type: K, + listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +): React.RefCallback { + const previousListener = React.useRef(null); + const previousNode = React.useRef(null); + + const callbackRef: React.RefCallback = React.useCallback( + (node) => { + if (!node) { + return; + } + + if (previousNode.current && previousListener.current) { + previousNode.current.removeEventListener(type, previousListener.current as any, options); + } + + node.addEventListener(type, listener as any, options); + previousNode.current = node; + previousListener.current = listener; + }, + [type, listener, options] + ); + + useIsoLayoutEffect( + () => () => { + if (previousNode.current && previousListener.current) { + previousNode.current.removeEventListener(type, previousListener.current as any, options); + } + }, + [type, options] + ); + + return callbackRef; +} From 3848b56acfbc0a36df5af74d59fd39d94acee174 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 23:42:22 +0300 Subject: [PATCH 08/16] feat(headless/hooks): add useColorScheme hook for responsive color scheme management --- .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../hooks/src/hooks/useColorSchema/index.ts | 1 + .../hooks/useColorSchema/useColorSchema.ts | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/useColorSchema.ts diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 777ba113..618b5ede 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useAnimationFrame'; export * from './useAnimationsFinished'; export * from './useClipboard'; +export * from './useColorSchema'; export * from './useControlledState'; export * from './useDidUpdate'; export * from './useDragGesture'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/index.ts new file mode 100644 index 00000000..f521cf65 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/index.ts @@ -0,0 +1 @@ +export { useColorScheme } from './useColorSchema'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/useColorSchema.ts b/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/useColorSchema.ts new file mode 100644 index 00000000..c5222ee6 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useColorSchema/useColorSchema.ts @@ -0,0 +1,19 @@ +import { useMediaQuery } from '../useMediaQuery'; + +import type { UseMediaQueryOptions } from '../useMediaQuery'; + +export type UseColorSchemeValue = 'dark' | 'light'; + +export function useColorScheme( + initialValue?: UseColorSchemeValue, + options?: Omit +): UseColorSchemeValue { + const defaultMatches = initialValue === 'dark'; + + return useMediaQuery('(prefers-color-scheme: dark)', { + defaultMatches, + ...options + }) + ? 'dark' + : 'light'; +} From 6e39f5cfca17aa623449b2a44623866ca959be89 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 23:44:09 +0300 Subject: [PATCH 09/16] feat(headless/hooks): add useDocumentTitle hook for managing document title updates --- packages/ui/uikit/headless/hooks/src/hooks/index.ts | 1 + .../headless/hooks/src/hooks/useDocumentTitle/index.ts | 1 + .../hooks/src/hooks/useDocumentTitle/useDocumentTitle.ts | 9 +++++++++ 3 files changed, 11 insertions(+) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/useDocumentTitle.ts diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 618b5ede..a0b52b58 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useClipboard'; export * from './useColorSchema'; export * from './useControlledState'; export * from './useDidUpdate'; +export * from './useDocumentTitle'; export * from './useDragGesture'; export * from './useEnhancedClickHandler'; export * from './useEnhancedEffect'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/index.ts new file mode 100644 index 00000000..b574d65d --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/index.ts @@ -0,0 +1 @@ +export { useDocumentTitle } from './useDocumentTitle'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/useDocumentTitle.ts b/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/useDocumentTitle.ts new file mode 100644 index 00000000..caf1a28c --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useDocumentTitle/useDocumentTitle.ts @@ -0,0 +1,9 @@ +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export function useDocumentTitle(title: string) { + useIsoLayoutEffect(() => { + if (typeof title === 'string' && title.trim().length > 0) { + document.title = title.trim(); + } + }, [title]); +} From 9f873169631b73662046364239241cee61711dfc Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 22 Dec 2025 23:52:18 +0300 Subject: [PATCH 10/16] feat(headless/hooks): add useFavicon hook for dynamic favicon management --- .../uikit/headless/hooks/src/hooks/index.ts | 1 + .../hooks/src/hooks/useFavicon/index.ts | 1 + .../hooks/src/hooks/useFavicon/useFavicon.ts | 37 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFavicon/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFavicon/useFavicon.ts diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index a0b52b58..21676bea 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -11,6 +11,7 @@ export * from './useEnhancedEffect'; export * from './useEventCallback'; export * from './useEventListener'; export * from './useEyeDropper'; +export * from './useFavicon'; export * from './useForcedRerendering'; export * from './useId'; export * from './useInterval'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/index.ts new file mode 100644 index 00000000..be2f484d --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/index.ts @@ -0,0 +1 @@ +export { useFavicon } from './useFavicon'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/useFavicon.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/useFavicon.ts new file mode 100644 index 00000000..aff50616 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFavicon/useFavicon.ts @@ -0,0 +1,37 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +const MIME_TYPES: Record = { + ico: 'image/x-icon', + png: 'image/png', + svg: 'image/svg+xml', + gif: 'image/gif' +}; + +export function useFavicon(url: string) { + const link = React.useRef(null); + + useIsoLayoutEffect(() => { + if (!url) { + return; + } + + if (!link.current) { + const existingElements = document.querySelectorAll('link[rel*="icon"]'); + existingElements.forEach((element) => document.head.removeChild(element)); + + const element = document.createElement('link'); + element.rel = 'shortcut icon'; + link.current = element; + document.querySelector('head')!.appendChild(element); + } + + const splittedUrl = url.split('.'); + link.current.setAttribute( + 'type', + MIME_TYPES[splittedUrl[splittedUrl.length - 1]?.toLowerCase() ?? ''] ?? '' + ); + link.current.setAttribute('href', url); + }, [url]); +} From 1b9f36a4165304191b52e9d0c30114362a93b1f0 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Tue, 23 Dec 2025 23:50:26 +0300 Subject: [PATCH 11/16] feat(headless/hooks): add multiple utility hooks for enhanced functionality including useClickOutside, useFullscreen, useHash, useHotkeys, useHover, useIdle, useInViewport, useIntersection, useLocalStorage, useLongPress, useMouse, useMove, useMutationObserver, useNetwork, useOs, useResizeObserver, useScrollIntoView, useScrollSpy, useTextSelection, useViewportSize, useWindowScroll --- .../uikit/headless/hooks/src/hooks/index.ts | 22 ++ .../hooks/src/hooks/useClickOutside/index.ts | 1 + .../hooks/useClickOutside/useClickOutside.ts | 39 +++ .../hooks/src/hooks/useFullscreen/index.ts | 1 + .../src/hooks/useFullscreen/useFullscreen.ts | 129 ++++++++ .../headless/hooks/src/hooks/useHash/index.ts | 1 + .../hooks/src/hooks/useHash/useHash.ts | 38 +++ .../hooks/src/hooks/useHotkeys/index.ts | 1 + .../hooks/src/hooks/useHotkeys/useHotkeys.ts | 301 ++++++++++++++++++ .../hooks/src/hooks/useHover/index.ts | 1 + .../hooks/src/hooks/useHover/useHover.ts | 50 +++ .../headless/hooks/src/hooks/useIdle/index.ts | 1 + .../hooks/src/hooks/useIdle/useIdle.ts | 55 ++++ .../hooks/src/hooks/useInViewport/index.ts | 1 + .../src/hooks/useInViewport/useInViewport.ts | 36 +++ .../hooks/src/hooks/useIntersection/index.ts | 1 + .../hooks/useIntersection/useIntersection.ts | 38 +++ .../hooks/useLocalStorage/createStorage.ts | 212 ++++++++++++ .../hooks/src/hooks/useLocalStorage/index.ts | 1 + .../hooks/useLocalStorage/useLocalStorage.ts | 9 + .../hooks/src/hooks/useLongPress/index.ts | 1 + .../src/hooks/useLongPress/useLongPress.ts | 112 +++++++ .../hooks/src/hooks/useMouse/index.ts | 1 + .../hooks/src/hooks/useMouse/useMouse.ts | 149 +++++++++ .../headless/hooks/src/hooks/useMove/index.ts | 1 + .../hooks/src/hooks/useMove/useMove.ts | 149 +++++++++ .../src/hooks/useMutationObserver/index.ts | 1 + .../useMutationObserver.ts | 40 +++ .../hooks/src/hooks/useNetwork/index.ts | 1 + .../hooks/src/hooks/useNetwork/useNetwork.ts | 68 ++++ .../hooks/src/hooks/useOrientation/index.ts | 0 .../hooks/useOrientation/useOrientation.ts | 77 +++++ .../headless/hooks/src/hooks/useOs/index.ts | 1 + .../headless/hooks/src/hooks/useOs/useOs.ts | 94 ++++++ .../src/hooks/useResizeObserver/index.ts | 0 .../useResizeObserver/useResizeObserver.ts | 85 +++++ .../src/hooks/useScrollIntoView/index.ts | 0 .../useScrollIntoView/useScrollIntoView.ts | 286 +++++++++++++++++ .../hooks/src/hooks/useScrollSpy/index.ts | 1 + .../src/hooks/useScrollSpy/useScrollSpy.ts | 151 +++++++++ .../hooks/src/hooks/useTextSelection/index.ts | 1 + .../useTextSelection/useTextSelection.ts | 22 ++ .../hooks/src/hooks/useViewportSize/index.ts | 1 + .../hooks/useViewportSize/useViewportSize.ts | 26 ++ .../hooks/src/hooks/useWindowEvent/index.ts | 1 + .../hooks/useWindowEvent/useWindowEvent.ts | 19 ++ .../hooks/src/hooks/useWindowScroll/index.ts | 1 + .../hooks/useWindowScroll/useWindowScroll.ts | 45 +++ .../ui/uikit/headless/hooks/src/lib/clamp.ts | 7 + 49 files changed, 2279 insertions(+) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/useFullscreen.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHash/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHash/useHash.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/useHotkeys.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHover/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useHover/useHover.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useIdle/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useIdle/useIdle.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useInViewport/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useInViewport/useInViewport.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useIntersection/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useIntersection/useIntersection.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/createStorage.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/useLocalStorage.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useLongPress/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useLongPress/useLongPress.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMouse/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMove/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMove/useMove.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useNetwork/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useNetwork/useNetwork.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useOs/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useOs/useOs.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/useTextSelection.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/useViewportSize.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/useWindowEvent.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/useWindowScroll.ts create mode 100644 packages/ui/uikit/headless/hooks/src/lib/clamp.ts diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 21676bea..88695398 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useAnimationFrame'; export * from './useAnimationsFinished'; +export * from './useClickOutside'; export * from './useClipboard'; export * from './useColorSchema'; export * from './useControlledState'; @@ -13,23 +14,44 @@ export * from './useEventListener'; export * from './useEyeDropper'; export * from './useFavicon'; export * from './useForcedRerendering'; +export * from './useFullscreen'; +export * from './useHash'; +export * from './useHotkeys'; +export * from './useHover'; export * from './useId'; +export * from './useIdle'; +export * from './useIntersection'; export * from './useInterval'; +export * from './useInViewport'; export * from './useIsFirstRender'; export * from './useIsoLayoutEffect'; export * from './useLatestRef'; export * from './useLazyRef'; +export * from './useLocalStorage'; +export * from './useLocalStorage'; +export * from './useLongPress'; export * from './useMediaQuery'; export * from './useMergedRef'; +export * from './useMouse'; +export * from './useMove'; +export * from './useMutationObserver'; +export * from './useNetwork'; +export * from './useNetwork'; export * from './useOnFirstRender'; export * from './useOnMount'; export * from './useOpenChangeComplete'; export * from './useOpenInteractionType'; +export * from './useOs'; export * from './usePreviousValue'; export * from './useScrollLock'; +export * from './useScrollSpy'; export * from './useSsr'; export * from './useStatusTransition'; export * from './useStore'; +export * from './useTextSelection'; export * from './useTimeout'; export * from './useTransitionStatus'; export * from './useUnmount'; +export * from './useViewportSize'; +export * from './useWindowEvent'; +export * from './useWindowScroll'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/index.ts new file mode 100644 index 00000000..96801f2f --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/index.ts @@ -0,0 +1 @@ +export * from './useClickOutside'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts new file mode 100644 index 00000000..641d252a --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +type EventType = MouseEvent | TouchEvent; + +const DEFAULT_EVENTS = ['mousedown', 'touchstart']; + +export function useClickOutside( + callback: (event: EventType) => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[] +) { + const ref = React.useRef(null); + const eventsList = events || DEFAULT_EVENTS; + + useIsoLayoutEffect(() => { + const listener = (event: Event) => { + const { target } = event ?? {}; + if (Array.isArray(nodes)) { + const shouldIgnore + = !document.body.contains(target as Node) && (target as Element)?.tagName !== 'HTML'; + const shouldTrigger = nodes.every((node) => !!node && !event.composedPath().includes(node)); + shouldTrigger && !shouldIgnore && callback(event as EventType); + } + else if (ref.current && !ref.current.contains(target as Node)) { + callback(event as EventType); + } + }; + + eventsList.forEach((fn) => document.addEventListener(fn, listener)); + + return () => { + eventsList.forEach((fn) => document.removeEventListener(fn, listener)); + }; + }, [ref, callback, nodes]); + + return ref; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/index.ts new file mode 100644 index 00000000..a4948136 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/index.ts @@ -0,0 +1 @@ +export * from './useFullscreen'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/useFullscreen.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/useFullscreen.ts new file mode 100644 index 00000000..fef25d64 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFullscreen/useFullscreen.ts @@ -0,0 +1,129 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; + +function getFullscreenElement(): HTMLElement | null { + const _document = window.document as any; + + const fullscreenElement + = _document.fullscreenElement + || _document.webkitFullscreenElement + || _document.mozFullScreenElement + || _document.msFullscreenElement; + + return fullscreenElement; +} + +function exitFullscreen() { + const _document = window.document as any; + + if (typeof _document.exitFullscreen === 'function') { + return _document.exitFullscreen(); + } + if (typeof _document.msExitFullscreen === 'function') { + return _document.msExitFullscreen(); + } + if (typeof _document.webkitExitFullscreen === 'function') { + return _document.webkitExitFullscreen(); + } + if (typeof _document.mozCancelFullScreen === 'function') { + return _document.mozCancelFullScreen(); + } + + return null; +} + +function enterFullScreen(element: HTMLElement) { + const _element = element as any; + + return ( + _element.requestFullscreen?.() + || _element.msRequestFullscreen?.() + || _element.webkitEnterFullscreen?.() + || _element.webkitRequestFullscreen?.() + || _element.mozRequestFullscreen?.() + ); +} + +const prefixes = ['', 'webkit', 'moz', 'ms']; + +function addEvents( + element: HTMLElement, + { + onFullScreen, + onError + }: { onFullScreen: (event: Event) => void; onError: (event: Event) => void } +) { + prefixes.forEach((prefix) => { + element.addEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.addEventListener(`${prefix}fullscreenerror`, onError); + }); + + return () => { + prefixes.forEach((prefix) => { + element.removeEventListener(`${prefix}fullscreenchange`, onFullScreen); + element.removeEventListener(`${prefix}fullscreenerror`, onError); + }); + }; +} + +export type UseFullscreenReturnValue = { + ref: React.RefCallback; + toggle: () => Promise; + fullscreen: boolean; +}; + +export function useFullscreen(): UseFullscreenReturnValue { + const [fullscreen, setFullscreen] = React.useState(false); + + const _ref = React.useRef(null); + + const handleFullscreenChange = useStableCallback( + (event: Event) => { + setFullscreen(event.target === getFullscreenElement()); + } + ); + + const handleFullscreenError = useStableCallback( + (event: Event) => { + setFullscreen(false); + + console.error( + `Headless UI hooks: use-fullscreen: Error attempting full-screen mode method: ${event} (${event.target})` + ); + } + ); + + const toggle = useStableCallback(async () => { + if (!getFullscreenElement()) { + await enterFullScreen(_ref.current!); + } + else { + await exitFullscreen(); + } + }); + + const ref = useStableCallback((element: T | null) => { + if (element === null) { + _ref.current = window.document.documentElement as T; + } + else { + _ref.current = element; + } + }); + + useIsoLayoutEffect(() => { + const target = _ref.current ?? window?.document?.documentElement; + + if (!target) + return undefined; + + return addEvents(target, { + onFullScreen: handleFullscreenChange, + onError: handleFullscreenError + }); + }, [_ref.current]); + + return { ref, toggle, fullscreen } as const; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHash/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHash/index.ts new file mode 100644 index 00000000..c465832a --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHash/index.ts @@ -0,0 +1 @@ +export * from './useHash'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHash/useHash.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHash/useHash.ts new file mode 100644 index 00000000..48fc18aa --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHash/useHash.ts @@ -0,0 +1,38 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useWindowEvent } from '../useWindowEvent'; + +export type UseHashReturnValue = [string, (value: string) => void]; +export type UseHashParams = { + getInitialValueInEffect?: boolean; +}; + +export function useHash({ + getInitialValueInEffect = true +}: UseHashParams = {}): UseHashReturnValue { + const [hash, setHash] = React.useState( + getInitialValueInEffect ? '' : window.location.hash || '' + ); + + const setHashHandler = (value: string) => { + const valueWithHash = value.startsWith('#') ? value : `#${value}`; + window.location.hash = valueWithHash; + setHash(valueWithHash); + }; + + useWindowEvent('hashchange', () => { + const newHash = window.location.hash; + if (hash !== newHash) { + setHash(newHash); + } + }); + + useIsoLayoutEffect(() => { + if (getInitialValueInEffect) { + setHash(window.location.hash); + } + }, []); + + return [hash, setHashHandler]; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/index.ts new file mode 100644 index 00000000..c7c64d99 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/index.ts @@ -0,0 +1 @@ +export * from './useHotkeys'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/useHotkeys.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/useHotkeys.ts new file mode 100644 index 00000000..985766c6 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHotkeys/useHotkeys.ts @@ -0,0 +1,301 @@ +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useValueAsRef } from '../useValueAsRef'; + +/** + * Клавиши-действия, которые можно использовать в хоткеях. + * Все в нижнем регистре для удобства сравнения. + */ +export const hotkeyAbleKeys = [ + // Буквы + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + // Цифры + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + // Функциональные + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12', + // Навигация и управление + 'arrowup', + 'arrowdown', + 'arrowleft', + 'arrowright', + 'enter', + 'tab', + 'escape', + 'backspace', + 'delete', + 'insert', + 'home', + 'end', + 'pageup', + 'pagedown', + // Пробел + 'space', // Псевдоним для ' ' + // Основные символы US-раскладки + '`', + '-', + '=', + '[', + ']', + '\\', + ';', + '\'', + ',', + '.', + '/' +]; + +/** + * Модификаторы, включая виртуальный 'mod'. + */ +export const modifierKeys = [ + 'control', + 'alt', + 'shift', + 'meta', + 'mod' +]; + +export type HotkeyItem = [string, (event: KeyboardEvent) => void, HotkeyItemOptions?]; + +function shouldFireEvent( + event: KeyboardEvent, + tagsToIgnore: string[], + triggerOnContentEditable = false +) { + if (event.target instanceof HTMLElement) { + if (triggerOnContentEditable) { + return !tagsToIgnore.includes(event.target.tagName); + } + + return !event.target.isContentEditable && !tagsToIgnore.includes(event.target.tagName); + } + + return true; +} + +export function useHotkeys( + hotkeys: HotkeyItem[], + tagsToIgnore: string[] = ['INPUT', 'TEXTAREA', 'SELECT'], + triggerOnContentEditable = false +) { + const hotkeysRef = useValueAsRef(hotkeys); + const tagsToIgnoreRef = useValueAsRef(tagsToIgnore); + const triggerOnContentEditableRef = useValueAsRef(triggerOnContentEditable); + + useIsoLayoutEffect(() => { + const keydownListener = (event: KeyboardEvent) => { + hotkeysRef.current.forEach( + ([hotkey, handler, options = { preventDefault: true, usePhysicalKeys: false }]) => { + if ( + getHotkeyMatcher(hotkey, options.usePhysicalKeys)(event) + && shouldFireEvent(event, tagsToIgnoreRef.current, triggerOnContentEditableRef.current) + ) { + if (options.preventDefault) { + event.preventDefault(); + } + + handler(event); + } + } + ); + }; + + document.documentElement.addEventListener('keydown', keydownListener); + return () => document.documentElement.removeEventListener('keydown', keydownListener); + }, []); +} +export type KeyboardModifiers = { + alt: boolean; + ctrl: boolean; + meta: boolean; + mod: boolean; + shift: boolean; + plus: boolean; +}; + +export type Hotkey = KeyboardModifiers & { + key?: string; +}; + +type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; + +const keyNameMap: Record = { + ' ': 'space', + 'ArrowLeft': 'arrowleft', + 'ArrowRight': 'arrowright', + 'ArrowUp': 'arrowup', + 'ArrowDown': 'arrowdown', + 'Escape': 'escape', + 'Esc': 'escape', + 'esc': 'escape', + 'Enter': 'enter', + 'Tab': 'tab', + 'Backspace': 'backspace', + 'Delete': 'delete', + 'Insert': 'insert', + 'Home': 'home', + 'End': 'end', + 'PageUp': 'pageup', + 'PageDown': 'pagedown', + '+': 'plus', + '-': 'minus', + '*': 'asterisk', + '/': 'slash' +}; + +function normalizeKey(key: string): string { + const lowerKey = key.replace('Key', '').toLowerCase(); + return keyNameMap[key] || lowerKey; +} + +export function parseHotkey(hotkey: string): Hotkey { + const keys = hotkey + .toLowerCase() + .split('+') + .map((part) => part.trim()); + + const modifiers: KeyboardModifiers = { + alt: keys.includes('alt'), + ctrl: keys.includes('ctrl'), + meta: keys.includes('meta'), + mod: keys.includes('mod'), + shift: keys.includes('shift'), + plus: keys.includes('[plus]') + }; + + const reservedKeys = [ + 'alt', + 'ctrl', + 'meta', + 'shift', + 'mod' + ]; + + const freeKey = keys.find((key) => !reservedKeys.includes(key)); + + return { + ...modifiers, + key: freeKey === '[plus]' ? '+' : freeKey + }; +} + +function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent, usePhysicalKeys?: boolean): boolean { + const { + alt, + ctrl, + meta, + mod, + shift, + key + } = hotkey; + const { + altKey, + ctrlKey, + metaKey, + shiftKey, + key: pressedKey, + code: pressedCode + } = event; + + if (alt !== altKey) { + return false; + } + + if (mod) { + if (!ctrlKey && !metaKey) { + return false; + } + } + else { + if (ctrl !== ctrlKey) { + return false; + } + if (meta !== metaKey) { + return false; + } + } + if (shift !== shiftKey) { + return false; + } + + if ( + key + && (usePhysicalKeys + ? normalizeKey(pressedCode) === normalizeKey(key) + : normalizeKey(pressedKey ?? pressedCode) === normalizeKey(key)) + ) { + return true; + } + + return false; +} + +export function getHotkeyMatcher(hotkey: string, usePhysicalKeys?: boolean): CheckHotkeyMatch { + return (event) => isExactHotkey(parseHotkey(hotkey), event, usePhysicalKeys); +} + +export type HotkeyItemOptions = { + preventDefault?: boolean; + usePhysicalKeys?: boolean; +}; + +export function getHotkeyHandler(hotkeys: HotkeyItem[]) { + return (event: React.KeyboardEvent | KeyboardEvent) => { + const _event = 'nativeEvent' in event ? event.nativeEvent : event; + hotkeys.forEach( + ([hotkey, handler, options = { preventDefault: true, usePhysicalKeys: false }]) => { + if (getHotkeyMatcher(hotkey, options.usePhysicalKeys)(_event)) { + if (options.preventDefault) { + event.preventDefault(); + } + + handler(_event); + } + } + ); + }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHover/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHover/index.ts new file mode 100644 index 00000000..4feaa84d --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHover/index.ts @@ -0,0 +1 @@ +export * from './useHover'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useHover/useHover.ts b/packages/ui/uikit/headless/hooks/src/hooks/useHover/useHover.ts new file mode 100644 index 00000000..21c76605 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useHover/useHover.ts @@ -0,0 +1,50 @@ +import { useCallback, useRef, useState } from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; + +export type UseHoverReturnValue = { + hovered: boolean; + ref: React.RefCallback; +}; + +export function useHover(): UseHoverReturnValue { + const [hovered, setHovered] = useState(false); + const previousNode = useRef(null); + + const handleMouseEnter = useStableCallback(() => { + setHovered(true); + }); + + const handleMouseLeave = useStableCallback(() => { + setHovered(false); + }); + + const ref: React.RefCallback = useCallback( + (node) => { + if (previousNode.current) { + previousNode.current.removeEventListener('mouseenter', handleMouseEnter); + previousNode.current.removeEventListener('mouseleave', handleMouseLeave); + } + + if (node) { + node.addEventListener('mouseenter', handleMouseEnter); + node.addEventListener('mouseleave', handleMouseLeave); + } + + previousNode.current = node; + }, + [handleMouseEnter, handleMouseLeave] + ); + + useIsoLayoutEffect(() => { + return () => { + if (previousNode.current) { + previousNode.current.removeEventListener('mouseenter', handleMouseEnter); + previousNode.current.removeEventListener('mouseleave', handleMouseLeave); + } + }; + }, []); + + return { ref, hovered }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useIdle/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useIdle/index.ts new file mode 100644 index 00000000..52e9b9f6 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useIdle/index.ts @@ -0,0 +1 @@ +export * from './useIdle'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useIdle/useIdle.ts b/packages/ui/uikit/headless/hooks/src/hooks/useIdle/useIdle.ts new file mode 100644 index 00000000..80f5fe90 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useIdle/useIdle.ts @@ -0,0 +1,55 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export type UseIdleOptions = { + events?: (keyof DocumentEventMap)[]; + initialState?: boolean; +}; + +const DEFAULT_OPTIONS: Required = { + events: [ + 'keydown', + 'mousemove', + 'touchmove', + 'click', + 'scroll', + 'wheel' + ], + initialState: true +}; + +export function useIdle(timeout: number, options?: UseIdleOptions) { + const { events, initialState } = { ...DEFAULT_OPTIONS, ...options }; + const [idle, setIdle] = React.useState(initialState); + const timer = React.useRef(-1); + + useIsoLayoutEffect(() => { + const handleEvents = () => { + setIdle(false); + + if (timer.current) { + window.clearTimeout(timer.current); + } + + timer.current = window.setTimeout(() => { + setIdle(true); + }, timeout); + }; + + events.forEach((event) => document.addEventListener(event, handleEvents)); + + // Start the timer immediately instead of waiting for the first event to happen + timer.current = window.setTimeout(() => { + setIdle(true); + }, timeout); + + return () => { + events.forEach((event) => document.removeEventListener(event, handleEvents)); + window.clearTimeout(timer.current); + timer.current = -1; + }; + }, [timeout]); + + return idle; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/index.ts new file mode 100644 index 00000000..9cd3d028 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/index.ts @@ -0,0 +1 @@ +export * from './useInViewport'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/useInViewport.ts b/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/useInViewport.ts new file mode 100644 index 00000000..900b6f54 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useInViewport/useInViewport.ts @@ -0,0 +1,36 @@ +import React from 'react'; + +export type UseInViewportReturnValue = { + inViewport: boolean; + ref: React.RefCallback; +}; + +export function useInViewport(): UseInViewportReturnValue { + const observer = React.useRef(null); + const [inViewport, setInViewport] = React.useState(false); + + const ref: React.RefCallback = React.useCallback((node) => { + if (typeof IntersectionObserver !== 'undefined') { + if (node && !observer.current) { + observer.current = new IntersectionObserver((entries) => { + // Entries might be batched (e.g. when scrolling very fast), so we need to use the + // last entry to get the most recent state + const lastEntry = entries[entries.length - 1]; + setInViewport(Boolean(lastEntry?.isIntersecting)); + }); + } + else { + observer.current?.disconnect(); + } + + if (node) { + observer.current?.observe(node); + } + else { + setInViewport(false); + } + } + }, []); + + return { ref, inViewport }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/index.ts new file mode 100644 index 00000000..7ed02301 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/index.ts @@ -0,0 +1 @@ +export * from './useIntersection'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/useIntersection.ts b/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/useIntersection.ts new file mode 100644 index 00000000..b06ae4d2 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useIntersection/useIntersection.ts @@ -0,0 +1,38 @@ +import React from 'react'; + +import { useStableCallback } from '../useStableCallback'; + +export type UseIntersectionReturnValue = { + ref: React.RefCallback; + entry: IntersectionObserverEntry | null; +}; + +export function useIntersection( + options?: IntersectionObserverInit +): UseIntersectionReturnValue { + const [entry, setEntry] = React.useState(null); + + const observer = React.useRef(null); + + const ref: React.RefCallback = useStableCallback( + (element) => { + if (observer.current) { + observer.current.disconnect(); + observer.current = null; + } + + if (element === null) { + setEntry(null); + return; + } + + observer.current = new IntersectionObserver(([_entry]) => { + setEntry(_entry ?? null); + }, options); + + observer.current.observe(element); + } + ); + + return { ref, entry }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/createStorage.ts b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/createStorage.ts new file mode 100644 index 00000000..0614edf4 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/createStorage.ts @@ -0,0 +1,212 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; +import { useWindowEvent } from '../useWindowEvent'; + +export type StorageType = 'localStorage' | 'sessionStorage'; + +export type UseStorageOptions = { + /** Storage key */ + key: string; + + /** Default value that will be set if value is not found in storage */ + defaultValue?: T; + + /** If set to true, value will be updated in useEffect after mount. Default value is true. */ + getInitialValueInEffect?: boolean; + + /** Determines whether the value must be synced between browser tabs, `true` by default */ + sync?: boolean; + + /** Function to serialize value into string to be save in storage */ + serialize?: (value: T) => string; + + /** Function to deserialize string value from storage to value */ + deserialize?: (value: string | undefined) => T; +}; + +function serializeJSON(value: T, hookName: string = 'use-local-storage') { + try { + return JSON.stringify(value); + } + catch (_error: unknown) { + throw new Error(`@mantine/hooks ${hookName}: Failed to serialize the value`); + } +} + +function deserializeJSON(value: string | undefined) { + try { + return value && JSON.parse(value); + } + catch { + return value; + } +} + +function createStorageHandler(type: StorageType) { + const getItem = (key: string) => { + try { + return window[type].getItem(key); + } + catch (_error: unknown) { + console.warn('use-local-storage: Failed to get value from storage, localStorage is blocked'); + return null; + } + }; + + const setItem = (key: string, value: string) => { + try { + window[type].setItem(key, value); + } + catch (_error: unknown) { + console.warn('use-local-storage: Failed to set value to storage, localStorage is blocked'); + } + }; + + const removeItem = (key: string) => { + try { + window[type].removeItem(key); + } + catch (_error: unknown) { + console.warn( + 'use-local-storage: Failed to remove value from storage, localStorage is blocked' + ); + } + }; + + return { getItem, setItem, removeItem }; +} + +export type UseStorageReturnValue = [ + T, // current value + (val: T | ((prevState: T) => T)) => void, // callback to set value in storage + () => void // callback to remove value from storage +]; + +export function createStorage(type: StorageType, hookName: string) { + const eventName = type === 'localStorage' ? 'mantine-local-storage' : 'mantine-session-storage'; + const { getItem, setItem, removeItem } = createStorageHandler(type); + + return function useStorage({ + key, + defaultValue, + getInitialValueInEffect = true, + sync = true, + deserialize = deserializeJSON, + serialize = (value: T) => serializeJSON(value, hookName) + }: UseStorageOptions): UseStorageReturnValue { + const readStorageValue = useStableCallback( + (skipStorage?: boolean): T => { + let storageBlockedOrSkipped; + + try { + storageBlockedOrSkipped + = typeof window === 'undefined' + || !(type in window) + || window[type] === null + || !!skipStorage; + } + catch (_e) { + storageBlockedOrSkipped = true; + } + + if (storageBlockedOrSkipped) { + return defaultValue as T; + } + + const storageValue = getItem(key); + return storageValue !== null ? deserialize(storageValue) : (defaultValue as T); + } + ); + + const [value, setValue] = React.useState(readStorageValue(getInitialValueInEffect)); + + const setStorageValue = React.useCallback( + (val: T | ((prevState: T) => T)) => { + if (typeof val === 'function') { + const updater = val as (prevState: T) => T; + setValue((current) => { + const result = updater(current); + setItem(key, serialize(result)); + // Defer dispatching this event to avoid the handler being called during render. + queueMicrotask(() => { + window.dispatchEvent( + new CustomEvent(eventName, { detail: { key, value: updater(current) } }) + ); + }); + return result; + }); + } + else { + setItem(key, serialize(val)); + window.dispatchEvent(new CustomEvent(eventName, { detail: { key, value: val } })); + setValue(val); + } + }, + [key] + ); + + const removeStorageValue = React.useCallback(() => { + removeItem(key); + setValue(defaultValue as T); + window.dispatchEvent(new CustomEvent(eventName, { detail: { key, value: defaultValue } })); + }, [key, defaultValue]); + + useWindowEvent('storage', (event) => { + if (sync) { + if (event.storageArea === window[type] && event.key === key) { + setValue(deserialize(event.newValue ?? undefined)); + } + } + }); + + useWindowEvent(eventName, (event) => { + if (sync) { + if (event.detail.key === key) { + setValue(event.detail.value); + } + } + }); + + useIsoLayoutEffect(() => { + if (defaultValue !== undefined && value === undefined) { + setStorageValue(defaultValue); + } + }, [defaultValue, value, setStorageValue]); + + useIsoLayoutEffect(() => { + const val = readStorageValue(); + val !== undefined && setStorageValue(val); + }, [key]); + + return [value === undefined ? (defaultValue as T) : value, setStorageValue, removeStorageValue]; + }; +} + +export function readValue(type: StorageType) { + const { getItem } = createStorageHandler(type); + + return function read({ + key, + defaultValue, + deserialize = deserializeJSON + }: UseStorageOptions) { + let storageBlockedOrSkipped; + + try { + storageBlockedOrSkipped + = typeof window === 'undefined' || !(type in window) || window[type] === null; + } + catch (_e) { + storageBlockedOrSkipped = true; + } + + if (storageBlockedOrSkipped) { + return defaultValue as T; + } + + const storageValue = getItem(key); + return storageValue !== null ? deserialize(storageValue) : (defaultValue as T); + }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/index.ts new file mode 100644 index 00000000..01cda12e --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/index.ts @@ -0,0 +1 @@ +export * from './useLocalStorage'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/useLocalStorage.ts b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/useLocalStorage.ts new file mode 100644 index 00000000..7724f5e8 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,9 @@ +import { createStorage, readValue } from './createStorage'; + +import type { UseStorageOptions } from './createStorage'; + +export function useLocalStorage(props: UseStorageOptions) { + return createStorage('localStorage', 'use-local-storage')(props); +} + +export const readLocalStorageValue = readValue('localStorage'); diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/index.ts new file mode 100644 index 00000000..50cac700 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/index.ts @@ -0,0 +1 @@ +export * from './useLongPress'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/useLongPress.ts b/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/useLongPress.ts new file mode 100644 index 00000000..3059fb81 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useLongPress/useLongPress.ts @@ -0,0 +1,112 @@ +import React from 'react'; + +import { useUnmount } from '../useUnmount'; + +export type UseLongPressOptions = { + /** Time in milliseconds to trigger the long press, default is 400ms */ + threshold?: number; + + /** Callback triggered when the long press starts */ + onStart?: (event: React.MouseEvent | React.TouchEvent) => void; + + /** Callback triggered when the long press finishes */ + onFinish?: (event: React.MouseEvent | React.TouchEvent) => void; + + /** Callback triggered when the long press is canceled */ + onCancel?: (event: React.MouseEvent | React.TouchEvent) => void; +}; + +export type UseLongPressReturnValue = { + onMouseDown: (event: React.MouseEvent) => void; + onMouseUp: (event: React.MouseEvent) => void; + onMouseLeave: (event: React.MouseEvent) => void; + onTouchStart: (event: React.TouchEvent) => void; + onTouchEnd: (event: React.TouchEvent) => void; +}; + +export function useLongPress( + onLongPress: (event: React.MouseEvent | React.TouchEvent) => void, + options: UseLongPressOptions = {} +): UseLongPressReturnValue { + const { + threshold = 400, + onStart, + onFinish, + onCancel + } = options; + const isLongPressActive = React.useRef(false); + const isPressed = React.useRef(false); + const timeout = React.useRef(-1); + + useUnmount(() => window.clearTimeout(timeout.current)); + + return React.useMemo(() => { + if (typeof onLongPress !== 'function') { + return {} as UseLongPressReturnValue; + } + + const start = (event: React.MouseEvent | React.TouchEvent) => { + if (!isMouseEvent(event) && !isTouchEvent(event)) { + return; + } + + if (onStart) { + onStart(event); + } + + isPressed.current = true; + timeout.current = window.setTimeout(() => { + onLongPress(event); + isLongPressActive.current = true; + }, threshold); + }; + + const cancel = (event: React.MouseEvent | React.TouchEvent) => { + if (!isMouseEvent(event) && !isTouchEvent(event)) { + return; + } + + if (isLongPressActive.current) { + if (onFinish) { + onFinish(event); + } + } + else if (isPressed.current) { + if (onCancel) { + onCancel(event); + } + } + + isLongPressActive.current = false; + isPressed.current = false; + + if (timeout.current) { + window.clearTimeout(timeout.current); + } + }; + + return { + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, + onTouchStart: start, + onTouchEnd: cancel + }; + }, [ + onLongPress, + threshold, + onCancel, + onFinish, + onStart + ]); +} + +function isTouchEvent(event: React.MouseEvent | React.TouchEvent): event is React.TouchEvent { + return window.TouchEvent + ? event.nativeEvent instanceof TouchEvent + : 'touches' in event.nativeEvent; +} + +function isMouseEvent(event: React.MouseEvent | React.TouchEvent): event is React.MouseEvent { + return event.nativeEvent instanceof MouseEvent; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMouse/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/index.ts new file mode 100644 index 00000000..1cec893a --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/index.ts @@ -0,0 +1 @@ +export * from './useMouse'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts new file mode 100644 index 00000000..b3fd5cd6 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts @@ -0,0 +1,149 @@ +import React from 'react'; + +import { clamp } from '~@lib/clamp'; + +import { useOnMount } from '../useOnMount'; +import { useStableCallback } from '../useStableCallback'; + +export type UseMovePosition = { + x: number; + y: number; +}; + +export function clampUseMovePosition(position: UseMovePosition) { + return { + x: clamp(position.x, 0, 1), + y: clamp(position.y, 0, 1) + }; +} + +export type UseMoveHandlers = { + onScrubStart?: () => void; + onScrubEnd?: () => void; +}; + +export type UseMoveReturnValue = { + ref: React.RefCallback; + active: boolean; +}; + +export function useMove( + onChange: (value: UseMovePosition) => void, + handlers?: UseMoveHandlers, + dir: 'ltr' | 'rtl' = 'ltr' +): UseMoveReturnValue { + const mounted = React.useRef(false); + const isSliding = React.useRef(false); + const frame = React.useRef(0); + const [active, setActive] = React.useState(false); + const cleanupRef = React.useRef<(() => void) | null>(null); + + useOnMount(() => { + mounted.current = true; + }); + + const refCallback: React.RefCallback = useStableCallback( + (node) => { + // Clean up previous node if it exists + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + + if (!node) { + return; + } + + const onScrub = ({ x, y }: UseMovePosition) => { + cancelAnimationFrame(frame.current); + + frame.current = requestAnimationFrame(() => { + if (mounted.current && node) { + node.style.userSelect = 'none'; + const rect = node.getBoundingClientRect(); + + if (rect.width && rect.height) { + const _x = clamp((x - rect.left) / rect.width, 0, 1); + onChange({ + x: dir === 'ltr' ? _x : 1 - _x, + y: clamp((y - rect.top) / rect.height, 0, 1) + }); + } + } + }); + }; + + const bindEvents = () => { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', stopScrubbing); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', stopScrubbing); + }; + + const unbindEvents = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', stopScrubbing); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', stopScrubbing); + }; + + function startScrubbing() { + if (!isSliding.current && mounted.current) { + isSliding.current = true; + typeof handlers?.onScrubStart === 'function' && handlers.onScrubStart(); + setActive(true); + bindEvents(); + } + } + + function stopScrubbing() { + if (isSliding.current && mounted.current) { + isSliding.current = false; + setActive(false); + unbindEvents(); + setTimeout(() => { + typeof handlers?.onScrubEnd === 'function' && handlers.onScrubEnd(); + }, 0); + } + } + + function onMouseMove(event: MouseEvent) { + onScrub({ x: event.clientX, y: event.clientY }); + } + + function onMouseDown(event: MouseEvent) { + startScrubbing(); + event.preventDefault(); + onMouseMove(event); + } + + function onTouchMove(event: TouchEvent) { + if (event.cancelable) { + event.preventDefault(); + } + + onScrub({ x: event.changedTouches[0]?.clientX ?? 0, y: event.changedTouches[0]?.clientY ?? 0 }); + } + + function onTouchStart(event: TouchEvent) { + if (event.cancelable) { + event.preventDefault(); + } + + startScrubbing(); + onTouchMove(event); + } + + node.addEventListener('mousedown', onMouseDown); + node.addEventListener('touchstart', onTouchStart, { passive: false }); + + // Store cleanup function in ref instead of returning it + cleanupRef.current = () => { + node.removeEventListener('mousedown', onMouseDown); + node.removeEventListener('touchstart', onTouchStart); + }; + } + ); + + return { ref: refCallback, active }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMove/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMove/index.ts new file mode 100644 index 00000000..baaa3d23 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMove/index.ts @@ -0,0 +1 @@ +export * from './useMove'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMove/useMove.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMove/useMove.ts new file mode 100644 index 00000000..09055191 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMove/useMove.ts @@ -0,0 +1,149 @@ +import React from 'react'; + +import { clamp } from '~@lib/clamp'; + +import { useOnMount } from '../useOnMount'; +import { useStableCallback } from '../useStableCallback'; + +export type UseMovePosition = { + x: number; + y: number; +}; + +export function clampUseMovePosition(position: UseMovePosition) { + return { + x: clamp(position.x, 0, 1), + y: clamp(position.y, 0, 1) + }; +} + +export type UseMoveHandlers = { + onScrubStart?: () => void; + onScrubEnd?: () => void; +}; + +export type UseMoveReturnValue = { + ref: React.RefCallback; + active: boolean; +}; + +export function useMove( + onChange: (value: UseMovePosition) => void, + handlers?: UseMoveHandlers, + dir: 'ltr' | 'rtl' = 'ltr' +): UseMoveReturnValue { + const mounted = React.useRef(false); + const isSliding = React.useRef(false); + const frame = React.useRef(0); + const [active, setActive] = React.useState(false); + const cleanupRef = React.useRef<(() => void) | null>(null); + + useOnMount(() => { + mounted.current = true; + }); + + const refCallback: React.RefCallback = useStableCallback( + (node) => { + // Clean up previous node if it exists + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + + if (!node) { + return; + } + + const onScrub = ({ x, y }: UseMovePosition) => { + cancelAnimationFrame(frame.current); + + frame.current = requestAnimationFrame(() => { + if (mounted.current && node) { + node.style.userSelect = 'none'; + const rect = node.getBoundingClientRect(); + + if (rect.width && rect.height) { + const _x = clamp((x - rect.left) / rect.width, 0, 1); + onChange({ + x: dir === 'ltr' ? _x : 1 - _x, + y: clamp((y - rect.top) / rect.height, 0, 1) + }); + } + } + }); + }; + + function bindEvents() { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', stopScrubbing); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', stopScrubbing); + } + + function unbindEvents() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', stopScrubbing); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', stopScrubbing); + } + + function startScrubbing() { + if (!isSliding.current && mounted.current) { + isSliding.current = true; + typeof handlers?.onScrubStart === 'function' && handlers.onScrubStart(); + setActive(true); + bindEvents(); + } + } + + function stopScrubbing() { + if (isSliding.current && mounted.current) { + isSliding.current = false; + setActive(false); + unbindEvents(); + setTimeout(() => { + typeof handlers?.onScrubEnd === 'function' && handlers.onScrubEnd(); + }, 0); + } + } + + function onMouseDown(event: MouseEvent) { + startScrubbing(); + event.preventDefault(); + onMouseMove(event); + } + + function onMouseMove(event: MouseEvent) { + onScrub({ x: event.clientX, y: event.clientY }); + } + + function onTouchStart(event: TouchEvent) { + if (event.cancelable) { + event.preventDefault(); + } + + startScrubbing(); + onTouchMove(event); + } + + function onTouchMove(event: TouchEvent) { + if (event.cancelable) { + event.preventDefault(); + } + + onScrub({ x: event.changedTouches[0]?.clientX ?? 0, y: event.changedTouches[0]?.clientY ?? 0 }); + } + + node.addEventListener('mousedown', onMouseDown); + node.addEventListener('touchstart', onTouchStart, { passive: false }); + + // Store cleanup function in ref instead of returning it + cleanupRef.current = () => { + node.removeEventListener('mousedown', onMouseDown); + node.removeEventListener('touchstart', onTouchStart); + }; + } + ); + + return { ref: refCallback, active }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/index.ts new file mode 100644 index 00000000..f311dee9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/index.ts @@ -0,0 +1 @@ +export * from './useMutationObserver'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts new file mode 100644 index 00000000..cc280cb4 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts @@ -0,0 +1,40 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +type Target = HTMLElement | (() => HTMLElement) | React.RefObject | null; + +function getTargetElement(target?: Target): Node | null { + if (typeof target === 'function') { + return target(); + } + if (target && 'current' in target) { + return target.current; + } + + return target ?? null; +} + +export function useMutationObserver( + callback: MutationCallback, + options: MutationObserverInit, + target?: Target +) { + const observer = React.useRef(null); + const ref = React.useRef(null); + + useIsoLayoutEffect(() => { + const targetElement = getTargetElement(target); + + if (targetElement || ref.current) { + observer.current = new MutationObserver(callback); + observer.current.observe(targetElement || ref.current!, options); + } + + return () => { + observer.current?.disconnect(); + }; + }, [callback, options]); + + return ref; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/index.ts new file mode 100644 index 00000000..9c818f30 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/index.ts @@ -0,0 +1 @@ +export * from './useNetwork'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/useNetwork.ts b/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/useNetwork.ts new file mode 100644 index 00000000..14e07db2 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useNetwork/useNetwork.ts @@ -0,0 +1,68 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; +import { useWindowEvent } from '../useWindowEvent'; + +export type UserNetworkReturnValue = { + online: boolean; + downlink?: number; + downlinkMax?: number; + effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; + rtt?: number; + saveData?: boolean; + type?: 'bluetooth' | 'cellular' | 'ethernet' | 'wifi' | 'wimax' | 'none' | 'other' | 'unknown'; +}; + +function getConnection(): Omit { + if (typeof navigator === 'undefined') { + return {}; + } + + const _navigator = navigator as any; + const connection: any + = _navigator.connection || _navigator.mozConnection || _navigator.webkitConnection; + + if (!connection) { + return {}; + } + + return { + downlink: connection?.downlink, + downlinkMax: connection?.downlinkMax, + effectiveType: connection?.effectiveType, + rtt: connection?.rtt, + saveData: connection?.saveData, + type: connection?.type + }; +} + +export function useNetwork(): UserNetworkReturnValue { + const [status, setStatus] = React.useState({ online: true }); + + const handleConnectionChange = useStableCallback( + () => setStatus((current) => ({ ...current, ...getConnection() })) + ); + + useWindowEvent('online', () => setStatus({ online: true, ...getConnection() })); + useWindowEvent('offline', () => setStatus({ online: false, ...getConnection() })); + + useIsoLayoutEffect(() => { + const _navigator = navigator as any; + + if (_navigator.connection) { + setStatus({ online: _navigator.onLine, ...getConnection() }); + _navigator.connection.addEventListener('change', handleConnectionChange); + return () => _navigator.connection.removeEventListener('change', handleConnectionChange); + } + + if (typeof _navigator.onLine === 'boolean') { + // Required for Firefox and other browsers that don't support navigator.connection + setStatus((current) => ({ ...current, online: _navigator.onLine })); + } + + return undefined; + }, []); + + return status; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts new file mode 100644 index 00000000..b002d18a --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts @@ -0,0 +1,77 @@ +import { useState } from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export type UseOrientationOptions = { + /** + * Default angle value, used until the real can be retrieved + * (during server side rendering and before js executes on the page) + * If not provided, the default value is `0` + */ + defaultAngle?: number; + + /** + * Default angle value, used until the real can be retrieved + * (during server side rendering and before js executes on the page) + * If not provided, the default value is `'landscape-primary'` + */ + defaultType?: OrientationType; + + /** + * If true, the initial value will be resolved in useEffect (ssr safe) + * If false, the initial value will be resolved in useLayoutEffect (ssr unsafe) + * True by default. + */ + getInitialValueInEffect?: boolean; +}; + +export type UseOrientationReturnType = { + angle: number; + type: OrientationType; +}; + +function getInitialValue( + initialValue: UseOrientationReturnType, + getInitialValueInEffect: boolean +): UseOrientationReturnType { + if (getInitialValueInEffect) { + return initialValue; + } + + if (typeof window !== 'undefined' && 'screen' in window) { + return { + angle: window.screen.orientation?.angle ?? initialValue.angle, + type: window.screen.orientation?.type ?? initialValue.type + }; + } + + return initialValue; +} + +export function useOrientation({ + defaultAngle = 0, + defaultType = 'landscape-primary', + getInitialValueInEffect = true +}: UseOrientationOptions = {}): UseOrientationReturnType { + const [orientation, setOrientation] = useState( + getInitialValue( + { + angle: defaultAngle, + type: defaultType + }, + getInitialValueInEffect + ) + ); + + const handleOrientationChange = (event: Event) => { + const target = event.currentTarget as ScreenOrientation; + setOrientation({ angle: target?.angle || 0, type: target?.type || 'landscape-primary' }); + }; + + useIsoLayoutEffect(() => { + window.screen.orientation?.addEventListener('change', handleOrientationChange); + return () => window.screen.orientation?.removeEventListener('change', handleOrientationChange); + }, []); + + return orientation; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOs/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOs/index.ts new file mode 100644 index 00000000..c00db2e9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOs/index.ts @@ -0,0 +1 @@ +export * from './useOs'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOs/useOs.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOs/useOs.ts new file mode 100644 index 00000000..b1d6a940 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOs/useOs.ts @@ -0,0 +1,94 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export type UseOSReturnValue + = | 'undetermined' + | 'macos' + | 'ios' + | 'windows' + | 'android' + | 'linux' + | 'chromeos'; + +function isMacOS(userAgent: string): boolean { + const macosPattern = /Macintosh|MacIntel|MacPPC|Mac68K/i; + + return macosPattern.test(userAgent); +} + +function isIOS(userAgent: string): boolean { + const iosPattern = /iPhone|iPad|iPod/i; + + return iosPattern.test(userAgent); +} + +function isWindows(userAgent: string): boolean { + const windowsPattern = /Win32|Win64|Windows|WinCE/i; + + return windowsPattern.test(userAgent); +} + +function isAndroid(userAgent: string): boolean { + const androidPattern = /Android/i; + + return androidPattern.test(userAgent); +} + +function isLinux(userAgent: string): boolean { + const linuxPattern = /Linux/i; + + return linuxPattern.test(userAgent); +} + +function isChromeOS(userAgent: string): boolean { + const chromePattern = /CrOS/i; + return chromePattern.test(userAgent); +} + +function getOS(): UseOSReturnValue { + if (typeof window === 'undefined') { + return 'undetermined'; + } + + const { userAgent } = window.navigator; + + if (isIOS(userAgent) || (isMacOS(userAgent) && 'ontouchend' in document)) { + return 'ios'; + } + if (isMacOS(userAgent)) { + return 'macos'; + } + if (isWindows(userAgent)) { + return 'windows'; + } + if (isAndroid(userAgent)) { + return 'android'; + } + if (isLinux(userAgent)) { + return 'linux'; + } + if (isChromeOS(userAgent)) { + return 'chromeos'; + } + + return 'undetermined'; +} + +export type UseOsOptions = { + getValueInEffect: boolean; +}; + +export function useOs(options: UseOsOptions = { getValueInEffect: true }): UseOSReturnValue { + const [value, setValue] = React.useState( + options.getValueInEffect ? 'undetermined' : getOS() + ); + + useIsoLayoutEffect(() => { + if (options.getValueInEffect) { + setValue(getOS); + } + }, []); + + return value; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts new file mode 100644 index 00000000..3374ded1 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts @@ -0,0 +1,85 @@ +import { + useEffect, + useMemo, + useRef, + useState +} from 'react'; + +type ObserverRect = Omit; + +const defaultState: ObserverRect = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0 +}; + +export function useResizeObserver(options?: ResizeObserverOptions) { + const frameID = useRef(0); + const ref = useRef(null); + + const [rect, setRect] = useState(defaultState); + + const observer = useMemo( + () => + typeof window !== 'undefined' + ? new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + cancelAnimationFrame(frameID.current); + + frameID.current = requestAnimationFrame(() => { + if (ref.current) { + const boxSize = entry.borderBoxSize?.[0] || entry.contentBoxSize?.[0]; + if (boxSize) { + const width = boxSize.inlineSize; + const height = boxSize.blockSize; + + setRect({ + width, + height, + x: entry.contentRect.x, + y: entry.contentRect.y, + top: entry.contentRect.top, + left: entry.contentRect.left, + bottom: entry.contentRect.bottom, + right: entry.contentRect.right + }); + } + else { + setRect(entry.contentRect); + } + } + }); + } + }) + : null, + [] + ); + + useEffect(() => { + if (ref.current) { + observer?.observe(ref.current, options); + } + + return () => { + observer?.disconnect(); + + if (frameID.current) { + cancelAnimationFrame(frameID.current); + } + }; + }, [ref.current]); + + return [ref, rect] as const; +} + +export function useElementSize(options?: ResizeObserverOptions) { + const [ref, { width, height }] = useResizeObserver(options); + return { ref, width, height }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts new file mode 100644 index 00000000..443de21d --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { useReducedMotion } from '../use-reduced-motion/use-reduced-motion'; +import { useWindowEvent } from '../useWindowEvent'; + +type UseScrollIntoViewAnimation = { + /** Target element alignment relatively to parent based on current axis */ + alignment?: 'start' | 'end' | 'center'; +}; + +export type UseScrollIntoViewOptions = { + /** Callback fired after scroll */ + onScrollFinish?: () => void; + + /** Duration of scroll in milliseconds */ + duration?: number; + + /** Axis of scroll */ + axis?: 'x' | 'y'; + + /** Custom mathematical easing function */ + easing?: (t: number) => number; + + /** Additional distance between nearest edge and element */ + offset?: number; + + /** Indicator if animation may be interrupted by user scrolling */ + cancelable?: boolean; + + /** Prevents content jumping in scrolling lists with multiple targets */ + isList?: boolean; +}; + +export type UseScrollIntoViewReturnValue< + Target extends HTMLElement = any, + Parent extends HTMLElement | null = null +> = { + scrollableRef: React.RefObject; + targetRef: React.RefObject; + scrollIntoView: (params?: UseScrollIntoViewAnimation) => void; + cancel: () => void; +}; + +export function useScrollIntoView< + Target extends HTMLElement = any, + Parent extends HTMLElement | null = null +>({ + duration = 1250, + axis = 'y', + onScrollFinish, + easing = easeInOutQuad, + offset = 0, + cancelable = true, + isList = false +}: UseScrollIntoViewOptions = {}): UseScrollIntoViewReturnValue { + const frameID = useRef(0); + const startTime = useRef(0); + const shouldStop = useRef(false); + + const scrollableRef = useRef(null); + const targetRef = useRef(null); + + const reducedMotion = useReducedMotion(); + + const cancel = (): void => { + if (frameID.current) { + cancelAnimationFrame(frameID.current); + } + }; + + const scrollIntoView = useCallback( + ({ alignment = 'start' }: UseScrollIntoViewAnimation = {}) => { + shouldStop.current = false; + + if (frameID.current) { + cancel(); + } + + const start = getScrollStart({ parent: scrollableRef.current, axis }) ?? 0; + + const change + = getRelativePosition({ + parent: scrollableRef.current, + target: targetRef.current, + axis, + alignment, + offset, + isList + }) - (scrollableRef.current ? 0 : start); + + function animateScroll() { + if (startTime.current === 0) { + startTime.current = performance.now(); + } + + const now = performance.now(); + const elapsed = now - startTime.current; + + // Easing timing progress + const t = reducedMotion || duration === 0 ? 1 : elapsed / duration; + + const distance = start + change * easing(t); + + setScrollParam({ + parent: scrollableRef.current, + axis, + distance + }); + + if (!shouldStop.current && t < 1) { + frameID.current = requestAnimationFrame(animateScroll); + } + else { + typeof onScrollFinish === 'function' && onScrollFinish(); + startTime.current = 0; + frameID.current = 0; + cancel(); + } + } + animateScroll(); + }, + [ + axis, + duration, + easing, + isList, + offset, + onScrollFinish, + reducedMotion + ] + ); + + const handleStop = () => { + if (cancelable) { + shouldStop.current = true; + } + }; + + /** + * Detection of one of these events stops scroll animation + * wheel - mouse wheel / touch pad + * touchmove - any touchable device + */ + + useWindowEvent('wheel', handleStop, { + passive: true + }); + + useWindowEvent('touchmove', handleStop, { + passive: true + }); + + // Cleanup requestAnimationFrame + useEffect(() => cancel, []); + + return { + scrollableRef, + targetRef, + scrollIntoView, + cancel + }; +} + +// --------------------------------------------------- +// Helpers +// --------------------------------------------------- + +function easeInOutQuad(t: number) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; +} + +function getRelativePosition({ + axis, + target, + parent, + alignment, + offset, + isList +}: any): number { + if (!target || (!parent && typeof document === 'undefined')) { + return 0; + } + const isCustomParent = !!parent; + const parentElement = parent || document.body; + const parentPosition = parentElement.getBoundingClientRect(); + const targetPosition = target.getBoundingClientRect(); + + const getDiff = (property: 'top' | 'left'): number => + targetPosition[property] - parentPosition[property]; + + if (axis === 'y') { + const diff = getDiff('top'); + + if (diff === 0) { + return 0; + } + + if (alignment === 'start') { + const distance = diff - offset; + const shouldScroll = distance <= targetPosition.height * (isList ? 0 : 1) || !isList; + + return shouldScroll ? distance : 0; + } + + const parentHeight = isCustomParent ? parentPosition.height : window.innerHeight; + + if (alignment === 'end') { + const distance = diff + offset - parentHeight + targetPosition.height; + const shouldScroll = distance >= -targetPosition.height * (isList ? 0 : 1) || !isList; + + return shouldScroll ? distance : 0; + } + + if (alignment === 'center') { + return diff - parentHeight / 2 + targetPosition.height / 2; + } + + return 0; + } + + if (axis === 'x') { + const diff = getDiff('left'); + + if (diff === 0) { + return 0; + } + + if (alignment === 'start') { + const distance = diff - offset; + const shouldScroll = distance <= targetPosition.width || !isList; + + return shouldScroll ? distance : 0; + } + + const parentWidth = isCustomParent ? parentPosition.width : window.innerWidth; + + if (alignment === 'end') { + const distance = diff + offset - parentWidth + targetPosition.width; + const shouldScroll = distance >= -targetPosition.width || !isList; + + return shouldScroll ? distance : 0; + } + + if (alignment === 'center') { + return diff - parentWidth / 2 + targetPosition.width / 2; + } + + return 0; + } + + return 0; +} + +function getScrollStart({ axis, parent }: any) { + if (!parent && typeof document === 'undefined') { + return 0; + } + + const method = axis === 'y' ? 'scrollTop' : 'scrollLeft'; + + if (parent) { + return parent[method]; + } + + const { body, documentElement } = document; + + // While one of it has a value the second is equal 0 + return body[method] + documentElement[method]; +} + +function setScrollParam({ axis, parent, distance }: any) { + if (!parent && typeof document === 'undefined') { + return; + } + + const method = axis === 'y' ? 'scrollTop' : 'scrollLeft'; + + if (parent) { + parent[method] = distance; + } + else { + const { body, documentElement } = document; + body[method] = distance; + documentElement[method] = distance; + } +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/index.ts new file mode 100644 index 00000000..2350b36d --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/index.ts @@ -0,0 +1 @@ +export * from './useScrollSpy'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts new file mode 100644 index 00000000..664bccc9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from 'react'; + +import { randomId } from '../utils'; + +function getHeadingsData( + headings: HTMLElement[], + getDepth: (element: HTMLElement) => number, + getValue: (element: HTMLElement) => string +): UseScrollSpyHeadingData[] { + const result: UseScrollSpyHeadingData[] = []; + + for (let i = 0; i < headings.length; i += 1) { + const heading = headings[i]; + result.push({ + depth: getDepth(heading), + value: getValue(heading), + id: heading.id || randomId(), + getNode: () => (heading.id ? document.getElementById(heading.id)! : heading) + }); + } + + return result; +} + +function getActiveElement(rects: DOMRect[], offset: number = 0) { + if (rects.length === 0) { + return -1; + } + + const closest = rects.reduce( + (acc, item, index) => { + if (Math.abs(acc.position - offset) < Math.abs(item.y - offset)) { + return acc; + } + + return { + index, + position: item.y + }; + }, + { index: 0, position: rects[0].y } + ); + + return closest.index; +} + +function getDefaultDepth(element: HTMLElement) { + return Number(element.tagName[1]); +} + +function getDefaultValue(element: HTMLElement) { + return element.textContent || ''; +} + +export type UseScrollSpyHeadingData = { + /** Heading depth, 1-6 */ + depth: number; + + /** Heading text content value */ + value: string; + + /** Heading id */ + id: string; + + /** Function to get heading node */ + getNode: () => HTMLElement; +}; + +export type UseScrollSpyOptions = { + /** Selector to get headings, `'h1, h2, h3, h4, h5, h6'` by default */ + selector?: string; + + /** A function to retrieve depth of heading, by default depth is calculated based on tag name */ + getDepth?: (element: HTMLElement) => number; + + /** A function to retrieve heading value, by default `element.textContent` is used */ + getValue?: (element: HTMLElement) => string; + + /** Host element to attach scroll event listener, if not provided, `window` is used */ + scrollHost?: HTMLElement; + + /** Offset from the top of the viewport to use when determining the active heading, `0` by default */ + offset?: number; +}; + +export type UseScrollSpyReturnType = { + /** Index of the active heading in the `data` array */ + active: number; + + /** Headings data. If not initialize, data is represented by an empty array. */ + data: UseScrollSpyHeadingData[]; + + /** True if headings value have been retrieved from the DOM. */ + initialized: boolean; + + /** Function to update headings values after the parent component has mounted. */ + reinitialize: () => void; +}; + +export function useScrollSpy({ + selector = 'h1, h2, h3, h4, h5, h6', + getDepth = getDefaultDepth, + getValue = getDefaultValue, + offset = 0, + scrollHost +}: UseScrollSpyOptions = {}): UseScrollSpyReturnType { + const [active, setActive] = useState(-1); + const [initialized, setInitialized] = useState(false); + const [data, setData] = useState([]); + const headingsRef = useRef([]); + + const handleScroll = () => { + setActive( + getActiveElement( + headingsRef.current.map((d) => d.getNode().getBoundingClientRect()), + offset + ) + ); + }; + + const initialize = () => { + const headings = getHeadingsData( + Array.from(document.querySelectorAll(selector)), + getDepth, + getValue + ); + headingsRef.current = headings; + setInitialized(true); + setData(headings); + setActive( + getActiveElement( + headings.map((d) => d.getNode().getBoundingClientRect()), + offset + ) + ); + }; + + useEffect(() => { + initialize(); + const _scrollHost = scrollHost || window; + _scrollHost.addEventListener('scroll', handleScroll); + return () => _scrollHost.removeEventListener('scroll', handleScroll); + }, [scrollHost]); + + return { + reinitialize: initialize, + active, + initialized, + data + }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/index.ts new file mode 100644 index 00000000..3323b9d8 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/index.ts @@ -0,0 +1 @@ +export * from './useTextSelection'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/useTextSelection.ts b/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/useTextSelection.ts new file mode 100644 index 00000000..c73ce5aa --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useTextSelection/useTextSelection.ts @@ -0,0 +1,22 @@ +import React from 'react'; + +import { useForcedRerendering } from '../useForcedRerendering'; +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export function useTextSelection(): Selection | null { + const forceUpdate = useForcedRerendering(); + const [selection, setSelection] = React.useState(null); + + const handleSelectionChange = () => { + setSelection(document.getSelection()); + forceUpdate(); + }; + + useIsoLayoutEffect(() => { + setSelection(document.getSelection()); + document.addEventListener('selectionchange', handleSelectionChange); + return () => document.removeEventListener('selectionchange', handleSelectionChange); + }, []); + + return selection; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/index.ts new file mode 100644 index 00000000..64e215a9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/index.ts @@ -0,0 +1 @@ +export * from './useViewportSize'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/useViewportSize.ts b/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/useViewportSize.ts new file mode 100644 index 00000000..fd7b30cc --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useViewportSize/useViewportSize.ts @@ -0,0 +1,26 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; +import { useWindowEvent } from '../useWindowEvent'; + +const eventListerOptions = { + passive: true +}; + +export function useViewportSize() { + const [windowSize, setWindowSize] = React.useState({ + width: 0, + height: 0 + }); + + const setSize = useStableCallback(() => { + setWindowSize({ width: window.innerWidth || 0, height: window.innerHeight || 0 }); + }); + + useWindowEvent('resize', setSize, eventListerOptions); + useWindowEvent('orientationchange', setSize, eventListerOptions); + useIsoLayoutEffect(setSize, []); + + return windowSize; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/index.ts new file mode 100644 index 00000000..6b8246d8 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/index.ts @@ -0,0 +1 @@ +export * from './useWindowEvent'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/useWindowEvent.ts b/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/useWindowEvent.ts new file mode 100644 index 00000000..db88811b --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useWindowEvent/useWindowEvent.ts @@ -0,0 +1,19 @@ +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export function useWindowEvent( + type: K, + listener: K extends keyof WindowEventMap + ? (this: Window, ev: WindowEventMap[K]) => void + : (this: Window, ev: CustomEvent) => void, + options?: boolean | AddEventListenerOptions +) { + useIsoLayoutEffect(() => { + if (!window) + return; + + // eslint-disable-next-line react-web-api/no-leaked-event-listener + window.addEventListener(type as any, listener, options); + + return () => window.removeEventListener(type as any, listener, options); + }, [type, listener]); +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/index.ts new file mode 100644 index 00000000..9452ea1c --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/index.ts @@ -0,0 +1 @@ +export * from './useWindowScroll'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/useWindowScroll.ts b/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/useWindowScroll.ts new file mode 100644 index 00000000..53838879 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useWindowScroll/useWindowScroll.ts @@ -0,0 +1,45 @@ +import React from 'react'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useWindowEvent } from '../useWindowEvent'; + +export type UseWindowScrollPosition = { + x: number; + y: number; +}; + +export type UseWindowScrollTo = (position: Partial) => void; +export type UseWindowScrollReturnValue = [UseWindowScrollPosition, UseWindowScrollTo]; + +function getScrollPosition(): UseWindowScrollPosition { + return typeof window !== 'undefined' ? { x: window.scrollX, y: window.scrollY } : { x: 0, y: 0 }; +} + +function scrollTo({ x, y }: Partial) { + if (typeof window !== 'undefined') { + const scrollOptions: ScrollToOptions = { behavior: 'smooth' }; + + if (typeof x === 'number') { + scrollOptions.left = x; + } + + if (typeof y === 'number') { + scrollOptions.top = y; + } + + window.scrollTo(scrollOptions); + } +} + +export function useWindowScroll(): UseWindowScrollReturnValue { + const [position, setPosition] = React.useState({ x: 0, y: 0 }); + + useWindowEvent('scroll', () => setPosition(getScrollPosition())); + useWindowEvent('resize', () => setPosition(getScrollPosition())); + + useIsoLayoutEffect(() => { + setPosition(getScrollPosition()); + }, []); + + return [position, scrollTo] as const; +} diff --git a/packages/ui/uikit/headless/hooks/src/lib/clamp.ts b/packages/ui/uikit/headless/hooks/src/lib/clamp.ts new file mode 100644 index 00000000..f4e47c3e --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/lib/clamp.ts @@ -0,0 +1,7 @@ +export function clamp( + val: number, + min: number = Number.MIN_SAFE_INTEGER, + max: number = Number.MAX_SAFE_INTEGER +): number { + return Math.max(min, Math.min(val, max)); +} From f7e667470b30272218dfd3a2f744c966670a2374 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 8 Feb 2026 01:01:20 +0300 Subject: [PATCH 12/16] feat(headless/hooks): add new utility hooks including useFileDialog, useFocus, useFocusReturn, useFocusTrap, useFocusWithin, useOrientation, useRefAsState, useResizeObserver, and useScrollIntoView; update useClickOutside and useMutationObserver for improved target handling --- .../uikit/headless/hooks/src/hooks/index.ts | 14 +- .../useAnimationsFinished.ts | 19 +- .../hooks/useClickOutside/useClickOutside.ts | 85 ++++++-- .../src/hooks/useDidUpdate/useDidUpdate.ts | 2 +- .../hooks/src/hooks/useFileDialog/index.ts | 1 + .../src/hooks/useFileDialog/useFileDialog.ts | 134 +++++++++++++ .../hooks/src/hooks/useFocus/index.ts | 1 + .../hooks/src/hooks/useFocus/useFocus.ts | 126 ++++++++++++ .../hooks/src/hooks/useFocusReturn/index.ts | 1 + .../hooks/useFocusReturn/useFocusReturn.ts | 52 +++++ .../hooks/src/hooks/useFocusTrap/index.ts | 1 + .../src/hooks/useFocusTrap/useFocusTrap.ts | 102 ++++++++++ .../hooks/src/hooks/useFocusWithin/index.ts | 1 + .../hooks/useFocusWithin/useFocusWithin.ts | 82 ++++++++ .../src/hooks/useMediaQuery/useMediaQuery.ts | 4 +- .../hooks/src/hooks/useMouse/useMouse.ts | 182 ++++++------------ .../useMutationObserver.ts | 62 +++--- .../hooks/src/hooks/useOrientation/index.ts | 1 + .../hooks/useOrientation/useOrientation.ts | 6 +- .../hooks/src/hooks/useRefAsState/index.ts | 1 + .../src/hooks/useRefAsState/useRefAsState.ts | 53 +++++ .../src/hooks/useResizeObserver/index.ts | 1 + .../useResizeObserver/useResizeObserver.ts | 96 +++++---- .../src/hooks/useScrollIntoView/index.ts | 1 + .../useScrollIntoView/useScrollIntoView.ts | 41 ++-- .../src/hooks/useScrollLock/useScrollLock.ts | 26 ++- .../src/hooks/useScrollSpy/useScrollSpy.ts | 33 ++-- .../uikit/headless/hooks/src/lib/isTarget.ts | 86 +++++++++ .../uikit/headless/hooks/src/lib/randomId.ts | 5 + .../ui/uikit/headless/hooks/src/lib/types.ts | 1 + 30 files changed, 979 insertions(+), 241 deletions(-) create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/useFileDialog.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocus/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocus/useFocus.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/useFocusReturn.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/useFocusWithin.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/index.ts create mode 100644 packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/useRefAsState.ts create mode 100644 packages/ui/uikit/headless/hooks/src/lib/isTarget.ts create mode 100644 packages/ui/uikit/headless/hooks/src/lib/randomId.ts create mode 100644 packages/ui/uikit/headless/hooks/src/lib/types.ts diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index 88695398..14ff6d55 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -13,6 +13,12 @@ export * from './useEventCallback'; export * from './useEventListener'; export * from './useEyeDropper'; export * from './useFavicon'; +export * from './useFileDialog'; +export * from './useFocus'; +export * from './useFocus'; +export * from './useFocusReturn'; +export * from './useFocusTrap'; +export * from './useFocusWithin'; export * from './useForcedRerendering'; export * from './useFullscreen'; export * from './useHash'; @@ -28,7 +34,6 @@ export * from './useIsoLayoutEffect'; export * from './useLatestRef'; export * from './useLazyRef'; export * from './useLocalStorage'; -export * from './useLocalStorage'; export * from './useLongPress'; export * from './useMediaQuery'; export * from './useMergedRef'; @@ -41,8 +46,12 @@ export * from './useOnFirstRender'; export * from './useOnMount'; export * from './useOpenChangeComplete'; export * from './useOpenInteractionType'; +export * from './useOrientation'; export * from './useOs'; export * from './usePreviousValue'; +export * from './useRefAsState'; +export * from './useResizeObserver'; +export * from './useScrollIntoView'; export * from './useScrollLock'; export * from './useScrollSpy'; export * from './useSsr'; @@ -55,3 +64,6 @@ export * from './useUnmount'; export * from './useViewportSize'; export * from './useWindowEvent'; export * from './useWindowScroll'; + +export { isTarget, target } from '~@lib/isTarget'; +export type { HookTarget, Target } from '~@lib/isTarget'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts b/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts index 5ce4d171..61c61c11 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts @@ -1,13 +1,16 @@ import type React from 'react'; import ReactDOM from 'react-dom'; +import { isTarget } from '~@lib/isTarget'; import { resolveRef } from '~@lib/resolveRef'; +import type { HookTarget } from '~@lib/isTarget'; + import { useAnimationFrame } from '../useAnimationFrame'; import { useStableCallback } from '../useStableCallback'; export function useAnimationsFinished( - elementOrRef: React.RefObject | HTMLElement | null, + elementOrRef: HookTarget | React.RefObject | HTMLElement | null, waitForNextTick = false, treatAbortedAsFinished = true ) { @@ -28,7 +31,19 @@ export function useAnimationsFinished( ) => { frame.cancel(); - const element = resolveRef(elementOrRef); + let element: HTMLElement | null = null; + if (elementOrRef) { + if (elementOrRef instanceof HTMLElement) { + element = elementOrRef; + } + else if (isTarget(elementOrRef as HookTarget)) { + element = isTarget.getElement(elementOrRef as HookTarget) as HTMLElement | null; + } + else { + element = resolveRef(elementOrRef as React.RefObject); + } + } + if (element == null) { return; } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts index 641d252a..d51d8472 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts @@ -1,30 +1,81 @@ -import React from 'react'; +import { isTarget } from '~@lib/isTarget'; + +import type { HookTarget } from '~@lib/isTarget'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useRefAsState } from '../useRefAsState'; +import { useStableCallback } from '../useStableCallback'; +import { useValueAsRef } from '../useValueAsRef'; + +import type { RefAsState } from '../useRefAsState'; type EventType = MouseEvent | TouchEvent; const DEFAULT_EVENTS = ['mousedown', 'touchstart']; -export function useClickOutside( +export type UseClickOutsideOptions = { + /** DOM events that trigger the callback */ + events?: string[] | null; + /** Additional nodes to exclude from click outside detection */ + nodes?: (HookTarget | HTMLElement | null)[]; +}; + +function resolveNode(node: HookTarget | HTMLElement | null): HTMLElement | null { + if (!node) + return null; + if (node instanceof HTMLElement) + return node; + if (isTarget(node)) + return isTarget.getElement(node) as HTMLElement | null; + return null; +} + +export function useClickOutside( + target: HookTarget, + callback: (event: EventType) => void, + options?: UseClickOutsideOptions +): void; + +export function useClickOutside( callback: (event: EventType) => void, - events?: string[] | null, - nodes?: (HTMLElement | null)[] -) { - const ref = React.useRef(null); + options?: UseClickOutsideOptions +): { ref: RefAsState }; + +export function useClickOutside( + ...args: + | [HookTarget, (event: EventType) => void, UseClickOutsideOptions?] + | [(event: EventType) => void, UseClickOutsideOptions?] +): void | { ref: RefAsState } { + const target = (isTarget(args[0] as HookTarget) ? args[0] : undefined) as HookTarget | undefined; + const callback = (target ? args[1] : args[0]) as (event: EventType) => void; + const options = (target ? args[2] : args[1]) as UseClickOutsideOptions | undefined; + + const events = options?.events; + const nodes = options?.nodes; + + const internalRef = useRefAsState(); + const nodesRef = useValueAsRef(nodes); + const eventsList = events || DEFAULT_EVENTS; + const stableCallback = useStableCallback(callback); + + const element = target ? isTarget.getElement(target) : internalRef.current; + useIsoLayoutEffect(() => { const listener = (event: Event) => { - const { target } = event ?? {}; - if (Array.isArray(nodes)) { + const { target: eventTarget } = event ?? {}; + const currentNodes = nodesRef.current; + + if (Array.isArray(currentNodes) && currentNodes.length > 0) { + const resolvedNodes = currentNodes.map(resolveNode).filter(Boolean) as HTMLElement[]; const shouldIgnore - = !document.body.contains(target as Node) && (target as Element)?.tagName !== 'HTML'; - const shouldTrigger = nodes.every((node) => !!node && !event.composedPath().includes(node)); - shouldTrigger && !shouldIgnore && callback(event as EventType); + = !document.body.contains(eventTarget as Node) && (eventTarget as Element)?.tagName !== 'HTML'; + const shouldTrigger = resolvedNodes.every((node) => !!node && !event.composedPath().includes(node)); + shouldTrigger && !shouldIgnore && stableCallback(event as EventType); } - else if (ref.current && !ref.current.contains(target as Node)) { - callback(event as EventType); + else if (element && element instanceof Element && !element.contains(eventTarget as Node)) { + stableCallback(event as EventType); } }; @@ -33,7 +84,11 @@ export function useClickOutside( return () => { eventsList.forEach((fn) => document.removeEventListener(fn, listener)); }; - }, [ref, callback, nodes]); + }, [element, eventsList]); + + if (target) { + return undefined; + } - return ref; + return { ref: internalRef }; } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useDidUpdate/useDidUpdate.ts b/packages/ui/uikit/headless/hooks/src/hooks/useDidUpdate/useDidUpdate.ts index 33cfa115..93d1e771 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useDidUpdate/useDidUpdate.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useDidUpdate/useDidUpdate.ts @@ -14,5 +14,5 @@ export function useDidUpdate(callback: React.EffectCallback, deps?: React.Depend isMountedRef.current = true; return undefined; - }, [deps]); + }, deps); } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/index.ts new file mode 100644 index 00000000..684ae8a9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/index.ts @@ -0,0 +1 @@ +export * from './useFileDialog'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/useFileDialog.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/useFileDialog.ts new file mode 100644 index 00000000..00c5e9e5 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFileDialog/useFileDialog.ts @@ -0,0 +1,134 @@ +import React from 'react'; + +import { useEventCallback } from '../useEventCallback'; +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useValueAsRef } from '../useValueAsRef'; + +export type UseFileDialogOptions = { + /** Determines whether multiple files are allowed, `true` by default */ + multiple?: boolean; + + /** `accept` attribute of the file input, '*' by default */ + accept?: string; + + /** `capture` attribute of the file input */ + capture?: string; + + /** Determines whether the user can pick a directory instead of file, `false` by default */ + directory?: boolean; + + /** Determines whether the file input state should be reset when the file dialog is opened, `false` by default */ + resetOnOpen?: boolean; + + /** Initial selected files */ + initialFiles?: FileList | File[]; + + /** Called when files are selected */ + onChange?: (files: FileList | null) => void; + + /** Called when file dialog is canceled */ + onCancel?: () => void; +}; + +const defaultOptions: UseFileDialogOptions = { + multiple: true, + accept: '*' +}; + +function getInitialFilesList(files: UseFileDialogOptions['initialFiles']): FileList | null { + if (!files) { + return null; + } + + if (files instanceof FileList) { + return files; + } + + const result = new DataTransfer(); + for (const file of files) { + result.items.add(file); + } + + return result.files; +} + +function createInput(options: UseFileDialogOptions) { + if (typeof document === 'undefined') { + return null; + } + + const input = document.createElement('input'); + input.type = 'file'; + + if (options.accept) { + input.accept = options.accept; + } + + if (options.multiple) { + input.multiple = options.multiple; + } + + if (options.capture) { + input.capture = options.capture; + } + + if (options.directory) { + input.webkitdirectory = options.directory; + } + + input.style.display = 'none'; + return input; +} + +export type UseFileDialogReturnValue = { + files: FileList | null; + open: () => void; + reset: () => void; +}; + +export function useFileDialog(input: UseFileDialogOptions = {}): UseFileDialogReturnValue { + const options = useValueAsRef({ ...defaultOptions, ...input }); + + const [files, setFiles] = React.useState(() => getInitialFilesList(options.current.initialFiles)); + const inputRef = React.useRef(null); + + const createAndSetupInput = useEventCallback(() => { + inputRef.current?.remove(); + inputRef.current = createInput(options.current); + + if (inputRef.current) { + const handleChange + = (event: Event) => { + const target = event.target as HTMLInputElement; + if (target?.files) { + setFiles(target.files); + options.current.onChange?.(target.files); + } + }; + + inputRef.current.addEventListener('change', handleChange, { once: true }); + document.body.appendChild(inputRef.current); + } + }); + + useIsoLayoutEffect(() => { + createAndSetupInput(); + return () => inputRef.current?.remove(); + }, []); + + const reset = useEventCallback(() => { + setFiles(null); + options.current.onChange?.(null); + }); + + const open = useEventCallback(() => { + if (options.current.resetOnOpen) { + reset(); + } + + createAndSetupInput(); + inputRef.current?.click(); + }); + + return { files, open, reset }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocus/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocus/index.ts new file mode 100644 index 00000000..fb626935 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocus/index.ts @@ -0,0 +1 @@ +export * from './useFocus'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocus/useFocus.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocus/useFocus.ts new file mode 100644 index 00000000..4ec4d99f --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocus/useFocus.ts @@ -0,0 +1,126 @@ +import React from 'react'; + +import { isTarget } from '~@lib/isTarget'; + +import type { HookTarget } from '~@lib/isTarget'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useRefAsState } from '../useRefAsState'; + +import type { RefAsState } from '../useRefAsState'; + +/** The use focus options type */ +export type UseFocusOptions = { + /** The enabled state of the focus hook */ + enabled?: boolean; + /** The initial focus state of the target */ + initialValue?: boolean; + /** The on blur callback */ + onBlur?: (event: FocusEvent) => void; + /** The on focus callback */ + onFocus?: (event: FocusEvent) => void; +}; + +/** The use focus return type */ +export type UseFocusReturn = { + /** The boolean state value of the target */ + focused: boolean; + /** Blur the target */ + blur: () => void; + /** Focus the target */ + focus: () => void; +}; + +export type UseFocus = { + (target: HookTarget, callback?: (event: FocusEvent) => void): UseFocusReturn; + + (target: HookTarget, options?: UseFocusOptions): UseFocusReturn; + + ( + callback?: (event: FocusEvent) => void, + target?: never + ): UseFocusReturn & { ref: RefAsState }; + + ( + options?: UseFocusOptions, + target?: never + ): UseFocusReturn & { ref: RefAsState }; +}; + +export const useFocus = ((...params: any[]) => { + const target = (isTarget(params[0]) ? params[0] : undefined) as HookTarget | undefined; + + const options = ( + target + ? typeof params[1] === 'object' + ? params[1] + : { onFocus: params[1] } + : typeof params[0] === 'object' + ? params[0] + : { onFocus: params[0] } + ) as UseFocusOptions | undefined; + const enabled = options?.enabled ?? true; + const initialValue = options?.initialValue ?? false; + + const [focused, setFocused] = React.useState(initialValue); + const internalRef = useRefAsState(); + const internalOptionsRef = React.useRef(options); + internalOptionsRef.current = options; + + const elementRef = React.useRef(null); + + const focus = () => { + if (!elementRef.current) + return; + elementRef.current.focus(); + setFocused(true); + }; + + const blur = () => { + if (!elementRef.current) + return; + elementRef.current.blur(); + setFocused(false); + }; + + useIsoLayoutEffect(() => { + if (!enabled || (!target && !internalRef.state)) + return; + const element = (target ? isTarget.getElement(target) : internalRef.current) as HTMLElement; + if (!element) + return; + + elementRef.current = element; + + const onFocus = (event: FocusEvent) => { + internalOptionsRef.current?.onFocus?.(event); + if (!focus || (event.target as HTMLElement).matches?.(':focus-visible')) + setFocused(true); + }; + + const onBlur = (event: FocusEvent) => { + internalOptionsRef.current?.onBlur?.(event); + setFocused(false); + }; + + if (initialValue) + element.focus(); + + element.addEventListener('focus', onFocus); + element.addEventListener('blur', onBlur); + + return () => { + element.removeEventListener('focus', onFocus); + element.removeEventListener('blur', onBlur); + }; + }, [target && isTarget.getRawElement(target), internalRef.state, enabled]); + + if (target) + return { focus, blur, focused }; + return { + ref: internalRef, + focus, + blur, + focused + }; +}) as UseFocus; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/index.ts new file mode 100644 index 00000000..ee9d0392 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/index.ts @@ -0,0 +1 @@ +export * from './useFocusReturn'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/useFocusReturn.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/useFocusReturn.ts new file mode 100644 index 00000000..8cfabd7f --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusReturn/useFocusReturn.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +import { useEventCallback } from '../useEventCallback'; +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export type UseFocusReturnOptions = { + manual?: boolean; +}; + +export type UseFocusReturnReturnValue = { + returnFocus: () => void; + saveFocus: () => void; +}; + +export function useFocusReturn({ + manual = false +}: UseFocusReturnOptions): UseFocusReturnReturnValue { + const savedElementRef = React.useRef(null); + + const saveFocus = useEventCallback(() => { + savedElementRef.current = document.activeElement as HTMLElement; + }); + + const returnFocus = useEventCallback(() => { + if ( + savedElementRef.current + && 'focus' in savedElementRef.current + && typeof savedElementRef.current.focus === 'function' + ) { + savedElementRef.current?.focus({ preventScroll: true }); + + return; + } + + if (process.env.NODE_ENV !== 'production') { + console.warn('useFocusReturn: No focusable element was found to return to'); + } + }); + + useIsoLayoutEffect(() => { + if (manual) + return; + + saveFocus(); + + return () => { + returnFocus(); + }; + }, [manual]); + + return { returnFocus, saveFocus }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/index.ts new file mode 100644 index 00000000..62d91f21 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/index.ts @@ -0,0 +1 @@ +export * from './useFocusTrap'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts new file mode 100644 index 00000000..7cdbae49 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts @@ -0,0 +1,102 @@ +import React from 'react'; + +import { isTarget } from '~@lib/isTarget'; + +import type { HookTarget } from '~@lib/isTarget'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +export const FOCUS_SELECTOR = 'a, input, select, textarea, button, object, [tabindex]'; + +function getFocusableElements(element: HTMLElement) { + const elements = Array.from(element.querySelectorAll(FOCUS_SELECTOR)); + return elements.filter((element) => { + const htmlEl = element as HTMLElement; + return htmlEl.tabIndex !== -1 && !htmlEl.hidden && htmlEl.style.display !== 'none'; + }) as HTMLElement[]; +} + +function focusElement(element: HTMLElement) { + const autofocusElement = element.querySelector('[data-autofocus]') as HTMLElement; + if (autofocusElement) + return autofocusElement.focus(); + const focusableElements = getFocusableElements(element); + if (focusableElements.length) + focusableElements[0]?.focus(); +} + +export type UseFocusTrapReturn = { + active: boolean; + disable: () => void; + enable: () => void; + toggle: () => void; +}; + +export function useFocusTrap(target: HookTarget, active?: boolean): UseFocusTrapReturn; +export function useFocusTrap(active?: boolean): UseFocusTrapReturn & { ref: React.RefObject }; +export function useFocusTrap(...args: any[]) { + const target = (isTarget(args[0]) ? args[0] : undefined) as HookTarget; + const initialActive = target ? args[1] : args[0]; + + const [active, setActive] = React.useState(initialActive); + const internalRef = React.useRef(null); + + const enable = () => setActive(true); + const disable = () => setActive(false); + const toggle = () => setActive((prevActive: boolean) => !prevActive); + + useIsoLayoutEffect(() => { + if (!active) + return; + + const element = target ? isTarget.getElement(target) : internalRef.current; + if (!element) + return; + + const htmlElement = element as HTMLElement; + focusElement(htmlElement); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') + return; + + const [firstElement, ...restElements] = getFocusableElements(htmlElement); + if (!restElements.length) + return; + + const lastElement = restElements.at(-1)!; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + + if (document.activeElement === lastElement) { + event.preventDefault(); + firstElement?.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; + }, [active, target && isTarget.getRawElement(target), internalRef.current]); + + if (target) { + return { + active, + enable, + disable, + toggle + }; + } + return { + active, + enable, + disable, + toggle, + ref: internalRef + }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/index.ts new file mode 100644 index 00000000..74b36f44 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/index.ts @@ -0,0 +1 @@ +export * from './useFocusWithin'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/useFocusWithin.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/useFocusWithin.ts new file mode 100644 index 00000000..c531e2e9 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusWithin/useFocusWithin.ts @@ -0,0 +1,82 @@ +import React from 'react'; + +import { useStableCallback } from '../useStableCallback'; +import { useUnmount } from '../useUnmount'; + +function containsRelatedTarget(event: FocusEvent) { + if (event.currentTarget instanceof HTMLElement && event.relatedTarget instanceof HTMLElement) { + return event.currentTarget.contains(event.relatedTarget); + } + + return false; +} + +export type UseFocusWithinOptions = { + onFocus?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; +}; + +export type UseFocusWithinReturnValue = { + ref: React.RefCallback; + focused: boolean; +}; + +export function useFocusWithin({ + onBlur, + onFocus +}: UseFocusWithinOptions = {}): UseFocusWithinReturnValue { + const [focused, setFocused] = React.useState(false); + const focusedRef = React.useRef(false); + const previousNode = React.useRef(null); + + const onFocusRef = useStableCallback(onFocus); + const onBlurRef = useStableCallback(onBlur); + + const _setFocused = React.useCallback((value: boolean) => { + setFocused(value); + focusedRef.current = value; + }, []); + + const handleFocusIn = React.useCallback((event: FocusEvent) => { + if (!focusedRef.current) { + _setFocused(true); + onFocusRef(event); + } + }, [_setFocused, onFocusRef]); + + const handleFocusOut = React.useCallback((event: FocusEvent) => { + if (focusedRef.current && !containsRelatedTarget(event)) { + _setFocused(false); + onBlurRef(event); + } + }, [_setFocused, onBlurRef]); + + const callbackRef: React.RefCallback = React.useCallback( + (node) => { + if (!node) { + return; + } + + if (previousNode.current) { + previousNode.current.removeEventListener('focusin', handleFocusIn); + previousNode.current.removeEventListener('focusout', handleFocusOut); + } + + node.addEventListener('focusin', handleFocusIn); + node.addEventListener('focusout', handleFocusOut); + previousNode.current = node; + }, + [handleFocusIn, handleFocusOut] + ); + + useUnmount( + () => { + if (previousNode.current) { + previousNode.current.removeEventListener('focusin', handleFocusIn); + previousNode.current.removeEventListener('focusout', handleFocusOut); + } + } + ); + + return { ref: callbackRef, focused }; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts index af2cd6fa..f55c67bf 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMediaQuery/useMediaQuery.ts @@ -2,9 +2,9 @@ import * as React from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -export function useMediaQuery(query: string, options: useMediaQuery.Options): boolean { +export function useMediaQuery(query: string, options: useMediaQuery.Options = {}): boolean { // Wait for jsdom to support the match media feature. - // All the browsers Base UI support have this built-in. + // All the browsers Headless UI support have this built-in. // This defensive check is here for simplicity. // Most of the time, the match media logic isn't central to people tests. const supportMatchMedia diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts index b3fd5cd6..b0d0fc0e 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts @@ -1,149 +1,83 @@ import React from 'react'; -import { clamp } from '~@lib/clamp'; +import { isTarget } from '~@lib/isTarget'; -import { useOnMount } from '../useOnMount'; -import { useStableCallback } from '../useStableCallback'; +import type { HookTarget } from '~@lib/isTarget'; -export type UseMovePosition = { +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useRefAsState } from '../useRefAsState'; + +import type { RefAsState } from '../useRefAsState'; + +export type UseMouseOptions = { + /** Reset position to (0, 0) when mouse leaves the element */ + resetOnExit?: boolean; +}; + +export type UseMouseReturn = { x: number; y: number; }; -export function clampUseMovePosition(position: UseMovePosition) { - return { - x: clamp(position.x, 0, 1), - y: clamp(position.y, 0, 1) - }; -} +export function useMouse(target: HookTarget, options?: UseMouseOptions): UseMouseReturn; -export type UseMoveHandlers = { - onScrubStart?: () => void; - onScrubEnd?: () => void; -}; +export function useMouse( + options?: UseMouseOptions +): UseMouseReturn & { ref: RefAsState }; -export type UseMoveReturnValue = { - ref: React.RefCallback; - active: boolean; -}; +export function useMouse( + ...args: [HookTarget, UseMouseOptions?] | [UseMouseOptions?] +): UseMouseReturn | (UseMouseReturn & { ref: RefAsState }) { + const target = (isTarget(args[0] as HookTarget) ? args[0] : undefined) as HookTarget | undefined; + const options = ((target ? args[1] : args[0]) ?? { resetOnExit: false }) as UseMouseOptions; -export function useMove( - onChange: (value: UseMovePosition) => void, - handlers?: UseMoveHandlers, - dir: 'ltr' | 'rtl' = 'ltr' -): UseMoveReturnValue { - const mounted = React.useRef(false); - const isSliding = React.useRef(false); - const frame = React.useRef(0); - const [active, setActive] = React.useState(false); - const cleanupRef = React.useRef<(() => void) | null>(null); - - useOnMount(() => { - mounted.current = true; - }); - - const refCallback: React.RefCallback = useStableCallback( - (node) => { - // Clean up previous node if it exists - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = null; - } + const [position, setPosition] = React.useState({ x: 0, y: 0 }); + const internalRef = useRefAsState(); - if (!node) { - return; - } + const resetMousePosition = () => setPosition({ x: 0, y: 0 }); - const onScrub = ({ x, y }: UseMovePosition) => { - cancelAnimationFrame(frame.current); - - frame.current = requestAnimationFrame(() => { - if (mounted.current && node) { - node.style.userSelect = 'none'; - const rect = node.getBoundingClientRect(); - - if (rect.width && rect.height) { - const _x = clamp((x - rect.left) / rect.width, 0, 1); - onChange({ - x: dir === 'ltr' ? _x : 1 - _x, - y: clamp((y - rect.top) / rect.height, 0, 1) - }); - } - } - }); - }; - - const bindEvents = () => { - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', stopScrubbing); - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', stopScrubbing); - }; - - const unbindEvents = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', stopScrubbing); - document.removeEventListener('touchmove', onTouchMove); - document.removeEventListener('touchend', stopScrubbing); - }; - - function startScrubbing() { - if (!isSliding.current && mounted.current) { - isSliding.current = true; - typeof handlers?.onScrubStart === 'function' && handlers.onScrubStart(); - setActive(true); - bindEvents(); - } - } + const element = target ? isTarget.getElement(target) : internalRef.current; - function stopScrubbing() { - if (isSliding.current && mounted.current) { - isSliding.current = false; - setActive(false); - unbindEvents(); - setTimeout(() => { - typeof handlers?.onScrubEnd === 'function' && handlers.onScrubEnd(); - }, 0); - } - } + useIsoLayoutEffect(() => { + const targetElement = element ?? document; - function onMouseMove(event: MouseEvent) { - onScrub({ x: event.clientX, y: event.clientY }); - } + const setMousePosition = (event: MouseEvent) => { + if (element) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); - function onMouseDown(event: MouseEvent) { - startScrubbing(); - event.preventDefault(); - onMouseMove(event); - } + const x = Math.max( + 0, + Math.round(event.pageX - rect.left - (window.scrollX || window.scrollX)) + ); - function onTouchMove(event: TouchEvent) { - if (event.cancelable) { - event.preventDefault(); - } + const y = Math.max( + 0, + Math.round(event.pageY - rect.top - (window.scrollY || window.scrollY)) + ); - onScrub({ x: event.changedTouches[0]?.clientX ?? 0, y: event.changedTouches[0]?.clientY ?? 0 }); + setPosition({ x, y }); } + else { + setPosition({ x: event.clientX, y: event.clientY }); + } + }; - function onTouchStart(event: TouchEvent) { - if (event.cancelable) { - event.preventDefault(); - } + targetElement.addEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + targetElement.addEventListener('mouseleave', resetMousePosition as any); + } - startScrubbing(); - onTouchMove(event); + return () => { + targetElement.removeEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + targetElement.removeEventListener('mouseleave', resetMousePosition as any); } + }; + }, [element, options.resetOnExit]); - node.addEventListener('mousedown', onMouseDown); - node.addEventListener('touchstart', onTouchStart, { passive: false }); - - // Store cleanup function in ref instead of returning it - cleanupRef.current = () => { - node.removeEventListener('mousedown', onMouseDown); - node.removeEventListener('touchstart', onTouchStart); - }; - } - ); + if (target) { + return position; + } - return { ref: refCallback, active }; + return { ref: internalRef, ...position }; } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts index cc280cb4..d80960b2 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts @@ -1,40 +1,56 @@ import React from 'react'; -import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { isTarget } from '~@lib/isTarget'; -type Target = HTMLElement | (() => HTMLElement) | React.RefObject | null; +import type { HookTarget } from '~@lib/isTarget'; -function getTargetElement(target?: Target): Node | null { - if (typeof target === 'function') { - return target(); - } - if (target && 'current' in target) { - return target.current; - } +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useRefAsState } from '../useRefAsState'; +import { useStableCallback } from '../useStableCallback'; +import { useValueAsRef } from '../useValueAsRef'; - return target ?? null; -} +import type { RefAsState } from '../useRefAsState'; + +export function useMutationObserver( + target: HookTarget, + callback: MutationCallback, + options: MutationObserverInit +): void; -export function useMutationObserver( +export function useMutationObserver( callback: MutationCallback, - options: MutationObserverInit, - target?: Target -) { + options: MutationObserverInit +): { ref: RefAsState }; + +export function useMutationObserver( + ...args: [HookTarget, MutationCallback, MutationObserverInit] | [MutationCallback, MutationObserverInit] +): void | { ref: RefAsState } { + const target = (isTarget(args[0] as HookTarget) ? args[0] : undefined) as HookTarget | undefined; + const callback = (target ? args[1] : args[0]) as MutationCallback; + const options = (target ? args[2] : args[1]) as MutationObserverInit; + const observer = React.useRef(null); - const ref = React.useRef(null); + const internalRef = useRefAsState(); + const optionsRef = useValueAsRef(options); - useIsoLayoutEffect(() => { - const targetElement = getTargetElement(target); + const stableCallback = useStableCallback(callback); + + const element = target ? isTarget.getElement(target) : internalRef.current; - if (targetElement || ref.current) { - observer.current = new MutationObserver(callback); - observer.current.observe(targetElement || ref.current!, options); + useIsoLayoutEffect(() => { + if (element) { + observer.current = new MutationObserver(stableCallback); + observer.current.observe(element as Node, optionsRef.current); } return () => { observer.current?.disconnect(); }; - }, [callback, options]); + }, [element]); + + if (target) { + return undefined; + } - return ref; + return { ref: internalRef }; } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts index e69de29b..2d15240a 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts @@ -0,0 +1 @@ +export * from './useOrientation'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts index b002d18a..ee1aeed7 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React from 'react'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; @@ -53,8 +53,8 @@ export function useOrientation({ defaultType = 'landscape-primary', getInitialValueInEffect = true }: UseOrientationOptions = {}): UseOrientationReturnType { - const [orientation, setOrientation] = useState( - getInitialValue( + const [orientation, setOrientation] = React.useState( + () => getInitialValue( { angle: defaultAngle, type: defaultType diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/index.ts new file mode 100644 index 00000000..78f27749 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/index.ts @@ -0,0 +1 @@ +export * from './useRefAsState'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/useRefAsState.ts b/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/useRefAsState.ts new file mode 100644 index 00000000..163246b0 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useRefAsState/useRefAsState.ts @@ -0,0 +1,53 @@ +import { useState } from 'react'; + +export type RefAsState = { + (node: Value): void; + current: Value; + state?: Value; +}; + +export function createRefState(initialValue: Value | undefined, setState: (value: Value) => void) { + let temp = initialValue; + function ref(value: Value) { + if (temp === value) + return; + temp = value; + setState(temp); + } + + Object.defineProperty(ref, 'current', { + get() { + return temp; + }, + set(value: Value) { + if (temp === value) + return; + temp = value; + setState(temp); + }, + configurable: true, + enumerable: true + }); + + return ref as RefAsState; +} + +/** + * @name useRefState + * @description - Hook that returns the state reference of the value + * @category State + * @usage low + * + * @template Value The type of the value + * @param {Value} [initialValue] The initial value + * @returns {StateRef} The current value + * + * @example + * const internalRefState = useRefState(); + */ +export function useRefAsState(initialValue?: Value) { + const [state, setState] = useState(initialValue); + const [ref] = useState(() => createRefState(initialValue, setState)); + ref.state = state; + return ref; +} diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts index e69de29b..888509ab 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/index.ts @@ -0,0 +1 @@ +export * from './useResizeObserver'; \ No newline at end of file diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts index 3374ded1..3d266578 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts @@ -1,11 +1,16 @@ -import { - useEffect, - useMemo, - useRef, - useState -} from 'react'; +import React from 'react'; -type ObserverRect = Omit; +import { isTarget } from '~@lib/isTarget'; + +import type { HookTarget } from '~@lib/isTarget'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useRefAsState } from '../useRefAsState'; +import { useValueAsRef } from '../useValueAsRef'; + +import type { RefAsState } from '../useRefAsState'; + +export type ObserverRect = Omit; const defaultState: ObserverRect = { x: 0, @@ -17,14 +22,29 @@ const defaultState: ObserverRect = { bottom: 0, right: 0 }; +export type UseResizeObserverReturn = readonly [RefAsState, ObserverRect]; + +export function useResizeObserver(target: HookTarget, options?: ResizeObserverOptions): ObserverRect; + +export function useResizeObserver( + options?: ResizeObserverOptions +): UseResizeObserverReturn; -export function useResizeObserver(options?: ResizeObserverOptions) { - const frameID = useRef(0); - const ref = useRef(null); +export function useResizeObserver( + ...args: [HookTarget, ResizeObserverOptions?] | [ResizeObserverOptions?] +): ObserverRect | UseResizeObserverReturn { + const target = (isTarget(args[0] as HookTarget) ? args[0] : undefined) as HookTarget | undefined; + const options = (target ? args[1] : args[0]) as ResizeObserverOptions | undefined; - const [rect, setRect] = useState(defaultState); + const frameID = React.useRef(0); + const internalRef = useRefAsState(); + const optionsRef = useValueAsRef(options); - const observer = useMemo( + const [rect, setRect] = React.useState(defaultState); + + const element = target ? isTarget.getElement(target) : internalRef.current; + + const observer = React.useMemo( () => typeof window !== 'undefined' ? new ResizeObserver((entries) => { @@ -34,26 +54,24 @@ export function useResizeObserver(options?: ResizeO cancelAnimationFrame(frameID.current); frameID.current = requestAnimationFrame(() => { - if (ref.current) { - const boxSize = entry.borderBoxSize?.[0] || entry.contentBoxSize?.[0]; - if (boxSize) { - const width = boxSize.inlineSize; - const height = boxSize.blockSize; - - setRect({ - width, - height, - x: entry.contentRect.x, - y: entry.contentRect.y, - top: entry.contentRect.top, - left: entry.contentRect.left, - bottom: entry.contentRect.bottom, - right: entry.contentRect.right - }); - } - else { - setRect(entry.contentRect); - } + const boxSize = entry.borderBoxSize?.[0] || entry.contentBoxSize?.[0]; + if (boxSize) { + const width = boxSize.inlineSize; + const height = boxSize.blockSize; + + setRect({ + width, + height, + x: entry.contentRect.x, + y: entry.contentRect.y, + top: entry.contentRect.top, + left: entry.contentRect.left, + bottom: entry.contentRect.bottom, + right: entry.contentRect.right + }); + } + else { + setRect(entry.contentRect); } }); } @@ -62,9 +80,9 @@ export function useResizeObserver(options?: ResizeO [] ); - useEffect(() => { - if (ref.current) { - observer?.observe(ref.current, options); + useIsoLayoutEffect(() => { + if (element) { + observer?.observe(element as Element, optionsRef.current); } return () => { @@ -74,9 +92,13 @@ export function useResizeObserver(options?: ResizeO cancelAnimationFrame(frameID.current); } }; - }, [ref.current]); + }, [element]); + + if (target) { + return rect; + } - return [ref, rect] as const; + return [internalRef, rect] as const; } export function useElementSize(options?: ResizeObserverOptions) { diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts index e69de29b..4a788d68 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/index.ts @@ -0,0 +1 @@ +export * from './useScrollIntoView'; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts index 443de21d..f7890271 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useRef } from 'react'; +import React from 'react'; -import { useReducedMotion } from '../use-reduced-motion/use-reduced-motion'; +import { useMediaQuery } from '../useMediaQuery'; +import { useOnMount } from '../useOnMount'; import { useWindowEvent } from '../useWindowEvent'; type UseScrollIntoViewAnimation = { @@ -53,14 +54,14 @@ export function useScrollIntoView< cancelable = true, isList = false }: UseScrollIntoViewOptions = {}): UseScrollIntoViewReturnValue { - const frameID = useRef(0); - const startTime = useRef(0); - const shouldStop = useRef(false); + const frameID = React.useRef(0); + const startTime = React.useRef(0); + const shouldStop = React.useRef(false); - const scrollableRef = useRef(null); - const targetRef = useRef(null); + const scrollableRef = React.useRef(null); + const targetRef = React.useRef(null); - const reducedMotion = useReducedMotion(); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const cancel = (): void => { if (frameID.current) { @@ -68,7 +69,7 @@ export function useScrollIntoView< } }; - const scrollIntoView = useCallback( + const scrollIntoView = React.useCallback( ({ alignment = 'start' }: UseScrollIntoViewAnimation = {}) => { shouldStop.current = false; @@ -90,10 +91,10 @@ export function useScrollIntoView< function animateScroll() { if (startTime.current === 0) { - startTime.current = performance.now(); + startTime.current = Date.now(); } - const now = performance.now(); + const now = Date.now(); const elapsed = now - startTime.current; // Easing timing progress @@ -117,6 +118,7 @@ export function useScrollIntoView< cancel(); } } + animateScroll(); }, [ @@ -151,7 +153,7 @@ export function useScrollIntoView< }); // Cleanup requestAnimationFrame - useEffect(() => cancel, []); + useOnMount(() => cancel); return { scrollableRef, @@ -251,7 +253,12 @@ function getRelativePosition({ return 0; } -function getScrollStart({ axis, parent }: any) { +type GetScrollStartParams = { + axis: 'x' | 'y'; + parent: HTMLElement | null; +}; + +function getScrollStart({ axis, parent }: GetScrollStartParams) { if (!parent && typeof document === 'undefined') { return 0; } @@ -268,7 +275,13 @@ function getScrollStart({ axis, parent }: any) { return body[method] + documentElement[method]; } -function setScrollParam({ axis, parent, distance }: any) { +type SetScrollParamParams = { + axis: 'x' | 'y'; + parent: HTMLElement | null; + distance: number; +}; + +function setScrollParam({ axis, parent, distance }: SetScrollParamParams) { if (!parent && typeof document === 'undefined') { return; } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollLock/useScrollLock.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollLock/useScrollLock.ts index dca1d404..681ba39c 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useScrollLock/useScrollLock.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollLock/useScrollLock.ts @@ -1,8 +1,11 @@ import { isIOS, isWebKit } from '~@lib/detectBrowser'; import { isOverflowElement } from '~@lib/isOverflowElement'; +import { isTarget } from '~@lib/isTarget'; import { NOOP } from '~@lib/noop'; import { ownerDocument, ownerWindow } from '~@lib/owner'; +import type { HookTarget } from '~@lib/isTarget'; + import { AnimationFrame } from '../useAnimationFrame'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; import { Timeout } from '../useTimeout'; @@ -233,13 +236,28 @@ const SCROLL_LOCKER = new ScrollLocker(); * Locks the scroll of the document when enabled. * * @param enabled - Whether to enable the scroll lock. - * @param referenceElement - Element to use as a reference for lock calculations. + * @param referenceElement - Element or HookTarget to use as a reference for lock calculations. */ -export function useScrollLock(enabled: boolean = true, referenceElement: Element | null = null) { +export function useScrollLock(enabled: boolean = true, referenceElement: HookTarget | Element | null = null) { useIsoLayoutEffect(() => { if (!enabled) { return undefined; } - return SCROLL_LOCKER.acquire(referenceElement); - }, [enabled, referenceElement]); + + let element: Element | null = null; + if (referenceElement) { + if (referenceElement instanceof Element) { + element = referenceElement; + } + else if (isTarget(referenceElement)) { + element = isTarget.getElement(referenceElement) as Element | null; + } + } + + return SCROLL_LOCKER.acquire(element); + }, [enabled, referenceElement instanceof Element + ? referenceElement + : referenceElement && isTarget(referenceElement) + ? isTarget.getRawElement(referenceElement) + : null]); } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts index 664bccc9..cd04a961 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts @@ -1,6 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import React from 'react'; -import { randomId } from '../utils'; +import { randomId } from '~@lib/randomId'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; function getHeadingsData( headings: HTMLElement[], @@ -9,12 +12,11 @@ function getHeadingsData( ): UseScrollSpyHeadingData[] { const result: UseScrollSpyHeadingData[] = []; - for (let i = 0; i < headings.length; i += 1) { - const heading = headings[i]; + for (const heading of headings) { result.push({ depth: getDepth(heading), value: getValue(heading), - id: heading.id || randomId(), + id: heading.id || randomId('scroll-spy'), getNode: () => (heading.id ? document.getElementById(heading.id)! : heading) }); } @@ -38,7 +40,7 @@ function getActiveElement(rects: DOMRect[], offset: number = 0) { position: item.y }; }, - { index: 0, position: rects[0].y } + { index: 0, position: rects[0]?.y ?? 0 } ); return closest.index; @@ -104,21 +106,21 @@ export function useScrollSpy({ offset = 0, scrollHost }: UseScrollSpyOptions = {}): UseScrollSpyReturnType { - const [active, setActive] = useState(-1); - const [initialized, setInitialized] = useState(false); - const [data, setData] = useState([]); - const headingsRef = useRef([]); + const [active, setActive] = React.useState(-1); + const [initialized, setInitialized] = React.useState(false); + const [data, setData] = React.useState([]); + const headingsRef = React.useRef([]); - const handleScroll = () => { + const handleScroll = useStableCallback(() => { setActive( getActiveElement( headingsRef.current.map((d) => d.getNode().getBoundingClientRect()), offset ) ); - }; + }); - const initialize = () => { + const initialize = useStableCallback(() => { const headings = getHeadingsData( Array.from(document.querySelectorAll(selector)), getDepth, @@ -133,12 +135,13 @@ export function useScrollSpy({ offset ) ); - }; + }); - useEffect(() => { + useIsoLayoutEffect(() => { initialize(); const _scrollHost = scrollHost || window; _scrollHost.addEventListener('scroll', handleScroll); + return () => _scrollHost.removeEventListener('scroll', handleScroll); }, [scrollHost]); diff --git a/packages/ui/uikit/headless/hooks/src/lib/isTarget.ts b/packages/ui/uikit/headless/hooks/src/lib/isTarget.ts new file mode 100644 index 00000000..3109dc6c --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/lib/isTarget.ts @@ -0,0 +1,86 @@ +import type { RefObject } from 'react'; + +export const targetSymbol = Symbol('target'); + +export type Target = (() => Element) | string | Document | Element | Window; +type BrowserTarget = { + type: symbol; + value: Target; +}; +type StateRef = { + (node: Value): void; + current: Value; + state: Value; +}; + +export type HookTarget + = | BrowserTarget + | RefObject + | StateRef; + +export function target(target: Target) { + return { + value: target, + type: targetSymbol + }; +} + +export const isRef = (target: HookTarget) => typeof target === 'object' && 'current' in target; + +export function isRefState(target: HookTarget) { + return typeof target === 'function' && 'state' in target && 'current' in target; +} + +export function isBrowserTarget(target: HookTarget) { + return typeof target === 'object' + && target + && 'type' in target + && target.type === targetSymbol + && 'value' in target; +} + +export function isTarget(target: HookTarget) { + return isRef(target) || isRefState(target) || isBrowserTarget(target); +} + +function getElement(target: HookTarget) { + if ('current' in target) { + return target.current; + } + + if (typeof target.value === 'function') { + return target.value(); + } + + if (typeof target.value === 'string') { + return document.querySelector(target.value); + } + + if (target.value instanceof Document) { + return target.value; + } + + if (target.value instanceof Window) { + return target.value; + } + + if (target.value instanceof Element) { + return target.value; + } + + return target.value; +} +export const getRefState = (target?: HookTarget) => target && 'state' in target && target.state; +export function getRawElement(target: HookTarget) { + if (isRefState(target)) + return target.state; + if (isBrowserTarget(target)) + return (target as BrowserTarget).value; + + return target; +} + +isTarget.wrap = target; +isTarget.getElement = getElement; +isTarget.getRefState = getRefState; +isTarget.getRawElement = getRawElement; diff --git a/packages/ui/uikit/headless/hooks/src/lib/randomId.ts b/packages/ui/uikit/headless/hooks/src/lib/randomId.ts new file mode 100644 index 00000000..0676c460 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/lib/randomId.ts @@ -0,0 +1,5 @@ +let counter = 0; +export function randomId(prefix: string) { + counter += 1; + return `${prefix}-${Math.random().toString(36).slice(2, 6)}-${counter}`; +} diff --git a/packages/ui/uikit/headless/hooks/src/lib/types.ts b/packages/ui/uikit/headless/hooks/src/lib/types.ts new file mode 100644 index 00000000..164a7056 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/lib/types.ts @@ -0,0 +1 @@ +export type Nullable = T | null | undefined; From 1ec0900f746b1838c8d6958cb142b80bc7d51da5 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 8 Feb 2026 16:08:58 +0300 Subject: [PATCH 13/16] feat(headless/hooks): improve tabbable search for useFocusTrap --- packages/ui/uikit/headless/hooks/package.json | 161 +++++++++++ .../src/hooks/useFocusTrap/useFocusTrap.ts | 16 +- pnpm-lock.yaml | 271 +++++++++++++++--- 3 files changed, 393 insertions(+), 55 deletions(-) diff --git a/packages/ui/uikit/headless/hooks/package.json b/packages/ui/uikit/headless/hooks/package.json index 6dabd12a..cf8aa493 100644 --- a/packages/ui/uikit/headless/hooks/package.json +++ b/packages/ui/uikit/headless/hooks/package.json @@ -25,11 +25,21 @@ "import": "./dist/hooks/useAnimationsFinished/index.es.js", "require": "./dist/hooks/useAnimationsFinished/index.cjs.js" }, + "./use-click-outside": { + "types": "./dist/hooks/useClickOutside/index.d.ts", + "import": "./dist/hooks/useClickOutside/index.es.js", + "require": "./dist/hooks/useClickOutside/index.cjs.js" + }, "./use-clipboard": { "types": "./dist/hooks/useClipboard/index.d.ts", "import": "./dist/hooks/useClipboard/index.es.js", "require": "./dist/hooks/useClipboard/index.cjs.js" }, + "./use-color-schema": { + "types": "./dist/hooks/useColorSchema/index.d.ts", + "import": "./dist/hooks/useColorSchema/index.es.js", + "require": "./dist/hooks/useColorSchema/index.cjs.js" + }, "./use-controlled-state": { "types": "./dist/hooks/useControlledState/index.d.ts", "import": "./dist/hooks/useControlledState/index.es.js", @@ -40,6 +50,11 @@ "import": "./dist/hooks/useDidUpdate/index.es.js", "require": "./dist/hooks/useDidUpdate/index.cjs.js" }, + "./use-document-title": { + "types": "./dist/hooks/useDocumentTitle/index.d.ts", + "import": "./dist/hooks/useDocumentTitle/index.es.js", + "require": "./dist/hooks/useDocumentTitle/index.cjs.js" + }, "./use-drag-gesture": { "types": "./dist/hooks/useDragGesture/index.d.ts", "import": "./dist/hooks/useDragGesture/index.es.js", @@ -70,16 +85,81 @@ "import": "./dist/hooks/useEyeDropper/index.es.js", "require": "./dist/hooks/useEyeDropper/index.cjs.js" }, + "./use-favicon": { + "types": "./dist/hooks/useFavicon/index.d.ts", + "import": "./dist/hooks/useFavicon/index.es.js", + "require": "./dist/hooks/useFavicon/index.cjs.js" + }, + "./use-file-dialog": { + "types": "./dist/hooks/useFileDialog/index.d.ts", + "import": "./dist/hooks/useFileDialog/index.es.js", + "require": "./dist/hooks/useFileDialog/index.cjs.js" + }, + "./use-focus": { + "types": "./dist/hooks/useFocus/index.d.ts", + "import": "./dist/hooks/useFocus/index.es.js", + "require": "./dist/hooks/useFocus/index.cjs.js" + }, + "./use-focus-return": { + "types": "./dist/hooks/useFocusReturn/index.d.ts", + "import": "./dist/hooks/useFocusReturn/index.es.js", + "require": "./dist/hooks/useFocusReturn/index.cjs.js" + }, + "./use-focus-trap": { + "types": "./dist/hooks/useFocusTrap/index.d.ts", + "import": "./dist/hooks/useFocusTrap/index.es.js", + "require": "./dist/hooks/useFocusTrap/index.cjs.js" + }, + "./use-focus-within": { + "types": "./dist/hooks/useFocusWithin/index.d.ts", + "import": "./dist/hooks/useFocusWithin/index.es.js", + "require": "./dist/hooks/useFocusWithin/index.cjs.js" + }, "./use-forced-rerendering": { "types": "./dist/hooks/useForcedRerendering/index.d.ts", "import": "./dist/hooks/useForcedRerendering/index.es.js", "require": "./dist/hooks/useForcedRerendering/index.cjs.js" }, + "./use-fullscreen": { + "types": "./dist/hooks/useFullscreen/index.d.ts", + "import": "./dist/hooks/useFullscreen/index.es.js", + "require": "./dist/hooks/useFullscreen/index.cjs.js" + }, + "./use-hash": { + "types": "./dist/hooks/useHash/index.d.ts", + "import": "./dist/hooks/useHash/index.es.js", + "require": "./dist/hooks/useHash/index.cjs.js" + }, + "./use-hotkeys": { + "types": "./dist/hooks/useHotkeys/index.d.ts", + "import": "./dist/hooks/useHotkeys/index.es.js", + "require": "./dist/hooks/useHotkeys/index.cjs.js" + }, + "./use-hover": { + "types": "./dist/hooks/useHover/index.d.ts", + "import": "./dist/hooks/useHover/index.es.js", + "require": "./dist/hooks/useHover/index.cjs.js" + }, "./use-id": { "types": "./dist/hooks/useId/index.d.ts", "import": "./dist/hooks/useId/index.es.js", "require": "./dist/hooks/useId/index.cjs.js" }, + "./use-idle": { + "types": "./dist/hooks/useIdle/index.d.ts", + "import": "./dist/hooks/useIdle/index.es.js", + "require": "./dist/hooks/useIdle/index.cjs.js" + }, + "./use-in-viewport": { + "types": "./dist/hooks/useInViewport/index.d.ts", + "import": "./dist/hooks/useInViewport/index.es.js", + "require": "./dist/hooks/useInViewport/index.cjs.js" + }, + "./use-intersection": { + "types": "./dist/hooks/useIntersection/index.d.ts", + "import": "./dist/hooks/useIntersection/index.es.js", + "require": "./dist/hooks/useIntersection/index.cjs.js" + }, "./use-interval": { "types": "./dist/hooks/useInterval/index.d.ts", "import": "./dist/hooks/useInterval/index.es.js", @@ -105,6 +185,16 @@ "import": "./dist/hooks/useLazyRef/index.es.js", "require": "./dist/hooks/useLazyRef/index.cjs.js" }, + "./use-local-storage": { + "types": "./dist/hooks/useLocalStorage/index.d.ts", + "import": "./dist/hooks/useLocalStorage/index.es.js", + "require": "./dist/hooks/useLocalStorage/index.cjs.js" + }, + "./use-long-press": { + "types": "./dist/hooks/useLongPress/index.d.ts", + "import": "./dist/hooks/useLongPress/index.es.js", + "require": "./dist/hooks/useLongPress/index.cjs.js" + }, "./use-media-query": { "types": "./dist/hooks/useMediaQuery/index.d.ts", "import": "./dist/hooks/useMediaQuery/index.es.js", @@ -115,6 +205,26 @@ "import": "./dist/hooks/useMergedRef/index.es.js", "require": "./dist/hooks/useMergedRef/index.cjs.js" }, + "./use-mouse": { + "types": "./dist/hooks/useMouse/index.d.ts", + "import": "./dist/hooks/useMouse/index.es.js", + "require": "./dist/hooks/useMouse/index.cjs.js" + }, + "./use-move": { + "types": "./dist/hooks/useMove/index.d.ts", + "import": "./dist/hooks/useMove/index.es.js", + "require": "./dist/hooks/useMove/index.cjs.js" + }, + "./use-mutation-observer": { + "types": "./dist/hooks/useMutationObserver/index.d.ts", + "import": "./dist/hooks/useMutationObserver/index.es.js", + "require": "./dist/hooks/useMutationObserver/index.cjs.js" + }, + "./use-network": { + "types": "./dist/hooks/useNetwork/index.d.ts", + "import": "./dist/hooks/useNetwork/index.es.js", + "require": "./dist/hooks/useNetwork/index.cjs.js" + }, "./use-on-first-render": { "types": "./dist/hooks/useOnFirstRender/index.d.ts", "import": "./dist/hooks/useOnFirstRender/index.es.js", @@ -135,16 +245,46 @@ "import": "./dist/hooks/useOpenInteractionType/index.es.js", "require": "./dist/hooks/useOpenInteractionType/index.cjs.js" }, + "./use-orientation": { + "types": "./dist/hooks/useOrientation/index.d.ts", + "import": "./dist/hooks/useOrientation/index.es.js", + "require": "./dist/hooks/useOrientation/index.cjs.js" + }, + "./use-os": { + "types": "./dist/hooks/useOs/index.d.ts", + "import": "./dist/hooks/useOs/index.es.js", + "require": "./dist/hooks/useOs/index.cjs.js" + }, "./use-previous-value": { "types": "./dist/hooks/usePreviousValue/index.d.ts", "import": "./dist/hooks/usePreviousValue/index.es.js", "require": "./dist/hooks/usePreviousValue/index.cjs.js" }, + "./use-ref-as-state": { + "types": "./dist/hooks/useRefAsState/index.d.ts", + "import": "./dist/hooks/useRefAsState/index.es.js", + "require": "./dist/hooks/useRefAsState/index.cjs.js" + }, + "./use-resize-observer": { + "types": "./dist/hooks/useResizeObserver/index.d.ts", + "import": "./dist/hooks/useResizeObserver/index.es.js", + "require": "./dist/hooks/useResizeObserver/index.cjs.js" + }, + "./use-scroll-into-view": { + "types": "./dist/hooks/useScrollIntoView/index.d.ts", + "import": "./dist/hooks/useScrollIntoView/index.es.js", + "require": "./dist/hooks/useScrollIntoView/index.cjs.js" + }, "./use-scroll-lock": { "types": "./dist/hooks/useScrollLock/index.d.ts", "import": "./dist/hooks/useScrollLock/index.es.js", "require": "./dist/hooks/useScrollLock/index.cjs.js" }, + "./use-scroll-spy": { + "types": "./dist/hooks/useScrollSpy/index.d.ts", + "import": "./dist/hooks/useScrollSpy/index.es.js", + "require": "./dist/hooks/useScrollSpy/index.cjs.js" + }, "./use-ssr": { "types": "./dist/hooks/useSsr/index.d.ts", "import": "./dist/hooks/useSsr/index.es.js", @@ -165,6 +305,11 @@ "import": "./dist/hooks/useStore/index.es.js", "require": "./dist/hooks/useStore/index.cjs.js" }, + "./use-text-selection": { + "types": "./dist/hooks/useTextSelection/index.d.ts", + "import": "./dist/hooks/useTextSelection/index.es.js", + "require": "./dist/hooks/useTextSelection/index.cjs.js" + }, "./use-timeout": { "types": "./dist/hooks/useTimeout/index.d.ts", "import": "./dist/hooks/useTimeout/index.es.js", @@ -189,6 +334,21 @@ "types": "./dist/hooks/useValueChanged/index.d.ts", "import": "./dist/hooks/useValueChanged/index.es.js", "require": "./dist/hooks/useValueChanged/index.cjs.js" + }, + "./use-viewport-size": { + "types": "./dist/hooks/useViewportSize/index.d.ts", + "import": "./dist/hooks/useViewportSize/index.es.js", + "require": "./dist/hooks/useViewportSize/index.cjs.js" + }, + "./use-window-event": { + "types": "./dist/hooks/useWindowEvent/index.d.ts", + "import": "./dist/hooks/useWindowEvent/index.es.js", + "require": "./dist/hooks/useWindowEvent/index.cjs.js" + }, + "./use-window-scroll": { + "types": "./dist/hooks/useWindowScroll/index.d.ts", + "import": "./dist/hooks/useWindowScroll/index.es.js", + "require": "./dist/hooks/useWindowScroll/index.cjs.js" } }, "main": "./dist/hooks/index.cjs.js", @@ -205,6 +365,7 @@ }, "dependencies": { "reselect": "catalog:", + "tabbable": "catalog:", "use-sync-external-store": "catalog:" }, "devDependencies": { diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts index 7cdbae49..74fc9da4 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts @@ -1,26 +1,18 @@ import React from 'react'; +import { tabbable } from 'tabbable'; + import { isTarget } from '~@lib/isTarget'; import type { HookTarget } from '~@lib/isTarget'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; -export const FOCUS_SELECTOR = 'a, input, select, textarea, button, object, [tabindex]'; - -function getFocusableElements(element: HTMLElement) { - const elements = Array.from(element.querySelectorAll(FOCUS_SELECTOR)); - return elements.filter((element) => { - const htmlEl = element as HTMLElement; - return htmlEl.tabIndex !== -1 && !htmlEl.hidden && htmlEl.style.display !== 'none'; - }) as HTMLElement[]; -} - function focusElement(element: HTMLElement) { const autofocusElement = element.querySelector('[data-autofocus]') as HTMLElement; if (autofocusElement) return autofocusElement.focus(); - const focusableElements = getFocusableElements(element); + const focusableElements = tabbable(element); if (focusableElements.length) focusableElements[0]?.focus(); } @@ -60,7 +52,7 @@ export function useFocusTrap(...args: any[]) { if (event.key !== 'Tab') return; - const [firstElement, ...restElements] = getFocusableElements(htmlElement); + const [firstElement, ...restElements] = tabbable(htmlElement); if (!restElements.length) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 700eadf4..5f07b937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,33 @@ settings: catalogs: default: + '@antfu/eslint-config': + specifier: ^5.2.1 + version: 5.2.1 + '@biomejs/biome': + specifier: ^2.0.0-beta.1 + version: 2.0.0-beta.1 + '@chromatic-com/storybook': + specifier: ^3.2.6 + version: 3.2.6 '@eslint-react/eslint-plugin': specifier: ^1.52.8 version: 1.52.8 + '@farfetched/core': + specifier: ^0.13.2 + version: 0.13.2 + '@figma-export/core': + specifier: ^6.2.2 + version: 6.2.2 + '@floating-ui/react': + specifier: ^0.27.16 + version: 0.27.16 + '@floating-ui/react-dom': + specifier: ^2.1.6 + version: 2.1.6 + '@floating-ui/utils': + specifier: ^0.2.10 + version: 0.2.10 '@storybook/addon-essentials': specifier: ^8.6.14 version: 8.6.14 @@ -39,6 +63,12 @@ catalogs: '@storybook/theming': specifier: 8.6.14 version: 8.6.14 + '@svgr/core': + specifier: ^8.1.0 + version: 8.1.0 + '@svgr/plugin-jsx': + specifier: ^8.1.0 + version: 8.1.0 '@testing-library/jest-dom': specifier: ^6.8.0 version: 6.8.0 @@ -48,27 +78,81 @@ catalogs: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1 + '@testing-library/webdriverio': + specifier: ^3.2.1 + version: 3.2.1 + '@testplane/global-hook': + specifier: ^1.0.0 + version: 1.0.0 + '@testplane/storybook': + specifier: ^1.7.3 + version: 1.7.3 + '@testplane/test-filter': + specifier: ^1.1.0 + version: 1.1.0 + '@testplane/url-decorator': + specifier: ^1.0.0 + version: 1.0.0 + '@types/eslint': + specifier: ^9.6.1 + version: 9.6.1 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^22.18.0 version: 22.18.0 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 '@types/react': specifier: ^19.0.12 version: 19.0.12 '@types/react-dom': specifier: ^19.0.4 version: 19.0.4 + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4 '@vitest/ui': specifier: ^3.2.4 version: 3.2.4 + '@withease/i18next': + specifier: ^24.0.0 + version: 24.0.0 + '@withease/web-api': + specifier: ^1.3.0 + version: 1.3.0 + atomic-router: + specifier: ^0.11.1 + version: 0.11.1 + atomic-router-react: + specifier: ^0.10.0 + version: 0.10.0 + axios: + specifier: ^1.11.0 + version: 1.11.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + effector: + specifier: ^23.4.2 + version: 23.4.2 + effector-react: + specifier: ^23.3.0 + version: 23.3.0 eslint: specifier: ^9.33.0 version: 9.33.0 + eslint-plugin-effector: + specifier: ^0.15.0 + version: 0.15.0 eslint-plugin-format: specifier: ^1.0.1 version: 1.0.1 @@ -78,15 +162,93 @@ catalogs: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20 + eslint-plugin-storybook: + specifier: ^0.12.0 + version: 0.12.0 + eslint-plugin-turbo: + specifier: ^2.5.6 + version: 2.5.6 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + framer-motion: + specifier: ^12.23.12 + version: 12.23.12 + glob: + specifier: ^11.0.3 + version: 11.0.3 + globals: + specifier: ^16.3.0 + version: 16.3.0 + globby: + specifier: ^16.0.0 + version: 16.0.0 + history: + specifier: ^5.3.0 + version: 5.3.0 + html-reporter: + specifier: ^10.19.0 + version: 10.19.0 + i18next: + specifier: ^24.2.3 + version: 24.2.3 + i18next-browser-languagedetector: + specifier: ^8.2.0 + version: 8.2.0 + i18next-hmr: + specifier: ^3.1.4 + version: 3.1.4 + i18next-http-backend: + specifier: ^3.0.2 + version: 3.0.2 + is-svg: + specifier: ^5.1.0 + version: 5.1.0 + jiti: + specifier: ^2.5.1 + version: 2.5.1 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 jsdom: specifier: ^24.1.3 version: 24.1.3 + patronum: + specifier: ^2.3.0 + version: 2.3.0 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + postcss-flexbugs-fixes: + specifier: ^5.0.2 + version: 5.0.2 + postcss-preset-env: + specifier: ^10.3.1 + version: 10.3.1 + qr-code-styling: + specifier: ^1.9.2 + version: 1.9.2 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.1.1 version: 19.1.1 react-dom: specifier: ^19.1.1 version: 19.1.1 + react-i18next: + specifier: ^15.7.3 + version: 15.7.3 + react-use-measure: + specifier: ^2.1.7 + version: 2.1.7 + reselect: + specifier: ^5.1.1 + version: 5.1.1 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 sass: specifier: ^1.91.0 version: 1.91.0 @@ -96,15 +258,51 @@ catalogs: storybook: specifier: ^8.6.14 version: 8.6.14 + storybook-react-i18next: + specifier: ^3.3.1 + version: 3.3.1 + stylelint: + specifier: ^16.23.1 + version: 16.23.1 + surrealdb: + specifier: ^1.3.2 + version: 1.3.2 + svgo: + specifier: ^3.3.2 + version: 3.3.2 + tabbable: + specifier: ^6.2.0 + version: 6.2.0 + terser: + specifier: ^5.44.1 + version: 5.44.1 + testplane: + specifier: ^8.31.0 + version: 8.31.0 + tsup: + specifier: ^8.4.0 + version: 8.5.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.2 version: 5.9.2 + use-sync-external-store: + specifier: ^1.5.0 + version: 1.5.0 vite: specifier: 6.2.5 version: 6.2.5 + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4 vitest: specifier: ^3.2.4 version: 3.2.4 + zod: + specifier: ^3.25.76 + version: 3.25.76 importers: @@ -630,10 +828,10 @@ importers: version: 6.8.0 '@testing-library/react': specifier: 'catalog:' - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: 'catalog:' - version: 14.6.1(@testing-library/dom@10.4.0) + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: 'catalog:' version: 22.18.0 @@ -806,6 +1004,9 @@ importers: reselect: specifier: 'catalog:' version: 5.1.1 + tabbable: + specifier: 'catalog:' + version: 6.2.0 use-sync-external-store: specifier: 'catalog:' version: 1.5.0(react@19.1.1) @@ -5556,20 +5757,22 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.0: resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-modules@2.0.0: resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} @@ -9406,7 +9609,7 @@ snapshots: '@babel/traverse': 7.28.3 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -9499,7 +9702,7 @@ snapshots: '@babel/parser': 7.28.3 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -10435,7 +10638,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -10449,7 +10652,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -11561,7 +11764,7 @@ snapshots: '@testing-library/dom@8.20.1': dependencies: '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 '@types/aria-query': 5.0.4 aria-query: 5.1.3 chalk: 4.1.2 @@ -11588,16 +11791,6 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': - dependencies: - '@babel/runtime': 7.28.3 - '@testing-library/dom': 10.4.0 - react: 19.1.1 - react-dom: 19.1.1(react@19.1.1) - optionalDependencies: - '@types/react': 19.0.12 - '@types/react-dom': 19.0.4(@types/react@19.0.12) - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.28.3 @@ -11612,17 +11805,13 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': - dependencies: - '@testing-library/dom': 10.4.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/webdriverio@3.2.1(webdriverio@9.12.4(bufferutil@4.0.9)(utf-8-validate@6.0.5))': dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 '@testing-library/dom': 8.20.1 simmerjs: 0.5.6 webdriverio: 9.12.4(bufferutil@4.0.9)(utf-8-validate@6.0.5) @@ -12075,7 +12264,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) '@typescript-eslint/types': 8.41.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -12094,7 +12283,7 @@ snapshots: '@typescript-eslint/types': 8.41.0 '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) eslint: 9.33.0(jiti@2.5.1) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -12109,7 +12298,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) '@typescript-eslint/types': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -12224,7 +12413,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@6.0.5))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) '@vitest/utils@2.0.5': dependencies: @@ -13169,10 +13358,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.1: - dependencies: - ms: 2.1.3 - debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -13480,7 +13665,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -14013,7 +14198,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -14576,7 +14761,7 @@ snapshots: history@5.3.0: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 hookified@1.12.0: {} @@ -14590,7 +14775,7 @@ snapshots: html-reporter@10.19.0(@types/node@22.18.0)(playwright@1.55.0)(testplane@8.31.0(@cspotcode/source-map-support@0.8.1)(@types/node@22.18.0)(bufferutil@4.0.9)(sass@1.91.0)(terser@5.44.1)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2))(typescript@5.9.2)(utf-8-validate@6.0.5)): dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 '@gemini-testing/commander': 2.15.4 '@gemini-testing/sql.js': 2.0.0 ansi-html-community: 0.0.8 @@ -14659,7 +14844,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -14671,7 +14856,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -14679,7 +14864,7 @@ snapshots: i18next-browser-languagedetector@8.2.0: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 i18next-hmr@3.1.4: {} @@ -14691,7 +14876,7 @@ snapshots: i18next@24.2.3(typescript@5.9.2): dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 optionalDependencies: typescript: 5.9.2 @@ -16555,7 +16740,7 @@ snapshots: react-i18next@15.7.3(i18next@24.2.3(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2): dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 html-parse-stringify: 3.0.1 i18next: 24.2.3(typescript@5.9.2) react: 19.1.1 @@ -17872,7 +18057,7 @@ snapshots: vite-node@3.2.4(@types/node@22.18.0)(jiti@2.5.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.3(@types/node@22.18.0)(jiti@2.5.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -17964,7 +18149,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) expect-type: 1.2.2 magic-string: 0.30.18 pathe: 2.0.3 From 6b22e93e2bc9ce3ecbb5696ed101dea4b57bd626 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Sun, 8 Feb 2026 17:36:20 +0300 Subject: [PATCH 14/16] feat(headless/components): enhance List component with virtualization support and element reference management --- .../src/components/List/item/ListItem.tsx | 50 +++++++++++++++++-- .../src/components/List/root/ListRoot.tsx | 39 +++++++++++---- .../components/src/components/List/store.ts | 16 +++++- 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/ui/uikit/headless/components/src/components/List/item/ListItem.tsx b/packages/ui/uikit/headless/components/src/components/List/item/ListItem.tsx index 026dc7d2..fd1fd5ed 100644 --- a/packages/ui/uikit/headless/components/src/components/List/item/ListItem.tsx +++ b/packages/ui/uikit/headless/components/src/components/List/item/ListItem.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useIsoLayoutEffect } from '@flippo-ui/hooks/use-iso-layout-effect'; + import { useRenderElement } from '~@lib/hooks'; import type { @@ -9,7 +11,10 @@ import type { Orientation } from '~@lib/types'; -import { useCompositeListItem } from '../../Composite'; +import { + IndexGuessBehavior, + useCompositeListItem +} from '../../Composite/list/useCompositeListItem'; import { useButton } from '../../use-button'; import { useListRootContext } from '../root/ListRootContext'; import { NestedListContext, useNestedListContext } from '../root/NestedListContext'; @@ -32,6 +37,7 @@ export function ListItem(componentProps: ListItem.Props) { render, /* eslint-enable unused-imports/no-unused-vars */ ref: refProp, + index: indexProp, interactive = false, disabled = false, focusableWhenDisabled = false, @@ -47,10 +53,42 @@ export function ListItem(componentProps: ListItem.Props) { const orientation = store.useState('orientation'); const type = store.useState('type'); + const virtualized = store.useState('virtualized'); + const elementsRef = store.useState('elementsRef'); + + const listItem = useCompositeListItem({ + index: indexProp, + indexGuessBehavior: IndexGuessBehavior.GuessFromOrder + }); + + const itemRef = React.useRef(null); + + const index = indexProp ?? listItem.index; + const hasRegistered = listItem.index !== -1; - const { ref, index } = useCompositeListItem(); const { getButtonProps, buttonRef } = useButton({ disabled, focusableWhenDisabled, native: nativeButton }); + // Register element in elementsRef when virtualized + useIsoLayoutEffect(() => { + const shouldRun = hasRegistered && (virtualized || indexProp != null); + if (!shouldRun) { + return undefined; + } + + const list = elementsRef.current; + list[index] = itemRef.current; + + return () => { + delete list[index]; + }; + }, [ + hasRegistered, + virtualized, + index, + indexProp, + elementsRef + ]); + const state: ListItem.State = React.useMemo(() => ({ index, orientation, @@ -83,7 +121,7 @@ export function ListItem(componentProps: ListItem.Props) { const element = useRenderElement('li', componentProps, { state, - ref: [ref, refProp, buttonRef], + ref: [listItem.ref, refProp, buttonRef, itemRef], props: [listItemProps, interactive ? getButtonProps(elementProps) : elementProps] }); @@ -154,5 +192,11 @@ export namespace ListItem { * Whether the item is focusable when disabled. */ focusableWhenDisabled?: boolean; + /** + * The index of the item in the list. Required when `virtualized` is `true` + * on the parent `List.Root`. Improves performance when specified by avoiding + * the need to calculate the index automatically from the DOM. + */ + index?: number; } & NonNativeButtonProps & HeadlessUIComponentProps<'li', State>; } diff --git a/packages/ui/uikit/headless/components/src/components/List/root/ListRoot.tsx b/packages/ui/uikit/headless/components/src/components/List/root/ListRoot.tsx index 7ef442f8..a71a9ad5 100644 --- a/packages/ui/uikit/headless/components/src/components/List/root/ListRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/List/root/ListRoot.tsx @@ -14,13 +14,6 @@ import { useNestedListContext } from './NestedListContext'; import type { ListRootContextValue } from './ListRootContext'; -const INITIAL_STATE = { - nested: false, - nestedListNumber: 1, - orientation: 'vertical', - type: 'ordered' -} as const; - /** * Root container for the List component with full state management. * Renders a `
` element by default. @@ -33,6 +26,7 @@ export function ListRoot(componentProps: ListRoot.Props) { /* eslint-enable unused-imports/no-unused-vars */ orientation = 'vertical', type = 'ordered', + virtualized = false, ref, ...elementProps } = componentProps; @@ -46,13 +40,24 @@ export function ListRoot(componentProps: ListRoot.Props) { const nestedListNumber = (parentListRootContext?.store.useState('nestedListNumber') ?? 0) + 1; const nestedListItemNumber = nesting?.nestedListItemNumber ?? undefined; - const store = useLazyRef(ListStore.create, INITIAL_STATE).current; + const initialState = React.useMemo(() => ({ + nested: false, + nestedListNumber: 1, + orientation: 'vertical' as const, + type: 'ordered' as const, + virtualized: false, + elementsRef + }), []); + + const store = useLazyRef(ListStore.create, initialState).current; store.useSyncedValues({ nested, nestedListNumber, orientation, - type + type, + virtualized, + elementsRef }); const state: ListRoot.State = React.useMemo(() => ({ @@ -82,6 +87,14 @@ export function ListRoot(componentProps: ListRoot.Props) { const contextValue: ListRootContextValue = React.useMemo(() => ({ store }), [store]); + if (virtualized) { + return ( + + {element} + + ); + } + return ( @@ -126,5 +139,13 @@ export namespace ListRoot { * @default 'ordered' */ type?: 'ordered' | 'unordered'; + /** + * Whether the items are being externally virtualized. + * When `true`, items should pass their `index` prop explicitly + * and CompositeList is not used for automatic index registration. + * + * @default false + */ + virtualized?: boolean; }; } diff --git a/packages/ui/uikit/headless/components/src/components/List/store.ts b/packages/ui/uikit/headless/components/src/components/List/store.ts index e57b0ee6..3c97a04c 100644 --- a/packages/ui/uikit/headless/components/src/components/List/store.ts +++ b/packages/ui/uikit/headless/components/src/components/List/store.ts @@ -21,6 +21,18 @@ export type State = { * List type. */ type: 'ordered' | 'unordered'; + + /** + * Whether the items are being externally virtualized. + * When `true`, items should pass their `index` prop explicitly + * and CompositeList is not used. + */ + virtualized: boolean; + + /** + * A ref to the list of HTML elements, ordered by their index. + */ + elementsRef: React.RefObject<(HTMLElement | null)[]>; }; type Context = undefined; @@ -29,7 +41,9 @@ const selectors = { nested: createSelector((state: State) => state.nested), nestedListNumber: createSelector((state: State) => state.nestedListNumber), orientation: createSelector((state: State) => state.orientation), - type: createSelector((state: State) => state.type) + type: createSelector((state: State) => state.type), + virtualized: createSelector((state: State) => state.virtualized), + elementsRef: createSelector((state: State) => state.elementsRef) }; export class ListStore extends ReactStore { From c89b3fff94a8cb8c8a662e98ab5659f720664856 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Mon, 16 Feb 2026 00:13:53 +0300 Subject: [PATCH 15/16] feat(headless/components): add CSPProvider component for Content Security Policy management and enhance Menu components with common props and state management improvements --- .../ui/uikit/headless/components/package.json | 6 + .../src/components/CSPProvider/CSPContext.tsx | 22 ++ .../components/CSPProvider/CSPProvider.tsx | 46 +++ .../src/components/CSPProvider/index.parts.ts | 1 + .../src/components/CSPProvider/index.ts | 3 + .../src/components/Dialog/root/DialogRoot.tsx | 4 +- .../Dialog/trigger/DialogTrigger.tsx | 6 +- .../src/components/Drawer/root/DrawerRoot.tsx | 2 +- .../src/components/Menu/arrow/MenuArrow.tsx | 24 +- .../components/Menu/backdrop/MenuBackdrop.tsx | 11 +- .../MenuCheckboxItemIndicator.tsx | 15 +- .../src/components/Menu/item/MenuItem.tsx | 13 +- .../src/components/Menu/item/useMenuItem.ts | 77 +--- .../Menu/item/useMenuItemCommonProps.ts | 138 +++++++ .../components/Menu/link-item/MenuLinkItem.ts | 93 +++++ .../link-item/MenuLinkItemDataAttributes.ts | 6 + .../src/components/Menu/popup/MenuPopup.tsx | 36 +- .../Menu/positioner/MenuPositioner.tsx | 59 ++- .../Menu/radio-group/MenuRadioGroup.tsx | 2 +- .../MenuRadioItemIndicator.tsx | 15 +- .../src/components/Menu/root/MenuRoot.tsx | 108 +++--- .../src/components/Menu/store/MenuStore.ts | 31 +- .../Menu/submenu-root/MenuSubmenuRoot.tsx | 18 +- .../submenu-trigger/MenuSubmenuTrigger.tsx | 33 +- .../components/Menu/trigger/MenuTrigger.tsx | 67 ++-- .../Popover/positioner/PopoverPositioner.tsx | 4 + .../components/Popover/root/PopoverRoot.tsx | 4 +- .../Popover/trigger/PopoverTrigger.tsx | 34 +- .../ScrollArea/content/ScrollAreaContent.tsx | 11 +- .../ScrollArea/corner/ScrollAreaCorner.tsx | 2 +- .../ScrollArea/root/ScrollAreaRoot.tsx | 102 +++-- .../ScrollArea/root/ScrollAreaRootContext.ts | 49 +-- .../scrollbar/ScrollAreaScrollbar.tsx | 51 +-- .../scrollbar/ScrollAreaScrollbarContext.ts | 23 +- .../ScrollArea/thumb/ScrollAreaThumb.tsx | 2 +- .../ScrollArea/utils/scrollEdges.ts | 29 ++ .../viewport/ScrollAreaViewport.tsx | 135 ++++--- .../components/Select/arrow/SelectArrow.tsx | 15 +- .../Select/backdrop/SelectBackdrop.tsx | 11 +- .../src/components/Select/icon/SelectIcon.tsx | 11 +- .../item-indicator/SelectItemIndicator.tsx | 11 +- .../src/components/Select/item/SelectItem.tsx | 54 +-- .../src/components/Select/list/SelectList.tsx | 5 +- .../components/Select/popup/SelectPopup.tsx | 359 +++++++++++------- .../src/components/Select/popup/utils.ts | 35 ++ .../components/Select/portal/SelectPortal.tsx | 11 + .../Select/positioner/SelectPositioner.tsx | 140 ++++--- .../src/components/Select/root/SelectRoot.tsx | 188 ++++++--- .../Select/root/SelectRootContext.ts | 5 +- .../Select/scroll-arrow/SelectScrollArrow.tsx | 21 +- .../components/src/components/Select/store.ts | 43 ++- .../Select/trigger/SelectTrigger.tsx | 111 +++--- .../components/Select/value/SelectValue.tsx | 55 ++- .../Select/value/SelectValueDataAttributes.ts | 6 + .../Tooltip/positioner/TooltipPositioner.tsx | 4 + .../components/Tooltip/root/TooltipRoot.tsx | 4 +- .../Tooltip/trigger/TooltipTrigger.tsx | 86 ++--- .../trigger/TooltipTriggerDataAttributes.ts | 3 +- .../components/src/components/index.ts | 2 + .../src/lib/hooks/useAnchorPositioning.ts | 66 ++-- .../components/src/lib/itemEquality.ts | 16 + .../src/lib/popups/popupStoreUtils.ts | 37 +- .../src/lib/popups/popupTriggerMap.ts | 23 +- .../components/src/lib/popups/store.ts | 58 +-- ...lveValueLabel.ts => resolveValueLabel.tsx} | 49 ++- .../headless/components/src/lib/styles.tsx | 16 +- .../components/src/lib/visuallyHidden.ts | 30 +- .../hooks/useFloatingRootContext.ts | 8 +- .../floating-ui-react/hooks/useHover.ts | 109 ++---- .../hooks/useHoverFloatingInteraction.ts | 119 +++--- .../hooks/useHoverInteractionSharedState.ts | 109 +++--- .../hooks/useHoverReferenceInteraction.ts | 209 +++++----- .../floating-ui-react/utils/element.ts | 27 +- .../useAnimationsFinished.ts | 20 +- .../useOpenChangeComplete.ts | 34 +- .../hooks/src/hooks/useStore/ReactStor.ts | 26 +- .../useTransitionStatus.ts | 10 +- 77 files changed, 2035 insertions(+), 1393 deletions(-) create mode 100644 packages/ui/uikit/headless/components/src/components/CSPProvider/CSPContext.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/CSPProvider/CSPProvider.tsx create mode 100644 packages/ui/uikit/headless/components/src/components/CSPProvider/index.parts.ts create mode 100644 packages/ui/uikit/headless/components/src/components/CSPProvider/index.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Menu/item/useMenuItemCommonProps.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Menu/link-item/MenuLinkItem.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Menu/link-item/MenuLinkItemDataAttributes.ts create mode 100644 packages/ui/uikit/headless/components/src/components/ScrollArea/utils/scrollEdges.ts create mode 100644 packages/ui/uikit/headless/components/src/components/Select/value/SelectValueDataAttributes.ts rename packages/ui/uikit/headless/components/src/lib/{resolveValueLabel.ts => resolveValueLabel.tsx} (76%) diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json index 113eed3b..3ace0327 100644 --- a/packages/ui/uikit/headless/components/package.json +++ b/packages/ui/uikit/headless/components/package.json @@ -40,6 +40,11 @@ "import": "./dist/components/Button/index.es.js", "require": "./dist/components/Button/index.cjs.js" }, + "./c-s-p-provider": { + "types": "./dist/components/CSPProvider/index.d.ts", + "import": "./dist/components/CSPProvider/index.es.js", + "require": "./dist/components/CSPProvider/index.cjs.js" + }, "./checkbox": { "types": "./dist/components/Checkbox/index.d.ts", "import": "./dist/components/Checkbox/index.es.js", @@ -284,6 +289,7 @@ "dev": "vite build --watch", "build": "vite build && node scripts/generate-exports.js", "build:exports": "node scripts/generate-exports.js", + "typecheck": "tsc --noEmit", "test": "vitest", "test:ui": "vitest --ui", "inline-scripts": "tsx ./scripts/inlineScripts.mts" diff --git a/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPContext.tsx b/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPContext.tsx new file mode 100644 index 00000000..7e04cc1f --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPContext.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +export type CSPContextValue = { + nonce?: string | undefined; + disableStyleElements?: boolean | undefined; +}; + +/** + * @internal + */ +export const CSPContext = React.createContext(undefined); + +const DEFAULT_CSP_CONTEXT_VALUE: CSPContextValue = { + disableStyleElements: false +}; + +/** + * @internal + */ +export function useCSPContext(): CSPContextValue { + return React.use(CSPContext) ?? DEFAULT_CSP_CONTEXT_VALUE; +} diff --git a/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPProvider.tsx b/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPProvider.tsx new file mode 100644 index 00000000..e94ddc8f --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/CSPProvider/CSPProvider.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { CSPContext } from './CSPContext'; + +import type { CSPContextValue } from './CSPContext'; + +/** + * Provides a default Content Security Policy (CSP) configuration for Base UI components that + * require inline ` - ) + className: DISABLE_SCROLLBAR_CLASS_NAME, + getElement(nonce?: string) { + return ( + + ); + } }; diff --git a/packages/ui/uikit/headless/components/src/lib/visuallyHidden.ts b/packages/ui/uikit/headless/components/src/lib/visuallyHidden.ts index c5c09390..38206874 100644 --- a/packages/ui/uikit/headless/components/src/lib/visuallyHidden.ts +++ b/packages/ui/uikit/headless/components/src/lib/visuallyHidden.ts @@ -1,14 +1,24 @@ import type * as React from 'react'; +const visuallyHiddenBase: React.CSSProperties = { + clipPath: 'inset(50%)', + overflow: 'hidden', + whiteSpace: 'nowrap', + border: 0, + padding: 0, + width: 1, + height: 1, + margin: -1 +}; + 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 + ...visuallyHiddenBase, + position: 'fixed', + top: 0, + left: 0 +}; + +export const visuallyHiddenInput: React.CSSProperties = { + ...visuallyHiddenBase, + position: 'absolute' }; 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 index 3e0276e6..e5300896 100644 --- 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 @@ -44,15 +44,15 @@ export function useFloatingRootContext(options: UseFloatingRootContextOptions): const store = useLazyRef( () => - new FloatingRootStore({ + new FloatingRootStore({ open, onOpenChange, referenceElement: elements.reference ?? null, floatingElement: elements.floating ?? null, - triggerElements: elements.triggers ?? new PopupTriggerMap(), + triggerElements: new PopupTriggerMap(), floatingId, nested, - noEmit: options.noEmit || false + noEmit: false }) ).current; @@ -83,7 +83,7 @@ export function useFloatingRootContext(options: UseFloatingRootContextOptions): store.context.onOpenChange = onOpenChange; store.context.nested = nested; - store.context.noEmit = options.noEmit || false; + store.context.noEmit = false; return store; } 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 index 7eab3cac..122f4f18 100644 --- 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 @@ -6,6 +6,7 @@ import { useValueAsRef } from '@flippo-ui/hooks/use-value-as-ref'; import { isElement } from '@floating-ui/utils/dom'; import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import { ownerDocument } from '~@lib/owner'; import { REASONS } from '~@lib/reason'; import type { FloatingUIOpenChangeDetails } from '~@lib/types'; @@ -13,14 +14,12 @@ import type { FloatingUIOpenChangeDetails } from '~@lib/types'; import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; import { contains, - getDocument, getTarget, isMouseLikePointerType } from '../utils'; import { TYPEABLE_SELECTOR } from '../utils/constants'; import { createAttribute } from '../utils/createAttribute'; -import type { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { Delay, ElementProps, @@ -80,52 +79,30 @@ function getRestMs(value: number | (() => number)) { } export type 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; + handleClose?: HandleClose | null | undefined; /** * Waits until the user’s cursor is at “rest” over the reference element * before changing the `open` state. * @default 0 */ - restMs?: number | (() => number); + restMs?: number | (() => number) | undefined; /** * 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; + delay?: Delay | (() => Delay) | undefined; /** * Whether moving the cursor over the floating element will open it, without a * regular hover event required. * @default true */ - move?: boolean; - /** - * Allows to override the element that will trigger the popup. - * When it's set, useHover won't read the reference element from the root context. - * This allows to have multiple triggers per floating element (assuming `useHover` is called per trigger). - */ - triggerElement?: HTMLElement | null; - /** - * External FlatingTree to use when the one provided by context can't be used. - */ - externalTree?: FloatingTreeStore; + move?: boolean | undefined; }; /** @@ -143,17 +120,13 @@ export function useHover( const domReferenceElement = store.useState('domReferenceElement'); const { dataRef, events } = store.context; const { - enabled = true, delay = 0, handleClose = null, - mouseOnly = false, restMs = 0, - move = true, - triggerElement = null, - externalTree + move = true } = props; - const tree = useFloatingTree(externalTree); + const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); const handleCloseRef = useValueAsRef(handleClose); const delayRef = useValueAsRef(delay); @@ -187,10 +160,6 @@ export function useHover( // When closing before opening, clear the delay timeouts to cancel it // from showing. React.useEffect(() => { - if (!enabled) { - return undefined; - } - function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if (!details.open) { timeout.clear(); @@ -204,12 +173,9 @@ export function useHover( return () => { events.off('openchange', onOpenChangeLocal); }; - }, [enabled, events, timeout, restTimeout]); + }, [events, timeout, restTimeout]); React.useEffect(() => { - if (!enabled) { - return undefined; - } if (!handleCloseRef.current) { return undefined; } @@ -234,7 +200,7 @@ export function useHover( } } - const html = getDocument(floatingElement).documentElement; + const html = ownerDocument(floatingElement).documentElement; html.addEventListener('mouseleave', onLeave); return () => { html.removeEventListener('mouseleave', onLeave); @@ -243,7 +209,6 @@ export function useHover( floatingElement, open, store, - enabled, handleCloseRef, isHoverOpen, isClickLikeOpenEvent @@ -271,7 +236,7 @@ export function useHover( const clearPointerEvents = useStableCallback(() => { if (performedPointerEventsMutationRef.current) { - const body = getDocument(floatingElement).body; + const body = ownerDocument(floatingElement).body; body.style.pointerEvents = ''; body.removeAttribute(safePolygonIdentifier); performedPointerEventsMutationRef.current = false; @@ -292,18 +257,11 @@ export function useHover( // 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')) - ) { + if (getRestMs(restMsRef.current) > 0 && !getDelay(delayRef.current, 'open')) { return; } @@ -334,7 +292,7 @@ export function useHover( unbindMouseMoveRef.current(); - const doc = getDocument(floatingElement); + const doc = ownerDocument(floatingElement); restTimeout.clear(); restTimeoutPendingRef.current = false; @@ -380,9 +338,9 @@ export function useHover( // pointer, a short close delay is an alternative, so it should work // consistently. const shouldClose - = pointerTypeRef.current === 'touch' - ? !contains(floatingElement, event.relatedTarget as Element | null) - : true; + = pointerTypeRef.current === 'touch' + ? !contains(floatingElement, event.relatedTarget as Element | null) + : true; if (shouldClose) { closeWithDelay(event); } @@ -392,10 +350,7 @@ export function useHover( // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 function onScrollMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent()) { - return; - } - if (!dataRef.current.floatingContext) { + if (isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.select('open')) { return; } @@ -433,7 +388,7 @@ export function useHover( } } - const trigger = (triggerElement ?? domReferenceElement) as HTMLElement | null; + const trigger = domReferenceElement as HTMLElement | null; if (isElement(trigger)) { const floating = floatingElement; @@ -481,12 +436,9 @@ export function useHover( return undefined; }, [ - enabled, - mouseOnly, move, domReferenceElement, floatingElement, - triggerElement, store, closeWithDelay, cleanupMouseMoveHandler, @@ -508,16 +460,12 @@ export function useHover( // handles nested floating elements. // https://github.com/floating-ui/floating-ui/issues/1722 useIsoLayoutEffect(() => { - if (!enabled) { - return undefined; - } - if (open && handleCloseRef.current?.__options?.blockPointerEvents && isHoverOpen()) { performedPointerEventsMutationRef.current = true; const floatingEl = floatingElement; if (isElement(domReferenceElement) && floatingEl) { - const body = getDocument(floatingElement).body; + const body = ownerDocument(floatingElement).body; body.setAttribute(safePolygonIdentifier, ''); const ref = domReferenceElement as HTMLElement | SVGSVGElement; @@ -542,7 +490,6 @@ export function useHover( return undefined; }, [ - enabled, open, parentId, tree, @@ -569,13 +516,7 @@ export function useHover( restTimeout.clear(); interactedInsideRef.current = false; }; - }, [ - enabled, - domReferenceElement, - cleanupMouseMoveHandler, - timeout, - restTimeout - ]); + }, [domReferenceElement, cleanupMouseMoveHandler, timeout, restTimeout]); React.useEffect(() => { return clearPointerEvents; @@ -596,8 +537,8 @@ export function useHover( // `true` when there are multiple triggers per floating element and user hovers over the one that // wasn't used to open the floating element. const isOverInactiveTrigger - = store.select('domReferenceElement') - && !contains(store.select('domReferenceElement'), event.target as Element); + = store.select('domReferenceElement') + && !contains(store.select('domReferenceElement'), event.target as Element); function handleMouseMove() { if (!blockMouseMoveRef.current && (!store.select('open') || isOverInactiveTrigger)) { @@ -608,10 +549,6 @@ export function useHover( } } - if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { - return; - } - if ( (store.select('open') && !isOverInactiveTrigger) || getRestMs(restMsRef.current) === 0 @@ -642,7 +579,7 @@ export function useHover( } } }; - }, [mouseOnly, store, restMsRef, restTimeout]); + }, [store, restMsRef, restTimeout]); - return React.useMemo(() => (enabled ? { reference } : {}), [enabled, reference]); + return React.useMemo(() => ({ reference }), [reference]); } diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverFloatingInteraction.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverFloatingInteraction.ts index 4bc36c5e..370f17ed 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverFloatingInteraction.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverFloatingInteraction.ts @@ -5,10 +5,16 @@ import { useStableCallback } from '@flippo-ui/hooks/use-stable-callback'; import { isElement } from '@floating-ui/utils/dom'; import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import { ownerDocument } from '~@lib/owner'; import { REASONS } from '~@lib/reason'; import { useFloatingParentNodeId, useFloatingTree } from '../components/FloatingTree'; -import { getDocument, getTarget, isMouseLikePointerType } from '../utils'; +import { + getDocument, + getTarget, + isMouseLikePointerType, + isTargetInsideEnabledTrigger +} from '../utils'; import type { FloatingTreeStore } from '../components/FloatingTreeStore'; import type { FloatingContext, FloatingRootContext } from '../types'; @@ -53,24 +59,15 @@ export function useHoverFloatingInteraction( const domReferenceElement = store.useState('domReferenceElement'); const { dataRef } = store.context; - const { enabled = true, closeDelay: closeDelayProp = 0, externalTree } = parameters; + const { enabled = true, closeDelay: closeDelayProp = 0 } = parameters; - const { - pointerTypeRef, - interactedInsideRef, - handlerRef, - performedPointerEventsMutationRef, - unbindMouseMoveRef, - restTimeoutPendingRef, - openChangeTimeout, - handleCloseOptionsRef - } = useHoverInteractionSharedState(store); + const instance = useHoverInteractionSharedState(store); - const tree = useFloatingTree(externalTree); + const tree = useFloatingTree(); const parentId = useFloatingParentNodeId(); const isClickLikeOpenEvent = useStableCallback(() => { - if (interactedInsideRef.current) { + if (instance.interactedInside) { return true; } @@ -82,67 +79,58 @@ export function useHoverFloatingInteraction( return type?.includes('mouse') && type !== 'mousedown'; }); + const isRelatedTargetInsideEnabledTrigger = useStableCallback((target: EventTarget | null) => { + return isTargetInsideEnabledTrigger(target, store.context.triggerElements); + }); + const closeWithDelay = React.useCallback( (event: MouseEvent, runElseBranch = true) => { - const closeDelay = getDelay(closeDelayProp, pointerTypeRef.current); - if (closeDelay && !handlerRef.current) { - openChangeTimeout.start(closeDelay, () => + const closeDelay = getDelay(closeDelayProp, instance.pointerType); + if (closeDelay && !instance.handler) { + instance.openChangeTimeout.start(closeDelay, () => store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event))); } else if (runElseBranch) { - openChangeTimeout.clear(); + instance.openChangeTimeout.clear(); store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); } }, - [ - closeDelayProp, - handlerRef, - store, - pointerTypeRef, - openChangeTimeout - ] + [closeDelayProp, store, instance] ); const cleanupMouseMoveHandler = useStableCallback(() => { - unbindMouseMoveRef.current(); - handlerRef.current = undefined; + instance.unbindMouseMove(); + instance.handler = undefined; }); const clearPointerEvents = useStableCallback(() => { - if (performedPointerEventsMutationRef.current) { - const body = getDocument(floatingElement).body; + if (instance.performedPointerEventsMutation) { + const body = ownerDocument(floatingElement).body; body.style.pointerEvents = ''; body.removeAttribute(safePolygonIdentifier); - performedPointerEventsMutationRef.current = false; + instance.performedPointerEventsMutation = false; } }); const handleInteractInside = useStableCallback((event: PointerEvent) => { const target = getTarget(event) as Element | null; if (!isInteractiveElement(target)) { - interactedInsideRef.current = false; + instance.interactedInside = false; return; } - interactedInsideRef.current = true; + instance.interactedInside = true; }); useIsoLayoutEffect(() => { if (!open) { - pointerTypeRef.current = undefined; - restTimeoutPendingRef.current = false; - interactedInsideRef.current = false; + instance.pointerType = undefined; + instance.restTimeoutPending = false; + instance.interactedInside = false; cleanupMouseMoveHandler(); clearPointerEvents(); } - }, [ - open, - pointerTypeRef, - restTimeoutPendingRef, - interactedInsideRef, - cleanupMouseMoveHandler, - clearPointerEvents - ]); + }, [open, instance, cleanupMouseMoveHandler, clearPointerEvents]); React.useEffect(() => { return () => { @@ -161,13 +149,13 @@ export function useHoverFloatingInteraction( if ( open - && handleCloseOptionsRef.current?.blockPointerEvents + && instance.handleCloseOptions?.blockPointerEvents && isHoverOpen() && isElement(domReferenceElement) && floatingElement ) { - performedPointerEventsMutationRef.current = true; - const body = getDocument(floatingElement).body; + instance.performedPointerEventsMutation = true; + const body = ownerDocument(floatingElement).body; body.setAttribute(safePolygonIdentifier, ''); const ref = domReferenceElement as HTMLElement | SVGSVGElement; @@ -196,11 +184,10 @@ export function useHoverFloatingInteraction( open, domReferenceElement, floatingElement, - handleCloseOptionsRef, + instance, isHoverOpen, tree, - parentId, - performedPointerEventsMutationRef + parentId ]); React.useEffect(() => { @@ -212,20 +199,23 @@ export function useHoverFloatingInteraction( // did not move. // https://github.com/floating-ui/floating-ui/discussions/1692 function onScrollMouseLeave(event: MouseEvent) { - if (isClickLikeOpenEvent()) { - return; - } - if (!dataRef.current.floatingContext) { + if (isClickLikeOpenEvent() || !dataRef.current.floatingContext || !store.select('open')) { return; } - const triggerElements = store.context.triggerElements; - if (event.relatedTarget && triggerElements.hasElement(event.relatedTarget as Element)) { + if (isRelatedTargetInsideEnabledTrigger(event.relatedTarget)) { // If the mouse is leaving the reference element to another trigger, don't explicitly close the popup // as it will be moved. return; } + // If the safePolygon handler is active, let it handle the close logic. + // The handler checks for open children in the floating tree. + if (instance.handler) { + instance.handler(event); + return; + } + clearPointerEvents(); cleanupMouseMoveHandler(); if (!isClickLikeOpenEvent()) { @@ -234,10 +224,9 @@ export function useHoverFloatingInteraction( } function onFloatingMouseEnter(event: MouseEvent) { - openChangeTimeout.clear(); + instance.openChangeTimeout.clear(); clearPointerEvents(); - handlerRef.current?.(event); - cleanupMouseMoveHandler(); + instance.handler?.(event); } function onFloatingMouseLeave(event: MouseEvent) { @@ -262,7 +251,19 @@ export function useHoverFloatingInteraction( floating.removeEventListener('pointerdown', handleInteractInside, true); } }; - }); + }, [ + enabled, + floatingElement, + store, + dataRef, + isClickLikeOpenEvent, + isRelatedTargetInsideEnabledTrigger, + closeWithDelay, + clearPointerEvents, + cleanupMouseMoveHandler, + handleInteractInside, + instance + ]); } export function getDelay( diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverInteractionSharedState.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverInteractionSharedState.ts index 183d1952..d73e694f 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverInteractionSharedState.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverInteractionSharedState.ts @@ -1,6 +1,6 @@ -import * as React from 'react'; - -import { useTimeout } from '@flippo-ui/hooks'; +import { Timeout } from '@flippo-ui/hooks'; +import { useLazyRef } from '@flippo-ui/hooks/use-lazy-ref'; +import { useOnMount } from '@flippo-ui/hooks/use-on-mount'; import { TYPEABLE_SELECTOR } from '../utils/constants'; import { createAttribute } from '../utils/createAttribute'; @@ -14,67 +14,58 @@ export function isInteractiveElement(element: Element | null) { return element ? Boolean(element.closest(interactiveSelector)) : false; } -export type HoverInteractionSharedState = { - pointerTypeRef: React.RefObject; - interactedInsideRef: React.RefObject; - handlerRef: React.RefObject<((event: MouseEvent) => void) | undefined>; - blockMouseMoveRef: React.RefObject; - performedPointerEventsMutationRef: React.RefObject; - unbindMouseMoveRef: React.RefObject<() => void>; - restTimeoutPendingRef: React.RefObject; - openChangeTimeout: ReturnType; - restTimeout: ReturnType; - handleCloseOptionsRef: React.RefObject; -}; +export class HoverInteraction { + pointerType: string | undefined; + interactedInside: boolean; + handler: ((event: MouseEvent) => void) | undefined; + blockMouseMove: boolean; + performedPointerEventsMutation: boolean; + unbindMouseMove: () => void; + restTimeoutPending: boolean; + openChangeTimeout: Timeout; + restTimeout: Timeout; + handleCloseOptions: SafePolygonOptions | undefined; + + constructor() { + this.pointerType = undefined; + this.interactedInside = false; + this.handler = undefined; + this.blockMouseMove = true; + this.performedPointerEventsMutation = false; + this.unbindMouseMove = () => {}; + this.restTimeoutPending = false; + this.openChangeTimeout = new Timeout(); + this.restTimeout = new Timeout(); + this.handleCloseOptions = undefined; + } + + static create(): HoverInteraction { + return new HoverInteraction(); + } + + dispose = () => { + this.openChangeTimeout.clear(); + this.restTimeout.clear(); + }; + + disposeEffect = () => { + return this.dispose; + }; +} type HoverContextData = ContextData & { - hoverInteractionState?: HoverInteractionSharedState; + hoverInteractionState?: HoverInteraction | undefined; }; -export function useHoverInteractionSharedState( - store: FloatingRootContext -): HoverInteractionSharedState { - const pointerTypeRef = React.useRef(undefined); - const interactedInsideRef = React.useRef(false); - const handlerRef = React.useRef<((event: MouseEvent) => void) | undefined>(undefined); - const blockMouseMoveRef = React.useRef(true); - const performedPointerEventsMutationRef = React.useRef(false); - const unbindMouseMoveRef = React.useRef<() => void>(() => {}); - const restTimeoutPendingRef = React.useRef(false); - const openChangeTimeout = useTimeout(); - const restTimeout = useTimeout(); - const handleCloseOptionsRef = React.useRef(undefined); +export function useHoverInteractionSharedState(store: FloatingRootContext): HoverInteraction { + const instance = useLazyRef(HoverInteraction.create).current; - return React.useMemo(() => { - const data = store.context.dataRef.current as HoverContextData; + const data = store.context.dataRef.current as HoverContextData; + if (!data.hoverInteractionState) { + data.hoverInteractionState = instance; + } - if (!data.hoverInteractionState) { - data.hoverInteractionState = { - pointerTypeRef, - interactedInsideRef, - handlerRef, - blockMouseMoveRef, - performedPointerEventsMutationRef, - unbindMouseMoveRef, - restTimeoutPendingRef, - openChangeTimeout, - restTimeout, - handleCloseOptionsRef - }; - } + useOnMount(data.hoverInteractionState.disposeEffect); - return data.hoverInteractionState; - }, [ - store, - pointerTypeRef, - interactedInsideRef, - handlerRef, - blockMouseMoveRef, - performedPointerEventsMutationRef, - unbindMouseMoveRef, - restTimeoutPendingRef, - openChangeTimeout, - restTimeout, - handleCloseOptionsRef - ]); + return data.hoverInteractionState; } diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverReferenceInteraction.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverReferenceInteraction.ts index 2b99dee3..196fad1e 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverReferenceInteraction.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/hooks/useHoverReferenceInteraction.ts @@ -6,14 +6,15 @@ import { useValueAsRef } from '@flippo-ui/hooks/use-value-as-ref'; import { isElement } from '@floating-ui/utils/dom'; import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import { ownerDocument } from '~@lib/owner'; import { REASONS } from '~@lib/reason'; import type { FloatingUIOpenChangeDetails, HTMLProps } from '~@lib/types'; import { useFloatingTree } from '../components/FloatingTree'; -import { contains, getDocument, isMouseLikePointerType } from '../utils'; +import { contains, isMouseLikePointerType, isTargetInsideEnabledTrigger } from '../utils'; -import type { FloatingContext, FloatingRootContext } from '../types'; +import type { FloatingContext, FloatingRootContext, FloatingTreeStore } from '../types'; import { getDelay } from './useHover'; import { @@ -24,13 +25,17 @@ import { import type { UseHoverProps } from './useHover'; export type UseHoverReferenceInteractionProps = { + enabled?: boolean | undefined; + mouseOnly?: boolean | undefined; + externalTree?: FloatingTreeStore | undefined; /** * Whether the hook controls the active trigger. When false, the props are * returned under the `trigger` key so they can be applied to inactive * triggers via `getTriggerProps`. * @default true */ - isActiveTrigger?: boolean; + isActiveTrigger?: boolean | undefined; + triggerElementRef?: Readonly> | undefined; } & UseHoverProps; function getRestMs(value: number | (() => number)) { @@ -60,36 +65,26 @@ export function useHoverReferenceInteraction( mouseOnly = false, restMs = 0, move = true, - triggerElement = EMPTY_REF, + triggerElementRef = EMPTY_REF, externalTree, isActiveTrigger = true } = props; const tree = useFloatingTree(externalTree); - const { - pointerTypeRef, - interactedInsideRef, - handlerRef: closeHandlerRef, - blockMouseMoveRef, - performedPointerEventsMutationRef, - unbindMouseMoveRef, - restTimeoutPendingRef, - openChangeTimeout, - restTimeout, - handleCloseOptionsRef - } = useHoverInteractionSharedState(store); + const instance = useHoverInteractionSharedState(store); const handleCloseRef = useValueAsRef(handleClose); const delayRef = useValueAsRef(delay); const restMsRef = useValueAsRef(restMs); + const enabledRef = useValueAsRef(enabled); if (isActiveTrigger) { - handleCloseOptionsRef.current = handleCloseRef.current?.__options; + instance.handleCloseOptions = handleCloseRef.current?.__options; } const isClickLikeOpenEvent = useStableCallback(() => { - if (interactedInsideRef.current) { + if (instance.interactedInside) { return true; } @@ -98,38 +93,36 @@ export function useHoverReferenceInteraction( : false; }); + const isRelatedTargetInsideEnabledTrigger = useStableCallback((target: EventTarget | null) => { + return isTargetInsideEnabledTrigger(target, store.context.triggerElements); + }); + const closeWithDelay = React.useCallback( (event: MouseEvent, runElseBranch = true) => { - const closeDelay = getDelay(delayRef.current, 'close', pointerTypeRef.current); - if (closeDelay && !closeHandlerRef.current) { - openChangeTimeout.start(closeDelay, () => + const closeDelay = getDelay(delayRef.current, 'close', instance.pointerType); + if (closeDelay && !instance.handler) { + instance.openChangeTimeout.start(closeDelay, () => store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event))); } else if (runElseBranch) { - openChangeTimeout.clear(); + instance.openChangeTimeout.clear(); store.setOpen(false, createChangeEventDetails(REASONS.triggerHover, event)); } }, - [ - delayRef, - closeHandlerRef, - store, - pointerTypeRef, - openChangeTimeout - ] + [delayRef, store, instance] ); const cleanupMouseMoveHandler = useStableCallback(() => { - unbindMouseMoveRef.current(); - closeHandlerRef.current = undefined; + instance.unbindMouseMove(); + instance.handler = undefined; }); const clearPointerEvents = useStableCallback(() => { - if (performedPointerEventsMutationRef.current) { - const body = getDocument(store.select('domReferenceElement')).body; + if (instance.performedPointerEventsMutation) { + const body = ownerDocument(store.select('domReferenceElement')).body; body.style.pointerEvents = ''; body.removeAttribute(safePolygonIdentifier); - performedPointerEventsMutationRef.current = false; + instance.performedPointerEventsMutation = false; } }); @@ -142,10 +135,10 @@ export function useHoverReferenceInteraction( function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) { if (!details.open) { - openChangeTimeout.clear(); - restTimeout.clear(); - blockMouseMoveRef.current = true; - restTimeoutPendingRef.current = false; + instance.openChangeTimeout.clear(); + instance.restTimeout.clear(); + instance.blockMouseMove = true; + instance.restTimeoutPending = false; } } @@ -153,14 +146,7 @@ export function useHoverReferenceInteraction( return () => { events.off('openchange', onOpenChangeLocal); }; - }, [ - enabled, - events, - openChangeTimeout, - restTimeout, - blockMouseMoveRef, - restTimeoutPendingRef - ]); + }, [enabled, events, instance]); const handleScrollMouseLeave = useStableCallback((event: MouseEvent) => { if (isClickLikeOpenEvent()) { @@ -170,11 +156,12 @@ export function useHoverReferenceInteraction( return; } - const triggerElements = store.context.triggerElements; - if (event.relatedTarget && triggerElements.hasElement(event.relatedTarget as Element)) { + if (isRelatedTargetInsideEnabledTrigger(event.relatedTarget)) { return; } + const currentTrigger = triggerElementRef.current; + handleCloseRef.current?.({ ...dataRef.current.floatingContext, tree, @@ -183,7 +170,7 @@ export function useHoverReferenceInteraction( onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent()) { + if (!isClickLikeOpenEvent() && currentTrigger === store.select('domReferenceElement')) { closeWithDelay(event); } } @@ -196,7 +183,7 @@ export function useHoverReferenceInteraction( } const trigger - = (triggerElement as HTMLElement | null) + = (triggerElementRef.current as HTMLElement | null) ?? (isActiveTrigger ? (store.select('domReferenceElement') as HTMLElement | null) : null); if (!isElement(trigger)) { @@ -204,10 +191,10 @@ export function useHoverReferenceInteraction( } function onMouseEnter(event: MouseEvent) { - openChangeTimeout.clear(); - blockMouseMoveRef.current = false; + instance.openChangeTimeout.clear(); + instance.blockMouseMove = false; - if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { + if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) { return; } @@ -217,25 +204,32 @@ export function useHoverReferenceInteraction( return; } - const openDelay = getDelay(delayRef.current, 'open', pointerTypeRef.current); + const openDelay = getDelay(delayRef.current, 'open', instance.pointerType); const currentDomReference = store.select('domReferenceElement'); const allTriggers = store.context.triggerElements; const isOverInactiveTrigger - = (allTriggers.hasElement(event.target as Element) - || allTriggers.hasMatchingElement((t) => contains(t, event.target as Element))) - && (!currentDomReference || !contains(currentDomReference, event.target as Element)); + = (allTriggers.hasElement(event.target as Element) + || allTriggers.hasMatchingElement((t) => contains(t, event.target as Element))) + && (!currentDomReference || !contains(currentDomReference, event.target as Element)); const triggerNode = (event.currentTarget as HTMLElement) ?? null; - if (openDelay) { - openChangeTimeout.start(openDelay, () => { - if (!store.select('open')) { + const isOpen = store.select('open'); + const shouldOpen = !isOpen || isOverInactiveTrigger; + + // When moving between triggers while already open, open immediately without delay + if (isOverInactiveTrigger && isOpen) { + store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); + } + else if (openDelay) { + instance.openChangeTimeout.start(openDelay, () => { + if (shouldOpen) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); } }); } - else if (!store.select('open') || isOverInactiveTrigger) { + else if (shouldOpen) { store.setOpen(true, createChangeEventDetails(REASONS.triggerHover, event, triggerNode)); } } @@ -246,25 +240,25 @@ export function useHoverReferenceInteraction( return; } - unbindMouseMoveRef.current(); + instance.unbindMouseMove(); const domReferenceElement = store.select('domReferenceElement'); - const doc = getDocument(domReferenceElement); - restTimeout.clear(); - restTimeoutPendingRef.current = false; - - const triggerElements = store.context.triggerElements; + const doc = ownerDocument(domReferenceElement); + instance.restTimeout.clear(); + instance.restTimeoutPending = false; - if (event.relatedTarget && triggerElements.hasElement(event.relatedTarget as Element)) { + if (isRelatedTargetInsideEnabledTrigger(event.relatedTarget)) { return; } if (handleCloseRef.current && dataRef.current.floatingContext) { if (!store.select('open')) { - openChangeTimeout.clear(); + instance.openChangeTimeout.clear(); } - closeHandlerRef.current = handleCloseRef.current({ + const currentTrigger = triggerElementRef.current; + + instance.handler = handleCloseRef.current({ ...dataRef.current.floatingContext, tree, x: event.clientX, @@ -272,17 +266,21 @@ export function useHoverReferenceInteraction( onClose() { clearPointerEvents(); cleanupMouseMoveHandler(); - if (!isClickLikeOpenEvent()) { + if ( + enabledRef.current + && !isClickLikeOpenEvent() + && currentTrigger === store.select('domReferenceElement') + ) { closeWithDelay(event, true); } } }); - const handler = closeHandlerRef.current; + const handler = instance.handler; handler(event); doc.addEventListener('mousemove', handler); - unbindMouseMoveRef.current = () => { + instance.unbindMouseMove = () => { doc.removeEventListener('mousemove', handler); }; @@ -290,9 +288,9 @@ export function useHoverReferenceInteraction( } const shouldClose - = pointerTypeRef.current === 'touch' - ? !contains(store.select('floatingElement'), event.relatedTarget as Element | null) - : true; + = instance.pointerType === 'touch' + ? !contains(store.select('floatingElement'), event.relatedTarget as Element | null) + : true; if (shouldClose) { closeWithDelay(event); @@ -329,7 +327,6 @@ export function useHoverReferenceInteraction( }, [ cleanupMouseMoveHandler, clearPointerEvents, - blockMouseMoveRef, dataRef, delayRef, closeWithDelay, @@ -337,24 +334,25 @@ export function useHoverReferenceInteraction( enabled, handleCloseRef, handleScrollMouseLeave, + instance, isActiveTrigger, isClickLikeOpenEvent, + isRelatedTargetInsideEnabledTrigger, mouseOnly, move, - pointerTypeRef, restMsRef, - restTimeout, - restTimeoutPendingRef, - openChangeTimeout, + triggerElementRef, tree, - unbindMouseMoveRef, - closeHandlerRef, - triggerElement + enabledRef ]); - return React.useMemo(() => { + return React.useMemo(() => { + if (!enabled) { + return undefined; + } + function setPointerRef(event: React.PointerEvent) { - pointerTypeRef.current = event.pointerType; + instance.pointerType = event.pointerType; } return { @@ -369,11 +367,11 @@ export function useHoverReferenceInteraction( const currentOpen = store.select('open'); const isOverInactiveTrigger - = (allTriggers.hasElement(event.target as Element) - || allTriggers.hasMatchingElement((t) => contains(t, event.target as Element))) - && (!currentDomReference || !contains(currentDomReference, event.target as Element)); + = (allTriggers.hasElement(event.target as Element) + || allTriggers.hasMatchingElement((t) => contains(t, event.target as Element))) + && (!currentDomReference || !contains(currentDomReference, event.target as Element)); - if (mouseOnly && !isMouseLikePointerType(pointerTypeRef.current)) { + if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) { return; } @@ -383,16 +381,26 @@ export function useHoverReferenceInteraction( if ( !isOverInactiveTrigger - && restTimeoutPendingRef.current + && instance.restTimeoutPending && event.movementX ** 2 + event.movementY ** 2 < 2 ) { return; } - restTimeout.clear(); + instance.restTimeout.clear(); function handleMouseMove() { - if (!blockMouseMoveRef.current && (!currentOpen || isOverInactiveTrigger)) { + instance.restTimeoutPending = false; + + // A delayed hover open should not override a click-like open that happened + // while the hover delay was pending. + if (isClickLikeOpenEvent()) { + return; + } + + const latestOpen = store.select('open'); + + if (!instance.blockMouseMove && (!latestOpen || isOverInactiveTrigger)) { store.setOpen( true, createChangeEventDetails(REASONS.triggerHover, nativeEvent, trigger) @@ -400,7 +408,7 @@ export function useHoverReferenceInteraction( } } - if (pointerTypeRef.current === 'touch') { + if (instance.pointerType === 'touch') { ReactDOM.flushSync(() => { handleMouseMove(); }); @@ -409,18 +417,17 @@ export function useHoverReferenceInteraction( handleMouseMove(); } else { - restTimeoutPendingRef.current = true; - restTimeout.start(getRestMs(restMsRef.current), handleMouseMove); + instance.restTimeoutPending = true; + instance.restTimeout.start(getRestMs(restMsRef.current), handleMouseMove); } } }; }, [ - blockMouseMoveRef, + enabled, + instance, + isClickLikeOpenEvent, mouseOnly, store, - pointerTypeRef, - restMsRef, - restTimeout, - restTimeoutPendingRef + restMsRef ]); } diff --git a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/element.ts b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/element.ts index 5e9a89df..40d7677a 100644 --- a/packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/element.ts +++ b/packages/ui/uikit/headless/components/src/packages/floating-ui-react/utils/element.ts @@ -1,7 +1,9 @@ -import { isHTMLElement, isShadowRoot } from '@floating-ui/utils/dom'; +import { isElement, isHTMLElement, isShadowRoot } from '@floating-ui/utils/dom'; import { isJSDOM } from '~@lib/detectBrowser'; +import type { PopupTriggerMap } from '~@lib/popups'; + import { FOCUSABLE_ATTRIBUTE, TYPEABLE_SELECTOR } from './constants'; export function activeElement(doc: Document) { @@ -41,6 +43,29 @@ export function contains(parent?: Element | null, child?: Element | null) { return false; } +export function isTargetInsideEnabledTrigger( + target: EventTarget | null, + triggerElements: PopupTriggerMap +) { + if (!isElement(target)) { + return false; + } + + const targetElement = target as Element; + + if (triggerElements.hasElement(targetElement)) { + return !targetElement.hasAttribute('data-trigger-disabled'); + } + + for (const [, trigger] of triggerElements.entries()) { + if (contains(trigger, targetElement)) { + return !trigger.hasAttribute('data-trigger-disabled'); + } + } + + return false; +} + export function getTarget(event: Event) { if ('composedPath' in event) { return event.composedPath()[0]; diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts b/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts index 61c61c11..6c29e1a4 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useAnimationsFinished/useAnimationsFinished.ts @@ -53,12 +53,22 @@ export function useAnimationsFinished( } else { frame.request(() => { - function exec() { + function exec(retryOnEmpty = false) { if (!element) { return; } - Promise.all(element.getAnimations().map((anim) => anim.finished)) + const animations = element.getAnimations(); + + // If no animations are detected, the browser may not have started the CSS + // transitions yet (e.g. for complex popups that take longer to process style + // changes). Wait one more frame and try again before giving up. + if (animations.length === 0 && retryOnEmpty) { + frame.request(() => exec(false)); + return; + } + + Promise.all(animations.map((anim) => anim.finished)) .then(() => { if (signal != null && signal.aborted) { return; @@ -86,17 +96,17 @@ export function useAnimationsFinished( // Sometimes animations can be aborted because a property they depend on changes // while the animation plays. // In such cases, we need to re-check if any new animations have started. - exec(); + exec(false); } }); } // `open: true` animations need to wait for the next tick to be detected if (waitForNextTick) { - frame.request(exec); + frame.request(() => exec(true)); } else { - exec(); + exec(true); } }); } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useOpenChangeComplete/useOpenChangeComplete.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOpenChangeComplete/useOpenChangeComplete.ts index ed232304..8f5a6ab6 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useOpenChangeComplete/useOpenChangeComplete.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOpenChangeComplete/useOpenChangeComplete.ts @@ -1,8 +1,7 @@ import React from 'react'; import { useAnimationsFinished } from '../useAnimationsFinished'; -import { useEventCallback } from '../useEventCallback'; -import { useLatestRef } from '../useLatestRef'; +import { useStableCallback } from '../useStableCallback'; type TUseOpenChangeCompleteParameters = { ref: React.RefObject; @@ -19,23 +18,20 @@ export function useOpenChangeComplete(params: TUseOpenChangeCompleteParameters) onComplete: onCompleteParam } = params; - const openRef = useLatestRef(open); - const onComplete = useEventCallback(onCompleteParam); - const runOnAnimationFinished = useAnimationsFinished(ref, open); + const onComplete = useStableCallback(onCompleteParam); + const runOnceAnimationsFinish = useAnimationsFinished(ref, open, false); React.useEffect(() => { - if (!enabled) - return; - - runOnAnimationFinished(() => { - if (open === openRef.current) - onComplete(); - }); - }, [ - open, - enabled, - onComplete, - runOnAnimationFinished, - openRef - ]); + if (!enabled) { + return undefined; + } + + const abortController = new AbortController(); + + runOnceAnimationsFinish(onComplete, abortController.signal); + + return () => { + abortController.abort(); + }; + }, [enabled, open, onComplete, runOnceAnimationsFinish]); } diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useStore/ReactStor.ts b/packages/ui/uikit/headless/hooks/src/hooks/useStore/ReactStor.ts index af3456d6..b24a0776 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useStore/ReactStor.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useStore/ReactStor.ts @@ -105,12 +105,18 @@ export class ReactStore< */ public useControlledProp( key: keyof State, - controlled: Value | undefined, - defaultValue: Value + controlled: Value | undefined ): void { React.useDebugValue(key); const isControlled = controlled !== undefined; + useIsoLayoutEffect(() => { + if (isControlled && !Object.is(this.state[key], controlled)) { + // Set the internal state to match the controlled value. + super.setState({ ...this.state, [key]: controlled }); + } + }, [key, controlled, isControlled]); + if (process.env.NODE_ENV !== 'production') { const previouslyControlled = this.controlledValues.get(key); if (previouslyControlled !== undefined && previouslyControlled !== isControlled) { @@ -120,22 +126,6 @@ export class ReactStore< ); } } - - if (!this.controlledValues.has(key)) { - // First time initialization - this.controlledValues.set(key, isControlled); - - if (!isControlled && !Object.is(this.state[key], defaultValue)) { - super.setState({ ...this.state, [key]: defaultValue }); - } - } - - useIsoLayoutEffect(() => { - if (isControlled && !Object.is(this.state[key], controlled)) { - // Set the internal state to match the controlled value. - super.setState({ ...this.state, [key]: controlled }); - } - }, [key, controlled, defaultValue, isControlled]); } /** diff --git a/packages/ui/uikit/headless/hooks/src/hooks/useTransitionStatus/useTransitionStatus.ts b/packages/ui/uikit/headless/hooks/src/hooks/useTransitionStatus/useTransitionStatus.ts index c7e12c87..aa692a4f 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/useTransitionStatus/useTransitionStatus.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/useTransitionStatus/useTransitionStatus.ts @@ -1,5 +1,4 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import { AnimationFrame } from '../useAnimationFrame'; import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; @@ -53,13 +52,10 @@ export function useTransitionStatus( return undefined; } - // Double RAF is needed to ensure the browser has painted the element - // with starting styles before we remove them. The first RAF waits for - // the browser to paint, the second RAF then removes the starting style. const frame = AnimationFrame.request(() => { - ReactDOM.flushSync(() => { - setTransitionStatus(undefined); - }); + // Avoid `flushSync` here due to Firefox. + // See https://github.com/mui/base-ui/pull/3424 + setTransitionStatus(undefined); }); return () => { From 6221cb85ea38d7affb33bac8817dad8cb46377c8 Mon Sep 17 00:00:00 2001 From: BlackPoretsky <20vinipuh02@gmail.com> Date: Fri, 1 May 2026 00:35:56 +0300 Subject: [PATCH 16/16] feat(flippo/components): introduce new Box, Card, Center, Flex, Grid, Section, and Skeleton components with layout props and styles; update tsconfig and vite config for path aliasing --- .../components/src/components/Box/index.ts | 1 + .../components/src/components/Box/ui/Box.tsx | 39 + .../src/components/Card/index.parts.ts | 9 + .../components/src/components/Card/index.ts | 1 + .../components/Card/story/Card.stories.tsx | 91 ++ .../Card/ui/content/CardContent.module.scss | 6 + .../Card/ui/content/CardContent.tsx | 30 + .../description/CardDescription.module.scss | 7 + .../Card/ui/description/CardDescription.tsx | 37 + .../Card/ui/footer/CardFooter.module.scss | 7 + .../components/Card/ui/footer/CardFooter.tsx | 30 + .../components/Card/ui/root/CardContext.tsx | 20 + .../Card/ui/root/CardRoot.module.scss | 11 + .../src/components/Card/ui/root/CardRoot.tsx | 64 + .../Card/ui/title/CardTitle.module.scss | 7 + .../components/Card/ui/title/CardTitle.tsx | 38 + .../components/src/components/Center/index.ts | 1 + .../src/components/Center/ui/Center.tsx | 48 + .../components/src/components/Code/index.ts | 1 + .../src/components/Code/ui/Code.module.scss | 184 +++ .../src/components/Code/ui/Code.tsx | 26 + .../src/components/Container/index.ts | 1 + .../src/components/Container/ui/Container.tsx | 49 + .../components/src/components/Flex/index.ts | 1 + .../src/components/Flex/ui/Flex.tsx | 38 + .../components/src/components/Grid/index.ts | 1 + .../src/components/Grid/ui/Grid.tsx | 38 + .../Menu/ui/arrow/MenuArrow.module.scss | 13 + .../src/components/Section/index.ts | 1 + .../src/components/Section/ui/Section.tsx | 46 + .../Select/story/Select.stories.tsx | 3 +- .../positioner/SelectPositioner.module.scss | 3 - .../src/components/Skeleton/index.ts | 2 + .../Skeleton/ui/Skeleton.module.scss | 48 + .../src/components/Skeleton/ui/Skeleton.tsx | 98 ++ .../components/Skeleton/ui/SkeletonText.tsx | 59 + .../src/components/Spinner/index.ts | 1 + .../Spinner/story/Spinner.stories.tsx | 17 + .../components/Spinner/ui/Spinner.module.scss | 64 + .../src/components/Spinner/ui/Spinner.tsx | 102 ++ .../components/src/components/Text/index.ts | 1 + .../components/Text/story/Text.stories.tsx | 159 +++ .../src/components/Text/ui/Text.module.scss | 197 +++ .../src/components/Text/ui/Text.tsx | 239 ++++ .../ui/uikit/flippo/components/src/index.ts | 63 + .../flippo/components/src/lib/layouts.ts | 1096 +++++++++++++++++ .../uikit/flippo/components/src/lib/types.ts | 294 +++++ .../components/src/styles/mixins/_font.scss | 106 +- .../components/src/types/polymorphic.ts | 12 - .../ui/uikit/flippo/components/tsconfig.json | 3 +- .../ui/uikit/flippo/components/vite.config.ts | 2 +- 51 files changed, 3342 insertions(+), 73 deletions(-) create mode 100644 packages/ui/uikit/flippo/components/src/components/Box/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Box/ui/Box.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/index.parts.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/story/Card.stories.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardContext.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Center/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Center/ui/Center.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Code/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Code/ui/Code.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Code/ui/Code.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Container/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Container/ui/Container.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Flex/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Flex/ui/Flex.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Grid/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Grid/ui/Grid.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Section/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Section/ui/Section.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Skeleton/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Skeleton/ui/SkeletonText.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Spinner/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Spinner/story/Spinner.stories.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Text/index.ts create mode 100644 packages/ui/uikit/flippo/components/src/components/Text/story/Text.stories.tsx create mode 100644 packages/ui/uikit/flippo/components/src/components/Text/ui/Text.module.scss create mode 100644 packages/ui/uikit/flippo/components/src/components/Text/ui/Text.tsx create mode 100644 packages/ui/uikit/flippo/components/src/lib/layouts.ts create mode 100644 packages/ui/uikit/flippo/components/src/lib/types.ts delete mode 100644 packages/ui/uikit/flippo/components/src/types/polymorphic.ts diff --git a/packages/ui/uikit/flippo/components/src/components/Box/index.ts b/packages/ui/uikit/flippo/components/src/components/Box/index.ts new file mode 100644 index 00000000..c4ee4a09 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Box/index.ts @@ -0,0 +1 @@ +export * from './ui/Box'; diff --git a/packages/ui/uikit/flippo/components/src/components/Box/ui/Box.tsx b/packages/ui/uikit/flippo/components/src/components/Box/ui/Box.tsx new file mode 100644 index 00000000..a24422b6 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Box/ui/Box.tsx @@ -0,0 +1,39 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { extractBoxProps } from '~@lib/layouts'; + +import type { BoxProps as BoxLayoutProps } from '~@lib/types'; + +/** + * Box - A fundamental layout building block. + * Supports all layout props: margin, padding, sizing, position, overflow, + * and can act as a flex/grid child. + */ +export function Box( + props: Box.Props +) { + const { as: Tag = 'div', ref, ...restProps } = props; + + const { style, otherProps } = extractBoxProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type BoxComponentProps + = React.PropsWithChildren> + & BoxLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Box { + export type Props = BoxComponentProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/Card/index.parts.ts new file mode 100644 index 00000000..22a86928 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/index.parts.ts @@ -0,0 +1,9 @@ +// Context +export { useCardContext, useOptionalCardContext } from './ui/root/CardContext'; +export type { CardContextValue } from './ui/root/CardContext'; +export { CardContent as Content } from './ui/content/CardContent'; +export { CardDescription as Description } from './ui/description/CardDescription'; +export { CardFooter as Footer } from './ui/footer/CardFooter'; + +export { CardRoot as Root } from './ui/root/CardRoot'; +export { CardTitle as Title } from './ui/title/CardTitle'; diff --git a/packages/ui/uikit/flippo/components/src/components/Card/index.ts b/packages/ui/uikit/flippo/components/src/components/Card/index.ts new file mode 100644 index 00000000..6d08719d --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/index.ts @@ -0,0 +1 @@ +export * as Card from './index.parts'; diff --git a/packages/ui/uikit/flippo/components/src/components/Card/story/Card.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Card/story/Card.stories.tsx new file mode 100644 index 00000000..9e12c0c2 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/story/Card.stories.tsx @@ -0,0 +1,91 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import * as Button from '../../Button'; +import * as Card from '../index.parts'; + +const meta: Meta = { + title: 'Components/Card', + component: Card.Root, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + {'Card Title'} + + {'Card automatically connects Title and Description to Root via aria-labelledby and aria-describedby.'} + +

{'Main content goes here. You can add any content you want inside the card.'}

+
+ + {'Action'} + {'Cancel'} + +
+ ) +}; + +export const WithoutFooter: Story = { + render: () => ( + + + {'Simple Card'} + + {'A card without a footer section.'} + +

{'This card only has content, no footer actions.'}

+
+
+ ) +}; + +export const WithLayoutProps: Story = { + render: () => ( + + + {'Card with Layout Props'} + + {'This card uses layout props (padding, margin, maxWidth) directly on the Root component.'} + + + + ) +}; + +export const MultipleCards: Story = { + render: () => ( +
+ + + {'Card 1'} + {'First card description'} +

{'Content for the first card.'}

+
+
+ + + {'Card 2'} + {'Second card description'} +

{'Content for the second card.'}

+
+
+ + + {'Card 3'} + {'Third card description'} +

{'Content for the third card.'}

+
+
+
+ ) +}; diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.module.scss b/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.module.scss new file mode 100644 index 00000000..3faa2075 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.module.scss @@ -0,0 +1,6 @@ +.CardContent { + padding: var(--f-spacing-6); + display: flex; + flex-direction: column; + gap: var(--f-spacing-4); +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.tsx new file mode 100644 index 00000000..03c6b0e4 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/content/CardContent.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cx } from 'class-variance-authority'; + +import type { PolymorphicComponentPropsWithRef } from '~@lib/types'; + +import styles from './CardContent.module.scss'; + +/** + * CardContent - Main content area of the card. + */ +export function CardContent(props: CardContent.Props) { + const { + as: Tag = 'div', + ref, + className, + ...otherProps + } = props; + + const cardContentClasses = cx(styles.CardContent, className); + + const element = useRender({ defaultTagName: Tag, ref, props: [{ className: cardContentClasses }, otherProps] }); + + return element; +} + +export namespace CardContent { + export type Props = PolymorphicComponentPropsWithRef<'div'>; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.module.scss b/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.module.scss new file mode 100644 index 00000000..d5e80599 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.module.scss @@ -0,0 +1,7 @@ +@use 'mixins/_font.scss' as font; + +.CardDescription { + @include font.body('default'); + color: var(--f-color-text-3); + margin: 0; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.tsx new file mode 100644 index 00000000..86846ac0 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/description/CardDescription.tsx @@ -0,0 +1,37 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cx } from 'class-variance-authority'; + +import type { PolymorphicComponentPropsWithRef } from '~@lib/types'; + +import { useCardContext } from '../root/CardContext'; + +import styles from './CardDescription.module.scss'; + +/** + * CardDescription - Card subtitle or description text. + * Automatically connected to Card.Root via context for accessibility. + */ +export function CardDescription(props: CardDescription.Props) { + const { + as: Tag = 'p', + ref, + className, + id: providedId, + ...otherProps + } = props; + + const context = useCardContext(); + + const descriptionId = providedId ?? context.descriptionId; + const cardDescriptionClasses = cx(styles.CardDescription, className); + + const element = useRender({ defaultTagName: Tag, ref, props: [{ className: cardDescriptionClasses, id: descriptionId }, otherProps] }); + + return element; +} + +export namespace CardDescription { + export type Props = PolymorphicComponentPropsWithRef<'p'>; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.module.scss b/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.module.scss new file mode 100644 index 00000000..7cd0e0f0 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.module.scss @@ -0,0 +1,7 @@ +.CardFooter { + padding: var(--f-spacing-6); + padding-top: 0; + display: flex; + align-items: center; + gap: var(--f-spacing-3); +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.tsx new file mode 100644 index 00000000..7026c577 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/footer/CardFooter.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cx } from 'class-variance-authority'; + +import type { PolymorphicComponentPropsWithRef } from '~@lib/types'; + +import styles from './CardFooter.module.scss'; + +/** + * CardFooter - Footer area for card actions or additional content. + */ +export function CardFooter(props: CardFooter.Props) { + const { + as: Tag = 'div', + ref, + className, + ...otherProps + } = props; + + const cardFooterClasses = cx(styles.CardFooter, className); + + const element = useRender({ defaultTagName: Tag, ref, props: [{ className: cardFooterClasses }, otherProps] }); + + return element; +} + +export namespace CardFooter { + export type Props = PolymorphicComponentPropsWithRef<'div'>; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardContext.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardContext.tsx new file mode 100644 index 00000000..f4cce246 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardContext.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export type CardContextValue = { + titleId: string; + descriptionId: string; +}; + +export const CardContext = React.createContext(null); + +export function useCardContext() { + const context = React.use(CardContext); + if (!context) { + throw new Error('Card components must be used within Card.Root'); + } + return context; +} + +export function useOptionalCardContext() { + return React.use(CardContext); +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.module.scss b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.module.scss new file mode 100644 index 00000000..7a0a903f --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.module.scss @@ -0,0 +1,11 @@ +.CardRoot { + background-color: var(--f-color-bg-1); + border-radius: var(--f-border-radius-card); + border: 1px solid var(--f-color-stroke); + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: var(--f-color-stroke-hover); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.tsx new file mode 100644 index 00000000..b4ef9600 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/root/CardRoot.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type * as ReactTypes from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cx } from 'class-variance-authority'; + +import { extractBoxProps } from '~@lib/layouts'; + +import type { BoxProps } from '~@lib/types'; + +import { CardContext } from './CardContext'; +import styles from './CardRoot.module.scss'; + +/** + * CardRoot - Root container for Card component. + * Provides context for Title and Description to connect via aria-labelledby and aria-describedby. + */ +export function CardRoot( + props: CardRoot.Props +) { + const { + as: Tag = 'div', + ref, + className, + ...restProps + } = props; + + const titleId = React.useId(); + const descriptionId = React.useId(); + + const { style, otherProps } = extractBoxProps(restProps); + + const contextValue = React.useMemo( + () => ({ titleId, descriptionId }), + [titleId, descriptionId] + ); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ + style, + 'className': cx(styles.CardRoot, className), + 'aria-labelledby': titleId, + 'aria-describedby': descriptionId + }, otherProps] + }); + + return {element}; +} + +export type CardRootProps + = React.PropsWithChildren> + & BoxProps + & { + /** HTML element to render */ + as?: ElementType; + /** Additional CSS class */ + className?: string; + }; + +export namespace CardRoot { + export type Props = CardRootProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.module.scss b/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.module.scss new file mode 100644 index 00000000..3dd25562 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.module.scss @@ -0,0 +1,7 @@ +@use 'mixins/_font.scss' as font; + +.CardTitle { + @include font.heading3('default'); + color: var(--f-color-text-primary); + margin: 0; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.tsx b/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.tsx new file mode 100644 index 00000000..8eec7912 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Card/ui/title/CardTitle.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cx } from 'class-variance-authority'; + +import type { PolymorphicComponentPropsWithRef } from '~@lib/types'; + +import { useCardContext } from '../root/CardContext'; + +import styles from './CardTitle.module.scss'; + +/** + * CardTitle - Card header title. + * Automatically connected to Card.Root via context for accessibility. + */ +export function CardTitle(props: CardTitle.Props) { + const { + as: Tag = 'h3', + ref, + className, + id: providedId, + ...otherProps + } = props; + const context = useCardContext(); + + const titleId = providedId ?? context.titleId; + const cardTitleClasses = cx(styles.CardTitle, className); + + const element = useRender({ defaultTagName: Tag, ref, props: [{ className: cardTitleClasses, id: titleId }, otherProps] }); + + return element; +} + +export type CardTitleProps = React.ComponentPropsWithRef<'h3'>; + +export namespace CardTitle { + export type Props = PolymorphicComponentPropsWithRef<'h3'>; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Center/index.ts b/packages/ui/uikit/flippo/components/src/components/Center/index.ts new file mode 100644 index 00000000..f31e3404 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Center/index.ts @@ -0,0 +1 @@ +export * from './ui/Center'; diff --git a/packages/ui/uikit/flippo/components/src/components/Center/ui/Center.tsx b/packages/ui/uikit/flippo/components/src/components/Center/ui/Center.tsx new file mode 100644 index 00000000..fe3d275b --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Center/ui/Center.tsx @@ -0,0 +1,48 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { extractCenterLayoutProps } from '~@lib/layouts'; + +import type { CenterLayoutProps } from '~@lib/types'; + +/** + * Center - A flexbox container that centers its children both horizontally and vertically. + * Use for centering single elements or small groups of content. + * + * @example + *
+ * + *
+ * + * @example + *
+ * Centered text + *
+ */ +export function Center( + props: Center.Props +) { + const { as: Tag = 'div', ref, ...restProps } = props; + + const { style, otherProps } = extractCenterLayoutProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type CenterComponentProps + = React.PropsWithChildren> + & CenterLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Center { + export type Props = CenterComponentProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Code/index.ts b/packages/ui/uikit/flippo/components/src/components/Code/index.ts new file mode 100644 index 00000000..7c0ef950 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Code/index.ts @@ -0,0 +1 @@ +export * from './ui/Code'; diff --git a/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.module.scss b/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.module.scss new file mode 100644 index 00000000..0b62ec08 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.module.scss @@ -0,0 +1,184 @@ +/* stylelint-disable selector-max-type */ +/* Disable selector-max-type rule to target individual element types. */ + +.Code { + --code-variant-font-size-adjust: calc(var(--code-font-size-adjust) * 0.95); + font-family: var(--code-font-family); + font-size: calc(var(--code-variant-font-size-adjust) * 1em); + font-style: var(--code-font-style); + font-weight: var(--code-font-weight); + line-height: 1.25; + letter-spacing: calc(var(--code-letter-spacing) + var(--letter-spacing, var(--default-letter-spacing))); + border-radius: calc((0.5px + 0.2em) * var(--radius-factor)); + + box-sizing: border-box; + padding-top: var(--code-padding-top); + padding-left: var(--code-padding-left); + padding-bottom: var(--code-padding-bottom); + padding-right: var(--code-padding-right); + + /* Make sure that the height is not stretched in a Flex/Grid layout */ + height: fit-content; + + & :where(&) { + font-size: inherit; + } +} + +/*************************************************************************************************** + * * + * SIZES * + * * + ***************************************************************************************************/ + +.size-1 { + font-size: calc(var(--font-size-1) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-1); + --letter-spacing: var(--letter-spacing-1); +} + +.size-2 { + font-size: calc(var(--font-size-2) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-2); + --letter-spacing: var(--letter-spacing-2); +} +.size-3 { + font-size: calc(var(--font-size-3) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-3); + --letter-spacing: var(--letter-spacing-3); +} +.size-4 { + font-size: calc(var(--font-size-4) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-4); + --letter-spacing: var(--letter-spacing-4); +} +.size-5 { + font-size: calc(var(--font-size-5) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-5); + --letter-spacing: var(--letter-spacing-5); +} +.size-6 { + font-size: calc(var(--font-size-6) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-6); + --letter-spacing: var(--letter-spacing-6); +} +.size-7 { + font-size: calc(var(--font-size-7) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-7); + --letter-spacing: var(--letter-spacing-7); +} +.size-8 { + font-size: calc(var(--font-size-8) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-8); + --letter-spacing: var(--letter-spacing-8); +} +.size-9 { + font-size: calc(var(--font-size-9) * var(--code-variant-font-size-adjust)); + line-height: var(--line-height-9); + --letter-spacing: var(--letter-spacing-9); +} + +/*************************************************************************************************** + * * + * VARIANTS * + * * + ***************************************************************************************************/ + +/* ghost */ + +.variant-ghost { + --code-variant-font-size-adjust: var(--code-font-size-adjust); + padding: 0; + + &:where([data-accent-color]) { + color: var(--accent-a11); + } + + &:where([data-accent-color].rt-high-contrast), + :where([data-accent-color]:not(.radix-themes)) &:where(.rt-high-contrast) { + color: var(--accent-12); + } +} + +/* solid */ + +.variant-solid { + background-color: var(--accent-a9); + color: var(--accent-contrast); + + &::selection { + background-color: var(--accent-7); + color: var(--accent-12); + } + + &:where(.rt-high-contrast) { + background-color: var(--accent-12); + color: var(--accent-1); + + &::selection { + background-color: var(--accent-a11); + color: var(--accent-1); + } + } + + :where(.rt-Link) &, + &:where(:any-link, button) { + /* Create a new stacking context (otherwise, `filter` may do it on hover) */ + isolation: isolate; + @media (hover: hover) { + &:where(:hover) { + background-color: var(--accent-10); + } + &:where(.rt-high-contrast:hover) { + background-color: var(--accent-12); + /* Re-use base button hover filter */ + filter: var(--base-button-solid-high-contrast-hover-filter); + } + } + } +} + +/* soft */ + +.variant-soft { + background-color: var(--accent-a3); + color: var(--accent-a11); + + &:where(.rt-high-contrast) { + color: var(--accent-12); + } + + :where(.rt-Link) &, + &:where(:any-link, button) { + isolation: isolate; + @media (hover: hover) { + &:where(:hover) { + background-color: var(--accent-a4); + } + } + } +} + +/* outline */ + +.variant-outline { + box-shadow: inset 0 0 0 max(1px, 0.033em) var(--accent-a8); + color: var(--accent-a11); + + &:where(.rt-high-contrast) { + box-shadow: + inset 0 0 0 max(1px, 0.033em) var(--accent-a7), + inset 0 0 0 max(1px, 0.033em) var(--gray-a11); + color: var(--accent-12); + } + + :where(.rt-Link) &, + &:where(:any-link, button) { + isolation: isolate; + @media (hover: hover) { + &:where(:hover) { + background-color: var(--accent-a2); + } + } + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.tsx b/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.tsx new file mode 100644 index 00000000..3354844a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Code/ui/Code.tsx @@ -0,0 +1,26 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; + +import type { PolymorphicComponentPropsWithRef } from '~@lib/types'; + +const state = { + slot: 'code' +}; + +export function Code(props: Code.Props) { + const { as: Tag = 'code', ref, ...otherProps } = props; + + const element = useRender({ + defaultTagName: Tag, + ref, + props: otherProps, + state + }); + + return element; +} + +export namespace Code { + export type Props = PolymorphicComponentPropsWithRef; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Container/index.ts b/packages/ui/uikit/flippo/components/src/components/Container/index.ts new file mode 100644 index 00000000..b6e7fdf6 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Container/index.ts @@ -0,0 +1 @@ +export * from './ui/Container'; diff --git a/packages/ui/uikit/flippo/components/src/components/Container/ui/Container.tsx b/packages/ui/uikit/flippo/components/src/components/Container/ui/Container.tsx new file mode 100644 index 00000000..e4509e8d --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Container/ui/Container.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; + +import { extractContainerLayoutProps } from '~@lib/layouts'; + +import type { ContainerLayoutProps } from '~@lib/types'; + +/** + * Container - A centered container with max-width. + * Use for constraining content width and centering it on the page. + * + * @example + * + *

Page Title

+ *

Content constrained to max-width...

+ *
+ * + * @example + * // Sizes: 'sm' (640px), 'md' (768px), 'lg' (1024px), 'xl' (1280px), 'full' (100%) + * Wide content + */ +export function Container( + props: Container.Props +) { + const { as: Tag = 'div', ref, ...restProps } = props; + + const { style, otherProps } = extractContainerLayoutProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type ContainerComponentProps + = React.PropsWithChildren> + & ContainerLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Container { + export type Props = ContainerComponentProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Flex/index.ts b/packages/ui/uikit/flippo/components/src/components/Flex/index.ts new file mode 100644 index 00000000..dd2cd2ee --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Flex/index.ts @@ -0,0 +1 @@ +export * from './ui/Flex'; diff --git a/packages/ui/uikit/flippo/components/src/components/Flex/ui/Flex.tsx b/packages/ui/uikit/flippo/components/src/components/Flex/ui/Flex.tsx new file mode 100644 index 00000000..972da0ff --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Flex/ui/Flex.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { extractFlexLayoutProps } from '~@lib/layouts'; + +import type { FlexLayoutProps } from '~@lib/types'; + +/** + * Flex - A flexbox container component. + * Supports all flex props, flex child props, and layout props (margin, padding, sizing, position). + */ +export function Flex( + props: Flex.Props +) { + const { as: Tag = 'div', ref, ...restProps } = props; + + const { style, otherProps } = extractFlexLayoutProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type FlexProps + = React.PropsWithChildren> + & FlexLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Flex { + export type Props = FlexProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Grid/index.ts b/packages/ui/uikit/flippo/components/src/components/Grid/index.ts new file mode 100644 index 00000000..a0b18583 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Grid/index.ts @@ -0,0 +1 @@ +export * from './ui/Grid'; diff --git a/packages/ui/uikit/flippo/components/src/components/Grid/ui/Grid.tsx b/packages/ui/uikit/flippo/components/src/components/Grid/ui/Grid.tsx new file mode 100644 index 00000000..45472aa8 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Grid/ui/Grid.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { extractGridLayoutProps } from '~@lib/layouts'; + +import type { GridLayoutProps } from '~@lib/types'; + +/** + * Grid - A CSS grid container component. + * Supports all grid props, grid child props, and layout props (margin, padding, sizing, position). + */ +export function Grid( + props: Grid.Props +) { + const { as: Tag = 'div', ref, ...restProps } = props; + + const { style, otherProps } = extractGridLayoutProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type GridComponentProps + = React.PropsWithChildren> + & GridLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Grid { + export type Props = GridComponentProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Menu/ui/arrow/MenuArrow.module.scss b/packages/ui/uikit/flippo/components/src/components/Menu/ui/arrow/MenuArrow.module.scss index af5d8a33..a05d132e 100644 --- a/packages/ui/uikit/flippo/components/src/components/Menu/ui/arrow/MenuArrow.module.scss +++ b/packages/ui/uikit/flippo/components/src/components/Menu/ui/arrow/MenuArrow.module.scss @@ -5,6 +5,12 @@ display: flex; + // Add transitions to sync with popup animation + transition: + opacity 150ms, + transform 150ms; + transform-origin: center; + &[data-side='top'] { bottom: -8px; rotate: 180deg; @@ -24,6 +30,13 @@ left: -13px; rotate: -90deg; } + + // Apply animation states to match popup + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: scale(0.9); + } } .ArrowFill { diff --git a/packages/ui/uikit/flippo/components/src/components/Section/index.ts b/packages/ui/uikit/flippo/components/src/components/Section/index.ts new file mode 100644 index 00000000..ae86b476 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Section/index.ts @@ -0,0 +1 @@ +export * from './ui/Section'; diff --git a/packages/ui/uikit/flippo/components/src/components/Section/ui/Section.tsx b/packages/ui/uikit/flippo/components/src/components/Section/ui/Section.tsx new file mode 100644 index 00000000..f5803b0e --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Section/ui/Section.tsx @@ -0,0 +1,46 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { extractSectionLayoutProps } from '~@lib/layouts'; + +import type { SectionLayoutProps } from '~@lib/types'; + +/** + * Section - A semantic section element with vertical padding. + * Use for creating vertical rhythm and spacing between page sections. + * + * @example + *
+ * + *

Section Title

+ *

Section content...

+ *
+ *
+ */ +export function Section( + props: Section.Props +) { + const { as: Tag = 'section', ref, ...restProps } = props; + + const { style, otherProps } = extractSectionLayoutProps(restProps); + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ style }, otherProps] + }); + + return element; +} + +export type SectionComponentProps + = React.PropsWithChildren> + & SectionLayoutProps + & { + /** HTML element to render */ + as?: ElementType; + }; + +export namespace Section { + export type Props = SectionComponentProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Select/story/Select.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Select/story/Select.stories.tsx index 10e5da1a..ff56c3bb 100644 --- a/packages/ui/uikit/flippo/components/src/components/Select/story/Select.stories.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Select/story/Select.stories.tsx @@ -5,8 +5,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { Select } from '..'; const meta: Meta = { - title: 'Input/Select', - component: Select.Root + title: 'Input/Select' }; export default meta; diff --git a/packages/ui/uikit/flippo/components/src/components/Select/ui/positioner/SelectPositioner.module.scss b/packages/ui/uikit/flippo/components/src/components/Select/ui/positioner/SelectPositioner.module.scss index d23fc0b0..12cbc7d6 100644 --- a/packages/ui/uikit/flippo/components/src/components/Select/ui/positioner/SelectPositioner.module.scss +++ b/packages/ui/uikit/flippo/components/src/components/Select/ui/positioner/SelectPositioner.module.scss @@ -3,9 +3,6 @@ .SelectPositioner { @include common.reset-appearance(); - display: flex; - flex-direction: column; - gap: var(--f-spacing-1); z-index: 1; -webkit-user-select: none; user-select: none; diff --git a/packages/ui/uikit/flippo/components/src/components/Skeleton/index.ts b/packages/ui/uikit/flippo/components/src/components/Skeleton/index.ts new file mode 100644 index 00000000..626fac00 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Skeleton/index.ts @@ -0,0 +1,2 @@ +export * from './ui/Skeleton'; +export * from './ui/SkeletonText'; diff --git a/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.module.scss b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.module.scss new file mode 100644 index 00000000..d8932341 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.module.scss @@ -0,0 +1,48 @@ +@keyframes skeleton-pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.4; + } + 100% { + opacity: 1; + } +} + +@keyframes skeleton-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.Skeleton { + background-color: var(--f-color-bg-3); + border-radius: var(--f-border-radius-card); + overflow: hidden; + position: relative; + + &.animate { + &.pulse { + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + &.shimmer { + background: linear-gradient( + 90deg, + var(--f-color-bg-3) 0%, + var(--f-color-bg-3-hover) 50%, + var(--f-color-bg-3) 100% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + } + } + + &.circle { + border-radius: var(--f-border-radius-full); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.tsx b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.tsx new file mode 100644 index 00000000..467e4367 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/Skeleton.tsx @@ -0,0 +1,98 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cva } from 'class-variance-authority'; + +import type { VariantProps } from 'class-variance-authority'; + +import styles from './Skeleton.module.scss'; + +const SkeletonVariants = cva(styles.Skeleton, { + variants: { + animate: { + pulse: [styles.animate, styles.pulse], + shimmer: [styles.animate, styles.shimmer], + false: '' + }, + circle: { + true: styles.circle, + false: '' + } + }, + defaultVariants: { + animate: 'shimmer', + circle: false + } +}); + +/** + * Skeleton - Loading placeholder component. + * Displays an animated placeholder for content that is loading. + * + * @example + * + * + * @example + * + * + * @example + * + * + * + * + * + */ +export function Skeleton(props: Skeleton.Props) { + const { + width = '100%', + height = 16, + radius, + circle = false, + animate = 'shimmer', + className, + ref, + ...otherProps + } = props; + + const skeletonClassName = SkeletonVariants({ + animate, + circle, + className + }); + + const style: React.CSSProperties = { + width: circle ? height : width, + height, + borderRadius: radius + }; + + const element = useRender({ + defaultTagName: 'span', + ref: ref as React.Ref, + props: [{ style, className: skeletonClassName }, otherProps] + }); + + return element; +} + +export namespace Skeleton { + /** + * Skeleton animation types + */ + export type SkeletonAnimation = 'pulse' | 'shimmer' | false; + + export type Props = { + /** Width of skeleton (defaults to 100%) */ + width?: React.CSSProperties['width']; + /** Height of skeleton */ + height?: React.CSSProperties['height']; + /** Border radius */ + radius?: React.CSSProperties['borderRadius']; + /** If true, width and height will be equal (creates a circle) */ + circle?: boolean; + /** Animation type or false to disable */ + animate?: SkeletonAnimation; + /** Additional CSS class */ + className?: string; + } & React.ComponentPropsWithRef<'span'> & VariantProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/SkeletonText.tsx b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/SkeletonText.tsx new file mode 100644 index 00000000..3e27f223 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Skeleton/ui/SkeletonText.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { Skeleton } from './Skeleton'; + +/** + * SkeletonText - Multi-line text placeholder. + * Displays multiple skeleton lines for paragraph-like content. + * + * @example + * + * + * @example + * + */ +export function SkeletonText(props: SkeletonText.Props) { + const { + lines = 3, + spacing = 8, + animate = 'shimmer', + ...otherProps + } = props; + + return ( +
+ {Array.from({ length: lines }).map((_, index) => { + const isLast = index === lines - 1; + const width = isLast ? '80%' : '100%'; + + return ( + + ); + })} +
+ ); +} + +export namespace SkeletonText { + + /** + * SkeletonText props - multi-line text placeholder + */ + export type Props = { + /** Number of skeleton lines */ + lines?: number; + /** Spacing between lines (in px) */ + spacing?: number; + /** Animation type */ + animate?: Skeleton.SkeletonAnimation; + /** Additional CSS class */ + className?: string; + }; + +} diff --git a/packages/ui/uikit/flippo/components/src/components/Spinner/index.ts b/packages/ui/uikit/flippo/components/src/components/Spinner/index.ts new file mode 100644 index 00000000..e80e0c4a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Spinner/index.ts @@ -0,0 +1 @@ +export * from './ui/Spinner'; diff --git a/packages/ui/uikit/flippo/components/src/components/Spinner/story/Spinner.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Spinner/story/Spinner.stories.tsx new file mode 100644 index 00000000..851fa37c --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Spinner/story/Spinner.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Spinner } from '../'; + +const meta: Meta = { + title: 'Components/Spinner', + component: Spinner +}; + +export default meta; + +export const Default: StoryObj = { + args: { + color: 'brand', + size: 'medium' + } +}; diff --git a/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.module.scss b/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.module.scss new file mode 100644 index 00000000..9b4c6747 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.module.scss @@ -0,0 +1,64 @@ +.Spinner { + pointer-events: none; + position: relative; + size: var(--f-spacing-4); + transform-origin: center; + animation: spin 1s linear infinite; + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +} + +.Spinner_brand { + color: var(--f-color-brand); +} + +.Spinner_danger { + color: var(--f-color-danger); +} + +.Spinner_warning { + color: var(--f-color-warning); +} + +.Spinner_info { + color: var(--f-color-info); +} + +.Spinner_success { + color: var(--f-color-success); +} + +.Spinner_error { + color: var(--f-color-error); +} + +.Spinner_current { + color: currentColor; +} + +.Spinner_x_small { + size: var(--f-spacing-3); + height: var(--f-spacing-3); +} + +.Spinner_small { + size: var(--f-spacing-4); + height: var(--f-spacing-4); +} + +.Spinner_medium { + size: var(--f-spacing-6); + height: var(--f-spacing-6); +} + +.Spinner_large { + size: var(--f-spacing-8); + height: var(--f-spacing-8); +} diff --git a/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.tsx b/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.tsx new file mode 100644 index 00000000..a63362cf --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Spinner/ui/Spinner.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import type { ComponentPropsWithRef } from 'react'; + +import { cva } from 'class-variance-authority'; + +import type { VariantProps } from 'class-variance-authority'; + +import styles from './Spinner.module.css'; + +const SpinnerVariants = cva(styles.Spinner, { + variants: { + color: { + brand: styles.Spinner_brand, + danger: styles.Spinner_danger, + warning: styles.Spinner_warning, + info: styles.Spinner_info, + success: styles.Spinner_success, + error: styles.Spinner_error, + current: styles.Spinner_current + }, + size: { + 'x-small': styles.Spinner_x_small, + 'small': styles.Spinner_small, + 'medium': styles.Spinner_medium, + 'large': styles.Spinner_large + } + }, + defaultVariants: { + color: 'brand', + size: 'medium' + } +}); + +type SpinnerPrimitiveProps = ComponentPropsWithRef<'svg'>; + +function SpinnerPrimitive({ ...props }: SpinnerPrimitiveProps) { + const id = React.useId(); + + return ( + + + + + + + + + + + + + + + + + + ); +} + +type SpinnerRootProps = Omit, 'display' | 'opacity' | 'color'> & VariantProps; + +export function Spinner({ + className, + color, + size, + ...props +}: SpinnerRootProps) { + const spinnerClasses = SpinnerVariants({ color, size, className }); + + return ( + + + + ); +} + +export namespace Spinner { + export type Props = SpinnerRootProps; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Text/index.ts b/packages/ui/uikit/flippo/components/src/components/Text/index.ts new file mode 100644 index 00000000..f6c81606 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Text/index.ts @@ -0,0 +1 @@ +export * from './ui/Text'; diff --git a/packages/ui/uikit/flippo/components/src/components/Text/story/Text.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Text/story/Text.stories.tsx new file mode 100644 index 00000000..68608123 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Text/story/Text.stories.tsx @@ -0,0 +1,159 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import * as Box from '../../Box'; +import { Text } from '../ui/Text'; + +const meta: Meta = { + title: 'Components/Text', + component: Text, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: [ + 'display-1', + 'display-2', + 'title-1', + 'title-2', + 'title-3', + 'heading-1', + 'heading-2', + 'heading-3', + 'body-plus', + 'body', + 'body-minus', + 'label' + ] + }, + weight: { + control: 'select', + options: ['weaker', 'default', 'stronger'] + }, + color: { + control: 'select', + options: [ + 'primary', + 'secondary', + 'tertiary', + 'quaternary', + 'white', + 'disabled', + 'brand', + 'success', + 'error', + 'warning' + ] + } + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'The quick brown fox jumps over the lazy dog' + } +}; + +export const Sizes: Story = { + render: () => ( + + {'Display 1'} + {'Display 2'} + {'Title 1'} + {'Title 2'} + {'Title 3'} + {'Heading 1'} + {'Heading 2'} + {'Heading 3'} + {'Body Plus'} + {'Body'} + {'Body Minus'} + {'Label'} + + ) +}; + +export const Weights: Story = { + render: () => ( + + {'Heading Weaker'} + {'Heading Default'} + {'Heading Stronger'} + + ) +}; + +export const Colors: Story = { + render: () => ( + + {'Primary color'} + {'Secondary color'} + {'Tertiary color'} + {'Quaternary color'} + {'Brand color'} + {'Success color'} + {'Error color'} + {'Warning color'} + + ) +}; + +export const Alignment: Story = { + render: () => ( + + {'Left aligned text'} + {'Center aligned text'} + {'Right aligned text'} + {'Justified text with longer content to show how justify alignment works with multiple lines of text.'} + + ) +}; + +export const Transform: Story = { + render: () => ( + + {'Normal text'} + {'Uppercase text'} + {'LOWERCASE TEXT'} + {'capitalized text'} + + ) +}; + +export const Truncate: Story = { + render: () => ( + + + {'This is a very long text that will be truncated with an ellipsis when it exceeds the container width'} + + + ) +}; + +export const WithMargins: Story = { + render: () => ( + + {'Title with bottom margin'} + {'Body text with vertical margins'} + {'Small text without margins'} + + ) +}; + +export const Polymorphic: Story = { + render: () => ( + + {'H1 Element'} + {'H2 Element'} + {'Paragraph element'} + {'Label element'} + + ) +}; diff --git a/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.module.scss b/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.module.scss new file mode 100644 index 00000000..314a5a55 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.module.scss @@ -0,0 +1,197 @@ +@use 'mixins/_font.scss' as font; + +.Text { + margin: 0; + padding: 0; +} + +// Sizes +.display-1-default { + @include font.display-1('default'); +} + +.display-1-stronger { + @include font.display-1('stronger'); +} + +.display-1-weaker { + @include font.display-1('weaker'); +} + +.display-2-default { + @include font.display-2('default'); +} + +.display-2-stronger { + @include font.display-2('stronger'); +} + +.title-1-default { + @include font.title-1('default'); +} + +.title-1-stronger { + @include font.title-1('stronger'); +} + +.title-2-default { + @include font.title-2('default'); +} + +.title-2-stronger { + @include font.title-2('stronger'); +} + +.title-3-default { + @include font.title-3('default'); +} + +.title-3-stronger { + @include font.title-3('stronger'); +} + +.heading-1-default { + @include font.heading-1('default'); +} + +.heading-1-stronger { + @include font.heading-1('stronger'); +} + +.heading-2-default { + @include font.heading-2('default'); +} + +.heading-2-weaker { + @include font.heading-2('weaker'); +} + +.heading-3-default { + @include font.heading-3('default'); +} + +.heading-3-stronger { + @include font.heading-3('stronger'); +} + +.body-plus-default { + @include font.bodyPlus('default'); +} + +.body-plus-stronger { + @include font.bodyPlus('stronger'); +} + +.body-default { + @include font.body('default'); +} + +.body-stronger { + @include font.body('stronger'); +} + +.body-weaker { + @include font.body('weaker'); +} + +.body-minus-default { + @include font.bodyMinus('default'); +} + +.body-minus-stronger { + @include font.bodyMinus('stronger'); +} + +.body-minus-weaker { + @include font.bodyMinus('weaker'); +} + +.label-default { + @include font.label('default'); +} + +.label-stronger { + @include font.label('stronger'); +} + +.label-weaker { + @include font.label('weaker'); +} + +// Colors +.color-primary { + color: var(--f-color-text-primary); +} + +.color-secondary { + color: var(--f-color-text-2); +} + +.color-tertiary { + color: var(--f-color-text-3); +} + +.color-quaternary { + color: var(--f-color-text-4); +} + +.color-white { + color: var(--f-color-text-white); +} + +.color-disabled { + color: var(--f-color-text-disabled); +} + +.color-brand { + color: var(--f-color-brand); +} + +.color-success { + color: var(--f-color-success); +} + +.color-error { + color: var(--f-color-error); +} + +.color-warning { + color: var(--f-color-warning); +} + +// Alignment +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-right { + text-align: right; +} + +.align-justify { + text-align: justify; +} + +// Transform +.transform-uppercase { + text-transform: uppercase; +} + +.transform-lowercase { + text-transform: lowercase; +} + +.transform-capitalize { + text-transform: capitalize; +} + +// Truncate +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.tsx b/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.tsx new file mode 100644 index 00000000..a4f29f8a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Text/ui/Text.tsx @@ -0,0 +1,239 @@ +import type React from 'react'; + +import { useRender } from '@flippo-ui/headless-components'; +import { cva } from 'class-variance-authority'; + +import type { VariantProps } from 'class-variance-authority'; + +import type { + MarginProps +} from '~@lib/types'; + +import styles from './Text.module.scss'; + +const TextVariants = cva(styles.Text, { + variants: { + size: { + 'display-1': '', + 'display-2': '', + 'title-1': '', + 'title-2': '', + 'title-3': '', + 'heading-1': '', + 'heading-2': '', + 'heading-3': '', + 'body-plus': '', + 'body': '', + 'body-minus': '', + 'label': '' + }, + weight: { + weaker: '', + default: '', + stronger: '' + }, + color: { + primary: styles['color-primary'], + secondary: styles['color-secondary'], + tertiary: styles['color-tertiary'], + quaternary: styles['color-quaternary'], + white: styles['color-white'], + disabled: styles['color-disabled'], + brand: styles['color-brand'], + success: styles['color-success'], + error: styles['color-error'], + warning: styles['color-warning'] + }, + align: { + left: styles['align-left'], + center: styles['align-center'], + right: styles['align-right'], + justify: styles['align-justify'] + }, + transform: { + none: '', + uppercase: styles['transform-uppercase'], + lowercase: styles['transform-lowercase'], + capitalize: styles['transform-capitalize'] + }, + truncate: { + true: styles.truncate, + false: '' + } + }, + compoundVariants: [ + // Display-1 + { size: 'display-1', weight: 'default', class: styles['display-1-default'] }, + { size: 'display-1', weight: 'stronger', class: styles['display-1-stronger'] }, + { size: 'display-1', weight: 'weaker', class: styles['display-1-weaker'] }, + // Display-2 + { size: 'display-2', weight: 'default', class: styles['display-2-default'] }, + { size: 'display-2', weight: 'stronger', class: styles['display-2-stronger'] }, + // Title-1 + { size: 'title-1', weight: 'default', class: styles['title-1-default'] }, + { size: 'title-1', weight: 'stronger', class: styles['title-1-stronger'] }, + // Title-2 + { size: 'title-2', weight: 'default', class: styles['title-2-default'] }, + { size: 'title-2', weight: 'stronger', class: styles['title-2-stronger'] }, + // Title-3 + { size: 'title-3', weight: 'default', class: styles['title-3-default'] }, + { size: 'title-3', weight: 'stronger', class: styles['title-3-stronger'] }, + // Heading-1 + { size: 'heading-1', weight: 'default', class: styles['heading-1-default'] }, + { size: 'heading-1', weight: 'stronger', class: styles['heading-1-stronger'] }, + // Heading-2 + { size: 'heading-2', weight: 'default', class: styles['heading-2-default'] }, + { size: 'heading-2', weight: 'weaker', class: styles['heading-2-weaker'] }, + // Heading-3 + { size: 'heading-3', weight: 'default', class: styles['heading-3-default'] }, + { size: 'heading-3', weight: 'stronger', class: styles['heading-3-stronger'] }, + // Body-plus + { size: 'body-plus', weight: 'default', class: styles['body-plus-default'] }, + { size: 'body-plus', weight: 'stronger', class: styles['body-plus-stronger'] }, + // Body + { size: 'body', weight: 'default', class: styles['body-default'] }, + { size: 'body', weight: 'stronger', class: styles['body-stronger'] }, + { size: 'body', weight: 'weaker', class: styles['body-weaker'] }, + // Body-minus + { size: 'body-minus', weight: 'default', class: styles['body-minus-default'] }, + { size: 'body-minus', weight: 'stronger', class: styles['body-minus-stronger'] }, + { size: 'body-minus', weight: 'weaker', class: styles['body-minus-weaker'] }, + // Label + { size: 'label', weight: 'default', class: styles['label-default'] }, + { size: 'label', weight: 'stronger', class: styles['label-stronger'] }, + { size: 'label', weight: 'weaker', class: styles['label-weaker'] } + ], + defaultVariants: { + size: 'body', + weight: 'default', + color: 'primary', + transform: 'none', + truncate: false + } +}); + +/** + * Text - Typography component for rendering text with predefined styles. + * Supports margin props but not padding (as per Radix design principles). + * + * @example + * Title + * + * @example + * Subtitle + * + * @example + * Long text... + */ +export function Text( + props: Text.Props +) { + const { + as: Tag = 'span', + ref, + size = 'body', + weight = 'default', + color = 'primary', + align, + transform = 'none', + truncate = false, + className, + // Margin props (Radix design principle: Text gets margin but not padding) + m, + mx, + my, + mt, + mr, + mb, + ml, + style, + ...otherProps + } = props; + + const textClassName = TextVariants({ + size, + weight, + color, + align, + transform, + truncate, + className + }); + + const textStyle: React.CSSProperties = { + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + ...style + }; + + const element = useRender({ + defaultTagName: Tag, + ref: ref as React.Ref, + props: [{ className: textClassName, style: textStyle }, otherProps] + }); + + return element; +} + +export type TextProps + = React.PropsWithChildren> + & MarginProps + & { + /** HTML element to render */ + as?: ElementType; + /** Text size variant */ + size?: Text.Size; + /** Text weight variant */ + weight?: Text.Weight; + /** Text color variant */ + color?: Text.Color; + /** Text alignment */ + align?: Text.Align; + /** Text transform */ + transform?: Text.Transform; + /** Truncate with ellipsis */ + truncate?: boolean; + /** Additional CSS class */ + className?: string; + }; + +export namespace Text { + + /** + * Text size variants (based on _font.scss mixins) + */ + export type Size + = | 'display-1' | 'display-2' + | 'title-1' | 'title-2' | 'title-3' + | 'heading-1' | 'heading-2' | 'heading-3' + | 'body-plus' | 'body' | 'body-minus' + | 'label'; + + /** + * Text weight variants + */ + export type Weight = 'weaker' | 'default' | 'stronger'; + + /** + * Text color variants + */ + export type Color + = | 'primary' | 'secondary' | 'tertiary' | 'quaternary' + | 'white' | 'disabled' + | 'brand' | 'success' | 'error' | 'warning'; + + /** + * Text alignment + */ + export type Align = 'left' | 'center' | 'right' | 'justify'; + + /** + * Text transform + */ + export type Transform = 'none' | 'uppercase' | 'lowercase' | 'capitalize'; + + export type Props = TextProps; +} diff --git a/packages/ui/uikit/flippo/components/src/index.ts b/packages/ui/uikit/flippo/components/src/index.ts index 3633da97..491deb46 100644 --- a/packages/ui/uikit/flippo/components/src/index.ts +++ b/packages/ui/uikit/flippo/components/src/index.ts @@ -1,15 +1,21 @@ // Components export * as Accordion from './components/Accordion'; export * as Avatar from './components/Avatar'; +export * as Box from './components/Box'; export * as Button from './components/Button'; +export * as Card from './components/Card'; +export * as Center from './components/Center'; export * as Checkbox from './components/Checkbox'; export * as CheckboxGroup from './components/CheckboxGroup'; export * as Collapsible from './components/Collapsible'; +export * as Container from './components/Container'; export * as ContextMenu from './components/ContextMenu'; export * as Dialog from './components/Dialog'; export * as Field from './components/Field'; export * as Fieldset from './components/Fieldset'; +export * as Flex from './components/Flex'; export * as Form from './components/Form'; +export * as Grid from './components/Grid'; export * as Input from './components/Input'; export * as Link from './components/Link'; export * as Marquee from './components/Marquee'; @@ -23,15 +29,72 @@ export * as Progress from './components/Progress'; export * as Radio from './components/Radio'; export * as RadioGroup from './components/RadioGroup'; export * as ScrollArea from './components/ScrollArea'; +export * as Section from './components/Section'; export * as Select from './components/Select'; export * as Separator from './components/Separator'; +export * as Skeleton from './components/Skeleton'; export * as Slider from './components/Slider'; export * as Slot from './components/Slot'; +export * as Spinner from './components/Spinner'; export * as Switch from './components/Switch'; export * as Tabs from './components/Tabs'; +export * as Text from './components/Text'; export * as Textarea from './components/Textarea'; export * as Toast from './components/Toast'; export * as Toggle from './components/Toggle'; export * as ToggleGroup from './components/ToggleGroup'; export * as Toolbar from './components/Toolbar'; export * as Tooltip from './components/Tooltip'; + +// Layout types and utilities +export { + extractBoxProps, + extractCenterLayoutProps, + extractContainerLayoutProps, + extractFlexChildProps, + extractFlexContainerProps, + extractFlexItemProps, + extractFlexLayoutProps, + extractGridChildProps, + extractGridContainerProps, + extractGridItemProps, + extractGridLayoutProps, + extractLayoutProps, + extractMarginProps, + extractOverflowProps, + extractPaddingProps, + extractPositionProps, + extractSectionLayoutProps, + extractSizingProps, + FLEX_CHILD_PROPS_KEYS, + FLEX_CONTAINER_PROPS_KEYS, + GRID_CHILD_PROPS_KEYS, + GRID_CONTAINER_PROPS_KEYS +} from '~@lib/layouts'; +export type { ExtractedLayoutProps } from '~@lib/layouts'; +export type { + BoxProps, + CenterLayoutProps, + ContainerLayoutProps, + ContainerSize, + DisplayProps, + FlexChildProps, + FlexContainerProps, + FlexItemProps, + FlexLayoutProps, + GridChildProps, + GridContainerProps, + GridItemProps, + GridLayoutProps, + LayoutProps, + MarginProps, + OverflowProps, + PaddingProps, + PositionProps, + SectionLayoutProps, + SectionSize, + SizingProps +} from '~@lib/types'; + +// Polymorphic types +export type { PolymorphicComponentProps, PolymorphicComponentPropsWithRef } from '~@lib/types'; diff --git a/packages/ui/uikit/flippo/components/src/lib/layouts.ts b/packages/ui/uikit/flippo/components/src/lib/layouts.ts new file mode 100644 index 00000000..e2ed0ddb --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/lib/layouts.ts @@ -0,0 +1,1096 @@ +import type { + BoxProps, + CenterLayoutProps, + ContainerLayoutProps, + FlexChildProps, + FlexContainerProps, + FlexItemProps, + FlexLayoutProps, + GridChildProps, + GridContainerProps, + GridItemProps, + GridLayoutProps, + LayoutProps, + MarginProps, + OverflowProps, + PaddingProps, + PositionProps, + SectionLayoutProps, + SizingProps, + WithStyle +} from './types'; + +/** + * Result type for extract functions + */ +export type ExtractedLayoutProps = { + style: React.CSSProperties; + otherProps: Omit; +}; + +/** + * Extract flex child props from component props and convert to style + */ +export function extractFlexChildProps( + props: T +): ExtractedLayoutProps { + const { + grow, + shrink, + basis, + flex, + alignSelf, + order, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + flexGrow: grow, + flexShrink: shrink, + flexBasis: basis, + flex, + alignSelf, + order, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract grid child props from component props and convert to style + */ +export function extractGridChildProps( + props: T +): ExtractedLayoutProps { + const { + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract flex container props from component props and convert to style + */ +export function extractFlexContainerProps( + props: T +): ExtractedLayoutProps { + const { + inline, + direction, + wrap, + flow, + justify, + align, + alignContent, + gap, + rowGap, + columnGap, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + display: inline ? 'inline-flex' : 'flex', + flexDirection: direction, + flexWrap: wrap, + flexFlow: flow, + justifyContent: justify, + alignItems: align, + alignContent, + gap, + rowGap, + columnGap, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract grid container props from component props and convert to style + */ +export function extractGridContainerProps( + props: T +): ExtractedLayoutProps { + const { + inline, + columns, + rows, + areas, + template, + autoColumns, + autoRows, + autoFlow, + justifyItems, + align, + placeItems, + justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + display: inline ? 'inline-grid' : 'grid', + gridTemplateColumns: columns, + gridTemplateRows: rows, + gridTemplateAreas: areas, + gridTemplate: template, + gridAutoColumns: autoColumns, + gridAutoRows: autoRows, + gridAutoFlow: autoFlow, + justifyItems, + alignItems: align, + placeItems, + justifyContent: justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract flex item props (container + child) from component props + */ +export function extractFlexItemProps( + props: T +): ExtractedLayoutProps { + const { + // Container + inline, + direction, + wrap, + flow, + justify, + align, + alignContent, + gap, + rowGap, + columnGap, + // Child + grow, + shrink, + basis, + flex, + alignSelf, + order, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Container + display: inline ? 'inline-flex' : 'flex', + flexDirection: direction, + flexWrap: wrap, + flexFlow: flow, + justifyContent: justify, + alignItems: align, + alignContent, + gap, + rowGap, + columnGap, + // Child + flexGrow: grow, + flexShrink: shrink, + flexBasis: basis, + flex, + alignSelf, + order, + // User style (overrides) + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract grid item props (container + child) from component props + */ +export function extractGridItemProps( + props: T +): ExtractedLayoutProps { + const { + // Container + inline, + columns, + rows, + areas, + template, + autoColumns, + autoRows, + autoFlow, + justifyItems, + align, + placeItems, + justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + // Child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Container + display: inline ? 'inline-grid' : 'grid', + gridTemplateColumns: columns, + gridTemplateRows: rows, + gridTemplateAreas: areas, + gridTemplate: template, + gridAutoColumns: autoColumns, + gridAutoRows: autoRows, + gridAutoFlow: autoFlow, + justifyItems, + alignItems: align, + placeItems, + justifyContent: justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + // Child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + // User style (overrides) + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Keys for extracting layout props from component props + */ +export const FLEX_CHILD_PROPS_KEYS: (keyof FlexChildProps)[] = [ + 'grow', + 'shrink', + 'basis', + 'flex', + 'alignSelf', + 'order' +]; + +export const GRID_CHILD_PROPS_KEYS: (keyof GridChildProps)[] = [ + 'gridColumn', + 'gridColumnStart', + 'gridColumnEnd', + 'gridRow', + 'gridRowStart', + 'gridRowEnd', + 'gridArea', + 'justifySelf', + 'alignSelf', + 'placeSelf', + 'order' +]; + +export const FLEX_CONTAINER_PROPS_KEYS: (keyof FlexContainerProps)[] = [ + 'inline', + 'direction', + 'wrap', + 'flow', + 'justify', + 'align', + 'alignContent', + 'gap', + 'rowGap', + 'columnGap' +]; + +export const GRID_CONTAINER_PROPS_KEYS: (keyof GridContainerProps)[] = [ + 'inline', + 'columns', + 'rows', + 'areas', + 'template', + 'autoColumns', + 'autoRows', + 'autoFlow', + 'justifyItems', + 'align', + 'placeItems', + 'justify', + 'alignContent', + 'placeContent', + 'gap', + 'rowGap', + 'columnGap' +]; + +// ============================================================================ +// Layout Props Extract Functions (Radix-style) +// ============================================================================ + +/** + * Extract margin props and convert to style + */ +export function extractMarginProps( + props: T +): ExtractedLayoutProps { + const { + m, + mx, + my, + mt, + mr, + mb, + ml, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract padding props and convert to style + */ +export function extractPaddingProps( + props: T +): ExtractedLayoutProps { + const { + p, + px, + py, + pt, + pr, + pb, + pl, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + padding: p, + paddingTop: pt ?? py, + paddingRight: pr ?? px, + paddingBottom: pb ?? py, + paddingLeft: pl ?? px, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract sizing props and convert to style + */ +export function extractSizingProps( + props: T +): ExtractedLayoutProps { + const { + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract position props and convert to style + */ +export function extractPositionProps( + props: T +): ExtractedLayoutProps { + const { + position, + inset, + top, + right, + bottom, + left, + zIndex, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + position, + inset, + top, + right, + bottom, + left, + zIndex, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract overflow props and convert to style + */ +export function extractOverflowProps( + props: T +): ExtractedLayoutProps { + const { + overflow, + overflowX, + overflowY, + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + overflow, + overflowX, + overflowY, + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract all layout props (for Box component) + */ +export function extractLayoutProps( + props: T +): ExtractedLayoutProps { + const { + // Margin + m, + mx, + my, + mt, + mr, + mb, + ml, + // Padding + p, + px, + py, + pt, + pr, + pb, + pl, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // Display + display, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Display + display, + // Margin + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + // Padding + padding: p, + paddingTop: pt ?? py, + paddingRight: pr ?? px, + paddingBottom: pb ?? py, + paddingLeft: pl ?? px, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // User style + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract Box props (layout + flex child + grid child) + */ +export function extractBoxProps( + props: T +): ExtractedLayoutProps { + const { + // Margin + m, + mx, + my, + mt, + mr, + mb, + ml, + // Padding + p, + px, + py, + pt, + pr, + pb, + pl, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // Display + display, + // Flex child + grow, + shrink, + basis, + flex, + alignSelf, + order, + // Grid child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + placeSelf, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Display + display, + // Margin + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + // Padding + padding: p, + paddingTop: pt ?? py, + paddingRight: pr ?? px, + paddingBottom: pb ?? py, + paddingLeft: pl ?? px, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // Flex child + flexGrow: grow, + flexShrink: shrink, + flexBasis: basis, + flex, + alignSelf, + order, + // Grid child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + placeSelf, + // User style + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract Flex layout props (flex container + flex child + layout props) + */ +export function extractFlexLayoutProps( + props: T +): ExtractedLayoutProps { + const { + // Flex container + inline, + direction, + wrap, + flow, + justify, + align, + alignContent, + gap, + rowGap, + columnGap, + // Flex child + grow, + shrink, + basis, + flex, + alignSelf, + order, + // Margin + m, + mx, + my, + mt, + mr, + mb, + ml, + // Padding + p, + px, + py, + pt, + pr, + pb, + pl, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Flex container + display: inline ? 'inline-flex' : 'flex', + flexDirection: direction, + flexWrap: wrap, + flexFlow: flow, + justifyContent: justify, + alignItems: align, + alignContent, + gap, + rowGap, + columnGap, + // Flex child + flexGrow: grow, + flexShrink: shrink, + flexBasis: basis, + flex, + alignSelf, + order, + // Margin + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + // Padding + padding: p, + paddingTop: pt ?? py, + paddingRight: pr ?? px, + paddingBottom: pb ?? py, + paddingLeft: pl ?? px, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // User style + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract Grid layout props (grid container + grid child + layout props) + */ +export function extractGridLayoutProps( + props: T +): ExtractedLayoutProps { + const { + // Grid container + inline, + columns, + rows, + areas, + template, + autoColumns, + autoRows, + autoFlow, + justifyItems, + align, + placeItems, + justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + // Grid child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + // Margin + m, + mx, + my, + mt, + mr, + mb, + ml, + // Padding + p, + px, + py, + pt, + pr, + pb, + pl, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // Style + style: styleProp, + ...otherProps + } = props; + + const style: React.CSSProperties = { + // Grid container + display: inline ? 'inline-grid' : 'grid', + gridTemplateColumns: columns, + gridTemplateRows: rows, + gridTemplateAreas: areas, + gridTemplate: template, + gridAutoColumns: autoColumns, + gridAutoRows: autoRows, + gridAutoFlow: autoFlow, + justifyItems, + alignItems: align, + placeItems, + justifyContent: justify, + alignContent, + placeContent, + gap, + rowGap, + columnGap, + // Grid child + gridColumn, + gridColumnStart, + gridColumnEnd, + gridRow, + gridRowStart, + gridRowEnd, + gridArea, + justifySelf, + alignSelf, + placeSelf, + order, + // Margin + margin: m, + marginTop: mt ?? my, + marginRight: mr ?? mx, + marginBottom: mb ?? my, + marginLeft: ml ?? mx, + // Padding + padding: p, + paddingTop: pt ?? py, + paddingRight: pr ?? px, + paddingBottom: pb ?? py, + paddingLeft: pl ?? px, + // Sizing + width, + height, + minWidth, + maxWidth, + minHeight, + maxHeight, + // Position + position, + inset, + top, + right, + bottom, + left, + zIndex, + // Overflow + overflow, + overflowX, + overflowY, + // User style + ...styleProp + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Section size to padding mapping + */ +const SECTION_SIZE_MAP: Record = { + sm: 'var(--f-spacing-6)', + md: 'var(--f-spacing-10)', + lg: 'var(--f-spacing-16)' +}; + +/** + * Extract Section layout props + */ +export function extractSectionLayoutProps( + props: T +): ExtractedLayoutProps { + const { size = 'md', ...restProps } = props; + const { style: layoutStyle, otherProps } = extractLayoutProps(restProps); + + const sectionPadding = SECTION_SIZE_MAP[size] ?? SECTION_SIZE_MAP.md; + + const style: React.CSSProperties = { + paddingTop: sectionPadding, + paddingBottom: sectionPadding, + ...layoutStyle + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Container size to max-width mapping + */ +const CONTAINER_SIZE_MAP: Record = { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + full: '100%' +}; + +/** + * Extract Container layout props + */ +export function extractContainerLayoutProps( + props: T +): ExtractedLayoutProps { + const { size = 'lg', ...restProps } = props; + const { style: layoutStyle, otherProps } = extractLayoutProps(restProps); + + const containerMaxWidth = CONTAINER_SIZE_MAP[size] ?? CONTAINER_SIZE_MAP.lg; + + const style: React.CSSProperties = { + width: '100%', + maxWidth: containerMaxWidth, + marginLeft: 'auto', + marginRight: 'auto', + ...layoutStyle + }; + + return { style, otherProps: otherProps as Omit }; +} + +/** + * Extract Center layout props (flexbox centering container) + */ +export function extractCenterLayoutProps( + props: T +): ExtractedLayoutProps { + const { style: layoutStyle, otherProps } = extractLayoutProps(props); + + const style: React.CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + ...layoutStyle + }; + + return { style, otherProps: otherProps as Omit }; +} diff --git a/packages/ui/uikit/flippo/components/src/lib/types.ts b/packages/ui/uikit/flippo/components/src/lib/types.ts new file mode 100644 index 00000000..7da1401f --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/lib/types.ts @@ -0,0 +1,294 @@ +import type { ComponentPropsWithRef, ElementType } from 'react'; +import type React from 'react'; + +// Для экспорта полиморфных типов в других компонентах +export type PolymorphicComponentProps< + T extends ElementType, + Props = Record +> = Props & ComponentPropsWithRef & { as?: T }; + +export type PolymorphicComponentPropsWithRef< + T extends ElementType = 'div', + Props = Record +> = PolymorphicComponentProps; + +/** + * CSS properties for flex container children + */ +export type FlexChildProps = { + /** CSS flex-grow */ + grow?: React.CSSProperties['flexGrow']; + /** CSS flex-shrink */ + shrink?: React.CSSProperties['flexShrink']; + /** CSS flex-basis */ + basis?: React.CSSProperties['flexBasis']; + /** CSS flex (shorthand) */ + flex?: React.CSSProperties['flex']; + /** CSS align-self */ + alignSelf?: React.CSSProperties['alignSelf']; + /** CSS order */ + order?: React.CSSProperties['order']; +}; + +/** + * CSS properties for grid container children + */ +export type GridChildProps = { + /** CSS grid-column */ + gridColumn?: React.CSSProperties['gridColumn']; + /** CSS grid-column-start */ + gridColumnStart?: React.CSSProperties['gridColumnStart']; + /** CSS grid-column-end */ + gridColumnEnd?: React.CSSProperties['gridColumnEnd']; + /** CSS grid-row */ + gridRow?: React.CSSProperties['gridRow']; + /** CSS grid-row-start */ + gridRowStart?: React.CSSProperties['gridRowStart']; + /** CSS grid-row-end */ + gridRowEnd?: React.CSSProperties['gridRowEnd']; + /** CSS grid-area */ + gridArea?: React.CSSProperties['gridArea']; + /** CSS justify-self */ + justifySelf?: React.CSSProperties['justifySelf']; + /** CSS align-self */ + alignSelf?: React.CSSProperties['alignSelf']; + /** CSS place-self */ + placeSelf?: React.CSSProperties['placeSelf']; + /** CSS order */ + order?: React.CSSProperties['order']; +}; + +/** + * CSS properties for flex container + */ +export type FlexContainerProps = { + /** CSS display: flex | inline-flex */ + inline?: boolean; + /** CSS flex-direction */ + direction?: React.CSSProperties['flexDirection']; + /** CSS flex-wrap */ + wrap?: React.CSSProperties['flexWrap']; + /** CSS flex-flow (shorthand) */ + flow?: React.CSSProperties['flexFlow']; + /** CSS justify-content */ + justify?: React.CSSProperties['justifyContent']; + /** CSS align-items */ + align?: React.CSSProperties['alignItems']; + /** CSS align-content */ + alignContent?: React.CSSProperties['alignContent']; + /** CSS gap */ + gap?: React.CSSProperties['gap']; + /** CSS row-gap */ + rowGap?: React.CSSProperties['rowGap']; + /** CSS column-gap */ + columnGap?: React.CSSProperties['columnGap']; +}; + +/** + * CSS properties for grid container + */ +export type GridContainerProps = { + /** CSS display: grid | inline-grid */ + inline?: boolean; + /** CSS grid-template-columns */ + columns?: React.CSSProperties['gridTemplateColumns']; + /** CSS grid-template-rows */ + rows?: React.CSSProperties['gridTemplateRows']; + /** CSS grid-template-areas */ + areas?: React.CSSProperties['gridTemplateAreas']; + /** CSS grid-template (shorthand) */ + template?: React.CSSProperties['gridTemplate']; + /** CSS grid-auto-columns */ + autoColumns?: React.CSSProperties['gridAutoColumns']; + /** CSS grid-auto-rows */ + autoRows?: React.CSSProperties['gridAutoRows']; + /** CSS grid-auto-flow */ + autoFlow?: React.CSSProperties['gridAutoFlow']; + /** CSS justify-items */ + justifyItems?: React.CSSProperties['justifyItems']; + /** CSS align-items */ + align?: React.CSSProperties['alignItems']; + /** CSS place-items */ + placeItems?: React.CSSProperties['placeItems']; + /** CSS justify-content */ + justify?: React.CSSProperties['justifyContent']; + /** CSS align-content */ + alignContent?: React.CSSProperties['alignContent']; + /** CSS place-content */ + placeContent?: React.CSSProperties['placeContent']; + /** CSS gap */ + gap?: React.CSSProperties['gap']; + /** CSS row-gap */ + rowGap?: React.CSSProperties['rowGap']; + /** CSS column-gap */ + columnGap?: React.CSSProperties['columnGap']; +}; + +/** + * Combined props for element that is both a flex/grid container and a child + */ +export type FlexItemProps = FlexContainerProps & FlexChildProps; +export type GridItemProps = GridContainerProps & GridChildProps; + +/** + * Props with optional style + */ +export type WithStyle = { style?: React.CSSProperties }; + +// ============================================================================ +// Layout Props (Radix-style) +// ============================================================================ + +/** + * Margin props (shorthand like Radix/Tailwind) + */ +export type MarginProps = { + /** Margin on all sides */ + m?: React.CSSProperties['margin']; + /** Margin horizontal (left & right) */ + mx?: React.CSSProperties['marginLeft']; + /** Margin vertical (top & bottom) */ + my?: React.CSSProperties['marginTop']; + /** Margin top */ + mt?: React.CSSProperties['marginTop']; + /** Margin right */ + mr?: React.CSSProperties['marginRight']; + /** Margin bottom */ + mb?: React.CSSProperties['marginBottom']; + /** Margin left */ + ml?: React.CSSProperties['marginLeft']; +}; + +/** + * Padding props (shorthand like Radix/Tailwind) + */ +export type PaddingProps = { + /** Padding on all sides */ + p?: React.CSSProperties['padding']; + /** Padding horizontal (left & right) */ + px?: React.CSSProperties['paddingLeft']; + /** Padding vertical (top & bottom) */ + py?: React.CSSProperties['paddingTop']; + /** Padding top */ + pt?: React.CSSProperties['paddingTop']; + /** Padding right */ + pr?: React.CSSProperties['paddingRight']; + /** Padding bottom */ + pb?: React.CSSProperties['paddingBottom']; + /** Padding left */ + pl?: React.CSSProperties['paddingLeft']; +}; + +/** + * Sizing props + */ +export type SizingProps = { + /** CSS width */ + width?: React.CSSProperties['width']; + /** CSS height */ + height?: React.CSSProperties['height']; + /** CSS min-width */ + minWidth?: React.CSSProperties['minWidth']; + /** CSS max-width */ + maxWidth?: React.CSSProperties['maxWidth']; + /** CSS min-height */ + minHeight?: React.CSSProperties['minHeight']; + /** CSS max-height */ + maxHeight?: React.CSSProperties['maxHeight']; +}; + +/** + * Position props + */ +export type PositionProps = { + /** CSS position */ + position?: React.CSSProperties['position']; + /** CSS inset (shorthand for top/right/bottom/left) */ + inset?: React.CSSProperties['inset']; + /** CSS top */ + top?: React.CSSProperties['top']; + /** CSS right */ + right?: React.CSSProperties['right']; + /** CSS bottom */ + bottom?: React.CSSProperties['bottom']; + /** CSS left */ + left?: React.CSSProperties['left']; + /** CSS z-index */ + zIndex?: React.CSSProperties['zIndex']; +}; + +/** + * Overflow props + */ +export type OverflowProps = { + /** CSS overflow */ + overflow?: React.CSSProperties['overflow']; + /** CSS overflow-x */ + overflowX?: React.CSSProperties['overflowX']; + /** CSS overflow-y */ + overflowY?: React.CSSProperties['overflowY']; +}; + +/** + * Display props + */ +export type DisplayProps = { + /** CSS display */ + display?: React.CSSProperties['display']; +}; + +/** + * Combined layout props for Box component (like Radix) + */ +export type LayoutProps = MarginProps + & PaddingProps + & SizingProps + & PositionProps + & OverflowProps + & DisplayProps; + +/** + * Box props - layout container with all layout props + */ +export type BoxProps = LayoutProps & FlexChildProps & GridChildProps; + +/** + * Flex component props - flex container + layout props (without display since flex sets it) + */ +export type FlexLayoutProps = FlexItemProps & Omit; + +/** + * Grid component props - grid container + layout props (without display since grid sets it) + */ +export type GridLayoutProps = GridItemProps & Omit; + +/** + * Container size presets + */ +export type ContainerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'; + +/** + * Section size presets + */ +export type SectionSize = 'sm' | 'md' | 'lg'; + +/** + * Section props - vertical spacing section + */ +export type SectionLayoutProps = LayoutProps & { + /** Section vertical padding size */ + size?: SectionSize; +}; + +/** + * Container props - centered max-width container + */ +export type ContainerLayoutProps = LayoutProps & { + /** Container max-width size */ + size?: ContainerSize; +}; + +/** + * Center props - flexbox centering container + */ +export type CenterLayoutProps = Omit; diff --git a/packages/ui/uikit/flippo/components/src/styles/mixins/_font.scss b/packages/ui/uikit/flippo/components/src/styles/mixins/_font.scss index 7a581f5b..8ddf813c 100644 --- a/packages/ui/uikit/flippo/components/src/styles/mixins/_font.scss +++ b/packages/ui/uikit/flippo/components/src/styles/mixins/_font.scss @@ -1,145 +1,145 @@ +@use '../variables/fonts' as *; + $default: 'default'; $stronger: 'stronger'; $weaker: 'weaker'; @mixin display-1($variant) { - line-height: 150%; - font-size: 105px; + line-height: var(--f-text-display-1-line-height); + font-size: var(--f-text-display-1-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-display-1-weight-default); } @else if $variant == $stronger { - font-weight: 800; - } @else if $variant == $weaker { - font-weight: 300; + font-weight: var(--f-text-display-1-weight-stronger); } } @mixin display-2($variant) { - line-height: 150%; - font-size: 66px; + line-height: var(--f-text-display-2-line-height); + font-size: var(--f-text-display-2-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-display-2-weight-default); } @else if $variant == $stronger { - font-weight: 700; + font-weight: var(--f-text-display-2-weight-stronger); } } @mixin title-1($variant) { - line-height: 150%; - font-size: 46px; + line-height: var(--f-text-title-1-line-height); + font-size: var(--f-text-title-1-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-title-1-weight-default); } @else if $variant == $stronger { - font-weight: 700; + font-weight: var(--f-text-title-1-weight-stronger); } } @mixin title-2($variant) { - line-height: 150%; - font-size: 36px; + line-height: var(--f-text-title-2-line-height); + font-size: var(--f-text-title-2-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-title-2-weight-default); } @else if $variant == $stronger { - font-weight: 800; + font-weight: var(--f-text-title-2-weight-stronger); } } @mixin title-3($variant) { - line-height: 150%; - font-size: 29px; + line-height: var(--f-text-title-3-line-height); + font-size: var(--f-text-title-3-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-title-3-weight-default); } @else if $variant == $stronger { - font-weight: 800; + font-weight: var(--f-text-title-3-weight-stronger); } } @mixin heading-1($variant) { - line-height: 150%; - font-size: 26px; + line-height: var(--f-text-heading-1-line-height); + font-size: var(--f-text-heading-1-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-heading-1-weight-default); } @else if $variant == $stronger { - font-weight: 700; + font-weight: var(--f-text-heading-1-weight-stronger); } } @mixin heading-2($variant) { - line-height: 150%; - font-size: 23px; + line-height: var(--f-text-heading-2-line-height); + font-size: var(--f-text-heading-2-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-heading-2-weight-default); } @else if $variant == $weaker { - font-weight: 400; + font-weight: var(--f-text-heading-2-weight-weaker); letter-spacing: 1%; } } @mixin heading-3($variant) { - line-height: 150%; - font-size: 20px; + line-height: var(--f-text-heading-3-line-height); + font-size: var(--f-text-heading-3-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-heading-3-weight-default); } @else if $variant == $stronger { - font-weight: 700; + font-weight: var(--f-text-heading-3-weight-stronger); } } @mixin bodyPlus($variant) { - line-height: 150%; - font-size: 18px; + line-height: var(--f-text-body-plus-line-height); + font-size: var(--f-text-body-plus-size); @if $variant == $default { - font-weight: 400; + font-weight: var(--f-text-body-plus-weight-default); } @else if $variant == $stronger { - font-weight: 600; + font-weight: var(--f-text-body-plus-weight-stronger); } } @mixin body($variant) { - line-height: 150%; - font-size: 16px; + line-height: var(--f-text-body-line-height); + font-size: var(--f-text-body-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-body-weight-default); letter-spacing: 1%; } @else if $variant == $stronger { - font-weight: 700; + font-weight: var(--f-text-body-weight-stronger); } @else if $variant == $weaker { - font-weight: 500; + font-weight: var(--f-text-body-weight-weaker); } } @mixin bodyMinus($variant) { - line-height: 150%; - font-size: 14px; + line-height: var(--f-text-body-minus-line-height); + font-size: var(--f-text-body-minus-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-body-minus-weight-default); } @else if $variant == $stronger { - font-weight: 800; + font-weight: var(--f-text-body-minus-weight-stronger); } @else if $variant == $weaker { - font-weight: 500; + font-weight: var(--f-text-body-minus-weight-weaker); } } @mixin label($variant) { - line-height: 150%; - font-size: 13px; + line-height: var(--f-text-label-line-height); + font-size: var(--f-text-label-size); @if $variant == $default { - font-weight: 600; + font-weight: var(--f-text-label-weight-default); } @else if $variant == $stronger { - font-weight: 800; + font-weight: var(--f-text-label-weight-stronger); } @else if $variant == $weaker { - font-weight: 500; + font-weight: var(--f-text-label-weight-weaker); } } diff --git a/packages/ui/uikit/flippo/components/src/types/polymorphic.ts b/packages/ui/uikit/flippo/components/src/types/polymorphic.ts deleted file mode 100644 index 6da0583b..00000000 --- a/packages/ui/uikit/flippo/components/src/types/polymorphic.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ComponentPropsWithRef, ElementType } from 'react'; - -// Для экспорта полиморфных типов в других компонентах -export type PolymorphicComponentProps< - T extends ElementType, - Props = Record -> = Props & ComponentPropsWithRef & { as?: T }; - -export type PolymorphicComponentPropsWithRef< - T extends ElementType, - Props = Record -> = PolymorphicComponentProps; diff --git a/packages/ui/uikit/flippo/components/tsconfig.json b/packages/ui/uikit/flippo/components/tsconfig.json index 39be7c1e..c2841eb0 100644 --- a/packages/ui/uikit/flippo/components/tsconfig.json +++ b/packages/ui/uikit/flippo/components/tsconfig.json @@ -6,8 +6,7 @@ "lib": ["dom", "ES2024"], "paths": { - "@lib/*": ["./src/lib/*"], - "@packages/*": ["./src/packages/*"] + "~@lib/*": ["./src/lib/*"] }, "types": ["react", "react-dom", "node"] }, diff --git a/packages/ui/uikit/flippo/components/vite.config.ts b/packages/ui/uikit/flippo/components/vite.config.ts index cf652891..f44e1d90 100644 --- a/packages/ui/uikit/flippo/components/vite.config.ts +++ b/packages/ui/uikit/flippo/components/vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig({ envPrefix: 'FLIPPO_', resolve: { alias: { - '@lib': path.resolve(__dirname, './src/lib') + '~@lib': path.resolve(__dirname, './src/lib') } }, server: {