@pk-design/react-tailwind 1.12.0 → 1.13.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.
@@ -372,8 +372,10 @@ const panelAlignmentMap = {
372
372
  start: 'data-[closed]:-translate-x-1/2 rounded-r-2xl',
373
373
  end: 'data-[closed]:translate-x-1/2 rounded-l-2xl',
374
374
  top: 'data-[closed]:-translate-y-1/2 rounded-b-2xl',
375
- // Bottom sheets clear the home indicator on notched phones (0 elsewhere).
376
- bottom: 'data-[closed]:translate-y-full rounded-t-2xl md:rounded-t-3xl max-h-[88vh] max-h-[88dvh] pb-[env(safe-area-inset-bottom,0px)]',
375
+ // Bottom sheets clear the home indicator on notched phones (0 elsewhere). Never
376
+ // taller than the container: with the keyboard up it's shorter than 88dvh, and a
377
+ // bottom-anchored panel past its top is off screen and can't be scrolled to.
378
+ bottom: 'data-[closed]:translate-y-full rounded-t-2xl md:rounded-t-3xl max-h-[min(88dvh,100%)] pb-[env(safe-area-inset-bottom,0px)]',
377
379
  };
378
380
  const panelSizeMap = {
379
381
  xs: 'max-w-xs',
@@ -384,27 +386,58 @@ const panelSizeMap = {
384
386
  '2xl': 'max-w-2xl',
385
387
  full: 'w-full',
386
388
  };
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, }) => {
389
+ // How much shorter than the window the visual viewport must be before it's
390
+ // taken to be the on-screen keyboard rather than the URL bar (≈ 56px).
391
+ const KEYBOARD_THRESHOLD = 150;
392
+ 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
393
  const align = useResponsiveValue(alignProp);
389
394
  const size = useResponsiveValue(sizeProp);
395
+ const breakpoint = useBreakpoint();
396
+ const fullscreen = phoneLayout === 'fullscreen' && breakpoint === 'compact';
390
397
  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);
398
+ const shouldShowDragHandle = !fullscreen && (dragHandle !== null && dragHandle !== void 0 ? dragHandle : align === 'bottom');
399
+ useCloseOnBack(fullscreen && isOpen, onClose);
400
+ // The visible area while the on-screen keyboard is open; null otherwise.
401
+ const [keyboardViewport, setKeyboardViewport] = React__default.useState(null);
393
402
  React__default.useEffect(() => {
394
403
  if (!isOpen) {
395
- setViewportHeight(null);
404
+ setKeyboardViewport(null);
396
405
  return;
397
406
  }
407
+ // Follow the visual viewport only while the on-screen keyboard takes space
408
+ // (it's then much shorter than the window), so the drawer and its footer
409
+ // stay above the keyboard. Otherwise it's pinned to the edges: tracking every resize and
410
+ // scroll in state re-rendered the drawer on each frame as the browser's
411
+ // URL bar moved, and its sticky header and footer jumped while scrolling.
412
+ let frame = 0;
413
+ const measure = () => {
414
+ frame = 0;
415
+ const vv = window.visualViewport;
416
+ if (!vv)
417
+ return;
418
+ const keyboardOpen = window.innerHeight - vv.height > KEYBOARD_THRESHOLD;
419
+ const top = Math.round(vv.offsetTop);
420
+ const height = Math.round(vv.height);
421
+ // The browser scrolls the visible area to the focused field, so the
422
+ // drawer follows its offset as well as its height. An unchanged value
423
+ // doesn't re-render.
424
+ setKeyboardViewport((current) => !keyboardOpen
425
+ ? null
426
+ : current && current.top === top && current.height === height
427
+ ? current
428
+ : { top, height });
429
+ };
398
430
  const updateHeight = () => {
399
- if (typeof window !== 'undefined' && window.visualViewport) {
400
- setViewportHeight(window.visualViewport.height);
401
- }
431
+ if (!frame)
432
+ frame = window.requestAnimationFrame(measure);
402
433
  };
403
- updateHeight();
434
+ measure();
404
435
  const vv = typeof window !== 'undefined' ? window.visualViewport : null;
405
436
  vv === null || vv === void 0 ? void 0 : vv.addEventListener('resize', updateHeight);
406
437
  vv === null || vv === void 0 ? void 0 : vv.addEventListener('scroll', updateHeight);
407
438
  return () => {
439
+ if (frame)
440
+ window.cancelAnimationFrame(frame);
408
441
  vv === null || vv === void 0 ? void 0 : vv.removeEventListener('resize', updateHeight);
409
442
  vv === null || vv === void 0 ? void 0 : vv.removeEventListener('scroll', updateHeight);
410
443
  };
@@ -414,9 +447,96 @@ const Drawer = ({ isOpen, onClose = () => { }, closeOnOutsideClick = true, align
414
447
  onClose === null || onClose === void 0 ? void 0 : onClose();
415
448
  }
416
449
  };
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 }))] }) }) })] }));
450
+ 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
451
+ ? {
452
+ top: `${keyboardViewport.top}px`,
453
+ height: `${keyboardViewport.height}px`,
454
+ bottom: 'auto',
455
+ }
456
+ : 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
457
+ ? ['h-full data-[closed]:translate-y-4']
458
+ : [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
459
  };
