diff --git a/package.json b/package.json index 7bc8d038..bd06970c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-slider": "1.3.6", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0d57e91..bc8640f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: 2.1.16 version: 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slider': specifier: 1.3.6 version: 1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -396,24 +399,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -1056,6 +1063,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1295,66 +1315,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1627,36 +1660,42 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.33': resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.33': resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.33': resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.15.33': resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.33': resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.33': resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} @@ -1738,24 +1777,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1948,51 +1991,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -3347,24 +3400,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5356,6 +5413,29 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/src/components/atoms/popover/Popover.stories.tsx b/src/components/atoms/popover/Popover.stories.tsx new file mode 100644 index 00000000..977cce2b --- /dev/null +++ b/src/components/atoms/popover/Popover.stories.tsx @@ -0,0 +1,383 @@ +import { Button } from '@atoms/button'; +import { buttonVariants } from '@atoms/button/types'; +import { Icon } from '@atoms/icon'; +import { Input } from '@atoms/input'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { type ComponentPropsWithoutRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Popover } from './Popover'; + +type TriggerButtonProps = ComponentPropsWithoutRef<'button'>; + +const TriggerButton = ({ children, className, type = 'button', ...props }: TriggerButtonProps) => ( + +); + +/** + * ## Description + * Popover displays rich non-modal dialog content in a floating layer anchored to a trigger while preserving Stack-and-Flow surface, focus, and accessibility rules. + * + * ## Dependencies + * These stories use `Button`, `Input`, and `Icon` to demonstrate real interactive content and trigger composition. + * + * ## Usage Guide + * Compose the component with the canonical compound API: `Popover`, `Popover.Trigger`, `Popover.Content`, `Popover.Arrow`, `Popover.Header`, `Popover.Body`, and `Popover.Footer`. + * `Popover.Content` always exposes non-modal dialog semantics and must have an accessible name from `Popover.Header`, `ariaLabel`, or `ariaLabelledBy`. + */ +const meta: Meta = { + title: 'Atoms/Popover', + component: Popover, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +/** + * Shows the default opaque floating surface with a labelled header and body. + */ +export const Default: Story = { + render: () => ( + + + Open popover + + + Profile details + Use Popover for rich contextual content that remains connected to its trigger. + + + ) +}; + +/** + * Shows the optional arrow slot aligned with the content surface. + */ +export const WithArrow: Story = { + render: () => ( + + + Open with arrow + + + + Arrow enabled + The arrow is rendered by adding the dedicated slot. + + + ) +}; + +/** + * Shows all supported placements for the content panel. + */ +export const Placements: Story = { + render: () => ( +
+ + + top + + + top placement + Collision handling stays enabled by default. + + + + + + right + + + right placement + Collision handling stays enabled by default. + + + + + + bottom + + + bottom placement + Collision handling stays enabled by default. + + + + + + left + + + left placement + Collision handling stays enabled by default. + + +
+ ) +}; + +/** + * Shows controlled open state owned by the consumer. + */ +export const ControlledOpen: Story = { + render: () => { + const [open, setOpen] = useState(false); + + return ( + + + {open ? 'Close popover' : 'Open controlled popover'} + + + Controlled state + The consumer owns the visibility state and receives every state change. + + + ); + } +}; + +/** + * Shows header, body, and footer slots with contextual actions. + */ +export const HeaderBodyFooter: Story = { + render: () => ( + + + Open actions + + + Unsaved changes + Review your updates before leaving this section. + + + + + Custom trigger + The trigger slot supports custom button content and icon composition. + + + ) +}; + +/** + * Shows the supported content size variants. + */ +export const Sizes: Story = { + render: () => ( +
+ + + sm + + + SM size + Panel spacing and width scale with the selected size variant. + + + + + + md + + + MD size + Panel spacing and width scale with the selected size variant. + + + + + + lg + + + LG size + Panel spacing and width scale with the selected size variant. + + +
+ ) +}; + +/** + * Shows the available accent colors using the same content structure. + */ +export const ColorAccents: Story = { + render: () => ( +
+ + + neutral + + + neutral accent + Accent color applies to the header and related floating details. + + + + + + primary + + + primary accent + Accent color applies to the header and related floating details. + + + + + + secondary + + + secondary accent + Accent color applies to the header and related floating details. + + + + + + success + + + success accent + Accent color applies to the header and related floating details. + + + + + + warning + + + warning accent + Accent color applies to the header and related floating details. + + + + + + danger + + + danger accent + Accent color applies to the header and related floating details. + + +
+ ) +}; + +/** + * Shows the frosted non-default surface variant. + */ +export const FrostedVariant: Story = { + render: () => ( +
+ + + Open frosted + + + + Frosted surface + + This variant uses the approved floating blur treatment instead of the opaque default. + + + +
+ ) +}; + +/** + * Shows the non-interactive disabled trigger state. + */ +export const DisabledTrigger: Story = { + render: () => ( + + + Disabled trigger + + + This content should not open while the trigger is disabled. + + + ) +}; + +/** + * Shows a scoped dark-mode portal without requiring full-page dark mode. + */ +export const DarkMode: Story = { + render: () => ( +
+ + + Open dark scope + + + + Scoped dark portal + + The content portal keeps the local dark-mode scope when rendered outside the DOM tree. + + + +
+ ) +}; diff --git a/src/components/atoms/popover/Popover.test.tsx b/src/components/atoms/popover/Popover.test.tsx new file mode 100644 index 00000000..9adfb654 --- /dev/null +++ b/src/components/atoms/popover/Popover.test.tsx @@ -0,0 +1,416 @@ +/** @vitest-environment jsdom */ + +import '@testing-library/jest-dom/vitest'; +import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Popover } from './Popover'; +import { PopoverRootProvider, usePopoverContent, usePopoverRoot } from './usePopover'; + +const HeaderSlot = ({ children }: { children: ReactNode }) => <>{children}; +const BodySlot = ({ children }: { children: ReactNode }) => <>{children}; + +HeaderSlot.displayName = 'Popover.Header'; +BodySlot.displayName = 'Popover.Body'; + +beforeAll(() => { + if (typeof window.ResizeObserver === 'undefined') { + class ResizeObserver { + observe() { + // no-op for jsdom positioning tests + } + + unobserve() { + // no-op for jsdom positioning tests + } + + disconnect() { + // no-op for jsdom positioning tests + } + } + + window.ResizeObserver = ResizeObserver; + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('usePopoverRoot — logic', () => { + it('uses uncontrolled state by default', () => { + const { result } = renderHook(() => usePopoverRoot({ children: null, defaultOpen: true })); + + expect(result.current.rootProps.open).toBe(true); + expect(result.current.contextValue.contentId).toMatch(/^popover-/); + }); +}); + +describe('usePopoverContent — logic', () => { + it('maps placement to side/alignment and enables collision handling by default', () => { + const wrapper = ({ children }: { children: ReactNode }) => { + const { contextValue } = usePopoverRoot({ children: null }); + + return {children}; + }; + + const { result } = renderHook( + () => + usePopoverContent({ + ariaLabel: 'Popover details', + children: Body content, + placement: 'right-end' + }), + { wrapper } + ); + + expect(result.current.contentProps.side).toBe('right'); + expect(result.current.contentProps.align).toBe('end'); + expect(result.current.contentProps.avoidCollisions).toBe(true); + expect(result.current.contentProps.collisionPadding).toBe(8); + expect(result.current.contentProps['aria-describedby']).toMatch(/^popover-/); + }); + + it('uses the header slot as the accessible label source when present', () => { + const wrapper = ({ children }: { children: ReactNode }) => { + const { contextValue } = usePopoverRoot({ children: null }); + + return {children}; + }; + + const { result } = renderHook( + () => + usePopoverContent({ + children: ( + <> + Popover title + Popover body + + ) + }), + { wrapper } + ); + + expect(result.current.contentProps['aria-labelledby']).toMatch(/^popover-/); + expect(result.current.contentProps['aria-label']).toBeUndefined(); + }); +}); + +describe('Popover — component behavior', () => { + it('opens on click and exposes dialog semantics with header and body slots', async () => { + const user = userEvent.setup(); + + render( + + + + + + Profile details + Popover body content + + + ); + + const trigger = screen.getByRole('button', { name: 'Open details' }); + expect(trigger).toHaveAttribute('aria-haspopup', 'dialog'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await user.click(trigger); + + const dialog = await screen.findByRole('dialog', { name: 'Profile details' }); + expect(dialog).toHaveAttribute('data-slot', 'popover-content'); + expect(dialog).toHaveAttribute('aria-describedby'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Popover body content')).toBeInTheDocument(); + }); + + it('supports keyboard opening and returns focus to the trigger on Escape close', async () => { + const user = userEvent.setup(); + + render( + + + + + + Keyboard popover + + + + + + ); + + const trigger = screen.getByRole('button', { name: 'Keyboard trigger' }); + trigger.focus(); + + await user.keyboard('{Enter}'); + expect(await screen.findByRole('dialog', { name: 'Keyboard popover' })).toBeInTheDocument(); + + await user.keyboard('{Tab}'); + expect(screen.getByRole('button', { name: 'Focusable action' })).toHaveFocus(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Keyboard popover' })).not.toBeInTheDocument(); + }); + expect(trigger).toHaveFocus(); + }); + + it('closes when clicking outside of the content', async () => { + const user = userEvent.setup(); + + render( +
+ + + + + + + Outside close + Dismiss me + + +
+ ); + + await user.click(screen.getByRole('button', { name: 'Open outside close' })); + expect(await screen.findByRole('dialog', { name: 'Outside close' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Outside action' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Outside close' })).not.toBeInTheDocument(); + }); + }); + + it('supports controlled mode without mutating visibility until the owner updates props', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + render( + + + + + + Controlled popover + Controlled content + + + ); + + await user.click(screen.getByRole('button', { name: 'Controlled trigger' })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.queryByRole('dialog', { name: 'Controlled popover' })).not.toBeInTheDocument(); + }); + + it('renders uncontrolled defaultOpen content immediately and restores focus to the trigger when closed', async () => { + const user = userEvent.setup(); + + render( +
+ + + + + + + Default open + Visible on mount + + +
+ ); + + const trigger = screen.getByRole('button', { name: 'Initially open' }); + expect(screen.getByRole('dialog', { name: 'Default open' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Outside close target' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Default open' })).not.toBeInTheDocument(); + }); + expect(trigger).toHaveFocus(); + }); + + it('does not open when the trigger is disabled', async () => { + const user = userEvent.setup(); + + render( + + + + + Disabled body + + ); + + const trigger = screen.getByRole('button', { name: 'Disabled trigger' }); + expect(trigger).toBeDisabled(); + + await user.click(trigger); + + expect(screen.queryByRole('dialog', { name: 'Disabled content' })).not.toBeInTheDocument(); + }); + + it('supports a non-button custom trigger with keyboard activation', async () => { + const user = userEvent.setup(); + + render( + + + Open custom trigger + + Custom trigger content + + ); + + const trigger = screen.getByRole('button', { name: 'Open custom trigger' }); + trigger.focus(); + + await user.keyboard(' '); + + expect(await screen.findByRole('dialog', { name: 'Custom trigger popover' })).toBeInTheDocument(); + }); + + it('preserves input trigger keyboard behavior while typing', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + render( + + + + + Suggestions content + + ); + + const trigger = screen.getByRole('textbox', { name: 'Search trigger' }); + trigger.focus(); + + await user.keyboard('hello world{Enter}'); + + expect(trigger).toHaveValue('hello world'); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog', { name: 'Search suggestions' })).not.toBeInTheDocument(); + }); + + it('renders the frosted variant with an arrow while preserving scoped dark-mode portal rendering', async () => { + const user = userEvent.setup(); + + render( +
+ + + + + + + Frosted popover + Scoped dark content + + +
+ ); + + await user.click(screen.getByRole('button', { name: 'Open frosted' })); + + const dialog = await screen.findByRole('dialog', { name: 'Frosted popover' }); + expect(dialog).toHaveAttribute('data-variant', 'frosted'); + expect(dialog.closest('.dark')).not.toBeNull(); + expect(document.body).toContainElement(dialog); + expect(document.querySelector('[data-slot="popover-arrow"]')).toBeInTheDocument(); + }); + + it('preserves scoped dark-mode portal rendering for initially-open popovers', () => { + render( +
+ + + + + + Initially open dark scope + Scoped dark content + + +
+ ); + + const dialog = screen.getByRole('dialog', { name: 'Initially open dark scope' }); + expect(dialog.closest('.dark')).not.toBeNull(); + expect(document.body).toContainElement(dialog); + }); + + it('renders placement attributes for collision and flip smoke coverage', async () => { + const user = userEvent.setup(); + + render( + + + + + + Placed popover + Placement smoke test + + + ); + + await user.click(screen.getByRole('button', { name: 'Open placed popover' })); + + const dialog = await screen.findByRole('dialog', { name: 'Placed popover' }); + expect(dialog).toHaveAttribute('data-side', 'right'); + expect(dialog).toHaveAttribute('data-align', 'end'); + }); + + it('supports external labelling when no header slot is rendered', async () => { + const user = userEvent.setup(); + + render( +
+ External label + + + + + + Externally labelled content + + +
+ ); + + await user.click(screen.getByRole('button', { name: 'Open external label' })); + + expect(await screen.findByRole('dialog', { name: 'External label' })).toBeInTheDocument(); + }); + + it('requires an accessible label when no header slot is rendered', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + // Expected React error noise for this negative render path. + }); + + expect(() => { + render( + + + + + + Missing accessible name + + + ); + }).toThrow('Popover.Content requires Popover.Header, ariaLabel, or ariaLabelledBy for accessible naming.'); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/components/atoms/popover/Popover.tsx b/src/components/atoms/popover/Popover.tsx new file mode 100644 index 00000000..9aed6eae --- /dev/null +++ b/src/components/atoms/popover/Popover.tsx @@ -0,0 +1,123 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import type { FC } from 'react'; +import type { + PopoverArrowProps, + PopoverBodyProps, + PopoverCompoundComponent, + PopoverContentProps, + PopoverFooterProps, + PopoverHeaderProps, + PopoverProps, + PopoverTriggerProps +} from './types'; +import { + PopoverContentProvider, + PopoverRootProvider, + usePopoverArrow, + usePopoverBody, + usePopoverContent, + usePopoverFooter, + usePopoverHeader, + usePopoverRoot, + usePopoverTrigger +} from './usePopover'; + +const PopoverRoot: FC = (props) => { + const { contextValue, rootProps } = usePopoverRoot(props); + + return ( + + {props.children} + + ); +}; + +const PopoverTrigger: FC = (props) => { + const trigger = usePopoverTrigger(props); + + return ( + + + {trigger.asChild ? ( + trigger.child + ) : ( + + )} + + + ); +}; + +const PopoverContent: FC = (props) => { + const { children, contentProps, contextValue, needsScopedDarkPortal } = usePopoverContent(props); + + return ( + +
+ + + {children} + + +
+
+ ); +}; + +const PopoverHeader: FC = (props) => { + const { className, id } = usePopoverHeader(props); + + return ( +

+ {props.children} +

+ ); +}; + +const PopoverBody: FC = (props) => { + const { className, id } = usePopoverBody(props); + + return ( +
+ {props.children} +
+ ); +}; + +const PopoverFooter: FC = (props) => { + const { className } = usePopoverFooter(props); + + return ( +
+ {props.children} +
+ ); +}; + +const PopoverArrow: FC = (props) => { + const { className } = usePopoverArrow(props); + + return ( + + ); +}; + +PopoverTrigger.displayName = 'Popover.Trigger'; +PopoverContent.displayName = 'Popover.Content'; +PopoverHeader.displayName = 'Popover.Header'; +PopoverBody.displayName = 'Popover.Body'; +PopoverFooter.displayName = 'Popover.Footer'; +PopoverArrow.displayName = 'Popover.Arrow'; + +const compoundPopover = PopoverRoot as PopoverCompoundComponent; + +compoundPopover['Trigger'] = PopoverTrigger; +compoundPopover['Content'] = PopoverContent; +compoundPopover['Header'] = PopoverHeader; +compoundPopover['Body'] = PopoverBody; +compoundPopover['Footer'] = PopoverFooter; +compoundPopover['Arrow'] = PopoverArrow; + +export const Popover = compoundPopover; diff --git a/src/components/atoms/popover/index.ts b/src/components/atoms/popover/index.ts new file mode 100644 index 00000000..26f9bc3c --- /dev/null +++ b/src/components/atoms/popover/index.ts @@ -0,0 +1,2 @@ +export { Popover } from './Popover'; +export type * from './types'; diff --git a/src/components/atoms/popover/types.ts b/src/components/atoms/popover/types.ts new file mode 100644 index 00000000..97d76189 --- /dev/null +++ b/src/components/atoms/popover/types.ts @@ -0,0 +1,283 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { FC, ReactNode } from 'react'; + +export const popoverContentVariants = cva( + [ + 'z-50 flex w-max max-w-[calc(100vw-1rem)] flex-col overflow-hidden border text-left outline-none', + 'data-[state=closed]:animate-out data-[state=open]:animate-in', + 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', + 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', + 'data-[side=top]:slide-in-from-bottom-1 data-[side=bottom]:slide-in-from-top-1', + 'data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1', + 'transition-[opacity,transform] duration-150 ease-out motion-reduce:!animate-none motion-reduce:!transition-none' + ], + { + variants: { + size: { + sm: 'w-[16rem] text-sm', + md: 'w-[20rem] text-sm', + lg: 'w-[24rem] text-base' + }, + variant: { + default: 'bg-background-light dark:bg-surface-dark', + frosted: [ + 'bg-navbar-light dark:bg-navbar-dark', + 'backdrop-blur-[16px] [backdrop-filter:blur(16px)] [-webkit-backdrop-filter:blur(16px)]' + ] + }, + color: { + neutral: 'border-border-light dark:border-border-dark', + primary: 'border-brand-light/20 dark:border-brand-dark/30', + secondary: 'border-indigo/20 dark:border-indigo-light/30', + success: 'border-success-light/30 dark:border-success/30', + warning: 'border-warning-light/30 dark:border-warning/30', + danger: 'border-error-light/30 dark:border-error/30' + }, + radius: { + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl' + }, + shadow: { + sm: 'shadow-sm', + md: 'shadow-dropdown-light dark:shadow-dropdown', + lg: 'shadow-lg dark:shadow-dropdown' + } + }, + defaultVariants: { + size: 'md', + variant: 'default', + color: 'neutral', + radius: 'lg', + shadow: 'md' + } + } +); + +export const popoverTriggerVariants = cva([ + 'outline-none focus-visible:outline-none', + 'focus-visible:shadow-glow-focus-light dark:focus-visible:shadow-glow-focus-dark', + 'data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40', + 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40' +]); + +export const popoverHeaderVariants = cva('border-b px-4 font-semibold tracking-normal', { + variants: { + size: { + sm: 'py-2.5 text-sm', + md: 'py-3 text-base', + lg: 'py-3.5 text-lg' + }, + color: { + neutral: 'border-border-light text-text-light dark:border-border-dark dark:text-text-dark', + primary: 'border-border-light text-brand-light dark:border-border-dark dark:text-brand-dark', + secondary: 'border-border-light text-indigo-dark dark:border-border-dark dark:text-indigo-light', + success: 'border-border-light text-success-light dark:border-border-dark dark:text-success', + warning: 'border-border-light text-warning-light dark:border-border-dark dark:text-warning', + danger: 'border-border-light text-error-light dark:border-border-dark dark:text-error' + } + }, + defaultVariants: { + size: 'md', + color: 'neutral' + } +}); + +export const popoverBodyVariants = cva('px-4 py-3 text-text-secondary-light dark:text-text-secondary-dark', { + variants: { + size: { + sm: 'text-sm', + md: 'text-sm', + lg: 'text-base' + } + }, + defaultVariants: { + size: 'md' + } +}); + +export const popoverFooterVariants = cva('flex items-center justify-end gap-2 border-t px-4 py-3', { + variants: { + size: { + sm: 'text-sm', + md: 'text-sm', + lg: 'text-base' + } + }, + defaultVariants: { + size: 'md' + }, + compoundVariants: [ + { + size: ['sm', 'md', 'lg'], + class: 'border-border-light dark:border-border-dark' + } + ] +}); + +export const popoverArrowVariants = cva('drop-shadow-sm', { + variants: { + variant: { + default: '', + frosted: '' + }, + color: { + neutral: 'fill-background-light dark:fill-surface-dark', + primary: 'fill-brand-light/15 dark:fill-brand-dark/20', + secondary: 'fill-indigo/15 dark:fill-indigo-light/20', + success: 'fill-success-light/15 dark:fill-success/20', + warning: 'fill-warning-light/15 dark:fill-warning/20', + danger: 'fill-error-light/15 dark:fill-error/20' + } + }, + compoundVariants: [ + { + variant: 'frosted', + color: 'neutral', + class: 'fill-navbar-light dark:fill-navbar-dark' + }, + { + variant: 'frosted', + color: 'primary', + class: 'fill-navbar-light dark:fill-navbar-dark' + }, + { + variant: 'frosted', + color: 'secondary', + class: 'fill-navbar-light dark:fill-navbar-dark' + }, + { + variant: 'frosted', + color: 'success', + class: 'fill-navbar-light dark:fill-navbar-dark' + }, + { + variant: 'frosted', + color: 'warning', + class: 'fill-navbar-light dark:fill-navbar-dark' + }, + { + variant: 'frosted', + color: 'danger', + class: 'fill-navbar-light dark:fill-navbar-dark' + } + ], + defaultVariants: { + variant: 'default', + color: 'neutral' + } +}); + +export type PopoverPlacement = + | 'top' + | 'top-start' + | 'top-end' + | 'right' + | 'right-start' + | 'right-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end'; + +export type PopoverSize = NonNullable['size']>; +export type PopoverVariant = NonNullable['variant']>; +export type PopoverColor = NonNullable['color']>; +export type PopoverRadius = NonNullable['radius']>; +export type PopoverShadow = NonNullable['shadow']>; + +type PopoverSlotProps = { + children: ReactNode; + /** @control text */ + className?: string; +}; + +export type PopoverProps = { + /** @control object */ + children: ReactNode; + /** @control boolean */ + open?: boolean; + /** @control boolean */ + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export type PopoverTriggerProps = { + /** @control object */ + children: ReactNode; + /** @control boolean */ + asChild?: boolean; + /** @control boolean */ + disabled?: boolean; + /** @control text */ + className?: string; +}; + +export type PopoverContentProps = PopoverSlotProps & { + /** + * @control select + * @default bottom + */ + placement?: PopoverPlacement; + /** + * @control number + * @default 8 + */ + offset?: number; + /** + * @control select + * @default md + */ + size?: PopoverSize; + /** + * @control select + * @default default + */ + variant?: PopoverVariant; + /** + * @control select + * @default neutral + */ + color?: PopoverColor; + /** + * @control select + * @default lg + */ + radius?: PopoverRadius; + /** + * @control select + * @default md + */ + shadow?: PopoverShadow; + /** @control text */ + ariaLabel?: string; + /** @control text */ + ariaLabelledBy?: string; + /** @control text */ + ariaDescribedBy?: string; +}; + +export type PopoverHeaderProps = PopoverSlotProps; +export type PopoverBodyProps = PopoverSlotProps; +export type PopoverFooterProps = PopoverSlotProps; +export type PopoverArrowProps = { + /** @control text */ + className?: string; +}; + +export type PopoverCompoundComponent = FC & { + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Trigger: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Content: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Header: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Body: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Footer: FC; + // biome-ignore lint/style/useNamingConvention: compound API requires PascalCase slot names + Arrow: FC; +}; diff --git a/src/components/atoms/popover/usePopover.ts b/src/components/atoms/popover/usePopover.ts new file mode 100644 index 00000000..05a1fde2 --- /dev/null +++ b/src/components/atoms/popover/usePopover.ts @@ -0,0 +1,579 @@ +import type { PopoverContentProps as PopoverPrimitiveContentProps } from '@radix-ui/react-popover'; +import { + Children, + cloneElement, + createContext, + createElement, + type FocusEvent, + isValidElement, + type KeyboardEvent, + type MouseEvent, + type ReactElement, + type ReactNode, + useCallback, + useContext, + useId, + useMemo, + useState +} from 'react'; +import { cn } from '@/lib/utils'; +import { + type PopoverArrowProps, + type PopoverBodyProps, + type PopoverColor, + type PopoverContentProps, + type PopoverFooterProps, + type PopoverHeaderProps, + type PopoverPlacement, + type PopoverProps, + type PopoverRadius, + type PopoverShadow, + type PopoverSize, + type PopoverTriggerProps, + type PopoverVariant, + popoverArrowVariants, + popoverBodyVariants, + popoverContentVariants, + popoverFooterVariants, + popoverHeaderVariants, + popoverTriggerVariants +} from './types'; + +type PopoverRootContextValue = { + contentId: string; + open: boolean; + setOpen: (open: boolean) => void; + triggerElement: HTMLElement | null; + recordTriggerElement: (element: HTMLElement | null) => void; +}; + +type PopoverContentContextValue = { + bodyId: string; + color: PopoverColor; + headerId: string; + radius: PopoverRadius; + shadow: PopoverShadow; + size: PopoverSize; + variant: PopoverVariant; +}; + +type PopoverRootProviderProps = { + children: ReactNode; + value: PopoverRootContextValue; +}; + +type PopoverContentProviderProps = { + children: ReactNode; + value: PopoverContentContextValue; +}; + +type PopoverSide = 'top' | 'right' | 'bottom' | 'left'; +type PopoverAlign = 'start' | 'center' | 'end'; +type PopoverTriggerElement = ReactElement<{ + 'aria-controls'?: string; + 'aria-disabled'?: boolean; + 'aria-expanded'?: boolean; + 'aria-haspopup'?: 'dialog'; + 'data-disabled'?: 'true'; + className?: string; + disabled?: boolean; + onClick?: (event: MouseEvent) => void; + onFocus?: (event: FocusEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; + role?: string; + tabIndex?: number; + type?: 'button'; +}>; +type CloseAutoFocusEvent = Parameters>[0]; + +type UsePopoverRootReturn = { + contextValue: PopoverRootContextValue; + rootProps: { + onOpenChange: (open: boolean) => void; + open: boolean; + }; +}; + +type UsePopoverTriggerReturn = + | { + anchorClassName: string; + anchorRef: (element: HTMLSpanElement | null) => void; + asChild: false; + buttonProps: { + 'aria-controls': string; + 'aria-disabled': true | undefined; + 'aria-expanded': boolean; + 'aria-haspopup': 'dialog'; + 'data-disabled': 'true' | undefined; + className: string; + disabled: boolean; + onClick: (event: MouseEvent) => void; + onFocus: (event: FocusEvent) => void; + type: 'button'; + }; + child: null; + } + | { + anchorClassName: string; + anchorRef: (element: HTMLSpanElement | null) => void; + asChild: true; + buttonProps: null; + child: ReactElement; + }; + +type UsePopoverContentReturn = { + contentProps: { + 'aria-describedby': string | undefined; + 'aria-label': string | undefined; + 'aria-labelledby': string | undefined; + 'aria-modal': false; + 'data-color': PopoverColor; + 'data-radius': PopoverRadius; + 'data-shadow': PopoverShadow; + 'data-size': PopoverSize; + 'data-variant': PopoverVariant; + align: PopoverAlign; + avoidCollisions: true; + className: string; + collisionPadding: number; + id: string; + onCloseAutoFocus: (event: CloseAutoFocusEvent) => void; + role: 'dialog'; + side: PopoverSide; + sideOffset: number; + }; + contextValue: PopoverContentContextValue; + children: ReactNode; + needsScopedDarkPortal: boolean; +}; + +type UsePopoverSlotReturn = { + className: string; +}; + +type UsePopoverHeaderReturn = UsePopoverSlotReturn & { + id: string; +}; + +type UsePopoverBodyReturn = UsePopoverSlotReturn & { + id: string; +}; + +const PopoverRootContext = createContext(null); +const PopoverContentContext = createContext(null); + +const getPlacementConfig = (placement: PopoverPlacement): { align: PopoverAlign; side: PopoverSide } => { + switch (placement) { + case 'top-start': + return { side: 'top', align: 'start' }; + case 'top-end': + return { side: 'top', align: 'end' }; + case 'right-start': + return { side: 'right', align: 'start' }; + case 'right-end': + return { side: 'right', align: 'end' }; + case 'bottom-start': + return { side: 'bottom', align: 'start' }; + case 'bottom-end': + return { side: 'bottom', align: 'end' }; + case 'left-start': + return { side: 'left', align: 'start' }; + case 'left-end': + return { side: 'left', align: 'end' }; + case 'top': + return { side: 'top', align: 'center' }; + case 'right': + return { side: 'right', align: 'center' }; + case 'left': + return { side: 'left', align: 'center' }; + default: + return { side: 'bottom', align: 'center' }; + } +}; + +const getPopoverSlotDisplayName = (child: ReactNode): string | undefined => { + if (!isValidElement(child)) { + return undefined; + } + + if (typeof child.type === 'string') { + return undefined; + } + + return (child.type as { displayName?: string }).displayName; +}; + +const hasPopoverSlot = ({ children, displayName }: { children: ReactNode; displayName: string }): boolean => { + return Children.toArray(children).some((child) => { + if (getPopoverSlotDisplayName(child) === displayName) { + return true; + } + + if (!isValidElement(child)) { + return false; + } + + return hasPopoverSlot({ children: child.props.children, displayName }); + }); +}; + +const getMergedIds = (ids: Array): string | undefined => { + const mergedIds = ids.filter(Boolean); + + if (mergedIds.length === 0) { + return undefined; + } + + return mergedIds.join(' '); +}; + +const isHtmlTag = (element: PopoverTriggerElement, tagName: string): boolean => { + return typeof element.type === 'string' && element.type.toLowerCase() === tagName; +}; + +const isIntrinsicInteractiveTrigger = (element: PopoverTriggerElement): boolean => { + return isHtmlTag(element, 'button') || isHtmlTag(element, 'a') || isHtmlTag(element, 'input'); +}; + +const getTriggerElementFromAnchor = (anchorElement: HTMLSpanElement | null): HTMLElement | null => { + if (!anchorElement) { + return null; + } + + const directChild = anchorElement.firstElementChild; + + if (directChild instanceof HTMLElement) { + return directChild; + } + + return anchorElement instanceof HTMLElement ? anchorElement : null; +}; + +const withComposedHandler = ( + originalHandler: ((event: TEvent) => void) | undefined, + nextHandler: (event: TEvent) => void +) => { + return (event: TEvent) => { + originalHandler?.(event); + nextHandler(event); + }; +}; + +export const PopoverRootProvider = ({ children, value }: PopoverRootProviderProps) => { + return createElement(PopoverRootContext.Provider, { value }, children); +}; + +export const PopoverContentProvider = ({ children, value }: PopoverContentProviderProps) => { + return createElement(PopoverContentContext.Provider, { value }, children); +}; + +export const usePopoverRoot = ({ + defaultOpen = false, + onOpenChange, + open: openProp +}: PopoverProps): UsePopoverRootReturn => { + const isControlled = typeof openProp === 'boolean'; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [triggerElement, setTriggerElement] = useState(null); + const idBase = useId().replaceAll(':', ''); + const open = isControlled ? openProp : uncontrolledOpen; + + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); + + const recordTriggerElement = useCallback((element: HTMLElement | null) => { + setTriggerElement((currentElement) => (currentElement === element ? currentElement : element)); + }, []); + + const contextValue = useMemo( + () => ({ + contentId: `popover-${idBase}-content`, + open, + recordTriggerElement, + setOpen, + triggerElement + }), + [idBase, open, recordTriggerElement, setOpen, triggerElement] + ); + + return { + contextValue, + rootProps: { + open, + onOpenChange: setOpen + } + }; +}; + +const usePopoverRootContext = (): PopoverRootContextValue => { + const context = useContext(PopoverRootContext); + + if (!context) { + throw new Error('Popover compound components must be rendered inside .'); + } + + return context; +}; + +const usePopoverContentContext = (): PopoverContentContextValue => { + const context = useContext(PopoverContentContext); + + if (!context) { + throw new Error('Popover slot components must be rendered inside .'); + } + + return context; +}; + +export const usePopoverTrigger = ({ + asChild = true, + children, + className, + disabled = false +}: PopoverTriggerProps): UsePopoverTriggerReturn => { + const { contentId, open, recordTriggerElement, setOpen } = usePopoverRootContext(); + + const anchorRef = useCallback( + (element: HTMLSpanElement | null) => { + recordTriggerElement(getTriggerElementFromAnchor(element)); + }, + [recordTriggerElement] + ); + + const toggleOpen = useCallback( + (element: HTMLElement) => { + if (disabled) { + return; + } + + recordTriggerElement(element); + setOpen(!open); + }, + [disabled, open, recordTriggerElement, setOpen] + ); + + const handleIntrinsicClick = useCallback( + (event: MouseEvent) => { + if (event.defaultPrevented || disabled) { + return; + } + + if (event.currentTarget.tagName === 'INPUT' && event.detail === 0) { + return; + } + + toggleOpen(event.currentTarget); + }, + [disabled, toggleOpen] + ); + + const handleIntrinsicFocus = useCallback( + (event: FocusEvent) => { + if (disabled) { + return; + } + + recordTriggerElement(event.currentTarget); + }, + [disabled, recordTriggerElement] + ); + + const handleIntrinsicKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.defaultPrevented || disabled) { + return; + } + + const currentTarget = event.currentTarget; + const isButton = currentTarget.tagName === 'BUTTON'; + const isAnchor = currentTarget.tagName === 'A'; + const isInput = currentTarget.tagName === 'INPUT'; + + if (event.key === ' ' && !isButton && !isInput) { + event.preventDefault(); + toggleOpen(currentTarget); + } + + if (event.key === 'Enter' && !isButton && !isAnchor && !isInput) { + event.preventDefault(); + toggleOpen(currentTarget); + } + }, + [disabled, toggleOpen] + ); + + if (asChild && isValidElement(children)) { + const triggerChild = children as PopoverTriggerElement; + const childClassName = cn(popoverTriggerVariants(), triggerChild.props.className, className); + const childProps: PopoverTriggerElement['props'] = { + ...triggerChild.props, + className: childClassName, + 'aria-controls': contentId, + 'aria-expanded': open, + 'aria-haspopup': 'dialog', + 'aria-disabled': disabled || undefined, + onClick: withComposedHandler(triggerChild.props.onClick, handleIntrinsicClick), + onFocus: withComposedHandler(triggerChild.props.onFocus, handleIntrinsicFocus), + onKeyDown: withComposedHandler(triggerChild.props.onKeyDown, handleIntrinsicKeyDown) + }; + + if (!isIntrinsicInteractiveTrigger(triggerChild)) { + childProps.role = triggerChild.props.role ?? 'button'; + childProps.tabIndex = disabled ? -1 : (triggerChild.props.tabIndex ?? 0); + } + + if (disabled) { + childProps['data-disabled'] = 'true'; + } + + if (isHtmlTag(triggerChild, 'button')) { + childProps.disabled = disabled; + childProps.type = 'button'; + } + + return { + asChild: true, + anchorClassName: 'inline-flex', + anchorRef, + buttonProps: null, + child: cloneElement(triggerChild, childProps) + }; + } + + return { + asChild: false, + anchorClassName: 'inline-flex', + anchorRef, + child: null, + buttonProps: { + 'aria-controls': contentId, + 'aria-disabled': disabled || undefined, + 'aria-expanded': open, + 'aria-haspopup': 'dialog', + 'data-disabled': disabled ? 'true' : undefined, + className: cn( + popoverTriggerVariants(), + 'inline-flex min-h-11 items-center justify-center rounded-md px-3 py-2 text-sm', + className + ), + disabled, + onClick: handleIntrinsicClick, + onFocus: handleIntrinsicFocus, + type: 'button' + } + }; +}; + +export const usePopoverContent = ({ + ariaDescribedBy, + ariaLabel, + ariaLabelledBy, + children, + className, + color = 'neutral', + offset = 8, + placement = 'bottom', + radius = 'lg', + shadow = 'md', + size = 'md', + variant = 'default' +}: PopoverContentProps): UsePopoverContentReturn => { + const { contentId, triggerElement } = usePopoverRootContext(); + const idBase = useId().replaceAll(':', ''); + const hasHeader = hasPopoverSlot({ children, displayName: 'Popover.Header' }); + const hasBody = hasPopoverSlot({ children, displayName: 'Popover.Body' }); + const { align, side } = getPlacementConfig(placement); + const headerId = `popover-${idBase}-header`; + const bodyId = `popover-${idBase}-body`; + const labelledBy = ariaLabelledBy ?? (hasHeader ? headerId : undefined); + const describedBy = getMergedIds([hasBody ? bodyId : undefined, ariaDescribedBy]); + const closestDarkContainer = typeof document === 'undefined' ? null : triggerElement?.closest('.dark'); + const needsScopedDarkPortal = Boolean( + closestDarkContainer && closestDarkContainer.tagName !== 'HTML' && closestDarkContainer.tagName !== 'BODY' + ); + + if (!labelledBy && !ariaLabel) { + throw new Error('Popover.Content requires Popover.Header, ariaLabel, or ariaLabelledBy for accessible naming.'); + } + + return { + children, + needsScopedDarkPortal, + contextValue: { + bodyId, + color, + headerId, + radius, + shadow, + size, + variant + }, + contentProps: { + id: contentId, + role: 'dialog', + 'aria-modal': false, + 'aria-describedby': describedBy, + 'aria-label': labelledBy ? undefined : ariaLabel, + 'aria-labelledby': labelledBy, + side, + align, + sideOffset: offset, + avoidCollisions: true, + collisionPadding: 8, + className: cn(popoverContentVariants({ color, radius, shadow, size, variant }), className), + onCloseAutoFocus: (event) => { + if (triggerElement) { + event.preventDefault(); + triggerElement.focus(); + } + }, + 'data-color': color, + 'data-radius': radius, + 'data-shadow': shadow, + 'data-size': size, + 'data-variant': variant + } + }; +}; + +export const usePopoverHeader = ({ className }: PopoverHeaderProps): UsePopoverHeaderReturn => { + const { color, headerId, size } = usePopoverContentContext(); + + return { + className: cn(popoverHeaderVariants({ color, size }), className), + id: headerId + }; +}; + +export const usePopoverBody = ({ className }: PopoverBodyProps): UsePopoverBodyReturn => { + const { bodyId, size } = usePopoverContentContext(); + + return { + className: cn(popoverBodyVariants({ size }), className), + id: bodyId + }; +}; + +export const usePopoverFooter = ({ className }: PopoverFooterProps): UsePopoverSlotReturn => { + const { size } = usePopoverContentContext(); + + return { + className: cn(popoverFooterVariants({ size }), className) + }; +}; + +export const usePopoverArrow = ({ className }: PopoverArrowProps): UsePopoverSlotReturn => { + const { color, variant } = usePopoverContentContext(); + + return { + className: cn(popoverArrowVariants({ color, variant }), className) + }; +}; diff --git a/src/index.ts b/src/index.ts index 0e60971d..0d3991fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { Icon } from './components/atoms/icon'; export { IconButton } from './components/atoms/icon-button'; export { Input } from './components/atoms/input'; export { Link } from './components/atoms/link'; +export { Popover } from './components/atoms/popover'; export { Progress } from './components/atoms/progress'; export { Skeleton } from './components/atoms/skeleton'; export { Slider } from './components/atoms/slider'; diff --git a/src/styles/theme.css b/src/styles/theme.css index 8ffd2429..49b4f447 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -519,3 +519,10 @@ @utility max-w-modal-5xl { max-width: min(var(--size-modal-viewport), var(--size-modal-5xl)); } + +@media (prefers-reduced-motion: reduce) { + [data-slot="popover-content"] { + animation: none !important; + transition: none !important; + } +}