@rozie-ui/popover-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,302 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
2
+ import { clsx, rozieAttr, useControllableState, useOutsideClick } from "@rozie/runtime-react";
3
+ import "./Popover.css";
4
+ import { arrow, autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/internal/middleware.ts
7
+ /**
8
+ * Build the ordered middleware array for `computePosition`. Pure — no engine
9
+ * import, no DOM read beyond the passed-in arrow element.
10
+ */
11
+ function buildMiddleware(factories, config) {
12
+ const mw = [factories.offset(config.offset)];
13
+ if (!config.disableFlip) mw.push(factories.flip());
14
+ if (!config.disableShift) mw.push(factories.shift());
15
+ if (config.arrow && config.arrowEl) mw.push(factories.arrow({ element: config.arrowEl }));
16
+ return mw;
17
+ }
18
+ //#endregion
19
+ //#region src/Popover.tsx
20
+ const Popover = forwardRef(function Popover(_props, ref) {
21
+ const props = {
22
+ ..._props,
23
+ placement: _props.placement ?? "bottom",
24
+ trigger: _props.trigger ?? "click",
25
+ offset: _props.offset ?? 8,
26
+ disableFlip: _props.disableFlip ?? false,
27
+ disableShift: _props.disableShift ?? false,
28
+ arrow: _props.arrow ?? false,
29
+ disabled: _props.disabled ?? false,
30
+ modal: _props.modal ?? false,
31
+ strategy: _props.strategy ?? "absolute"
32
+ };
33
+ const attrs = (() => {
34
+ const { open, placement, trigger, offset, disableFlip, disableShift, arrow, disabled, modal, strategy, defaultValue, onOpenChange, defaultOpen, ...rest } = _props;
35
+ return rest;
36
+ })();
37
+ const anchorNode = useRef(null);
38
+ const floatingNode = useRef(null);
39
+ const arrowNode = useRef(null);
40
+ const stopAutoUpdate = useRef(null);
41
+ const lastFocusedEl = useRef(null);
42
+ const [open, setOpen] = useControllableState({
43
+ value: props.open,
44
+ defaultValue: props.defaultOpen ?? false,
45
+ onValueChange: props.onOpenChange
46
+ });
47
+ const _openRef = useRef(open);
48
+ _openRef.current = open;
49
+ const anchorEl = useRef(null);
50
+ const floatingEl = useRef(null);
51
+ const arrowEl = useRef(null);
52
+ const _watch0First = useRef(true);
53
+ const _watch1First = useRef(true);
54
+ const _watch2First = useRef(true);
55
+ const _watch3First = useRef(true);
56
+ const _watch4First = useRef(true);
57
+ const _watch5First = useRef(true);
58
+ function deepActiveElement() {
59
+ let el = document.activeElement;
60
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
61
+ return el;
62
+ }
63
+ function requestOpen(next) {
64
+ if (open === next) return;
65
+ if (next && props.trigger === "click") lastFocusedEl.current = deepActiveElement();
66
+ setOpen(next);
67
+ props.onChange && props.onChange(next);
68
+ if (!next && props.trigger === "click" && lastFocusedEl.current && lastFocusedEl.current.isConnected && typeof lastFocusedEl.current.focus === "function") lastFocusedEl.current.focus();
69
+ if (!next) lastFocusedEl.current = null;
70
+ }
71
+ function applyPosition(x, y, middlewareData) {
72
+ if (!floatingNode.current) return;
73
+ floatingNode.current.style.left = x + "px";
74
+ floatingNode.current.style.top = y + "px";
75
+ if (arrowNode.current && middlewareData && middlewareData.arrow) {
76
+ const ax = middlewareData.arrow.x;
77
+ const ay = middlewareData.arrow.y;
78
+ arrowNode.current.style.left = ax == null ? "" : ax + "px";
79
+ arrowNode.current.style.top = ay == null ? "" : ay + "px";
80
+ }
81
+ }
82
+ function position() {
83
+ if (!anchorNode.current || !floatingNode.current) return;
84
+ const middleware = buildMiddleware({
85
+ offset,
86
+ flip,
87
+ shift,
88
+ arrow
89
+ }, {
90
+ offset: props.offset,
91
+ disableFlip: props.disableFlip,
92
+ disableShift: props.disableShift,
93
+ arrow: props.arrow,
94
+ arrowEl: arrowNode.current
95
+ });
96
+ if (props.strategy === "fixed") floatingNode.current.style.position = "fixed";
97
+ else floatingNode.current.style.position = "";
98
+ let opts = null;
99
+ opts = {
100
+ placement: props.placement,
101
+ strategy: props.strategy,
102
+ middleware
103
+ };
104
+ computePosition(anchorNode.current, floatingNode.current, opts).then((result) => {
105
+ applyPosition(result.x, result.y, result.middlewareData);
106
+ });
107
+ }
108
+ const startTracking = useCallback(() => {
109
+ if (!anchorNode.current || !floatingNode.current) return;
110
+ if (stopAutoUpdate.current) {
111
+ stopAutoUpdate.current();
112
+ stopAutoUpdate.current = null;
113
+ }
114
+ stopAutoUpdate.current = autoUpdate(anchorNode.current, floatingNode.current, position);
115
+ }, [position]);
116
+ const stopTracking = useCallback(() => {
117
+ if (stopAutoUpdate.current) {
118
+ stopAutoUpdate.current();
119
+ stopAutoUpdate.current = null;
120
+ }
121
+ }, []);
122
+ const onAnchorClick = useCallback(() => {
123
+ if (props.disabled) return;
124
+ requestOpen(!open);
125
+ }, [
126
+ open,
127
+ props.disabled,
128
+ requestOpen
129
+ ]);
130
+ const onAnchorPointerEnter = useCallback(() => {
131
+ if (props.disabled) return;
132
+ requestOpen(true);
133
+ }, [props.disabled, requestOpen]);
134
+ const onAnchorPointerLeave = useCallback(() => {
135
+ if (props.disabled) return;
136
+ requestOpen(false);
137
+ }, [props.disabled, requestOpen]);
138
+ const onAnchorFocus = useCallback(() => {
139
+ if (props.disabled) return;
140
+ requestOpen(true);
141
+ }, [props.disabled, requestOpen]);
142
+ const onAnchorBlur = useCallback(() => {
143
+ if (props.disabled) return;
144
+ requestOpen(false);
145
+ }, [props.disabled, requestOpen]);
146
+ const dismiss = useCallback(() => {
147
+ requestOpen(false);
148
+ }, [requestOpen]);
149
+ function isTooltip() {
150
+ return props.trigger === "hover" || props.trigger === "focus";
151
+ }
152
+ function floatingRole() {
153
+ return isTooltip() ? "tooltip" : props.modal ? "dialog" : void 0;
154
+ }
155
+ function show() {
156
+ if (!props.disabled) requestOpen(true);
157
+ }
158
+ function hide() {
159
+ requestOpen(false);
160
+ }
161
+ function toggle() {
162
+ if (!props.disabled) requestOpen(!open);
163
+ }
164
+ function reposition() {
165
+ position();
166
+ }
167
+ useEffect(() => {
168
+ anchorNode.current = anchorEl.current;
169
+ if (_openRef.current && !props.disabled) {
170
+ floatingNode.current = floatingEl.current;
171
+ arrowNode.current = arrowEl.current;
172
+ startTracking();
173
+ }
174
+ return () => {
175
+ stopTracking();
176
+ };
177
+ }, []);
178
+ useEffect(() => {
179
+ if (_watch0First.current) {
180
+ _watch0First.current = false;
181
+ return;
182
+ }
183
+ if (open && !props.disabled) queueMicrotask(() => {
184
+ if (!open || props.disabled) return;
185
+ floatingNode.current = floatingEl.current;
186
+ arrowNode.current = arrowEl.current;
187
+ startTracking();
188
+ });
189
+ else stopTracking();
190
+ }, [open]);
191
+ useEffect(() => {
192
+ if (_watch1First.current) {
193
+ _watch1First.current = false;
194
+ return;
195
+ }
196
+ if (open) position();
197
+ }, [props.placement]);
198
+ useEffect(() => {
199
+ if (_watch2First.current) {
200
+ _watch2First.current = false;
201
+ return;
202
+ }
203
+ if (open) position();
204
+ }, [props.offset]);
205
+ useEffect(() => {
206
+ if (_watch3First.current) {
207
+ _watch3First.current = false;
208
+ return;
209
+ }
210
+ if (open) position();
211
+ }, [props.disableFlip]);
212
+ useEffect(() => {
213
+ if (_watch4First.current) {
214
+ _watch4First.current = false;
215
+ return;
216
+ }
217
+ if (open) position();
218
+ }, [props.disableShift]);
219
+ useEffect(() => {
220
+ if (_watch5First.current) {
221
+ _watch5First.current = false;
222
+ return;
223
+ }
224
+ if (open) position();
225
+ }, [props.strategy]);
226
+ useEffect(() => {
227
+ if (!open) return;
228
+ const _rozieHandler = ($event) => {
229
+ if ($event.key !== "Escape") return;
230
+ dismiss($event);
231
+ };
232
+ document.addEventListener("keydown", _rozieHandler);
233
+ return () => document.removeEventListener("keydown", _rozieHandler);
234
+ }, [dismiss, open]);
235
+ useOutsideClick([anchorEl, floatingEl], dismiss, () => !!open);
236
+ const _rozieExposeRef = useRef({
237
+ show,
238
+ hide,
239
+ toggle,
240
+ reposition
241
+ });
242
+ _rozieExposeRef.current = {
243
+ show,
244
+ hide,
245
+ toggle,
246
+ reposition
247
+ };
248
+ useImperativeHandle(ref, () => ({
249
+ show: (...args) => _rozieExposeRef.current.show(...args),
250
+ hide: (...args) => _rozieExposeRef.current.hide(...args),
251
+ toggle: (...args) => _rozieExposeRef.current.toggle(...args),
252
+ reposition: (...args) => _rozieExposeRef.current.reposition(...args)
253
+ }), []);
254
+ return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", {
255
+ ...attrs,
256
+ className: clsx("rozie-popover", attrs.className),
257
+ "data-rozie-s-c6cf02ea": "",
258
+ children: [/* @__PURE__ */ jsx("div", {
259
+ className: "rozie-popover-anchor",
260
+ ref: anchorEl,
261
+ "aria-haspopup": "dialog",
262
+ "aria-expanded": !!open,
263
+ "aria-describedby": rozieAttr(isTooltip() && open ? "rozie-popover-floating" : void 0),
264
+ onClick: ($event) => {
265
+ props.trigger === "click" && onAnchorClick();
266
+ },
267
+ onPointerEnter: ($event) => {
268
+ props.trigger === "hover" && onAnchorPointerEnter();
269
+ },
270
+ onPointerLeave: ($event) => {
271
+ props.trigger === "hover" && onAnchorPointerLeave();
272
+ },
273
+ onFocus: ($event) => {
274
+ props.trigger === "focus" && onAnchorFocus();
275
+ },
276
+ onBlur: ($event) => {
277
+ props.trigger === "focus" && onAnchorBlur();
278
+ },
279
+ "data-rozie-s-c6cf02ea": "",
280
+ children: (props.renderAnchor ?? props.slots?.["anchor"])?.({
281
+ open,
282
+ toggle,
283
+ show,
284
+ hide
285
+ })
286
+ }), !!(open && !props.disabled) && /* @__PURE__ */ jsxs("div", {
287
+ className: "rozie-popover-floating",
288
+ ref: floatingEl,
289
+ id: "rozie-popover-floating",
290
+ role: rozieAttr(floatingRole()),
291
+ "aria-modal": !!(floatingRole() === "dialog"),
292
+ "data-rozie-s-c6cf02ea": "",
293
+ children: [!!props.arrow && /* @__PURE__ */ jsx("div", {
294
+ className: "rozie-popover-arrow",
295
+ ref: arrowEl,
296
+ "data-rozie-s-c6cf02ea": ""
297
+ }), typeof (props.children ?? props.slots?.[""]) === "function" ? (props.children ?? props.slots?.[""])() : props.children ?? props.slots?.[""]]
298
+ })]
299
+ }) });
300
+ });
301
+ //#endregion
302
+ export { Popover, Popover as default };
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@rozie-ui/popover-react",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "description": "Idiomatic React popover/tooltip wrapping @floating-ui/dom — one Rozie source compiled to React.",
8
+ "keywords": [
9
+ "rozie",
10
+ "rozie-ui",
11
+ "floating-ui",
12
+ "popover",
13
+ "tooltip",
14
+ "dropdown",
15
+ "positioning",
16
+ "headless",
17
+ "component",
18
+ "react"
19
+ ],
20
+ "author": "One Learning Community (https://github.com/One-Learning-Community)",
21
+ "homepage": "https://github.com/One-Learning-Community/rozie.js#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/One-Learning-Community/rozie.js.git",
25
+ "directory": "packages/ui/popover/packages/react"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/One-Learning-Community/rozie.js/issues"
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.mts",
36
+ "import": "./dist/index.mjs",
37
+ "require": "./dist/index.cjs"
38
+ },
39
+ "./themes/*": "./src/themes/*",
40
+ "./rozie-manifest.json": "./rozie-manifest.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "src",
45
+ "rozie-manifest.json"
46
+ ],
47
+ "dependencies": {
48
+ "@rozie/runtime-react": "0.1.4"
49
+ },
50
+ "peerDependencies": {
51
+ "react": "^18.2 || ^19",
52
+ "react-dom": "^18.2 || ^19",
53
+ "@floating-ui/dom": "^1.7.2"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "react": {
57
+ "optional": false
58
+ },
59
+ "react-dom": {
60
+ "optional": false
61
+ },
62
+ "@floating-ui/dom": {
63
+ "optional": false
64
+ }
65
+ },
66
+ "devDependencies": {
67
+ "@floating-ui/dom": "^1.7.2",
68
+ "@types/react": "^18",
69
+ "@types/react-dom": "^18"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public"
73
+ },
74
+ "sideEffects": [
75
+ "*.css",
76
+ "**/*.css"
77
+ ],
78
+ "scripts": {
79
+ "build": "tsdown",
80
+ "typecheck": "tsc --noEmit"
81
+ }
82
+ }
@@ -0,0 +1,112 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "Popover",
4
+ "props": [
5
+ {
6
+ "name": "open",
7
+ "isModel": true,
8
+ "required": false,
9
+ "type": "Boolean"
10
+ },
11
+ {
12
+ "name": "placement",
13
+ "isModel": false,
14
+ "required": false,
15
+ "type": "String"
16
+ },
17
+ {
18
+ "name": "trigger",
19
+ "isModel": false,
20
+ "required": false,
21
+ "type": "String"
22
+ },
23
+ {
24
+ "name": "offset",
25
+ "isModel": false,
26
+ "required": false,
27
+ "type": "Number"
28
+ },
29
+ {
30
+ "name": "disableFlip",
31
+ "isModel": false,
32
+ "required": false,
33
+ "type": "Boolean"
34
+ },
35
+ {
36
+ "name": "disableShift",
37
+ "isModel": false,
38
+ "required": false,
39
+ "type": "Boolean"
40
+ },
41
+ {
42
+ "name": "arrow",
43
+ "isModel": false,
44
+ "required": false,
45
+ "type": "Boolean"
46
+ },
47
+ {
48
+ "name": "disabled",
49
+ "isModel": false,
50
+ "required": false,
51
+ "type": "Boolean"
52
+ },
53
+ {
54
+ "name": "modal",
55
+ "isModel": false,
56
+ "required": false,
57
+ "type": "Boolean"
58
+ },
59
+ {
60
+ "name": "strategy",
61
+ "isModel": false,
62
+ "required": false,
63
+ "type": "String"
64
+ }
65
+ ],
66
+ "slots": [
67
+ {
68
+ "name": "anchor",
69
+ "params": [
70
+ {
71
+ "name": "open"
72
+ },
73
+ {
74
+ "name": "toggle"
75
+ },
76
+ {
77
+ "name": "show"
78
+ },
79
+ {
80
+ "name": "hide"
81
+ }
82
+ ],
83
+ "paramTypes": null,
84
+ "isPortal": false,
85
+ "isReactive": false
86
+ },
87
+ {
88
+ "name": "",
89
+ "params": [],
90
+ "paramTypes": null,
91
+ "isPortal": false,
92
+ "isReactive": false
93
+ }
94
+ ],
95
+ "emits": [
96
+ "change"
97
+ ],
98
+ "expose": [
99
+ {
100
+ "name": "show"
101
+ },
102
+ {
103
+ "name": "hide"
104
+ },
105
+ {
106
+ "name": "toggle"
107
+ },
108
+ {
109
+ "name": "reposition"
110
+ }
111
+ ]
112
+ }
@@ -0,0 +1,28 @@
1
+ .rozie-popover[data-rozie-s-c6cf02ea] {
2
+ display: contents;
3
+ }
4
+ .rozie-popover-anchor[data-rozie-s-c6cf02ea] {
5
+ display: inline-block;
6
+ }
7
+ .rozie-popover-floating[data-rozie-s-c6cf02ea] {
8
+ position: absolute;
9
+ left: 0;
10
+ top: 0;
11
+ z-index: var(--rozie-popover-z, 1000);
12
+ width: max-content;
13
+ max-width: var(--rozie-popover-max-width, calc(100vw - 16px));
14
+ background: var(--rozie-popover-bg, #fff);
15
+ color: var(--rozie-popover-color, inherit);
16
+ border: var(--rozie-popover-border, 1px solid rgba(0, 0, 0, 0.12));
17
+ border-radius: var(--rozie-popover-radius, 8px);
18
+ box-shadow: var(--rozie-popover-shadow, 0 8px 24px rgba(0, 0, 0, 0.12));
19
+ padding: var(--rozie-popover-padding, 8px 12px);
20
+ }
21
+ .rozie-popover-arrow[data-rozie-s-c6cf02ea] {
22
+ position: absolute;
23
+ width: var(--rozie-popover-arrow-size, 8px);
24
+ height: var(--rozie-popover-arrow-size, 8px);
25
+ background: var(--rozie-popover-bg, #fff);
26
+ border: var(--rozie-popover-border, 1px solid rgba(0, 0, 0, 0.12));
27
+ transform: rotate(45deg);
28
+ }
@@ -0,0 +1,62 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ForwardRefExoticComponent, RefAttributes } from 'react';
3
+ import type * as React from 'react';
4
+
5
+ export interface PopoverProps {
6
+ /**
7
+ * Whether the floating content is open. The sole `model: true` prop — two-way bind it (`r-model:open` / `v-model:open` / `bind:open` / `[(open)]`) and Popover writes the new state back whenever the trigger or a dismissal toggles it. Left unbound it falls back to an uncontrolled default.
8
+ */
9
+ open?: boolean;
10
+ defaultOpen?: boolean;
11
+ onOpenChange?: (next: boolean) => void;
12
+ /**
13
+ * Floating UI placement of the content relative to the anchor — one of `top`/`right`/`bottom`/`left`, each optionally suffixed `-start`/`-end` (e.g. `bottom-start`). With `disableFlip` off, the content may flip to the opposite side when it would overflow the viewport. Reconciled at runtime.
14
+ */
15
+ placement?: string;
16
+ /**
17
+ * How the anchor opens the content: `'click'` toggles on click, `'hover'` opens on pointer-enter and closes on pointer-leave (tooltip-style), `'focus'` opens on focus and closes on blur. Drives both the gesture handlers and the ARIA role (`'hover'`/`'focus'` → tooltip, `'click'` → popover dialog).
18
+ */
19
+ trigger?: string;
20
+ /**
21
+ * Distance in pixels between the anchor and the floating content (the Floating UI `offset` middleware). Reconciled at runtime.
22
+ */
23
+ offset?: number;
24
+ /**
25
+ * Disable the Floating UI `flip` middleware. By default the content flips to the opposite side of the anchor when it would overflow the viewport; set this to keep it pinned to `placement` regardless.
26
+ */
27
+ disableFlip?: boolean;
28
+ /**
29
+ * Disable the Floating UI `shift` middleware. By default the content shifts along its axis to stay within the viewport; set this to keep it strictly aligned to the anchor.
30
+ */
31
+ disableShift?: boolean;
32
+ /**
33
+ * Opt in to a positioned arrow element. When set, Popover renders an arrow `<div>` and runs the Floating UI `arrow` middleware against it so it points at the anchor. Style it via the `--rozie-popover-*` arrow CSS custom properties.
34
+ */
35
+ arrow?: boolean;
36
+ /**
37
+ * Disable the control entirely: the trigger no longer opens the content and any open content is suppressed.
38
+ */
39
+ disabled?: boolean;
40
+ /**
41
+ * Opt in to modal dialog semantics for a `click` popover. **Off by default:** a click popover is a non-modal, click-outside-dismissable layer, so its panel is rendered role-neutral (the slot content owns its own ARIA role — e.g. a `role="menu"`) and carries NO `aria-modal`. Set `modal` for a genuinely modal dialog popover: the panel then gets `role="dialog"` + `aria-modal="true"`. **Note:** Popover ships no focus trap (it stays a minimal headless primitive); if you set `modal`, provide your own focus containment so the `aria-modal` claim holds. Ignored for `hover`/`focus` triggers (always tooltip-flavored).
42
+ */
43
+ modal?: boolean;
44
+ /**
45
+ * Floating UI positioning strategy — 'absolute' (default) or 'fixed'. Use 'fixed' to escape a scrollable/overflow-clipping ancestor (e.g. a sticky table header). Reconciled at runtime.
46
+ */
47
+ strategy?: string;
48
+ onChange?: (...args: unknown[]) => void;
49
+ renderAnchor?: (params: { open: boolean; toggle: () => void; show: () => void; hide: () => void }) => ReactNode;
50
+ children?: ReactNode;
51
+ slots?: Record<string, () => ReactNode>;
52
+ }
53
+
54
+ export interface PopoverHandle {
55
+ show: (...args: any[]) => any;
56
+ hide: (...args: any[]) => any;
57
+ toggle: (...args: any[]) => any;
58
+ reposition: (...args: any[]) => any;
59
+ }
60
+
61
+ declare const Popover: React.ForwardRefExoticComponent<PopoverProps & React.RefAttributes<PopoverHandle>>;
62
+ export default Popover;