@allxsmith/bestax-bulma 5.22.0 → 5.24.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.
Files changed (52) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/extras.css +1 -1
  4. package/dist/extras.css.map +1 -1
  5. package/dist/index.cjs +1769 -562
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.esm.js +1764 -564
  8. package/dist/index.esm.js.map +1 -1
  9. package/dist/types/components/Popover.d.ts +166 -0
  10. package/dist/types/form/DateInput.d.ts +1 -1
  11. package/dist/types/form/DateInputBase.d.ts +33 -3
  12. package/dist/types/form/_pickerInternals/Calendar.d.ts +11 -1
  13. package/dist/types/form/_pickerInternals/dateUtils.d.ts +36 -1
  14. package/dist/types/form/_pickerInternals/formatters.d.ts +2 -0
  15. package/dist/types/form/_pickerInternals/nativeInputSupport.d.ts +11 -0
  16. package/dist/types/form/_pickerInternals/pickerTypes.d.ts +10 -0
  17. package/dist/types/form/_pickerInternals/useSegmentedEntry.d.ts +9 -1
  18. package/dist/types/helpers/portal.d.ts +62 -0
  19. package/dist/types/helpers/shadowDom.d.ts +3 -1
  20. package/dist/types/helpers/useAnchoredPosition.d.ts +59 -0
  21. package/dist/types/helpers/useClientLayoutEffect.d.ts +7 -0
  22. package/dist/types/helpers/useFocusTrap.d.ts +84 -0
  23. package/dist/types/index.d.ts +5 -0
  24. package/dist/types-cjs/components/Popover.d.ts +166 -0
  25. package/dist/types-cjs/form/DateInput.d.ts +1 -1
  26. package/dist/types-cjs/form/DateInputBase.d.ts +33 -3
  27. package/dist/types-cjs/form/_pickerInternals/Calendar.d.ts +11 -1
  28. package/dist/types-cjs/form/_pickerInternals/dateUtils.d.ts +36 -1
  29. package/dist/types-cjs/form/_pickerInternals/formatters.d.ts +2 -0
  30. package/dist/types-cjs/form/_pickerInternals/nativeInputSupport.d.ts +11 -0
  31. package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +10 -0
  32. package/dist/types-cjs/form/_pickerInternals/useSegmentedEntry.d.ts +9 -1
  33. package/dist/types-cjs/helpers/portal.d.ts +62 -0
  34. package/dist/types-cjs/helpers/shadowDom.d.ts +3 -1
  35. package/dist/types-cjs/helpers/useAnchoredPosition.d.ts +59 -0
  36. package/dist/types-cjs/helpers/useClientLayoutEffect.d.ts +7 -0
  37. package/dist/types-cjs/helpers/useFocusTrap.d.ts +84 -0
  38. package/dist/types-cjs/index.d.ts +5 -0
  39. package/dist/versions/bestax-no-dark-mode.css +1 -1
  40. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  41. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  42. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  43. package/dist/versions/bestax-no-helpers.css +1 -1
  44. package/dist/versions/bestax-no-helpers.css.map +1 -1
  45. package/dist/versions/bestax-prefixed.css +1 -1
  46. package/dist/versions/bestax-prefixed.css.map +1 -1
  47. package/package.json +1 -1
  48. package/src/scss/components/_index.scss +1 -0
  49. package/src/scss/components/_popover.scss +144 -0
  50. package/src/scss/form/_dateinput.scss +20 -3
  51. package/dist/types/form/_pickerInternals/useFocusTrap.d.ts +0 -7
  52. package/dist/types-cjs/form/_pickerInternals/useFocusTrap.d.ts +0 -7
package/dist/index.cjs CHANGED
@@ -2256,10 +2256,12 @@ function getActiveElementInTree(node) {
2256
2256
  * opener sits in. A closed shadow root cannot be entered, so focus inside one
2257
2257
  * reads as its host.
2258
2258
  *
2259
+ * @param doc - The document to start from: the component's own, when it may
2260
+ * render into one other than the page's (an iframe's). Defaults to `document`.
2259
2261
  * @returns The focused element, or `null` when the document has none.
2260
2262
  */
2261
- function getDeepestActiveElement() {
2262
- let active = document.activeElement;
2263
+ function getDeepestActiveElement(doc = document) {
2264
+ let active = doc.activeElement;
2263
2265
  while (active?.shadowRoot?.activeElement) {
2264
2266
  active = active.shadowRoot.activeElement;
2265
2267
  }
@@ -2796,6 +2798,32 @@ function useScrollLock(active) {
2796
2798
  }, [active]);
2797
2799
  }
2798
2800
 
2801
+ // "Are we past hydration?" as a store that never changes: React reads the
2802
+ // server snapshot both while server-rendering and while hydrating, and the
2803
+ // client snapshot from the first post-hydration render onward.
2804
+ const subscribeToNothing = () => () => { };
2805
+ const getClientSnapshot = () => true;
2806
+ const getServerSnapshot = () => false;
2807
+ /**
2808
+ * Reports whether the component has passed hydration.
2809
+ *
2810
+ * Returns `false` during server rendering and during the hydrating client
2811
+ * render, then `true` from the commit that follows. Use it to defer
2812
+ * DOM-only behaviour (such as portalling) past hydration so the first client
2813
+ * render matches the server markup instead of tripping hydration recovery.
2814
+ *
2815
+ * Only a component that hydrates ever sees `false` on the client. One that
2816
+ * mounts later (after a route change, or in an app with no server rendering)
2817
+ * gets `true` on its first render, so it pays for no extra render. It
2818
+ * subscribes to nothing, so no later render flips it back.
2819
+ *
2820
+ * @function useIsHydrated
2821
+ * @returns `true` once the hydrating render has completed, otherwise `false`.
2822
+ */
2823
+ function useIsHydrated() {
2824
+ return React.useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot);
2825
+ }
2826
+
2799
2827
  /**
2800
2828
  * Resolves a portal target: an `HTMLElement` is used directly, a non-empty
2801
2829
  * `string` is treated as a `document.querySelector` selector (falling back to
@@ -2819,31 +2847,49 @@ function resolvePortalContainer(container) {
2819
2847
  }
2820
2848
  return container;
2821
2849
  }
2822
-
2823
- // "Are we past hydration?" as a store that never changes: React reads the
2824
- // server snapshot both while server-rendering and while hydrating, and the
2825
- // client snapshot from the first post-hydration render onward.
2826
- const subscribeToNothing = () => () => { };
2827
- const getClientSnapshot = () => true;
2828
- const getServerSnapshot = () => false;
2829
2850
  /**
2830
- * Reports whether the component has passed hydration.
2851
+ * Renders its children into another part of the page, `document.body` unless `container` says otherwise, so floating content escapes an ancestor's `overflow`, `transform` or stacking context.
2831
2852
  *
2832
- * Returns `false` during server rendering and during the hydrating client
2833
- * render, then `true` from the commit that follows. Use it to defer
2834
- * DOM-only behaviour (such as portalling) past hydration so the first client
2835
- * render matches the server markup instead of tripping hydration recovery.
2853
+ * React's server renderer can't render a portal, so `Portal` renders nothing
2854
+ * on the server and during hydration, and mounts its children in the commit
2855
+ * that follows. The first client render then matches the server markup. In an app
2856
+ * without server rendering it portals on the first render.
2836
2857
  *
2837
- * Only a component that hydrates ever sees `false` on the client. One that
2838
- * mounts later (after a route change, or in an app with no server rendering)
2839
- * gets `true` on its first render, so it pays for no extra render. It
2840
- * subscribes to nothing, so no later render flips it back.
2858
+ * React context, such as `ConfigProvider`'s class prefix, reaches the
2859
+ * portaled children, and their React events bubble through the React tree.
2860
+ * CSS inheritance follows the DOM instead: a scoped (non-`isRoot`) `Theme`
2861
+ * sets its variables on a wrapper element the portaled content has left, so
2862
+ * that content sees the page-level theme. Point `container` at an element
2863
+ * inside the themed area to keep it.
2841
2864
  *
2842
- * @function useIsHydrated
2843
- * @returns `true` once the hydrating render has completed, otherwise `false`.
2865
+ * Tab order and focus follow the DOM too. Content portaled from inside a
2866
+ * focus trap is no longer inside the trapped element, so the trap neither
2867
+ * reaches it nor holds focus there: render nested overlays inside the
2868
+ * trapped element, or give them a trap of their own.
2869
+ *
2870
+ * @function
2871
+ * @param {PortalProps} props - Props for the Portal component.
2872
+ * @returns The portal, the children in place when `disabled`, or nothing
2873
+ * before hydration.
2874
+ *
2875
+ * @example
2876
+ * <Portal>
2877
+ * <div className="box">Rendered at the end of document.body</div>
2878
+ * </Portal>
2879
+ *
2880
+ * @example
2881
+ * // Into a dedicated overlay root
2882
+ * <Portal container="#overlays">
2883
+ * <CommandPalette />
2884
+ * </Portal>
2844
2885
  */
2845
- function useIsHydrated() {
2846
- return React.useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot);
2886
+ function Portal({ children, container, disabled = false }) {
2887
+ const hydrated = useIsHydrated();
2888
+ if (disabled)
2889
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
2890
+ if (!hydrated)
2891
+ return null;
2892
+ return reactDom.createPortal(children, resolvePortalContainer(container));
2847
2893
  }
2848
2894
 
2849
2895
  /**
@@ -2857,7 +2903,7 @@ const activeModalStack = [];
2857
2903
  * excluded because `focus()` on them is a no-op, which would leave focus
2858
2904
  * outside the modal.
2859
2905
  */
2860
- const FOCUSABLE_SELECTOR$1 = 'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]';
2906
+ const FOCUSABLE_SELECTOR = 'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]';
2861
2907
  /**
2862
2908
  * The modal's tab stops, in document order. The selector alone is not the
2863
2909
  * tabbable set — `[href]` and `button` match regardless of `tabindex`, so an
@@ -2866,7 +2912,7 @@ const FOCUSABLE_SELECTOR$1 = 'button:not(:disabled), [href], input:not(:disabled
2866
2912
  * resolved value, so filtering on it drops every negative index (not just
2867
2913
  * `-1`) without a second selector to keep in sync.
2868
2914
  */
2869
- const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR$1)).filter(el => el.tabIndex >= 0);
2915
+ const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.tabIndex >= 0);
2870
2916
  /**
2871
2917
  * Modal.Background - Renders the modal background overlay.
2872
2918
  *
@@ -4756,160 +4802,1085 @@ const Tooltip = ({ label, content, position = 'top', color, size, active: always
4756
4802
  };
4757
4803
 
4758
4804
  /**
4759
- * Individual Step component for use inside Steps.
4760
- *
4761
- * @function
4762
- * @param {StepProps} props - Props for the Step component.
4763
- * @returns {JSX.Element} The rendered step segment.
4805
+ * Whether `el` is editable while its parent is not, which makes it an editing
4806
+ * host: the one tab stop an editable region gets. `contenteditable="false"`
4807
+ * turns editing off, and a missing or unknown value inherits the parent's.
4764
4808
  */
