@versini/ui-panel 7.0.0 → 8.0.1
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.js +80 -65
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
@versini/ui-panel
|
|
2
|
+
@versini/ui-panel v8.0.1
|
|
3
3
|
© 2025 gizmette.com
|
|
4
4
|
*/
|
|
5
5
|
try {
|
|
6
6
|
if (!window.__VERSINI_UI_PANEL__) {
|
|
7
7
|
window.__VERSINI_UI_PANEL__ = {
|
|
8
|
-
version: "
|
|
9
|
-
buildTime: "12/
|
|
8
|
+
version: "8.0.1",
|
|
9
|
+
buildTime: "12/17/2025 07:04 PM EST",
|
|
10
10
|
homepage: "https://www.npmjs.com/package/@versini/ui-panel",
|
|
11
11
|
license: "MIT",
|
|
12
12
|
};
|
|
@@ -177,14 +177,15 @@ const NONE = "none";
|
|
|
177
177
|
*
|
|
178
178
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
|
|
179
179
|
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
180
|
+
*
|
|
180
181
|
*/ function PanelPortal({ open, onClose, children, className, style, title, initialFocus = 0 }) {
|
|
181
182
|
const labelId = useId();
|
|
182
183
|
const descriptionId = useId();
|
|
183
184
|
const dialogRef = useRef(null);
|
|
184
185
|
const previouslyFocusedRef = useRef(null);
|
|
185
186
|
/**
|
|
186
|
-
* Get all focusable elements within the dialog.
|
|
187
|
-
*
|
|
187
|
+
* Get all focusable elements within the dialog. Excludes focus sentinel
|
|
188
|
+
* elements used for circular focus trapping.
|
|
188
189
|
*/ const getFocusableElements = useCallback(()=>{
|
|
189
190
|
/* c8 ignore next 3 - defensive check, dialogRef is always set when open */ if (!dialogRef.current) {
|
|
190
191
|
return [];
|
|
@@ -214,7 +215,8 @@ const NONE = "none";
|
|
|
214
215
|
]);
|
|
215
216
|
/**
|
|
216
217
|
* Handle the native cancel event (fired when ESC is pressed). This replaces
|
|
217
|
-
* the custom keydown handler since the native dialog handles ESC
|
|
218
|
+
* the custom keydown handler since the native dialog handles ESC
|
|
219
|
+
* automatically.
|
|
218
220
|
*/ const handleCancel = useCallback((event)=>{
|
|
219
221
|
// Prevent the default close behavior so we can control it via onClose.
|
|
220
222
|
event.preventDefault();
|
|
@@ -223,14 +225,15 @@ const NONE = "none";
|
|
|
223
225
|
onClose
|
|
224
226
|
]);
|
|
225
227
|
/**
|
|
226
|
-
* Handle Tab key to implement circular focus trapping.
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
228
|
+
* Handle Tab key to implement circular focus trapping. Native dialog focus
|
|
229
|
+
* trapping may not be circular in all browsers, so we manually wrap focus from
|
|
230
|
+
* last to first element (and vice versa). Uses document-level event listener
|
|
231
|
+
* for better iPad physical keyboard support.
|
|
230
232
|
*
|
|
231
233
|
* IMPORTANT: On iPad Safari with a physical keyboard, the Tab key does not
|
|
232
234
|
* automatically navigate between focusable elements. We must manually handle
|
|
233
235
|
* ALL Tab key presses, not just wrapping cases.
|
|
236
|
+
*
|
|
234
237
|
*/ const handleKeyDown = useCallback((event)=>{
|
|
235
238
|
if (event.key !== "Tab" || !dialogRef.current) {
|
|
236
239
|
return;
|
|
@@ -245,10 +248,11 @@ const NONE = "none";
|
|
|
245
248
|
const activeElement = document.activeElement;
|
|
246
249
|
// Find the current index of the focused element.
|
|
247
250
|
const currentIndex = focusableElements.indexOf(activeElement);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Always prevent default and manually handle focus navigation. This is
|
|
253
|
+
* required for iPad Safari with physical keyboard, where Tab key doesn't
|
|
254
|
+
* automatically navigate between focusable elements.
|
|
255
|
+
*/ event.preventDefault();
|
|
252
256
|
if (event.shiftKey) {
|
|
253
257
|
// Shift+Tab: move to previous element, wrap to last if on first.
|
|
254
258
|
if (activeElement === firstElement || currentIndex <= 0) {
|
|
@@ -268,14 +272,15 @@ const NONE = "none";
|
|
|
268
272
|
getFocusableElements
|
|
269
273
|
]);
|
|
270
274
|
/**
|
|
271
|
-
* Handle focus events to ensure focus stays within the dialog.
|
|
272
|
-
*
|
|
275
|
+
* Handle focus events to ensure focus stays within the dialog. This catches
|
|
276
|
+
* focus that escapes via Tab key on iPad Safari or other means.
|
|
277
|
+
*
|
|
278
|
+
* Uses the dialog stack manager's ignore flag to prevent interference during
|
|
279
|
+
* programmatic focus operations (e.g., when a nested dialog closes and returns
|
|
280
|
+
* focus to its trigger element).
|
|
273
281
|
*
|
|
274
|
-
* Uses the dialog stack manager's ignore flag to prevent interference
|
|
275
|
-
* during programmatic focus operations (e.g., when a nested dialog closes
|
|
276
|
-
* and returns focus to its trigger element).
|
|
277
282
|
*/ /* v8 ignore next 20 - focus escape handling for iPad Safari, hard to test in jsdom */ const handleFocusIn = useCallback((event)=>{
|
|
278
|
-
// Ignore focus changes triggered by programmatic focus operations
|
|
283
|
+
// Ignore focus changes triggered by programmatic focus operations.
|
|
279
284
|
if (shouldIgnoreFocusChanges()) {
|
|
280
285
|
return;
|
|
281
286
|
}
|
|
@@ -291,22 +296,21 @@ const NONE = "none";
|
|
|
291
296
|
getFocusableElements
|
|
292
297
|
]);
|
|
293
298
|
/**
|
|
294
|
-
* Handle clicks on the backdrop (the area outside the dialog content).
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
299
|
+
* Handle clicks on the backdrop (the area outside the dialog content). Native
|
|
300
|
+
* dialog doesn't provide backdrop click handling, so we use the dialog
|
|
301
|
+
* element's click event and check if the click target is the dialog itself
|
|
302
|
+
* (not a child element).
|
|
298
303
|
*/ /* v8 ignore next 9 - backdrop clicks are disabled by design in current implementation */ const handleDialogClick = useCallback((_event)=>{
|
|
299
|
-
// If the click is directly on the dialog element (the backdrop area),
|
|
300
|
-
// not on any child element, then close the dialog.
|
|
301
|
-
// Currently disabled - outsidePress is false by design.
|
|
302
|
-
// if (_event.target === dialogRef.current) {
|
|
303
|
-
// onClose();
|
|
304
|
-
// }
|
|
305
|
-
}, []);
|
|
306
304
|
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
305
|
+
* If the click is directly on the dialog element (the backdrop area), not on
|
|
306
|
+
* any child element, then close the dialog. Currently disabled -
|
|
307
|
+
* outsidePress is false by design. if (_event.target === dialogRef.current)
|
|
308
|
+
* { onClose(); }
|
|
309
|
+
*/ }, []);
|
|
310
|
+
/**
|
|
311
|
+
* Focus sentinel handler - when a sentinel element receives focus, redirect
|
|
312
|
+
* focus to the appropriate element inside the dialog. This handles iPad
|
|
313
|
+
* Safari's Tab key behavior which can bypass event listeners.
|
|
310
314
|
*/ const handleSentinelFocus = useCallback((position)=>{
|
|
311
315
|
const focusableElements = getFocusableElements();
|
|
312
316
|
/* c8 ignore next 3 - edge case: dialog with no focusable elements */ if (focusableElements.length === 0) {
|
|
@@ -323,8 +327,8 @@ const NONE = "none";
|
|
|
323
327
|
getFocusableElements
|
|
324
328
|
]);
|
|
325
329
|
/**
|
|
326
|
-
* Effect to show/hide the dialog and manage focus.
|
|
327
|
-
*
|
|
330
|
+
* Effect to show/hide the dialog and manage focus. Uses the dialog stack
|
|
331
|
+
* manager to coordinate listeners between nested dialogs.
|
|
328
332
|
*/ useEffect(()=>{
|
|
329
333
|
const dialog = dialogRef.current;
|
|
330
334
|
/* c8 ignore next 3 - defensive check */ if (!dialog) {
|
|
@@ -336,11 +340,14 @@ const NONE = "none";
|
|
|
336
340
|
if (!dialog.open) {
|
|
337
341
|
dialog.showModal();
|
|
338
342
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Add cancel event listener for ESC key (always needed, not managed by
|
|
345
|
+
* stack).
|
|
346
|
+
*/ dialog.addEventListener("cancel", handleCancel);
|
|
347
|
+
/**
|
|
348
|
+
* Define listener management functions for the stack manager. These will be
|
|
349
|
+
* called when this dialog becomes/stops being the topmost dialog.
|
|
350
|
+
*/ const addListeners = ()=>{
|
|
344
351
|
document.addEventListener("keydown", handleKeyDown);
|
|
345
352
|
document.addEventListener("focusin", handleFocusIn);
|
|
346
353
|
};
|
|
@@ -348,16 +355,18 @@ const NONE = "none";
|
|
|
348
355
|
document.removeEventListener("keydown", handleKeyDown);
|
|
349
356
|
document.removeEventListener("focusin", handleFocusIn);
|
|
350
357
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Register this dialog with the stack manager. This will suspend parent
|
|
360
|
+
* dialog listeners if any exist.
|
|
361
|
+
*/ registerDialog({
|
|
354
362
|
dialogRef: dialog,
|
|
355
363
|
addListeners,
|
|
356
364
|
removeListeners
|
|
357
365
|
});
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Set initial focus after a small delay to ensure the DOM is ready. This
|
|
368
|
+
* works around React's autoFocus prop not working with native dialog.
|
|
369
|
+
*/ const focusTimer = setTimeout(()=>{
|
|
361
370
|
focusElement(initialFocus);
|
|
362
371
|
}, 0);
|
|
363
372
|
// Capture the previously focused element for restoration in cleanup.
|
|
@@ -365,17 +374,19 @@ const NONE = "none";
|
|
|
365
374
|
return ()=>{
|
|
366
375
|
clearTimeout(focusTimer);
|
|
367
376
|
dialog.removeEventListener("cancel", handleCancel);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Unregister from the stack manager. This will restore parent dialog
|
|
379
|
+
* listeners if any exist.
|
|
380
|
+
*/ unregisterDialog(dialog);
|
|
371
381
|
// Close the dialog if it's still open.
|
|
372
382
|
if (dialog.open) {
|
|
373
383
|
dialog.close();
|
|
374
384
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
/**
|
|
386
|
+
* Restore focus to the previously focused element if it's still in the DOM.
|
|
387
|
+
* Use withIgnoredFocusChanges to prevent parent dialog's handleFocusIn from
|
|
388
|
+
* interfering with focus restoration.
|
|
389
|
+
*/ if (previouslyFocused?.isConnected) {
|
|
379
390
|
withIgnoredFocusChanges(()=>{
|
|
380
391
|
if (previouslyFocused.isConnected) {
|
|
381
392
|
previouslyFocused.focus();
|
|
@@ -393,12 +404,16 @@ const NONE = "none";
|
|
|
393
404
|
/* c8 ignore next 3 - early return when panel is closed */ if (!open) {
|
|
394
405
|
return null;
|
|
395
406
|
}
|
|
396
|
-
const dialogClass = clsx(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
407
|
+
const dialogClass = clsx(/**
|
|
408
|
+
* Center the dialog on screen with fixed positioning. Native dialog uses
|
|
409
|
+
* position: fixed by default. On mobile: inset-0 + no margin for full screen.
|
|
410
|
+
* On desktop: inset-x-0 + top/bottom auto for vertical centering with
|
|
411
|
+
* shrink-to-fit.
|
|
412
|
+
*/ "fixed inset-0 m-0 sm:inset-auto sm:inset-x-0 sm:top-1/2 sm:-translate-y-1/2 sm:mx-auto max-h-none max-w-none p-0", /**
|
|
413
|
+
* Backdrop styling via Tailwind's backdrop: variant for ::backdrop
|
|
414
|
+
* pseudo-element. Full black on mobile, 80% opacity on desktop (matches
|
|
415
|
+
* original overlay).
|
|
416
|
+
*/ "backdrop:bg-black sm:backdrop:bg-black/80", className);
|
|
402
417
|
return /*#__PURE__*/ jsxs("dialog", {
|
|
403
418
|
ref: dialogRef,
|
|
404
419
|
"aria-labelledby": labelId,
|
|
@@ -477,10 +492,10 @@ const getPanelClassName = ({ className, kind, borderMode, animation, maxWidth =
|
|
|
477
492
|
["w-full sm:w-[95%] md:max-w-4xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .LARGE */ ("large"),
|
|
478
493
|
/**
|
|
479
494
|
* Heights and max heights for Panel
|
|
480
|
-
* Mobile: full height
|
|
481
|
-
*/ "min-h-
|
|
482
|
-
"min-h-
|
|
483
|
-
"min-h-
|
|
495
|
+
* Mobile: full height (h-full works with inset-0), Desktop: shrink-to-fit with max-height constraint
|
|
496
|
+
*/ "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"),
|
|
497
|
+
"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"),
|
|
498
|
+
"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"),
|
|
484
499
|
/**
|
|
485
500
|
* Panel border colors
|
|
486
501
|
*/ "sm:border-border-dark": borderMode === "dark" && kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
@@ -521,7 +536,7 @@ const getPanelClassName = ({ className, kind, borderMode, animation, maxWidth =
|
|
|
521
536
|
}),
|
|
522
537
|
title: "mb-0 pt-2 pl-4 pr-2 pb-2",
|
|
523
538
|
closeWrapper: "pr-[18px]",
|
|
524
|
-
closeButton: clsx("flex items-center justify-center", "size-
|
|
539
|
+
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
|
|
525
540
|
"relative", "before:content-['']", "before:absolute", "before:-top-4", "before:-right-4", "before:-bottom-4", "before:-left-4"),
|
|
526
541
|
content: "p-4 rounded-3xl"
|
|
527
542
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@versini/ui-panel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Arno Versini",
|
|
6
6
|
"publishConfig": {
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"test:coverage:ui": "vitest --coverage --ui",
|
|
35
35
|
"test:coverage": "vitest run --coverage",
|
|
36
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",
|
|
37
41
|
"test:watch": "vitest",
|
|
38
42
|
"test": "vitest run"
|
|
39
43
|
},
|
|
@@ -48,5 +52,5 @@
|
|
|
48
52
|
"sideEffects": [
|
|
49
53
|
"**/*.css"
|
|
50
54
|
],
|
|
51
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "9fa7c53e84b8f40adefbe63becd4841af772753a"
|
|
52
56
|
}
|