├── src ├── vite-env.d.ts ├── App.css ├── index.tsx ├── App.tsx ├── index.css └── lib │ └── index.tsx ├── bun.lockb ├── tsconfig.node.json ├── .gitignore ├── index.html ├── vite.config.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── public └── vite.svg ├── CHANGELOG.md └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elite174/solid-simple-popover/HEAD/bun.lockb -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | [popover] { 2 | margin: 0; 3 | background-color: transparent; 4 | padding: 0; 5 | border: 0; 6 | } 7 | 8 | main { 9 | display: grid; 10 | place-content: center; 11 | height: 100dvh; 12 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | 4 | import App from "./App"; 5 | import "./index.css"; 6 | 7 | const root = document.getElementById("root"); 8 | 9 | render(() => , root!); 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Solid + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solid from "vite-plugin-solid"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | dts({ 8 | entryRoot: "src/lib", 9 | }), 10 | solid(), 11 | ], 12 | build: { 13 | lib: { 14 | entry: "src/lib/index.tsx", 15 | name: "solid-simple-popover", 16 | fileName: "index", 17 | formats: ["es"], 18 | }, 19 | emptyOutDir: true, 20 | outDir: "dist", 21 | minify: false, 22 | rollupOptions: { 23 | external: ["solid-js", "solid-js/web"], 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "jsxImportSource": "solid-js", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "verbatimModuleSyntax": true 24 | }, 25 | "include": ["src"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Popover } from "./lib"; 2 | 3 | import "./App.css"; 4 | 5 | function App() { 6 | let anchorRef: HTMLDivElement | undefined; 7 | 8 | return ( 9 |
10 |
11 | Test anchor 12 |
13 | 14 | [`${anchorName} flip-block`]} 23 | positionVisibility="anchors-visible" 24 | > 25 |
26 | 27 | This div is visible when popover is open! 28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vladislav Lipatov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | button { 26 | display: block; 27 | } 28 | 29 | h1 { 30 | font-size: 3.2em; 31 | line-height: 1.1; 32 | } 33 | 34 | button { 35 | border-radius: 8px; 36 | border: 1px solid transparent; 37 | padding: 0.6em 1.2em; 38 | font-size: 1em; 39 | font-weight: 500; 40 | font-family: inherit; 41 | background-color: #1a1a1a; 42 | cursor: pointer; 43 | transition: border-color 0.25s; 44 | } 45 | button:hover { 46 | border-color: #646cff; 47 | } 48 | button:focus, 49 | button:focus-visible { 50 | outline: 4px auto -webkit-focus-ring-color; 51 | } 52 | 53 | #anchor-element { 54 | border: 1px solid red; 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-simple-popover", 3 | "version": "3.0.0", 4 | "description": "A simple popover component for SolidJS", 5 | "author": "Vladislav Lipatov", 6 | "type": "module", 7 | "files": [ 8 | "dist" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "dist/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "solid": "./dist/index.js", 16 | "import": "./dist/index.js", 17 | "browser": "./dist/index.js", 18 | "types": "./dist/index.d.ts" 19 | } 20 | }, 21 | "private": false, 22 | "sideEffects": false, 23 | "keywords": [ 24 | "solid", 25 | "popover", 26 | "css anchor" 27 | ], 28 | "license": "MIT", 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "homepage": "https://github.com/elite174/solid-simple-popover", 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/elite174/solid-simple-popover" 36 | }, 37 | "bugs": { 38 | "url": "https://github.com/elite174/solid-simple-popover/issues" 39 | }, 40 | "scripts": { 41 | "dev": "vite", 42 | "build": "tsc && vite build", 43 | "preview": "vite preview" 44 | }, 45 | "devDependencies": { 46 | "solid-js": "1.9.3", 47 | "typescript": "5.6.3", 48 | "vite": "5.4.10", 49 | "vite-plugin-dts": "4.3.0", 50 | "vite-plugin-solid": "2.10.2" 51 | }, 52 | "peerDependencies": { 53 | "solid-js": "^1.8" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.0.0 2 | 3 | - Dropped floating UI. Used CSS anchor positioning API. See updated types in README. 4 | 5 | # 2.0.0 6 | 7 | - Now triggerElement is optional. Moreover you can pass a CSS selector for a trigger element, so you have full control over 8 | trigger position. 9 | - Popover is a ParentComponent now, so you should pass only popover content as children. Children won't be evaluated when 10 | popover is closed. 11 | 12 | ```tsx 13 | 14 | 15 |
I'm the content!
16 |
17 | ``` 18 | 19 | # 1.10.0 20 | 21 | - Added `onComputePosition` callback which receives `ComputePositionDataReturn` 22 | 23 | # 1.9.0 24 | 25 | - Popover API is used by default without possibility to disable it. 26 | - Removed props `usePopoverAPI`, `popoverAPIMountFallback`, `mount` 27 | 28 | # 1.8.0 29 | 30 | - `anchorElementSelector` => `anchorElement`. Now you can pass HTML element or CSS selector. 31 | 32 | # 1.7.0 33 | 34 | - Popover API enabled by default with mount fallback to `body` 35 | - Supported multiple trigger events with modifiers 36 | - Supported custom anchor element 37 | 38 | # 1.6.0 39 | 40 | - Added `disabled` prop which disables triggering popover. Popover now also looks at `disabled` state of triggering html element. 41 | 42 | # 1.5.0 43 | 44 | - Added new prop: `closeOnEscape`. If `true` (by default) the popover will be closed if `Escape` key pressed. 45 | - `ignoreOutsideInteraction` => `closeOnOutsideInteraction` (`true` by default) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # solid-simple-popover 2 | 3 | [![version](https://img.shields.io/npm/v/solid-simple-popover?style=for-the-badge)](https://www.npmjs.com/package/solid-simple-popover) 4 | ![npm](https://img.shields.io/npm/dw/solid-simple-popover?style=for-the-badge) 5 | 6 | A really simple and minimalistic popover component for your apps with CSS anchor position support. 7 | 8 | **Warning:** CSS anchor positioninig is not supported [everywhere](https://caniuse.com/css-anchor-positioning), so use the version **v3.0** carefully. Use **v2.0** if wide support needed (with floating ui). 9 | 10 | **V2 docs are [here](https://github.com/elite174/solid-simple-popover/tree/v2)** 11 | 12 | ## Features 13 | 14 | - Minimalistic - no wrapper DOM nodes! 15 | - Popover API support 16 | - Full control over position (CSS Anchor positioning) 17 | - Works with SSR and Astro 18 | - Multiple trigger events with vue-style modifiers 19 | - Custom anchor element 20 | 21 | ### No wrapper nodes 22 | 23 | No extra DOM nodes. Trigger node will have `data-popover-open` attribute, so you can use it in your CSS styles. 24 | 25 | ```tsx 26 | 27 | 28 |
Nice content here
29 |
30 | ``` 31 | 32 | ### Popover API support 33 | 34 | This component uses Popover API by default. 35 | 36 | Don't forget to reset default browser styles for `[popover]`: 37 | 38 | ```css 39 | [popover] { 40 | margin: 0; 41 | background-color: transparent; 42 | padding: 0; 43 | border: none; 44 | } 45 | ``` 46 | 47 | ### Full control over position 48 | 49 | You can pass all the options for positioning. See docs for [computePosition](https://floating-ui.com/docs/computePosition). 50 | 51 | ```tsx 52 | 53 | 58 |
I'm a content
59 |
; 60 | ``` 61 | 62 | ### Multiple trigger events with vue-style modifiers 63 | 64 | You can pass multiple trigger events with modifiers: 65 | 66 | Events support the following modifiers: 67 | 68 | - `capture` 69 | - `once` 70 | - `prevent` 71 | - `stop` 72 | - `passive` 73 | 74 | ```tsx 75 | 76 | 80 |
I'm a content
81 |
82 | ``` 83 | 84 | ### Custom anchor element 85 | 86 | Sometimes it's necessary the anchor element to be different from trigger element. You may pass optional selector to find anchor element: 87 | 88 | ```tsx 89 |
90 | 91 | 96 |
97 | 98 | This div is visible when popover is open! 99 |
100 |
101 | ``` 102 | 103 | ## Installation 104 | 105 | This package has the following peer dependencies: 106 | 107 | ```json 108 | "solid-js": "^1.8" 109 | ``` 110 | 111 | so you need to install required packages by yourself. 112 | 113 | `pnpm i solid-js solid-simple-popover` 114 | 115 | ## Usage 116 | 117 | ```tsx 118 | import { Popover } from "solid-simple-popover"; 119 | 120 | 121 | 129 |
This div is visible when popover is open!
130 |
; 131 | ``` 132 | 133 | ## Types 134 | 135 | ```tsx 136 | import { JSXElement, ParentComponent } from "solid-js"; 137 | type ValidPositionAreaX = 138 | | "left" 139 | | "right" 140 | | "start" 141 | | "end" 142 | | "center" 143 | | "selft-start" 144 | | "self-end" 145 | | "x-start" 146 | | "x-end"; 147 | type ValidPositionAreaY = 148 | | "top" 149 | | "bottom" 150 | | "start" 151 | | "end" 152 | | "center" 153 | | "self-start" 154 | | "self-end" 155 | | "y-start" 156 | | "y-end"; 157 | export type PositionArea = `${ValidPositionAreaY} ${ValidPositionAreaX}`; 158 | export type TargetPositionArea = 159 | | PositionArea 160 | | { 161 | top?: (anchorName: string) => string; 162 | left?: (anchorName: string) => string; 163 | right?: (anchorName: string) => string; 164 | bottom?: (anchorName: string) => string; 165 | }; 166 | export type PopoverProps = { 167 | /** 168 | * HTML Element or CSS selector to find trigger element which triggers popover 169 | */ 170 | triggerElement?: JSXElement; 171 | /** 172 | * HTML element or CSS selector to find anchor element which is used for positioning 173 | * Can be used with Astro, because astro wraps trigger element into astro-slot 174 | * and position breaks 175 | */ 176 | anchorElement?: string | HTMLElement; 177 | open?: boolean; 178 | defaultOpen?: boolean; 179 | /** 180 | * Disables listening to trigger events 181 | * Note: if your trigger element has `disabled` state (like button or input), popover also won't be triggered 182 | */ 183 | disabled?: boolean; 184 | /** 185 | * @default "pointerdown" 186 | * If set to null no event would trigger popover, 187 | * so you need to trigger it mannually. 188 | * Event name or list of event names separated by "|" which triggers popover. 189 | * You may also add modifiers like "capture", "passive", "once", "prevent", "stop" to the event separated by ".": 190 | * @example "pointerdown.capture.once.prevent|click" 191 | */ 192 | triggerEvents?: string | null; 193 | /** 194 | * Close popover on interaction outside 195 | * @default true 196 | * By default when popover is open it will listen to "pointerdown" event outside of popover content and trigger 197 | */ 198 | closeOnOutsideInteraction?: boolean; 199 | /** 200 | * Data attribute name to set on trigger element 201 | * @default "data-popover-open" 202 | */ 203 | dataAttributeName?: string; 204 | /** 205 | * CSS selector to find html element inside content 206 | * Can be used with Astro, because astro wraps element into astro-slot 207 | * and position breaks 208 | */ 209 | contentElementSelector?: string; 210 | /** 211 | * Close popover on escape key press. 212 | * Uses 'keydown' event with 'Escape' key. 213 | * @default true 214 | */ 215 | closeOnEscape?: boolean; 216 | onOpenChange?: (open: boolean) => void; 217 | /** @default absolute */ 218 | targetPosition?: "absolute" | "fixed"; 219 | /** 220 | * @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-area 221 | * @default "end center" 222 | */ 223 | targetPositionArea?: TargetPositionArea; 224 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-visibility */ 225 | positionVisibility?: "always" | "anchors-visible" | "no-overflow"; 226 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-try-fallbacks */ 227 | positionTryFallbacks?: (anchorName: string) => string[]; 228 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-try-order */ 229 | positionTryOrder?: "normal" | "most-width" | "most-height" | "most-block-size" | "most-inline-size"; 230 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-anchor-size */ 231 | targetWidth?: string; 232 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-anchor-size */ 233 | targetHeight?: string; 234 | }; 235 | export declare const Popover: ParentComponent; 236 | ``` 237 | 238 | ## License 239 | 240 | MIT 241 | -------------------------------------------------------------------------------- /src/lib/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type JSXElement, 3 | type ChildrenReturn, 4 | Show, 5 | createEffect, 6 | createSignal, 7 | onCleanup, 8 | createUniqueId, 9 | createComputed, 10 | on, 11 | children, 12 | mergeProps, 13 | type ParentComponent, 14 | untrack, 15 | } from "solid-js"; 16 | 17 | type ValidPositionAreaX = 18 | | "left" 19 | | "right" 20 | | "start" 21 | | "end" 22 | | "center" 23 | | "selft-start" 24 | | "self-end" 25 | | "x-start" 26 | | "x-end"; 27 | type ValidPositionAreaY = 28 | | "top" 29 | | "bottom" 30 | | "start" 31 | | "end" 32 | | "center" 33 | | "self-start" 34 | | "self-end" 35 | | "y-start" 36 | | "y-end"; 37 | 38 | export type PositionArea = `${ValidPositionAreaY} ${ValidPositionAreaX}`; 39 | export type TargetPositionArea = 40 | | PositionArea 41 | | { 42 | top?: (anchorName: string) => string; 43 | left?: (anchorName: string) => string; 44 | right?: (anchorName: string) => string; 45 | bottom?: (anchorName: string) => string; 46 | }; 47 | 48 | export type PopoverProps = { 49 | /** 50 | * HTML Element or CSS selector to find trigger element which triggers popover 51 | */ 52 | triggerElement?: JSXElement; 53 | /** 54 | * HTML element or CSS selector to find anchor element which is used for positioning 55 | * Can be used with Astro, because astro wraps trigger element into astro-slot 56 | * and position breaks 57 | */ 58 | anchorElement?: string | HTMLElement; 59 | open?: boolean; 60 | defaultOpen?: boolean; 61 | /** 62 | * Disables listening to trigger events 63 | * Note: if your trigger element has `disabled` state (like button or input), popover also won't be triggered 64 | */ 65 | disabled?: boolean; 66 | /** 67 | * @default "pointerdown" 68 | * If set to null no event would trigger popover, 69 | * so you need to trigger it mannually. 70 | * Event name or list of event names separated by "|" which triggers popover. 71 | * You may also add modifiers like "capture", "passive", "once", "prevent", "stop" to the event separated by ".": 72 | * @example "pointerdown.capture.once.prevent|click" 73 | */ 74 | triggerEvents?: string | null; 75 | /** 76 | * Close popover on interaction outside 77 | * @default true 78 | * By default when popover is open it will listen to "pointerdown" event outside of popover content and trigger 79 | */ 80 | closeOnOutsideInteraction?: boolean; 81 | /** 82 | * Data attribute name to set on trigger element 83 | * @default "data-popover-open" 84 | */ 85 | dataAttributeName?: string; 86 | /** 87 | * CSS selector to find html element inside content 88 | * Can be used with Astro, because astro wraps element into astro-slot 89 | * and position breaks 90 | */ 91 | contentElementSelector?: string; 92 | /** 93 | * Close popover on escape key press. 94 | * Uses 'keydown' event with 'Escape' key. 95 | * @default true 96 | */ 97 | closeOnEscape?: boolean; 98 | onOpenChange?: (open: boolean) => void; 99 | /** @default absolute */ 100 | targetPosition?: "absolute" | "fixed"; 101 | /** 102 | * @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-area 103 | * @default "end center" 104 | */ 105 | targetPositionArea?: TargetPositionArea; 106 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-visibility */ 107 | positionVisibility?: "always" | "anchors-visible" | "no-overflow"; 108 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-try-fallbacks */ 109 | positionTryFallbacks?: (anchorName: string) => string[]; 110 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-position-try-order */ 111 | positionTryOrder?: "normal" | "most-width" | "most-height" | "most-block-size" | "most-inline-size"; 112 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-anchor-size */ 113 | targetWidth?: string; 114 | /** @see https://css-tricks.com/css-anchor-positioning-guide/#aa-anchor-size */ 115 | targetHeight?: string; 116 | }; 117 | 118 | const getElement = (element: JSXElement): Element | undefined | null => { 119 | if (typeof element === "string") return document.querySelector(element); 120 | 121 | if (element !== null && element !== undefined && !(element instanceof HTMLElement)) 122 | throw new Error("trigger must be an HTML element or null or undefined"); 123 | 124 | return element; 125 | }; 126 | 127 | const getContentElement = (childrenReturn: ChildrenReturn, elementSelector?: string): HTMLElement => { 128 | let element = childrenReturn(); 129 | 130 | if (!(element instanceof HTMLElement)) throw new Error("content must be HTML element"); 131 | 132 | if (elementSelector) { 133 | element = element.matches(elementSelector) ? element : element.querySelector(elementSelector); 134 | 135 | if (!(element instanceof HTMLElement)) throw new Error(`Unable to find element with selector "${elementSelector}"`); 136 | } 137 | 138 | return element; 139 | }; 140 | 141 | const DEFAULT_PROPS = Object.freeze({ 142 | triggerEvents: "pointerdown", 143 | dataAttributeName: "data-popover-open", 144 | closeOnEscape: true, 145 | closeOnOutsideInteraction: true, 146 | computePositionOptions: { 147 | /** 148 | * Default position here is absolute, because there might be some bugs in safari with "fixed" position 149 | * @see https://stackoverflow.com/questions/65764243/position-fixed-within-a-display-grid-on-safari 150 | */ 151 | strategy: "absolute" as const, 152 | }, 153 | }) satisfies Partial; 154 | 155 | export const Popover: ParentComponent = (initialProps) => { 156 | const props = mergeProps(DEFAULT_PROPS, initialProps); 157 | const [open, setOpen] = createSignal(props.open ?? props.defaultOpen ?? false); 158 | 159 | // sync state with props 160 | createComputed( 161 | on( 162 | () => Boolean(props.open), 163 | (isOpen) => { 164 | setOpen(isOpen); 165 | props.onOpenChange?.(isOpen); 166 | }, 167 | { defer: true } 168 | ) 169 | ); 170 | 171 | createEffect(() => { 172 | const events = (props.triggerEvents === undefined ? DEFAULT_PROPS.triggerEvents : props.triggerEvents)?.split("|"); 173 | 174 | if (events === undefined || events.length === 0) return; 175 | if (props.disabled) return; 176 | 177 | const abortController = new AbortController(); 178 | const trigger = getElement(props.triggerElement); 179 | 180 | if (!(trigger instanceof HTMLElement)) return; 181 | 182 | events.forEach((event) => { 183 | const [eventName, ...modifiers] = event.split("."); 184 | const modifiersSet = new Set(modifiers); 185 | 186 | trigger.addEventListener( 187 | eventName, 188 | (e: Event) => { 189 | if (modifiersSet.has("prevent")) e.preventDefault(); 190 | if (modifiersSet.has("stop")) e.stopPropagation(); 191 | 192 | // don't trigger if trigger is disabled 193 | if (e.target && "disabled" in e.target && e.target.disabled) return; 194 | 195 | const newOpenValue = !open(); 196 | // if uncontrolled, set open state 197 | if (props.open === undefined) setOpen(newOpenValue); 198 | props.onOpenChange?.(newOpenValue); 199 | }, 200 | { 201 | signal: abortController.signal, 202 | capture: modifiersSet.has("capture"), 203 | passive: modifiersSet.has("passive"), 204 | once: modifiersSet.has("once"), 205 | } 206 | ); 207 | }); 208 | 209 | onCleanup(() => abortController.abort()); 210 | }); 211 | 212 | createEffect(() => { 213 | const dataAttributeName = props.dataAttributeName; 214 | const trigger = getElement(props.triggerElement); 215 | 216 | // if there's no trigger no need to set an attribute 217 | // Should we set it on anchor element? 218 | if (!(trigger instanceof HTMLElement)) return; 219 | 220 | createEffect(() => trigger.setAttribute(dataAttributeName, String(open()))); 221 | 222 | onCleanup(() => trigger.removeAttribute(dataAttributeName)); 223 | }); 224 | 225 | return ( 226 | 227 | {(_) => { 228 | const resolvedContent = children(() => props.children); 229 | 230 | createEffect(() => { 231 | const trigger = getElement(props.triggerElement); 232 | const content = getContentElement(resolvedContent, props.contentElementSelector); 233 | const anchorElement = props.anchorElement 234 | ? typeof props.anchorElement === "string" 235 | ? document.querySelector(props.anchorElement) 236 | : props.anchorElement 237 | : trigger; 238 | 239 | if (!(anchorElement instanceof HTMLElement)) throw new Error("Unable to find anchor element"); 240 | 241 | const anchorName = `--anchor-${String(Math.random()).slice(2, 6)}`; 242 | 243 | // @ts-expect-error ts(2339) 244 | anchorElement.style.anchorName = anchorName; 245 | // @ts-expect-error ts(2339) 246 | content.style.positionAnchor = anchorName; 247 | 248 | createEffect(() => { 249 | content.style.position = props.targetPosition ?? "absolute"; 250 | }); 251 | 252 | createEffect(() => { 253 | if (typeof props.targetPositionArea === "string") { 254 | // @ts-expect-error ts(2339) 255 | content.style.positionArea = props.targetPositionArea ?? ""; 256 | 257 | onCleanup(() => { 258 | // @ts-expect-error ts(2339) 259 | content.style.positionArea = ""; 260 | }); 261 | } else if (typeof props.targetPositionArea === "object") { 262 | const targetPositionAreaObject = props.targetPositionArea; 263 | 264 | content.style.top = untrack(() => targetPositionAreaObject.top?.(anchorName)) ?? ""; 265 | content.style.left = untrack(() => targetPositionAreaObject.left?.(anchorName)) ?? ""; 266 | content.style.right = untrack(() => targetPositionAreaObject.right?.(anchorName)) ?? ""; 267 | content.style.bottom = untrack(() => targetPositionAreaObject.bottom?.(anchorName)) ?? ""; 268 | 269 | onCleanup(() => { 270 | content.style.top = ""; 271 | content.style.left = ""; 272 | content.style.right = ""; 273 | content.style.bottom = ""; 274 | }); 275 | } else { 276 | // @ts-expect-error ts(2339) 277 | content.style.positionArea = "end center"; 278 | 279 | onCleanup(() => { 280 | // @ts-expect-error ts(2339) 281 | content.style.positionArea = ""; 282 | }); 283 | } 284 | }); 285 | 286 | createEffect(() => { 287 | // @ts-expect-error ts(2339) 288 | content.style.positionVisibility = props.positionVisibility ?? ""; 289 | }); 290 | 291 | createEffect(() => { 292 | // @ts-expect-error ts(2339) 293 | content.style.positionTryFallbacks = untrack(() => props.positionTryFallbacks!(anchorName).join(",")) ?? ""; 294 | }); 295 | 296 | createEffect(() => { 297 | // @ts-expect-error ts(2339) 298 | content.style.positionTryOrder = props.positionTryOrder ?? ""; 299 | }); 300 | 301 | createEffect(() => { 302 | content.style.width = props.targetWidth ?? ""; 303 | }); 304 | 305 | createEffect(() => { 306 | content.style.height = props.targetHeight ?? ""; 307 | }); 308 | 309 | createEffect(() => { 310 | if (!props.closeOnOutsideInteraction) return; 311 | if (!trigger) return; 312 | 313 | // Handle click outside correctly 314 | const handleClickOutside = (e: MouseEvent) => { 315 | const eventPath = e.composedPath(); 316 | 317 | if (eventPath.includes(trigger) || eventPath.includes(content)) return; 318 | 319 | // if uncontrolled, close popover 320 | if (props.open === undefined) setOpen(false); 321 | props.onOpenChange?.(false); 322 | }; 323 | 324 | document.addEventListener("pointerdown", handleClickOutside); 325 | onCleanup(() => document.removeEventListener("pointerdown", handleClickOutside)); 326 | }); 327 | 328 | createEffect(() => { 329 | if (!trigger) return; 330 | 331 | const popoverId = createUniqueId(); 332 | 333 | trigger.setAttribute("popovertarget", popoverId); 334 | content.setAttribute("popover", "manual"); 335 | content.setAttribute("id", `popover-${popoverId}`); 336 | 337 | if (!content.matches(":popover-open")) content.showPopover(); 338 | 339 | onCleanup(() => trigger.removeAttribute("popovertarget")); 340 | }); 341 | 342 | // Listen to escape key down to close popup 343 | createEffect(() => { 344 | if (!props.closeOnEscape) return; 345 | 346 | const handleKeydown = (e: KeyboardEvent) => { 347 | if (e.key !== "Escape") return; 348 | 349 | // if content is not in the event path, return 350 | if (e.target instanceof Node && (content.contains(e.target) || trigger?.contains(e.target))) return; 351 | 352 | // if uncontrolled, close popover 353 | if (props.open === undefined) setOpen(false); 354 | props.onOpenChange?.(false); 355 | }; 356 | 357 | document.addEventListener("keydown", handleKeydown); 358 | onCleanup(() => document.removeEventListener("keydown", handleKeydown)); 359 | }); 360 | }); 361 | 362 | return resolvedContent(); 363 | }} 364 | 365 | ); 366 | }; 367 | --------------------------------------------------------------------------------