4765
- const Step = ({ isActive = false, isCompleted = false, label, icon, clickable = false, onClick, stepNumber, completedIcon, className, children, ...props }) => {
4766
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4767
- const stepClasses = classNames(usePrefixedClassNames('steps-segment', {
4768
- 'is-active': isActive,
4769
- 'is-completed': isCompleted,
4770
- }), bulmaHelperClasses, className);
4771
- const stepsLinkClass = usePrefixedClassNames('steps-link');
4772
- const stepsMarkerClass = usePrefixedClassNames('steps-marker');
4773
- const stepsContentClass = usePrefixedClassNames('steps-content');
4774
- const stepsTitleClass = usePrefixedClassNames('steps-title');
4775
- const handleClick = () => {
4776
- if (clickable && onClick) {
4777
- onClick();
4778
- }
4779
- };
4780
- const handleKeyDown = (e) => {
4781
- if (clickable && onClick && (e.key === 'Enter' || e.key === ' ')) {
4782
- e.preventDefault();
4783
- onClick();
4809
+ function isEditingHost(el) {
4810
+ const editable = (node) => {
4811
+ const value = node.getAttribute('contenteditable')?.toLowerCase();
4812
+ if (value === undefined)
4813
+ return null;
4814
+ if (value === '' || value === 'true' || value === 'plaintext-only') {
4815
+ return true;
4784
4816
  }
4817
+ return value === 'false' ? false : null;
4785
4818
  };
4786
- const resolvedCompletedIcon = completedIcon === undefined ? '\u2713' : completedIcon;
4787
- const markerContent = icon ||
4788
- (isCompleted && resolvedCompletedIcon
4789
- ? resolvedCompletedIcon
4790
- : (stepNumber ?? null));
4791
- return (jsxRuntime.jsx("li", { className: stepClasses, onClick: handleClick, onKeyDown: handleKeyDown, tabIndex: clickable ? 0 : undefined, role: clickable ? 'button' : undefined, "aria-current": isActive ? 'step' : undefined, ...rest, children: jsxRuntime.jsxs("div", { className: stepsLinkClass, children: [jsxRuntime.jsx("span", { className: stepsMarkerClass, children: markerContent }), (label || children) && (jsxRuntime.jsx("div", { className: stepsContentClass, children: jsxRuntime.jsx("p", { className: stepsTitleClass, children: label || children }) }))] }) }));
4819
+ if (editable(el) !== true)
4820
+ return false;
4821
+ for (let node = el.parentElement; node; node = node.parentElement) {
4822
+ const state = editable(node);
4823
+ if (state !== null)
4824
+ return !state;
4825
+ }
4826
+ return true;
4827
+ }
4828
+ /** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
4829
+ const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
4830
+ Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
4831
+ /**
4832
+ * Whether the browser puts `el` in the tab order, before anything that hides
4833
+ * or disables it. The `tabIndex` property can't answer this on its own: it
4834
+ * can read `-1` for an editable region or an `<embed>` that Tab still visits,
4835
+ * and `0` for a `<video>` without controls that it doesn't. So a valid
4836
+ * `tabindex` attribute decides, and otherwise the element's kind does.
4837
+ */
4838
+ function isTabbableKind(el) {
4839
+ const tabindex = el.getAttribute('tabindex');
4840
+ if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
4841
+ return el.tabIndex >= 0;
4842
+ }
4843
+ switch (el.localName) {
4844
+ case 'a':
4845
+ case 'area':
4846
+ return el.hasAttribute('href');
4847
+ case 'input':
4848
+ return el.type !== 'hidden';
4849
+ case 'button':
4850
+ case 'select':
4851
+ case 'textarea':
4852
+ case 'iframe':
4853
+ case 'embed':
4854
+ case 'object':
4855
+ return true;
4856
+ case 'audio':
4857
+ case 'video':
4858
+ return el.hasAttribute('controls');
4859
+ case 'summary':
4860
+ return isDetailsSummary(el);
4861
+ default:
4862
+ return isEditingHost(el);
4863
+ }
4864
+ }
4865
+ /**
4866
+ * The node's parent in the flattened tree, the one the browser renders and
4867
+ * tabs through: the slot it is assigned to, else its parent, else the host of
4868
+ * the shadow root it tops, and nothing above the document. A closed shadow
4869
+ * root hides its slots (`assignedSlot` reads `null` under one), so a child of
4870
+ * its host goes straight to the host, whether a slot takes it or not.
4871
+ */
4872
+ const flatParent = (node) => node.assignedSlot ??
4873
+ node.parentElement ??
4874
+ node.parentNode?.host ??
4875
+ null;
4876
+ /**
4877
+ * The element's children in the flattened tree, in the order they render: a
4878
+ * host's shadow root content, a slot's assigned elements (or its fallback
4879
+ * content when nothing is assigned), anything else's own children. A host
4880
+ * whose shadow root is closed has no root to read, so its own children stand
4881
+ * in, including any that no slot takes and that aren't rendered.
4882
+ */
4883
+ function flatChildren(el) {
4884
+ if (el.shadowRoot)
4885
+ return Array.from(el.shadowRoot.children);
4886
+ if (el.localName === 'slot') {
4887
+ const assigned = el.assignedNodes();
4888
+ if (assigned.length > 0) {
4889
+ return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
4890
+ }
4891
+ }
4892
+ return Array.from(el.children);
4893
+ }
4894
+ const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
4895
+ const isVisible = (el) => {
4896
+ const { visibility } = getComputedStyle(el);
4897
+ return visibility !== 'hidden' && visibility !== 'collapse';
4792
4898
  };
4793
4899
  /**
4794
- * The `Steps` component provides a multi-step progress indicator for wizard flows, checkout processes, or any multi-step workflow.
4795
- *
4796
- * @function
4797
- * @param {StepsProps} props - Props for the Steps component.
4798
- * @returns {JSX.Element} The rendered steps component.
4799
- *
4800
- * @example
4801
- * // Basic steps with items
4802
- * <Steps
4803
- * value={1}
4804
- * items={[
4805
- * { label: 'Account' },
4806
- * { label: 'Profile' },
4807
- * { label: 'Complete' },
4808
- * ]}
4809
- * />
4810
- *
4811
- * @example
4812
- * // Clickable steps with navigation
4813
- * <Steps
4814
- * value={currentStep}
4815
- * onStepClick={(step) => setCurrentStep(step)}
4816
- * hasNavigation
4817
- * items={[
4818
- * { label: 'Step 1', clickable: true },
4819
- * { label: 'Step 2', clickable: true },
4820
- * { label: 'Step 3', clickable: true },
4821
- * ]}
4822
- * />
4900
+ * Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
4901
+ * (not inherited, so each element is checked) or a closed `<details>` it
4902
+ * isn't the summary of. `top` itself isn't checked, and `null` walks to the
4903
+ * top of the page.
4823
4904
  */
4824
- const StepsComponent = ({ value = 0, items, size, color, hasMarker = true, animated = true, rounded = true, vertical = false, labelPosition = 'bottom', mobileMode, showStepNumbers = true, hasNavigation = false, prevLabel, nextLabel, onPrev, onNext, onStepClick, className, children, ...props }) => {
4825
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4826
- // Generate classes for the wrapper div
4827
- const stepsClasses = usePrefixedClassNames('steps', {
4828
- [`is-${size}`]: size,
4829
- [`is-${color}`]: color,
4830
- 'has-marker': hasMarker,
4831
- 'is-animated': animated,
4832
- 'is-rounded': rounded,
4833
- 'is-vertical': vertical,
4834
- [`has-label-${labelPosition}`]: labelPosition && (labelPosition !== 'bottom' || vertical),
4835
- [`is-${mobileMode}`]: mobileMode,
4836
- });
4837
- const combinedClasses = classNames(stepsClasses, bulmaHelperClasses, className);
4838
- // Generate classes for the ul (list)
4839
- const listClasses = usePrefixedClassNames('steps-list', {
4840
- 'is-vertical': vertical,
4841
- });
4842
- const stepsNavigationClass = usePrefixedClassNames('steps-navigation');
4843
- const prevButtonClass = usePrefixedClassNames('button');
4844
- const nextButtonClass = usePrefixedClassNames('button', {
4845
- 'is-primary': true,
4846
- });
4847
- // Count total steps for navigation
4848
- const totalSteps = items ? items.length : React.Children.count(children);
4849
- // Render items if provided
4850
- const renderSteps = () => {
4851
- if (items && items.length > 0) {
4852
- return items.map((item, index) => (jsxRuntime.jsx(Step, { isActive: index === value, isCompleted: index < value, label: item.label, icon: item.icon, completedIcon: item.completedIcon, clickable: item.clickable, stepNumber: showStepNumbers ? index + 1 : undefined, onClick: item.clickable && onStepClick ? () => onStepClick(index) : undefined, className: item.className }, index)));
4853
- }
4854
- // If children are provided, clone them with active/completed states
4855
- if (children) {
4856
- return React.Children.map(children, (child, index) => {
4857
- if (React.isValidElement(child)) {
4858
- return React.cloneElement(child, {
4859
- isActive: index === value,
4860
- isCompleted: index < value,
4861
- stepNumber: showStepNumbers ? index + 1 : undefined,
4862
- onClick: child.props.clickable && onStepClick
4863
- ? () => onStepClick(index)
4864
- : child.props.onClick,
4865
- });
4866
- }
4867
- return child;
4868
- });
4905
+ function isShownUpTo(el, top) {
4906
+ let below = null;
4907
+ for (let node = el; node && node !== top; node = flatParent(node)) {
4908
+ if (!isShown(node))
4909
+ return false;
4910
+ if (below &&
4911
+ node.localName === 'details' &&
4912
+ !node.hasAttribute('open') &&
4913
+ !isDetailsSummary(below)) {
4914
+ return false;
4869
4915
  }
4916
+ below = node;
4917
+ }
4918
+ return true;
4919
+ }
4920
+ /**
4921
+ * The images that use the image map `area` belongs to. An image names its
4922
+ * map with `usemap="#…"`, matching the map's `name` or `id`.
4923
+ */
4924
+ function imagesUsingMapOf(area) {
4925
+ const map = area.closest('map');
4926
+ if (!map)
4927
+ return [];
4928
+ const names = [map.getAttribute('name'), map.id].filter(name => name);
4929
+ return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
4930
+ const ref = img.getAttribute('usemap');
4931
+ return ref.startsWith('#') && names.includes(ref.slice(1));
4932
+ });
4933
+ }
4934
+ /**
4935
+ * Whether an image map area is shown. An area has no box of its own: the
4936
+ * HTML rendering rules give it `display: none`, though a browser may not
4937
+ * report that. So it counts where an image using its map is shown, as long
4938
+ * as nothing makes it inert.
4939
+ */
4940
+ function isAreaShown(area, container) {
4941
+ for (let node = area; node && node !== container; node = flatParent(node)) {
4942
+ if (node.hasAttribute('inert'))
4943
+ return false;
4944
+ }
4945
+ return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
4946
+ }
4947
+ /**
4948
+ * Whether Tab can land on `el`, one of the kinds it visits. A disabled control
4949
+ * (including one inside a disabled `<fieldset>`), an inert subtree and
4950
+ * anything not rendered are skipped. `visibility` is inherited, so the
4951
+ * element's own computed value covers its ancestors.
4952
+ */
4953
+ function isRenderedAndEnabled(el, container) {
4954
+ if (el.matches(':disabled'))
4955
+ return false;
4956
+ if (el.localName === 'area')
4957
+ return isAreaShown(el, container);
4958
+ return isShownUpTo(el, container) && isVisible(el);
4959
+ }
4960
+ /** A radio button with a name, which shares one tab stop with its group. */
4961
+ const isGroupedRadio = (el) => el.localName === 'input' &&
4962
+ el.type === 'radio' &&
4963
+ el.name !== '';
4964
+ /** Whether two radio buttons are in one group: same name, form owner and tree. */
4965
+ const sameGroup = (a, b) => isGroupedRadio(b) &&
4966
+ b.name === a.name &&
4967
+ b.form === a.form &&
4968
+ b.getRootNode() === a.getRootNode();
4969
+ /**
4970
+ * The checked button of `radio`'s group. The group isn't bounded by the trap,
4971
+ * so this looks through the radio's whole tree.
4972
+ */
4973
+ const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
4974
+ /** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
4975
+ function flatChain(node, container) {
4976
+ const chain = [];
4977
+ for (let n = node; n && n !== container; n = flatParent(n)) {
4978
+ chain.unshift(n);
4979
+ }
4980
+ return chain;
4981
+ }
4982
+ /**
4983
+ * Whether `node` is inside the container in the flattened tree: in it, in a
4984
+ * shadow root it holds, or slotted into it. A child of a closed-root host
4985
+ * counts as inside wherever the host is, as its slot can't be seen.
4986
+ */
4987
+ function inside(container, node) {
4988
+ for (let n = node; n; n = flatParent(n)) {
4989
+ if (n === container)
4990
+ return true;
4991
+ }
4992
+ return false;
4993
+ }
4994
+ /**
4995
+ * `true` when Tab reaches `b` after `a`, which is their order in the
4996
+ * flattened tree: a shadow host comes before its shadow root's content, and
4997
+ * slotted content comes where its slot is. A closed root's slots can't be
4998
+ * seen, so its host's children keep their place in the light DOM. Two
4999
+ * flattened-tree siblings are always in one DOM tree, so their document
5000
+ * position decides between them.
5001
+ */
5002
+ function follows(container, a, b) {
5003
+ const chainA = flatChain(a, container);
5004
+ const chainB = flatChain(b, container);
5005
+ for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
5006
+ const [x, y] = [chainA[i], chainB[i]];
5007
+ if (x === y)
5008
+ continue;
5009
+ if (!x)
5010
+ return true;
5011
+ if (!y)
5012
+ return false;
5013
+ return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
5014
+ }
5015
+ return false;
5016
+ }
5017
+ /**
5018
+ * Every element of a tabbable kind under `el` in the flattened tree, depth
5019
+ * first, which is the order Tab visits them. Under a host whose shadow root
5020
+ * is closed, that takes in the host's own children instead, rendered or not.
5021
+ */
5022
+ function collectCandidates(el, out = []) {
5023
+ for (const child of flatChildren(el)) {
5024
+ if (isTabbableKind(child))
5025
+ out.push(child);
5026
+ collectCandidates(child, out);
5027
+ }
5028
+ return out;
5029
+ }
5030
+ /**
5031
+ * The first and last tab stops inside `container`, or `null` when there are
5032
+ * none. Only the ends matter for wrapping, so the style checks run from each
5033
+ * end until they find a stop rather than over every candidate.
5034
+ *
5035
+ * A radio group is one stop, the way the browser treats it: its checked
5036
+ * button, or its first when none is checked. Counting every button would make
5037
+ * the group's last one an end, and Tab from the checked one would leave. The
5038
+ * group goes by name, form owner and tree, not by the trap, so a checked
5039
+ * button outside the container that Tab can land on leaves the group no stop
5040
+ * inside. One that Tab can't land on leaves the group as if none were checked.
5041
+ */
5042
+ function findTabStops(container) {
5043
+ const candidates = collectCandidates(container);
5044
+ const checked = new Map();
5045
+ const usable = (el) => {
5046
+ let ok = checked.get(el);
5047
+ if (ok === undefined) {
5048
+ ok = isRenderedAndEnabled(el, container);
5049
+ checked.set(el, ok);
5050
+ }
5051
+ return ok;
5052
+ };
5053
+ // Resolved once per group, filed under each of its buttons, since finding
5054
+ // the checked one scans the radio's whole tree.
5055
+ const groupStops = new Map();
5056
+ const groupStop = (radio) => {
5057
+ if (groupStops.has(radio))
5058
+ return groupStops.get(radio);
5059
+ const group = candidates.filter(el => sameGroup(radio, el));
5060
+ const on = checkedInGroup(radio);
5061
+ const stop = on && isTabbableKind(on) && usable(on)
5062
+ ? group.find(el => el === on)
5063
+ : group.find(usable);
5064
+ for (const el of [radio, ...group])
5065
+ groupStops.set(el, stop);
5066
+ return stop;
5067
+ };
5068
+ const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
5069
+ const first = candidates.find(isStop);
5070
+ if (!first)
4870
5071
  return null;
5072
+ let last = first;
5073
+ for (let i = candidates.length - 1; candidates[i] !== first; i--) {
5074
+ if (isStop(candidates[i])) {
5075
+ last = candidates[i];
5076
+ break;
5077
+ }
5078
+ }
5079
+ return {
5080
+ first,
5081
+ last,
5082
+ stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
4871
5083
  };
4872
- return (jsxRuntime.jsxs("div", { className: combinedClasses, ...rest, children: [jsxRuntime.jsx("ul", { className: listClasses, children: renderSteps() }), hasNavigation && (jsxRuntime.jsxs("div", { className: stepsNavigationClass, children: [jsxRuntime.jsx("button", { type: "button", className: prevButtonClass, disabled: value === 0, onClick: onPrev ?? (() => onStepClick?.(value - 1)), children: prevLabel ?? 'Previous' }), jsxRuntime.jsx("button", { type: "button", className: nextButtonClass, disabled: value === totalSteps - 1, onClick: onNext ?? (() => onStepClick?.(value + 1)), children: nextLabel ?? 'Next' })] }))] }));
4873
- };
4874
- // Attach Step as static property
4875
- const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
4876
-
5084
+ }
4877
5085
  /**
4878
- * The `Sidebar` component provides a slide-out navigation panel that appears from the left or right side of the screen.
4879
- *
4880
- * @function
4881
- * @param {SidebarProps} props - Props for the Sidebar component.
4882
- * @param {React.Ref<HTMLElement>} ref - Forwarded ref to the sidebar element.
4883
- * @returns {JSX.Element | null} The rendered sidebar component.
4884
- *
4885
- * @example
4886
- * // Basic sidebar
4887
- * const [isOpen, setIsOpen] = useState(false);
4888
- * <Sidebar isOpen={isOpen} onClose={() => setIsOpen(false)}>
4889
- * <Menu>...</Menu>
4890
- * </Sidebar>
5086
+ * The first tab stop inside `container`, found the way the trap finds it, or
5087
+ * `null` when there is none. For a component that moves focus into a panel
5088
+ * without trapping it there. `src/index.ts` names its exports from this
5089
+ * module, so this one stays internal.
5090
+ */
5091
+ function firstTabStop(container) {
5092
+ return findTabStops(container)?.first ?? null;
5093
+ }
5094
+ /**
5095
+ * The element that has focus in the container's own document (an iframe's,
5096
+ * when it renders into one), inside open shadow roots too. Focus inside a
5097
+ * closed one reads as its host, which the comparisons above place like any
5098
+ * other element. Browsers fall back to <body> when nothing has
5099
+ * focus. jsdom, where consumers run their tests, reports nothing once focus
5100
+ * was inside a shadow root that was removed, so this falls back for it.
5101
+ */
5102
+ const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
5103
+ /**
5104
+ * Keeps keyboard focus inside a container while it is active: focus moves in
5105
+ * when the trap turns on, Tab and Shift+Tab wrap between the first and last
5106
+ * tab stops, and focus goes back where it came from when the trap turns off.
5107
+ *
5108
+ * Tab stops are what the browser's Tab visits, in document order: links,
5109
+ * form controls, frames, media with controls, a `<details>` summary, editable
5110
+ * regions, anything with `tabIndex={0}`, and the content of open shadow
5111
+ * roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
5112
+ * and a radio group counts once. Give the container `tabIndex={-1}` so it can
5113
+ * hold focus itself when nothing inside can. The trap listens on the
5114
+ * container, so a trap nested inside another handles Tab first and the outer
5115
+ * one stands aside.
5116
+ *
5117
+ * The trap is about Tab. If a Tab it let through still takes focus out (a
5118
+ * browser can leave out a stop the page has, such as links when its settings
5119
+ * say to), it sends focus back to the other end. A pointer or a script moving
5120
+ * focus out is left alone, because pulling focus back from a click is modal
5121
+ * behaviour, and a modal also has to block the page behind it, which `Modal`
5122
+ * does. Content the container renders through a portal lives elsewhere in
5123
+ * the DOM, so it is outside the trap: render nested overlays inside the
5124
+ * container, or give them a trap of their own.
5125
+ *
5126
+ * The container has to be in the DOM when the trap turns on. The trap waits
5127
+ * for hydration, so a container that only appears once the page has
5128
+ * hydrated, the way portaled content does, still has it attach in the commit
5129
+ * where the container appears.
5130
+ *
5131
+ * @function useFocusTrap
5132
+ * @param containerRef - Ref to the element focus stays inside.
5133
+ * @param options - When the trap is on, and where focus starts and ends.
4891
5134
  *
4892
5135
  * @example
4893
- * // Right-side sidebar with custom width
4894
- * <Sidebar
4895
- * isOpen={isOpen}
4896
- * onClose={() => setIsOpen(false)}
4897
- * position="right"
4898
- * width="320px"
4899
- * >
4900
- * <div>Panel content</div>
4901
- * </Sidebar>
4902
- */
4903
- const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
4904
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4905
- const sidebarRef = React.useRef(null);
4906
- // Cleanup returned by a consumer's callback ref on attach, held until detach.
4907
- const consumerCleanupRef = React.useRef(null);
4908
- const resolvedFullwidth = isFullwidth ?? fullWidth ?? false;
4909
- // Close handler
4910
- const handleClose = React.useCallback(() => {
4911
- if (canCancel && onClose) {
4912
- onClose();
5136
+ * const panelRef = useRef<HTMLDivElement>(null);
5137
+ * useFocusTrap(panelRef, { active: isOpen });
5138
+ * return isOpen ? (
5139
+ * <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
5140
+ * …
5141
+ * </div>
5142
+ * ) : null;
5143
+ */
5144
+ function useFocusTrap(containerRef, options = {}) {
5145
+ const { active = true, initialFocusRef, restoreFocus = true } = options;
5146
+ // Portaled content has no server-rendered counterpart, so a portaled
5147
+ // container only exists from the commit after hydration.
5148
+ const hydrated = useIsHydrated();
5149
+ React.useEffect(() => {
5150
+ if (!active || !hydrated)
5151
+ return undefined;
5152
+ const container = containerRef.current;
5153
+ if (!container)
5154
+ return undefined;
5155
+ const doc = container.ownerDocument;
5156
+ const opener = focusedElement(doc);
5157
+ // `initialFocusRef` counts only when focusing it puts focus inside. Focus
5158
+ // put outside would stay out, as Tab there never reaches the container's
5159
+ // listener, and focusing something that can't take focus (a heading
5160
+ // without `tabIndex`, a disabled control) leaves focus where it was.
5161
+ const initial = initialFocusRef?.current;
5162
+ const inTrap = initial && inside(container, initial) ? initial : null;
5163
+ inTrap?.focus();
5164
+ if (!inTrap || !inside(container, focusedElement(doc))) {
5165
+ (findTabStops(container)?.first ?? container).focus();
5166
+ }
5167
+ // Where focus goes if a Tab the trap let the browser handle takes it out,
5168
+ // held until that Tab has finished moving focus.
5169
+ let wrapTo = null;
5170
+ let wrapTimer;
5171
+ const handleKeyDown = (e) => {
5172
+ // A trap nested inside this one has already wrapped focus.
5173
+ if (e.key !== 'Tab' || e.defaultPrevented)
5174
+ return;
5175
+ const stops = findTabStops(container);
5176
+ if (!stops) {
5177
+ e.preventDefault();
5178
+ container.focus();
5179
+ return;
5180
+ }
5181
+ const current = stops.stopFor(focusedElement(doc));
5182
+ // Wrap whenever the browser has no stop left in that direction inside
5183
+ // the container. Comparing positions rather than testing `=== first`
5184
+ // also covers focus resting on the container or on an element outside
5185
+ // the tab order, which the browser would otherwise walk straight out of.
5186
+ if (e.shiftKey
5187
+ ? !follows(container, stops.first, current)
5188
+ : !follows(container, current, stops.last)) {
5189
+ e.preventDefault();
5190
+ (e.shiftKey ? stops.last : stops.first).focus();
5191
+ return;
5192
+ }
5193
+ wrapTo = e.shiftKey ? stops.last : stops.first;
5194
+ clearTimeout(wrapTimer);
5195
+ wrapTimer = setTimeout(() => {
5196
+ wrapTo = null;
5197
+ });
5198
+ };
5199
+ // The browser moves focus as soon as the keydown is over, so focus that
5200
+ // lands outside while `wrapTo` is set got there by that Tab.
5201
+ const handleFocusIn = (e) => {
5202
+ if (!wrapTo || inside(container, e.composedPath()[0]))
5203
+ return;
5204
+ const target = wrapTo;
5205
+ wrapTo = null;
5206
+ target.focus();
5207
+ };
5208
+ container.addEventListener('keydown', handleKeyDown);
5209
+ doc.addEventListener('focusin', handleFocusIn, true);
5210
+ return () => {
5211
+ container.removeEventListener('keydown', handleKeyDown);
5212
+ doc.removeEventListener('focusin', handleFocusIn, true);
5213
+ clearTimeout(wrapTimer);
5214
+ let target = restoreFocus ? opener : null;
5215
+ // `null` is an object too, and a caller without types can pass it.
5216
+ if (restoreFocus && typeof restoreFocus === 'object') {
5217
+ // Read now rather than when the trap turned on: a trigger that
5218
+ // re-mounted in between has a new node.
5219
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5220
+ target = restoreFocus.current;
5221
+ }
5222
+ // Hand focus back only while the trap still holds it: inside the
5223
+ // container, or dropped to <body> because the container left the page.
5224
+ const now = focusedElement(doc);
5225
+ if (target && (now === doc.body || inside(container, now))) {
5226
+ target.focus();
5227
+ }
5228
+ };
5229
+ }, [active, hydrated, containerRef, initialFocusRef, restoreFocus]);
5230
+ }
5231
+
5232
+ // INTERNAL: deliberately not exported from src/index.ts.
5233
+ /**
5234
+ * `useLayoutEffect` in the browser, `useEffect` on the server. A server
5235
+ * render runs neither, and React 18 warns about a layout effect there, so the
5236
+ * server is handed the plain one.
5237
+ */
5238
+ const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5239
+
5240
+ // INTERNAL: deliberately not exported from src/index.ts.
5241
+ /**
5242
+ * The corner that keeps a panel of this size in the viewport, preferring
5243
+ * below the anchor and lined up with its left edge.
5244
+ */
5245
+ function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
5246
+ const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
5247
+ const fitsRight = rect.left + panelWidth <= view.innerWidth;
5248
+ if (fitsBelow)
5249
+ return fitsRight ? 'bottom-left' : 'bottom-right';
5250
+ return fitsRight ? 'top-left' : 'top-right';
5251
+ }
5252
+ const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
5253
+ /**
5254
+ * Places a floating panel against its anchor: resolves `auto` to a corner
5255
+ * and, for a panel fixed to the viewport, works out its coordinates. It
5256
+ * measures again on resize and on any scroll of the anchor's window (an
5257
+ * iframe's, when the anchor renders into one), so a fixed panel follows its
5258
+ * anchor. A measurement that changes nothing doesn't render again.
5259
+ *
5260
+ * Measuring happens in a layout effect, so the panel is placed before it
5261
+ * paints. It needs both elements on the page while `active` is on, and
5262
+ * keeps the last placement when either is missing.
5263
+ *
5264
+ * @function useAnchoredPosition
5265
+ * @param anchorRef - The element the panel opens from.
5266
+ * @param panelRef - The floating panel.
5267
+ * @param options - Whether to measure, the corner asked for, and how the panel is placed.
5268
+ * @returns The corner the panel opens from, with coordinates when it is fixed.
5269
+ */
5270
+ function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
5271
+ const [resolved, setResolved] = React.useState({
5272
+ position: position === 'auto' ? 'bottom-left' : position,
5273
+ });
5274
+ useClientLayoutEffect(() => {
5275
+ if (!active)
5276
+ return undefined;
5277
+ const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
5278
+ window;
5279
+ const update = () => {
5280
+ const anchor = anchorRef.current;
5281
+ const panel = panelRef.current;
5282
+ if (!anchor || !panel)
5283
+ return;
5284
+ const rect = anchor.getBoundingClientRect();
5285
+ const { width, height } = panel.getBoundingClientRect();
5286
+ const corner = position === 'auto'
5287
+ ? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
5288
+ : position;
5289
+ const next = fixed
5290
+ ? {
5291
+ position: corner,
5292
+ top: corner.startsWith('bottom')
5293
+ ? rect.bottom + offset
5294
+ : rect.top - height - offset,
5295
+ left: corner.endsWith('left') ? rect.left : rect.right - width,
5296
+ }
5297
+ : { position: corner };
5298
+ setResolved(prev => (samePosition(prev, next) ? prev : next));
5299
+ };
5300
+ update();
5301
+ view.addEventListener('resize', update, { passive: true });
5302
+ view.addEventListener('scroll', update, { passive: true, capture: true });
5303
+ return () => {
5304
+ view.removeEventListener('resize', update);
5305
+ view.removeEventListener('scroll', update, { capture: true });
5306
+ };
5307
+ }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
5308
+ return resolved;
5309
+ }
5310
+
5311
+ const validButtonColors = [...validColors, 'text', 'ghost'];
5312
+ /**
5313
+ * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5314
+ *
5315
+ * @function
5316
+ * @param {ButtonProps} props - Props for the Button component.
5317
+ * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
5318
+ * @returns {JSX.Element} The rendered button, anchor, or custom element.
5319
+ * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
5320
+ */
5321
+ const Button = React.forwardRef(function Button(props, ref) {
5322
+ const { color, size, isLight, isRounded, isLoading, isStatic, isFullwidth, isFullWidth, isOutlined, isInverted, isFocused, isActive, isHovered, isDisabled, className, children, textColor, bgColor, as: Component = 'button', href, onClick, target, rel, ...bulmaProps } = props;
5323
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
5324
+ color: textColor,
5325
+ backgroundColor: bgColor,
5326
+ ...bulmaProps,
5327
+ });
5328
+ // Generate Bulma classes with prefix
5329
+ const bulmaClasses = usePrefixedClassNames('button', {
5330
+ [`is-${color}`]: color &&
5331
+ validButtonColors.includes(color),
5332
+ [`is-${size}`]: size,
5333
+ 'is-outlined': isOutlined,
5334
+ 'is-light': isLight,
5335
+ 'is-loading': isLoading,
5336
+ 'is-static': isStatic,
5337
+ 'is-disabled': isDisabled,
5338
+ 'is-rounded': isRounded,
5339
+ 'is-hovered': isHovered,
5340
+ 'is-focused': isFocused,
5341
+ 'is-active': isActive,
5342
+ 'is-inverted': isInverted,
5343
+ 'is-fullwidth': isFullwidth ?? isFullWidth,
5344
+ });
5345
+ // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
5346
+ const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
5347
+ if (Component !== 'button') {
5348
+ // Strip the form-control attributes a link-like element cannot carry, so a
5349
+ // JavaScript consumer or a spread object doesn't put them on the DOM. The
5350
+ // types no longer allow them through; this is the runtime backstop.
5351
+ //
5352
+ // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
5353
+ // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
5354
+ // its props reach it. Stripping names by their spelling alone broke that
5355
+ // promise silently: `<Button as={Custom} name="x" />` type-checked while
5356
+ // `Custom` never received the `name` it requires.
5357
+ //
5358
+ // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
5359
+ // targets this list was filtering them from. `autoFocus` is a global
5360
+ // attribute (React decides per element whether to honour it, which is not
5361
+ // ours to pre-empt), and `type` on an anchor is the MIME hint that
5362
+ // `ButtonProps<'a'>` now types it as.
5363
+ //
5364
+ // The types already answer this for a TypeScript caller: `ButtonProps<T>`
5365
+ // derives from `ComponentPropsWithoutRef<T>`, so only what the target
5366
+ // accepts can be passed. What is left is a runtime backstop for JavaScript
5367
+ // consumers and spread objects.
5368
+ //
5369
+ // Keep it to the two cases where a stray attribute does something. The
5370
+ // submit-overrides belong to a submit control and never make sense on the
5371
+ // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
5372
+ // `.button[disabled]` (background, border, shadow, opacity), so letting it
5373
+ // through to a `<span>` greys the element out — main stripped it, and not
5374
+ // stripping it would be a silent visual change on a types-only release.
5375
+ //
5376
+ // Everything else (`name`, `value`, `form`) is inert where it does not
5377
+ // belong, and enumerating which tags own it is what kept going wrong:
5378
+ // first all nine names on every non-button tag (so `<Button as="input"
5379
+ // name="query" value="Search">` submitted nothing), then a form-control
5380
+ // allowlist that still lost `formAction` on an input and `value` on an
5381
+ // option. Those now ride on the types instead.
5382
+ //
5383
+ // A custom component is exempt entirely — it owns its prop contract, and
5384
+ // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
5385
+ //
5386
+ // The elements that own `disabled`, per the HTML spec. A closed set, unlike
5387
+ // the open-ended question of which tag owns which attribute.
5388
+ const SUBMIT_OVERRIDES = [
5389
+ 'formAction',
5390
+ 'formEncType',
5391
+ 'formMethod',
5392
+ 'formNoValidate',
5393
+ 'formTarget',
5394
+ ];
5395
+ const OWNS_DISABLED = [
5396
+ 'button',
5397
+ 'fieldset',
5398
+ 'input',
5399
+ 'optgroup',
5400
+ 'option',
5401
+ 'select',
5402
+ 'textarea',
5403
+ ];
5404
+ // Two independent filters. They are not exclusive — an `<a>` owns neither
5405
+ // the submit-overrides nor `disabled`, so it takes both — and deriving them
5406
+ // from one destructure, or from one if/else, is what broke each of them in
5407
+ // turn: first the overrides came off every intrinsic (an `as="input"` lost
5408
+ // `formAction`), then scoping them to the anchor let `disabled` back onto
5409
+ // it.
5410
+ const forwardedRest = { ...rest };
5411
+ if (typeof Component === 'string' && !isCustomElement(Component)) {
5412
+ // Meaningless anywhere but a submit control, and an anchor is not one.
5413
+ if (Component === 'a') {
5414
+ for (const key of SUBMIT_OVERRIDES)
5415
+ delete forwardedRest[key];
5416
+ }
5417
+ // Visible via Bulma's `.button[disabled]`, so withheld from any element
5418
+ // that does not own it.
5419
+ if (!OWNS_DISABLED.includes(Component))
5420
+ delete forwardedRest.disabled;
5421
+ }
5422
+ return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
5423
+ ? // Guarded, because this handler is installed into whatever `as`
5424
+ // renders and a custom component's `onClick` need not be a DOM
5425
+ // event handler at all. One declaring `onClick: (v: string) =>
5426
+ // void` calls this with a string, and an unguarded
5427
+ // `e.preventDefault()` throws `is not a function`. Blocking the
5428
+ // real event is the point; a foreign payload is simply not ours
5429
+ // to act on.
5430
+ (e) => {
5431
+ if (typeof e?.preventDefault === 'function')
5432
+ e.preventDefault();
5433
+ }
5434
+ : onClick, ...forwardedRest, children: children }));
5435
+ }
5436
+ return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
5437
+ });
5438
+ Button.displayName = 'Button';
5439
+
5440
+ const PopoverContext = React.createContext(null);
5441
+ /** Intrinsic elements that are controls already, and keep their own role. */
5442
+ const NATIVE_CONTROLS = new Set([
5443
+ 'button',
5444
+ 'input',
5445
+ 'select',
5446
+ 'textarea',
5447
+ 'summary',
5448
+ ]);
5449
+ /**
5450
+ * Whether the browser answers this key on `el` itself, by clicking it or by
5451
+ * using the key as input, so the trigger's click handler covers it.
5452
+ */
5453
+ function handlesKeyItself(el, key) {
5454
+ if (NATIVE_CONTROLS.has(el.localName))
5455
+ return true;
5456
+ return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
5457
+ }
5458
+ /** The innermost dialog the event passed through, which is the one its Escape belongs to. */
5459
+ function nearestDialog(event) {
5460
+ return (event.composedPath().find(node => {
5461
+ const role = node.getAttribute?.('role');
5462
+ return role === 'dialog' || role === 'alertdialog';
5463
+ }) ?? null);
5464
+ }
5465
+ /**
5466
+ * The gap the stylesheet puts between the panel and the trigger, in pixels.
5467
+ * It is a margin on whichever side faces the trigger, and a negative one on
5468
+ * a portaled panel above it, so the larger magnitude is the gap.
5469
+ */
5470
+ function styledGap(panel) {
5471
+ const { marginTop, marginBottom } = getComputedStyle(panel);
5472
+ return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
5473
+ }
5474
+ /**
5475
+ * The `Popover` component opens a panel of interactive content, such as a filter form or a set of share options, anchored to the element that toggles it.
5476
+ *
5477
+ * The panel is a non-modal `role="dialog"`: focus moves into it on open
5478
+ * and returns to the trigger on close, while the rest of the page stays
5479
+ * reachable by pointer and assistive technology. For hover-only hints use
5480
+ * `Tooltip`, and for a list of actions use `Dropdown`.
5481
+ *
5482
+ * @function
5483
+ * @param {PopoverProps} props - Props for the Popover component.
5484
+ * @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
5485
+ *
5486
+ * @example
5487
+ * <Popover trigger={<Button>Filters</Button>}>
5488
+ * <Popover.Header>Filter rows</Popover.Header>
5489
+ * <Popover.Body>…</Popover.Body>
5490
+ * <Popover.Footer>
5491
+ * <Popover.Close>Done</Popover.Close>
5492
+ * </Popover.Footer>
5493
+ * </Popover>
5494
+ */
5495
+ const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
5496
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5497
+ const rootRef = React.useRef(null);
5498
+ const panelRef = React.useRef(null);
5499
+ // The last pointerdown React saw inside the panel. React's tree includes
5500
+ // content the panel portals elsewhere, which the DOM path doesn't.
5501
+ const insidePointerRef = React.useRef(null);
5502
+ const baseId = React.useId();
5503
+ const panelId = `${baseId}-panel`;
5504
+ const titleId = `${baseId}-title`;
5505
+ const isControlled = controlledOpen !== undefined;
5506
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
5507
+ const isOpen = controlledOpen ?? internalOpen;
5508
+ const requestOpen = React.useCallback((next) => {
5509
+ onOpenChange?.(next);
5510
+ if (!isControlled)
5511
+ setInternalOpen(next);
5512
+ }, [isControlled, onOpenChange]);
5513
+ const close = React.useCallback(() => requestOpen(false), [requestOpen]);
5514
+ // A portaled panel has no server markup, so it only exists from the commit
5515
+ // after hydration. Everything that measures or focuses it waits for that.
5516
+ const hydrated = useIsHydrated();
5517
+ const panelMounted = isOpen && (hydrated || !appendToBody);
5518
+ const resolved = useAnchoredPosition(rootRef, panelRef, {
5519
+ active: panelMounted,
5520
+ position,
5521
+ fixed: appendToBody,
5522
+ // The gap comes from `--bulma-popover-offset` in the stylesheet, so the
5523
+ // coordinates leave it out and `auto` reads it off the panel.
5524
+ offset: 0,
5525
+ styledGap,
5526
+ });
5527
+ // The trigger renders first inside the wrapper. Read when needed rather
5528
+ // than held, so a trigger that remounted is still the one found.
5529
+ const triggerNode = React.useCallback(() => {
5530
+ const first = rootRef.current?.firstElementChild;
5531
+ return first && first !== panelRef.current ? first : null;
5532
+ }, []);
5533
+ const restoreFocusRef = React.useMemo(() => ({
5534
+ get current() {
5535
+ return triggerNode();
5536
+ },
5537
+ }), [triggerNode]);
5538
+ useFocusTrap(panelRef, {
5539
+ active: panelMounted && trapFocus,
5540
+ restoreFocus: restoreFocusRef,
5541
+ });
5542
+ // Without the trap, focus still arrives in the panel and goes back to the
5543
+ // trigger, on the trap's terms: only while the panel still holds it.
5544
+ React.useEffect(() => {
5545
+ const panel = panelRef.current;
5546
+ if (!panelMounted || trapFocus || !panel)
5547
+ return undefined;
5548
+ (firstTabStop(panel) ?? panel).focus();
5549
+ return () => {
5550
+ const doc = panel.ownerDocument;
5551
+ const now = getDeepestActiveElement(doc);
5552
+ if (!now || now === doc.body || panel.contains(now)) {
5553
+ triggerNode()?.focus();
5554
+ }
5555
+ };
5556
+ }, [panelMounted, trapFocus, triggerNode]);
5557
+ React.useEffect(() => {
5558
+ if (!panelMounted || !closeOnClickOutside)
5559
+ return undefined;
5560
+ const handlePointerDown = (e) => {
5561
+ if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
5562
+ return;
5563
+ }
5564
+ close();
5565
+ };
5566
+ // The wrapper is on the page whenever the panel is. Its document, an
5567
+ // iframe's when the tree renders into one, is where presses land.
5568
+ const doc = rootRef.current.ownerDocument;
5569
+ doc.addEventListener('pointerdown', handlePointerDown);
5570
+ return () => doc.removeEventListener('pointerdown', handlePointerDown);
5571
+ }, [panelMounted, closeOnClickOutside, close]);
5572
+ const handlePanelKeyDown = (e) => {
5573
+ if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
5574
+ return;
5575
+ // React bubbles the key here from portaled content as well, so a dialog
5576
+ // of its own on the way (a nested popover, a picker's calendar) owns it.
5577
+ const owner = nearestDialog(e.nativeEvent);
5578
+ if (owner && owner !== panelRef.current)
5579
+ return;
5580
+ e.preventDefault();
5581
+ close();
5582
+ };
5583
+ const triggerProps = trigger.props;
5584
+ const handleTriggerClick = (e) => {
5585
+ triggerProps.onClick?.(e);
5586
+ if (!e.defaultPrevented)
5587
+ requestOpen(!isOpen);
5588
+ };
5589
+ const handleTriggerKeyDown = (e) => {
5590
+ triggerProps.onKeyDown?.(e);
5591
+ // A key on something inside the trigger is that element's to answer.
5592
+ if (e.defaultPrevented || e.target !== e.currentTarget)
5593
+ return;
5594
+ if (e.key === 'Escape') {
5595
+ if (isOpen && closeOnEscape) {
5596
+ e.preventDefault();
5597
+ close();
5598
+ }
5599
+ return;
5600
+ }
5601
+ if (e.key !== 'Enter' && e.key !== ' ')
5602
+ return;
5603
+ if (handlesKeyItself(e.currentTarget, e.key))
5604
+ return;
5605
+ // Space would scroll the page; a held key repeats.
5606
+ e.preventDefault();
5607
+ if (!e.repeat)
5608
+ requestOpen(!isOpen);
5609
+ };
5610
+ const needsButtonRole = typeof trigger.type === 'string' &&
5611
+ !NATIVE_CONTROLS.has(trigger.type) &&
5612
+ !(trigger.type === 'a' && triggerProps.href != null);
5613
+ const clonedTrigger = React.cloneElement(trigger, {
5614
+ ...(needsButtonRole
5615
+ ? {
5616
+ role: triggerProps.role ?? 'button',
5617
+ tabIndex: triggerProps.tabIndex ?? 0,
5618
+ }
5619
+ : {}),
5620
+ 'aria-haspopup': 'dialog',
5621
+ 'aria-expanded': isOpen,
5622
+ 'aria-controls': panelMounted ? panelId : undefined,
5623
+ onClick: handleTriggerClick,
5624
+ onKeyDown: handleTriggerKeyDown,
5625
+ });
5626
+ const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
5627
+ const panelClasses = classNames(usePrefixedClassNames('popover-content', {
5628
+ [`is-${resolved.position}`]: true,
5629
+ 'is-portal': appendToBody,
5630
+ }), contentClassName);
5631
+ return (jsxRuntime.jsxs("div", { className: rootClasses, ...rest, ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx(PopoverPanel, { panelRef: panelRef, titleId: titleId, ariaLabel: ariaLabel, close: close, id: panelId, className: panelClasses, style: appendToBody
5632
+ ? {
5633
+ position: 'fixed',
5634
+ top: resolved.top,
5635
+ left: resolved.left,
5636
+ }
5637
+ : undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
5638
+ insidePointerRef.current = e.nativeEvent;
5639
+ }, children: children }) }))] }));
5640
+ };
5641
+ /**
5642
+ * The open panel. It mounts with each opening, so it counts the
5643
+ * `Popover.Header`s inside it afresh every time.
5644
+ */
5645
+ function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
5646
+ // Headers register in a layout effect, after this first render, and this
5647
+ // panel's own layout effect runs after theirs. Until then the panel assumes
5648
+ // it has one, so a panel that does is named from its first commit, before
5649
+ // focus moves in, and one that doesn't drops the reference before it paints.
5650
+ const [titlesCounted, setTitlesCounted] = React.useState(false);
5651
+ const [titleCount, setTitleCount] = React.useState(0);
5652
+ const registerTitle = React.useCallback(() => {
5653
+ setTitleCount(n => n + 1);
5654
+ return () => setTitleCount(n => n - 1);
5655
+ }, []);
5656
+ useClientLayoutEffect(() => {
5657
+ setTitlesCounted(true);
5658
+ }, []);
5659
+ const named = Boolean(ariaLabel) || titleCount > 0;
5660
+ React.useEffect(() => {
5661
+ if (titlesCounted && !named) {
5662
+ warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
5663
+ 'Pass ariaLabel, or render a Popover.Header inside it.');
5664
+ }
5665
+ }, [titlesCounted, named]);
5666
+ const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
5667
+ return (jsxRuntime.jsx(PopoverContext.Provider, { value: context, children: jsxRuntime.jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
5668
+ }
5669
+ /**
5670
+ * The popover's title, which names the panel for assistive technology.
5671
+ *
5672
+ * @function
5673
+ * @param {PopoverHeaderProps} props - Props for the Popover.Header component.
5674
+ * @returns {JSX.Element} The header element.
5675
+ */
5676
+ const PopoverHeader = ({ className, ...props }) => {
5677
+ const context = React.useContext(PopoverContext);
5678
+ const registerTitle = context?.registerTitle;
5679
+ // Before paint, so the panel is named by the time it shows.
5680
+ useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
5681
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5682
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
5683
+ };
5684
+ /**
5685
+ * The popover's main content area.
5686
+ *
5687
+ * @function
5688
+ * @param {PopoverBodyProps} props - Props for the Popover.Body component.
5689
+ * @returns {JSX.Element} The body element.
5690
+ */
5691
+ const PopoverBody = ({ className, ...props }) => {
5692
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5693
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
5694
+ };
5695
+ /**
5696
+ * A row of actions along the bottom of the popover, aligned to the end.
5697
+ *
5698
+ * @function
5699
+ * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
5700
+ * @returns {JSX.Element} The footer element.
5701
+ */
5702
+ const PopoverFooter = ({ className, ...props }) => {
5703
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5704
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
5705
+ };
5706
+ /**
5707
+ * A `Button` that closes the popover, handing focus back to the trigger.
5708
+ *
5709
+ * @function
5710
+ * @param {PopoverCloseProps} props - Props for the Popover.Close component.
5711
+ * @returns {JSX.Element} The close button.
5712
+ */
5713
+ const PopoverClose = ({ onClick, ...props }) => {
5714
+ const context = React.useContext(PopoverContext);
5715
+ return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
5716
+ onClick?.(e);
5717
+ if (!e.defaultPrevented)
5718
+ context?.close();
5719
+ } }));
5720
+ };
5721
+ /** Popover with Header, Body, Footer and Close sub-components. */
5722
+ const Popover = withSubComponents(PopoverComponent, {
5723
+ Header: PopoverHeader,
5724
+ Body: PopoverBody,
5725
+ Footer: PopoverFooter,
5726
+ Close: PopoverClose,
5727
+ }, 'Popover');
5728
+
5729
+ /**
5730
+ * Individual Step component for use inside Steps.
5731
+ *
5732
+ * @function
5733
+ * @param {StepProps} props - Props for the Step component.
5734
+ * @returns {JSX.Element} The rendered step segment.
5735
+ */
5736
+ const Step = ({ isActive = false, isCompleted = false, label, icon, clickable = false, onClick, stepNumber, completedIcon, className, children, ...props }) => {
5737
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5738
+ const stepClasses = classNames(usePrefixedClassNames('steps-segment', {
5739
+ 'is-active': isActive,
5740
+ 'is-completed': isCompleted,
5741
+ }), bulmaHelperClasses, className);
5742
+ const stepsLinkClass = usePrefixedClassNames('steps-link');
5743
+ const stepsMarkerClass = usePrefixedClassNames('steps-marker');
5744
+ const stepsContentClass = usePrefixedClassNames('steps-content');
5745
+ const stepsTitleClass = usePrefixedClassNames('steps-title');
5746
+ const handleClick = () => {
5747
+ if (clickable && onClick) {
5748
+ onClick();
5749
+ }
5750
+ };
5751
+ const handleKeyDown = (e) => {
5752
+ if (clickable && onClick && (e.key === 'Enter' || e.key === ' ')) {
5753
+ e.preventDefault();
5754
+ onClick();
5755
+ }
5756
+ };
5757
+ const resolvedCompletedIcon = completedIcon === undefined ? '\u2713' : completedIcon;
5758
+ const markerContent = icon ||
5759
+ (isCompleted && resolvedCompletedIcon
5760
+ ? resolvedCompletedIcon
5761
+ : (stepNumber ?? null));
5762
+ return (jsxRuntime.jsx("li", { className: stepClasses, onClick: handleClick, onKeyDown: handleKeyDown, tabIndex: clickable ? 0 : undefined, role: clickable ? 'button' : undefined, "aria-current": isActive ? 'step' : undefined, ...rest, children: jsxRuntime.jsxs("div", { className: stepsLinkClass, children: [jsxRuntime.jsx("span", { className: stepsMarkerClass, children: markerContent }), (label || children) && (jsxRuntime.jsx("div", { className: stepsContentClass, children: jsxRuntime.jsx("p", { className: stepsTitleClass, children: label || children }) }))] }) }));
5763
+ };
5764
+ /**
5765
+ * The `Steps` component provides a multi-step progress indicator for wizard flows, checkout processes, or any multi-step workflow.
5766
+ *
5767
+ * @function
5768
+ * @param {StepsProps} props - Props for the Steps component.
5769
+ * @returns {JSX.Element} The rendered steps component.
5770
+ *
5771
+ * @example
5772
+ * // Basic steps with items
5773
+ * <Steps
5774
+ * value={1}
5775
+ * items={[
5776
+ * { label: 'Account' },
5777
+ * { label: 'Profile' },
5778
+ * { label: 'Complete' },
5779
+ * ]}
5780
+ * />
5781
+ *
5782
+ * @example
5783
+ * // Clickable steps with navigation
5784
+ * <Steps
5785
+ * value={currentStep}
5786
+ * onStepClick={(step) => setCurrentStep(step)}
5787
+ * hasNavigation
5788
+ * items={[
5789
+ * { label: 'Step 1', clickable: true },
5790
+ * { label: 'Step 2', clickable: true },
5791
+ * { label: 'Step 3', clickable: true },
5792
+ * ]}
5793
+ * />
5794
+ */
5795
+ const StepsComponent = ({ value = 0, items, size, color, hasMarker = true, animated = true, rounded = true, vertical = false, labelPosition = 'bottom', mobileMode, showStepNumbers = true, hasNavigation = false, prevLabel, nextLabel, onPrev, onNext, onStepClick, className, children, ...props }) => {
5796
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5797
+ // Generate classes for the wrapper div
5798
+ const stepsClasses = usePrefixedClassNames('steps', {
5799
+ [`is-${size}`]: size,
5800
+ [`is-${color}`]: color,
5801
+ 'has-marker': hasMarker,
5802
+ 'is-animated': animated,
5803
+ 'is-rounded': rounded,
5804
+ 'is-vertical': vertical,
5805
+ [`has-label-${labelPosition}`]: labelPosition && (labelPosition !== 'bottom' || vertical),
5806
+ [`is-${mobileMode}`]: mobileMode,
5807
+ });
5808
+ const combinedClasses = classNames(stepsClasses, bulmaHelperClasses, className);
5809
+ // Generate classes for the ul (list)
5810
+ const listClasses = usePrefixedClassNames('steps-list', {
5811
+ 'is-vertical': vertical,
5812
+ });
5813
+ const stepsNavigationClass = usePrefixedClassNames('steps-navigation');
5814
+ const prevButtonClass = usePrefixedClassNames('button');
5815
+ const nextButtonClass = usePrefixedClassNames('button', {
5816
+ 'is-primary': true,
5817
+ });
5818
+ // Count total steps for navigation
5819
+ const totalSteps = items ? items.length : React.Children.count(children);
5820
+ // Render items if provided
5821
+ const renderSteps = () => {
5822
+ if (items && items.length > 0) {
5823
+ return items.map((item, index) => (jsxRuntime.jsx(Step, { isActive: index === value, isCompleted: index < value, label: item.label, icon: item.icon, completedIcon: item.completedIcon, clickable: item.clickable, stepNumber: showStepNumbers ? index + 1 : undefined, onClick: item.clickable && onStepClick ? () => onStepClick(index) : undefined, className: item.className }, index)));
5824
+ }
5825
+ // If children are provided, clone them with active/completed states
5826
+ if (children) {
5827
+ return React.Children.map(children, (child, index) => {
5828
+ if (React.isValidElement(child)) {
5829
+ return React.cloneElement(child, {
5830
+ isActive: index === value,
5831
+ isCompleted: index < value,
5832
+ stepNumber: showStepNumbers ? index + 1 : undefined,
5833
+ onClick: child.props.clickable && onStepClick
5834
+ ? () => onStepClick(index)
5835
+ : child.props.onClick,
5836
+ });
5837
+ }
5838
+ return child;
5839
+ });
5840
+ }
5841
+ return null;
5842
+ };
5843
+ return (jsxRuntime.jsxs("div", { className: combinedClasses, ...rest, children: [jsxRuntime.jsx("ul", { className: listClasses, children: renderSteps() }), hasNavigation && (jsxRuntime.jsxs("div", { className: stepsNavigationClass, children: [jsxRuntime.jsx("button", { type: "button", className: prevButtonClass, disabled: value === 0, onClick: onPrev ?? (() => onStepClick?.(value - 1)), children: prevLabel ?? 'Previous' }), jsxRuntime.jsx("button", { type: "button", className: nextButtonClass, disabled: value === totalSteps - 1, onClick: onNext ?? (() => onStepClick?.(value + 1)), children: nextLabel ?? 'Next' })] }))] }));
5844
+ };
5845
+ // Attach Step as static property
5846
+ const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
5847
+
5848
+ /**
5849
+ * The `Sidebar` component provides a slide-out navigation panel that appears from the left or right side of the screen.
5850
+ *
5851
+ * @function
5852
+ * @param {SidebarProps} props - Props for the Sidebar component.
5853
+ * @param {React.Ref<HTMLElement>} ref - Forwarded ref to the sidebar element.
5854
+ * @returns {JSX.Element | null} The rendered sidebar component.
5855
+ *
5856
+ * @example
5857
+ * // Basic sidebar
5858
+ * const [isOpen, setIsOpen] = useState(false);
5859
+ * <Sidebar isOpen={isOpen} onClose={() => setIsOpen(false)}>
5860
+ * <Menu>...</Menu>
5861
+ * </Sidebar>
5862
+ *
5863
+ * @example
5864
+ * // Right-side sidebar with custom width
5865
+ * <Sidebar
5866
+ * isOpen={isOpen}
5867
+ * onClose={() => setIsOpen(false)}
5868
+ * position="right"
5869
+ * width="320px"
5870
+ * >
5871
+ * <div>Panel content</div>
5872
+ * </Sidebar>
5873
+ */
5874
+ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
5875
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5876
+ const sidebarRef = React.useRef(null);
5877
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
5878
+ const consumerCleanupRef = React.useRef(null);
5879
+ const resolvedFullwidth = isFullwidth ?? fullWidth ?? false;
5880
+ // Close handler
5881
+ const handleClose = React.useCallback(() => {
5882
+ if (canCancel && onClose) {
5883
+ onClose();
4913
5884
  }
4914
5885
  }, [canCancel, onClose]);
