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/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..583e52e3 --- /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: 'Widgets/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/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/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..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 @@ -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,61 @@ export const Default: TooltipStory = { ) } }; + +export const Multiple: TooltipStory = { + render: () => ( +
+ + + + + + + + + + + + {'Bold'} + + + + + + + + + + + + + + + + {'Italic'} + + + + + + + + + + + + + + + + {'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/flippo/components/src/index.ts b/packages/ui/uikit/flippo/components/src/index.ts index 26374efd..491deb46 100644 --- a/packages/ui/uikit/flippo/components/src/index.ts +++ b/packages/ui/uikit/flippo/components/src/index.ts @@ -1,17 +1,24 @@ // 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'; export * as Menu from './components/Menu'; export * as Menubar from './components/Menubar'; export * as Meter from './components/Meter'; @@ -22,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: { diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json index 273f69e6..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", @@ -110,6 +115,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", @@ -130,6 +140,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 +269,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", @@ -269,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/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/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/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/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/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/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/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/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/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/package.json b/packages/ui/uikit/headless/hooks/package.json index b913959e..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", @@ -60,16 +75,91 @@ "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", + "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", @@ -95,11 +185,46 @@ "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", + "require": "./dist/hooks/useMediaQuery/index.cjs.js" + }, "./use-merged-ref": { "types": "./dist/hooks/useMergedRef/index.d.ts", "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", @@ -120,16 +245,51 @@ "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", + "require": "./dist/hooks/useSsr/index.cjs.js" + }, "./use-stable-callback": { "types": "./dist/hooks/useStableCallback/index.d.ts", "import": "./dist/hooks/useStableCallback/index.es.js", @@ -145,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", @@ -169,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", @@ -185,6 +365,7 @@ }, "dependencies": { "reselect": "catalog:", + "tabbable": "catalog:", "use-sync-external-store": "catalog:" }, "devDependencies": { diff --git a/packages/ui/uikit/headless/hooks/src/hooks/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/index.ts index b0df4167..14ff6d55 100644 --- a/packages/ui/uikit/headless/hooks/src/hooks/index.ts +++ b/packages/ui/uikit/headless/hooks/src/hooks/index.ts @@ -1,28 +1,69 @@ export * from './useAnimationFrame'; export * from './useAnimationsFinished'; +export * from './useClickOutside'; export * from './useClipboard'; +export * from './useColorSchema'; export * from './useControlledState'; export * from './useDidUpdate'; +export * from './useDocumentTitle'; export * from './useDragGesture'; export * from './useEnhancedClickHandler'; export * from './useEnhancedEffect'; 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'; +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 './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 './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'; 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'; + +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..6c29e1a4 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; } @@ -38,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; @@ -71,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/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..d51d8472 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useClickOutside/useClickOutside.ts @@ -0,0 +1,94 @@ +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 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, + 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: 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(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 (element && element instanceof Element && !element.contains(eventTarget as Node)) { + stableCallback(event as EventType); + } + }; + + eventsList.forEach((fn) => document.addEventListener(fn, listener)); + + return () => { + eventsList.forEach((fn) => document.removeEventListener(fn, listener)); + }; + }, [element, eventsList]); + + if (target) { + return undefined; + } + + return { ref: internalRef }; +} 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'; +} 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/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]); +} 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; +} 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/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]); +} 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..74fc9da4 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useFocusTrap/useFocusTrap.ts @@ -0,0 +1,94 @@ +import React from 'react'; + +import { tabbable } from 'tabbable'; + +import { isTarget } from '~@lib/isTarget'; + +import type { HookTarget } from '~@lib/isTarget'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; + +function focusElement(element: HTMLElement) { + const autofocusElement = element.querySelector('[data-autofocus]') as HTMLElement; + if (autofocusElement) + return autofocusElement.focus(); + const focusableElements = tabbable(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] = tabbable(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/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/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..f55c67bf --- /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 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 + = 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; +} 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..b0d0fc0e --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMouse/useMouse.ts @@ -0,0 +1,83 @@ +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'; + +export type UseMouseOptions = { + /** Reset position to (0, 0) when mouse leaves the element */ + resetOnExit?: boolean; +}; + +export type UseMouseReturn = { + x: number; + y: number; +}; + +export function useMouse(target: HookTarget, options?: UseMouseOptions): UseMouseReturn; + +export function useMouse( + options?: UseMouseOptions +): UseMouseReturn & { ref: RefAsState }; + +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; + + const [position, setPosition] = React.useState({ x: 0, y: 0 }); + const internalRef = useRefAsState(); + + const resetMousePosition = () => setPosition({ x: 0, y: 0 }); + + const element = target ? isTarget.getElement(target) : internalRef.current; + + useIsoLayoutEffect(() => { + const targetElement = element ?? document; + + const setMousePosition = (event: MouseEvent) => { + if (element) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + + const x = Math.max( + 0, + Math.round(event.pageX - rect.left - (window.scrollX || window.scrollX)) + ); + + const y = Math.max( + 0, + Math.round(event.pageY - rect.top - (window.scrollY || window.scrollY)) + ); + + setPosition({ x, y }); + } + else { + setPosition({ x: event.clientX, y: event.clientY }); + } + }; + + targetElement.addEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + targetElement.addEventListener('mouseleave', resetMousePosition as any); + } + + return () => { + targetElement.removeEventListener('mousemove', setMousePosition as any); + if (options.resetOnExit) { + targetElement.removeEventListener('mouseleave', resetMousePosition as any); + } + }; + }, [element, options.resetOnExit]); + + if (target) { + return position; + } + + return { ref: internalRef, ...position }; +} 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..d80960b2 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useMutationObserver/useMutationObserver.ts @@ -0,0 +1,56 @@ +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'; + +export function useMutationObserver( + target: HookTarget, + callback: MutationCallback, + options: MutationObserverInit +): void; + +export function useMutationObserver( + callback: MutationCallback, + 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 internalRef = useRefAsState(); + const optionsRef = useValueAsRef(options); + + const stableCallback = useStableCallback(callback); + + const element = target ? isTarget.getElement(target) : internalRef.current; + + useIsoLayoutEffect(() => { + if (element) { + observer.current = new MutationObserver(stableCallback); + observer.current.observe(element as Node, optionsRef.current); + } + + return () => { + observer.current?.disconnect(); + }; + }, [element]); + + if (target) { + return undefined; + } + + return { ref: internalRef }; +} 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/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/useOrientation/index.ts b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/index.ts new file mode 100644 index 00000000..2d15240a --- /dev/null +++ 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 new file mode 100644 index 00000000..ee1aeed7 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useOrientation/useOrientation.ts @@ -0,0 +1,77 @@ +import React 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] = React.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/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 new file mode 100644 index 00000000..888509ab --- /dev/null +++ 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 new file mode 100644 index 00000000..3d266578 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useResizeObserver/useResizeObserver.ts @@ -0,0 +1,107 @@ +import React from 'react'; + +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, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + 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( + ...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 frameID = React.useRef(0); + const internalRef = useRefAsState(); + const optionsRef = useValueAsRef(options); + + const [rect, setRect] = React.useState(defaultState); + + const element = target ? isTarget.getElement(target) : internalRef.current; + + const observer = React.useMemo( + () => + typeof window !== 'undefined' + ? new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + cancelAnimationFrame(frameID.current); + + frameID.current = requestAnimationFrame(() => { + 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, + [] + ); + + useIsoLayoutEffect(() => { + if (element) { + observer?.observe(element as Element, optionsRef.current); + } + + return () => { + observer?.disconnect(); + + if (frameID.current) { + cancelAnimationFrame(frameID.current); + } + }; + }, [element]); + + if (target) { + return rect; + } + + return [internalRef, 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..4a788d68 --- /dev/null +++ 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 new file mode 100644 index 00000000..f7890271 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollIntoView/useScrollIntoView.ts @@ -0,0 +1,299 @@ +import React from 'react'; + +import { useMediaQuery } from '../useMediaQuery'; +import { useOnMount } from '../useOnMount'; +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 = React.useRef(0); + const startTime = React.useRef(0); + const shouldStop = React.useRef(false); + + const scrollableRef = React.useRef(null); + const targetRef = React.useRef(null); + + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + + const cancel = (): void => { + if (frameID.current) { + cancelAnimationFrame(frameID.current); + } + }; + + const scrollIntoView = React.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 = Date.now(); + } + + const now = Date.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 + useOnMount(() => 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; +} + +type GetScrollStartParams = { + axis: 'x' | 'y'; + parent: HTMLElement | null; +}; + +function getScrollStart({ axis, parent }: GetScrollStartParams) { + 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]; +} + +type SetScrollParamParams = { + axis: 'x' | 'y'; + parent: HTMLElement | null; + distance: number; +}; + +function setScrollParam({ axis, parent, distance }: SetScrollParamParams) { + 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/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/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..cd04a961 --- /dev/null +++ b/packages/ui/uikit/headless/hooks/src/hooks/useScrollSpy/useScrollSpy.ts @@ -0,0 +1,154 @@ +import React from 'react'; + +import { randomId } from '~@lib/randomId'; + +import { useIsoLayoutEffect } from '../useIsoLayoutEffect'; +import { useStableCallback } from '../useStableCallback'; + +function getHeadingsData( + headings: HTMLElement[], + getDepth: (element: HTMLElement) => number, + getValue: (element: HTMLElement) => string +): UseScrollSpyHeadingData[] { + const result: UseScrollSpyHeadingData[] = []; + + for (const heading of headings) { + result.push({ + depth: getDepth(heading), + value: getValue(heading), + id: heading.id || randomId('scroll-spy'), + 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 ?? 0 } + ); + + 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] = React.useState(-1); + const [initialized, setInitialized] = React.useState(false); + const [data, setData] = React.useState([]); + const headingsRef = React.useRef([]); + + const handleScroll = useStableCallback(() => { + setActive( + getActiveElement( + headingsRef.current.map((d) => d.getNode().getBoundingClientRect()), + offset + ) + ); + }); + + const initialize = useStableCallback(() => { + 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 + ) + ); + }); + + useIsoLayoutEffect(() => { + 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/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; +} 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/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/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 () => { 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)); +} 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 919cfb04..5f07b937 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1004,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) @@ -5754,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==} @@ -11759,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 @@ -11806,7 +11811,7 @@ snapshots: '@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) @@ -14756,7 +14761,7 @@ snapshots: history@5.3.0: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.28.4 hookified@1.12.0: {} @@ -14770,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 @@ -14859,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: {} @@ -14871,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 @@ -16735,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