@versini/ui-dialog 8.0.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Arno Versini
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.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # @versini/ui-panel
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@versini/ui-panel?style=flat-square)](https://www.npmjs.com/package/@versini/ui-panel)
4
+ ![npm package minimized gzipped size](<https://img.shields.io/bundlejs/size/%40versini%2Fui-panel?style=flat-square&label=size%20(gzip)>)
5
+
6
+ > An accessible React slide-out panel component built with TypeScript and TailwindCSS.
7
+
8
+ The Panel component provides slide-out panels and drawers with focus management, keyboard navigation, document title management, optional animations, and customizable positioning / sizing.
9
+
10
+ ## Table of Contents
11
+
12
+ - [Table of Contents](#table-of-contents)
13
+ - [Features](#features)
14
+ - [Installation](#installation)
15
+ - [Usage](#usage)
16
+ - [Examples](#examples)
17
+ - [Message Box Variant](#message-box-variant)
18
+ - [Animated Panel (Fade)](#animated-panel-fade)
19
+ - [API](#api)
20
+ - [Panel Props](#panel-props)
21
+
22
+ ## Features
23
+
24
+ - **🪟 Versatile Layouts**: Standard panel and message box variants (`kind` prop)
25
+ - **🎯 Focus Management**: Uses underlying modal primitives for proper focus trapping & return
26
+ - **♿ Accessible**: ARIA compliant structure with heading, description, close control
27
+ - **🎬 Optional Animations**: Slide or fade entrance animations (`animation` / `animationType`)
28
+ - **📐 Responsive Sizing**: Predefined max widths (`small`, `medium`, `large`) above md breakpoint
29
+ - **🧩 Composable**: Footer slot for actions / extra content
30
+ - **🧪 Type Safe**: Fully typed props with inline documentation
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ npm install @versini/ui-panel
36
+ ```
37
+
38
+ > **Note**: This component requires TailwindCSS and the `@versini/ui-styles` plugin for proper styling. See the [installation documentation](https://versini-org.github.io/ui-components/?path=/docs/getting-started-installation--docs) for complete setup instructions.
39
+
40
+ ## Usage
41
+
42
+ ```tsx
43
+ import { Panel } from "@versini/ui-panel";
44
+ import { useState } from "react";
45
+
46
+ function App() {
47
+ const [open, setOpen] = useState(false);
48
+
49
+ return (
50
+ <>
51
+ <button onClick={() => setOpen(true)}>Open Panel</button>
52
+ <Panel title="Panel Title" open={open} onOpenChange={setOpen}>
53
+ Panel content goes here.
54
+ </Panel>
55
+ </>
56
+ );
57
+ }
58
+ ```
59
+
60
+ ## Examples
61
+
62
+ ### Message Box Variant
63
+
64
+ ```tsx
65
+ import { Panel } from "@versini/ui-panel";
66
+ import { useState } from "react";
67
+
68
+ function MessageBoxExample() {
69
+ const [open, setOpen] = useState(false);
70
+ return (
71
+ <>
72
+ <button onClick={() => setOpen(true)}>Show Message</button>
73
+ <Panel
74
+ kind="messagebox"
75
+ title="Session Expired"
76
+ open={open}
77
+ onOpenChange={setOpen}
78
+ footer={
79
+ <div className="flex justify-end gap-2">
80
+ <button
81
+ className="px-3 py-1 rounded bg-surface-lighter"
82
+ onClick={() => setOpen(false)}
83
+ >
84
+ Dismiss
85
+ </button>
86
+ <button className="px-3 py-1 rounded bg-blue-600 text-white">
87
+ Re‑authenticate
88
+ </button>
89
+ </div>
90
+ }
91
+ >
92
+ Your session has expired. Please sign in again to continue.
93
+ </Panel>
94
+ </>
95
+ );
96
+ }
97
+ ```
98
+
99
+ ### Animated Panel (Fade)
100
+
101
+ ```tsx
102
+ <Panel
103
+ title="Animated Panel"
104
+ open={open}
105
+ onOpenChange={setOpen}
106
+ animation
107
+ animationType="fade"
108
+ >
109
+ Content with fade animation.
110
+ </Panel>
111
+ ```
112
+
113
+ ## API
114
+
115
+ ### Panel Props
116
+
117
+ | Prop | Type | Default | Description |
118
+ | --------------- | ------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | `open` | `boolean` | - | Whether the panel is open. |
120
+ | `onOpenChange` | `(open: boolean) => void` | - | Callback fired when open state changes. |
121
+ | `title` | `string` | - | Title displayed in the header (also used to augment `document.title`). |
122
+ | `children` | `React.ReactNode` | - | Main content of the panel. |
123
+ | `footer` | `React.ReactNode` | - | Optional footer content (actions, etc.). |
124
+ | `className` | `string` | - | Extra classes applied to width wrapper (overrides default width). |
125
+ | `borderMode` | `"dark" \| "light"` | `"light"` | Visual style of border / surface. |
126
+ | `kind` | `"panel" \| "messagebox"` | `"panel"` | Layout variant. |
127
+ | `animation` | `boolean` | `false` | Enable entrance animation. |
128
+ | `animationType` | `"slide" \| "fade"` | `"slide"` | Animation style (only when `animation` is true). |
129
+ | `maxWidth` | `"small" \| "medium" \| "large"` | `"medium"` | Max width applied (≥ md breakpoint) for `kind="panel"`. |
130
+ | `initialFocus` | `number \| React.RefObject<HTMLElement \| null>` | `0` | Which element to initially focus when the Panel opens. Can be a tabbable index (0 = close button), a ref to an element, or -1 to disable. |
131
+
132
+ > Also inherits any valid props for the underlying modal primitives where relevant.
@@ -0,0 +1,95 @@
1
+ import { JSX } from 'react/jsx-runtime';
2
+
3
+ export declare const ANIMATION_FADE = "fade";
4
+
5
+ export declare const ANIMATION_SLIDE = "slide";
6
+
7
+ export declare const LARGE = "large";
8
+
9
+ export declare const MEDIUM = "medium";
10
+
11
+ export declare const MESSAGEBOX_CLASSNAME = "av-messagebox";
12
+
13
+ export declare const NONE = "none";
14
+
15
+ export declare const Panel: ({ open, onOpenChange, title, children, footer, borderMode, kind, className, animation, animationType, maxWidth, maxHeight, blurEffect, initialFocus, }: PanelProps) => JSX.Element;
16
+
17
+ export declare const PANEL_CLASSNAME = "av-panel";
18
+
19
+ declare type PanelProps = {
20
+ /**
21
+ * Class name to apply to the Panel - this will ONLY override the default width styles.
22
+ */
23
+ className?: string;
24
+ /**
25
+ * The children to render.
26
+ */
27
+ children: React.ReactNode;
28
+ /**
29
+ * Callback fired when the component is opened or closed.
30
+ * @param open whether or not the menu is open
31
+ */
32
+ onOpenChange: (open: boolean) => void;
33
+ /**
34
+ * Whether or not to open the component..
35
+ * @default false
36
+ */
37
+ open: boolean;
38
+ /**
39
+ * The title to use for the panel.
40
+ */
41
+ title: string;
42
+ /**
43
+ * The type of Panel border.
44
+ */
45
+ borderMode?: "dark" | "light";
46
+ /**
47
+ * The content to render in the footer.
48
+ */
49
+ footer?: React.ReactNode;
50
+ /**
51
+ * The type of Panel. This will change the layout of the Panel.
52
+ */
53
+ kind?: "panel" | "messagebox";
54
+ /**
55
+ * Whether or not to animate the opening and closing of the Panel.
56
+ */
57
+ animation?: boolean;
58
+ /**
59
+ * The type of animation to use when opening and closing the Panel.
60
+ * NOTE: This is only used when `animation` is set to `true`.
61
+ * @default "slide"
62
+ */
63
+ animationType?: "slide" | "fade";
64
+ /**
65
+ * The maximum width of the Panel when kind is "panel".
66
+ * NOTE: This does not affect messageboxes, which have a fixed width.
67
+ * @default "medium"
68
+ */
69
+ maxWidth?: "small" | "medium" | "large";
70
+ /**
71
+ * The maximum height of the Panel or Messagebox.
72
+ * @default large for Panel, small for Messagebox
73
+ */
74
+ maxHeight?: "small" | "medium" | "large";
75
+ /**
76
+ * The blur effect to apply to the header and footer backgrounds.
77
+ * @default "none"
78
+ */
79
+ blurEffect?: "none" | "small" | "medium" | "large";
80
+ /**
81
+ * Which element to initially focus when the Panel opens.
82
+ * Can be a number (tabbable index, 0 = first tabbable element which is
83
+ * the close button), a ref to an element, or -1 to disable initial focus.
84
+ * @default 0
85
+ */
86
+ initialFocus?: number | React.RefObject<HTMLElement | null>;
87
+ };
88
+
89
+ export declare const SMALL = "small";
90
+
91
+ export declare const TYPE_MESSAGEBOX = "messagebox";
92
+
93
+ export declare const TYPE_PANEL = "panel";
94
+
95
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,705 @@
1
+ /*!
2
+ @versini/ui-dialog v8.0.6
3
+ © 2025 gizmette.com
4
+ */
5
+ try {
6
+ if (!window.__VERSINI_UI_DIALOG__) {
7
+ window.__VERSINI_UI_DIALOG__ = {
8
+ version: "8.0.6",
9
+ buildTime: "12/18/2025 07:24 PM EST",
10
+ homepage: "https://www.npmjs.com/package/@versini/ui-dialog",
11
+ license: "MIT",
12
+ };
13
+ }
14
+ } catch (error) {
15
+ // nothing to declare officer
16
+ }
17
+
18
+ import { jsx, jsxs } from "react/jsx-runtime";
19
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
20
+ import clsx from "clsx";
21
+ import { createPortal } from "react-dom";
22
+
23
+ ;// CONCATENATED MODULE: ./src/common/constants.ts
24
+ const MESSAGEBOX_CLASSNAME = "av-messagebox";
25
+ const PANEL_CLASSNAME = "av-panel";
26
+ const TYPE_PANEL = "panel";
27
+ const TYPE_MESSAGEBOX = "messagebox";
28
+ const ANIMATION_SLIDE = "slide";
29
+ const ANIMATION_FADE = "fade";
30
+ const SMALL = "small";
31
+ const MEDIUM = "medium";
32
+ const LARGE = "large";
33
+ const NONE = "none";
34
+
35
+ ;// CONCATENATED MODULE: external "react/jsx-runtime"
36
+
37
+ ;// CONCATENATED MODULE: external "react"
38
+
39
+ ;// CONCATENATED MODULE: external "clsx"
40
+
41
+ ;// CONCATENATED MODULE: external "react-dom"
42
+
43
+ ;// CONCATENATED MODULE: ./src/components/Panel/dialogStackManager.ts
44
+ /**
45
+ * Dialog Stack Manager
46
+ *
47
+ * Implements the W3C WAI-ARIA APG pattern for managing nested modal dialogs.
48
+ * This module maintains a stack of open dialogs and coordinates focus event
49
+ * listeners between them.
50
+ *
51
+ * Key features:
52
+ * - Only the topmost dialog has active focus listeners
53
+ * - When a nested dialog opens, parent listeners are suspended
54
+ * - When a dialog closes, parent listeners are restored
55
+ * - Programmatic focus changes are flagged to prevent event interference
56
+ *
57
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/
58
+ */ /**
59
+ * Stack of currently open dialogs. The last item is the topmost dialog.
60
+ */ const openDialogStack = [];
61
+ /**
62
+ * Flag to ignore focus changes during programmatic focus operations.
63
+ * When true, focus event handlers should return early to prevent interference.
64
+ *
65
+ * This is critical for nested dialogs: when a child dialog closes and
66
+ * programmatically returns focus to its trigger element inside the parent,
67
+ * we don't want the parent's focusin handler to interfere.
68
+ */ let ignoreFocusChanges = false;
69
+ /**
70
+ * Get whether focus changes should be ignored.
71
+ * Focus event handlers should check this and return early if true.
72
+ */ function shouldIgnoreFocusChanges() {
73
+ return ignoreFocusChanges;
74
+ }
75
+ /**
76
+ * Execute a function while ignoring focus change events.
77
+ * Use this when programmatically moving focus to prevent event handlers
78
+ * from interfering.
79
+ *
80
+ * @param fn - Function to execute (typically contains focus() calls)
81
+ */ function withIgnoredFocusChanges(fn) {
82
+ ignoreFocusChanges = true;
83
+ try {
84
+ fn();
85
+ } finally{
86
+ // Use setTimeout to ensure the flag stays true through any
87
+ // microtasks that might fire focus events
88
+ setTimeout(()=>{
89
+ ignoreFocusChanges = false;
90
+ }, 0);
91
+ }
92
+ }
93
+ /**
94
+ * Register a dialog in the stack when it opens.
95
+ * If there's a parent dialog, its listeners are suspended.
96
+ *
97
+ * @param entry - The dialog entry to register
98
+ */ function registerDialog(entry) {
99
+ // If there's already a dialog open, suspend its listeners
100
+ if (openDialogStack.length > 0) {
101
+ const currentTop = openDialogStack[openDialogStack.length - 1];
102
+ currentTop.removeListeners();
103
+ }
104
+ // Add the new dialog to the stack and activate its listeners
105
+ openDialogStack.push(entry);
106
+ entry.addListeners();
107
+ }
108
+ /**
109
+ * Unregister a dialog from the stack when it closes.
110
+ * If there's a parent dialog, its listeners are restored.
111
+ *
112
+ * @param dialogRef - Reference to the dialog element being closed
113
+ */ function unregisterDialog(dialogRef) {
114
+ const index = openDialogStack.findIndex((entry)=>entry.dialogRef === dialogRef);
115
+ /* c8 ignore next 3 - defensive check */ if (index === -1) {
116
+ return;
117
+ }
118
+ // Remove listeners from the dialog being closed
119
+ const [removedEntry] = openDialogStack.splice(index, 1);
120
+ removedEntry.removeListeners();
121
+ // If there's a parent dialog, restore its listeners
122
+ if (openDialogStack.length > 0) {
123
+ const newTop = openDialogStack[openDialogStack.length - 1];
124
+ newTop.addListeners();
125
+ }
126
+ }
127
+ /**
128
+ * Get the current number of open dialogs.
129
+ * Useful for debugging and testing.
130
+ */ function getOpenDialogCount() {
131
+ return openDialogStack.length;
132
+ }
133
+ /**
134
+ * Check if a given dialog is the topmost (current) dialog.
135
+ *
136
+ * @param dialogRef - Reference to the dialog element to check
137
+ */ function isTopmostDialog(dialogRef) {
138
+ if (openDialogStack.length === 0) {
139
+ return false;
140
+ }
141
+ return openDialogStack[openDialogStack.length - 1].dialogRef === dialogRef;
142
+ }
143
+ /**
144
+ * Reset the stack (for testing purposes only).
145
+ * @internal
146
+ */ function _resetStackForTesting() {
147
+ while(openDialogStack.length > 0){
148
+ openDialogStack.pop();
149
+ }
150
+ ignoreFocusChanges = false;
151
+ }
152
+
153
+ ;// CONCATENATED MODULE: ./src/components/Panel/PanelPortal.tsx
154
+
155
+
156
+
157
+
158
+
159
+
160
+ /**
161
+ * Selector for all focusable elements within a container. Based on W3C WAI-ARIA
162
+ * practices for dialog focus management.
163
+ */ const FOCUSABLE_SELECTOR = [
164
+ 'a[href]:not([disabled]):not([tabindex="-1"])',
165
+ 'button:not([disabled]):not([tabindex="-1"])',
166
+ 'textarea:not([disabled]):not([tabindex="-1"])',
167
+ 'input:not([disabled]):not([tabindex="-1"])',
168
+ 'select:not([disabled]):not([tabindex="-1"])',
169
+ '[tabindex]:not([tabindex="-1"]):not([disabled])',
170
+ 'audio[controls]:not([tabindex="-1"])',
171
+ 'video[controls]:not([tabindex="-1"])',
172
+ 'details:not([tabindex="-1"])'
173
+ ].join(", ");
174
+ /**
175
+ * Portal component for rendering the Panel as a modal dialog using the native
176
+ * HTML <dialog> element with showModal(). This provides:
177
+ * - Native focus trapping (works correctly on iPad with physical keyboard)
178
+ * - Native ESC key handling via cancel event
179
+ * - Native backdrop via ::backdrop pseudo-element
180
+ * - Native inert background (no need for manual scroll lock)
181
+ * - Top layer rendering (no need for createPortal)
182
+ *
183
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
184
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
185
+ *
186
+ */ function PanelPortal({ open, onClose, children, className, style, title, initialFocus = 0, kind = /* inlined export .TYPE_PANEL */ ("panel") }) {
187
+ const labelId = useId();
188
+ const descriptionId = useId();
189
+ const dialogRef = useRef(null);
190
+ const previouslyFocusedRef = useRef(null);
191
+ /**
192
+ * Get all focusable elements within the dialog. Excludes focus sentinel
193
+ * elements used for circular focus trapping.
194
+ */ const getFocusableElements = useCallback(()=>{
195
+ /* c8 ignore next 3 - defensive check, dialogRef is always set when open */ if (!dialogRef.current) {
196
+ return [];
197
+ }
198
+ const elements = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR);
199
+ return Array.from(elements).filter((el)=>el.offsetParent !== null && // Filter out hidden elements
200
+ !el.hasAttribute("data-focus-sentinel"));
201
+ }, []);
202
+ /**
203
+ * Focus a specific element by index, or the element referenced by a ref.
204
+ */ const focusElement = useCallback((target)=>{
205
+ if (typeof target === "number") {
206
+ if (target === -1) {
207
+ // -1 means don't focus anything.
208
+ return;
209
+ }
210
+ const focusableElements = getFocusableElements();
211
+ if (focusableElements.length > 0) {
212
+ const index = Math.min(target, focusableElements.length - 1);
213
+ focusableElements[index]?.focus();
214
+ }
215
+ } else if (target?.current) {
216
+ target.current.focus();
217
+ }
218
+ }, [
219
+ getFocusableElements
220
+ ]);
221
+ /**
222
+ * Handle the native cancel event (fired when ESC is pressed). This replaces
223
+ * the custom keydown handler since the native dialog handles ESC
224
+ * automatically.
225
+ */ const handleCancel = useCallback((event)=>{
226
+ // Prevent the default close behavior so we can control it via onClose.
227
+ event.preventDefault();
228
+ onClose();
229
+ }, [
230
+ onClose
231
+ ]);
232
+ /**
233
+ * Handle Tab key to implement circular focus trapping. Native dialog focus
234
+ * trapping may not be circular in all browsers, so we manually wrap focus from
235
+ * last to first element (and vice versa). Uses document-level event listener
236
+ * for better iPad physical keyboard support.
237
+ *
238
+ * IMPORTANT: On iPad Safari with a physical keyboard, the Tab key does not
239
+ * automatically navigate between focusable elements. We must manually handle
240
+ * ALL Tab key presses, not just wrapping cases.
241
+ *
242
+ */ const handleKeyDown = useCallback((event)=>{
243
+ if (event.key !== "Tab" || !dialogRef.current) {
244
+ return;
245
+ }
246
+ const focusableElements = getFocusableElements();
247
+ /* c8 ignore next 4 - edge case: dialog with no focusable elements */ if (focusableElements.length === 0) {
248
+ event.preventDefault();
249
+ return;
250
+ }
251
+ const firstElement = focusableElements[0];
252
+ const lastElement = focusableElements[focusableElements.length - 1];
253
+ const activeElement = document.activeElement;
254
+ // Find the current index of the focused element.
255
+ const currentIndex = focusableElements.indexOf(activeElement);
256
+ /**
257
+ * Always prevent default and manually handle focus navigation. This is
258
+ * required for iPad Safari with physical keyboard, where Tab key doesn't
259
+ * automatically navigate between focusable elements.
260
+ */ event.preventDefault();
261
+ if (event.shiftKey) {
262
+ // Shift+Tab: move to previous element, wrap to last if on first.
263
+ if (activeElement === firstElement || currentIndex <= 0) {
264
+ lastElement?.focus();
265
+ } else {
266
+ focusableElements[currentIndex - 1]?.focus();
267
+ }
268
+ } else {
269
+ // Tab: move to next element, wrap to first if on last.
270
+ if (activeElement === lastElement || currentIndex >= focusableElements.length - 1) {
271
+ firstElement?.focus();
272
+ } else {
273
+ focusableElements[currentIndex + 1]?.focus();
274
+ }
275
+ }
276
+ }, [
277
+ getFocusableElements
278
+ ]);
279
+ /**
280
+ * Handle focus events to ensure focus stays within the dialog. This catches
281
+ * focus that escapes via Tab key on iPad Safari or other means.
282
+ *
283
+ * Uses the dialog stack manager's ignore flag to prevent interference during
284
+ * programmatic focus operations (e.g., when a nested dialog closes and returns
285
+ * focus to its trigger element).
286
+ *
287
+ */ /* v8 ignore next 20 - focus escape handling for iPad Safari, hard to test in jsdom */ const handleFocusIn = useCallback((event)=>{
288
+ // Ignore focus changes triggered by programmatic focus operations.
289
+ if (shouldIgnoreFocusChanges()) {
290
+ return;
291
+ }
292
+ if (!dialogRef.current || dialogRef.current.contains(event.target)) {
293
+ return;
294
+ }
295
+ // Focus escaped the dialog, bring it back.
296
+ const focusableElements = getFocusableElements();
297
+ if (focusableElements.length > 0) {
298
+ focusableElements[0]?.focus();
299
+ }
300
+ }, [
301
+ getFocusableElements
302
+ ]);
303
+ /**
304
+ * Handle clicks on the backdrop (the area outside the dialog content). Native
305
+ * dialog doesn't provide backdrop click handling, so we use the dialog
306
+ * element's click event and check if the click target is the dialog itself
307
+ * (not a child element).
308
+ */ /* v8 ignore next 9 - backdrop clicks are disabled by design in current implementation */ const handleDialogClick = useCallback((_event)=>{
309
+ /**
310
+ * If the click is directly on the dialog element (the backdrop area), not on
311
+ * any child element, then close the dialog. Currently disabled -
312
+ * outsidePress is false by design. if (_event.target === dialogRef.current)
313
+ * { onClose(); }
314
+ */ }, []);
315
+ /**
316
+ * Focus sentinel handler - when a sentinel element receives focus, redirect
317
+ * focus to the appropriate element inside the dialog. This handles iPad
318
+ * Safari's Tab key behavior which can bypass event listeners.
319
+ */ const handleSentinelFocus = useCallback((position)=>{
320
+ const focusableElements = getFocusableElements();
321
+ /* c8 ignore next 3 - edge case: dialog with no focusable elements */ if (focusableElements.length === 0) {
322
+ return;
323
+ }
324
+ if (position === "start") {
325
+ // Focus came from the end, wrap to last element.
326
+ focusableElements[focusableElements.length - 1]?.focus();
327
+ } else {
328
+ // Focus came from the start, wrap to first element.
329
+ focusableElements[0]?.focus();
330
+ }
331
+ }, [
332
+ getFocusableElements
333
+ ]);
334
+ /**
335
+ * Effect to show/hide the dialog and manage focus. Uses the dialog stack
336
+ * manager to coordinate listeners between nested dialogs.
337
+ */ useEffect(()=>{
338
+ const dialog = dialogRef.current;
339
+ /* c8 ignore next 3 - defensive check */ if (!dialog) {
340
+ return;
341
+ }
342
+ // Store the currently focused element to restore later.
343
+ previouslyFocusedRef.current = document.activeElement;
344
+ // Show the dialog as a modal.
345
+ if (!dialog.open) {
346
+ dialog.showModal();
347
+ }
348
+ /**
349
+ * Add cancel event listener for ESC key (always needed, not managed by
350
+ * stack).
351
+ */ dialog.addEventListener("cancel", handleCancel);
352
+ /**
353
+ * Define listener management functions for the stack manager. These will be
354
+ * called when this dialog becomes/stops being the topmost dialog.
355
+ */ const addListeners = ()=>{
356
+ document.addEventListener("keydown", handleKeyDown);
357
+ document.addEventListener("focusin", handleFocusIn);
358
+ };
359
+ const removeListeners = ()=>{
360
+ document.removeEventListener("keydown", handleKeyDown);
361
+ document.removeEventListener("focusin", handleFocusIn);
362
+ };
363
+ /**
364
+ * Register this dialog with the stack manager. This will suspend parent
365
+ * dialog listeners if any exist.
366
+ */ registerDialog({
367
+ dialogRef: dialog,
368
+ addListeners,
369
+ removeListeners
370
+ });
371
+ /**
372
+ * Set initial focus after a small delay to ensure the DOM is ready. This
373
+ * works around React's autoFocus prop not working with native dialog.
374
+ */ const focusTimer = setTimeout(()=>{
375
+ focusElement(initialFocus);
376
+ }, 0);
377
+ // Capture the previously focused element for restoration in cleanup.
378
+ const previouslyFocused = previouslyFocusedRef.current;
379
+ return ()=>{
380
+ clearTimeout(focusTimer);
381
+ dialog.removeEventListener("cancel", handleCancel);
382
+ /**
383
+ * Unregister from the stack manager. This will restore parent dialog
384
+ * listeners if any exist.
385
+ */ unregisterDialog(dialog);
386
+ // Close the dialog if it's still open.
387
+ if (dialog.open) {
388
+ dialog.close();
389
+ }
390
+ /**
391
+ * Restore focus to the previously focused element if it's still in the DOM.
392
+ * Use withIgnoredFocusChanges to prevent parent dialog's handleFocusIn from
393
+ * interfering with focus restoration.
394
+ */ if (previouslyFocused?.isConnected) {
395
+ withIgnoredFocusChanges(()=>{
396
+ if (previouslyFocused.isConnected) {
397
+ previouslyFocused.focus();
398
+ }
399
+ });
400
+ }
401
+ };
402
+ }, [
403
+ handleCancel,
404
+ handleKeyDown,
405
+ handleFocusIn,
406
+ initialFocus,
407
+ focusElement
408
+ ]);
409
+ /* c8 ignore next 3 - early return when panel is closed */ if (!open) {
410
+ return null;
411
+ }
412
+ const isMessageBox = kind === TYPE_MESSAGEBOX;
413
+ const dialogClass = clsx(/**
414
+ * Center the dialog on screen with fixed positioning. Native dialog uses
415
+ * position: fixed by default.
416
+ * - Panel on mobile: inset-0 + no margin for full screen.
417
+ * - Panel on desktop: inset-x-0 + top/bottom auto for vertical centering.
418
+ * - MessageBox: Always centered (both mobile and desktop) since it doesn't
419
+ * take full screen.
420
+ */ "fixed max-h-none max-w-none p-0", {
421
+ // Panel: full screen on mobile, centered on desktop
422
+ "inset-0 m-0 sm:inset-auto sm:inset-x-0 sm:top-1/2 sm:-translate-y-1/2 sm:mx-auto": !isMessageBox,
423
+ // MessageBox: always centered at all breakpoints
424
+ "inset-auto inset-x-0 top-1/2 -translate-y-1/2 mx-auto z-100": isMessageBox
425
+ }, /**
426
+ * Backdrop styling via Tailwind's backdrop: variant for ::backdrop
427
+ * pseudo-element. Full black on mobile, 80% opacity on desktop (matches
428
+ * original overlay).
429
+ */ "backdrop:bg-black sm:backdrop:bg-black/80", className);
430
+ return /*#__PURE__*/ createPortal(/*#__PURE__*/ jsxs("dialog", {
431
+ ref: dialogRef,
432
+ "aria-labelledby": labelId,
433
+ "aria-describedby": descriptionId,
434
+ className: dialogClass,
435
+ style: style,
436
+ onClick: handleDialogClick,
437
+ children: [
438
+ /*#__PURE__*/ jsx("span", {
439
+ tabIndex: 0,
440
+ onFocus: ()=>handleSentinelFocus("start"),
441
+ "data-focus-sentinel": "start",
442
+ style: {
443
+ position: "absolute",
444
+ width: 1,
445
+ height: 1,
446
+ padding: 0,
447
+ margin: -1,
448
+ overflow: "hidden",
449
+ clip: "rect(0, 0, 0, 0)",
450
+ whiteSpace: "nowrap",
451
+ border: 0
452
+ },
453
+ "aria-hidden": "true"
454
+ }),
455
+ /*#__PURE__*/ jsx("span", {
456
+ id: labelId,
457
+ className: "sr-only",
458
+ children: title
459
+ }),
460
+ children,
461
+ /*#__PURE__*/ jsx("span", {
462
+ tabIndex: 0,
463
+ onFocus: ()=>handleSentinelFocus("end"),
464
+ "data-focus-sentinel": "end",
465
+ style: {
466
+ position: "absolute",
467
+ width: 1,
468
+ height: 1,
469
+ padding: 0,
470
+ margin: -1,
471
+ overflow: "hidden",
472
+ clip: "rect(0, 0, 0, 0)",
473
+ whiteSpace: "nowrap",
474
+ border: 0
475
+ },
476
+ "aria-hidden": "true"
477
+ })
478
+ ]
479
+ }), document.body);
480
+ }
481
+
482
+ ;// CONCATENATED MODULE: ./src/components/Panel/utilities.ts
483
+
484
+
485
+ const getFooterAndHeaderCommonClasses = ({ blurEffect })=>{
486
+ return clsx("absolute left-0 right-0 z-20 backdrop-brightness-50", {
487
+ "backdrop-blur-sm": blurEffect === /* inlined export .SMALL */ ("small"),
488
+ "backdrop-blur-md": blurEffect === /* inlined export .MEDIUM */ ("medium"),
489
+ "backdrop-blur-lg": blurEffect === /* inlined export .LARGE */ ("large"),
490
+ "bg-surface-darker": blurEffect === /* inlined export .NONE */ ("none")
491
+ });
492
+ };
493
+ const getPanelClassName = ({ className, kind, borderMode, animation, maxWidth = /* inlined export .MEDIUM */ ("medium"), maxHeight, blurEffect = /* inlined export .NONE */ ("none"), hasFooter })=>{
494
+ const effectiveMaxHeight = maxHeight ?? (kind === /* inlined export .TYPE_PANEL */ ("panel") ? /* inlined export .LARGE */ ("large") : /* inlined export .SMALL */ ("small"));
495
+ return {
496
+ outerWrapper: clsx("prose prose-lighter flex flex-col bg-surface-dark overflow-hidden", {
497
+ "duration-200 ease-out": animation,
498
+ /**
499
+ * Panel styles
500
+ */ [`${PANEL_CLASSNAME} sm:rounded-3xl sm:border`]: kind === /* inlined export .TYPE_PANEL */ ("panel"),
501
+ /**
502
+ * Widths and max widths for Panel when no className is provided
503
+ */ ["w-full sm:w-[95%] md:max-w-2xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .SMALL */ ("small"),
504
+ ["w-full sm:w-[95%] md:max-w-3xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .MEDIUM */ ("medium"),
505
+ ["w-full sm:w-[95%] md:max-w-4xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .LARGE */ ("large"),
506
+ /**
507
+ * Heights and max heights for Panel
508
+ * Mobile: full height (h-full works with inset-0), Desktop: shrink-to-fit with max-height constraint
509
+ */ "h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[40vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .SMALL */ ("small"),
510
+ "h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[60vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .MEDIUM */ ("medium"),
511
+ "h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[95vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .LARGE */ ("large"),
512
+ /**
513
+ * Panel border colors
514
+ */ "sm:border-border-dark": borderMode === "dark" && kind === /* inlined export .TYPE_PANEL */ ("panel"),
515
+ "sm:border-border-accent": borderMode === "light" && kind === /* inlined export .TYPE_PANEL */ ("panel"),
516
+ /**
517
+ * Messagebox styles
518
+ */ [`${MESSAGEBOX_CLASSNAME} rounded-3xl border`]: kind === TYPE_MESSAGEBOX,
519
+ /**
520
+ * Widths and max widths for Messagebox when no className is provided
521
+ */ ["w-[95%] sm:w-[50%] md:max-w-2xl"]: kind === TYPE_MESSAGEBOX && !className,
522
+ /**
523
+ * Heights and max heights for Messagebox
524
+ */ "h-64": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .SMALL */ ("small"),
525
+ "h-80": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .MEDIUM */ ("medium"),
526
+ "h-96": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .LARGE */ ("large"),
527
+ /**
528
+ * Messagebox border colors
529
+ */ "border-border-dark": borderMode === "dark" && kind === TYPE_MESSAGEBOX,
530
+ "border-border-accent": borderMode === "light" && kind === TYPE_MESSAGEBOX,
531
+ [`${className}`]: !!className
532
+ }),
533
+ innerWrapper: "content flex flex-col rounded-[inherit] relative min-h-full isolate",
534
+ scrollableContent: clsx("flex-1 overflow-y-auto overflow-x-hidden", "pt-12", {
535
+ "pb-12": hasFooter
536
+ }),
537
+ footer: clsx(getFooterAndHeaderCommonClasses({
538
+ blurEffect
539
+ }), "p-2 bottom-0", "sm:min-h-auto h-12", {
540
+ "min-h-20": hasFooter && kind === /* inlined export .TYPE_PANEL */ ("panel"),
541
+ "sm:rounded-b-3xl": kind === /* inlined export .TYPE_PANEL */ ("panel"),
542
+ "rounded-b-3xl": kind === TYPE_MESSAGEBOX
543
+ }),
544
+ header: clsx("flex flex-row-reverse items-center justify-between h-12", getFooterAndHeaderCommonClasses({
545
+ blurEffect
546
+ }), "top-0", {
547
+ "sm:rounded-t-3xl": kind === /* inlined export .TYPE_PANEL */ ("panel"),
548
+ "rounded-t-3xl": kind === TYPE_MESSAGEBOX
549
+ }),
550
+ title: "mb-0 pt-2 pl-4 pr-2 pb-2",
551
+ closeWrapper: "pr-[18px]",
552
+ closeButton: clsx("flex items-center justify-center", "size-3", "p-1", "rounded-full", "border", "border-transparent", "text-[rgba(255,96,92,1)]", "bg-[rgba(255,96,92,1)]", "shadow-[0_0_0_0.5px_red]", "focus:outline", "focus:outline-2", "focus:outline-offset-2", "focus:outline-focus-light", "hover:text-copy-dark", "focus:text-copy-dark", "active:bg-[#ba504a]", // Extended touch target using pseudo-element
553
+ "relative", "before:content-['']", "before:absolute", "before:-top-4", "before:-right-4", "before:-bottom-4", "before:-left-4"),
554
+ content: "p-4 rounded-3xl"
555
+ };
556
+ };
557
+
558
+ ;// CONCATENATED MODULE: ./src/components/Panel/Panel.tsx
559
+
560
+
561
+
562
+
563
+
564
+ const Panel = ({ open, onOpenChange, title, children, footer, borderMode = "light", kind = /* inlined export .TYPE_PANEL */ ("panel"), className, animation = false, animationType = /* inlined export .ANIMATION_SLIDE */ ("slide"), maxWidth = /* inlined export .MEDIUM */ ("medium"), maxHeight, blurEffect = /* inlined export .NONE */ ("none"), initialFocus })=>{
565
+ const originalTitleRef = useRef("");
566
+ /* v8 ignore next 9 */ const [animationStyles, setAnimationStyles] = useState(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
567
+ opacity: 0
568
+ } : {
569
+ transform: "translateY(-100vh)"
570
+ });
571
+ const panelClassName = getPanelClassName({
572
+ className,
573
+ kind,
574
+ borderMode,
575
+ animation,
576
+ maxWidth,
577
+ maxHeight,
578
+ blurEffect,
579
+ hasFooter: Boolean(footer)
580
+ });
581
+ /**
582
+ * Handle close button click.
583
+ */ const handleClose = useCallback(()=>{
584
+ onOpenChange(false);
585
+ }, [
586
+ onOpenChange
587
+ ]);
588
+ /**
589
+ * If the panel is opened, set the document title to the panel's title. If it's
590
+ * closed, restore the original document.title.
591
+ */ useEffect(()=>{
592
+ if (open) {
593
+ originalTitleRef.current = document.title;
594
+ document.title = `${title} | ${originalTitleRef.current}`;
595
+ }
596
+ return ()=>{
597
+ if (open) {
598
+ document.title = originalTitleRef.current;
599
+ }
600
+ };
601
+ }, [
602
+ title,
603
+ open
604
+ ]);
605
+ /**
606
+ * Effect to handle the opening and closing animations.
607
+ */ /* v8 ignore next 31 */ useEffect(()=>{
608
+ if (!animation) {
609
+ return;
610
+ }
611
+ if (open) {
612
+ setAnimationStyles(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
613
+ opacity: 0
614
+ } : {
615
+ transform: "translateY(-666vh)"
616
+ });
617
+ /**
618
+ * Small delay to ensure the opening state is applied after the component is
619
+ * rendered.
620
+ */ const timer = setTimeout(()=>{
621
+ setAnimationStyles(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
622
+ opacity: 1
623
+ } : {
624
+ transform: "translateY(0)"
625
+ });
626
+ }, 100);
627
+ return ()=>clearTimeout(timer);
628
+ }
629
+ }, [
630
+ open,
631
+ animation,
632
+ animationType
633
+ ]);
634
+ return /*#__PURE__*/ jsx(PanelPortal, {
635
+ open: open,
636
+ onClose: handleClose,
637
+ className: panelClassName.outerWrapper,
638
+ style: animationStyles,
639
+ title: title,
640
+ initialFocus: initialFocus,
641
+ kind: kind,
642
+ children: /*#__PURE__*/ jsxs("div", {
643
+ className: panelClassName.innerWrapper,
644
+ children: [
645
+ /*#__PURE__*/ jsxs("div", {
646
+ className: panelClassName.header,
647
+ children: [
648
+ /*#__PURE__*/ jsx("div", {
649
+ className: panelClassName.closeWrapper,
650
+ children: /*#__PURE__*/ jsx("button", {
651
+ className: panelClassName.closeButton,
652
+ type: "button",
653
+ "aria-label": "Close",
654
+ onClick: handleClose,
655
+ children: /*#__PURE__*/ jsx("span", {
656
+ children: /*#__PURE__*/ jsx("svg", {
657
+ xmlns: "http://www.w3.org/2000/svg",
658
+ className: "size-3",
659
+ viewBox: "0 0 384 512",
660
+ fill: "currentColor",
661
+ role: "img",
662
+ "aria-hidden": "true",
663
+ focusable: "false",
664
+ children: /*#__PURE__*/ jsx("path", {
665
+ d: "M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256l105.3-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3z",
666
+ opacity: "1"
667
+ })
668
+ })
669
+ })
670
+ })
671
+ }),
672
+ /*#__PURE__*/ jsx("h1", {
673
+ className: panelClassName.title,
674
+ children: title
675
+ })
676
+ ]
677
+ }),
678
+ /*#__PURE__*/ jsx("div", {
679
+ className: panelClassName.scrollableContent,
680
+ children: /*#__PURE__*/ jsx("div", {
681
+ className: panelClassName.content,
682
+ children: children
683
+ })
684
+ }),
685
+ footer && /*#__PURE__*/ jsx("div", {
686
+ className: panelClassName.footer,
687
+ children: footer
688
+ })
689
+ ]
690
+ })
691
+ });
692
+ };
693
+
694
+ ;// CONCATENATED MODULE: ./src/components/index.ts
695
+
696
+
697
+
698
+ var __webpack_exports__ANIMATION_FADE = /* inlined export .ANIMATION_FADE */ ("fade");
699
+ var __webpack_exports__ANIMATION_SLIDE = /* inlined export .ANIMATION_SLIDE */ ("slide");
700
+ var __webpack_exports__LARGE = /* inlined export .LARGE */ ("large");
701
+ var __webpack_exports__MEDIUM = /* inlined export .MEDIUM */ ("medium");
702
+ var __webpack_exports__NONE = /* inlined export .NONE */ ("none");
703
+ var __webpack_exports__SMALL = /* inlined export .SMALL */ ("small");
704
+ var __webpack_exports__TYPE_PANEL = /* inlined export .TYPE_PANEL */ ("panel");
705
+ export { MESSAGEBOX_CLASSNAME, PANEL_CLASSNAME, Panel, TYPE_MESSAGEBOX, __webpack_exports__ANIMATION_FADE as ANIMATION_FADE, __webpack_exports__ANIMATION_SLIDE as ANIMATION_SLIDE, __webpack_exports__LARGE as LARGE, __webpack_exports__MEDIUM as MEDIUM, __webpack_exports__NONE as NONE, __webpack_exports__SMALL as SMALL, __webpack_exports__TYPE_PANEL as TYPE_PANEL };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@versini/ui-dialog",
3
+ "version": "8.0.6",
4
+ "license": "MIT",
5
+ "author": "Arno Versini",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "homepage": "https://www.npmjs.com/package/@versini/ui-dialog",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git@github.com:aversini/ui-components.git"
13
+ },
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build:check": "tsc",
23
+ "build:js": "rslib build",
24
+ "build:types": "echo 'Types now built with rslib'",
25
+ "build": "npm-run-all --serial clean build:check build:js",
26
+ "clean": "rimraf dist tmp",
27
+ "dev:js": "rslib build --watch",
28
+ "dev:types": "echo 'Types now watched with rslib'",
29
+ "dev": "rslib build --watch",
30
+ "lint": "biome lint src",
31
+ "lint:fix": "biome check src --write --no-errors-on-unmatched",
32
+ "prettier": "biome check --write --no-errors-on-unmatched",
33
+ "start": "static-server dist --port 5173",
34
+ "test:coverage:ui": "vitest --coverage --ui",
35
+ "test:coverage": "vitest run --coverage",
36
+ "test:update": "vitest run --update",
37
+ "test:visual": "playwright test -c playwright-ct.config.ts",
38
+ "test:visual:report": "playwright show-report playwright-report",
39
+ "test:visual:update": "playwright test -c playwright-ct.config.ts --update-snapshots",
40
+ "test:visual:ui": "playwright test -c playwright-ct.config.ts --ui",
41
+ "test:watch": "vitest",
42
+ "test": "vitest run"
43
+ },
44
+ "devDependencies": {
45
+ "@testing-library/jest-dom": "6.9.1"
46
+ },
47
+ "dependencies": {
48
+ "@tailwindcss/typography": "0.5.19",
49
+ "clsx": "2.1.1",
50
+ "tailwindcss": "4.1.18"
51
+ },
52
+ "sideEffects": [
53
+ "**/*.css"
54
+ ],
55
+ "gitHead": "3582aaec11fa1d50f3d6ef280d4f06adaf4746e4"
56
+ }