4915
5886
  // Handle overlay click
@@ -5579,198 +6550,69 @@ const notifyDialogListeners = () => {
5579
6550
  /**
5580
6551
  * Programmatic dialog API for showing dialogs from anywhere.
5581
6552
  */
5582
- const dialog = {
5583
- /**
5584
- * Show an alert dialog. Returns a promise that resolves when closed.
5585
- */
5586
- alert: (options) => {
5587
- return new Promise(resolve => {
5588
- const opts = typeof options === 'string' ? { message: options } : options;
5589
- currentDialog = {
5590
- type: 'alert',
5591
- options: opts,
5592
- resolve: () => resolve(),
5593
- };
5594
- notifyDialogListeners();
5595
- });
5596
- },
5597
- /**
5598
- * Show a confirm dialog. Returns a promise that resolves to true/false.
5599
- */
5600
- confirm: (options) => {
5601
- return new Promise(resolve => {
5602
- const opts = typeof options === 'string' ? { message: options } : options;
5603
- currentDialog = {
5604
- type: 'confirm',
5605
- options: opts,
5606
- resolve: value => resolve(Boolean(value)),
5607
- };
5608
- notifyDialogListeners();
5609
- });
5610
- },
5611
- /**
5612
- * Close the current dialog.
5613
- */
5614
- close: (value) => {
5615
- if (currentDialog) {
5616
- currentDialog.resolve(value);
5617
- currentDialog = null;
5618
- notifyDialogListeners();
5619
- }
5620
- },
5621
- /**
5622
- * Subscribe to dialog changes.
5623
- */
5624
- subscribe: (listener) => {
5625
- dialogListeners.add(listener);
5626
- return () => dialogListeners.delete(listener);
5627
- },
5628
- };
5629
- /**
5630
- * DialogContainer component to render programmatic dialogs.
5631
- * Place this once at the root of your app.
5632
- *
5633
- * @function
5634
- * @returns {JSX.Element | null} The rendered dialog, or null if none is active.
5635
- */
5636
- const DialogContainer = () => {
5637
- // Starts from the dialog already open instead of none, then follows changes.
5638
- const current = React.useSyncExternalStore(dialog.subscribe, getCurrentDialog, getServerDialog);
5639
- if (!current) {
5640
- return null;
5641
- }
5642
- const { type, options } = current;
5643
- return (jsxRuntime.jsx(Dialog, { isOpen: true, title: options.title, message: options.message, type: options.type, confirmText: options.confirmText, cancelText: options.cancelText, icon: options.icon, showCancel: type === 'confirm', focusCancel: options.focusCancel, onConfirm: () => dialog.close(type === 'confirm' ? true : undefined), onCancel: () => dialog.close(type === 'confirm' ? false : undefined) }));
5644
- };
5645
-
5646
- const validButtonColors = [...validColors, 'text', 'ghost'];
5647
- /**
5648
- * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5649
- *
5650
- * @function
5651
- * @param {ButtonProps} props - Props for the Button component.
5652
- * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
5653
- * @returns {JSX.Element} The rendered button, anchor, or custom element.
5654
- * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
5655
- */
5656
- const Button = React.forwardRef(function Button(props, ref) {
5657
- const { color, size, isLight, isRounded, isLoading, isStatic, isFullwidth, isFullWidth, isOutlined, isInverted, isFocused, isActive, isHovered, isDisabled, className, children, textColor, bgColor, as: Component = 'button', href, onClick, target, rel, ...bulmaProps } = props;
5658
- const { bulmaHelperClasses, rest } = useBulmaClasses({
5659
- color: textColor,
5660
- backgroundColor: bgColor,
5661
- ...bulmaProps,
5662
- });
5663
- // Generate Bulma classes with prefix
5664
- const bulmaClasses = usePrefixedClassNames('button', {
5665
- [`is-${color}`]: color &&
5666
- validButtonColors.includes(color),
5667
- [`is-${size}`]: size,
5668
- 'is-outlined': isOutlined,
5669
- 'is-light': isLight,
5670
- 'is-loading': isLoading,
5671
- 'is-static': isStatic,
5672
- 'is-disabled': isDisabled,
5673
- 'is-rounded': isRounded,
5674
- 'is-hovered': isHovered,
5675
- 'is-focused': isFocused,
5676
- 'is-active': isActive,
5677
- 'is-inverted': isInverted,
5678
- 'is-fullwidth': isFullwidth ?? isFullWidth,
5679
- });
5680
- // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
5681
- const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
5682
- if (Component !== 'button') {
5683
- // Strip the form-control attributes a link-like element cannot carry, so a
5684
- // JavaScript consumer or a spread object doesn't put them on the DOM. The
5685
- // types no longer allow them through; this is the runtime backstop.
5686
- //
5687
- // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
5688
- // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
5689
- // its props reach it. Stripping names by their spelling alone broke that
5690
- // promise silently: `<Button as={Custom} name="x" />` type-checked while
5691
- // `Custom` never received the `name` it requires.
5692
- //
5693
- // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
5694
- // targets this list was filtering them from. `autoFocus` is a global
5695
- // attribute (React decides per element whether to honour it, which is not
5696
- // ours to pre-empt), and `type` on an anchor is the MIME hint that
5697
- // `ButtonProps<'a'>` now types it as.
5698
- //
5699
- // The types already answer this for a TypeScript caller: `ButtonProps<T>`
5700
- // derives from `ComponentPropsWithoutRef<T>`, so only what the target
5701
- // accepts can be passed. What is left is a runtime backstop for JavaScript
5702
- // consumers and spread objects.
5703
- //
5704
- // Keep it to the two cases where a stray attribute does something. The
5705
- // submit-overrides belong to a submit control and never make sense on the
5706
- // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
5707
- // `.button[disabled]` (background, border, shadow, opacity), so letting it
5708
- // through to a `<span>` greys the element out — main stripped it, and not
5709
- // stripping it would be a silent visual change on a types-only release.
5710
- //
5711
- // Everything else (`name`, `value`, `form`) is inert where it does not
5712
- // belong, and enumerating which tags own it is what kept going wrong:
5713
- // first all nine names on every non-button tag (so `<Button as="input"
5714
- // name="query" value="Search">` submitted nothing), then a form-control
5715
- // allowlist that still lost `formAction` on an input and `value` on an
5716
- // option. Those now ride on the types instead.
5717
- //
5718
- // A custom component is exempt entirely — it owns its prop contract, and
5719
- // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
5720
- //
5721
- // The elements that own `disabled`, per the HTML spec. A closed set, unlike
5722
- // the open-ended question of which tag owns which attribute.
5723
- const SUBMIT_OVERRIDES = [
5724
- 'formAction',
5725
- 'formEncType',
5726
- 'formMethod',
5727
- 'formNoValidate',
5728
- 'formTarget',
5729
- ];
5730
- const OWNS_DISABLED = [
5731
- 'button',
5732
- 'fieldset',
5733
- 'input',
5734
- 'optgroup',
5735
- 'option',
5736
- 'select',
5737
- 'textarea',
5738
- ];
5739
- // Two independent filters. They are not exclusive — an `<a>` owns neither
5740
- // the submit-overrides nor `disabled`, so it takes both — and deriving them
5741
- // from one destructure, or from one if/else, is what broke each of them in
5742
- // turn: first the overrides came off every intrinsic (an `as="input"` lost
5743
- // `formAction`), then scoping them to the anchor let `disabled` back onto
5744
- // it.
5745
- const forwardedRest = { ...rest };
5746
- if (typeof Component === 'string' && !isCustomElement(Component)) {
5747
- // Meaningless anywhere but a submit control, and an anchor is not one.
5748
- if (Component === 'a') {
5749
- for (const key of SUBMIT_OVERRIDES)
5750
- delete forwardedRest[key];
5751
- }
5752
- // Visible via Bulma's `.button[disabled]`, so withheld from any element
5753
- // that does not own it.
5754
- if (!OWNS_DISABLED.includes(Component))
5755
- delete forwardedRest.disabled;
6553
+ const dialog = {
6554
+ /**
6555
+ * Show an alert dialog. Returns a promise that resolves when closed.
6556
+ */
6557
+ alert: (options) => {
6558
+ return new Promise(resolve => {
6559
+ const opts = typeof options === 'string' ? { message: options } : options;
6560
+ currentDialog = {
6561
+ type: 'alert',
6562
+ options: opts,
6563
+ resolve: () => resolve(),
6564
+ };
6565
+ notifyDialogListeners();
6566
+ });
6567
+ },
6568
+ /**
6569
+ * Show a confirm dialog. Returns a promise that resolves to true/false.
6570
+ */
6571
+ confirm: (options) => {
6572
+ return new Promise(resolve => {
6573
+ const opts = typeof options === 'string' ? { message: options } : options;
6574
+ currentDialog = {
6575
+ type: 'confirm',
6576
+ options: opts,
6577
+ resolve: value => resolve(Boolean(value)),
6578
+ };
6579
+ notifyDialogListeners();
6580
+ });
6581
+ },
6582
+ /**
6583
+ * Close the current dialog.
6584
+ */
6585
+ close: (value) => {
6586
+ if (currentDialog) {
6587
+ currentDialog.resolve(value);
6588
+ currentDialog = null;
6589
+ notifyDialogListeners();
5756
6590
  }
5757
- return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
5758
- ? // Guarded, because this handler is installed into whatever `as`
5759
- // renders and a custom component's `onClick` need not be a DOM
5760
- // event handler at all. One declaring `onClick: (v: string) =>
5761
- // void` calls this with a string, and an unguarded
5762
- // `e.preventDefault()` throws `is not a function`. Blocking the
5763
- // real event is the point; a foreign payload is simply not ours
5764
- // to act on.
5765
- (e) => {
5766
- if (typeof e?.preventDefault === 'function')
5767
- e.preventDefault();
5768
- }
5769
- : onClick, ...forwardedRest, children: children }));
6591
+ },
6592
+ /**
6593
+ * Subscribe to dialog changes.
6594
+ */
6595
+ subscribe: (listener) => {
6596
+ dialogListeners.add(listener);
6597
+ return () => dialogListeners.delete(listener);
6598
+ },
6599
+ };
6600
+ /**
6601
+ * DialogContainer component to render programmatic dialogs.
6602
+ * Place this once at the root of your app.
6603
+ *
6604
+ * @function
6605
+ * @returns {JSX.Element | null} The rendered dialog, or null if none is active.
6606
+ */
6607
+ const DialogContainer = () => {
6608
+ // Starts from the dialog already open instead of none, then follows changes.
6609
+ const current = React.useSyncExternalStore(dialog.subscribe, getCurrentDialog, getServerDialog);
6610
+ if (!current) {
6611
+ return null;
5770
6612
  }
5771
- return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
5772
- });
5773
- Button.displayName = 'Button';
6613
+ const { type, options } = current;
6614
+ return (jsxRuntime.jsx(Dialog, { isOpen: true, title: options.title, message: options.message, type: options.type, confirmText: options.confirmText, cancelText: options.cancelText, icon: options.icon, showCancel: type === 'confirm', focusCancel: options.focusCancel, onConfirm: () => dialog.close(type === 'confirm' ? true : undefined), onCancel: () => dialog.close(type === 'confirm' ? false : undefined) }));
6615
+ };
5774
6616
 
