diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index fd0ae0f4..373b3023 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,17 +1,21 @@ import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - addons: [ - '@storybook/addon-onboarding', - '@storybook/addon-essentials', - '@chromatic-com/storybook', - '@storybook/addon-interactions', - 'storybook-react-i18next' - ], - framework: { - name: '@storybook/react-vite', - options: {} - }, - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'] + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + 'storybook-react-i18next' + ], + framework: { + name: '@storybook/react-vite', + options: {} + }, + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + typescript: { + // Disable react-docgen to avoid errors with namespace exports and ObjectMethod patterns + reactDocgen: false + } }; export default config; diff --git a/frontend/src/modules/auth/view/ui/Auth/Auth.tsx b/frontend/src/modules/auth/view/ui/Auth/Auth.tsx index 70ecebb5..f0032bc9 100644 --- a/frontend/src/modules/auth/view/ui/Auth/Auth.tsx +++ b/frontend/src/modules/auth/view/ui/Auth/Auth.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { JSX } from 'react'; import type { TAuthContent } from '../../../types/TAuthContent'; diff --git a/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx b/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx index 6762ad2f..476919ec 100644 --- a/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx +++ b/frontend/src/modules/auth/view/ui/Auth/AuthorizationMethodContent/AuthorizationMethodContent.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { TAuthProvider } from '@shared/query'; import type { ChangeEvent } from 'react'; diff --git a/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx b/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx index 65aa84af..076c55f4 100644 --- a/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx +++ b/frontend/src/modules/auth/view/ui/Auth/InputUsernameContent/InputUsernameContent.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { ChangeEvent } from 'react'; import { LoadingButton } from '@shared/ui/Button'; diff --git a/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx b/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx index ca69b118..cadadacd 100644 --- a/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx +++ b/frontend/src/modules/auth/view/ui/Auth/VerificationCodeContent/VerificationCodeContent.tsx @@ -1,4 +1,4 @@ -'use client'; + import { ArrowIcon, EmailIcon } from '@shared/icons'; diff --git a/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx b/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx index 3ec5086e..dbd78e73 100644 --- a/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx +++ b/frontend/src/modules/auth/view/ui/AuthCallback/AuthCallback.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { JSX } from 'react'; import { FadeTransition } from '@shared/ui/FadeTransition'; diff --git a/frontend/src/modules/auth/vm/useAuthorizationMethod.ts b/frontend/src/modules/auth/vm/useAuthorizationMethod.ts index 92f32c36..dd877135 100644 --- a/frontend/src/modules/auth/vm/useAuthorizationMethod.ts +++ b/frontend/src/modules/auth/vm/useAuthorizationMethod.ts @@ -1,4 +1,4 @@ -'use client'; + import { useUnit } from 'effector-react'; import { useEffect, useRef } from 'react'; diff --git a/frontend/src/modules/auth/vm/useVerificationCode.ts b/frontend/src/modules/auth/vm/useVerificationCode.ts index 558251cb..b5191fa8 100644 --- a/frontend/src/modules/auth/vm/useVerificationCode.ts +++ b/frontend/src/modules/auth/vm/useVerificationCode.ts @@ -1,4 +1,4 @@ -'use client'; + import type { TVerifyInputHandler } from '@shared/ui/Input'; import { useUnit } from 'effector-react'; diff --git a/frontend/src/shared/ui/Dialog/ui/Dialog.tsx b/frontend/src/shared/ui/Dialog/ui/Dialog.tsx index e442e1ee..21b6de29 100644 --- a/frontend/src/shared/ui/Dialog/ui/Dialog.tsx +++ b/frontend/src/shared/ui/Dialog/ui/Dialog.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { TDialogProps } from '../types/TDialogProps'; import clsx from 'clsx'; diff --git a/frontend/src/shared/ui/Input/InputVerificationCode/ui/InputVerificationCode.tsx b/frontend/src/shared/ui/Input/InputVerificationCode/ui/InputVerificationCode.tsx index d4d3ff45..0f054c73 100644 --- a/frontend/src/shared/ui/Input/InputVerificationCode/ui/InputVerificationCode.tsx +++ b/frontend/src/shared/ui/Input/InputVerificationCode/ui/InputVerificationCode.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { ChangeEvent, ClipboardEvent, KeyboardEvent, MouseEvent } from 'react'; diff --git a/frontend/src/widgets/ToastNotification/ui/Toast.tsx b/frontend/src/widgets/ToastNotification/ui/Toast.tsx index ba6d7bea..2a0ca4a6 100644 --- a/frontend/src/widgets/ToastNotification/ui/Toast.tsx +++ b/frontend/src/widgets/ToastNotification/ui/Toast.tsx @@ -1,4 +1,4 @@ -'use client'; + import type { JSX, MouseEvent } from 'react'; import type { TNotificationKind, TToastProps } from '../types/TToastProps'; diff --git a/packages/eslint/src/index.ts b/packages/eslint/src/index.ts index bf042445..c6758c5c 100644 --- a/packages/eslint/src/index.ts +++ b/packages/eslint/src/index.ts @@ -33,12 +33,14 @@ export const overridesStylisticConfig: Exclude; /** diff --git a/packages/ui/uikit/flippo/components/.storybook/main.ts b/packages/ui/uikit/flippo/components/.storybook/main.ts index 67e34052..25000f7f 100644 --- a/packages/ui/uikit/flippo/components/.storybook/main.ts +++ b/packages/ui/uikit/flippo/components/.storybook/main.ts @@ -1,16 +1,20 @@ import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - addons: [ - '@storybook/addon-onboarding', - '@storybook/addon-essentials', - '@chromatic-com/storybook', - '@storybook/addon-interactions' - ], + addons: ['@storybook/addon-onboarding', '@storybook/addon-essentials', '@chromatic-com/storybook', '@storybook/addon-interactions'], framework: { name: '@storybook/react-vite', options: {} }, - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'] + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + typescript: { + check: false, + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true) + } + } }; + export default config; diff --git a/packages/ui/uikit/flippo/components/src/components/Dialog/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/Dialog/index.parts.ts index bfedba89..cd13e1e0 100644 --- a/packages/ui/uikit/flippo/components/src/components/Dialog/index.parts.ts +++ b/packages/ui/uikit/flippo/components/src/components/Dialog/index.parts.ts @@ -1,3 +1,5 @@ +import { Dialog as DialogHeadless } from '@flippo-ui/headless-components/dialog'; + export { DialogBackdrop as Backdrop } from './ui/backdrop/DialogBackdrop'; export { DialogClose as Close } from './ui/close/DialogClose'; export { DialogDescription as Description } from './ui/description/DialogDescription'; @@ -6,3 +8,6 @@ export { DialogPortal as Portal } from './ui/portal/DialogPortal'; export { DialogRoot as Root } from './ui/root/DialogRoot'; export { DialogTitle as Title } from './ui/title/DialogTitle'; export { DialogTrigger as Trigger } from './ui/trigger/DialogTrigger'; +export { DialogViewport as Viewport } from './ui/viewport/DialogViewport'; + +export const createHandle = DialogHeadless.createHandle; diff --git a/packages/ui/uikit/flippo/components/src/components/Dialog/ui/viewport/DialogViewport.tsx b/packages/ui/uikit/flippo/components/src/components/Dialog/ui/viewport/DialogViewport.tsx new file mode 100644 index 00000000..f8312315 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Dialog/ui/viewport/DialogViewport.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Dialog as DialogHeadless } from '@flippo-ui/headless-components/dialog'; + +export function DialogViewport(props: DialogViewport.Props) { + return ; +} + +export namespace DialogViewport { + export type Props = DialogHeadless.Title.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/Drawer/index.parts.ts new file mode 100644 index 00000000..0b4b742a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/index.parts.ts @@ -0,0 +1,9 @@ +export { DrawerBackdrop as Backdrop } from './ui/backdrop/DrawerBackdrop'; +export { DrawerClose as Close } from './ui/close/DrawerClose'; +export { DrawerDescription as Description } from './ui/description/DrawerDescription'; +export { DrawerDrag as Drag } from './ui/drag/DrawerDrag'; +export { DrawerPopup as Popup } from './ui/popup/DrawerPopup'; +export { DrawerPortal as Portal } from './ui/portal/DrawerPortal'; +export { DrawerRoot as Root } from './ui/root/DrawerRoot'; +export { DrawerTitle as Title } from './ui/title/DrawerTitle'; +export { DrawerTrigger as Trigger } from './ui/trigger/DrawerTrigger'; diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/index.ts b/packages/ui/uikit/flippo/components/src/components/Drawer/index.ts new file mode 100644 index 00000000..6c1a9361 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/index.ts @@ -0,0 +1 @@ +export * as Drawer from './index.parts'; diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/story/Drawer.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/story/Drawer.stories.tsx new file mode 100644 index 00000000..7e29abd5 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/story/Drawer.stories.tsx @@ -0,0 +1,237 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { Drawer } from '..'; +import { Button } from '../../Button'; + +const meta: Meta = { + title: 'Overlay/Drawer', + component: Drawer.Root +}; + +export default meta; + +type DrawerStory = StoryObj; + +export const Default: DrawerStory = { + render: (args) => ( + + + + + + + + + + {'Drawer Title'} + + + {'This is a drawer component with drag support. You can drag it to close or use the button below.'} + + +
+

+ {'Drawer content goes here. You can add any content you want.'} +

+
+ +
+ + + +
+
+
+
+ ) +}; + +export const FromTop: DrawerStory = { + render: (args) => ( + + + + + + + + + + {'Top Drawer'} + + + {'This drawer opens from the top of the screen.'} + + + + + + + + ) +}; + +export const FromLeft: DrawerStory = { + render: (args) => ( + + + + + + + + + + {'Left Drawer'} + + + {'This drawer opens from the left side of the screen.'} + + + + + + + + ) +}; + +export const FromRight: DrawerStory = { + render: (args) => ( + + + + + + + + + + {'Right Drawer'} + + + {'This drawer opens from the right side of the screen.'} + + + + + + + + ) +}; + +function DrawerWithSnapPoints(args: Drawer.Root.Props) { + const [activeSnapPoint, setActiveSnapPoint] = React.useState(0); + + return ( + setActiveSnapPoint(index)} + > + + + + + + + + + {'Drawer with Snap Points'} + + + {'Drag to see different snap points: 100px, 300px, 500px, or closed.'} + +
+

+ {`Current snap point index: ${activeSnapPoint}`} +

+
+
+
+
+ ); +} + +function DrawerWithSnapPointsTop(args: Drawer.Root.Props) { + const [activeSnapPoint, setActiveSnapPoint] = React.useState(0); + + return ( + setActiveSnapPoint(index)} + > + + + + + + + + {'Drawer with Snap Points'} + + + {'Drag to see different snap points: 100px, 300px, 500px, or closed.'} + +
+

+ {`Current snap point index: ${activeSnapPoint}`} +

+
+ +
+
+
+ ); +} + +export const WithSnapPoints: DrawerStory = { + render: (args) => +}; + +export const WithSnapPointsTop: DrawerStory = { + render: (args) => +}; diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.module.scss b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.module.scss new file mode 100644 index 00000000..1793dfb2 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.module.scss @@ -0,0 +1,85 @@ +@use 'mixins/_common.scss' as common; + +.DrawerBackdrop { + @include common.reset-appearance(); + + --backdrop-color: var(--f-color-bg-0); + + position: fixed; + inset: 0; + background-color: var(--backdrop-color); + opacity: 0.2; + transition: + opacity 500ms cubic-bezier(0.45, 1.005, 0, 1.005), + transform 500ms cubic-bezier(0.45, 1.005, 0, 1.005); + + @media (prefers-color-scheme: dark) { + opacity: 0.7; + } + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 300px; + } + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } + + // Down direction - gradient fades from top + &[data-swipe-direction-down] { + &::before { + transform: translateX(-100%); + background: linear-gradient(to bottom, var(--backdrop-color), transparent); + } + + &[data-ending-style], + &[data-starting-style] { + transform: translateY(100%); + } + } + + // Up direction - gradient fades from bottom + &[data-swipe-direction-up] { + &::before { + transform: translateY(100%); + background: linear-gradient(to top, var(--backdrop-color), transparent); + } + + &[data-ending-style], + &[data-starting-style] { + transform: translateY(-100%); + } + } + + // Right direction - gradient fades from left + &[data-swipe-direction-right] { + &::before { + transform: translateX(-100%); + background: linear-gradient(to left, var(--backdrop-color), transparent); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateX(100%); + } + } + + // Left direction - gradient fades from right + &[data-swipe-direction-left] { + &::before { + transform: translateX(100%); + background: linear-gradient(to right, var(--backdrop-color), transparent); + } + + &[data-starting-style], + &[data-ending-style] { + transform: translateX(-100%); + } + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.tsx new file mode 100644 index 00000000..f84b7812 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/backdrop/DrawerBackdrop.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; +import { cx } from 'class-variance-authority'; + +import styles from './DrawerBackdrop.module.scss'; + +export function DrawerBackdrop(props: DrawerBackdrop.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace DrawerBackdrop { + export type Props = DrawerHeadless.Backdrop.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/close/DrawerClose.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/close/DrawerClose.tsx new file mode 100644 index 00000000..576c28ff --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/close/DrawerClose.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; + +export function DrawerClose(props: DrawerClose.Props) { + return ; +} + +export namespace DrawerClose { + export type Props = DrawerHeadless.Close.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.module.scss b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.module.scss new file mode 100644 index 00000000..414a07c7 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.module.scss @@ -0,0 +1,12 @@ +@use 'mixins/_common.scss' as common; +@use 'mixins/_font.scss' as font; + +.DrawerDescription { + @include common.reset-appearance(); + @include font.body(default); + + color: var(--f-color-text-2); + margin-bottom: var(--f-spacing-5); +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.tsx new file mode 100644 index 00000000..5d87ea44 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/description/DrawerDescription.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; +import { cx } from 'class-variance-authority'; + +import styles from './DrawerDescription.module.scss'; + +export function DrawerDescription(props: DrawerDescription.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace DrawerDescription { + export type Props = DrawerHeadless.Description.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.module.scss b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.module.scss new file mode 100644 index 00000000..b1687243 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.module.scss @@ -0,0 +1,24 @@ +@use 'mixins/_common.scss' as common; + +.DrawerDrag { + @include common.reset-appearance(); + + width: 48px; + height: 4px; + margin: 0 auto var(--f-spacing-4); + background-color: var(--f-color-border-2); + border-radius: 2px; + cursor: grab; + transition: background-color 150ms; + + &:hover { + background-color: var(--f-color-border-1); + } + + &[data-dragging] { + cursor: grabbing; + background-color: var(--f-color-border-1); + } +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.tsx new file mode 100644 index 00000000..6744a717 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/drag/DrawerDrag.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; +import { cx } from 'class-variance-authority'; + +import styles from './DrawerDrag.module.scss'; + +export function DrawerDrag(props: DrawerDrag.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace DrawerDrag { + export type Props = DrawerHeadless.Drag.Props; +} + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.module.scss b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.module.scss new file mode 100644 index 00000000..6fae5894 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.module.scss @@ -0,0 +1,101 @@ +@use 'mixins/_common.scss' as common; +@use 'mixins/_effect.scss' as effect; + +.DrawerPopup { + @include common.reset-appearance(); + @include effect.elevation-modal(); + + position: fixed; + padding: var(--f-spacing-8); + border-radius: var(--f-spacing-6); + background-color: var(--f-color-bg-1); + color: var(--f-color-text-primary); + transition: transform 500ms cubic-bezier(0.32, 0.72, 0, 1); + + // Direction-specific positioning + &[data-swipe-direction-down] { + bottom: 0; + left: 0; + right: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + transform: translateY(var(--swipe-movement, 0px)); + } + + &[data-swipe-direction-up] { + top: 0; + left: 0; + right: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + + transform: translateY(var(--swipe-movement, 0px)); + } + + &[data-swipe-direction-left] { + left: 0; + top: 0; + bottom: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + transform: translateX(var(--swipe-movement, 0px)); + } + + &[data-swipe-direction-right] { + right: 0; + top: 0; + bottom: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + transform: translateX(var(--swipe-movement, 0px)); + } + + &[data-snap-points][data-swipe-direction-down], + &[data-snap-points][data-swipe-direction-up] { + height: var(--snap-point-max); + } + + &[data-snap-points][data-swipe-direction-left], + &[data-snap-points][data-swipe-direction-right] { + width: var(--snap-point-max); + } + + &[data-snap-points][data-swipe-direction-down] { + transform: translateY(calc(var(--snap-point-transform, 100%) + var(--swipe-movement, 0px))); + } + &[data-snap-points][data-swipe-direction-up] { + transform: translateY(calc(var(--snap-point-transform, -100%) + var(--swipe-movement, 0px))); + } + + &[data-snap-points][data-swipe-direction-left] { + transform: translateX(calc(var(--snap-point-transform, -100%) + var(--swipe-movement, 0px))); + } + + &[data-snap-points][data-swipe-direction-right] { + transform: translateX(calc(var(--snap-point-transform, 100%) + var(--swipe-movement, 0px))); + } + + // Closed state transforms + &[data-swipe-direction-down][data-ending-style], + &[data-swipe-direction-down][data-starting-style] { + transform: translateY(100%); + } + + &[data-swipe-direction-up][data-ending-style], + &[data-swipe-direction-up][data-starting-style] { + transform: translateY(-100%); + } + + &[data-swipe-direction-left][data-ending-style], + &[data-swipe-direction-left][data-starting-style] { + transform: translateX(-100%); + } + + &[data-swipe-direction-right][data-ending-style], + &[data-swipe-direction-right][data-starting-style] { + transform: translateX(100%); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.tsx new file mode 100644 index 00000000..7939d24a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/popup/DrawerPopup.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; +import { cx } from 'class-variance-authority'; + +import styles from './DrawerPopup.module.scss'; + +export function DrawerPopup(props: DrawerPopup.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace DrawerPopup { + export type Props = DrawerHeadless.Popup.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/portal/DrawerPortal.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/portal/DrawerPortal.tsx new file mode 100644 index 00000000..1f7f00c4 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/portal/DrawerPortal.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; + +export function DrawerPortal(props: DrawerPortal.Props) { + return ; +} + +export namespace DrawerPortal { + export type Props = DrawerHeadless.Portal.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/root/DrawerRoot.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/root/DrawerRoot.tsx new file mode 100644 index 00000000..9a8657ef --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/root/DrawerRoot.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; + +export function DrawerRoot(props: DrawerRoot.Props) { + return ; +} + +export namespace DrawerRoot { + export type Props = DrawerHeadless.Root.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.module.scss b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.module.scss new file mode 100644 index 00000000..846c16d0 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.module.scss @@ -0,0 +1,13 @@ +@use 'mixins/_common.scss' as common; +@use 'mixins/_font.scss' as font; + +.DrawerTitle { + @include common.reset-appearance(); + @include font.title-3(default); + + color: var(--f-color-text-primary); + margin: 0; + margin-bottom: var(--f-spacing-6); +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.tsx new file mode 100644 index 00000000..8c2e972c --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/title/DrawerTitle.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; +import { cx } from 'class-variance-authority'; + +import styles from './DrawerTitle.module.scss'; + +export function DrawerTitle(props: DrawerTitle.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace DrawerTitle { + export type Props = DrawerHeadless.Title.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Drawer/ui/trigger/DrawerTrigger.tsx b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/trigger/DrawerTrigger.tsx new file mode 100644 index 00000000..7ffce666 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/Drawer/ui/trigger/DrawerTrigger.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { Drawer as DrawerHeadless } from '@flippo-ui/headless-components/drawer'; + +export function DrawerTrigger(props: DrawerTrigger.Props) { + return ; +} + +export namespace DrawerTrigger { + export type Props = DrawerHeadless.Trigger.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/Form/story/Form.stories.tsx b/packages/ui/uikit/flippo/components/src/components/Form/story/Form.stories.tsx index 808c678a..06b233d8 100644 --- a/packages/ui/uikit/flippo/components/src/components/Form/story/Form.stories.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Form/story/Form.stories.tsx @@ -24,10 +24,10 @@ export const Default: FormStory = { render: (args) => (
- - {'Name'} - + + {'Name'} + diff --git a/packages/ui/uikit/flippo/components/src/components/Input/ui/clear/InputClear.tsx b/packages/ui/uikit/flippo/components/src/components/Input/ui/clear/InputClear.tsx index befaa4c6..743a3470 100644 --- a/packages/ui/uikit/flippo/components/src/components/Input/ui/clear/InputClear.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Input/ui/clear/InputClear.tsx @@ -18,7 +18,7 @@ export function InputClear(props: InputClear.Props) { const { value, state, setValue } = InputHeadless.useInputControl(); const onClick = React.useCallback>((event) => { - setValue('', event as unknown as Event); + setValue('', { event }); onClickProp?.(event); }, [onClickProp, setValue]); diff --git a/packages/ui/uikit/flippo/components/src/components/Input/ui/slot/InputSlot.tsx b/packages/ui/uikit/flippo/components/src/components/Input/ui/slot/InputSlot.tsx index d4191865..72fc7781 100644 --- a/packages/ui/uikit/flippo/components/src/components/Input/ui/slot/InputSlot.tsx +++ b/packages/ui/uikit/flippo/components/src/components/Input/ui/slot/InputSlot.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Input as InputHeadless } from '@flippo-ui/headless-components'; +import { Input as InputHeadless } from '@flippo-ui/headless-components/input'; import { cx } from 'class-variance-authority'; import styles from './InputSlot.module.scss'; diff --git a/packages/ui/uikit/flippo/components/src/components/List/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/List/index.parts.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/flippo/components/src/components/List/index.ts b/packages/ui/uikit/flippo/components/src/components/List/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/QrCode/index.parts.ts new file mode 100644 index 00000000..8c8b092f --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/index.parts.ts @@ -0,0 +1,10 @@ +import { QrCode } from '@flippo-ui/headless-components/qr-code'; + +export { QrCodeBorder as Border } from './ui/border/QrCodeBorder'; +export { QrCodeClipboardTrigger as ClipboardTrigger } from './ui/clipboard-trigger/QrCodeClipboardTrigger'; +export { QrCodeDownloadTrigger as DownloadTrigger } from './ui/download-trigger/QrCodeDownloadTrigger'; +export { QrCodeFrame as Frame } from './ui/frame/QrCodeFrame'; +export { QrCodeOverlay as Overlay } from './ui/overlay/QrCodeOverlay'; +export { QrCodeRoot as Root } from './ui/root/QrCodeRoot'; + +export const GenerateTrigger = QrCode.GenerateTrigger; diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/index.ts b/packages/ui/uikit/flippo/components/src/components/QrCode/index.ts new file mode 100644 index 00000000..2a4a1a6a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/index.ts @@ -0,0 +1 @@ +export * as QrCode from './index.parts'; diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/story/QrCode.stories.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/story/QrCode.stories.tsx new file mode 100644 index 00000000..7120a175 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/story/QrCode.stories.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { SpinnerIcon } from '@flippo-ui/icons'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { QrCode } from '..'; +import { Button } from '../../Button'; + +const meta: Meta = { + title: 'Widgets/QrCode', + component: QrCode.Root +}; + +export default meta; + +type QrCodeStory = StoryObj; + +export const Default: QrCodeStory = { + render: (args) => ( + { return new Promise((resolve) => setTimeout(resolve, 5000)); }} size={200} value={'https://www.google.com'} options={{ + type: 'svg', + dotsOptions: { + color: 'var(--f-color-text-2)', + type: 'extra-rounded' + }, + backgroundOptions: { + color: 'transparent' + }, + cornersSquareOptions: { + color: 'var(--f-color-text-2)', + type: 'extra-rounded' + }, + cornersDotOptions: { + color: 'var(--f-color-text-2)', + type: 'extra-rounded' + } + }} + > + + + + {(props) => { + const { status, error } = props; + + if (status === 'loading') { + return ; + } + + if (error) { + return error.toString(); + } + + return null; + }} + + + + ) +}; diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.module.scss b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.module.scss new file mode 100644 index 00000000..7a7372c6 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.module.scss @@ -0,0 +1,152 @@ +@use 'mixins/_common.scss' as common; + +.QrCodeBorder { + @include common.reset-appearance(); + + position: absolute; + inset: 0; + pointer-events: none; + width: 100%; + height: 100%; + + overflow: hidden; + color: var(--f-color-text-6); + + --f-border-color: var(--border-color, var(--f-color-text-6)); +} + +.TopLeftCorner { + width: var(--corner-length); + height: var(--corner-length); + border-radius: var(--radius-top-left) 0 0 0; + border-top: var(--stroke-width) solid var(--f-border-color); + border-left: var(--stroke-width) solid var(--f-border-color); + + position: absolute; + top: 0; + left: 0; + + &::before { + content: ''; + position: absolute; + top: calc(var(--stroke-width) * -1); + right: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-bottom-right-radius: var(--radius-top-left); + background-color: var(--f-border-color); + } + + &::after { + content: ''; + position: absolute; + bottom: calc(var(--stroke-width) * -1); + left: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-bottom-right-radius: var(--radius-top-right); + background-color: var(--f-border-color); + } +} + +.TopRightCorner { + width: var(--corner-length); + height: var(--corner-length); + border-radius: 0 var(--radius-top-right) 0 0; + border-top: var(--stroke-width) solid var(--f-border-color); + border-right: var(--stroke-width) solid var(--f-border-color); + + position: absolute; + top: 0; + right: 0; + + &::before { + content: ''; + position: absolute; + top: calc(var(--stroke-width) * -1); + left: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-bottom-left-radius: var(--radius-top-right); + background-color: var(--f-border-color); + } + + &::after { + content: ''; + position: absolute; + bottom: calc(var(--stroke-width) * -1); + right: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-bottom-left-radius: var(--radius-top-right); + background-color: var(--f-border-color); + } +} + +.BottomLeftCorner { + width: var(--corner-length); + height: var(--corner-length); + border-radius: 0 0 0 var(--radius-bottom-left); + border-bottom: var(--stroke-width) solid var(--f-border-color); + border-left: var(--stroke-width) solid var(--f-border-color); + + position: absolute; + bottom: 0; + left: 0; + + &::before { + content: ''; + position: absolute; + bottom: calc(var(--stroke-width) * -1); + right: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-top-right-radius: var(--radius-top-left); + background-color: var(--f-border-color); + } + + &::after { + content: ''; + position: absolute; + top: calc(var(--stroke-width) * -1); + left: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-top-right-radius: var(--radius-top-right); + background-color: var(--f-border-color); + } +} + +.BottomRightCorner { + width: var(--corner-length); + height: var(--corner-length); + border-radius: 0 0 var(--radius-bottom-right) 0; + border-bottom: var(--stroke-width) solid var(--f-border-color); + border-right: var(--stroke-width) solid var(--f-border-color); + + position: absolute; + bottom: 0; + right: 0; + + &::before { + content: ''; + position: absolute; + bottom: calc(var(--stroke-width) * -1); + left: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-top-left-radius: var(--radius-top-left); + background-color: var(--f-border-color); + } + + &::after { + content: ''; + position: absolute; + top: calc(var(--stroke-width) * -1); + right: calc(var(--stroke-width) * -1); + width: var(--stroke-width); + height: var(--stroke-width); + border-top-left-radius: var(--radius-top-right); + background-color: var(--f-border-color); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.tsx new file mode 100644 index 00000000..26e7891c --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/border/QrCodeBorder.tsx @@ -0,0 +1,102 @@ +import React from 'react'; + +import { cx } from 'class-variance-authority'; + +import styles from './QrCodeBorder.module.scss'; + +export function QrCodeBorder(props: QrCodeBorder.Props) { + const { + cornerLength, + strokeWidth = 2, + className + } = props; + + const borderRef = React.useRef(null); + + const updateRadius = React.useCallback(() => { + const border = borderRef.current; + if (!border) + return; + + const parent = border.parentElement; + if (!parent) + return; + + const parentStyle = getComputedStyle(parent); + + const borderRadiusTopLeftPx = parentStyle.borderTopLeftRadius; + const borderRadiusTopRightPx = parentStyle.borderTopRightRadius; + const borderRadiusBottomLeftPx = parentStyle.borderBottomLeftRadius; + const borderRadiusBottomRightPx = parentStyle.borderBottomRightRadius; + + const parentWidth = parent.clientWidth; + const parentHeight = parent.clientHeight; + + const preparedCornerLength = cornerLength ?? Math.min(parentWidth, parentHeight) * 0.2; + + border.style.setProperty('--radius-top-left', borderRadiusTopLeftPx); + border.style.setProperty('--radius-top-right', borderRadiusTopRightPx); + border.style.setProperty('--radius-bottom-left', borderRadiusBottomLeftPx); + border.style.setProperty('--radius-bottom-right', borderRadiusBottomRightPx); + + border.style.setProperty('--stroke-width', `${strokeWidth}px`); + border.style.setProperty('--corner-length', `${preparedCornerLength}px`); + }, [strokeWidth, cornerLength]); + + React.useLayoutEffect(() => { + updateRadius(); + + const border = borderRef.current; + if (!border?.parentElement) + return; + + // ResizeObserver для отслеживания изменений размера родителя + const resizeObserver = new ResizeObserver(updateRadius); + resizeObserver.observe(border.parentElement); + + // MutationObserver для отслеживания изменений стилей родителя + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' + && (mutation.attributeName === 'style' || mutation.attributeName === 'class')) { + updateRadius(); + } + } + }); + + mutationObserver.observe(border.parentElement, { + attributes: true, + attributeFilter: ['style', 'class'] + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [updateRadius]); + + return ( +
+
+
+
+
+
+ ); +} + +export namespace QrCodeBorder { + export type Props = { + /** + * Length of the corner lines in viewBox units + * @default 20 + */ + cornerLength?: number; + /** + * Width of the stroke + * @default 2 + */ + strokeWidth?: number; + + } & React.ComponentProps<'svg'>; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/clipboard-trigger/QrCodeClipboardTrigger.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/clipboard-trigger/QrCodeClipboardTrigger.tsx new file mode 100644 index 00000000..6e4ec2d2 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/clipboard-trigger/QrCodeClipboardTrigger.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; + +export function QrCodeClipboardTrigger(props: QrCodeClipboardTrigger.Props) { + return ; +} + +export namespace QrCodeClipboardTrigger { + export type Props = QrCodeHeadless.ClipboardTrigger.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/download-trigger/QrCodeDownloadTrigger.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/download-trigger/QrCodeDownloadTrigger.tsx new file mode 100644 index 00000000..132517ba --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/download-trigger/QrCodeDownloadTrigger.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; + +export function QrCodeDownloadTrigger(props: QrCodeDownloadTrigger.Props) { + return ; +} + +export namespace QrCodeDownloadTrigger { + export type Props = QrCodeHeadless.DownloadTrigger.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.module.scss b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.module.scss new file mode 100644 index 00000000..fd47d8ce --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.module.scss @@ -0,0 +1,16 @@ +@use 'mixins/_common.scss' as common; + +.QrCodeFrame { + @include common.reset-appearance(); + + position: relative; + + flex: 0 0 auto; + + width: var(--qr-code-width); + height: var(--qr-code-height); + + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.tsx new file mode 100644 index 00000000..2e956254 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/frame/QrCodeFrame.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; +import { cx } from 'class-variance-authority'; + +import styles from './QrCodeFrame.module.scss'; + +export function QrCodeFrame(props: QrCodeFrame.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace QrCodeFrame { + export type Props = QrCodeHeadless.Frame.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/generate-trigger/QrCodeGenerateTrigger.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/generate-trigger/QrCodeGenerateTrigger.tsx new file mode 100644 index 00000000..3bf90a9f --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/generate-trigger/QrCodeGenerateTrigger.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; + +export function QrCodeGenerateTrigger(props: QrCodeGenerateTrigger.Props) { + return ; +} + +export namespace QrCodeGenerateTrigger { + export type Props = QrCodeHeadless.GenerateTrigger.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.module.scss b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.module.scss new file mode 100644 index 00000000..400124c4 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.module.scss @@ -0,0 +1,45 @@ +@use 'mixins/_common.scss' as common; + +.QrCodeOverlay { + @include common.reset-appearance(); + + display: flex; + align-items: center; + justify-content: center; + + position: absolute; + inset: 0; + + width: var(--qr-code-width); + height: var(--qr-code-height); + + border-radius: var(--f-spacing-4); + + transition: + transform 1500ms, + opacity 1500ms, + background-color 1500ms; + + &[data-status-loading], + &[data-status-error], + &[data-status-generated] { + backdrop-filter: blur(10px); + + box-shadow: + inset -0.75px -0.5px rgba(255, 255, 255, 0.1), + inset 0.75px 0.5px rgba(255, 255, 255, 0.025), + 3px 2px 10px rgba(0, 0, 0, 0.25), + inset 0px 0px 10px 5px rgba(255, 255, 255, 0.025), + inset 0px 0px 40px 5px rgba(255, 255, 255, 0.025); + } + + &[data-status-error] { + color: var(--f-color-error); + } + + &[data-starting], + &[data-ending] { + opacity: 0; + transform: scale(0.9); + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.tsx new file mode 100644 index 00000000..e2f6dc2a --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/overlay/QrCodeOverlay.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; +import { cx } from 'class-variance-authority'; + +import styles from './QrCodeOverlay.module.scss'; + +export function QrCodeOverlay(props: QrCodeOverlay.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace QrCodeOverlay { + export type Props = QrCodeHeadless.Overlay.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.module.scss b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.module.scss new file mode 100644 index 00000000..3a213b3b --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.module.scss @@ -0,0 +1,15 @@ +@use 'mixins/_common.scss' as common; + +.QrCodeRoot { + @include common.reset-appearance(); + + position: relative; + padding: var(--f-spacing-8); + + display: flex; + align-items: center; + justify-content: center; + + border-radius: var(--f-spacing-4); + background-color: var(--f-color-bg-2-hover); +} diff --git a/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.tsx b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.tsx new file mode 100644 index 00000000..78e12bb7 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/QrCode/ui/root/QrCodeRoot.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { QrCode as QrCodeHeadless } from '@flippo-ui/headless-components/qr-code'; +import { cx } from 'class-variance-authority'; + +import styles from './QrCodeRoot.module.scss'; + +export function QrCodeRoot(props: QrCodeRoot.Props) { + const { + className, + size = 200, + options, + ...otherProps + } = props; + + return ( + + ); +} + +export namespace QrCodeRoot { + export type Props = QrCodeHeadless.Root.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.parts.ts b/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.parts.ts new file mode 100644 index 00000000..68930cb8 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.parts.ts @@ -0,0 +1,6 @@ +export { ScrollAreaContent as Content } from './ui/content/ScrollAreaContent'; +export { ScrollAreaCorner as Corner } from './ui/corner/ScrollAreaCorner'; +export { ScrollAreaRoot as Root } from './ui/root/ScrollAreaRoot'; +export { ScrollAreaScrollbar as Scrollbar } from './ui/scrollbar/ScrollAreaScrollbar'; +export { ScrollAreaThumb as Thumb } from './ui/thumb/ScrollAreaThumb'; +export { ScrollAreaViewport as Viewport } from './ui/viewport/ScrollAreaViewport'; diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.ts b/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.ts new file mode 100644 index 00000000..0b92c3a7 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/index.ts @@ -0,0 +1 @@ +export * as ScrollArea from './index.parts'; diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/story/ScrollArea.stories.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/story/ScrollArea.stories.tsx new file mode 100644 index 00000000..ee0c1772 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/story/ScrollArea.stories.tsx @@ -0,0 +1,102 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { ScrollArea } from '..'; + +const meta: Meta = { + title: 'Layout/ScrollArea', + component: ScrollArea.Root +}; + +export default meta; + +type ScrollAreaStory = StoryObj; + +export const Vertical: ScrollAreaStory = { + render: (args) => ( + + + +
+ {Array.from({ length: 20 }, (_, i) => ( +

+ {'Item '} + {i + 1} + {': Lorem ipsum dolor sit amet consectetur.'} +

+ ))} +
+
+
+ + + +
+ ) +}; + +export const Horizontal: ScrollAreaStory = { + render: (args) => ( + + + +
+ {Array.from({ length: 20 }, (_, i) => ( +
+ {i + 1} +
+ ))} +
+
+
+ + + +
+ ) +}; + +export const Both: ScrollAreaStory = { + render: (args) => ( + + + +
+ {Array.from({ length: 20 }, (_, i) => ( +

+ {'Item '} + {i + 1} + {': Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptates.'} +

+ ))} +
+
+
+ + + + + + + +
+ ) +}; diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.module.scss new file mode 100644 index 00000000..2a93c91e --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.module.scss @@ -0,0 +1,12 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaContent { + @include common.reset-appearance(); + + display: flex; + flex-direction: column; + gap: var(--f-spacing-4); + padding-block: var(--f-spacing-3); + padding-left: var(--f-spacing-4); + padding-right: var(--f-spacing-6); +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.tsx new file mode 100644 index 00000000..8109f7ee --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/content/ScrollAreaContent.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaContent.module.scss'; + +export function ScrollAreaContent(props: ScrollAreaContent.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaContent { + export type Props = ScrollAreaHeadless.Content.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.module.scss new file mode 100644 index 00000000..295a2df9 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.module.scss @@ -0,0 +1,9 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaCorner { + @include common.reset-appearance(); + + background-color: transparent; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.tsx new file mode 100644 index 00000000..67634ef4 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/corner/ScrollAreaCorner.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaCorner.module.scss'; + +export function ScrollAreaCorner(props: ScrollAreaCorner.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaCorner { + export type Props = ScrollAreaHeadless.Corner.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.module.scss new file mode 100644 index 00000000..4375b4cb --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.module.scss @@ -0,0 +1,7 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaRoot { + @include common.reset-appearance(); + + max-width: calc(100vw - 8rem); +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.tsx new file mode 100644 index 00000000..7c6438c1 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/root/ScrollAreaRoot.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaRoot.module.scss'; + +export function ScrollAreaRoot(props: ScrollAreaRoot.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaRoot { + export type Props = ScrollAreaHeadless.Root.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.module.scss new file mode 100644 index 00000000..0c22e036 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.module.scss @@ -0,0 +1,32 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaScrollbar { + @include common.reset-appearance(); + + display: flex; + justify-content: center; + background-color: var(--color-gray-200); + width: 0.25rem; + border-radius: 0.375rem; + margin: 0.5rem; + opacity: 0; + transition: opacity 150ms; + pointer-events: none; + + &[data-scrolling] { + transition-duration: 0ms; + } + + &[data-hovering], + &[data-scrolling] { + opacity: 1; + pointer-events: auto; + } + + &::before { + content: ''; + position: absolute; + width: 1.25rem; + height: 100%; + } +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.tsx new file mode 100644 index 00000000..2103e476 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/scrollbar/ScrollAreaScrollbar.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaScrollbar.module.scss'; + +export function ScrollAreaScrollbar(props: ScrollAreaScrollbar.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaScrollbar { + export type Props = ScrollAreaHeadless.Scrollbar.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.module.scss new file mode 100644 index 00000000..55d056e5 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.module.scss @@ -0,0 +1,9 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaThumb { + @include common.reset-appearance(); + + width: 100%; + border-radius: inherit; + background-color: var(--f-color-neutral-40); +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.tsx new file mode 100644 index 00000000..d9c2ba00 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/thumb/ScrollAreaThumb.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaThumb.module.scss'; + +export function ScrollAreaThumb(props: ScrollAreaThumb.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaThumb { + export type Props = ScrollAreaHeadless.Thumb.Props; +} + + diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.module.scss b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.module.scss new file mode 100644 index 00000000..d38a84b4 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.module.scss @@ -0,0 +1,8 @@ +@use 'mixins/_common.scss' as common; + +.ScrollAreaViewport { + @include common.reset-appearance(); + + height: 100%; + overscroll-behavior: contain; +} diff --git a/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.tsx b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.tsx new file mode 100644 index 00000000..4142b050 --- /dev/null +++ b/packages/ui/uikit/flippo/components/src/components/ScrollArea/ui/viewport/ScrollAreaViewport.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { ScrollArea as ScrollAreaHeadless } from '@flippo-ui/headless-components/scroll-area'; +import { cx } from 'class-variance-authority'; + +import styles from './ScrollAreaViewport.module.scss'; + +export function ScrollAreaViewport(props: ScrollAreaViewport.Props) { + const { className, ...otherProps } = props; + + return ; +} + +export namespace ScrollAreaViewport { + export type Props = ScrollAreaHeadless.Viewport.Props; +} diff --git a/packages/ui/uikit/flippo/components/src/index.ts b/packages/ui/uikit/flippo/components/src/index.ts index b5790129..26374efd 100644 --- a/packages/ui/uikit/flippo/components/src/index.ts +++ b/packages/ui/uikit/flippo/components/src/index.ts @@ -21,6 +21,7 @@ export * as Popover from './components/Popover'; 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 Select from './components/Select'; export * as Separator from './components/Separator'; export * as Slider from './components/Slider'; diff --git a/packages/ui/uikit/headless/components/eslint.config.js b/packages/ui/uikit/headless/components/eslint.config.js index ea294a0a..56db2b2c 100644 --- a/packages/ui/uikit/headless/components/eslint.config.js +++ b/packages/ui/uikit/headless/components/eslint.config.js @@ -24,7 +24,8 @@ export default createEslintConfig( format: ['camelCase', 'UPPER_CASE', 'PascalCase'], leadingUnderscore: 'allow', trailingUnderscore: 'allow' - }] + }], + 'ts/no-empty-object-type': 'off' } }, stylistic: { @@ -35,7 +36,12 @@ export default createEslintConfig( jsx: true, formatters: true, ...general, - ignores: ['**/*.md/*.ts'] + ignores: [ + '**/*.md/*.ts', + '**/*.md/*.tsx', + '**/*.mdx/*.ts', + '**/*.mdx/*.tsx' + ] }, { rules: { @@ -51,7 +57,7 @@ export default createEslintConfig( type: 'natural', order: 'asc', newlinesBetween: 'always', - internalPattern: ['^~/.+', '^@/.+'], + internalPattern: ['^~/.+', '^@/.+', '^~@.+'], groups: [ 'react', 'builtin', diff --git a/packages/ui/uikit/headless/components/package.json b/packages/ui/uikit/headless/components/package.json index 43d13a87..273f69e6 100644 --- a/packages/ui/uikit/headless/components/package.json +++ b/packages/ui/uikit/headless/components/package.json @@ -25,11 +25,21 @@ "import": "./dist/components/Accordion/index.es.js", "require": "./dist/components/Accordion/index.cjs.js" }, + "./autocomplete": { + "types": "./dist/components/Autocomplete/index.d.ts", + "import": "./dist/components/Autocomplete/index.es.js", + "require": "./dist/components/Autocomplete/index.cjs.js" + }, "./avatar": { "types": "./dist/components/Avatar/index.d.ts", "import": "./dist/components/Avatar/index.es.js", "require": "./dist/components/Avatar/index.cjs.js" }, + "./button": { + "types": "./dist/components/Button/index.d.ts", + "import": "./dist/components/Button/index.es.js", + "require": "./dist/components/Button/index.cjs.js" + }, "./checkbox": { "types": "./dist/components/Checkbox/index.d.ts", "import": "./dist/components/Checkbox/index.es.js", @@ -45,6 +55,11 @@ "import": "./dist/components/Collapsible/index.es.js", "require": "./dist/components/Collapsible/index.cjs.js" }, + "./combobox": { + "types": "./dist/components/Combobox/index.d.ts", + "import": "./dist/components/Combobox/index.es.js", + "require": "./dist/components/Combobox/index.cjs.js" + }, "./composite": { "types": "./dist/components/Composite/index.d.ts", "import": "./dist/components/Composite/index.es.js", @@ -60,6 +75,11 @@ "import": "./dist/components/Dialog/index.es.js", "require": "./dist/components/Dialog/index.cjs.js" }, + "./drawer": { + "types": "./dist/components/Drawer/index.d.ts", + "import": "./dist/components/Drawer/index.es.js", + "require": "./dist/components/Drawer/index.cjs.js" + }, "./field": { "types": "./dist/components/Field/index.d.ts", "import": "./dist/components/Field/index.es.js", @@ -80,6 +100,16 @@ "import": "./dist/components/Input/index.es.js", "require": "./dist/components/Input/index.cjs.js" }, + "./labelable-provider": { + "types": "./dist/components/LabelableProvider/index.d.ts", + "import": "./dist/components/LabelableProvider/index.es.js", + "require": "./dist/components/LabelableProvider/index.cjs.js" + }, + "./list": { + "types": "./dist/components/List/index.d.ts", + "import": "./dist/components/List/index.es.js", + "require": "./dist/components/List/index.cjs.js" + }, "./menu": { "types": "./dist/components/Menu/index.d.ts", "import": "./dist/components/Menu/index.es.js", @@ -95,6 +125,11 @@ "import": "./dist/components/Meter/index.es.js", "require": "./dist/components/Meter/index.cjs.js" }, + "./navigation-menu": { + "types": "./dist/components/NavigationMenu/index.d.ts", + "import": "./dist/components/NavigationMenu/index.es.js", + "require": "./dist/components/NavigationMenu/index.cjs.js" + }, "./number-field": { "types": "./dist/components/NumberField/index.d.ts", "import": "./dist/components/NumberField/index.es.js", @@ -115,6 +150,11 @@ "import": "./dist/components/Progress/index.es.js", "require": "./dist/components/Progress/index.cjs.js" }, + "./qr-code": { + "types": "./dist/components/QrCode/index.d.ts", + "import": "./dist/components/QrCode/index.es.js", + "require": "./dist/components/QrCode/index.cjs.js" + }, "./radio": { "types": "./dist/components/Radio/index.d.ts", "import": "./dist/components/Radio/index.es.js", @@ -125,6 +165,11 @@ "import": "./dist/components/RadioGroup/index.es.js", "require": "./dist/components/RadioGroup/index.cjs.js" }, + "./scroll-area": { + "types": "./dist/components/ScrollArea/index.d.ts", + "import": "./dist/components/ScrollArea/index.es.js", + "require": "./dist/components/ScrollArea/index.cjs.js" + }, "./select": { "types": "./dist/components/Select/index.d.ts", "import": "./dist/components/Select/index.es.js", @@ -145,6 +190,11 @@ "import": "./dist/components/Slot/index.es.js", "require": "./dist/components/Slot/index.cjs.js" }, + "./snippet": { + "types": "./dist/components/Snippet/index.d.ts", + "import": "./dist/components/Snippet/index.es.js", + "require": "./dist/components/Snippet/index.cjs.js" + }, "./switch": { "types": "./dist/components/Switch/index.d.ts", "import": "./dist/components/Switch/index.es.js", @@ -220,16 +270,21 @@ "build": "vite build && node scripts/generate-exports.js", "build:exports": "node scripts/generate-exports.js", "test": "vitest", - "test:ui": "vitest --ui" + "test:ui": "vitest --ui", + "inline-scripts": "tsx ./scripts/inlineScripts.mts" }, "peerDependencies": { - "@flippo-ui/hooks": "workspace:*" + "react": "catalog:", + "react-dom": "catalog:" }, "dependencies": { + "@flippo-ui/hooks": "workspace:*", "@floating-ui/react": "catalog:", "@floating-ui/react-dom": "catalog:", "@floating-ui/utils": "catalog:", "@vitejs/plugin-react": "catalog:", + "qr-code-styling": "catalog:", + "qrcode": "catalog:", "tabbable": "catalog:", "vite": "catalog:" }, @@ -241,6 +296,7 @@ "@testing-library/react": "catalog:", "@testing-library/user-event": "catalog:", "@types/node": "catalog:", + "@types/qrcode": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitest/ui": "catalog:", @@ -249,9 +305,10 @@ "eslint-plugin-react-hooks": "catalog:", "eslint-plugin-react-refresh": "catalog:", "glob": "catalog:", + "globby": "catalog:", "jsdom": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", + "terser": "catalog:", + "tsx": "catalog:", "typescript": "catalog:", "vite-plugin-dts": "catalog:", "vitest": "catalog:" diff --git a/packages/ui/uikit/headless/components/scripts/inlineScripts.mts b/packages/ui/uikit/headless/components/scripts/inlineScripts.mts new file mode 100644 index 00000000..8ff43cc2 --- /dev/null +++ b/packages/ui/uikit/headless/components/scripts/inlineScripts.mts @@ -0,0 +1,48 @@ +import { watch } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { globby } from 'globby'; +import { minify } from 'terser'; + +const currentDirectory = fileURLToPath(new URL('.', import.meta.url)); + +const preamble = ['// This file is autogenerated. Do not edit it directly.', '// To update it, modify the corresponding source file and run `pnpm inline-scripts`.'].join('\n'); + +async function buildFile(sourceFile: string) { + const sourceContent = await readFile(sourceFile, 'utf8'); + const { code: minifiedCode } = await minify(sourceContent, { ecma: 2020 }); + const escapedCode = minifiedCode!.replace(/'/g, '\\\''); + const output = [preamble, '', '// prettier-ignore', `export const script = '${escapedCode}';\n`].join('\n'); + const outputFilename = sourceFile.replace('.template.js', '.min.ts'); + await writeFile(outputFilename, output); +} + +/** + * Finds files with the `.template.js` extension in the `react` package, minifies them, and writes + * the code to a new file with the `.min.ts` extension. + * The minified code is then exported as a string literal. + */ +async function run() { + const files = await globby('**/*.template.js', { + absolute: true, + cwd: path.resolve(currentDirectory, '../packages/react/src') + }); + await Promise.all(files.map(buildFile)); + return files; +} + +const files = await run(); + +if (process.argv.includes('--watch') || process.argv.includes('-w')) { + console.log('Processing *.template.js files in watch mode...'); + + files.forEach((file) => { + watch(file, (eventType) => { + if (eventType === 'change') { + buildFile(file); + } + }); + }); +} diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx index 7110ca6c..5c24d7c6 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx +++ b/packages/ui/uikit/headless/components/src/components/Accordion/header/AccordionHeader.tsx @@ -1,8 +1,6 @@ -'use client'; +import { useRenderElement } from '~@lib/hooks'; -import { useRenderElement } from '@lib/hooks'; - -import type { HeadlessUIComponentProps } from '@lib/types'; +import type { HeadlessUIComponentProps } from '~@lib/types'; import { useAccordionItemContext } from '../item/AccordionItemContext'; import { accordionStyleHookMapping } from '../item/styleHooks'; @@ -15,7 +13,7 @@ import type { AccordionItem } from '../item/AccordionItem'; * * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) */ -export function AccordionHeader(componentProps: AccordionHeader.Props) { +export function AccordionHeader(componentProps: AccordionHeaderProps) { const { /* eslint-disable unused-imports/no-unused-vars */ render, @@ -37,6 +35,8 @@ export function AccordionHeader(componentProps: AccordionHeader.Props) { return element; } +export type AccordionHeaderProps = {} & HeadlessUIComponentProps<'h3', AccordionItem.State>; + export namespace AccordionHeader { - export type Props = HeadlessUIComponentProps<'h3', AccordionItem.State>; + export type Props = AccordionHeaderProps; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/index.ts b/packages/ui/uikit/headless/components/src/components/Accordion/index.ts index bc75beb6..3dd1a866 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/index.ts +++ b/packages/ui/uikit/headless/components/src/components/Accordion/index.ts @@ -1 +1,7 @@ +export type * from './header/AccordionHeader'; + export * as Accordion from './index.parts'; +export type * from './item/AccordionItem'; +export type * from './panel/AccordionPanel'; +export type * from './root/AccordionRoot'; +export type * from './trigger/AccordionTrigger'; diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx index e9e59283..ca0572fe 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItem.tsx @@ -1,13 +1,13 @@ -'use client'; - import React from 'react'; -import { useEventCallback, useMergedRef } from '@flippo-ui/hooks'; +import { useMergedRef } from '@flippo-ui/hooks/use-merged-ref'; +import { useStableCallback } from '@flippo-ui/hooks/use-stable-callback'; -import { EMPTY_OBJECT } from '@lib/constants'; -import { useHeadlessUiId, useRenderElement } from '@lib/hooks'; +import { useHeadlessUiId, useRenderElement } from '~@lib/hooks'; -import type { HeadlessUIComponentProps } from '@lib/types'; +import type { HeadlessUIChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import type { REASONS } from '~@lib/reason'; +import type { HeadlessUIComponentProps } from '~@lib/types'; import { CollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; import { useCollapsibleRoot } from '../../Collapsible/root/useCollapsibleRoot'; @@ -15,13 +15,13 @@ import { useCompositeListItem } from '../../Composite/list/useCompositeListItem' import { useAccordionRootContext } from '../root/AccordionRootContext'; import type { CollapsibleRoot } from '../../Collapsible/root/CollapsibleRoot'; -import type { TCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; +import type { CollapsibleRootContextValue } from '../../Collapsible/root/CollapsibleRootContext'; import type { AccordionRoot } from '../root/AccordionRoot'; import { AccordionItemContext } from './AccordionItemContext'; import { accordionStyleHookMapping } from './styleHooks'; -import type { TAccordionItemContext } from './AccordionItemContext'; +import type { AccordionItemContextValue } from './AccordionItemContext'; /** * Groups an accordion header with the corresponding panel. @@ -29,7 +29,7 @@ import type { TAccordionItemContext } from './AccordionItemContext'; * * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) */ -export function AccordionItem(componentProps: AccordionItem.Props) { +export function AccordionItem(componentProps: AccordionItemProps) { const { /* eslint-disable unused-imports/no-unused-vars */ className, @@ -42,7 +42,7 @@ export function AccordionItem(componentProps: AccordionItem.Props) { ...elementProps } = componentProps; - const { ref: listItemRef, index } = useCompositeListItem(EMPTY_OBJECT); + const { ref: listItemRef, index } = useCompositeListItem(); const mergedRef = useMergedRef(ref, listItemRef); const { @@ -52,7 +52,9 @@ export function AccordionItem(componentProps: AccordionItem.Props) { value: openValues } = useAccordionRootContext(); - const value = valueProp ?? index; + const fallbackValue = useHeadlessUiId(); + + const value = valueProp ?? fallbackValue; const disabled = disabledProp || contextDisabled; @@ -70,10 +72,17 @@ export function AccordionItem(componentProps: AccordionItem.Props) { return false; }, [openValues, value]); - const onOpenChange = useEventCallback((nextOpen: boolean) => { - handleValueChange(value, nextOpen); - onOpenChangeProp?.(nextOpen); - }); + const onOpenChange = useStableCallback( + (nextOpen: boolean, eventDetails: CollapsibleRoot.ChangeEventDetails) => { + onOpenChangeProp?.(nextOpen, eventDetails); + + if (eventDetails.isCanceled) { + return; + } + + handleValueChange(value, nextOpen); + } + ); const collapsible = useCollapsibleRoot({ open: isOpen, @@ -85,17 +94,17 @@ export function AccordionItem(componentProps: AccordionItem.Props) { () => ({ open: collapsible.open, disabled: collapsible.disabled, - hidden: !collapsible.mounted + hidden: !collapsible.mounted, + transitionStatus: collapsible.transitionStatus }), - [collapsible.open, collapsible.disabled, collapsible.mounted] + [collapsible.open, collapsible.disabled, collapsible.mounted, collapsible.transitionStatus] ); - const collapsibleContext: TCollapsibleRootContext = React.useMemo( + const collapsibleContext: CollapsibleRootContextValue = React.useMemo( () => ({ ...collapsible, onOpenChange, - state: collapsibleState, - transitionStatus: collapsible.transitionStatus + state: collapsibleState }), [collapsible, collapsibleState, onOpenChange] ); @@ -107,29 +116,19 @@ export function AccordionItem(componentProps: AccordionItem.Props) { disabled, open: isOpen }), - [ - disabled, - index, - isOpen, - rootState - ] + [disabled, index, isOpen, rootState] ); const [triggerId, setTriggerId] = React.useState(useHeadlessUiId()); - const accordionItemContext: TAccordionItemContext = React.useMemo( + const accordionItemContext: AccordionItemContextValue = React.useMemo( () => ({ open: isOpen, state, setTriggerId, triggerId }), - [ - isOpen, - state, - setTriggerId, - triggerId - ] + [isOpen, state, setTriggerId, triggerId] ); const element = useRenderElement('div', componentProps, { @@ -140,23 +139,48 @@ export function AccordionItem(componentProps: AccordionItem.Props) { }); return ( - - + + {element} - - + + ); } -export type AccordionItemValue = any | null; +export type AccordionItemState = { + index: number; + open: boolean; +} & AccordionRoot.State; + +export type AccordionItemProps = { + /** + * A unique value that identifies this accordion item. + * If no value is provided, a unique ID will be generated automatically. + * Use when controlling the accordion programmatically, or to set an initial + * open state. + * @example + * ```tsx + * + * // initially open + * // initially closed + * + * ``` + */ + value?: any; + /** + * Event handler called when the panel is opened or closed. + */ + onOpenChange?: (open: boolean, eventDetails: AccordionItem.ChangeEventDetails) => void; +} & HeadlessUIComponentProps<'div', AccordionItem.State> & Partial>; + +export type AccordionItemChangeEventReason = typeof REASONS.triggerPress | typeof REASONS.none; + +export type AccordionItemChangeEventDetails + = HeadlessUIChangeEventDetails; export namespace AccordionItem { - export type State = { - index: number; - open: boolean; - } & AccordionRoot.State; - - export type Props = { - value?: AccordionItemValue; - } & HeadlessUIComponentProps<'div', State> & Partial>; + export type State = AccordionItemState; + export type Props = AccordionItemProps; + export type ChangeEventReason = AccordionItemChangeEventReason; + export type ChangeEventDetails = AccordionItemChangeEventDetails; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts index a50cac90..a988db05 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/AccordionItemContext.ts @@ -1,17 +1,15 @@ -'use client'; - import React from 'react'; import type { AccordionItem } from './AccordionItem'; -export type TAccordionItemContext = { +export type AccordionItemContextValue = { open: boolean; state: AccordionItem.State; setTriggerId: (id: string | undefined) => void; triggerId?: string; }; -export const AccordionItemContext = React.createContext( +export const AccordionItemContext = React.createContext( undefined ); @@ -23,5 +21,6 @@ export function useAccordionItemContext() { 'Headless UI: AccordionItemContext is missing. Accordion parts must be placed within .' ); } + return context; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts b/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts index 423cbd76..5a0e4974 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts +++ b/packages/ui/uikit/headless/components/src/components/Accordion/item/styleHooks.ts @@ -1,7 +1,7 @@ -import { collapsibleOpenStateMapping } from '@lib/collapsibleOpenStateMapping'; -import { transitionStatusMapping } from '@lib/styleHookMapping'; +import { collapsibleOpenStateMapping } from '~@lib/collapsibleOpenStateMapping'; +import { transitionStatusMapping } from '~@lib/styleHookMapping'; -import type { CustomStyleHookMapping } from '@lib/getStyleHookProps'; +import type { CustomStyleHookMapping } from '~@lib/getStyleHookProps'; import { AccordionItemDataAttributes } from './AccordionItemDataAttributes'; diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx index 158f481a..6a7c0b3c 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx +++ b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanel.tsx @@ -1,13 +1,13 @@ -'use client'; - import React from 'react'; import { useIsoLayoutEffect, useOpenChangeComplete } from '@flippo-ui/hooks'; -import { useRenderElement } from '@lib/hooks'; -import { warn } from '@lib/warn'; import type { TransitionStatus } from '@flippo-ui/hooks'; -import type { HeadlessUIComponentProps } from '@lib/types'; + +import { useRenderElement } from '~@lib/hooks'; +import { warn } from '~@lib/warn'; + +import type { HeadlessUIComponentProps } from '~@lib/types'; import { useCollapsiblePanel } from '../../Collapsible/panel/useCollapsiblePanel'; import { useCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; @@ -26,7 +26,7 @@ import { AccordionPanelCssVars } from './AccordionPanelCssVars'; * * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) */ -export function AccordionPanel(componentProps: AccordionPanel.Props) { +export function AccordionPanel(componentProps: AccordionPanelProps) { const { /* eslint-disable unused-imports/no-unused-vars */ className, @@ -112,7 +112,7 @@ export function AccordionPanel(componentProps: AccordionPanel.Props) { const { props } = useCollapsiblePanel({ abortControllerRef, animationTypeRef, - externalRef: ref as React.RefObject, + externalRef: ref, height, hiddenUntilFound, id: idProp ?? panelId, @@ -165,10 +165,13 @@ export function AccordionPanel(componentProps: AccordionPanel.Props) { return element; } -export namespace AccordionPanel { - export type State = { - transitionStatus: TransitionStatus; - } & AccordionItem.State; +export type AccordionPanelState = { + transitionStatus: TransitionStatus; +} & AccordionItem.State; + +export type AccordionPanelProps = {} & HeadlessUIComponentProps<'div', AccordionPanel.State> & Pick; - export type Props = HeadlessUIComponentProps<'div', AccordionItem.State> & Pick; +export namespace AccordionPanel { + export type State = AccordionPanelState; + export type Props = AccordionPanelProps; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts index 4cd381cf..46e06b3c 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts +++ b/packages/ui/uikit/headless/components/src/components/Accordion/panel/AccordionPanelDataAttributes.ts @@ -1,4 +1,4 @@ -import { TransitionStatusDataAttributes } from '@lib/styleHookMapping'; +import { TransitionStatusDataAttributes } from '~@lib/styleHookMapping'; export enum AccordionPanelDataAttributes { /** diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx index 982c38b8..dbf9c096 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRoot.tsx @@ -1,31 +1,48 @@ -'use client'; - import React from 'react'; -import { useControlledState, useEventCallback, useIsoLayoutEffect } from '@flippo-ui/hooks'; - -import { useDirection, useRenderElement } from '@lib/hooks'; -import { warn } from '@lib/warn'; - -import type { HeadlessUIComponentProps, Orientation } from '@lib/types'; - +import { + AnimationFrame, + useAnimationFrame, + useControlledState, + useEventCallback, + useIsoLayoutEffect, + useMergedRef, + useOnMount +} from '@flippo-ui/hooks'; +import { useStableCallback } from '@flippo-ui/hooks/use-stable-callback'; + +import { createChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import { useDirection, useRenderElement } from '~@lib/hooks'; +import { REASONS } from '~@lib/reason'; +import { warn } from '~@lib/warn'; + +import type { HeadlessUIChangeEventDetails } from '~@lib/createHeadlessUIEventDetails'; +import type { HeadlessUIComponentProps, HTMLProps, Orientation } from '~@lib/types'; + +import { CollapsiblePanelDataAttributes } from '../../Collapsible/panel/CollapsiblePanelDataAttributes'; import { CompositeList } from '../../Composite/list/CompositeList'; +import type { CollapsibleRoot } from '../../Collapsible/root/CollapsibleRoot'; +import type { AnimationType, Dimensions } from '../../Collapsible/root/useCollapsibleRoot'; + import { AccordionRootContext } from './AccordionRootContext'; +import { AccordionRootDataAttributes } from './AccordionRootDataAttributes'; -import type { TAccordionRootContext } from './AccordionRootContext'; +import type { AccordionRootContextValue } from './AccordionRootContext'; -const rootStyleHookMapping = { +const rootStateAttributesMapping = { value: () => null }; +const EMPTY_ACCORDION_VALUE: AccordionValue = []; + /** * Groups all parts of the accordion. * Renders a `
` element. * * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) */ -export function AccordionRoot(componentProps: AccordionRoot.Props) { +export function AccordionRoot(componentProps: AccordionRootProps) { const { /* eslint-disable unused-imports/no-unused-vars */ render, @@ -34,9 +51,9 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { disabled = false, hiddenUntilFound: hiddenUntilFoundProp, keepMounted: keepMountedProp, - loop = true, + loopFocus = true, onValueChange: onValueChangeProp, - openMultiple = true, + multiple = false, orientation = 'vertical', value: valueProp, defaultValue: defaultValueProp, @@ -64,45 +81,47 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { return defaultValueProp ?? []; } - return []; + return undefined; }, [valueProp, defaultValueProp]); - const onValueChange = useEventCallback(onValueChangeProp); + const onValueChange = useStableCallback(onValueChangeProp); const accordionItemRefs = React.useRef<(HTMLElement | null)[]>([]); const [value, setValue] = useControlledState({ prop: valueProp, - defaultProp: defaultValue, + defaultProp: defaultValue ?? EMPTY_ACCORDION_VALUE, caller: 'Accordion' }); - const handleValueChange = React.useCallback( - (newValue: number | string, nextOpen: boolean) => { - if (!openMultiple) { - const nextValue = value[0] === newValue ? [] : [newValue]; - setValue(nextValue); - onValueChange(nextValue); + const handleValueChange = useStableCallback((newValue: number | string, nextOpen: boolean) => { + const details = createChangeEventDetails(REASONS.none); + if (!multiple) { + const nextValue = value[0] === newValue ? [] : [newValue]; + onValueChange(nextValue, details); + if (details.isCanceled) { + return; } - else if (nextOpen) { - const nextOpenValues = value.slice(); - nextOpenValues.push(newValue); - setValue(nextOpenValues); - onValueChange(nextOpenValues); + setValue(nextValue); + } + else if (nextOpen) { + const nextOpenValues = value.slice(); + nextOpenValues.push(newValue); + onValueChange(nextOpenValues, details); + if (details.isCanceled) { + return; } - else { - const nextOpenValues = value.filter((v) => v !== newValue); - setValue(nextOpenValues); - onValueChange(nextOpenValues); + setValue(nextOpenValues); + } + else { + const nextOpenValues = value.filter((v) => v !== newValue); + onValueChange(nextOpenValues, details); + if (details.isCanceled) { + return; } - }, - [ - onValueChange, - openMultiple, - setValue, - value - ] - ); + setValue(nextOpenValues); + } + }); const state: AccordionRoot.State = React.useMemo( () => ({ @@ -113,7 +132,7 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { [value, disabled, orientation] ); - const contextValue: TAccordionRootContext = React.useMemo( + const contextValue: AccordionRootContextValue = React.useMemo( () => ({ accordionItemRefs, direction, @@ -121,7 +140,7 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { handleValueChange, hiddenUntilFound: hiddenUntilFoundProp ?? false, keepMounted: keepMountedProp ?? false, - loop, + loopFocus, orientation, state, value @@ -132,7 +151,7 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { handleValueChange, hiddenUntilFoundProp, keepMountedProp, - loop, + loopFocus, orientation, state, value @@ -146,81 +165,91 @@ export function AccordionRoot(componentProps: AccordionRoot.Props) { dir: direction, role: 'region' }, elementProps], - customStyleHookMapping: rootStyleHookMapping + customStyleHookMapping: rootStateAttributesMapping }); return ( - + {element} - + ); } export type AccordionValue = (any | null)[]; +export type AccordionRootState = { + value: AccordionValue; + /** + * Whether the component should ignore user interaction. + */ + disabled: boolean; + orientation: Orientation; +}; + +export type AccordionRootProps = { + /** + * The controlled value of the item(s) that should be expanded. + * + * To render an uncontrolled accordion, use the `defaultValue` prop instead. + */ + value?: AccordionValue; + /** + * The uncontrolled value of the item(s) that should be initially expanded. + * + * To render a controlled accordion, use the `value` prop instead. + */ + defaultValue?: AccordionValue; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean; + /** + * Allows the browser’s built-in page search to find and expand the panel contents. + * + * Overrides the `keepMounted` prop and uses `hidden="until-found"` + * to hide the element without removing it from the DOM. + * @default false + */ + hiddenUntilFound?: boolean; + /** + * Whether to keep the element in the DOM while the panel is closed. + * This prop is ignored when `hiddenUntilFound` is used. + * @default false + */ + keepMounted?: boolean; + /** + * Whether to loop keyboard focus back to the first item + * when the end of the list is reached while using the arrow keys. + * @default true + */ + loopFocus?: boolean; + /** + * Event handler called when an accordion item is expanded or collapsed. + * Provides the new value as an argument. + */ + onValueChange?: (value: AccordionValue, eventDetails: AccordionRootChangeEventDetails) => void; + /** + * Whether multiple items can be open at the same time. + * @default true + */ + multiple?: boolean; + /** + * The visual orientation of the accordion. + * Controls whether roving focus uses left/right or up/down arrow keys. + * @default 'vertical' + */ + orientation?: Orientation; +} & HeadlessUIComponentProps<'div', AccordionRoot.State>; + +export type AccordionRootChangeEventReason = typeof REASONS.triggerPress | typeof REASONS.none; + +export type AccordionRootChangeEventDetails + = HeadlessUIChangeEventDetails; + export namespace AccordionRoot { - export type State = { - value: AccordionValue; - /** - * Whether the component should ignore user interaction. - */ - disabled: boolean; - orientation: Orientation; - }; - - export type Props = { - /** - * The controlled value of the item(s) that should be expanded. - * - * To render an uncontrolled accordion, use the `defaultValue` prop instead. - */ - value?: AccordionValue; - /** - * The uncontrolled value of the item(s) that should be initially expanded. - * - * To render a controlled accordion, use the `value` prop instead. - */ - defaultValue?: AccordionValue; - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled?: boolean; - /** - * Allows the browser’s built-in page search to find and expand the panel contents. - * - * Overrides the `keepMounted` prop and uses `hidden="until-found"` - * to hide the element without removing it from the DOM. - * @default false - */ - hiddenUntilFound?: boolean; - /** - * Whether to keep the element in the DOM while the panel is closed. - * This prop is ignored when `hiddenUntilFound` is used. - * @default false - */ - keepMounted?: boolean; - /** - * Whether to loop keyboard focus back to the first item - * when the end of the list is reached while using the arrow keys. - * @default true - */ - loop?: boolean; - /** - * Event handler called when an accordion item is expanded or collapsed. - * Provides the new value as an argument. - */ - onValueChange?: (value: AccordionValue) => void; - /** - * Whether multiple items can be open at the same time. - * @default true - */ - openMultiple?: boolean; - /** - * The visual orientation of the accordion. - * Controls whether roving focus uses left/right or up/down arrow keys. - * @default 'vertical' - */ - orientation?: Orientation; - } & HeadlessUIComponentProps<'div', State>; + export type State = AccordionRootState; + export type Props = AccordionRootProps; + export type ChangeEventReason = AccordionRootChangeEventReason; + export type ChangeEventDetails = AccordionRootChangeEventDetails; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts index dd0af0b2..97a1beb9 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Accordion/root/AccordionRootContext.ts @@ -1,35 +1,34 @@ -'use client'; -import * as React from 'react'; +import React from 'react'; -import type { TTextDirection } from '@lib/hooks'; -import type { Orientation } from '@lib/types'; +import type { TextDirection } from '~@lib/hooks'; +import type { Orientation } from '~@lib/types'; import type { AccordionRoot, AccordionValue } from './AccordionRoot'; -export type TAccordionRootContext = { +export type AccordionRootContextValue = { accordionItemRefs: React.RefObject<(HTMLElement | null)[]>; - direction: TTextDirection; + direction: TextDirection; disabled: boolean; handleValueChange: (newValue: number | string, nextOpen: boolean) => void; hiddenUntilFound: boolean; keepMounted: boolean; - loop: boolean; + loopFocus: boolean; orientation: Orientation; state: AccordionRoot.State; value: AccordionValue; }; -export const AccordionRootContext = React.createContext( +export const AccordionRootContext = React.createContext( undefined ); export function useAccordionRootContext() { const context = React.use(AccordionRootContext); - if (context === undefined) { throw new Error( 'Headless UI: AccordionRootContext is missing. Accordion parts must be placed within .' ); } + return context; } diff --git a/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx b/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx index 6098f05b..d5548f97 100644 --- a/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx +++ b/packages/ui/uikit/headless/components/src/components/Accordion/trigger/AccordionTrigger.tsx @@ -1,14 +1,12 @@ -'use client'; - import React from 'react'; import { useIsoLayoutEffect } from '@flippo-ui/hooks'; -import { triggerOpenStateMapping } from '@lib/collapsibleOpenStateMapping'; -import { useRenderElement } from '@lib/hooks'; -import { isElementDisabled } from '@lib/isElementDisabled'; +import { triggerOpenStateMapping } from '~@lib/collapsibleOpenStateMapping'; +import { useRenderElement } from '~@lib/hooks'; +import { isElementDisabled } from '~@lib/isElementDisabled'; -import type { HeadlessUIComponentProps, NativeButtonProps } from '@lib/types'; +import type { HeadlessUIComponentProps, NativeButtonProps } from '~@lib/types'; import { useCollapsibleRootContext } from '../../Collapsible/root/CollapsibleRootContext'; import { @@ -62,7 +60,7 @@ function getActiveTriggers(accordionItemRefs: { * Documentation: [Base UI Accordion](https://base-ui.com/react/components/accordion) */ -export function AccordionTrigger(componentProps: AccordionTrigger.Props) { +export function AccordionTrigger(componentProps: AccordionTriggerProps) { const { /* eslint-disable unused-imports/no-unused-vars */ className, @@ -70,7 +68,7 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { /* eslint-enable unused-imports/no-unused-vars */ disabled: disabledProp, id: idProp, - nativeButton, + nativeButton = true, ref, ...elementProps } = componentProps; @@ -93,7 +91,7 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { const { accordionItemRefs, direction, - loop, + loopFocus, orientation } = useAccordionRootContext(); @@ -135,7 +133,7 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { const thisIndex = triggers.indexOf(event.target as HTMLButtonElement); function toNext() { - if (loop) { + if (loopFocus) { nextIndex = thisIndex + 1 > lastIndex ? 0 : thisIndex + 1; } else { @@ -144,7 +142,7 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { } function toPrev() { - if (loop) { + if (loopFocus) { nextIndex = thisIndex === 0 ? lastIndex : thisIndex - 1; } else { @@ -205,7 +203,7 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { id, isHorizontal, isRtl, - loop, + loopFocus, open, panelId ] @@ -221,6 +219,8 @@ export function AccordionTrigger(componentProps: AccordionTrigger.Props) { return element; } +export type AccordionTriggerProps = {} & NativeButtonProps & HeadlessUIComponentProps<'button', AccordionItem.State>; + export namespace AccordionTrigger { - export type Props = HeadlessUIComponentProps<'button', AccordionItem.State> & NativeButtonProps; + export type Props = AccordionTriggerProps; } diff --git a/packages/ui/uikit/headless/components/src/components/Autocomplete/index.parts.ts b/packages/ui/uikit/headless/components/src/components/Autocomplete/index.parts.ts new file mode 100644 index 00000000..811a50d1 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Autocomplete/index.parts.ts @@ -0,0 +1,24 @@ +export { ComboboxArrow as Arrow } from '../Combobox/arrow/ComboboxArrow'; +export { ComboboxBackdrop as Backdrop } from '../Combobox/backdrop/ComboboxBackdrop'; + +export { ComboboxClear as Clear } from '../Combobox/clear/ComboboxClear'; +export { ComboboxCollection as Collection } from '../Combobox/collection/ComboboxCollection'; +export { ComboboxEmpty as Empty } from '../Combobox/empty/ComboboxEmpty'; +export { ComboboxGroupLabel as GroupLabel } from '../Combobox/group-label/ComboboxGroupLabel'; +export { ComboboxGroup as Group } from '../Combobox/group/ComboboxGroup'; +export { ComboboxIcon as Icon } from '../Combobox/icon/ComboboxIcon'; +export { ComboboxInput as Input } from '../Combobox/input/ComboboxInput'; +export { ComboboxItem as Item } from '../Combobox/item/ComboboxItem'; +export { ComboboxList as List } from '../Combobox/list/ComboboxList'; +export { ComboboxPopup as Popup } from '../Combobox/popup/ComboboxPopup'; +export { ComboboxPortal as Portal } from '../Combobox/portal/ComboboxPortal'; +export { ComboboxPositioner as Positioner } from '../Combobox/positioner/ComboboxPositioner'; +export { useCoreFilter as useFilter } from '../Combobox/root/utils/useFilter'; +export { ComboboxRow as Row } from '../Combobox/row/ComboboxRow'; +export { ComboboxStatus as Status } from '../Combobox/status/ComboboxStatus'; +export { ComboboxTrigger as Trigger } from '../Combobox/trigger/ComboboxTrigger'; +export { Separator } from '../Separator/Separator'; + +export { AutocompleteRoot as Root } from './root/AutocompleteRoot'; + +export { AutocompleteValue as Value } from './value/AutocompleteValue'; diff --git a/packages/ui/uikit/headless/components/src/components/Autocomplete/index.ts b/packages/ui/uikit/headless/components/src/components/Autocomplete/index.ts new file mode 100644 index 00000000..76f45d7d --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Autocomplete/index.ts @@ -0,0 +1,60 @@ +export type { + ComboboxArrowProps as AutocompleteArrowProps, + ComboboxArrowState as AutocompleteArrowState +} from '../Combobox/arrow/ComboboxArrow'; + +export type { + ComboboxBackdropProps as AutocompleteBackdropProps, + ComboboxBackdropState as AutocompleteBackdropState +} from '../Combobox/backdrop/ComboboxBackdrop'; +export type { ComboboxCollectionProps as AutocompleteCollectionProps } from '../Combobox/collection/ComboboxCollection'; + +export type { + ComboboxEmptyProps as AutocompleteEmptyProps, + ComboboxEmptyState as AutocompleteEmptyState +} from '../Combobox/empty/ComboboxEmpty'; +export type { + ComboboxGroupLabelProps as AutocompleteGroupLabelProps, + ComboboxGroupLabelState as AutocompleteGroupLabelState +} from '../Combobox/group-label/ComboboxGroupLabel'; +export type { + ComboboxGroupProps as AutocompleteGroupProps, + ComboboxGroupState as AutocompleteGroupState +} from '../Combobox/group/ComboboxGroup'; +export type { + ComboboxInputProps as AutocompleteInputProps, + ComboboxInputState as AutocompleteInputState +} from '../Combobox/input/ComboboxInput'; +export type { + ComboboxItemProps as AutocompleteItemProps, + ComboboxItemState as AutocompleteItemState +} from '../Combobox/item/ComboboxItem'; +export type { + ComboboxListProps as AutocompleteListProps, + ComboboxListState as AutocompleteListState +} from '../Combobox/list/ComboboxList'; +export type { + ComboboxPopupProps as AutocompletePopupProps, + ComboboxPopupState as AutocompletePopupState +} from '../Combobox/popup/ComboboxPopup'; +export type { ComboboxPortalProps as AutocompletePortalProps } from '../Combobox/portal/ComboboxPortal'; +export type { + ComboboxPositionerProps as AutocompletePositionerProps, + ComboboxPositionerState as AutocompletePositionerState +} from '../Combobox/positioner/ComboboxPositioner'; +export type { + Filter as AutocompleteFilter, + UseFilterOptions as AutocompleteFilterOptions +} from '../Combobox/root/utils/useFilter'; +export type { + ComboboxStatusProps as AutocompleteStatusProps, + ComboboxStatusState as AutocompleteStatusState +} from '../Combobox/status/ComboboxStatus'; +export type { + ComboboxTriggerProps as AutocompleteTriggerProps, + ComboboxTriggerState as AutocompleteTriggerState +} from '../Combobox/trigger/ComboboxTrigger'; +export * as Autocomplete from './index.parts'; +export type * from './root/AutocompleteRoot'; + +export type * from './value/AutocompleteValue'; diff --git a/packages/ui/uikit/headless/components/src/components/Autocomplete/root/AutocompleteRoot.tsx b/packages/ui/uikit/headless/components/src/components/Autocomplete/root/AutocompleteRoot.tsx new file mode 100644 index 00000000..8e3427a6 --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Autocomplete/root/AutocompleteRoot.tsx @@ -0,0 +1,280 @@ +import React from 'react'; + +import { useStableCallback } from '@flippo-ui/hooks/use-stable-callback'; + +import { REASONS } from '~@lib/reason'; +import { stringifyAsLabel } from '~@lib/resolveValueLabel'; + +import { AriaCombobox } from '../../Combobox/root/AriaCombobox'; +import { useCoreFilter } from '../../Combobox/root/utils/useFilter'; + +/** + * Groups all parts of the autocomplete. + * Doesn't render its own HTML element. + * + * Documentation: [Base UI Autocomplete](https://base-ui.com/react/components/autocomplete) + */ +export function AutocompleteRoot( + props: Omit, 'items'> & { + /** + * The items to be displayed in the list. + * Can be either a flat array of items or an array of groups with items. + */ + items: Items; + }, +): React.JSX.Element; +export function AutocompleteRoot( + props: Omit, 'items'> & { + /** + * The items to be displayed in the list. + * Can be either a flat array of items or an array of groups with items. + */ + items?: readonly ItemValue[]; + }, +): React.JSX.Element; +export function AutocompleteRoot( + props: AutocompleteRoot.Props +): React.JSX.Element { + const { + openOnInputClick = false, + value, + defaultValue, + onValueChange, + mode = 'list', + itemToStringValue, + ...other + } = props; + + const enableInline = mode === 'inline' || mode === 'both'; + const staticItems = mode === 'inline' || mode === 'none'; + + // Mirror the typed value for uncontrolled usage so we can compose the temporary + // inline input value. + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = React.useState(defaultValue ?? ''); + const [inlineInputValue, setInlineInputValue] = React.useState(''); + + React.useEffect(() => { + if (isControlled) { + setInlineInputValue(''); + } + }, [value, isControlled]); + + // Compose the input value shown to the user: inline value takes precedence when present. + let resolvedInputValue: typeof value; + if (enableInline && inlineInputValue !== '') { + resolvedInputValue = inlineInputValue; + } + else if (isControlled) { + resolvedInputValue = value ?? ''; + } + else { + resolvedInputValue = internalValue; + } + + const handleValueChange = useStableCallback( + (nextValue: string, eventDetails: AutocompleteRoot.ChangeEventDetails) => { + setInlineInputValue(''); + if (!isControlled) { + setInternalValue(nextValue); + } + onValueChange?.(nextValue, eventDetails); + } + ); + + const collator = useCoreFilter(); + + const baseFilter: typeof other.filter = React.useMemo(() => { + if (other.filter) { + return other.filter; + } + return (item, query, toString) => { + return collator.contains(stringifyAsLabel(item, toString), query); + }; + }, [other, collator]); + + const resolvedQuery = String(isControlled ? value : internalValue).trim(); + + // In "both", wrap filtering to use only the typed value, ignoring the inline value. + const resolvedFilter: typeof other.filter = React.useMemo(() => { + if (mode !== 'both') { + return staticItems ? null : other.filter; + } + return (item, _query, toString) => { + return baseFilter(item, resolvedQuery, toString); + }; + }, [ + baseFilter, + mode, + other.filter, + resolvedQuery, + staticItems + ]); + + const handleItemHighlighted = useStableCallback( + (highlightedValue: any, eventDetails: AriaCombobox.HighlightEventDetails) => { + props.onItemHighlighted?.(highlightedValue, eventDetails); + + if (eventDetails.reason === REASONS.pointer) { + return; + } + + if (enableInline) { + if (highlightedValue == null) { + setInlineInputValue(''); + } + else { + setInlineInputValue(stringifyAsLabel(highlightedValue, itemToStringValue)); + } + } + else { + setInlineInputValue(''); + } + } + ); + + return ( + + ); +} + +export type AutocompleteRootState = AriaCombobox.State; + +export type AutocompleteRootActions = { + unmount: () => void; +}; + +export type AutocompleteRootChangeEventReason = AriaCombobox.ChangeEventReason; +export type AutocompleteRootChangeEventDetails = AriaCombobox.ChangeEventDetails; + +export type AutocompleteRootHighlightEventReason = AriaCombobox.HighlightEventReason; +export type AutocompleteRootHighlightEventDetails = AriaCombobox.HighlightEventDetails; + +export type AutocompleteRootProps = { + /** + * Controls how the autocomplete behaves with respect to list filtering and inline autocompletion. + * - `list` (default): items are dynamically filtered based on the input value. The input value does not change + * based on the active item. + * - `both`: items are dynamically filtered based on the input value, which will temporarily change based on the + * active item (inline autocompletion). + * - `inline`: items are static (not filtered), and the input value will temporarily change based on the active item + * (inline autocompletion). + * - `none`: items are static (not filtered), and the input value will not change based on the active item. + * @default 'list' + */ + mode?: 'list' | 'both' | 'inline' | 'none'; + /** + * Whether the first matching item is highlighted automatically. + * - `true`: highlight after the user types and keep the highlight while the query changes. + * - `'always'`: always highlight the first item. + * @default false + */ + autoHighlight?: boolean | 'always'; + /** + * Whether the highlighted item should be preserved when the pointer leaves the list. + * @default false + */ + keepHighlight?: boolean; + /** + * Whether moving the pointer over items should highlight them. + * @default true + */ + highlightItemOnHover?: boolean; + /** + * The uncontrolled input value of the autocomplete when it's initially rendered. + * + * To render a controlled autocomplete, use the `value` prop instead. + */ + defaultValue?: AriaCombobox.Props< + React.ComponentProps<'input'>['defaultValue'], + 'none' + >['defaultInputValue']; + /** + * The input value of the autocomplete. Use when controlled. + */ + value?: AriaCombobox.Props['value'], 'none'>['inputValue']; + /** + * Event handler called when the input value of the autocomplete changes. + */ + onValueChange?: (value: string, eventDetails: AutocompleteRootChangeEventDetails) => void; + /** + * Whether clicking an item should submit the autocomplete's owning form. + * By default, clicking an item via a pointer or Enter key does not submit the owning form. + * Useful when the autocomplete is used as a single-field form search input. + * @default false + */ + submitOnItemClick?: AriaCombobox.Props['submitOnItemClick']; + /** + * When the item values are objects (``), this function converts the object value + * to a string representation for both display in the input and form submission. + * If the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop. + */ + itemToStringValue?: (itemValue: ItemValue) => string; + /** + * A ref to imperative actions. + * - `unmount`: When specified, the autocomplete will not be unmounted when closed. + * Instead, the `unmount` function must be called to unmount the autocomplete manually. + * Useful when the autocomplete's animation is controlled by an external library. + */ + actionsRef?: React.RefObject; + /** + * Event handler called when the popup is opened or closed. + */ + onOpenChange?: (open: boolean, eventDetails: AutocompleteRootChangeEventDetails) => void; + /** + * Callback fired when an item is highlighted or unhighlighted. + * Receives the highlighted item value (or `undefined` if no item is highlighted) and event details with a `reason` + * property describing why the highlight changed. + * The `reason` can be: + * - `'keyboard'`: the highlight changed due to keyboard navigation. + * - `'pointer'`: the highlight changed due to pointer hovering. + * - `'none'`: the highlight changed programmatically. + */ + onItemHighlighted?: ( + highlightedValue: ItemValue | undefined, + eventDetails: AutocompleteRootHighlightEventDetails, + ) => void; +} & Omit< + AriaCombobox.Props, + | 'selectionMode' + | 'selectedValue' + | 'defaultSelectedValue' + | 'onSelectedValueChange' + | 'fillInputOnItemPress' + | 'itemToStringValue' + | 'isItemEqualToValue' + // Different names + | 'inputValue' // value + | 'defaultInputValue' // defaultValue + | 'onInputValueChange' // onValueChange + | 'autoComplete' // mode + | 'itemToStringLabel' // itemToStringValue + // Custom JSDoc + | 'autoHighlight' + | 'keepHighlight' + | 'highlightItemOnHover' + | 'actionsRef' + | 'onOpenChange' +>; + +export namespace AutocompleteRoot { + export type Props = AutocompleteRootProps; + export type State = AutocompleteRootState; + export type Actions = AutocompleteRootActions; + export type ChangeEventReason = AutocompleteRootChangeEventReason; + export type ChangeEventDetails = AutocompleteRootChangeEventDetails; + export type HighlightEventReason = AutocompleteRootHighlightEventReason; + export type HighlightEventDetails = AutocompleteRootHighlightEventDetails; +} diff --git a/packages/ui/uikit/headless/components/src/components/Autocomplete/value/AutocompleteValue.tsx b/packages/ui/uikit/headless/components/src/components/Autocomplete/value/AutocompleteValue.tsx new file mode 100644 index 00000000..07bc322c --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Autocomplete/value/AutocompleteValue.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { useComboboxInputValueContext } from '../../Combobox/root/ComboboxRootContext'; + +/** + * The current value of the autocomplete. + * Doesn't render its own HTML element. + * + * Documentation: [Base UI Autocomplete](https://base-ui.com/react/components/autocomplete) + */ +export function AutocompleteValue(props: AutocompleteValue.Props): React.ReactElement { + const { children } = props; + + const inputValue = useComboboxInputValueContext(); + + let returnValue = null; + if (typeof children === 'function') { + returnValue = children(String(inputValue)); + } + else if (children != null) { + returnValue = children; + } + else { + returnValue = inputValue; + } + + return {returnValue}; +} + +export type AutocompleteValueState = {}; + +export type AutocompleteValueProps = { + children?: React.ReactNode | ((value: string) => React.ReactNode); +}; + +export namespace AutocompleteValue { + export type State = AutocompleteValueState; + export type Props = AutocompleteValueProps; +} diff --git a/packages/ui/uikit/headless/components/src/components/Avatar/fallback/AvatarFallback.tsx b/packages/ui/uikit/headless/components/src/components/Avatar/fallback/AvatarFallback.tsx index 1ca4dc22..294adf2b 100644 --- a/packages/ui/uikit/headless/components/src/components/Avatar/fallback/AvatarFallback.tsx +++ b/packages/ui/uikit/headless/components/src/components/Avatar/fallback/AvatarFallback.tsx @@ -1,12 +1,12 @@ -'use client'; + import React from 'react'; import { useTimeout } from '@flippo-ui/hooks'; -import { useRenderElement } from '@lib/hooks'; +import { useRenderElement } from '~@lib/hooks'; -import type { HeadlessUIComponentProps } from '@lib/types'; +import type { HeadlessUIComponentProps } from '~@lib/types'; import { useAvatarRootContext } from '../root/AvatarRootContext'; import { avatarStyleHookMapping } from '../root/styleHooks'; diff --git a/packages/ui/uikit/headless/components/src/components/Avatar/image/AvatarImage.tsx b/packages/ui/uikit/headless/components/src/components/Avatar/image/AvatarImage.tsx index fddd6fe5..d793a96c 100644 --- a/packages/ui/uikit/headless/components/src/components/Avatar/image/AvatarImage.tsx +++ b/packages/ui/uikit/headless/components/src/components/Avatar/image/AvatarImage.tsx @@ -1,12 +1,12 @@ -'use client'; + import React from 'react'; import { useEventCallback, useIsoLayoutEffect } from '@flippo-ui/hooks'; -import { useRenderElement } from '@lib/hooks'; +import { useRenderElement } from '~@lib/hooks'; -import type { HeadlessUIComponentProps } from '@lib/types'; +import type { HeadlessUIComponentProps } from '~@lib/types'; import { useAvatarRootContext } from '../root/AvatarRootContext'; import { avatarStyleHookMapping } from '../root/styleHooks'; diff --git a/packages/ui/uikit/headless/components/src/components/Avatar/image/useImageLoadingStatus.ts b/packages/ui/uikit/headless/components/src/components/Avatar/image/useImageLoadingStatus.ts index 5dd571f2..6f0cbaa5 100644 --- a/packages/ui/uikit/headless/components/src/components/Avatar/image/useImageLoadingStatus.ts +++ b/packages/ui/uikit/headless/components/src/components/Avatar/image/useImageLoadingStatus.ts @@ -1,10 +1,10 @@ -'use client'; + import React from 'react'; import { useIsoLayoutEffect } from '@flippo-ui/hooks'; -import { NOOP } from '@lib/noop'; +import { NOOP } from '~@lib/noop'; export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; diff --git a/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRoot.tsx b/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRoot.tsx index 45ea494a..a2371b32 100644 --- a/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRoot.tsx +++ b/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRoot.tsx @@ -1,10 +1,10 @@ -'use client'; + import React from 'react'; -import { useRenderElement } from '@lib/hooks'; +import { useRenderElement } from '~@lib/hooks'; -import type { HeadlessUIComponentProps } from '@lib/types'; +import type { HeadlessUIComponentProps } from '~@lib/types'; import { AvatarRootContext } from './AvatarRootContext'; import { avatarStyleHookMapping } from './styleHooks'; diff --git a/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRootContext.ts b/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRootContext.ts index fd21f8fc..c0b7da6a 100644 --- a/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRootContext.ts +++ b/packages/ui/uikit/headless/components/src/components/Avatar/root/AvatarRootContext.ts @@ -1,15 +1,13 @@ -'use client'; - import React from 'react'; import type { ImageLoadingStatus } from './AvatarRoot'; -export type TAvatarRootContext = { +export type AvatarRootContextValue = { imageLoadingStatus: ImageLoadingStatus; setImageLoadingStatus: React.Dispatch>; }; -export const AvatarRootContext = React.createContext(undefined); +export const AvatarRootContext = React.createContext(undefined); export function useAvatarRootContext() { const context = React.use(AvatarRootContext); diff --git a/packages/ui/uikit/headless/components/src/components/Button/Button.tsx b/packages/ui/uikit/headless/components/src/components/Button/Button.tsx new file mode 100644 index 00000000..fef2f7ed --- /dev/null +++ b/packages/ui/uikit/headless/components/src/components/Button/Button.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { useRenderElement } from '~@lib/hooks/useRenderElement'; + +import type { HeadlessUIComponentProps, NativeButtonProps, NonNativeButtonProps } from '~@lib/types'; + +import { useButton } from '../use-button/useButton'; + +/** + * A button component that can be used to trigger actions. + * Renders a `