@versini/ui-panel 8.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.
Files changed (2) hide show
  1. package/dist/index.js +80 -65
  2. package/package.json +6 -2
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /*!
2
- @versini/ui-panel v8.0.0
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: "8.0.0",
9
- buildTime: "12/16/2025 06:31 PM EST",
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
- * Excludes focus sentinel elements used for circular focus trapping.
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 automatically.
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
- * Native dialog focus trapping may not be circular in all browsers,
228
- * so we manually wrap focus from last to first element (and vice versa).
229
- * Uses document-level event listener for better iPad physical keyboard support.
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
- // Always prevent default and manually handle focus navigation.
249
- // This is required for iPad Safari with physical keyboard, where
250
- // Tab key doesn't automatically navigate between focusable elements.
251
- event.preventDefault();
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
- * This catches focus that escapes via Tab key on iPad Safari or other means.
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
- * Native dialog doesn't provide backdrop click handling, so we use the
296
- * dialog element's click event and check if the click target is the dialog
297
- * itself (not a child element).
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
- * Focus sentinel handler - when a sentinel element receives focus,
308
- * redirect focus to the appropriate element inside the dialog.
309
- * This handles iPad Safari's Tab key behavior which can bypass event listeners.
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
- * Uses the dialog stack manager to coordinate listeners between nested dialogs.
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
- // Add cancel event listener for ESC key (always needed, not managed by stack).
340
- dialog.addEventListener("cancel", handleCancel);
341
- // Define listener management functions for the stack manager.
342
- // These will be called when this dialog becomes/stops being the topmost dialog.
343
- const addListeners = ()=>{
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
- // Register this dialog with the stack manager.
352
- // This will suspend parent dialog listeners if any exist.
353
- registerDialog({
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
- // Set initial focus after a small delay to ensure the DOM is ready.
359
- // This works around React's autoFocus prop not working with native dialog.
360
- const focusTimer = setTimeout(()=>{
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
- // Unregister from the stack manager.
369
- // This will restore parent dialog listeners if any exist.
370
- unregisterDialog(dialog);
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
- // Restore focus to the previously focused element if it's still in the DOM.
376
- // Use withIgnoredFocusChanges to prevent parent dialog's handleFocusIn
377
- // from interfering with focus restoration.
378
- if (previouslyFocused?.isConnected) {
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(// Center the dialog on screen with fixed positioning.
397
- // Native dialog uses position: fixed by default, but we need to ensure
398
- // proper centering with inset-0 and margin auto.
399
- "fixed inset-0 m-auto max-h-none max-w-none p-0", // Backdrop styling via Tailwind's backdrop: variant for ::backdrop pseudo-element.
400
- // Full black on mobile, 80% opacity on desktop (matches original overlay).
401
- "backdrop:bg-black sm:backdrop:bg-black/80", className);
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, Desktop: clamp between min and max to allow flexible sizing
481
- */ "min-h-[10rem] max-h-full sm:max-h-[40vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .SMALL */ ("small"),
482
- "min-h-[10rem] max-h-full sm:max-h-[60vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .MEDIUM */ ("medium"),
483
- "min-h-[10rem] max-h-full sm:max-h-[95vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .LARGE */ ("large"),
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-[13px]", "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
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": "8.0.0",
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": "e68a69d2d0dae9585b4c8e626c903ebea426db2e"
55
+ "gitHead": "9fa7c53e84b8f40adefbe63becd4841af772753a"
52
56
  }