5775
6617
  /**
5776
6618
  * Individual carousel item/slide.
@@ -10300,7 +11142,11 @@ TextArea.displayName = 'TextArea';
10300
11142
  const DEFAULT_PICKER_LABELS = {
10301
11143
  prevMonth: 'Previous month',
10302
11144
  nextMonth: 'Next month',
11145
+ prevYear: 'Previous year',
11146
+ nextYear: 'Next year',
10303
11147
  chooseDate: 'Choose date',
11148
+ chooseMonth: 'Choose month',
11149
+ chooseYear: 'Choose year',
10304
11150
  hours: 'hours',
10305
11151
  minutes: 'minutes',
10306
11152
  seconds: 'seconds',
@@ -10330,6 +11176,8 @@ const mergeLabels = (overrides) => ({
10330
11176
  });
10331
11177
 
10332
11178
  const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
11179
+ const DEFAULT_MONTH_FORMAT = 'YYYY-MM';
11180
+ const DEFAULT_YEAR_FORMAT = 'YYYY';
10333
11181
  const DEFAULT_TIME_FORMAT_24 = 'HH:mm';
10334
11182
  const DEFAULT_TIME_FORMAT_12 = 'hh:mm A';
10335
11183
  const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DD HH:mm';
@@ -10635,9 +11483,87 @@ function isWithin(d, min, max) {
10635
11483
  function startOfDay(d) {
10636
11484
  return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
10637
11485
  }
11486
+ function endOfDay(d) {
11487
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
11488
+ }
10638
11489
  function startOfMonth(d) {
10639
11490
  return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
10640
11491
  }
11492
+ /**
11493
+ * Local midnight on `year`-`month`-`day` (`month` from 0), like the
11494
+ * `Date(y, m, d)` constructor except that years 0–99 stay as given rather than
11495
+ * turning into 1900–1999.
11496
+ */
11497
+ function makeDate(year, month = 0, day = 1) {
11498
+ const d = new Date(2000, 0, 1);
11499
+ d.setFullYear(year, month, day);
11500
+ return d;
11501
+ }
11502
+ /**
11503
+ * First instant of the day, month or year containing `d`. Works by setters on
11504
+ * a copy rather than the `Date(y, m, d)` constructor, which reads years 0–99
11505
+ * as 1900–1999: segmented typing passes through such years on the way to a
11506
+ * four-digit one.
11507
+ */
11508
+ function startOfPeriod(d, granularity) {
11509
+ const r = new Date(d);
11510
+ if (granularity === 'year')
11511
+ r.setMonth(0, 1);
11512
+ else if (granularity === 'month')
11513
+ r.setDate(1);
11514
+ r.setHours(0, 0, 0, 0);
11515
+ return r;
11516
+ }
11517
+ /** Last instant of the day, month or year containing `d`. */
11518
+ function endOfPeriod(d, granularity) {
11519
+ const r = new Date(d);
11520
+ if (granularity === 'year')
11521
+ r.setMonth(11, 31);
11522
+ // Day 0 of the next month is this month's last day.
11523
+ else if (granularity === 'month')
11524
+ r.setMonth(r.getMonth() + 1, 0);
11525
+ r.setHours(23, 59, 59, 999);
11526
+ return r;
11527
+ }
11528
+ /**
11529
+ * Whether the day `d` falls outside `[min, max]` or is blocked by
11530
+ * `shouldDisableDate` / `unselectableDates`. This is the day grid's rule for a
11531
+ * disabled cell.
11532
+ */
11533
+ function isDayUnselectable(d, c) {
11534
+ if (!isWithin(d, c.min, c.max))
11535
+ return true;
11536
+ if (c.shouldDisableDate?.(d))
11537
+ return true;
11538
+ if (c.unselectableDates?.some(u => isSameDay(u, d)))
11539
+ return true;
11540
+ return false;
11541
+ }
11542
+ /**
11543
+ * Whether every day of the month or year containing `d` is unselectable, so
11544
+ * the period as a whole can't be picked. Walks the period's days and stops at
11545
+ * the first selectable one. `min` and `max` count whole days here, so a `min`
11546
+ * of 3pm on a month's last day leaves that day, and so its month, selectable.
11547
+ */
11548
+ function isPeriodUnselectable(d, granularity, c) {
11549
+ const days = {
11550
+ ...c,
11551
+ min: c.min && startOfDay(c.min),
11552
+ max: c.max && endOfDay(c.max),
11553
+ };
11554
+ const start = startOfPeriod(d, granularity);
11555
+ const end = endOfPeriod(d, granularity).getTime();
11556
+ // A period wholly outside the bounds needs no walk.
11557
+ if (days.min && end < days.min.getTime())
11558
+ return true;
11559
+ if (days.max && start.getTime() > days.max.getTime())
11560
+ return true;
11561
+ for (let day = start; day.getTime() <= end; day = addDays(day, 1)) {
11562
+ if (!isDayUnselectable(day, days))
11563
+ return false;
11564
+ }
11565
+ return true;
11566
+ }
10641
11567
  function addDays(d, n) {
10642
11568
  const r = new Date(d);
10643
11569
  r.setDate(r.getDate() + n);
@@ -10731,12 +11657,100 @@ function buildMonthGrid(monthAnchor, firstDayOfWeek = 0) {
10731
11657
  return cells;
10732
11658
  }
10733
11659
 
10734
- const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, locale, dayNames, monthNames, nearbyMonthDays = true, color, size, className, id, autoFocusCell = false, labels, yearsRange, }) => {
11660
+ const BASE_VIEW = {
11661
+ day: 'days',
11662
+ month: 'months',
11663
+ year: 'years',
11664
+ };
11665
+ // Months in rows of three, so the grid is four rows deep.
11666
+ const MONTH_ROWS = [
11667
+ [0, 1, 2],
11668
+ [3, 4, 5],
11669
+ [6, 7, 8],
11670
+ [9, 10, 11],
11671
+ ];
11672
+ const MONTHS_PER_ROW = 3;
11673
+ // The year list's CSS lays it out four to a row.
11674
+ const YEARS_PER_ROW = 4;
11675
+ /**
11676
+ * The month grid's keyboard, after the day grid's: arrows move a month or a
11677
+ * row, Home/End go to the row's ends, PageUp/PageDown move a year. Returns
11678
+ * the months to move by and the direction to search past disabled months, or
11679
+ * `null` for a key the grid doesn't handle. `col` is the focused month's
11680
+ * column.
11681
+ */
11682
+ function monthStep(key, col) {
11683
+ switch (key) {
11684
+ case 'ArrowLeft':
11685
+ return [-1, -1];
11686
+ case 'ArrowRight':
11687
+ return [1, 1];
11688
+ case 'ArrowUp':
11689
+ return [-MONTHS_PER_ROW, -1];
11690
+ case 'ArrowDown':
11691
+ return [MONTHS_PER_ROW, 1];
11692
+ case 'PageUp':
11693
+ return [-12, -1];
11694
+ case 'PageDown':
11695
+ return [12, 1];
11696
+ case 'Home':
11697
+ return [-col, 1];
11698
+ case 'End':
11699
+ return [MONTHS_PER_ROW - 1 - col, -1];
11700
+ default:
11701
+ return null;
11702
+ }
11703
+ }
11704
+ /**
11705
+ * The month nearest `month` that `disabled` leaves enabled, `month` itself
11706
+ * when it is. At equal distance the later month wins. With every month
11707
+ * disabled it stays `month`, as there is nothing to reach.
11708
+ */
11709
+ function nearestEnabledMonth(month, disabled) {
11710
+ if (!disabled[month])
11711
+ return month;
11712
+ for (let d = 1; d < 12; d++) {
11713
+ if (month + d < 12 && !disabled[month + d])
11714
+ return month + d;
11715
+ if (month - d >= 0 && !disabled[month - d])
11716
+ return month - d;
11717
+ }
11718
+ return month;
11719
+ }
11720
+ /**
11721
+ * The year list's keyboard when it is the selection surface: arrows move a
11722
+ * year or a row, Home/End go to the list's ends. Returns the year to start
11723
+ * from and the direction to search past disabled years, or `null` for a key
11724
+ * the list doesn't handle.
11725
+ */
11726
+ function yearStep(key, focusedYear, years) {
11727
+ switch (key) {
11728
+ case 'ArrowLeft':
11729
+ return [focusedYear - 1, -1];
11730
+ case 'ArrowRight':
11731
+ return [focusedYear + 1, 1];
11732
+ case 'ArrowUp':
11733
+ return [focusedYear - YEARS_PER_ROW, -1];
11734
+ case 'ArrowDown':
11735
+ return [focusedYear + YEARS_PER_ROW, 1];
11736
+ case 'Home':
11737
+ return [years[0], 1];
11738
+ case 'End':
11739
+ return [years[years.length - 1], -1];
11740
+ default:
11741
+ return null;
11742
+ }
11743
+ }
11744
+ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, locale, dayNames, monthNames, nearbyMonthDays = true, color, size, className, id, autoFocusCell = false, labels, yearsRange, granularity = 'day', }) => {
10735
11745
  const gridRef = React.useRef(null);
11746
+ const monthGridRef = React.useRef(null);
10736
11747
  const yearGridRef = React.useRef(null);
10737
11748
  const { classPrefix } = useConfig();
10738
11749
  const t = mergeLabels(labels);
10739
- const [view, setView] = React.useState('days');
11750
+ const baseView = BASE_VIEW[granularity];
11751
+ const isDayGranularity = granularity === 'day';
11752
+ const isYearGranularity = granularity === 'year';
11753
+ const [view, setView] = React.useState(baseView);
10740
11754
  const computedDayNames = React.useMemo(() => {
10741
11755
  if (dayNames && dayNames.length === 7)
10742
11756
  return dayNames;
@@ -10751,16 +11765,28 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10751
11765
  return monthNames;
10752
11766
  return getMonthNames(locale, 'long');
10753
11767
  }, [monthNames, locale]);
11768
+ // The month grid's cells are too narrow for long names, so they show the
11769
+ // short form unless the caller supplied names of their own.
11770
+ const monthCellNames = React.useMemo(() => {
11771
+ if (monthNames && monthNames.length === 12)
11772
+ return monthNames;
11773
+ return getMonthNames(locale, 'short');
11774
+ }, [monthNames, locale]);
10754
11775
  const cells = React.useMemo(() => buildMonthGrid(focusedDate, firstDayOfWeek), [focusedDate, firstDayOfWeek]);
10755
- const isDateUnselectable = React.useCallback((d) => {
10756
- if (!isWithin(d, min, max))
10757
- return true;
10758
- if (shouldDisableDate?.(d))
10759
- return true;
10760
- if (unselectableDates?.some(u => isSameDay(u, d)))
10761
- return true;
10762
- return false;
10763
- }, [min, max, shouldDisableDate, unselectableDates]);
11776
+ const isDateUnselectable = React.useCallback((d) => isDayUnselectable(d, { min, max, shouldDisableDate, unselectableDates }), [min, max, shouldDisableDate, unselectableDates]);
11777
+ // A month or year is out of reach only when every day in it is.
11778
+ const isMonthUnselectable = React.useCallback((d) => isPeriodUnselectable(d, 'month', {
11779
+ min,
11780
+ max,
11781
+ shouldDisableDate,
11782
+ unselectableDates,
11783
+ }), [min, max, shouldDisableDate, unselectableDates]);
11784
+ const isYearUnselectable = React.useCallback((year) => isPeriodUnselectable(makeDate(year), 'year', {
11785
+ min,
11786
+ max,
11787
+ shouldDisableDate,
11788
+ unselectableDates,
11789
+ }), [min, max, shouldDisableDate, unselectableDates]);
10764
11790
  // Step from `next` by `direction` days until we hit a selectable date,
10765
11791
  // skipping past dates blocked by min/max, shouldDisableDate, or
10766
11792
  // unselectableDates. If the whole searched range is disabled, focus stays
@@ -10827,6 +11853,38 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10827
11853
  break;
10828
11854
  }
