@pk-design/react-tailwind 1.11.1 → 1.13.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.
@@ -384,27 +384,58 @@ const panelSizeMap = {
384
384
  '2xl': 'max-w-2xl',
385
385
  full: 'w-full',
386
386
  };
387
- const Drawer = ({ isOpen, onClose = () => { }, closeOnOutsideClick = true, align: alignProp = 'end', size: sizeProp = 'md', backdrop = true, title, description, children, footer, footerSticky = false, panelClass = '', contentClass = '', titleClass = '', descriptionClass = '', titleSticky = false, showBackButton = false, showCloseButton = true, dragHandle, }) => {
387
+ // How much shorter than the window the visual viewport must be before it's
388
+ // taken to be the on-screen keyboard rather than the URL bar (≈ 56px).
389
+ const KEYBOARD_THRESHOLD = 150;
390
+ const Drawer = ({ isOpen, onClose = () => { }, closeOnOutsideClick = true, align: alignProp = 'end', size: sizeProp = 'md', backdrop = true, title, description, children, footer, footerSticky = false, panelClass = '', contentClass = '', titleClass = '', descriptionClass = '', titleSticky = false, showBackButton = false, showCloseButton = true, dragHandle, phoneLayout = 'default', headerAction, }) => {
388
391
  const align = useResponsiveValue(alignProp);
389
392
  const size = useResponsiveValue(sizeProp);
393
+ const breakpoint = useBreakpoint();
394
+ const fullscreen = phoneLayout === 'fullscreen' && breakpoint === 'compact';
390
395
  const showTitleSection = Boolean(title || description || showBackButton || showCloseButton);
391
- const shouldShowDragHandle = dragHandle !== null && dragHandle !== void 0 ? dragHandle : align === 'bottom';
392
- const [viewportHeight, setViewportHeight] = React__default.useState(null);
396
+ const shouldShowDragHandle = !fullscreen && (dragHandle !== null && dragHandle !== void 0 ? dragHandle : align === 'bottom');
397
+ useCloseOnBack(fullscreen && isOpen, onClose);
398
+ // The visible area while the on-screen keyboard is open; null otherwise.
399
+ const [keyboardViewport, setKeyboardViewport] = React__default.useState(null);
393
400
  React__default.useEffect(() => {
394
401
  if (!isOpen) {
395
- setViewportHeight(null);
402
+ setKeyboardViewport(null);
396
403
  return;
397
404
  }
405
+ // Follow the visual viewport only while the on-screen keyboard takes space
406
+ // (it's then much shorter than the window), so the drawer and its footer
407
+ // stay above the keyboard. Otherwise it's pinned to the edges: tracking every resize and
408
+ // scroll in state re-rendered the drawer on each frame as the browser's
409
+ // URL bar moved, and its sticky header and footer jumped while scrolling.
410
+ let frame = 0;
411
+ const measure = () => {
412
+ frame = 0;
413
+ const vv = window.visualViewport;
414
+ if (!vv)
415
+ return;
416
+ const keyboardOpen = window.innerHeight - vv.height > KEYBOARD_THRESHOLD;
417
+ const top = Math.round(vv.offsetTop);
418
+ const height = Math.round(vv.height);
419
+ // The browser scrolls the visible area to the focused field, so the
420
+ // drawer follows its offset as well as its height. An unchanged value
421
+ // doesn't re-render.
422
+ setKeyboardViewport((current) => !keyboardOpen
423
+ ? null
424
+ : current && current.top === top && current.height === height
425
+ ? current
426
+ : { top, height });
427
+ };
398
428
  const updateHeight = () => {
399
- if (typeof window !== 'undefined' && window.visualViewport) {
400
- setViewportHeight(window.visualViewport.height);
401
- }
429
+ if (!frame)
430
+ frame = window.requestAnimationFrame(measure);
402
431
  };
403
- updateHeight();
432
+ measure();
404
433
  const vv = typeof window !== 'undefined' ? window.visualViewport : null;
405
434
  vv === null || vv === void 0 ? void 0 : vv.addEventListener('resize', updateHeight);
406
435
  vv === null || vv === void 0 ? void 0 : vv.addEventListener('scroll', updateHeight);
407
436
  return () => {
437
+ if (frame)
438
+ window.cancelAnimationFrame(frame);
408
439
  vv === null || vv === void 0 ? void 0 : vv.removeEventListener('resize', updateHeight);
409
440
  vv === null || vv === void 0 ? void 0 : vv.removeEventListener('scroll', updateHeight);
410
441
  };
@@ -414,9 +445,62 @@ const Drawer = ({ isOpen, onClose = () => { }, closeOnOutsideClick = true, align
414
445
  onClose === null || onClose === void 0 ? void 0 : onClose();
415
446
  }
416
447
  };
417
- return (jsxs(Dialog, { open: isOpen, as: "div", className: "relative z-50 focus:outline-none", onClose: handleDialogClose, transition: true, children: [backdrop && (jsx(DialogBackdrop, { className: "fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity duration-200" })), jsx("div", { className: "fixed left-0 top-0 w-full", style: { height: viewportHeight ? `${viewportHeight}px` : '100dvh' }, children: jsx("div", { className: buildClassName('flex', alignmentMap[align]), children: jsxs(DialogPanel, { transition: true, className: buildClassName('w-full max-w-full bg-white dark:bg-gray-800 shadow-xl flex flex-col min-w-0', 'overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y [-webkit-overflow-scrolling:touch] duration-200 ease-out data-[closed]:opacity-0', panelAlignmentMap[align], panelSizeMap[size], panelClass), children: [shouldShowDragHandle && (jsx("div", { className: "w-12 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mx-auto my-2.5 shrink-0 cursor-grab", "data-testid": "drawer-drag-handle" })), showTitleSection && (jsxs("div", { className: buildClassName('space-y-1 py-3 px-4 md:px-6 flex flex-col border-b border-[var(--ui-border)] shrink-0', titleSticky && 'sticky top-0 bg-white dark:bg-gray-800 z-10'), children: [jsxs("div", { className: "flex items-center justify-between gap-3 min-w-0", children: [jsxs("div", { className: "flex items-center gap-2 min-w-0 flex-1", children: [showBackButton && (jsx(Button, { iconOnly: true, "aria-label": "Back", variant: "plain", theme: "secondary", size: "xs", onClick: onClose, children: jsx(SvgChevronLeft, { className: "size-4" }) })), title && (jsx(DialogTitle, { as: "h3", className: buildClassName('text-lg font-semibold text-gray-900 dark:text-white truncate tracking-tight min-w-0', titleClass), children: title }))] }), showCloseButton && (jsx(Button, { iconOnly: true, "aria-label": "Close drawer", variant: "plain", theme: "secondary", size: "xs", onClick: onClose, className: "rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0", children: jsx(SvgX, { className: "size-4" }) }))] }), description && (jsx("p", { className: buildClassName('text-sm text-gray-500 dark:text-gray-400 font-normal truncate', descriptionClass), children: description }))] })), jsx("div", { className: buildClassName('flex-1 p-4 md:p-6 space-y-4 min-w-0', contentClass), children: children }), footer && (jsx("div", { className: buildClassName('p-4 border-t border-[var(--ui-border-muted)] bg-white dark:bg-gray-800 shrink-0 min-w-0', footerSticky && 'sticky bottom-0 z-10'), children: footer }))] }) }) })] }));
448
+ return (jsxs(Dialog, { open: isOpen, as: "div", className: "relative z-50 focus:outline-none", onClose: handleDialogClose, transition: true, children: [backdrop && (jsx(DialogBackdrop, { className: "fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity duration-200" })), jsx("div", { className: "fixed inset-0", style: keyboardViewport
449
+ ? {
450
+ top: `${keyboardViewport.top}px`,
451
+ height: `${keyboardViewport.height}px`,
452
+ bottom: 'auto',
453
+ }
454
+ : undefined, "data-testid": "drawer-viewport", children: jsx("div", { className: buildClassName('flex', fullscreen ? 'h-full' : alignmentMap[align]), children: jsxs(DialogPanel, { transition: true, className: buildClassName('w-full max-w-full bg-white dark:bg-gray-800 shadow-xl flex flex-col min-w-0', 'overflow-y-auto overflow-x-hidden overscroll-contain touch-pan-y [-webkit-overflow-scrolling:touch] duration-200 ease-out data-[closed]:opacity-0', ...(fullscreen
455
+ ? ['h-full data-[closed]:translate-y-4']
456
+ : [panelAlignmentMap[align], panelSizeMap[size], panelClass])), "data-layout": fullscreen ? 'fullscreen' : undefined, children: [shouldShowDragHandle && (jsx("div", { className: "w-12 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mx-auto my-2.5 shrink-0 cursor-grab", "data-testid": "drawer-drag-handle" })), fullscreen && (jsxs("div", { className: "sticky top-0 z-10 flex h-14 shrink-0 items-center gap-1 border-b border-[var(--ui-border)] bg-white pl-1 pr-3 pt-[env(safe-area-inset-top,0px)] box-content dark:bg-gray-800", children: [jsx(Button, { iconOnly: true, "aria-label": "Close", variant: "plain", theme: "secondary", onClick: onClose, className: "shrink-0", children: jsx(SvgX, { className: "size-5" }) }), title && (jsx(DialogTitle, { as: "h3", className: "min-w-0 flex-1 truncate text-base font-semibold text-gray-900 dark:text-white", children: title })), headerAction && jsx("div", { className: "ml-auto shrink-0", children: headerAction })] })), !fullscreen && showTitleSection && (jsxs("div", { className: buildClassName('space-y-1 py-3 px-4 md:px-6 flex flex-col border-b border-[var(--ui-border)] shrink-0', titleSticky && 'sticky top-0 bg-white dark:bg-gray-800 z-10'), children: [jsxs("div", { className: "flex items-center justify-between gap-3 min-w-0", children: [jsxs("div", { className: "flex items-center gap-2 min-w-0 flex-1", children: [showBackButton && (jsx(Button, { iconOnly: true, "aria-label": "Back", variant: "plain", theme: "secondary", size: "xs", onClick: onClose, children: jsx(SvgChevronLeft, { className: "size-4" }) })), title && (jsx(DialogTitle, { as: "h3", className: buildClassName('text-lg font-semibold text-gray-900 dark:text-white truncate tracking-tight min-w-0', titleClass), children: title }))] }), showCloseButton && (jsx(Button, { iconOnly: true, "aria-label": "Close drawer", variant: "plain", theme: "secondary", size: "xs", onClick: onClose, className: "rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 shrink-0", children: jsx(SvgX, { className: "size-4" }) }))] }), description && (jsx("p", { className: buildClassName('text-sm text-gray-500 dark:text-gray-400 font-normal truncate', descriptionClass), children: description }))] })), jsxs("div", { className: buildClassName('flex-1 p-4 md:p-6 space-y-4 min-w-0', contentClass, fullscreen && 'pb-[calc(1rem+env(safe-area-inset-bottom,0px))]'), children: [fullscreen && description && (jsx("p", { className: buildClassName('text-sm text-gray-500 dark:text-gray-400', descriptionClass), children: description })), children] }), !fullscreen && footer && (jsx("div", { className: buildClassName('p-4 border-t border-[var(--ui-border-muted)] bg-white dark:bg-gray-800 shrink-0 min-w-0', footerSticky && 'sticky bottom-0 z-10'), children: footer }))] }) }) })] }));
418
457
  };
419
458
  Drawer.displayName = 'Drawer';
459
+ /**
460
+ * While `active`, the browser's back button (and Android's back gesture)
461
+ * closes the drawer instead of leaving the page: opening adds a history entry
462
+ * (keeping the current state, so routers see the same location), and back
463
+ * removes it. Closed any other way, the entry is removed too, but only if it's
464
+ * still the latest one, so a form that saves and then navigates isn't undone.
465
+ */
466
+ function useCloseOnBack(active, onClose) {
467
+ const onCloseRef = React__default.useRef(onClose);
468
+ React__default.useEffect(() => {
469
+ onCloseRef.current = onClose;
470
+ });
471
+ const token = React__default.useId();
472
+ React__default.useEffect(() => {
473
+ if (!active || typeof window === 'undefined')
474
+ return;
475
+ const pushEntry = () => { var _a; return window.history.pushState({ ...((_a = window.history.state) !== null && _a !== void 0 ? _a : {}), drawer: token }, ''); };
476
+ pushEntry();
477
+ let entryPresent = true;
478
+ let disposed = false;
479
+ const handlePop = () => {
480
+ var _a;
481
+ if (((_a = window.history.state) === null || _a === void 0 ? void 0 : _a.drawer) === token)
482
+ return;
483
+ entryPresent = false;
484
+ onCloseRef.current();
485
+ // onClose may step back within the drawer instead of closing it; if it's
486
+ // still open afterwards, take back its entry so the next back works too.
487
+ window.setTimeout(() => {
488
+ if (disposed)
489
+ return;
490
+ pushEntry();
491
+ entryPresent = true;
492
+ }, 0);
493
+ };
494
+ window.addEventListener('popstate', handlePop);
495
+ return () => {
496
+ var _a;
497
+ disposed = true;
498
+ window.removeEventListener('popstate', handlePop);
499
+ if (entryPresent && ((_a = window.history.state) === null || _a === void 0 ? void 0 : _a.drawer) === token)
500
+ window.history.back();
501
+ };
502
+ }, [active, token]);
503
+ }
420
504
 
421
505
  function ActionSheet({ isOpen, onClose, title, description, actions, showCancelButton = true, cancelLabel = 'Cancel', onCancel, closeOnBackdropClick = true, dragHandle = true, panelClass = '', containerClass = '', }) {
422
506
  const handleItemClick = (action) => {
@@ -701,7 +785,7 @@ const BodyText = forwardRef((props, ref) => {
701
785
  });
702
786
  BodyText.displayName = 'BodyText';
703
787
 
704
- function BottomNavigation({ items, activeId, onChange, fixed = true, showLabels = 'always', theme = 'light', activeIndicatorStyle = 'line', containerClass = '', itemClass = '', linkAs = 'a', linkHrefProp = 'href', ariaLabel = 'Bottom Navigation', hidden = false, }) {
788
+ function BottomNavigation({ items, activeId, onChange, fixed = true, showLabels = 'always', theme = 'light', activeIndicatorStyle = 'line', containerClass = '', itemClass = '', linkAs = 'a', linkHrefProp = 'href', ariaLabel = 'Bottom Navigation', hidden = false, rounded = false, }) {
705
789
  const handleTabClick = (item) => {
706
790
  var _a;
707
791
  if (item.disabled)
@@ -717,7 +801,7 @@ function BottomNavigation({ items, activeId, onChange, fixed = true, showLabels
717
801
  dark: 'bg-gray-900 text-gray-400 border-t border-gray-800',
718
802
  primary: 'bg-blue-600 text-blue-100 border-t border-blue-700',
719
803
  }[theme];
720
- return (jsx("nav", { "aria-label": ariaLabel, className: buildClassName('w-full flex items-center justify-around select-none shadow-lg transition-colors relative', fixed && 'fixed bottom-0 left-0 right-0 z-40', 'pb-[env(safe-area-inset-bottom,6px)] px-2', themeStyles, containerClass), "data-testid": "bottom-navigation", children: items.map((item) => {
804
+ return (jsx("nav", { "aria-label": ariaLabel, className: buildClassName('w-full flex items-center justify-around select-none shadow-lg transition-colors relative', fixed && 'fixed bottom-0 left-0 right-0 z-40', 'pb-[env(safe-area-inset-bottom,6px)] px-2', themeStyles, rounded && 'rounded-t-2xl border-x', containerClass), "data-testid": "bottom-navigation", children: items.map((item) => {
721
805
  const isActive = item.id === activeId;
722
806
  const showLabelText = showLabels === 'always' || (showLabels === 'active' && isActive);
723
807
  const activeItemColor = {
@@ -3495,7 +3579,9 @@ function MobileHeader({ title, subtitle, titleAlign = 'center', onBack, backLabe
3495
3579
  };
3496
3580
  return (jsxs("header", { className: buildClassName('w-full transition-colors select-none z-30', sticky && 'sticky top-0', transparent
3497
3581
  ? 'bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-[var(--ui-border-muted)]'
3498
- : 'bg-white dark:bg-gray-900 border-b border-[var(--ui-border)] shadow-xs', 'pt-[env(safe-area-inset-top,0px)]', className), "data-testid": "mobile-header", children: [jsx("div", { className: "h-14 px-4 flex items-center justify-between gap-2 relative", children: searchable && isSearchActive ? (jsxs("div", { className: "flex-1 flex items-center gap-2", "data-testid": "mobile-header-search-container", children: [jsxs("div", { className: "relative flex-1 flex items-center", children: [jsx("span", { className: "absolute left-3 pointer-events-none", children: jsx(SearchIcon$1, {}) }), jsx("input", { type: "text", value: searchValue, onChange: handleSearchInput, placeholder: searchPlaceholder, autoFocus: true, className: "w-full pl-9 pr-8 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-full border border-transparent focus:border-blue-500 focus:outline-hidden transition-all", "data-testid": "mobile-header-search-input" }), searchValue && (jsx("button", { type: "button", onClick: handleClearSearch, "aria-label": "Clear search", className: "absolute right-2.5 p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer", children: jsx(ClearIcon, {}) }))] }), jsx("button", { type: "button", onClick: () => setIsSearchActive(false), className: "text-xs font-semibold text-blue-600 dark:text-blue-400 px-2 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950/40 cursor-pointer", children: "Cancel" })] })) : (jsxs(Fragment, { children: [hasLeadingSlot && (jsx("div", { className: buildClassName('flex items-center gap-1 shrink-0 z-10',
3582
+ : 'bg-white dark:bg-gray-900 border-b border-[var(--ui-border)] shadow-xs', 'pt-[env(safe-area-inset-top,0px)]', className), "data-testid": "mobile-header", children: [jsx("div", { className: "h-16 px-4 flex items-center justify-between gap-2 relative", children: searchable && isSearchActive ? (jsxs("div", { className: "flex-1 flex items-center gap-2", "data-testid": "mobile-header-search-container", children: [jsxs("div", { className: "relative flex-1 flex items-center", children: [jsx("span", { className: "absolute left-3 pointer-events-none", children: jsx(SearchIcon$1, {}) }), jsx("input", { type: "text", value: searchValue, onChange: handleSearchInput, placeholder: searchPlaceholder, autoFocus: true,
3583
+ // 16px text: iOS zooms the page into any field with smaller text.
3584
+ className: "w-full pl-9 pr-8 py-2 text-base bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-full border border-transparent focus:border-blue-500 focus:outline-hidden transition-all", "data-testid": "mobile-header-search-input" }), searchValue && (jsx("button", { type: "button", onClick: handleClearSearch, "aria-label": "Clear search", className: "absolute right-2.5 p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer", children: jsx(ClearIcon, {}) }))] }), jsx("button", { type: "button", onClick: () => setIsSearchActive(false), className: "text-xs font-semibold text-blue-600 dark:text-blue-400 px-2 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950/40 cursor-pointer", children: "Cancel" })] })) : (jsxs(Fragment, { children: [hasLeadingSlot && (jsx("div", { className: buildClassName('flex items-center gap-1 shrink-0 z-10',
3499
3585
  // A Back button is its own 40px target; reserve the width only for other content.
3500
3586
  !onBack && 'min-w-[40px]'), "data-testid": "mobile-header-leading", children: onBack ? (jsxs("button", { type: "button", onClick: onBack, "aria-label": backLabel,
3501
3587
  // 40px-tall touch target hanging into the row padding: the chevron
@@ -3506,7 +3592,7 @@ function MobileHeader({ title, subtitle, titleAlign = 'center', onBack, backLabe
3506
3592
  // a centred title keeps even padding.
3507
3593
  titleAlign === 'center' ? 'px-2' : 'pr-2', titleAlign === 'center'
3508
3594
  ? 'absolute inset-x-12 text-center pointer-events-none flex flex-col items-center justify-center'
3509
- : 'text-left'), children: [title && (jsx("div", { className: "text-sm font-bold text-gray-900 dark:text-white truncate pointer-events-auto", "data-testid": "mobile-header-title", children: title })), subtitle && (jsx("div", { className: "text-[11px] text-gray-500 dark:text-gray-400 truncate pointer-events-auto", "data-testid": "mobile-header-subtitle", children: subtitle }))] }), jsxs("div", { className: "flex items-center gap-1 min-w-[40px] shrink-0 justify-end z-10", children: [searchable && (jsx("button", { type: "button", onClick: () => setIsSearchActive(true), "aria-label": "Search", className: "p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors cursor-pointer", "data-testid": "mobile-header-search-toggle", children: jsx(SearchIcon$1, {}) })), visibleActions.map((action) => {
3595
+ : 'text-left'), children: [title && (jsx("div", { className: "text-lg leading-6 font-semibold text-gray-900 dark:text-white truncate pointer-events-auto", "data-testid": "mobile-header-title", children: title })), subtitle && (jsx("div", { className: "text-[13px] leading-5 text-gray-500 dark:text-gray-400 truncate pointer-events-auto", "data-testid": "mobile-header-subtitle", children: subtitle }))] }), jsxs("div", { className: "flex items-center gap-1 min-w-[40px] shrink-0 justify-end z-10", children: [searchable && (jsx("button", { type: "button", onClick: () => setIsSearchActive(true), "aria-label": "Search", className: "p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors cursor-pointer", "data-testid": "mobile-header-search-toggle", children: jsx(SearchIcon$1, {}) })), visibleActions.map((action) => {
3510
3596
  const badgeColor = {
3511
3597
  danger: 'bg-red-500 text-white',
3512
3598
  info: 'bg-blue-500 text-white',