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 | [](https://www.npmjs.com/package/solid-simple-popover)
4 | 
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 |
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 |
--------------------------------------------------------------------------------