10829
11855
  }, [focusedDate, firstDayOfWeek, isDateUnselectable, moveFocus, onSelect]);
11856
+ // The month grid's counterpart to `moveFocus`: step by `direction` months
11857
+ // until a month with a selectable day turns up, stopping at the min/max
11858
+ // months. The cap guards against a range with nothing selectable.
11859
+ const moveMonthFocus = React.useCallback((next, direction) => {
11860
+ let candidate = next;
11861
+ for (let i = 0; i < 120; i++) {
11862
+ const month = startOfMonth(candidate).getTime();
11863
+ if (min && month < startOfMonth(min).getTime())
11864
+ return;
11865
+ if (max && month > startOfMonth(max).getTime())
11866
+ return;
11867
+ if (!isMonthUnselectable(candidate)) {
11868
+ onFocusedDateChange(candidate);
11869
+ return;
11870
+ }
11871
+ candidate = addMonths(candidate, direction);
11872
+ }
11873
+ }, [min, max, isMonthUnselectable, onFocusedDateChange]);
11874
+ const handleMonthKeyDown = React.useCallback((e) => {
11875
+ if (e.key === 'Enter' || e.key === ' ') {
11876
+ e.preventDefault();
11877
+ if (!isMonthUnselectable(focusedDate)) {
11878
+ onSelect(startOfMonth(focusedDate));
11879
+ }
11880
+ return;
11881
+ }
11882
+ const step = monthStep(e.key, focusedDate.getMonth() % MONTHS_PER_ROW);
11883
+ if (!step)
11884
+ return;
11885
+ e.preventDefault();
11886
+ moveMonthFocus(addMonths(focusedDate, step[0]), step[1]);
11887
+ }, [focusedDate, isMonthUnselectable, moveMonthFocus, onSelect]);
10830
11888
  const calendarClass = usePrefixedClassNames('dateinput', {
10831
11889
  [`is-${color}`]: !!color,
10832
11890
  [`is-${size}`]: !!size,
@@ -10843,21 +11901,44 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10843
11901
  const dayNamesRowClass = usePrefixedClassNames('dateinput-day-names');
10844
11902
  const dayNameClass = usePrefixedClassNames('dateinput-day-name');
10845
11903
  const gridClass = usePrefixedClassNames('dateinput-grid');
11904
+ const monthsGridClass = usePrefixedClassNames('dateinput-months-grid');
11905
+ const monthsRowClass = usePrefixedClassNames('dateinput-months-row');
10846
11906
  const yearsGridClass = usePrefixedClassNames('dateinput-years-grid');
11907
+ const focusedYear = focusedDate.getFullYear();
11908
+ const today = new Date();
11909
+ const todayYear = today.getFullYear();
11910
+ // The header steps a month at a time over the day grid and a year at a
11911
+ // time over the month grid, kept inside min/max.
10847
11912
  const prevMonthAnchor = addMonths(focusedDate, -1);
10848
11913
  const nextMonthAnchor = addMonths(focusedDate, 1);
10849
- const prevDisabled = !!(min &&
10850
- prevMonthAnchor.getTime() < startOfDay(min).getTime() &&
10851
- isSameMonth(focusedDate, min));
10852
- const nextDisabled = !!(max &&
10853
- nextMonthAnchor.getTime() > startOfDay(max).getTime() &&
10854
- isSameMonth(focusedDate, max));
11914
+ const prevAnchor = isDayGranularity
11915
+ ? prevMonthAnchor
11916
+ : clampDate(addYears(focusedDate, -1), min, max);
11917
+ const nextAnchor = isDayGranularity
11918
+ ? nextMonthAnchor
11919
+ : clampDate(addYears(focusedDate, 1), min, max);
11920
+ const prevDisabled = isDayGranularity
11921
+ ? !!(min &&
11922
+ prevMonthAnchor.getTime() < startOfDay(min).getTime() &&
11923
+ isSameMonth(focusedDate, min))
11924
+ : !!(min && focusedYear <= min.getFullYear());
11925
+ const nextDisabled = isDayGranularity
11926
+ ? !!(max &&
11927
+ nextMonthAnchor.getTime() > startOfDay(max).getTime() &&
11928
+ isSameMonth(focusedDate, max))
11929
+ : !!(max && focusedYear >= max.getFullYear());
10855
11930
  const labelId = id ? `${id}-label` : undefined;
10856
- const monthLabel = `${computedMonthNames[focusedDate.getMonth()]} ${focusedDate.getFullYear()}`;
11931
+ const monthLabel = isDayGranularity
11932
+ ? `${computedMonthNames[focusedDate.getMonth()]} ${focusedYear}`
11933
+ : String(focusedYear);
11934
+ // As the selection surface the year list is where focus moves, so its
11935
+ // window stays centred on the year focused at mount: re-centring on every
11936
+ // move would reflow the list under the keyboard.
11937
+ const [mountYear] = React.useState(focusedYear);
11938
+ const windowYear = isYearGranularity ? mountYear : focusedYear;
10857
11939
  const yearList = React.useMemo(() => {
10858
- const focusedYear = focusedDate.getFullYear();
10859
- const minYear = min ? min.getFullYear() : focusedYear - 100;
10860
- const maxYear = max ? max.getFullYear() : focusedYear + 100;
11940
+ const minYear = min ? min.getFullYear() : windowYear - 100;
11941
+ const maxYear = max ? max.getFullYear() : windowYear + 100;
10861
11942
  const [lo, hi] = yearsRange ?? [minYear, maxYear];
10862
11943
  const start = Math.max(lo, minYear);
10863
11944
  const end = Math.min(hi, maxYear);
@@ -10865,14 +11946,59 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10865
11946
  for (let y = start; y <= end; y++)
10866
11947
  out.push(y);
10867
11948
  return out;
10868
- }, [focusedDate, min, max, yearsRange]);
11949
+ }, [windowYear, min, max, yearsRange]);
11950
+ // Only the selection surface disables years; as navigation every listed
11951
+ // year can be visited.
11952
+ const disabledYears = React.useMemo(() => new Set(isYearGranularity ? yearList.filter(y => isYearUnselectable(y)) : []), [isYearGranularity, yearList, isYearUnselectable]);
11953
+ // Which of the focused year's months have no selectable day.
11954
+ const disabledMonths = React.useMemo(() => granularity === 'month'
11955
+ ? Array.from({ length: 12 }, (_, m) => isMonthUnselectable(makeDate(focusedYear, m)))
11956
+ : [], [granularity, focusedYear, isMonthUnselectable]);
11957
+ // The month grid's one tab stop. A disabled button can't take focus, so
11958
+ // when the focused month is disabled the stop moves to the nearest enabled
11959
+ // month, and focusing it makes it the focused month.
11960
+ const tabStopMonth = nearestEnabledMonth(focusedDate.getMonth(), disabledMonths);
10869
11961
  const handleYearSelect = React.useCallback((year) => {
10870
11962
  const next = new Date(focusedDate);
10871
11963
  next.setFullYear(year);
10872
- // Keep month/day but clamp into [min, max].
10873
- onFocusedDateChange(next);
10874
- setView('days');
10875
- }, [focusedDate, onFocusedDateChange]);
11964
+ // Keep month/day. The month grid also clamps into [min, max] so its
11965
+ // focused month is never one the bounds rule out.
11966
+ onFocusedDateChange(isDayGranularity ? next : clampDate(next, min, max));
11967
+ setView(baseView);
11968
+ }, [focusedDate, onFocusedDateChange, isDayGranularity, min, max, baseView]);
11969
+ // Picking from the year list as the selection surface: focus follows the
11970
+ // pick and `onSelect` gets the year's first day.
11971
+ const handleYearPick = React.useCallback((year) => {
11972
+ onFocusedDateChange(clampDate(makeDate(year), min, max));
11973
+ onSelect(makeDate(year));
11974
+ }, [min, max, onFocusedDateChange, onSelect]);
11975
+ // Step through the listed years from `year` by `direction`, skipping
11976
+ // disabled ones, and focus the first selectable year found.
11977
+ const moveYearFocus = React.useCallback((year, direction) => {
11978
+ const first = yearList[0];
11979
+ const last = yearList[yearList.length - 1];
11980
+ for (let y = year; y >= first && y <= last; y += direction) {
11981
+ if (!disabledYears.has(y)) {
11982
+ onFocusedDateChange(clampDate(makeDate(y), min, max));
11983
+ return;
11984
+ }
11985
+ }
11986
+ }, [yearList, disabledYears, onFocusedDateChange, min, max]);
11987
+ const handleYearListKeyDown = React.useCallback((e) => {
11988
+ if (!isYearGranularity) {
11989
+ // As navigation, Escape returns to the grid the list was opened from.
11990
+ if (e.key === 'Escape') {
11991
+ e.preventDefault();
11992
+ setView(baseView);
11993
+ }
11994
+ return;
11995
+ }
11996
+ const step = yearStep(e.key, focusedYear, yearList);
11997
+ if (!step)
11998
+ return;
11999
+ e.preventDefault();
12000
+ moveYearFocus(step[0], step[1]);
12001
+ }, [isYearGranularity, baseView, focusedYear, yearList, moveYearFocus]);
10876
12002
  React.useEffect(() => {
10877
12003
  if (view !== 'days')
10878
12004
  return;
@@ -10881,17 +12007,55 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10881
12007
  const target = gridRef.current.querySelector('[data-focused="true"]');
10882
12008
  target?.focus();
10883
12009
  }, [autoFocusCell, focusedDate, view]);
