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) => (
+
+ {children}
+
+);
+
+/**
+ * ## 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.
+
+
+
+
+
+
+ )
+};
+
+/**
+ * Shows interactive form content inside the floating panel.
+ */
+export const InteractiveFormContent: Story = {
+ render: () => (
+
+
+ Quick edit
+
+
+ Rename project
+
+
+
+
+
+
+
+
+
+
+ )
+};
+
+/**
+ * Shows a custom icon trigger composed through the trigger slot.
+ */
+export const CustomTrigger: Story = {
+ render: () => (
+
+
+
+
+
+ More actions
+
+
+
+
+ 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(
+
+
+ Open details
+
+
+ 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 trigger
+
+
+ Keyboard popover
+
+ Focusable action
+
+
+
+ );
+
+ 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 action
+
+
+ Open outside close
+
+
+ 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 trigger
+
+
+ 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(
+
+
Outside close target
+
+
+ Initially open
+
+
+ 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 trigger
+
+ 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(
+
+
+
+ Open frosted
+
+
+
+ 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(
+
+
+
+ Dark trigger
+
+
+ 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(
+
+
+ Open placed popover
+
+
+ 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
+
+
+ Open 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 label
+
+
+ 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
+ ) : (
+
+ {props.children}
+
+ )}
+
+
+ );
+};
+
+const PopoverContent: FC = (props) => {
+ const { children, contentProps, contextValue, needsScopedDarkPortal } = usePopoverContent(props);
+
+ return (
+
+
+
+ );
+};
+
+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;
+ }
+}