419
460
  Drawer.displayName = 'Drawer';
461
+ // history.back() lands later, so a drawer opening meanwhile (one replacing another,
462
+ // or React re-running effects under StrictMode) would have its new entry removed
463
+ // instead, and close at once. Removals are counted, and an opening drawer waits for them.
464
+ let pendingBacks = 0;
465
+ const afterBacks = [];
466
+ function settleBack() {
467
+ pendingBacks = Math.max(0, pendingBacks - 1);
468
+ if (pendingBacks === 0)
469
+ afterBacks.splice(0).forEach((run) => run());
470
+ }
471
+ function removeEntry() {
472
+ pendingBacks += 1;
473
+ let settled = false;
474
+ const settle = () => {
475
+ if (settled)
476
+ return;
477
+ settled = true;
478
+ window.removeEventListener('popstate', settle);
479
+ settleBack();
480
+ };
481
+ window.addEventListener('popstate', settle);
482
+ // A back that never reports (nothing to go back to) mustn't hold others up.
483
+ window.setTimeout(settle, 500);
484
+ window.history.back();
485
+ }
486
+ /**
487
+ * While `active`, the browser's back button (and Android's back gesture)
488
+ * closes the drawer instead of leaving the page: opening adds a history entry
489
+ * (keeping the current state, so routers see the same location), and back
490
+ * removes it. Closed any other way, the entry is removed too, but only if it's
491
+ * still the latest one, so a form that saves and then navigates isn't undone.
492
+ */
493
+ function useCloseOnBack(active, onClose) {
494
+ const onCloseRef = React__default.useRef(onClose);
495
+ React__default.useEffect(() => {
496
+ onCloseRef.current = onClose;
497
+ });
498
+ const token = React__default.useId();
499
+ React__default.useEffect(() => {
500
+ if (!active || typeof window === 'undefined')
501
+ return;
502
+ const pushEntry = () => { var _a; return window.history.pushState({ ...((_a = window.history.state) !== null && _a !== void 0 ? _a : {}), drawer: token }, ''); };
503
+ let entryPresent = false;
504
+ let disposed = false;
505
+ const handlePop = () => {
506
+ var _a;
507
+ if (((_a = window.history.state) === null || _a === void 0 ? void 0 : _a.drawer) === token)
508
+ return;
509
+ entryPresent = false;
510
+ onCloseRef.current();
511
+ // onClose may step back within the drawer instead of closing it; if it's
512
+ // still open afterwards, take back its entry so the next back works too.
513
+ window.setTimeout(() => {
514
+ if (disposed)
515
+ return;
516
+ pushEntry();
517
+ entryPresent = true;
518
+ }, 0);
519
+ };
520
+ const start = () => {
521
+ if (disposed)
522
+ return;
523
+ pushEntry();
524
+ entryPresent = true;
525
+ window.addEventListener('popstate', handlePop);
526
+ };
527
+ if (pendingBacks > 0)
528
+ afterBacks.push(start);
529
+ else
530
+ start();
531
+ return () => {
532
+ var _a;
533
+ disposed = true;
534
+ window.removeEventListener('popstate', handlePop);
535
+ if (entryPresent && ((_a = window.history.state) === null || _a === void 0 ? void 0 : _a.drawer) === token)
536
+ removeEntry();
537
+ };
538
+ }, [active, token]);
539
+ }
420
540
 