10884
- // When the year view opens, scroll the focused year into view.
12010
+ // The month grid takes focus when the popover asks for it, and keeps it on
12011
+ // the focused month while the keyboard moves through the grid.
12012
+ React.useEffect(() => {
12013
+ const grid = monthGridRef.current;
12014
+ if (view !== 'months' || !grid)
12015
+ return;
12016
+ if (!autoFocusCell && !grid.contains(document.activeElement))
12017
+ return;
12018
+ grid.querySelector('[data-focused="true"]')?.focus();
12019
+ }, [autoFocusCell, focusedDate, view]);
12020
+ // When the year view opens as navigation, scroll the focused year into
12021
+ // view.
10885
12022
  React.useEffect(() => {
10886
- if (view !== 'years' || !yearGridRef.current)
12023
+ if (view !== 'years' || isYearGranularity || !yearGridRef.current)
10887
12024
  return;
10888
12025
  const sel = yearGridRef.current.querySelector('[data-focused-year="true"]');
10889
12026
  sel?.scrollIntoView({ block: 'center' });
10890
12027
  sel?.focus();
10891
- }, [view]);
10892
- const focusedYear = focusedDate.getFullYear();
10893
- const todayYear = new Date().getFullYear();
10894
- return (jsxRuntime.jsxs("div", { className: classNames(calendarClass, className), id: id, children: [jsxRuntime.jsxs("div", { className: headerClass, children: [jsxRuntime.jsxs("button", { type: "button", className: monthTriggerClass, "aria-haspopup": "listbox", "aria-expanded": view === 'years', onClick: () => setView(v => (v === 'years' ? 'days' : 'years')), children: [jsxRuntime.jsx("span", { className: monthLabelClass, id: labelId, "aria-live": "polite", children: monthLabel }), jsxRuntime.jsx("svg", { className: monthCaretClass, viewBox: "0 0 12 12", width: "10", height: "10", "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: "M2 4l4 4 4-4", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), jsxRuntime.jsxs("div", { className: navGroupClass, children: [jsxRuntime.jsx("button", { type: "button", className: navPrevClass, "aria-label": t.prevMonth, disabled: prevDisabled || view !== 'days', onClick: () => onFocusedDateChange(prevMonthAnchor), children: jsxRuntime.jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: "M10 3l-5 5 5 5", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) }), jsxRuntime.jsx("button", { type: "button", className: navNextClass, "aria-label": t.nextMonth, disabled: nextDisabled || view !== 'days', onClick: () => onFocusedDateChange(nextMonthAnchor), children: jsxRuntime.jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: "M6 3l5 5-5 5", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), view === 'days' ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: dayNamesRowClass, "aria-hidden": "true", children: computedDayNames.map((name, i) => (jsxRuntime.jsx("div", { className: dayNameClass, children: name }, i))) }), jsxRuntime.jsx("div", { ref: gridRef, role: "grid", "aria-labelledby": labelId, className: gridClass, onKeyDown: handleKeyDown, children: cells.map(cell => {
12028
+ }, [view, isYearGranularity]);
12029
+ // As the selection surface the year list opens with the focused year in
12030
+ // its middle. It scrolls the list itself rather than calling
12031
+ // `scrollIntoView`, which would also scroll the page to an inline calendar
12032
+ // on load, and focuses that year only when the popover asks.
12033
+ React.useEffect(() => {
12034
+ const list = yearGridRef.current;
12035
+ if (!isYearGranularity || !list)
12036
+ return;
12037
+ const target = list.querySelector('[data-focused-year="true"]');
12038
+ if (!target)
12039
+ return;
12040
+ const rect = target.getBoundingClientRect();
12041
+ list.scrollTop +=
12042
+ rect.top -
12043
+ list.getBoundingClientRect().top -
12044
+ (list.clientHeight - rect.height) / 2;
12045
+ if (autoFocusCell)
12046
+ target.focus({ preventScroll: true });
12047
+ }, [isYearGranularity, autoFocusCell]);
12048
+ // Keyboard moves keep DOM focus on the focused year while the list has it.
12049
+ React.useEffect(() => {
12050
+ const list = yearGridRef.current;
12051
+ if (!isYearGranularity || !list)
12052
+ return;
12053
+ if (!list.contains(document.activeElement))
12054
+ return;
12055
+ list.querySelector('[data-focused-year="true"]')?.focus();
12056
+ }, [isYearGranularity, focusedYear]);
12057
+ const navArrow = (d) => (jsxRuntime.jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: d, fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }));
12058
+ return (jsxRuntime.jsxs("div", { className: classNames(calendarClass, className), id: id, children: [jsxRuntime.jsxs("div", { className: headerClass, children: [isYearGranularity ? (jsxRuntime.jsx("span", { className: monthLabelClass, id: labelId, children: monthLabel })) : (jsxRuntime.jsxs("button", { type: "button", className: monthTriggerClass, "aria-haspopup": "listbox", "aria-expanded": view === 'years', onClick: () => setView(v => (v === 'years' ? baseView : 'years')), children: [jsxRuntime.jsx("span", { className: monthLabelClass, id: labelId, "aria-live": "polite", children: monthLabel }), jsxRuntime.jsx("svg", { className: monthCaretClass, viewBox: "0 0 12 12", width: "10", height: "10", "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: "M2 4l4 4 4-4", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] })), !isYearGranularity && (jsxRuntime.jsxs("div", { className: navGroupClass, children: [jsxRuntime.jsx("button", { type: "button", className: navPrevClass, "aria-label": isDayGranularity ? t.prevMonth : t.prevYear, disabled: prevDisabled || view !== baseView, onClick: () => onFocusedDateChange(prevAnchor), children: navArrow('M10 3l-5 5 5 5') }), jsxRuntime.jsx("button", { type: "button", className: navNextClass, "aria-label": isDayGranularity ? t.nextMonth : t.nextYear, disabled: nextDisabled || view !== baseView, onClick: () => onFocusedDateChange(nextAnchor), children: navArrow('M6 3l5 5-5 5') })] }))] }), view === 'days' && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: dayNamesRowClass, "aria-hidden": "true", children: computedDayNames.map((name, i) => (jsxRuntime.jsx("div", { className: dayNameClass, children: name }, i))) }), jsxRuntime.jsx("div", { ref: gridRef, role: "grid", "aria-labelledby": labelId, className: gridClass, onKeyDown: handleKeyDown, children: cells.map(cell => {
10895
12059
  const disabled = isDateUnselectable(cell.date);
10896
12060
  const isSelected = !!value && isSameDay(value, cell.date);
10897
12061
  const isFocused = isSameDay(cell.date, focusedDate);
@@ -10909,156 +12073,56 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
10909
12073
  onFocusedDateChange(cell.date);
10910
12074
  onSelect(cell.date);
10911
12075
  }, style: !display ? { visibility: 'hidden' } : undefined, children: cell.date.getDate() }, cell.date.toISOString()));
10912
- }) })] })) : (jsxRuntime.jsx("div", { ref: yearGridRef, className: yearsGridClass, role: "listbox", "aria-label": monthLabel, onKeyDown: e => {
10913
- if (e.key === 'Escape') {
10914
- e.preventDefault();
10915
- setView('days');
10916
- }
10917
- }, children: yearList.map(year => {
12076
+ }) })] })), view === 'months' && (jsxRuntime.jsx("div", { ref: monthGridRef, role: "grid", "aria-labelledby": labelId, className: monthsGridClass, onKeyDown: handleMonthKeyDown, children: MONTH_ROWS.map(row => (jsxRuntime.jsx("div", { role: "row", className: monthsRowClass, children: row.map(month => {
12077
+ const date = makeDate(focusedYear, month);
12078
+ const disabled = disabledMonths[month];
12079
+ const isSelected = !!value && isSameMonth(value, date);
12080
+ const isFocused = month === tabStopMonth;
12081
+ const isCurrent = isSameMonth(date, today);
12082
+ const cellClass = prefixedClassNames(classPrefix, 'dateinput-month-cell', {
12083
+ 'is-selected': isSelected,
12084
+ 'is-today': isCurrent,
12085
+ 'is-disabled': disabled,
12086
+ });
12087
+ return (jsxRuntime.jsx("button", { type: "button", role: "gridcell", tabIndex: isFocused ? 0 : -1, "aria-selected": isSelected, "aria-disabled": disabled, "aria-current": isCurrent ? 'date' : undefined, "aria-label": computedMonthNames[month], "data-focused": isFocused ? 'true' : undefined, disabled: disabled, className: cellClass, onFocus: () => {
12088
+ // Reached by Tab while the focused month is disabled,
12089
+ // or by pointer: keys move on from here.
12090
+ if (month !== focusedDate.getMonth()) {
12091
+ onFocusedDateChange(clampDate(date, min, max));
12092
+ }
12093
+ }, onClick: () => {
12094
+ onFocusedDateChange(clampDate(date, min, max));
12095
+ onSelect(date);
12096
+ }, children: monthCellNames[month] }, month));
12097
+ }) }, row[0]))) })), view === 'years' && (jsxRuntime.jsx("div", { ref: yearGridRef, className: yearsGridClass, role: "listbox", "aria-label": isYearGranularity ? t.chooseYear : monthLabel, onKeyDown: handleYearListKeyDown, children: yearList.map(year => {
10918
12098
  const isFocused = year === focusedYear;
10919
12099
  const isToday = year === todayYear;
12100
+ // As navigation the focused year is the current one. As the
12101
+ // selection surface the value's year is selected and focus roams.
12102
+ const isSelected = isYearGranularity
12103
+ ? !!value && value.getFullYear() === year
12104
+ : isFocused;
12105
+ const disabled = disabledYears.has(year);
10920
12106
  const cellCls = prefixedClassNames(classPrefix, 'dateinput-year-cell', {
10921
- 'is-selected': isFocused,
12107
+ 'is-selected': isSelected,
10922
12108
  'is-today': isToday,
12109
+ 'is-disabled': disabled,
10923
12110
  });
10924
- return (jsxRuntime.jsx("button", { type: "button", role: "option", "aria-selected": isFocused, "data-focused-year": isFocused ? 'true' : undefined, tabIndex: isFocused ? 0 : -1, className: cellCls, onClick: () => handleYearSelect(year), children: year }, year));
12111
+ return (jsxRuntime.jsx("button", { type: "button", role: "option", "aria-selected": isSelected, "aria-disabled": isYearGranularity ? disabled : undefined, "aria-current": isYearGranularity && isToday ? 'date' : undefined, "data-focused-year": isFocused ? 'true' : undefined, tabIndex: isFocused ? 0 : -1, disabled: disabled, className: cellCls, onClick: () => isYearGranularity
12112
+ ? handleYearPick(year)
12113
+ : handleYearSelect(year), children: year }, year));
10925
12114
  }) }))] }));
10926
12115
  };
10927
12116
  Calendar.displayName = 'Calendar';
10928
12117
 
10929
- const FOCUSABLE_SELECTOR = [
10930
- 'a[href]',
10931
- 'button:not([disabled])',
10932
- 'input:not([disabled]):not([type="hidden"])',
10933
- 'select:not([disabled])',
10934
- 'textarea:not([disabled])',
10935
- '[tabindex]:not([tabindex="-1"])',
10936
- ].join(',');
10937
- function useFocusTrap(containerRef, active, options = {}) {
10938
- const { initialFocusRef, restoreFocus = true } = options;
10939
- React.useEffect(() => {
10940
- if (!active)
10941
- return undefined;
10942
- const container = containerRef.current;
10943
- if (!container)
10944
- return undefined;
10945
- const previouslyFocused = typeof document !== 'undefined'
10946
- ? document.activeElement
10947
- : null;
10948
- const focusInitial = () => {
10949
- if (initialFocusRef?.current) {
10950
- initialFocusRef.current.focus();
10951
- return;
10952
- }
10953
- const focusables = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.hasAttribute('disabled'));
10954
- const target = focusables[0] ?? container;
10955
- target.focus();
10956
- };
10957
- focusInitial();
10958
- const handleKeyDown = (e) => {
10959
- if (e.key !== 'Tab')
10960
- return;
10961
- const focusables = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.hasAttribute('disabled'));
10962
- if (focusables.length === 0) {
10963
- e.preventDefault();
10964
- return;
10965
- }
10966
- const first = focusables[0];
10967
- const last = focusables[focusables.length - 1];
10968
- const activeEl = document.activeElement;
10969
- if (e.shiftKey && activeEl === first) {
10970
- e.preventDefault();
10971
- last.focus();
10972
- }
10973
- else if (!e.shiftKey && activeEl === last) {
10974
- e.preventDefault();
10975
- first.focus();
10976
- }
10977
- };
10978
- container.addEventListener('keydown', handleKeyDown);
10979
- return () => {
10980
- container.removeEventListener('keydown', handleKeyDown);
10981
- if (restoreFocus && previouslyFocused?.focus) {
10982
- previouslyFocused.focus();
10983
- }
10984
- };
10985
- }, [active, containerRef, initialFocusRef, restoreFocus]);
10986
- }
10987
-
10988
12118
  const isBrowser = typeof window !== 'undefined';
