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