421
541
  function ActionSheet({ isOpen, onClose, title, description, actions, showCancelButton = true, cancelLabel = 'Cancel', onCancel, closeOnBackdropClick = true, dragHandle = true, panelClass = '', containerClass = '', }) {
422
542
  const handleItemClick = (action) => {
@@ -4255,7 +4375,7 @@ const SelectBox = forwardRef((props, forwardedRef) => {
4255
4375
  const isOptSelected = selectedValueSet.has(optVal);
4256
4376
  return (jsx(ComboboxOption, { disabled: option.disabled, value: option, className: "group rounded-lg py-1.5 px-3 select-none data-[focus]:bg-gray-100 dark:data-[focus]:bg-white/10 data-disabled:opacity-50 cursor-pointer data-disabled:pointer-events-none", children: renderOption ? (renderOption(option, Boolean(isOptSelected))) : (jsxs("div", { className: "flex items-center justify-between gap-2 text-gray-900 dark:text-white group-data-[selected]:text-blue-600", children: [jsx("span", { className: "text-sm/6", children: getDisplayValue(option) }), jsx(SvgCheck, { className: "invisible size-5 dark:stroke-gray-300 group-data-[selected]:visible" })] })) }, optVal));
4257
4377
  };
4258
- return (jsxs("div", { className: buildClassName('group space-y-2', hasError && 'has-error', containerClass), ref: containerRef, children: [label && (jsxs("div", { className: buildClassName('flex items-center justify-between', labelWrapperClass), children: [jsx(Label, { className: labelClass, htmlFor: id, "aria-required": required, children: label }), labelHint && jsx(TextContent, { xsmall: true, children: labelHint })] })), jsxs(Combobox, { multiple: multiple, immediate: immediate, name: name, value: normalizedSelected, onChange: comboboxOnChangeWrapper, onClose: () => setQuery(''), disabled: disabled, by: (a, b) => (a && b ? getValue(a) === getValue(b) : a === b), children: [multiple ? (jsxs("div", { className: buildClassName(baseInputClass, 'flex flex-wrap gap-2 peer relative', rightPaddingClass, hasLeftGroup ? (size === 'lg' ? 'ps-12' : size === 'sm' ? 'ps-8' : 'ps-10') : ''), ref: forwardedRef, children: [hasLeftGroup && (jsx("span", { className: buildClassName('absolute left-0 top-0 h-full flex items-center justify-center text-gray-400 dark:text-gray-500', currentSize.group), children: leftGroup })), isArray(normalizedSelected) &&
4378
+ return (jsxs("div", { className: buildClassName('group', hasError && 'has-error', containerClass), ref: containerRef, children: [label && (jsxs("div", { className: buildClassName('flex items-center justify-between mb-1.5', labelWrapperClass), children: [jsx(Label, { className: labelClass, htmlFor: id, "aria-required": required, children: label }), labelHint && jsx(TextContent, { xsmall: true, children: labelHint })] })), jsxs(Combobox, { multiple: multiple, immediate: immediate, name: name, value: normalizedSelected, onChange: comboboxOnChangeWrapper, onClose: () => setQuery(''), disabled: disabled, by: (a, b) => (a && b ? getValue(a) === getValue(b) : a === b), children: [multiple ? (jsxs("div", { className: buildClassName(baseInputClass, 'flex flex-wrap gap-2 peer relative', rightPaddingClass, hasLeftGroup ? (size === 'lg' ? 'ps-12' : size === 'sm' ? 'ps-8' : 'ps-10') : ''), ref: forwardedRef, children: [hasLeftGroup && (jsx("span", { className: buildClassName('absolute left-0 top-0 h-full flex items-center justify-center text-gray-400 dark:text-gray-500', currentSize.group), children: leftGroup })), isArray(normalizedSelected) &&
4259
4379
  (normalizedSelected === null || normalizedSelected === void 0 ? void 0 : normalizedSelected.length) > 0 &&
4260
4380
  normalizedSelected.map((option, idx) => (jsx(Badge, { theme: "secondary", size: currentSize.badgeSize, removable: !disabled, onRemove: () => removeSelection(idx), children: getDisplayValue(option) }, `${getValue(option)}-${idx}`))), jsx(ComboboxInput, { className: "outline-none flex-1 dark:placeholder-gray-400 placeholder-gray-400 bg-transparent", "aria-label": name, value: query, autoComplete: "off", placeholder: (normalizedSelected === null || normalizedSelected === void 0 ? void 0 : normalizedSelected.length) ? '' : placeholder, onChange: (e) => {
4261
4381
  const val = e.target.value;
@@ -4386,7 +4506,7 @@ const QuantityStepper = forwardRef((props, ref) => {
4386
4506
  };
4387
4507
  const isAtMin = disabled || value <= min;
4388
4508
  const isAtMax = disabled || value >= max;
4389
- return (jsxs("div", { className: buildClassName('flex flex-col gap-1 inline-flex', className), children: [label && (jsxs("div", { className: "flex items-center justify-between gap-2", children: [jsx(Label, { htmlFor: stepperId, className: disabled ? 'opacity-70 cursor-not-allowed' : '', children: label }), labelHint && jsx(TextContent, { xsmall: true, children: labelHint })] })), jsxs("div", { className: buildClassName('inline-flex items-center rounded-lg shadow-2xs transition-all duration-150', 'outline outline-1 -outline-offset-1', CONTAINER_SIZE_CLASS[size], hasError
4509
+ return (jsxs("div", { className: buildClassName('flex flex-col gap-1.5 inline-flex', className), children: [label && (jsxs("div", { className: "flex items-center justify-between gap-2", children: [jsx(Label, { htmlFor: stepperId, className: disabled ? 'opacity-70 cursor-not-allowed' : '', children: label }), labelHint && jsx(TextContent, { xsmall: true, children: labelHint })] })), jsxs("div", { className: buildClassName('inline-flex items-center rounded-lg shadow-2xs transition-all duration-150', 'outline outline-1 -outline-offset-1', CONTAINER_SIZE_CLASS[size], hasError
4390
4510
  ? '!outline-red-500 !ring-1 !ring-red-500/50 dark:!outline-red-500'
4391
4511
  : 'outline-[var(--ui-border)]', disabled
4392
4512
  ? 'pointer-events-none bg-gray-100 dark:bg-gray-800 outline-[var(--ui-border-muted)]'