10989
- function resolveAuto(rect, panelWidth, panelHeight) {
10990
- const vw = window.innerWidth;
10991
- const vh = window.innerHeight;
10992
- const fitsBelow = rect.bottom + panelHeight + 4 <= vh;
10993
- const fitsRight = rect.left + panelWidth <= vw;
10994
- if (fitsBelow && fitsRight)
10995
- return 'bottom-left';
10996
- if (fitsBelow && !fitsRight)
10997
- return 'bottom-right';
10998
- if (!fitsBelow && fitsRight)
10999
- return 'top-left';
11000
- return 'top-right';
11001
- }
11002
12119
  const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'bottom-left', appendToBody = false, className, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, ariaLabelledBy, role = 'dialog', id, }) => {
11003
12120
  const panelRef = React.useRef(null);
11004
- const [resolved, setResolved] = React.useState({
11005
- position: position === 'auto' ? 'bottom-left' : position,
12121
+ const resolved = useAnchoredPosition(anchorRef, panelRef, {
12122
+ active: isOpen,
12123
+ position,
12124
+ fixed: appendToBody,
11006
12125
  });
11007
- const updatePosition = React.useCallback(() => {
11008
- if (!isBrowser || !isOpen)
11009
- return;
11010
- const anchor = anchorRef.current;
11011
- const panel = panelRef.current;
11012
- if (!anchor || !panel)
11013
- return;
11014
- const rect = anchor.getBoundingClientRect();
11015
- const panelRect = panel.getBoundingClientRect();
11016
- const w = panelRect.width;
11017
- const h = panelRect.height;
11018
- const finalPos = position === 'auto' ? resolveAuto(rect, w, h) : position;
11019
- if (!appendToBody) {
11020
- setResolved({ position: finalPos });
11021
- return;
11022
- }
11023
- let top = 0;
11024
- let left = 0;
11025
- switch (finalPos) {
11026
- case 'bottom-left':
11027
- top = rect.bottom + 4;
11028
- left = rect.left;
11029
- break;
11030
- case 'bottom-right':
11031
- top = rect.bottom + 4;
11032
- left = rect.right - w;
11033
- break;
11034
- case 'top-left':
11035
- top = rect.top - h - 4;
11036
- left = rect.left;
11037
- break;
11038
- case 'top-right':
11039
- top = rect.top - h - 4;
11040
- left = rect.right - w;
11041
- break;
11042
- }
11043
- setResolved({ position: finalPos, top, left });
11044
- }, [anchorRef, appendToBody, isOpen, position]);
11045
- React.useLayoutEffect(() => {
11046
- if (!isOpen)
11047
- return undefined;
11048
- updatePosition();
11049
- if (!isBrowser)
11050
- return undefined;
11051
- const handler = () => updatePosition();
11052
- window.addEventListener('resize', handler, { passive: true });
11053
- window.addEventListener('scroll', handler, {
11054
- passive: true,
11055
- capture: true,
11056
- });
11057
- return () => {
11058
- window.removeEventListener('resize', handler);
11059
- window.removeEventListener('scroll', handler, { capture: true });
11060
- };
11061
- }, [isOpen, updatePosition]);
11062
12126
  React.useEffect(() => {
11063
12127
  if (!isOpen || !closeOnClickOutside || !isBrowser)
11064
12128
  return undefined;
@@ -11089,7 +12153,7 @@ const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'botto
11089
12153
  document.addEventListener('keydown', handler);
11090
12154
  return () => document.removeEventListener('keydown', handler);
11091
12155
  }, [isOpen, closeOnEscape, onClose]);
11092
- useFocusTrap(panelRef, isOpen && trapFocus);
12156
+ useFocusTrap(panelRef, { active: isOpen && trapFocus });
11093
12157
  const panelClass = usePrefixedClassNames('picker-popover', {
11094
12158
  'is-active': isOpen,
11095
12159
  [`is-${resolved.position}`]: true,
@@ -11481,8 +12545,14 @@ const SEPARATOR_RE = /[/:\-.\s]/;
11481
12545
  * null and the input transparently falls back to free-form text entry.
11482
12546
  */
11483
12547
  function useSegmentedEntry(params) {
11484
- const { format, value, commitValue, formatFn, tryParse, text, setText, makeBaseDate, locale, min, max, isBlocked, disabled, readOnly, editable = true, popover = true, openOnFocus = true, closeOnSelect = false, isOpen, setOpen, inputRef, containerRef, onFocus, onClick, onKeyDown, onBlur, } = params;
11485
- const segmentMap = React.useMemo(() => (typeof format === 'string' ? buildSegmentMap(format) : null), [format]);
12548
+ const { format, skipKinds, value, commitValue, formatFn, tryParse, text, setText, makeBaseDate, locale, min, max, isBlocked, disabled, readOnly, editable = true, popover = true, openOnFocus = true, closeOnSelect = false, isOpen, setOpen, inputRef, containerRef, onFocus, onClick, onKeyDown, onBlur, } = params;
12549
+ const segmentMap = React.useMemo(() => {
12550
+ const map = typeof format === 'string' ? buildSegmentMap(format) : null;
12551
+ if (!map || !skipKinds)
12552
+ return map;
12553
+ const editable = map.editable.filter(i => !skipKinds.includes(map.segments[i].kind));
12554
+ return editable.length > 0 ? { ...map, editable } : null;
12555
+ }, [format, skipKinds]);
11486
12556
  const [activeSegmentIdx, setActiveSegmentIdx] = React.useState(null);
11487
12557
  // Buffer of digits typed within the current segment, cleared on segment
11488
12558
  // change. Kept in a ref because key handlers read+update synchronously
@@ -11786,13 +12856,64 @@ function useSegmentedEntry(params) {
11786
12856
  };
11787
12857
  }
11788
12858
 
11789
- const toIsoDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
11790
- const fromIsoDate = (s) => {
12859
+ const support = new Map();
12860
+ /**
12861
+ * Whether this browser implements `<input type={type}>`. A browser that
12862
+ * doesn't know a type reads it back as `'text'`, so this sets the type on a
12863
+ * detached input and checks what sticks. The answer can't change within a
12864
+ * page, so it is worked out once per type.
12865
+ *
12866
+ * It needs a DOM, so callers ask only after hydration. It also can't see a
12867
+ * browser that recognises a type without drawing a control for it: desktop
12868
+ * Safari reports `'month'` but shows a plain text box.
12869
+ */
12870
+ function supportsInputType(type) {
12871
+ let supported = support.get(type);
12872
+ if (supported === undefined) {
12873
+ const input = document.createElement('input');
12874
+ input.setAttribute('type', type);
12875
+ supported = input.type === type;
12876
+ support.set(type, supported);
12877
+ }
12878
+ return supported;
12879
+ }
12880
+
12881
+ const pad2$1 = (n) => String(n).padStart(2, '0');
12882
+ /**
12883
+ * The value as the native input and the hidden form input carry it:
12884
+ * `YYYY-MM-DD`, `YYYY-MM` or `YYYY`, the shapes `<input type="date">` and
12885
+ * `<input type="month">` use. The year is padded to four digits, as those
12886
+ * inputs require and as the `YYYY` token displays it.
12887
+ */
12888
+ const toIsoValue = (d, granularity) => {
12889
+ const year = String(d.getFullYear()).padStart(4, '0');
12890
+ if (granularity === 'year')
12891
+ return year;
12892
+ const month = `${year}-${pad2$1(d.getMonth() + 1)}`;
12893
+ return granularity === 'month' ? month : `${month}-${pad2$1(d.getDate())}`;
12894
+ };
12895
+ /** Read a native `type="date"` or `type="month"` value back into a Date. */
12896
+ const fromIsoValue = (s, granularity) => {
12897
+ if (granularity === 'month') {
12898
+ const m = /^(\d{4})-(\d{2})$/.exec(s);
12899
+ return m ? makeDate(Number(m[1]), Number(m[2]) - 1) : null;
12900
+ }
11791
12901
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
11792
12902
  if (!m)
11793
12903
  return null;
11794
12904
  return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
11795
12905
  };
12906
+ const DEFAULT_FORMATS = {
12907
+ day: DEFAULT_DATE_FORMAT,
12908
+ month: DEFAULT_MONTH_FORMAT,
12909
+ year: DEFAULT_YEAR_FORMAT,
12910
+ };
12911
+ // Segments finer than the granularity render but are never edited.
12912
+ const SKIPPED_SEGMENTS = {
12913
+ day: undefined,
12914
+ month: ['day'],
12915
+ year: ['month', 'day'],
12916
+ };
11796
12917
  /**
11797
12918
  * Raw DateInput — input + popover calendar without Field/Control wrapping.
11798
12919
  * Use `DateInput` for the convenience wrapper.
@@ -11802,8 +12923,10 @@ const fromIsoDate = (s) => {
11802
12923
  * @returns {JSX.Element}
11803
12924
  */
11804
12925
  const DateInputBase = React.forwardRef((props, ref) => {
11805
- const { value: controlledValue, defaultValue, onChange, onOpen, onClose, min, max, disabled, readOnly, placeholder, format, parse, locale, inline = false, mobileNative = 'auto', editable = true, popover = true, openOnFocus = true, closeOnSelect = true, position = 'bottom-left', appendToBody = false, color, size, isRounded, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, dayNames, monthNames, nearbyMonthDays = true, className, name, form, required, id, onFocus, onClick, onKeyDown, onBlur, iconLeftName: _iconLeftName, triggerIcon = true, triggerIconName = 'chevron-down', labels, ...rest } = props;
12926
+ const { value: controlledValue, defaultValue, onChange, onOpen, onClose, min, max, disabled, readOnly, placeholder, granularity = 'day', format, parse, locale, inline = false, mobileNative = 'auto', editable = true, popover = true, openOnFocus = true, closeOnSelect = true, position = 'bottom-left', appendToBody = false, color, size, isRounded, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, dayNames, monthNames, nearbyMonthDays = true, className, name, form, required, id, onFocus, onClick, onKeyDown, onBlur, iconLeftName: _iconLeftName, triggerIcon = true, triggerIconName = 'chevron-down', labels, ...rest } = props;
11806
12927
  const t = mergeLabels(labels);
12928
+ const isDayGranularity = granularity === 'day';
12929
+ const resolvedFormat = format ?? DEFAULT_FORMATS[granularity];
11807
12930
  const isControlled = controlledValue !== undefined;
11808
12931
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? null);
11809
12932
  const value = isControlled ? (controlledValue ?? null) : internalValue;
@@ -11818,7 +12941,7 @@ const DateInputBase = React.forwardRef((props, ref) => {
11818
12941
  setFocusedDate(prev => clampDate(prev, min, max));
11819
12942
  }, [min, max]);
11820
12943
  const [open, setOpenState] = React.useState(false);
11821
- const [text, setText] = React.useState(value ? formatDate(value, format, locale) : '');
12944
+ const [text, setText] = React.useState(value ? formatDate(value, resolvedFormat, locale) : '');
11822
12945
  const inputRef = React.useRef(null);
11823
12946
  const containerRef = React.useRef(null);
11824
12947
  const reactId = React.useId();
@@ -11827,7 +12950,14 @@ const DateInputBase = React.forwardRef((props, ref) => {
11827
12950
  const { shouldUseNative } = useNativeMobilePicker({
11828
12951
  force: mobileNative === 'auto' ? undefined : mobileNative,
11829
12952
  });
11830
- const useNative = !inline && shouldUseNative;
12953
+ // The day picker goes native as it always has. A month picker does only
12954
+ // where the browser implements the month input, asked after hydration so
12955
+ // the server and the first client render agree. HTML has no year input,
12956
+ // so a year picker always renders the calendar.
12957
+ const hydrated = useIsHydrated();
12958
+ const hasNativeInput = isDayGranularity ||
12959
+ (granularity === 'month' && hydrated && supportsInputType('month'));
12960
+ const useNative = !inline && shouldUseNative && hasNativeInput;
11831
12961
  const inputClass = usePrefixedClassNames('input', {
11832
12962
  [`is-${color}`]: !!color,
11833
12963
  [`is-${size}`]: !!size,
@@ -11848,24 +12978,31 @@ const DateInputBase = React.forwardRef((props, ref) => {
11848
12978
  }, [onOpen, onClose]);
11849
12979
  // Sync displayed text with value when value changes externally.
11850
12980
  React.useEffect(() => {
11851
- setText(value ? formatDate(value, format, locale) : '');
11852
- }, [value, format, locale]);
12981
+ setText(value ? formatDate(value, resolvedFormat, locale) : '');
12982
+ }, [value, resolvedFormat, locale]);
11853
12983
  const commitValue = React.useCallback((next) => {
12984
+ // A month or year value is its period's first day, however the
12985
+ // calendar, typing or parsing arrived at it.
12986
+ const committed = next && !isDayGranularity ? startOfPeriod(next, granularity) : next;
11854
12987
  if (!isControlled)
11855
- setInternalValue(next);
12988
+ setInternalValue(committed);
11856
12989
  // Keep the calendar's focused cell tracking typed / parsed values.
11857
- if (next)
11858
- setFocusedDate(next);
11859
- onChange?.(next);
11860
- }, [isControlled, onChange]);
12990
+ if (committed)
12991
+ setFocusedDate(committed);
12992
+ onChange?.(committed);
12993
+ }, [isControlled, onChange, isDayGranularity, granularity]);
12994
+ // At month or year granularity the bounds widen to the periods holding
12995
+ // them, so the month or year containing `min` stays reachable.
12996
+ const periodMin = React.useMemo(() => (min && !isDayGranularity ? startOfPeriod(min, granularity) : min), [min, isDayGranularity, granularity]);
12997
+ const periodMax = React.useMemo(() => (max && !isDayGranularity ? endOfPeriod(max, granularity) : max), [max, isDayGranularity, granularity]);
11861
12998
  const handleSelect = React.useCallback((d) => {
11862
- if (!isWithin(d, min, max))
12999
+ if (!isWithin(d, periodMin, periodMax))
11863
13000
  return;
11864
13001
  commitValue(d);
11865
13002
  setFocusedDate(d);
11866
13003
  if (closeOnSelect)
11867
13004
  setOpen(false);
11868
- }, [min, max, commitValue, closeOnSelect, setOpen]);
13005
+ }, [periodMin, periodMax, commitValue, closeOnSelect, setOpen]);
11869
13006
  const tryParse = React.useCallback((s) => {
11870
13007
  const trimmed = s.trim();
11871
13008
  if (!trimmed)
@@ -11873,37 +13010,72 @@ const DateInputBase = React.forwardRef((props, ref) => {
11873
13010
  const fmt = typeof format === 'string' ? format : undefined;
11874
13011
  return parse
11875
13012
  ? parse(trimmed)
11876
- : parseDate(trimmed, fmt ?? DEFAULT_DATE_FORMAT);
11877
- }, [parse, format, locale]);
13013
+ : parseDate(trimmed, fmt ?? DEFAULT_FORMATS[granularity]);
13014
+ }, [parse, format, locale, granularity]);
11878
13015
  // The Date the user edits when starting without a current value (today at
11879
- // midnight; the hook clamps to min/max on commit).
13016
+ // midnight, or the start of this month or year; the hook clamps to
13017
+ // min/max on commit).
11880
13018
  const makeBaseDate = React.useCallback(() => {
11881
13019
  const d = new Date();
11882
13020
  d.setHours(0, 0, 0, 0);
11883
- return d;
11884
- }, []);
13021
+ return isDayGranularity ? d : startOfPeriod(d, granularity);
13022
+ }, [isDayGranularity, granularity]);
11885
13023
  const inputReadOnlyAttr = !!readOnly || !editable;
11886
13024
  const canOpen = !!popover && !disabled && !readOnly;
11887
13025
  // Blocking predicate for manual entry, matching the calendar's
11888
- // disabled-cell logic (shouldDisableDate + unselectableDates by day).
13026
+ // disabled-cell logic (shouldDisableDate + unselectableDates by day, or
13027
+ // every day of the month or year blocked at those granularities).
11889
13028
  const isBlocked = React.useMemo(() => {
13029
+ if (!isDayGranularity) {
13030
+ const constraints = { min, max, shouldDisableDate, unselectableDates };
13031
+ return (d) => isPeriodUnselectable(d, granularity, constraints);
13032
+ }
11890
13033
  if (!shouldDisableDate && !unselectableDates?.length)
11891
13034
  return undefined;
11892
13035
  return (d) => !!shouldDisableDate?.(d) ||
11893
13036
  !!unselectableDates?.some(u => isSameDay(u, d));
11894
- }, [shouldDisableDate, unselectableDates]);
11895
- const { inputHandlers } = useSegmentedEntry({
11896
- format: format ?? DEFAULT_DATE_FORMAT,
13037
+ }, [
13038
+ isDayGranularity,
13039
+ granularity,
13040
+ min,
13041
+ max,
13042
+ shouldDisableDate,
13043
+ unselectableDates,
13044
+ ]);
13045
+ // Typing, and the re-parse on blur or Enter, that lands in the period the
13046
+ // value already holds commits nothing, so a `value` elsewhere in its month
13047
+ // or year isn't rewritten by focus passing through the field.
13048
+ const commitTyped = React.useCallback((next) => {
13049
+ if (next &&
13050
+ value &&
13051
+ !isDayGranularity &&
13052
+ startOfPeriod(next, granularity).getTime() ===
13053
+ startOfPeriod(value, granularity).getTime()) {
13054
+ setText(formatDate(value, resolvedFormat, locale));
13055
+ return;
13056
+ }
13057
+ commitValue(next);
13058
+ }, [
11897
13059
  value,
13060
+ isDayGranularity,
13061
+ granularity,
13062
+ resolvedFormat,
13063
+ locale,
11898
13064
  commitValue,
13065
+ ]);
13066
+ const { inputHandlers } = useSegmentedEntry({
13067
+ format: resolvedFormat,
13068
+ skipKinds: SKIPPED_SEGMENTS[granularity],
13069
+ value,
13070
+ commitValue: commitTyped,
11899
13071
  formatFn: formatDate,
11900
13072
  tryParse,
11901
13073
  text,
11902
13074
  setText,
11903
13075
  makeBaseDate,
11904
13076
  locale,
11905
- min,
11906
- max,
13077
+ min: periodMin,
13078
+ max: periodMax,
11907
13079
  isBlocked,
11908
13080
  disabled,
11909
13081
  readOnly,
@@ -11930,21 +13102,31 @@ const DateInputBase = React.forwardRef((props, ref) => {
11930
13102
  ref.current =
11931
13103
  node;
11932
13104
  }, [ref]);
11933
- // Native mobile path renders <input type="date">.
13105
+ // Native mobile path renders <input type="date"> (type="month" for a
13106
+ // month picker).
11934
13107
  if (useNative) {
11935
- return (jsxRuntime.jsx("input", { ...cleanRest, ref: combinedRef, type: "date", className: classNames(inputClass, bulmaHelperClasses, className), value: value ? toIsoDate(value) : '', onChange: e => {
11936
- const parsed = e.target.value ? fromIsoDate(e.target.value) : null;
13108
+ return (jsxRuntime.jsx("input", { ...cleanRest, ref: combinedRef, type: isDayGranularity ? 'date' : 'month', className: classNames(inputClass, bulmaHelperClasses, className), value: value ? toIsoValue(value, granularity) : '', onChange: e => {
13109
+ const parsed = e.target.value
13110
+ ? fromIsoValue(e.target.value, granularity)
13111
+ : null;
11937
13112
  commitValue(parsed);
11938
- }, min: min ? toIsoDate(min) : undefined, max: max ? toIsoDate(max) : undefined, disabled: disabled, readOnly: readOnly, placeholder: placeholder, name: name, form: form, required: required, id: id }));
11939
- }
11940
- const calendar = (jsxRuntime.jsx(Calendar, { value: value, focusedDate: focusedDate, onSelect: handleSelect, onFocusedDateChange: setFocusedDate, min: min, max: max, shouldDisableDate: shouldDisableDate, unselectableDates: unselectableDates, firstDayOfWeek: firstDayOfWeek, locale: locale, dayNames: dayNames, monthNames: monthNames, nearbyMonthDays: nearbyMonthDays, color: color, size: size, id: popoverId, autoFocusCell: open, labels: labels }));
13113
+ }, min: min ? toIsoValue(min, granularity) : undefined, max: max ? toIsoValue(max, granularity) : undefined, disabled: disabled, readOnly: readOnly, placeholder: placeholder, name: name, form: form, required: required, id: id }));
13114
+ }
13115
+ const chooseLabel = granularity === 'month'
13116
+ ? t.chooseMonth
13117
+ : granularity === 'year'
13118
+ ? t.chooseYear
13119
+ : t.chooseDate;
13120
+ const calendar = (jsxRuntime.jsx(Calendar
13121
+ // A new granularity starts the calendar over on its own view.
13122
+ , { granularity: granularity, value: value, focusedDate: focusedDate, onSelect: handleSelect, onFocusedDateChange: setFocusedDate, min: min, max: max, shouldDisableDate: shouldDisableDate, unselectableDates: unselectableDates, firstDayOfWeek: firstDayOfWeek, locale: locale, dayNames: dayNames, monthNames: monthNames, nearbyMonthDays: nearbyMonthDays, color: color, size: size, id: popoverId, autoFocusCell: open, labels: labels }, granularity));
11941
13123
  if (inline) {
11942
- return (jsxRuntime.jsxs("div", { ...cleanRest, ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), children: [calendar, name && (jsxRuntime.jsx("input", { type: "hidden", name: name, form: form, value: value ? toIsoDate(value) : '', required: required }))] }));
13124
+ return (jsxRuntime.jsxs("div", { ...cleanRest, ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), children: [calendar, name && (jsxRuntime.jsx("input", { type: "hidden", name: name, form: form, value: value ? toIsoValue(value, granularity) : '', required: required }))] }));
11943
13125
  }
11944
13126
  return (jsxRuntime.jsxs("div", { ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), ...cleanRest, children: [jsxRuntime.jsx("input", { ref: combinedRef, type: "text", role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": popoverId, autoComplete: "off", className: inputClass, value: text, placeholder: placeholder, disabled: disabled, readOnly: inputReadOnlyAttr, name: name, form: form, required: required, id: id, ...inputHandlers }), popover && triggerIcon && (jsxRuntime.jsx("button", { type: "button", className: triggerClass, onClick: () => {
11945
13127
  if (canOpen)
11946
13128
  setOpen(!open);
11947
- }, disabled: !canOpen, "aria-label": t.chooseDate, "aria-haspopup": "dialog", "aria-controls": popoverId, "aria-expanded": open, tabIndex: canOpen ? 0 : -1, children: jsxRuntime.jsx(Icon, { name: triggerIconName, size: size }) })), popover && (jsxRuntime.jsx(PickerPopover, { isOpen: open, onClose: handlePopoverClose, anchorRef: containerRef, position: position, appendToBody: appendToBody, ariaLabel: t.chooseDate, id: popoverId, children: calendar }))] }));
13129
+ }, disabled: !canOpen, "aria-label": chooseLabel, "aria-haspopup": "dialog", "aria-controls": popoverId, "aria-expanded": open, tabIndex: canOpen ? 0 : -1, children: jsxRuntime.jsx(Icon, { name: triggerIconName, size: size }) })), popover && (jsxRuntime.jsx(PickerPopover, { isOpen: open, onClose: handlePopoverClose, anchorRef: containerRef, position: position, appendToBody: appendToBody, ariaLabel: chooseLabel, id: popoverId, children: calendar }))] }));
11948
13130
  });
11949
13131
  DateInputBase.displayName = 'DateInputBase';
11950
13132
 
@@ -12917,6 +14099,9 @@ const TimeInputBase = React.forwardRef((props, ref) => {
12917
14099
  const mobileFooterClass = usePrefixedClassNames('timeinput-footer-mobile');
12918
14100
  const mobileFooterResetClass = usePrefixedClassNames('timeinput-footer-reset');
12919
14101
  const mobileFooterDoneClass = usePrefixedClassNames('timeinput-footer-done');
14102
+ // Set from the moment the popover starts to close until the close has
14103
+ // committed. See `setOpenFromInput`.
14104
+ const closingRef = React.useRef(false);
12920
14105
  const setOpen = React.useCallback((next) => {
12921
14106
  setOpenState(prev => {
12922
14107
  if (prev === next)
@@ -12926,11 +14111,26 @@ const TimeInputBase = React.forwardRef((props, ref) => {
12926
14111
  onOpen?.();
12927
14112
  }
12928
14113
  else {
14114
+ closingRef.current = true;
12929
14115
  onClose?.();
12930
14116
  }
12931
14117
  return next;
12932
14118
  });
12933
14119
  }, [onOpen, onClose, value]);
14120
+ // React runs a commit's effect cleanups before any of its effects, so on a
14121
+ // close this runs after the popover's focus trap has handed focus back.
14122
+ React.useEffect(() => {
14123
+ closingRef.current = false;
14124
+ }, [open]);
14125
+ // As the popover closes, its focus trap hands focus back to the input.
14126
+ // That focus is not the user arriving, so under `openOnFocus` it must not
14127
+ // open the popover again. The input asks to open only on focus, click or
14128
+ // ArrowDown, so a request while a close commits can only be that one.
14129
+ const setOpenFromInput = React.useCallback((next) => {
14130
+ if (next && closingRef.current)
14131
+ return;
14132
+ setOpen(next);
14133
+ }, [setOpen]);
12934
14134
  React.useEffect(() => {
12935
14135
  setText(value ? formatTime(value, defaultFormat, locale) : '');
12936
14136
  }, [value, defaultFormat, locale]);
@@ -12987,7 +14187,7 @@ const TimeInputBase = React.forwardRef((props, ref) => {
12987
14187
  openOnFocus,
12988
14188
  closeOnSelect,
12989
14189
  isOpen: open,
12990
- setOpen,
14190
+ setOpen: setOpenFromInput,
12991
14191
  inputRef,
12992
14192
  containerRef,
12993
14193
  onFocus,
@@ -14768,6 +15968,12 @@ exports.PanelIcon = PanelIcon;
14768
15968
  exports.PanelInputBlock = PanelInputBlock;
14769
15969
  exports.PanelTabs = PanelTabs;
14770
15970
  exports.Paragraph = Paragraph;
15971
+ exports.Popover = Popover;
15972
+ exports.PopoverBody = PopoverBody;
15973
+ exports.PopoverClose = PopoverClose;
15974
+ exports.PopoverFooter = PopoverFooter;
15975
+ exports.PopoverHeader = PopoverHeader;
15976
+ exports.Portal = Portal;
14771
15977
  exports.Pre = Pre;
14772
15978
  exports.Progress = Progress;
14773
15979
  exports.Radio = Radio;
@@ -14833,6 +16039,7 @@ exports.useColorClasses = useColorClasses;
14833
16039
  exports.useColorStyles = useColorStyles;
14834
16040
  exports.useConfig = useConfig;
14835
16041
  exports.useFlexboxClasses = useFlexboxClasses;
16042
+ exports.useFocusTrap = useFocusTrap;
14836
16043
  exports.useIconLibrary = useIconLibrary;
14837
16044
  exports.useInsideControl = useInsideControl;
14838
16045
  exports.useInsideField = useInsideField;