@allxsmith/bestax-bulma 5.28.0 → 5.30.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 (94) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/esm/components/ContextMenu.js +244 -0
  4. package/dist/esm/components/ContextMenu.js.map +1 -0
  5. package/dist/esm/components/Dialog.js +8 -4
  6. package/dist/esm/components/Dialog.js.map +1 -1
  7. package/dist/esm/components/HoverCard.js +3 -10
  8. package/dist/esm/components/HoverCard.js.map +1 -1
  9. package/dist/esm/components/Modal.js +19 -11
  10. package/dist/esm/components/Modal.js.map +1 -1
  11. package/dist/esm/components/Popover.js.map +1 -1
  12. package/dist/esm/components/Sidebar.js +12 -3
  13. package/dist/esm/components/Sidebar.js.map +1 -1
  14. package/dist/esm/components/Toast.js +5 -3
  15. package/dist/esm/components/Toast.js.map +1 -1
  16. package/dist/esm/components/Tooltip.js +36 -61
  17. package/dist/esm/components/Tooltip.js.map +1 -1
  18. package/dist/esm/elements/Button.js.map +1 -1
  19. package/dist/esm/elements/Notification.js +3 -1
  20. package/dist/esm/elements/Notification.js.map +1 -1
  21. package/dist/esm/elements/ToggleButton.js +60 -0
  22. package/dist/esm/elements/ToggleButton.js.map +1 -0
  23. package/dist/esm/form/DateInputBase.js.map +1 -1
  24. package/dist/esm/form/DateRangeInputBase.js.map +1 -1
  25. package/dist/esm/form/DateTimeInputBase.js.map +1 -1
  26. package/dist/esm/form/TimeInputBase.js.map +1 -1
  27. package/dist/esm/form/_pickerInternals/PickerPopover.js +3 -1
  28. package/dist/esm/form/_pickerInternals/PickerPopover.js.map +1 -1
  29. package/dist/esm/form/_pickerInternals/pickerTypes.js +14 -6
  30. package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -1
  31. package/dist/esm/helpers/Config.js +5 -2
  32. package/dist/esm/helpers/Config.js.map +1 -1
  33. package/dist/esm/helpers/portal.js +30 -5
  34. package/dist/esm/helpers/portal.js.map +1 -1
  35. package/dist/esm/helpers/useContextMenuTrigger.js +166 -0
  36. package/dist/esm/helpers/useContextMenuTrigger.js.map +1 -0
  37. package/dist/esm/helpers/useHoverOpen.js +40 -6
  38. package/dist/esm/helpers/useHoverOpen.js.map +1 -1
  39. package/dist/esm/helpers/useMenuKeys.js +182 -0
  40. package/dist/esm/helpers/useMenuKeys.js.map +1 -0
  41. package/dist/extras.css +1 -1
  42. package/dist/extras.css.map +1 -1
  43. package/dist/index.cjs +2150 -1474
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.esm.js +2 -0
  46. package/dist/index.esm.js.map +1 -1
  47. package/dist/types/components/ContextMenu.d.ts +66 -0
  48. package/dist/types/components/HoverCard.d.ts +14 -12
  49. package/dist/types/components/Modal.d.ts +2 -1
  50. package/dist/types/components/Popover.d.ts +8 -7
  51. package/dist/types/components/Tooltip.d.ts +45 -4
  52. package/dist/types/elements/Button.d.ts +1 -1
  53. package/dist/types/elements/ToggleButton.d.ts +90 -0
  54. package/dist/types/form/DateInputBase.d.ts +4 -1
  55. package/dist/types/form/DateRangeInputBase.d.ts +4 -1
  56. package/dist/types/form/DateTimeInputBase.d.ts +8 -2
  57. package/dist/types/form/TimeInputBase.d.ts +7 -3
  58. package/dist/types/form/_pickerInternals/pickerTypes.d.ts +16 -1
  59. package/dist/types/helpers/Config.d.ts +34 -3
  60. package/dist/types/helpers/portal.d.ts +27 -5
  61. package/dist/types/helpers/useContextMenuTrigger.d.ts +67 -0
  62. package/dist/types/helpers/useHoverOpen.d.ts +19 -3
  63. package/dist/types/helpers/useMenuKeys.d.ts +57 -0
  64. package/dist/types/index.d.ts +2 -0
  65. package/dist/types-cjs/components/ContextMenu.d.ts +66 -0
  66. package/dist/types-cjs/components/HoverCard.d.ts +14 -12
  67. package/dist/types-cjs/components/Modal.d.ts +2 -1
  68. package/dist/types-cjs/components/Popover.d.ts +8 -7
  69. package/dist/types-cjs/components/Tooltip.d.ts +45 -4
  70. package/dist/types-cjs/elements/Button.d.ts +1 -1
  71. package/dist/types-cjs/elements/ToggleButton.d.ts +90 -0
  72. package/dist/types-cjs/form/DateInputBase.d.ts +4 -1
  73. package/dist/types-cjs/form/DateRangeInputBase.d.ts +4 -1
  74. package/dist/types-cjs/form/DateTimeInputBase.d.ts +8 -2
  75. package/dist/types-cjs/form/TimeInputBase.d.ts +7 -3
  76. package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +16 -1
  77. package/dist/types-cjs/helpers/Config.d.ts +34 -3
  78. package/dist/types-cjs/helpers/portal.d.ts +27 -5
  79. package/dist/types-cjs/helpers/useContextMenuTrigger.d.ts +67 -0
  80. package/dist/types-cjs/helpers/useHoverOpen.d.ts +19 -3
  81. package/dist/types-cjs/helpers/useMenuKeys.d.ts +57 -0
  82. package/dist/types-cjs/index.d.ts +2 -0
  83. package/dist/versions/bestax-no-dark-mode.css +1 -1
  84. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  85. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  86. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  87. package/dist/versions/bestax-no-helpers.css +1 -1
  88. package/dist/versions/bestax-no-helpers.css.map +1 -1
  89. package/dist/versions/bestax-prefixed.css +1 -1
  90. package/dist/versions/bestax-prefixed.css.map +1 -1
  91. package/package.json +1 -1
  92. package/src/scss/components/_contextmenu.scss +50 -0
  93. package/src/scss/components/_index.scss +1 -0
  94. package/src/scss/components/_tooltip.scss +76 -1
package/dist/index.cjs CHANGED
@@ -16,6 +16,9 @@ const useConfig = () => React.useContext(ConfigContext);
16
16
  /**
17
17
  * ConfigProvider injects configuration into all child components via React context.
18
18
  *
19
+ * A nested provider replaces the outer one's settings rather than adding to
20
+ * them, so inside it any setting it leaves out is back to its default.
21
+ *
19
22
  * @function
20
23
  * @param {ConfigProviderProps} props - Provider props.
21
24
  * @returns {JSX.Element} The context provider wrapping children.
@@ -25,8 +28,8 @@ const useConfig = () => React.useContext(ConfigContext);
25
28
  * <App />
26
29
  * </ConfigProvider>
27
30
  */
28
- const ConfigProvider = ({ classPrefix, iconLibrary, children, }) => {
29
- return (jsxRuntime.jsx(ConfigContext.Provider, { value: { classPrefix, iconLibrary }, children: children }));
31
+ const ConfigProvider = ({ classPrefix, iconLibrary, portalContainer, children, }) => {
32
+ return (jsxRuntime.jsx(ConfigContext.Provider, { value: { classPrefix, iconLibrary, portalContainer }, children: children }));
30
33
  };
31
34
  /**
32
35
  * Utility hook to get the configured class prefix string.
@@ -2934,7 +2937,26 @@ function resolvePortalContainer(container) {
2934
2937
  return container;
2935
2938
  }
2936
2939
  /**
2937
- * 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.
2940
+ * Where a component's portal goes, as a function to call when the portal is
2941
+ * created: the target the component was given, else the `portalContainer` a
2942
+ * `ConfigProvider` sets, else `document.body`, each read as
2943
+ * `resolvePortalContainer` reads it. A component that portals resolves its
2944
+ * target through this, so the provider's default reaches it.
2945
+ *
2946
+ * The hook only reads context, so it is safe to call on the server, ahead of
2947
+ * any early return. The function it returns reads the DOM, so call it only
2948
+ * where the portal is created, which the server never reaches.
2949
+ *
2950
+ * @function usePortalContainer
2951
+ * @returns A function that takes the component's own target, if any, and
2952
+ * returns the DOM node to portal into.
2953
+ */
2954
+ function usePortalContainer() {
2955
+ const { portalContainer } = useConfig();
2956
+ return container => resolvePortalContainer(container || portalContainer);
2957
+ }
2958
+ /**
2959
+ * Renders its children into another part of the page, `document.body` unless `container` or a `ConfigProvider` says otherwise, so floating content escapes an ancestor's `overflow`, `transform` or stacking context.
2938
2960
  *
2939
2961
  * React's server renderer can't render a portal, so `Portal` renders nothing
2940
2962
  * on the server and during hydration, and mounts its children in the commit
@@ -2945,8 +2967,12 @@ function resolvePortalContainer(container) {
2945
2967
  * portaled children, and their React events bubble through the React tree.
2946
2968
  * CSS inheritance follows the DOM instead: a scoped (non-`isRoot`) `Theme`
2947
2969
  * sets its variables on a wrapper element the portaled content has left, so
2948
- * that content sees the page-level theme. Point `container` at an element
2949
- * inside the themed area to keep it.
2970
+ * that content sees the page-level theme, and styles scoped to a shadow root
2971
+ * don't reach `document.body` at all. To keep them, portal into an element
2972
+ * inside the themed area or the shadow root: pass it as `container`, or as
2973
+ * the `portalContainer` of a `ConfigProvider` around that area. The second
2974
+ * also reaches what the library's components portal, such as Popover's
2975
+ * `appendToBody` panel or a `Modal` with `portal`.
2950
2976
  *
2951
2977
  * Tab order and focus follow the DOM too. Content portaled from inside a
2952
2978
  * focus trap is no longer inside the trapped element, so the trap neither
@@ -2972,11 +2998,12 @@ function resolvePortalContainer(container) {
2972
2998
  */
2973
2999
  function Portal({ children, container, disabled = false }) {
2974
3000
  const hydrated = useIsHydrated();
3001
+ const resolveContainer = usePortalContainer();
2975
3002
  if (disabled)
2976
3003
  return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
2977
3004
  if (!hydrated)
2978
3005
  return null;
2979
- return reactDom.createPortal(children, resolvePortalContainer(container));
3006
+ return reactDom.createPortal(children, resolveContainer(container));
2980
3007
  }
2981
3008
 
2982
3009
  /**
@@ -3131,7 +3158,7 @@ const ModalClose = ({ className, size = 'large', variant = 'delete', ...props })
3131
3158
  * @see {@link https://bulma.io/documentation/components/modal/ | Bulma Modal documentation}
3132
3159
  */
3133
3160
  const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClose, className, textColor, bgColor, modalCardTitle, modalCardFoot, type, children, closeOnEscape = true, lockScroll = true, portal = false, role, 'aria-modal': ariaModalProp, ...props }, ref) {
3134
- const { classPrefix } = useConfig();
3161
+ const { classPrefix, portalContainer } = useConfig();
3135
3162
  const { bulmaHelperClasses, rest } = useBulmaClasses({
3136
3163
  color: textColor,
3137
3164
  backgroundColor: bgColor,
@@ -3181,10 +3208,17 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
3181
3208
  // hydrating render have to stay inline and only move into the portal once
3182
3209
  // we're past hydration.
3183
3210
  const onClient = useIsHydrated();
3184
- // Moving into the portal remounts the modal's subtree, so any effect holding
3185
- // a DOM node from before the move is holding a detached one. Effects that
3186
- // touch the modal's nodes key on this so they re-run against the new tree.
3211
+ const resolveContainer = usePortalContainer();
3212
+ // Moving into the portal, or from one portal target to another, remounts
3213
+ // the modal's subtree, so any effect holding a DOM node from before the move
3214
+ // is holding a detached one. Effects that touch the modal's nodes key on
3215
+ // this, on `portal` and on `defaultTarget`, so they re-run against the new
3216
+ // tree.
3187
3217
  const isPortaled = Boolean(portal) && onClient;
3218
+ // The configured `portalContainer` only places a modal portaled with
3219
+ // `portal` set to `true`. In place, or with a target of its own, a change
3220
+ // to it moves nothing, so it mustn't re-run the effects either.
3221
+ const defaultTarget = isPortaled && portal === true ? portalContainer : undefined;
3188
3222
  useScrollLock(isModalActive && lockScroll);
3189
3223
  // Record open order so keyboard handling only applies to the topmost modal.
3190
3224
  React.useEffect(() => {
@@ -3264,11 +3298,12 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
3264
3298
  previouslyFocusedRef.current?.focus?.();
3265
3299
  };
3266
3300
  // `isPortaled` flips once when a hydrated portal modal moves out of the
3267
- // inline tree; re-running rebinds focus onto the remounted nodes. The
3268
- // cleanup above restores focus to the pre-open element first (the detached
3269
- // subtree has left focus on <body>), so the re-run re-records the same
3270
- // element and restore-on-close still lands in the right place.
3271
- }, [isModalActive, isPortaled]);
3301
+ // inline tree, and `portal` or `defaultTarget` changing moves a portaled
3302
+ // one to another target; re-running rebinds focus onto the remounted
3303
+ // nodes. The cleanup above restores focus to the pre-open element first
3304
+ // (the detached subtree has left focus on <body>), so the re-run re-records
3305
+ // the same element and restore-on-close still lands in the right place.
3306
+ }, [isModalActive, isPortaled, portal, defaultTarget]);
3272
3307
  // Check if children contain compound components
3273
3308
  const hasCompoundComponents = React.Children.toArray(children).some(child => React.isValidElement(child) &&
3274
3309
  (child.type === ModalBackground ||
@@ -3305,7 +3340,7 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
3305
3340
  modalElement = (jsxRuntime.jsxs("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, "aria-labelledby": titleId, tabIndex: -1, ...rest, "data-testid": "modal", children: [jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxRuntime.jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxRuntime.jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsxRuntime.jsx("p", { id: titleId, className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsxRuntime.jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsxRuntime.jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsxRuntime.jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsxRuntime.jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
3306
3341
  }
3307
3342
  if (isPortaled) {
3308
- const target = resolvePortalContainer(typeof portal === 'boolean' ? undefined : portal);
3343
+ const target = resolveContainer(typeof portal === 'boolean' ? undefined : portal);
3309
3344
  return reactDom.createPortal(modalElement, target);
3310
3345
  }
3311
3346
  return modalElement;
@@ -4860,1551 +4895,1552 @@ const CollapsesComponent = ({ multiple = false, value, defaultValue, onChange, s
4860
4895
  };
4861
4896
  const Collapses = withSubComponents(CollapsesComponent, { Collapse }, 'Collapses');
4862
4897
 
4898
+ // INTERNAL: deliberately not exported from src/index.ts.
4863
4899
  /**
4864
- * The `Tooltip` component displays helpful information when users hover over or focus on an element.
4865
- *
4866
- * @function
4867
- * @param {TooltipProps} props - Props for the Tooltip component.
4868
- * @returns {JSX.Element} The rendered tooltip wrapper.
4869
- *
4870
- * @example
4871
- * // Basic tooltip
4872
- * <Tooltip label="This is helpful info">
4873
- * <button>Hover me</button>
4874
- * </Tooltip>
4875
- *
4876
- * @example
4877
- * // Positioned tooltip with color
4878
- * <Tooltip label="Delete item" position="right" color="danger">
4879
- * <Icon icon="fas fa-trash" />
4880
- * </Tooltip>
4881
- *
4882
- * @example
4883
- * // Multiline tooltip
4884
- * <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
4885
- * <span>?</span>
4886
- * </Tooltip>
4900
+ * `useLayoutEffect` in the browser, `useEffect` on the server. A server
4901
+ * render runs neither, and React 18 warns about a layout effect there, so the
4902
+ * server is handed the plain one.
4887
4903
  */
4888
- const Tooltip = ({ label, content, position = 'top', color, size, active: alwaysActive, multiline = false, animated = true, square = false, dashed = false, delay = 0, closeDelay = 0, children, className, tooltipClassName, ...props }) => {
4889
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4890
- const [isVisible, setIsVisible] = React.useState(false);
4891
- const [isHovering, setIsHovering] = React.useState(false);
4892
- const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
4893
- const [contentStyle, setContentStyle] = React.useState({});
4894
- const openTimeoutRef = React.useRef(null);
4895
- const closeTimeoutRef = React.useRef(null);
4896
- const wrapperRef = React.useRef(null);
4897
- const contentRef = React.useRef(null);
4898
- // Clear timeouts on unmount
4899
- React.useEffect(() => {
4900
- return () => {
4901
- if (openTimeoutRef.current) {
4902
- clearTimeout(openTimeoutRef.current);
4903
- }
4904
- if (closeTimeoutRef.current) {
4905
- clearTimeout(closeTimeoutRef.current);
4906
- }
4907
- };
4908
- }, []);
4909
- // Handle delayed visibility
4910
- React.useEffect(() => {
4911
- if (isHovering) {
4912
- if (closeTimeoutRef.current) {
4913
- clearTimeout(closeTimeoutRef.current);
4914
- closeTimeoutRef.current = null;
4915
- }
4916
- if (delay > 0) {
4917
- openTimeoutRef.current = setTimeout(() => {
4918
- setIsVisible(true);
4919
- }, delay);
4920
- }
4921
- else {
4922
- // eslint-disable-next-line react-hooks/set-state-in-effect -- show immediately on hover when no delay
4923
- setIsVisible(true);
4924
- }
4925
- }
4926
- else {
4927
- if (openTimeoutRef.current) {
4928
- clearTimeout(openTimeoutRef.current);
4929
- openTimeoutRef.current = null;
4930
- }
4931
- if (closeDelay > 0) {
4932
- closeTimeoutRef.current = setTimeout(() => {
4933
- setIsVisible(false);
4934
- }, closeDelay);
4935
- }
4936
- else {
4937
- setIsVisible(false);
4938
- }
4939
- }
4940
- }, [isHovering, delay, closeDelay]);
4941
- // Auto-placement and overflow correction
4942
- React.useLayoutEffect(() => {
4943
- const isAuto = position === 'auto';
4944
- if (!isAuto) {
4945
- // eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
4946
- setResolvedPosition(position);
4947
- }
4948
- const showing = isVisible || alwaysActive;
4949
- if (!showing) {
4950
- setContentStyle({});
4951
- return;
4952
- }
4953
- const wrapper = wrapperRef.current;
4954
- const content = contentRef.current;
4955
- if (!wrapper || !content)
4956
- return;
4957
- const wrapperRect = wrapper.getBoundingClientRect();
4958
- const margin = 40;
4959
- const edgePadding = 8; // minimum distance from viewport edge
4960
- // Step 1: determine position (auto mode only)
4961
- if (isAuto) {
4962
- const spaceTop = wrapperRect.top;
4963
- const spaceBottom = window.innerHeight - wrapperRect.bottom;
4964
- if (spaceTop >= margin) {
4965
- setResolvedPosition('top');
4966
- }
4967
- else if (spaceBottom >= margin) {
4968
- setResolvedPosition('bottom');
4969
- }
4970
- else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
4971
- setResolvedPosition('right');
4972
- }
4973
- else {
4974
- setResolvedPosition('left');
4975
- }
4976
- }
4977
- // Step 2: measure tooltip content and correct overflow.
4978
- // Use requestAnimationFrame so the resolved position class is applied first.
4979
- requestAnimationFrame(() => {
4980
- const contentEl = contentRef.current;
4981
- if (!contentEl)
4982
- return;
4983
- const contentRect = contentEl.getBoundingClientRect();
4984
- const style = {};
4985
- // For top/bottom positions, tooltip is centered horizontally via CSS
4986
- // (left: 50%; transform: translateX(-50%)).
4987
- // Check if it overflows left or right and apply a corrective shift.
4988
- const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
4989
- const effectivePos = pos || 'top'; // fallback, actual is set by state
4990
- const isVertical = effectivePos === 'top' ||
4991
- effectivePos === 'bottom' ||
4992
- resolvedPosition === 'top' ||
4993
- resolvedPosition === 'bottom';
4994
- if (isVertical) {
4995
- if (contentRect.left < edgePadding) {
4996
- // Overflows left — shift right
4997
- const shift = edgePadding - contentRect.left;
4998
- style.left = `calc(50% + ${shift}px)`;
4999
- style['--tooltip-arrow-offset'] =
5000
- `${-shift}px`;
5001
- }
5002
- else if (contentRect.right > window.innerWidth - edgePadding) {
5003
- // Overflows right — shift left
5004
- const shift = contentRect.right - (window.innerWidth - edgePadding);
5005
- style.left = `calc(50% - ${shift}px)`;
5006
- style['--tooltip-arrow-offset'] =
5007
- `${shift}px`;
5008
- }
5009
- }
5010
- else {
5011
- // For left/right positions, tooltip is centered vertically via CSS
5012
- // (top: 50%; transform: translateY(-50%)).
5013
- if (contentRect.top < edgePadding) {
5014
- const shift = edgePadding - contentRect.top;
5015
- style.top = `calc(50% + ${shift}px)`;
5016
- style['--tooltip-arrow-offset'] =
5017
- `${-shift}px`;
5018
- }
5019
- else if (contentRect.bottom > window.innerHeight - edgePadding) {
5020
- const shift = contentRect.bottom - (window.innerHeight - edgePadding);
5021
- style.top = `calc(50% - ${shift}px)`;
5022
- style['--tooltip-arrow-offset'] =
5023
- `${shift}px`;
5024
- }
5025
- }
5026
- setContentStyle(style);
5027
- });
5028
- }, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
5029
- const handleMouseEnter = React.useCallback(() => {
5030
- setIsHovering(true);
5031
- }, []);
5032
- const handleMouseLeave = React.useCallback(() => {
5033
- setIsHovering(false);
5034
- }, []);
5035
- const handleFocus = React.useCallback(() => {
5036
- setIsHovering(true);
5037
- }, []);
5038
- const handleBlur = React.useCallback(() => {
5039
- setIsHovering(false);
5040
- }, []);
5041
- // Generate Bulma classes
5042
- const tooltipClasses = usePrefixedClassNames('tooltip', {
5043
- 'is-active': alwaysActive || isVisible,
5044
- [`is-${resolvedPosition}`]: resolvedPosition,
5045
- [`is-${color}`]: color,
5046
- [`is-${size}`]: size,
5047
- 'is-multiline': multiline,
5048
- 'is-animated': animated,
5049
- 'is-square': square,
5050
- 'is-dashed': dashed,
5051
- });
5052
- const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
5053
- const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
5054
- return (jsxRuntime.jsxs("span", { ref: wrapperRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleFocus, onBlur: handleBlur, ...(label ? { 'data-tooltip': label } : {}), ...rest, children: [children, jsxRuntime.jsx("span", { ref: contentRef, className: combinedTooltipClasses, role: "tooltip", "aria-hidden": !isVisible && !alwaysActive, style: Object.keys(contentStyle).length > 0 ? contentStyle : undefined, children: content || label })] }));
5055
- };
4904
+ const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5056
4905
 
4906
+ // INTERNAL: deliberately not exported from src/index.ts.
5057
4907
  /**
5058
- * Whether `el` is editable while its parent is not, which makes it an editing
5059
- * host: the one tab stop an editable region gets. `contenteditable="false"`
5060
- * turns editing off, and a missing or unknown value inherits the parent's.
4908
+ * Runs the caller's own handler on the element that holds the trigger, then
4909
+ * the hook's, so neither replaces the other.
5061
4910
  */
5062
- function isEditingHost(el) {
5063
- const editable = (node) => {
5064
- const value = node.getAttribute('contenteditable')?.toLowerCase();
5065
- if (value === undefined)
5066
- return null;
5067
- if (value === '' || value === 'true' || value === 'plaintext-only') {
5068
- return true;
5069
- }
5070
- return value === 'false' ? false : null;
4911
+ function chain$1(own, ours) {
4912
+ return event => {
4913
+ own?.(event);
4914
+ ours(event);
5071
4915
  };
5072
- if (editable(el) !== true)
5073
- return false;
5074
- for (let node = el.parentElement; node; node = node.parentElement) {
5075
- const state = editable(node);
5076
- if (state !== null)
5077
- return !state;
5078
- }
5079
- return true;
5080
4916
  }
5081
- /** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
5082
- const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
5083
- Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
5084
4917
  /**
5085
- * Whether the browser puts `el` in the tab order, before anything that hides
5086
- * or disables it. The `tabIndex` property can't answer this on its own: it
5087
- * can read `-1` for an editable region or an `<embed>` that Tab still visits,
5088
- * and `0` for a `<video>` without controls that it doesn't. So a valid
5089
- * `tabindex` attribute decides, and otherwise the element's kind does.
5090
- * Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
4918
+ * Whether focus that just arrived on `el` is keyboard focus, as the browser
4919
+ * decides it for `:focus-visible`: focus from a key or from the browser's
4920
+ * own controls is, focus a press brings isn't, and focus a script moves
4921
+ * follows whichever came last. A browser that can't match the selector
4922
+ * throws, and then every focus counts, so keyboard users can still open it.
5091
4923
  */
5092
- function isTabbableKind(el) {
5093
- const tabindex = el.getAttribute('tabindex');
5094
- if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
5095
- return el.tabIndex >= 0;
4924
+ function isKeyboardFocus(el) {
4925
+ try {
4926
+ return el.matches(':focus-visible');
5096
4927
  }
5097
- switch (el.localName) {
5098
- case 'a':
5099
- case 'area':
5100
- return el.hasAttribute('href');
5101
- case 'input':
5102
- return el.type !== 'hidden';
5103
- case 'button':
5104
- case 'select':
5105
- case 'textarea':
5106
- case 'iframe':
5107
- case 'embed':
5108
- case 'object':
5109
- return true;
5110
- case 'audio':
5111
- case 'video':
5112
- return el.hasAttribute('controls');
5113
- case 'summary':
5114
- return isDetailsSummary(el);
5115
- default:
5116
- return isEditingHost(el);
4928
+ catch {
4929
+ return true;
5117
4930
  }
5118
4931
  }
4932
+ /** Whether `node` is `container` or inside it, when there are both. */
4933
+ const within = (container, node) => Boolean(container && node && container.contains(node));
5119
4934
  /**
5120
- * The node's parent in the flattened tree, the one the browser renders and
5121
- * tabs through: the slot it is assigned to, else its parent, else the host of
5122
- * the shadow root it tops, and nothing above the document. A closed shadow
5123
- * root hides its slots (`assignedSlot` reads `null` under one), so a child of
5124
- * its host goes straight to the host, whether a slot takes it or not.
4935
+ * Per document, the hooks that want Escape and the one listener they share
4936
+ * while there are any, so a card inside another card doesn't add a second
4937
+ * listener that closes both on one keypress.
5125
4938
  */
5126
- const flatParent = (node) => node.assignedSlot ??
5127
- node.parentElement ??
5128
- node.parentNode?.host ??
5129
- null;
4939
+ const escapeListeners = new Map();
4940
+ /** Counts openings across the page, so the most recent one is known. */
4941
+ let openings = 0;
5130
4942
  /**
5131
- * The element's children in the flattened tree, in the order they render: a
5132
- * host's shadow root content, a slot's assigned elements (or its fallback
5133
- * content when nothing is assigned), anything else's own children. A host
5134
- * whose shadow root is closed has no root to read, so its own children stand
5135
- * in, including any that no slot takes and that aren't rendered.
4943
+ * Adds `entry` to the hooks that want Escape on `doc`, starting the shared
4944
+ * listener when it is the first.
4945
+ *
4946
+ * Escape takes back every open that is on its way, so nothing appears right
4947
+ * after it, and closes one open floating element, chosen by these rules in
4948
+ * turn:
4949
+ *
4950
+ * 1. One that focus is in, on its trigger or inside it, when there is one.
4951
+ * 2. The innermost: one whose floating element holds no other's trigger.
4952
+ * That follows nesting whether either one is portaled, since an inner
4953
+ * trigger sits in the outer floating element either way.
4954
+ * 3. The one that opened last.
4955
+ *
4956
+ * The keypress belongs to whatever has focus. When that is one of the open
4957
+ * floating elements or their triggers, or nothing, the listener stops it in
4958
+ * the capture phase, before anything around (a Popover, a Modal) sees it,
4959
+ * unless the one it closes was set not to consume Escape.
4960
+ * When focus is anywhere else, such as a combobox or a dialog of its own,
4961
+ * the floating element still closes, since hover put it there without
4962
+ * focus, and the key goes on to that owner.
4963
+ *
4964
+ * @returns What to call when the hook stops wanting Escape.
5136
4965
  */
5137
- function flatChildren(el) {
5138
- if (el.shadowRoot)
5139
- return Array.from(el.shadowRoot.children);
5140
- if (el.localName === 'slot') {
5141
- const assigned = el.assignedNodes();
5142
- if (assigned.length > 0) {
5143
- return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
5144
- }
4966
+ function wantEscape(doc, entry) {
4967
+ let listener = escapeListeners.get(doc);
4968
+ if (!listener) {
4969
+ const entries = new Set();
4970
+ const handleKeyDown = (event) => {
4971
+ if (event.key !== 'Escape')
4972
+ return;
4973
+ const open = [];
4974
+ // A copy, since taking an open back can take its hook out of the set.
4975
+ for (const each of [...entries]) {
4976
+ if (each.openedAt)
4977
+ open.push(each);
4978
+ else
4979
+ each.cancel();
4980
+ }
4981
+ const focused = getDeepestActiveElement(doc);
4982
+ const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
4983
+ const pool = holding.length > 0 ? holding : open;
4984
+ let top;
4985
+ for (const each of pool) {
4986
+ const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
4987
+ if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
4988
+ top = each;
4989
+ }
4990
+ }
4991
+ if (!top)
4992
+ return;
4993
+ if (top.consumes() &&
4994
+ (holding.length > 0 || !focused || focused === doc.body)) {
4995
+ event.preventDefault();
4996
+ event.stopPropagation();
4997
+ }
4998
+ top.close();
4999
+ };
5000
+ doc.addEventListener('keydown', handleKeyDown, true);
5001
+ listener = {
5002
+ entries,
5003
+ stop: () => {
5004
+ doc.removeEventListener('keydown', handleKeyDown, true);
5005
+ escapeListeners.delete(doc);
5006
+ },
5007
+ };
5008
+ escapeListeners.set(doc, listener);
5145
5009
  }
5146
- return Array.from(el.children);
5010
+ const shared = listener;
5011
+ shared.entries.add(entry);
5012
+ return () => {
5013
+ shared.entries.delete(entry);
5014
+ if (shared.entries.size === 0)
5015
+ shared.stop();
5016
+ };
5147
5017
  }
5148
- const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
5149
- const isVisible = (el) => {
5150
- const { visibility } = getComputedStyle(el);
5151
- return visibility !== 'hidden' && visibility !== 'collapse';
5152
- };
5153
5018
  /**
5154
- * Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
5155
- * (not inherited, so each element is checked) or a closed `<details>` it
5156
- * isn't the summary of. `top` itself isn't checked, and `null` walks to the
5157
- * top of the page.
5019
+ * Decides when a floating element that opens on hover or keyboard focus asks
5020
+ * to open and to close. It renders nothing, and leaves roles, ARIA and
5021
+ * placement to the component that calls it.
5022
+ *
5023
+ * - The pointer entering, or keyboard focus arriving on the trigger, asks to
5024
+ * open after `openDelay`. A touch pointer doesn't count. Neither does focus
5025
+ * that a press brings to the trigger: it counts when the browser shows it
5026
+ * as `:focus-visible`. The pointer arriving from outside a shadow root the
5027
+ * trigger renders into counts too, though React sends no enter for it.
5028
+ * - Focus in the floating element holds it open however it got there,
5029
+ * including focus in content it portals elsewhere, such as a nested card.
5030
+ * Whether a click focuses what it lands on is up to the browser: Safari
5031
+ * and Firefox on macOS don't focus a button on click, so there a clicked
5032
+ * button doesn't hold it once the pointer leaves.
5033
+ * - Once the pointer and focus have both left, it asks to close after
5034
+ * `closeDelay`, and either coming back before then keeps it open. Nothing
5035
+ * else closes it on a timer.
5036
+ * - Escape closes it at once, wherever focus is, and stops there when focus
5037
+ * is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
5038
+ * keeps its own. With focus somewhere else, or with `consumeEscape` off,
5039
+ * the key goes on after closing it. Inside another one, the inner one
5040
+ * closes first. Escape before it
5041
+ * opens takes the opening back and goes on its way. One key listener per
5042
+ * document serves every hook on it, there only while one of them is open
5043
+ * or on its way to open.
5044
+ * - With `openOnTap`, a tap on the trigger opens it at once and the next tap
5045
+ * on the trigger closes it. While a tap holds it open, a press outside the
5046
+ * trigger and the floating element closes it, from a listener on the
5047
+ * document that is there only then. Taps never count as hover, and mouse
5048
+ * and pen work the same with it or without.
5049
+ * - Once it closes, for any reason, whatever was holding it open has to
5050
+ * leave and come back to open it again.
5051
+ * - When the floating element closes with focus inside it, focus goes back
5052
+ * to the trigger, unless the same update put it somewhere else. Handing it
5053
+ * back doesn't count as focus arriving.
5054
+ *
5055
+ * Nothing it scheduled runs after the component unmounts.
5056
+ *
5057
+ * @function useHoverOpen
5058
+ * @param options - The open state, how to ask for another, the delays, and the two elements.
5059
+ * @returns Handlers for the element that holds the trigger.
5158
5060
  */
5159
- function isShownUpTo(el, top) {
5160
- let below = null;
5161
- for (let node = el; node && node !== top; node = flatParent(node)) {
5162
- if (!isShown(node))
5163
- return false;
5164
- if (below &&
5165
- node.localName === 'details' &&
5166
- !node.hasAttribute('open') &&
5167
- !isDetailsSummary(below)) {
5168
- return false;
5061
+ function useHoverOpen(options) {
5062
+ const latestRef = React.useRef(options);
5063
+ const stateRef = React.useRef({
5064
+ pointerIn: false,
5065
+ focusIn: false,
5066
+ focusInFloating: false,
5067
+ held: false,
5068
+ opening: false,
5069
+ asked: false,
5070
+ restoring: false,
5071
+ });
5072
+ // In the hooks that want Escape from the moment an open is on its way until
5073
+ // the floating element closes, and out otherwise. Stamped with the order it
5074
+ // opened in.
5075
+ const listenForEscape = React.useCallback(function listen() {
5076
+ const s = stateRef.current;
5077
+ const entry = entryRef.current;
5078
+ const { open, triggerRef } = latestRef.current;
5079
+ if (!open)
5080
+ entry.openedAt = 0;
5081
+ else if (!entry.openedAt) {
5082
+ openings += 1;
5083
+ entry.openedAt = openings;
5169
5084
  }
5170
- below = node;
5171
- }
5172
- return true;
5173
- }
5174
- /**
5175
- * The images that use the image map `area` belongs to. An image names its
5176
- * map with `usemap="#…"`, matching the map's `name` or `id`.
5177
- */
5178
- function imagesUsingMapOf(area) {
5179
- const map = area.closest('map');
5180
- if (!map)
5181
- return [];
5182
- const names = [map.getAttribute('name'), map.id].filter(name => name);
5183
- return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
5184
- const ref = img.getAttribute('usemap');
5185
- return ref.startsWith('#') && names.includes(ref.slice(1));
5085
+ const wanted = open || s.opening || s.asked;
5086
+ if (wanted === Boolean(s.releaseEscape))
5087
+ return;
5088
+ if (s.releaseEscape) {
5089
+ s.releaseEscape();
5090
+ s.releaseEscape = undefined;
5091
+ return;
5092
+ }
5093
+ const doc = triggerRef.current?.ownerDocument ?? document;
5094
+ s.releaseEscape = wantEscape(doc, entry);
5095
+ }, []);
5096
+ const entryRef = React.useRef({
5097
+ openedAt: 0,
5098
+ trigger: () => latestRef.current.triggerRef.current,
5099
+ floating: () => latestRef.current.floatingRef.current,
5100
+ // Only ever called while an open is on its way, so it is one or the
5101
+ // other: still waiting on its delay, or asked for already.
5102
+ cancel: () => {
5103
+ const s = stateRef.current;
5104
+ clearTimeout(s.timer);
5105
+ // An ask already made is taken back by asking the other way.
5106
+ if (s.asked)
5107
+ latestRef.current.onOpenChange(false);
5108
+ s.opening = false;
5109
+ s.asked = false;
5110
+ listenForEscape();
5111
+ },
5112
+ close: () => {
5113
+ clearTimeout(stateRef.current.timer);
5114
+ latestRef.current.onOpenChange(false);
5115
+ },
5116
+ consumes: () => latestRef.current.consumeEscape !== false,
5186
5117
  });
5118
+ // Asks to open or to close. Escape can take an ask to open back until it is
5119
+ // answered: by the commit that follows it, or, when the caller keeps it
5120
+ // closed without rendering at all, once the event that asked is over, by
5121
+ // which time React has rendered any answer the caller set.
5122
+ const ask = React.useCallback((next) => {
5123
+ const s = stateRef.current;
5124
+ s.asked = next;
5125
+ latestRef.current.onOpenChange(next);
5126
+ listenForEscape();
5127
+ if (!next)
5128
+ return;
5129
+ clearTimeout(s.askTimer);
5130
+ s.askTimer = setTimeout(() => {
5131
+ // Still closed: the caller turned it down.
5132
+ if (latestRef.current.open)
5133
+ return;
5134
+ s.asked = false;
5135
+ s.tappedIn = undefined;
5136
+ listenForEscape();
5137
+ });
5138
+ }, [listenForEscape]);
5139
+ // Asks for what the pointer and focus call for, after its delay, when that
5140
+ // changed since the last time and isn't the state already.
5141
+ const settle = React.useCallback(() => {
5142
+ const s = stateRef.current;
5143
+ const held = s.pointerIn || s.focusIn;
5144
+ if (held !== s.held) {
5145
+ s.held = held;
5146
+ clearTimeout(s.timer);
5147
+ const { open, openDelay, closeDelay } = latestRef.current;
5148
+ s.opening = held && !open;
5149
+ if (held !== open) {
5150
+ s.timer = setTimeout(() => {
5151
+ s.opening = false;
5152
+ if (latestRef.current.open !== held)
5153
+ ask(held);
5154
+ else
5155
+ listenForEscape();
5156
+ }, held ? openDelay : closeDelay);
5157
+ }
5158
+ }
5159
+ listenForEscape();
5160
+ }, [listenForEscape, ask]);
5161
+ // While a tap holds the floating element open, a press outside the trigger
5162
+ // and the floating element closes it. Set up again each time it opens or
5163
+ // closes.
5164
+ const listenForPress = React.useCallback(() => {
5165
+ const s = stateRef.current;
5166
+ s.stopPress?.();
5167
+ s.stopPress = undefined;
5168
+ const doc = latestRef.current.open ? s.tappedIn : undefined;
5169
+ if (!doc)
5170
+ return;
5171
+ const handlePointerDown = (event) => {
5172
+ // Inside is a press React brought to the holder, which covers content
5173
+ // the floating element portals elsewhere, or one whose path runs
5174
+ // through the trigger or the floating element, which covers a press
5175
+ // that something inside stopped before React got it to the holder.
5176
+ const { triggerRef, floatingRef } = latestRef.current;
5177
+ if (event === s.pressedInside ||
5178
+ isEventInside(event, triggerRef.current) ||
5179
+ isEventInside(event, floatingRef.current)) {
5180
+ return;
5181
+ }
5182
+ latestRef.current.onOpenChange(false);
5183
+ };
5184
+ doc.addEventListener('pointerdown', handlePointerDown);
5185
+ s.stopPress = () => doc.removeEventListener('pointerdown', handlePointerDown);
5186
+ }, []);
5187
+ useClientLayoutEffect(() => {
5188
+ latestRef.current = options;
5189
+ // The commit after an ask to open answers it, open or still closed. A
5190
+ // tap that asked and still finds it closed was turned down.
5191
+ const s = stateRef.current;
5192
+ if (s.asked) {
5193
+ s.asked = false;
5194
+ if (!options.open)
5195
+ s.tappedIn = undefined;
5196
+ listenForEscape();
5197
+ }
5198
+ });
5199
+ useClientLayoutEffect(() => {
5200
+ const s = stateRef.current;
5201
+ return () => {
5202
+ clearTimeout(s.timer);
5203
+ clearTimeout(s.askTimer);
5204
+ s.opening = false;
5205
+ s.asked = false;
5206
+ s.releaseEscape?.();
5207
+ s.releaseEscape = undefined;
5208
+ s.stopPress?.();
5209
+ s.stopPress = undefined;
5210
+ };
5211
+ }, []);
5212
+ const { open } = options;
5213
+ // Before paint, so a pointer or a key arriving next finds this settled.
5214
+ useClientLayoutEffect(() => {
5215
+ const s = stateRef.current;
5216
+ if (!open) {
5217
+ // The floating element left from under the pointer, if it was there,
5218
+ // and no leave event says so. Focus resting on the trigger has done
5219
+ // its job too, so neither opens it again until it comes back.
5220
+ s.pointerIn = false;
5221
+ s.focusIn = false;
5222
+ s.tappedIn = undefined;
5223
+ if (s.focusInFloating) {
5224
+ s.focusInFloating = false;
5225
+ const trigger = latestRef.current.triggerRef.current;
5226
+ // Focus that went down with the floating element sits on <body>.
5227
+ // Focus somewhere else was put there by the same update, so it stays.
5228
+ if (trigger &&
5229
+ trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
5230
+ s.restoring = true;
5231
+ trigger.focus();
5232
+ s.restoring = false;
5233
+ }
5234
+ }
5235
+ }
5236
+ settle();
5237
+ listenForPress();
5238
+ }, [open, settle, listenForPress]);
5239
+ return React.useMemo(() => {
5240
+ const inTrigger = (node) => within(latestRef.current.triggerRef.current, node);
5241
+ return {
5242
+ onPointerEnter: event => {
5243
+ if (event.pointerType === 'touch')
5244
+ return;
5245
+ stateRef.current.pointerIn = true;
5246
+ settle();
5247
+ },
5248
+ onPointerOver: event => {
5249
+ // React makes the enter out of the pointerout on the element the
5250
+ // pointer left, and skips this pointerover when that element is its
5251
+ // own. Leaving page content for a trigger inside a shadow root, that
5252
+ // pointerout names only the shadow host, so no enter reaches here.
5253
+ // A pointerover from outside the holder and the floating element is
5254
+ // the pointer arriving, then. One from inside them is the pointer
5255
+ // moving about within, which doesn't bring back a closed one.
5256
+ const s = stateRef.current;
5257
+ if (s.pointerIn || event.pointerType === 'touch')
5258
+ return;
5259
+ const from = event.relatedTarget;
5260
+ if (within(event.currentTarget, from) ||
5261
+ within(latestRef.current.floatingRef.current, from)) {
5262
+ return;
5263
+ }
5264
+ s.pointerIn = true;
5265
+ settle();
5266
+ },
5267
+ onPointerLeave: event => {
5268
+ // A touch pointer never set pointerIn, so its leave has nothing to
5269
+ // clear, and on a device with a mouse too it would close a card the
5270
+ // mouse is still holding open.
5271
+ if (event.pointerType === 'touch')
5272
+ return;
5273
+ stateRef.current.pointerIn = false;
5274
+ settle();
5275
+ },
5276
+ onFocus: event => {
5277
+ const s = stateRef.current;
5278
+ // React brings focus here from the trigger and from the floating
5279
+ // element's whole tree, portals included, so focus not in the
5280
+ // trigger is in the floating element, wherever its DOM is.
5281
+ s.focusInFloating = !inTrigger(event.target);
5282
+ if (s.restoring)
5283
+ return;
5284
+ // Any focus in the floating element holds it. On the trigger, only
5285
+ // keyboard focus does, so a click there opens nothing.
5286
+ s.focusIn = s.focusInFloating || isKeyboardFocus(event.target);
5287
+ settle();
5288
+ },
5289
+ onBlur: event => {
5290
+ const s = stateRef.current;
5291
+ const next = event.relatedTarget;
5292
+ // Where focus goes next, as far as the DOM can say. Focus going into
5293
+ // content the floating element portals elsewhere reads as leaving,
5294
+ // and the focus event that follows straight after brings it back.
5295
+ s.focusInFloating = within(latestRef.current.floatingRef.current, next);
5296
+ // Moving between the trigger and the floating element isn't leaving.
5297
+ if (s.focusInFloating || within(event.currentTarget, next))
5298
+ return;
5299
+ s.focusIn = false;
5300
+ settle();
5301
+ },
5302
+ onPointerDown: event => {
5303
+ stateRef.current.pressedInside = event.nativeEvent;
5304
+ },
5305
+ onPointerUp: event => {
5306
+ const now = latestRef.current;
5307
+ // With openOnTap, a tap on the trigger opens it at once, and the
5308
+ // next one closes it. A tap anywhere else does nothing here.
5309
+ if (!now.openOnTap ||
5310
+ event.pointerType !== 'touch' ||
5311
+ !inTrigger(event.target)) {
5312
+ return;
5313
+ }
5314
+ stateRef.current.tappedIn = now.open
5315
+ ? undefined
5316
+ : event.target.ownerDocument;
5317
+ ask(!now.open);
5318
+ },
5319
+ };
5320
+ }, [settle, ask]);
5187
5321
  }
5322
+
5188
5323
  /**
5189
- * Whether an image map area is shown. An area has no box of its own: the
5190
- * HTML rendering rules give it `display: none`, though a browser may not
5191
- * report that. So it counts where an image using its map is shown, as long
5192
- * as nothing makes it inert.
5193
- */
5194
- function isAreaShown(area, container) {
5195
- for (let node = area; node && node !== container; node = flatParent(node)) {
5196
- if (node.hasAttribute('inert'))
5197
- return false;
5198
- }
5199
- return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
5200
- }
5201
- /**
5202
- * Whether Tab can land on `el`, one of the kinds it visits. A disabled control
5203
- * (including one inside a disabled `<fieldset>`), an inert subtree and
5204
- * anything not rendered are skipped. `visibility` is inherited, so the
5205
- * element's own computed value covers its ancestors.
5206
- */
5207
- function isRenderedAndEnabled(el, container) {
5208
- if (el.matches(':disabled'))
5209
- return false;
5210
- if (el.localName === 'area')
5211
- return isAreaShown(el, container);
5212
- return isShownUpTo(el, container) && isVisible(el);
5213
- }
5214
- /** A radio button with a name, which shares one tab stop with its group. */
5215
- const isGroupedRadio = (el) => el.localName === 'input' &&
5216
- el.type === 'radio' &&
5217
- el.name !== '';
5218
- /** Whether two radio buttons are in one group: same name, form owner and tree. */
5219
- const sameGroup = (a, b) => isGroupedRadio(b) &&
5220
- b.name === a.name &&
5221
- b.form === a.form &&
5222
- b.getRootNode() === a.getRootNode();
5223
- /**
5224
- * The checked button of `radio`'s group. The group isn't bounded by the trap,
5225
- * so this looks through the radio's whole tree.
5226
- */
5227
- const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
5228
- /** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
5229
- function flatChain(node, container) {
5230
- const chain = [];
5231
- for (let n = node; n && n !== container; n = flatParent(n)) {
5232
- chain.unshift(n);
5233
- }
5234
- return chain;
5235
- }
5236
- /**
5237
- * Whether `node` is inside the container in the flattened tree: in it, in a
5238
- * shadow root it holds, or slotted into it. A child of a closed-root host
5239
- * counts as inside wherever the host is, as its slot can't be seen.
5240
- */
5241
- function inside(container, node) {
5242
- for (let n = node; n; n = flatParent(n)) {
5243
- if (n === container)
5244
- return true;
5245
- }
5246
- return false;
5247
- }
5248
- /**
5249
- * `true` when Tab reaches `b` after `a`, which is their order in the
5250
- * flattened tree: a shadow host comes before its shadow root's content, and
5251
- * slotted content comes where its slot is. A closed root's slots can't be
5252
- * seen, so its host's children keep their place in the light DOM. Two
5253
- * flattened-tree siblings are always in one DOM tree, so their document
5254
- * position decides between them.
5255
- */
5256
- function follows(container, a, b) {
5257
- const chainA = flatChain(a, container);
5258
- const chainB = flatChain(b, container);
5259
- for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
5260
- const [x, y] = [chainA[i], chainB[i]];
5261
- if (x === y)
5262
- continue;
5263
- if (!x)
5264
- return true;
5265
- if (!y)
5266
- return false;
5267
- return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
5268
- }
5269
- return false;
5270
- }
5271
- /**
5272
- * Every element of a tabbable kind under `el` in the flattened tree, depth
5273
- * first, which is the order Tab visits them. Under a host whose shadow root
5274
- * is closed, that takes in the host's own children instead, rendered or not.
5275
- */
5276
- function collectCandidates(el, out = []) {
5277
- for (const child of flatChildren(el)) {
5278
- if (isTabbableKind(child))
5279
- out.push(child);
5280
- collectCandidates(child, out);
5281
- }
5282
- return out;
5283
- }
5284
- /**
5285
- * The first and last tab stops inside `container`, or `null` when there are
5286
- * none. Only the ends matter for wrapping, so the style checks run from each
5287
- * end until they find a stop rather than over every candidate.
5324
+ * The `Tooltip` component displays helpful information when users hover over or focus on an element.
5288
5325
  *
5289
- * A radio group is one stop, the way the browser treats it: its checked
5290
- * button, or its first when none is checked. Counting every button would make
5291
- * the group's last one an end, and Tab from the checked one would leave. The
5292
- * group goes by name, form owner and tree, not by the trap, so a checked
5293
- * button outside the container that Tab can land on leaves the group no stop
5294
- * inside. One that Tab can't land on leaves the group as if none were checked.
5326
+ * Hovering the trigger or giving it keyboard focus shows it. Touch doesn't,
5327
+ * unless `openOnTap` is on, and neither does focus a click or a tap brings,
5328
+ * so tapping the trigger just does what the trigger does. The pointer can
5329
+ * move onto a shown tooltip. Escape hides it wherever focus is, without
5330
+ * moving the pointer or focus, and it stays hidden until one of them leaves
5331
+ * the trigger and comes back, or, with `openOnTap`, until the next tap on
5332
+ * the trigger. The key always goes on too, to whatever has focus or a
5333
+ * `Modal` or `Popover` around it, since a tooltip only adds to what's
5334
+ * there.
5335
+ *
5336
+ * @function
5337
+ * @param {TooltipProps} props - Props for the Tooltip component.
5338
+ * @returns {JSX.Element} The rendered tooltip wrapper.
5339
+ *
5340
+ * @example
5341
+ * // Basic tooltip
5342
+ * <Tooltip label="This is helpful info">
5343
+ * <button>Hover me</button>
5344
+ * </Tooltip>
5345
+ *
5346
+ * @example
5347
+ * // Positioned tooltip with color
5348
+ * <Tooltip label="Delete item" position="right" color="danger">
5349
+ * <Icon icon="fas fa-trash" />
5350
+ * </Tooltip>
5351
+ *
5352
+ * @example
5353
+ * // Multiline tooltip
5354
+ * <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
5355
+ * <span>?</span>
5356
+ * </Tooltip>
5357
+ */
5358
+ const Tooltip = ({ label, content, position = 'top', color, size, active: alwaysActive, multiline = false, animated = true, square = false, dashed = false, delay = 0, closeDelay = 0, openOnTap = false, children, className, tooltipClassName, ...props }) => {
5359
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5360
+ const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
5361
+ const [isVisible, setIsVisible] = React.useState(false);
5362
+ const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
5363
+ const [contentStyle, setContentStyle] = React.useState({});
5364
+ const wrapperRef = React.useRef(null);
5365
+ const contentRef = React.useRef(null);
5366
+ const hover = useHoverOpen({
5367
+ // The pointer and focus as they are. `active` shows it on top of that, so
5368
+ // dropping `active` leaves it shown while the pointer or focus holds it.
5369
+ // An ask to close can come while it's already hidden, which is no change.
5370
+ open: isVisible,
5371
+ onOpenChange: setIsVisible,
5372
+ openDelay: delay,
5373
+ closeDelay,
5374
+ // The wrapper stands in for the trigger. It holds only the children and
5375
+ // the tooltip, and nothing in the tooltip takes focus, so focus anywhere
5376
+ // in it is focus on the trigger.
5377
+ triggerRef: wrapperRef,
5378
+ floatingRef: contentRef,
5379
+ // Escape hides it and carries on, so a control in the trigger, or a
5380
+ // Modal around it, still gets the key.
5381
+ consumeEscape: false,
5382
+ openOnTap,
5383
+ });
5384
+ // Auto-placement and overflow correction
5385
+ React.useLayoutEffect(() => {
5386
+ const isAuto = position === 'auto';
5387
+ if (!isAuto) {
5388
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
5389
+ setResolvedPosition(position);
5390
+ }
5391
+ const showing = isVisible || alwaysActive;
5392
+ if (!showing) {
5393
+ setContentStyle({});
5394
+ return;
5395
+ }
5396
+ const wrapper = wrapperRef.current;
5397
+ const content = contentRef.current;
5398
+ if (!wrapper || !content)
5399
+ return;
5400
+ const wrapperRect = wrapper.getBoundingClientRect();
5401
+ const margin = 40;
5402
+ const edgePadding = 8; // minimum distance from viewport edge
5403
+ // Step 1: determine position (auto mode only)
5404
+ if (isAuto) {
5405
+ const spaceTop = wrapperRect.top;
5406
+ const spaceBottom = window.innerHeight - wrapperRect.bottom;
5407
+ if (spaceTop >= margin) {
5408
+ setResolvedPosition('top');
5409
+ }
5410
+ else if (spaceBottom >= margin) {
5411
+ setResolvedPosition('bottom');
5412
+ }
5413
+ else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
5414
+ setResolvedPosition('right');
5415
+ }
5416
+ else {
5417
+ setResolvedPosition('left');
5418
+ }
5419
+ }
5420
+ // Step 2: measure tooltip content and correct overflow.
5421
+ // Use requestAnimationFrame so the resolved position class is applied first.
5422
+ requestAnimationFrame(() => {
5423
+ const contentEl = contentRef.current;
5424
+ if (!contentEl)
5425
+ return;
5426
+ const contentRect = contentEl.getBoundingClientRect();
5427
+ const style = {};
5428
+ // For top/bottom positions, tooltip is centered horizontally via CSS
5429
+ // (left: 50%; transform: translateX(-50%)).
5430
+ // Check if it overflows left or right and apply a corrective shift.
5431
+ const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
5432
+ const effectivePos = pos || 'top'; // fallback, actual is set by state
5433
+ const isVertical = effectivePos === 'top' ||
5434
+ effectivePos === 'bottom' ||
5435
+ resolvedPosition === 'top' ||
5436
+ resolvedPosition === 'bottom';
5437
+ if (isVertical) {
5438
+ if (contentRect.left < edgePadding) {
5439
+ // Overflows left — shift right
5440
+ const shift = edgePadding - contentRect.left;
5441
+ style.left = `calc(50% + ${shift}px)`;
5442
+ style['--tooltip-arrow-offset'] =
5443
+ `${-shift}px`;
5444
+ }
5445
+ else if (contentRect.right > window.innerWidth - edgePadding) {
5446
+ // Overflows right — shift left
5447
+ const shift = contentRect.right - (window.innerWidth - edgePadding);
5448
+ style.left = `calc(50% - ${shift}px)`;
5449
+ style['--tooltip-arrow-offset'] =
5450
+ `${shift}px`;
5451
+ }
5452
+ }
5453
+ else {
5454
+ // For left/right positions, tooltip is centered vertically via CSS
5455
+ // (top: 50%; transform: translateY(-50%)).
5456
+ if (contentRect.top < edgePadding) {
5457
+ const shift = edgePadding - contentRect.top;
5458
+ style.top = `calc(50% + ${shift}px)`;
5459
+ style['--tooltip-arrow-offset'] =
5460
+ `${-shift}px`;
5461
+ }
5462
+ else if (contentRect.bottom > window.innerHeight - edgePadding) {
5463
+ const shift = contentRect.bottom - (window.innerHeight - edgePadding);
5464
+ style.top = `calc(50% - ${shift}px)`;
5465
+ style['--tooltip-arrow-offset'] =
5466
+ `${shift}px`;
5467
+ }
5468
+ }
5469
+ setContentStyle(style);
5470
+ });
5471
+ }, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
5472
+ // Generate Bulma classes
5473
+ const tooltipClasses = usePrefixedClassNames('tooltip', {
5474
+ 'is-active': alwaysActive || isVisible,
5475
+ // Open by the pointer, focus or a tap, rather than shown by `active`
5476
+ // alone: only then does the tooltip, and the gap to it, take the pointer.
5477
+ 'is-open': isVisible,
5478
+ [`is-${resolvedPosition}`]: resolvedPosition,
5479
+ [`is-${color}`]: color,
5480
+ [`is-${size}`]: size,
5481
+ 'is-multiline': multiline,
5482
+ 'is-animated': animated,
5483
+ 'is-square': square,
5484
+ 'is-dashed': dashed,
5485
+ });
5486
+ const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
5487
+ const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
5488
+ return (jsxRuntime.jsxs("span", { ref: wrapperRef, className: combinedClasses, onPointerEnter: chain$1(onPointerEnter, hover.onPointerEnter), onPointerOver: chain$1(onPointerOver, hover.onPointerOver), onPointerLeave: chain$1(onPointerLeave, hover.onPointerLeave), onFocus: chain$1(onFocus, hover.onFocus), onBlur: chain$1(onBlur, hover.onBlur), onPointerDown: chain$1(onPointerDown, hover.onPointerDown), onPointerUp: chain$1(onPointerUp, hover.onPointerUp), ...(label ? { 'data-tooltip': label } : {}), ...attrs, children: [children, jsxRuntime.jsx("span", { ref: contentRef, className: combinedTooltipClasses, role: "tooltip", "aria-hidden": !isVisible && !alwaysActive, style: Object.keys(contentStyle).length > 0 ? contentStyle : undefined, children: content || label })] }));
5489
+ };
5490
+
5491
+ /**
5492
+ * Whether `el` is editable while its parent is not, which makes it an editing
5493
+ * host: the one tab stop an editable region gets. `contenteditable="false"`
5494
+ * turns editing off, and a missing or unknown value inherits the parent's.
5295
5495
  */
5296
- function findTabStops(container) {
5297
- const candidates = collectCandidates(container);
5298
- const checked = new Map();
5299
- const usable = (el) => {
5300
- let ok = checked.get(el);
5301
- if (ok === undefined) {
5302
- ok = isRenderedAndEnabled(el, container);
5303
- checked.set(el, ok);
5496
+ function isEditingHost(el) {
5497
+ const editable = (node) => {
5498
+ const value = node.getAttribute('contenteditable')?.toLowerCase();
5499
+ if (value === undefined)
5500
+ return null;
5501
+ if (value === '' || value === 'true' || value === 'plaintext-only') {
5502
+ return true;
5304
5503
  }
5305
- return ok;
5306
- };
5307
- // Resolved once per group, filed under each of its buttons, since finding
5308
- // the checked one scans the radio's whole tree.
5309
- const groupStops = new Map();
5310
- const groupStop = (radio) => {
5311
- if (groupStops.has(radio))
5312
- return groupStops.get(radio);
5313
- const group = candidates.filter(el => sameGroup(radio, el));
5314
- const on = checkedInGroup(radio);
5315
- const stop = on && isTabbableKind(on) && usable(on)
5316
- ? group.find(el => el === on)
5317
- : group.find(usable);
5318
- for (const el of [radio, ...group])
5319
- groupStops.set(el, stop);
5320
- return stop;
5504
+ return value === 'false' ? false : null;
5321
5505
  };
5322
- const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
5323
- const first = candidates.find(isStop);
5324
- if (!first)
5325
- return null;
5326
- let last = first;
5327
- for (let i = candidates.length - 1; candidates[i] !== first; i--) {
5328
- if (isStop(candidates[i])) {
5329
- last = candidates[i];
5330
- break;
5331
- }
5506
+ if (editable(el) !== true)
5507
+ return false;
5508
+ for (let node = el.parentElement; node; node = node.parentElement) {
5509
+ const state = editable(node);
5510
+ if (state !== null)
5511
+ return !state;
5332
5512
  }
5333
- return {
5334
- first,
5335
- last,
5336
- stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
5337
- };
5338
- }
5339
- /**
5340
- * The first tab stop inside `container`, found the way the trap finds it, or
5341
- * `null` when there is none. For a component that moves focus into a panel
5342
- * without trapping it there. `src/index.ts` names its exports from this
5343
- * module, so this one stays internal.
5344
- */
5345
- function firstTabStop(container) {
5346
- return findTabStops(container)?.first ?? null;
5513
+ return true;
5347
5514
  }
5515
+ /** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
5516
+ const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
5517
+ Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
5348
5518
  /**
5349
- * The element that has focus in the container's own document (an iframe's,
5350
- * when it renders into one), inside open shadow roots too. Focus inside a
5351
- * closed one reads as its host, which the comparisons above place like any
5352
- * other element. Browsers fall back to <body> when nothing has
5353
- * focus. jsdom, where consumers run their tests, reports nothing once focus
5354
- * was inside a shadow root that was removed, so this falls back for it.
5355
- */
5356
- const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
5357
- /**
5358
- * Keeps keyboard focus inside a container while it is active: focus moves in
5359
- * when the trap turns on, Tab and Shift+Tab wrap between the first and last
5360
- * tab stops, and focus goes back where it came from when the trap turns off.
5361
- *
5362
- * Tab stops are what the browser's Tab visits, in document order: links,
5363
- * form controls, frames, media with controls, a `<details>` summary, editable
5364
- * regions, anything with `tabIndex={0}`, and the content of open shadow
5365
- * roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
5366
- * and a radio group counts once. Give the container `tabIndex={-1}` so it can
5367
- * hold focus itself when nothing inside can. The trap listens on the
5368
- * container, so a trap nested inside another handles Tab first and the outer
5369
- * one stands aside.
5370
- *
5371
- * The trap is about Tab. If a Tab it let through still takes focus out (a
5372
- * browser can leave out a stop the page has, such as links when its settings
5373
- * say to), it sends focus back to the other end. A pointer or a script moving
5374
- * focus out is left alone, because pulling focus back from a click is modal
5375
- * behaviour, and a modal also has to block the page behind it, which `Modal`
5376
- * does. Content the container renders through a portal lives elsewhere in
5377
- * the DOM, so it is outside the trap: render nested overlays inside the
5378
- * container, or give them a trap of their own. To trap content a `Portal`
5379
- * renders, pass a ref to the element inside the `Portal` as `containerRef`.
5380
- *
5381
- * The trap looks at `containerRef` again each time the component calling it
5382
- * renders. A container that mounts after the trap turns on, such as a panel
5383
- * waiting for a `Portal` target held in state, has it attach once that
5384
- * component renders with the container in the DOM. When the container leaves
5385
- * or is replaced, the trap lets go of it as it would turning off, handing
5386
- * focus back, and attaches to the next one.
5387
- * A child that mounts the container in a render of its own (from its own
5388
- * state, or as a `Suspense` boundary resolves) goes unseen until the
5389
- * component calling the hook renders again. The trap waits for hydration, so
5390
- * that component renders again when the page hydrates, and portaled content,
5391
- * which first appears then, is found.
5392
- *
5393
- * @function useFocusTrap
5394
- * @param containerRef - Ref to the element focus stays inside.
5395
- * @param options - When the trap is on, and where focus starts and ends.
5396
- *
5397
- * @example
5398
- * const panelRef = useRef<HTMLDivElement>(null);
5399
- * useFocusTrap(panelRef, { active: isOpen });
5400
- * return isOpen ? (
5401
- * <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
5402
- * …
5403
- * </div>
5404
- * ) : null;
5519
+ * Whether the browser puts `el` in the tab order, before anything that hides
5520
+ * or disables it. The `tabIndex` property can't answer this on its own: it
5521
+ * can read `-1` for an editable region or an `<embed>` that Tab still visits,
5522
+ * and `0` for a `<video>` without controls that it doesn't. So a valid
5523
+ * `tabindex` attribute decides, and otherwise the element's kind does.
5524
+ * Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
5405
5525
  */
5406
- function useFocusTrap(containerRef, options = {}) {
5407
- const { active = true, initialFocusRef, restoreFocus = true } = options;
5408
- // Portaled content has no server-rendered counterpart, so a portaled
5409
- // container only exists from the commit after hydration.
5410
- const hydrated = useIsHydrated();
5411
- // The container the trap is attached to, or `null` while it has none.
5412
- const heldRef = React.useRef(null);
5413
- // Bumped to run the trap again when the container in the ref changes.
5414
- const [refChanges, setRefChanges] = React.useState(0);
5415
- React.useEffect(() => {
5416
- heldRef.current = null;
5417
- if (!active || !hydrated)
5418
- return undefined;
5419
- const container = containerRef.current;
5420
- if (!container)
5421
- return undefined;
5422
- heldRef.current = container;
5423
- const doc = container.ownerDocument;
5424
- const opener = focusedElement(doc);
5425
- // `initialFocusRef` counts only when focusing it puts focus inside. Focus
5426
- // put outside would stay out, as Tab there never reaches the container's
5427
- // listener, and focusing something that can't take focus (a heading
5428
- // without `tabIndex`, a disabled control) leaves focus where it was.
5429
- const initial = initialFocusRef?.current;
5430
- const inTrap = initial && inside(container, initial) ? initial : null;
5431
- inTrap?.focus();
5432
- if (!inTrap || !inside(container, focusedElement(doc))) {
5433
- (findTabStops(container)?.first ?? container).focus();
5434
- }
5435
- // Where focus goes if a Tab the trap let the browser handle takes it out,
5436
- // held until that Tab has finished moving focus.
5437
- let wrapTo = null;
5438
- let wrapTimer;
5439
- const handleKeyDown = (e) => {
5440
- // A trap nested inside this one has already wrapped focus.
5441
- if (e.key !== 'Tab' || e.defaultPrevented)
5442
- return;
5443
- const stops = findTabStops(container);
5444
- if (!stops) {
5445
- e.preventDefault();
5446
- container.focus();
5447
- return;
5448
- }
5449
- const current = stops.stopFor(focusedElement(doc));
5450
- // Wrap whenever the browser has no stop left in that direction inside
5451
- // the container. Comparing positions rather than testing `=== first`
5452
- // also covers focus resting on the container or on an element outside
5453
- // the tab order, which the browser would otherwise walk straight out of.
5454
- if (e.shiftKey
5455
- ? !follows(container, stops.first, current)
5456
- : !follows(container, current, stops.last)) {
5457
- e.preventDefault();
5458
- (e.shiftKey ? stops.last : stops.first).focus();
5459
- return;
5460
- }
5461
- wrapTo = e.shiftKey ? stops.last : stops.first;
5462
- clearTimeout(wrapTimer);
5463
- wrapTimer = setTimeout(() => {
5464
- wrapTo = null;
5465
- });
5466
- };
5467
- // The browser moves focus as soon as the keydown is over, so focus that
5468
- // lands outside while `wrapTo` is set got there by that Tab.
5469
- const handleFocusIn = (e) => {
5470
- if (!wrapTo || inside(container, e.composedPath()[0]))
5471
- return;
5472
- const target = wrapTo;
5473
- wrapTo = null;
5474
- target.focus();
5475
- };
5476
- container.addEventListener('keydown', handleKeyDown);
5477
- doc.addEventListener('focusin', handleFocusIn, true);
5478
- return () => {
5479
- container.removeEventListener('keydown', handleKeyDown);
5480
- doc.removeEventListener('focusin', handleFocusIn, true);
5481
- clearTimeout(wrapTimer);
5482
- let target = restoreFocus ? opener : null;
5483
- // `null` is an object too, and a caller without types can pass it.
5484
- if (restoreFocus && typeof restoreFocus === 'object') {
5485
- // Read now rather than when the trap turned on: a trigger that
5486
- // re-mounted in between has a new node.
5487
- // eslint-disable-next-line react-hooks/exhaustive-deps
5488
- target = restoreFocus.current;
5489
- }
5490
- // Hand focus back only while the trap still holds it: inside the
5491
- // container, or dropped to <body> because the container left the page.
5492
- const now = focusedElement(doc);
5493
- if (target && (now === doc.body || inside(container, now))) {
5494
- target.focus();
5495
- }
5496
- };
5497
- }, [
5498
- active,
5499
- hydrated,
5500
- containerRef,
5501
- initialFocusRef,
5502
- restoreFocus,
5503
- refChanges,
5504
- ]);
5505
- // Nothing says when a ref is set, so after every render, a ref that no
5506
- // longer matches what the trap holds (a container that mounted, left or was
5507
- // replaced) runs it again. Effects run in order, so whenever the effect
5508
- // above ran in this commit, it read the ref just before this does, and a
5509
- // mismatch means the ref changed in a render it didn't run for. The render
5510
- // a bump asks for runs it, so the two match and the trap settles. An empty
5511
- // ref matches a trap holding nothing, so a container that never mounts
5512
- // costs no render.
5513
- // eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
5514
- React.useEffect(() => {
5515
- if (active && hydrated && containerRef.current !== heldRef.current) {
5516
- setRefChanges(n => n + 1);
5526
+ function isTabbableKind(el) {
5527
+ const tabindex = el.getAttribute('tabindex');
5528
+ if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
5529
+ return el.tabIndex >= 0;
5530
+ }
5531
+ switch (el.localName) {
5532
+ case 'a':
5533
+ case 'area':
5534
+ return el.hasAttribute('href');
5535
+ case 'input':
5536
+ return el.type !== 'hidden';
5537
+ case 'button':
5538
+ case 'select':
5539
+ case 'textarea':
5540
+ case 'iframe':
5541
+ case 'embed':
5542
+ case 'object':
5543
+ return true;
5544
+ case 'audio':
5545
+ case 'video':
5546
+ return el.hasAttribute('controls');
5547
+ case 'summary':
5548
+ return isDetailsSummary(el);
5549
+ default:
5550
+ return isEditingHost(el);
5551
+ }
5552
+ }
5553
+ /**
5554
+ * The node's parent in the flattened tree, the one the browser renders and
5555
+ * tabs through: the slot it is assigned to, else its parent, else the host of
5556
+ * the shadow root it tops, and nothing above the document. A closed shadow
5557
+ * root hides its slots (`assignedSlot` reads `null` under one), so a child of
5558
+ * its host goes straight to the host, whether a slot takes it or not.
5559
+ */
5560
+ const flatParent = (node) => node.assignedSlot ??
5561
+ node.parentElement ??
5562
+ node.parentNode?.host ??
5563
+ null;
5564
+ /**
5565
+ * The element's children in the flattened tree, in the order they render: a
5566
+ * host's shadow root content, a slot's assigned elements (or its fallback
5567
+ * content when nothing is assigned), anything else's own children. A host
5568
+ * whose shadow root is closed has no root to read, so its own children stand
5569
+ * in, including any that no slot takes and that aren't rendered.
5570
+ */
5571
+ function flatChildren(el) {
5572
+ if (el.shadowRoot)
5573
+ return Array.from(el.shadowRoot.children);
5574
+ if (el.localName === 'slot') {
5575
+ const assigned = el.assignedNodes();
5576
+ if (assigned.length > 0) {
5577
+ return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
5578
+ }
5579
+ }
5580
+ return Array.from(el.children);
5581
+ }
5582
+ const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
5583
+ const isVisible = (el) => {
5584
+ const { visibility } = getComputedStyle(el);
5585
+ return visibility !== 'hidden' && visibility !== 'collapse';
5586
+ };
5587
+ /**
5588
+ * Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
5589
+ * (not inherited, so each element is checked) or a closed `<details>` it
5590
+ * isn't the summary of. `top` itself isn't checked, and `null` walks to the
5591
+ * top of the page.
5592
+ */
5593
+ function isShownUpTo(el, top) {
5594
+ let below = null;
5595
+ for (let node = el; node && node !== top; node = flatParent(node)) {
5596
+ if (!isShown(node))
5597
+ return false;
5598
+ if (below &&
5599
+ node.localName === 'details' &&
5600
+ !node.hasAttribute('open') &&
5601
+ !isDetailsSummary(below)) {
5602
+ return false;
5517
5603
  }
5604
+ below = node;
5605
+ }
5606
+ return true;
5607
+ }
5608
+ /**
5609
+ * The images that use the image map `area` belongs to. An image names its
5610
+ * map with `usemap="#…"`, matching the map's `name` or `id`.
5611
+ */
5612
+ function imagesUsingMapOf(area) {
5613
+ const map = area.closest('map');
5614
+ if (!map)
5615
+ return [];
5616
+ const names = [map.getAttribute('name'), map.id].filter(name => name);
5617
+ return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
5618
+ const ref = img.getAttribute('usemap');
5619
+ return ref.startsWith('#') && names.includes(ref.slice(1));
5518
5620
  });
5519
5621
  }
5520
-
5521
- // INTERNAL: deliberately not exported from src/index.ts.
5522
5622
  /**
5523
- * `useLayoutEffect` in the browser, `useEffect` on the server. A server
5524
- * render runs neither, and React 18 warns about a layout effect there, so the
5525
- * server is handed the plain one.
5623
+ * Whether an image map area is shown. An area has no box of its own: the
5624
+ * HTML rendering rules give it `display: none`, though a browser may not
5625
+ * report that. So it counts where an image using its map is shown, as long
5626
+ * as nothing makes it inert.
5526
5627
  */
5527
- const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5528
-
5529
- // INTERNAL: deliberately not exported from src/index.ts.
5628
+ function isAreaShown(area, container) {
5629
+ for (let node = area; node && node !== container; node = flatParent(node)) {
5630
+ if (node.hasAttribute('inert'))
5631
+ return false;
5632
+ }
5633
+ return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
5634
+ }
5530
5635
  /**
5531
- * A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
5532
- * side facing the anchor, negative on a portaled panel above it, so the
5533
- * larger magnitude is the gap. In pixels.
5636
+ * Whether Tab can land on `el`, one of the kinds it visits. A disabled control
5637
+ * (including one inside a disabled `<fieldset>`), an inert subtree and
5638
+ * anything not rendered are skipped. `visibility` is inherited, so the
5639
+ * element's own computed value covers its ancestors.
5534
5640
  */
5535
- function marginGap(panel) {
5536
- const { marginTop, marginBottom } = getComputedStyle(panel);
5537
- return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
5641
+ function isRenderedAndEnabled(el, container) {
5642
+ if (el.matches(':disabled'))
5643
+ return false;
5644
+ if (el.localName === 'area')
5645
+ return isAreaShown(el, container);
5646
+ return isShownUpTo(el, container) && isVisible(el);
5538
5647
  }
5648
+ /** A radio button with a name, which shares one tab stop with its group. */
5649
+ const isGroupedRadio = (el) => el.localName === 'input' &&
5650
+ el.type === 'radio' &&
5651
+ el.name !== '';
5652
+ /** Whether two radio buttons are in one group: same name, form owner and tree. */
5653
+ const sameGroup = (a, b) => isGroupedRadio(b) &&
5654
+ b.name === a.name &&
5655
+ b.form === a.form &&
5656
+ b.getRootNode() === a.getRootNode();
5539
5657
  /**
5540
- * The corner that keeps a panel of this size in the viewport, preferring
5541
- * below the anchor and lined up with its left edge.
5658
+ * The checked button of `radio`'s group. The group isn't bounded by the trap,
5659
+ * so this looks through the radio's whole tree.
5542
5660
  */
5543
- function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
5544
- const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
5545
- const fitsRight = rect.left + panelWidth <= view.innerWidth;
5546
- if (fitsBelow)
5547
- return fitsRight ? 'bottom-left' : 'bottom-right';
5548
- return fitsRight ? 'top-left' : 'top-right';
5661
+ const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
5662
+ /** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
5663
+ function flatChain(node, container) {
5664
+ const chain = [];
5665
+ for (let n = node; n && n !== container; n = flatParent(n)) {
5666
+ chain.unshift(n);
5667
+ }
5668
+ return chain;
5549
5669
  }
5550
- const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
5551
5670
  /**
5552
- * Places a floating panel against its anchor: resolves `auto` to a corner
5553
- * and, for a panel fixed to the viewport, works out its coordinates. It
5554
- * measures again on resize and on any scroll of the anchor's window (an
5555
- * iframe's, when the anchor renders into one), so a fixed panel follows its
5556
- * anchor. A measurement that changes nothing doesn't render again.
5557
- *
5558
- * Measuring happens in a layout effect, so the panel is placed before it
5559
- * paints. It needs both elements on the page while `active` is on, and
5560
- * keeps the last placement when either is missing.
5561
- *
5562
- * @function useAnchoredPosition
5563
- * @param anchorRef - The element the panel opens from.
5564
- * @param panelRef - The floating panel.
5565
- * @param options - Whether to measure, the corner asked for, and how the panel is placed.
5566
- * @returns The corner the panel opens from, with coordinates when it is fixed.
5671
+ * Whether `node` is inside the container in the flattened tree: in it, in a
5672
+ * shadow root it holds, or slotted into it. A child of a closed-root host
5673
+ * counts as inside wherever the host is, as its slot can't be seen.
5567
5674
  */
5568
- function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
5569
- const [resolved, setResolved] = React.useState({
5570
- position: position === 'auto' ? 'bottom-left' : position,
5571
- });
5572
- useClientLayoutEffect(() => {
5573
- if (!active)
5574
- return undefined;
5575
- const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
5576
- window;
5577
- const update = () => {
5578
- const anchor = anchorRef.current;
5579
- const panel = panelRef.current;
5580
- if (!anchor || !panel)
5581
- return;
5582
- const rect = anchor.getBoundingClientRect();
5583
- const { width, height } = panel.getBoundingClientRect();
5584
- const corner = position === 'auto'
5585
- ? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
5586
- : position;
5587
- const next = fixed
5588
- ? {
5589
- position: corner,
5590
- top: corner.startsWith('bottom')
5591
- ? rect.bottom + offset
5592
- : rect.top - height - offset,
5593
- left: corner.endsWith('left') ? rect.left : rect.right - width,
5594
- }
5595
- : { position: corner };
5596
- setResolved(prev => (samePosition(prev, next) ? prev : next));
5597
- };
5598
- update();
5599
- view.addEventListener('resize', update, { passive: true });
5600
- view.addEventListener('scroll', update, { passive: true, capture: true });
5601
- return () => {
5602
- view.removeEventListener('resize', update);
5603
- view.removeEventListener('scroll', update, { capture: true });
5604
- };
5605
- }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
5606
- return resolved;
5675
+ function inside(container, node) {
5676
+ for (let n = node; n; n = flatParent(n)) {
5677
+ if (n === container)
5678
+ return true;
5679
+ }
5680
+ return false;
5607
5681
  }
5608
-
5609
- const validButtonColors = [...validColors, 'text', 'ghost'];
5610
5682
  /**
5611
- * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5612
- *
5613
- * @function
5614
- * @param {ButtonProps} props - Props for the Button component.
5615
- * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
5616
- * @returns {JSX.Element} The rendered button, anchor, or custom element.
5617
- * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
5683
+ * `true` when Tab reaches `b` after `a`, which is their order in the
5684
+ * flattened tree: a shadow host comes before its shadow root's content, and
5685
+ * slotted content comes where its slot is. A closed root's slots can't be
5686
+ * seen, so its host's children keep their place in the light DOM. Two
5687
+ * flattened-tree siblings are always in one DOM tree, so their document
5688
+ * position decides between them.
5689
+ */
5690
+ function follows(container, a, b) {
5691
+ const chainA = flatChain(a, container);
5692
+ const chainB = flatChain(b, container);
5693
+ for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
5694
+ const [x, y] = [chainA[i], chainB[i]];
5695
+ if (x === y)
5696
+ continue;
5697
+ if (!x)
5698
+ return true;
5699
+ if (!y)
5700
+ return false;
5701
+ return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
5702
+ }
5703
+ return false;
5704
+ }
5705
+ /**
5706
+ * Every element of a tabbable kind under `el` in the flattened tree, depth
5707
+ * first, which is the order Tab visits them. Under a host whose shadow root
5708
+ * is closed, that takes in the host's own children instead, rendered or not.
5709
+ */
5710
+ function collectCandidates(el, out = []) {
5711
+ for (const child of flatChildren(el)) {
5712
+ if (isTabbableKind(child))
5713
+ out.push(child);
5714
+ collectCandidates(child, out);
5715
+ }
5716
+ return out;
5717
+ }
5718
+ /**
5719
+ * The first and last tab stops inside `container`, or `null` when there are
5720
+ * none. Only the ends matter for wrapping, so the style checks run from each
5721
+ * end until they find a stop rather than over every candidate.
5722
+ *
5723
+ * A radio group is one stop, the way the browser treats it: its checked
5724
+ * button, or its first when none is checked. Counting every button would make
5725
+ * the group's last one an end, and Tab from the checked one would leave. The
5726
+ * group goes by name, form owner and tree, not by the trap, so a checked
5727
+ * button outside the container that Tab can land on leaves the group no stop
5728
+ * inside. One that Tab can't land on leaves the group as if none were checked.
5618
5729
  */
5619
- const Button = React.forwardRef(function Button(props, ref) {
5620
- 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;
5621
- const { bulmaHelperClasses, rest } = useBulmaClasses({
5622
- color: textColor,
5623
- backgroundColor: bgColor,
5624
- ...bulmaProps,
5625
- });
5626
- // Generate Bulma classes with prefix
5627
- const bulmaClasses = usePrefixedClassNames('button', {
5628
- [`is-${color}`]: color &&
5629
- validButtonColors.includes(color),
5630
- [`is-${size}`]: size,
5631
- 'is-outlined': isOutlined,
5632
- 'is-light': isLight,
5633
- 'is-loading': isLoading,
5634
- 'is-static': isStatic,
5635
- 'is-disabled': isDisabled,
5636
- 'is-rounded': isRounded,
5637
- 'is-hovered': isHovered,
5638
- 'is-focused': isFocused,
5639
- 'is-active': isActive,
5640
- 'is-inverted': isInverted,
5641
- 'is-fullwidth': isFullwidth ?? isFullWidth,
5642
- });
5643
- // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
5644
- const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
5645
- if (Component !== 'button') {
5646
- // Strip the form-control attributes a link-like element cannot carry, so a
5647
- // JavaScript consumer or a spread object doesn't put them on the DOM. The
5648
- // types no longer allow them through; this is the runtime backstop.
5649
- //
5650
- // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
5651
- // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
5652
- // its props reach it. Stripping names by their spelling alone broke that
5653
- // promise silently: `<Button as={Custom} name="x" />` type-checked while
5654
- // `Custom` never received the `name` it requires.
5655
- //
5656
- // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
5657
- // targets this list was filtering them from. `autoFocus` is a global
5658
- // attribute (React decides per element whether to honour it, which is not
5659
- // ours to pre-empt), and `type` on an anchor is the MIME hint that
5660
- // `ButtonProps<'a'>` now types it as.
5661
- //
5662
- // The types already answer this for a TypeScript caller: `ButtonProps<T>`
5663
- // derives from `ComponentPropsWithoutRef<T>`, so only what the target
5664
- // accepts can be passed. What is left is a runtime backstop for JavaScript
5665
- // consumers and spread objects.
5666
- //
5667
- // Keep it to the two cases where a stray attribute does something. The
5668
- // submit-overrides belong to a submit control and never make sense on the
5669
- // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
5670
- // `.button[disabled]` (background, border, shadow, opacity), so letting it
5671
- // through to a `<span>` greys the element out — main stripped it, and not
5672
- // stripping it would be a silent visual change on a types-only release.
5673
- //
5674
- // Everything else (`name`, `value`, `form`) is inert where it does not
5675
- // belong, and enumerating which tags own it is what kept going wrong:
5676
- // first all nine names on every non-button tag (so `<Button as="input"
5677
- // name="query" value="Search">` submitted nothing), then a form-control
5678
- // allowlist that still lost `formAction` on an input and `value` on an
5679
- // option. Those now ride on the types instead.
5680
- //
5681
- // A custom component is exempt entirely — it owns its prop contract, and
5682
- // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
5683
- //
5684
- // The elements that own `disabled`, per the HTML spec. A closed set, unlike
5685
- // the open-ended question of which tag owns which attribute.
5686
- const SUBMIT_OVERRIDES = [
5687
- 'formAction',
5688
- 'formEncType',
5689
- 'formMethod',
5690
- 'formNoValidate',
5691
- 'formTarget',
5692
- ];
5693
- const OWNS_DISABLED = [
5694
- 'button',
5695
- 'fieldset',
5696
- 'input',
5697
- 'optgroup',
5698
- 'option',
5699
- 'select',
5700
- 'textarea',
5701
- ];
5702
- // Two independent filters. They are not exclusive — an `<a>` owns neither
5703
- // the submit-overrides nor `disabled`, so it takes both — and deriving them
5704
- // from one destructure, or from one if/else, is what broke each of them in
5705
- // turn: first the overrides came off every intrinsic (an `as="input"` lost
5706
- // `formAction`), then scoping them to the anchor let `disabled` back onto
5707
- // it.
5708
- const forwardedRest = { ...rest };
5709
- if (typeof Component === 'string' && !isCustomElement(Component)) {
5710
- // Meaningless anywhere but a submit control, and an anchor is not one.
5711
- if (Component === 'a') {
5712
- for (const key of SUBMIT_OVERRIDES)
5713
- delete forwardedRest[key];
5714
- }
5715
- // Visible via Bulma's `.button[disabled]`, so withheld from any element
5716
- // that does not own it.
5717
- if (!OWNS_DISABLED.includes(Component))
5718
- delete forwardedRest.disabled;
5730
+ function findTabStops(container) {
5731
+ const candidates = collectCandidates(container);
5732
+ const checked = new Map();
5733
+ const usable = (el) => {
5734
+ let ok = checked.get(el);
5735
+ if (ok === undefined) {
5736
+ ok = isRenderedAndEnabled(el, container);
5737
+ checked.set(el, ok);
5738
+ }
5739
+ return ok;
5740
+ };
5741
+ // Resolved once per group, filed under each of its buttons, since finding
5742
+ // the checked one scans the radio's whole tree.
5743
+ const groupStops = new Map();
5744
+ const groupStop = (radio) => {
5745
+ if (groupStops.has(radio))
5746
+ return groupStops.get(radio);
5747
+ const group = candidates.filter(el => sameGroup(radio, el));
5748
+ const on = checkedInGroup(radio);
5749
+ const stop = on && isTabbableKind(on) && usable(on)
5750
+ ? group.find(el => el === on)
5751
+ : group.find(usable);
5752
+ for (const el of [radio, ...group])
5753
+ groupStops.set(el, stop);
5754
+ return stop;
5755
+ };
5756
+ const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
5757
+ const first = candidates.find(isStop);
5758
+ if (!first)
5759
+ return null;
5760
+ let last = first;
5761
+ for (let i = candidates.length - 1; candidates[i] !== first; i--) {
5762
+ if (isStop(candidates[i])) {
5763
+ last = candidates[i];
5764
+ break;
5719
5765
  }
5720
- return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
5721
- ? // Guarded, because this handler is installed into whatever `as`
5722
- // renders and a custom component's `onClick` need not be a DOM
5723
- // event handler at all. One declaring `onClick: (v: string) =>
5724
- // void` calls this with a string, and an unguarded
5725
- // `e.preventDefault()` throws `is not a function`. Blocking the
5726
- // real event is the point; a foreign payload is simply not ours
5727
- // to act on.
5728
- (e) => {
5729
- if (typeof e?.preventDefault === 'function')
5730
- e.preventDefault();
5731
- }
5732
- : onClick, ...forwardedRest, children: children }));
5733
5766
  }
5734
- return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
5735
- });
5736
- Button.displayName = 'Button';
5737
-
5738
- const PopoverContext = React.createContext(null);
5739
- /** Intrinsic elements that are controls already, and keep their own role. */
5740
- const NATIVE_CONTROLS = new Set([
5741
- 'button',
5742
- 'input',
5743
- 'select',
5744
- 'textarea',
5745
- 'summary',
5746
- ]);
5767
+ return {
5768
+ first,
5769
+ last,
5770
+ stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
5771
+ };
5772
+ }
5747
5773
  /**
5748
- * Whether the browser answers this key on `el` itself, by clicking it or by
5749
- * using the key as input, so the trigger's click handler covers it.
5774
+ * The first tab stop inside `container`, found the way the trap finds it, or
5775
+ * `null` when there is none. For a component that moves focus into a panel
5776
+ * without trapping it there. `src/index.ts` names its exports from this
5777
+ * module, so this one stays internal.
5750
5778
  */
5751
- function handlesKeyItself(el, key) {
5752
- if (NATIVE_CONTROLS.has(el.localName))
5753
- return true;
5754
- return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
5755
- }
5756
- /** The innermost dialog the event passed through, which is the one its Escape belongs to. */
5757
- function nearestDialog(event) {
5758
- return (event.composedPath().find(node => {
5759
- const role = node.getAttribute?.('role');
5760
- return role === 'dialog' || role === 'alertdialog';
5761
- }) ?? null);
5779
+ function firstTabStop(container) {
5780
+ return findTabStops(container)?.first ?? null;
5762
5781
  }
5763
5782
  /**
5764
- * 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.
5783
+ * The element that has focus in the container's own document (an iframe's,
5784
+ * when it renders into one), inside open shadow roots too. Focus inside a
5785
+ * closed one reads as its host, which the comparisons above place like any
5786
+ * other element. Browsers fall back to <body> when nothing has
5787
+ * focus. jsdom, where consumers run their tests, reports nothing once focus
5788
+ * was inside a shadow root that was removed, so this falls back for it.
5789
+ */
5790
+ const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
5791
+ /**
5792
+ * Keeps keyboard focus inside a container while it is active: focus moves in
5793
+ * when the trap turns on, Tab and Shift+Tab wrap between the first and last
5794
+ * tab stops, and focus goes back where it came from when the trap turns off.
5765
5795
  *
5766
- * The panel is a non-modal `role="dialog"`: focus moves into it on open
5767
- * and returns to the trigger on close, while the rest of the page stays
5768
- * reachable by pointer and assistive technology. For hover-only hints use
5769
- * `Tooltip`, and for a list of actions use `Dropdown`.
5796
+ * Tab stops are what the browser's Tab visits, in document order: links,
5797
+ * form controls, frames, media with controls, a `<details>` summary, editable
5798
+ * regions, anything with `tabIndex={0}`, and the content of open shadow
5799
+ * roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
5800
+ * and a radio group counts once. Give the container `tabIndex={-1}` so it can
5801
+ * hold focus itself when nothing inside can. The trap listens on the
5802
+ * container, so a trap nested inside another handles Tab first and the outer
5803
+ * one stands aside.
5770
5804
  *
5771
- * @function
5772
- * @param {PopoverProps} props - Props for the Popover component.
5773
- * @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
5805
+ * The trap is about Tab. If a Tab it let through still takes focus out (a
5806
+ * browser can leave out a stop the page has, such as links when its settings
5807
+ * say to), it sends focus back to the other end. A pointer or a script moving
5808
+ * focus out is left alone, because pulling focus back from a click is modal
5809
+ * behaviour, and a modal also has to block the page behind it, which `Modal`
5810
+ * does. Content the container renders through a portal lives elsewhere in
5811
+ * the DOM, so it is outside the trap: render nested overlays inside the
5812
+ * container, or give them a trap of their own. To trap content a `Portal`
5813
+ * renders, pass a ref to the element inside the `Portal` as `containerRef`.
5814
+ *
5815
+ * The trap looks at `containerRef` again each time the component calling it
5816
+ * renders. A container that mounts after the trap turns on, such as a panel
5817
+ * waiting for a `Portal` target held in state, has it attach once that
5818
+ * component renders with the container in the DOM. When the container leaves
5819
+ * or is replaced, the trap lets go of it as it would turning off, handing
5820
+ * focus back, and attaches to the next one.
5821
+ * A child that mounts the container in a render of its own (from its own
5822
+ * state, or as a `Suspense` boundary resolves) goes unseen until the
5823
+ * component calling the hook renders again. The trap waits for hydration, so
5824
+ * that component renders again when the page hydrates, and portaled content,
5825
+ * which first appears then, is found.
5826
+ *
5827
+ * @function useFocusTrap
5828
+ * @param containerRef - Ref to the element focus stays inside.
5829
+ * @param options - When the trap is on, and where focus starts and ends.
5774
5830
  *
5775
5831
  * @example
5776
- * <Popover trigger={<Button>Filters</Button>}>
5777
- * <Popover.Header>Filter rows</Popover.Header>
5778
- * <Popover.Body>…</Popover.Body>
5779
- * <Popover.Footer>
5780
- * <Popover.Close>Done</Popover.Close>
5781
- * </Popover.Footer>
5782
- * </Popover>
5832
+ * const panelRef = useRef<HTMLDivElement>(null);
5833
+ * useFocusTrap(panelRef, { active: isOpen });
5834
+ * return isOpen ? (
5835
+ * <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
5836
+ * …
5837
+ * </div>
5838
+ * ) : null;
5783
5839
  */
5784
- const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
5785
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5786
- const rootRef = React.useRef(null);
5787
- const panelRef = React.useRef(null);
5788
- // The last pointerdown React saw inside the panel. React's tree includes
5789
- // content the panel portals elsewhere, which the DOM path doesn't.
5790
- const insidePointerRef = React.useRef(null);
5791
- const baseId = React.useId();
5792
- const panelId = `${baseId}-panel`;
5793
- const titleId = `${baseId}-title`;
5794
- const isControlled = controlledOpen !== undefined;
5795
- const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
5796
- const isOpen = controlledOpen ?? internalOpen;
5797
- const requestOpen = React.useCallback((next) => {
5798
- onOpenChange?.(next);
5799
- if (!isControlled)
5800
- setInternalOpen(next);
5801
- }, [isControlled, onOpenChange]);
5802
- const close = React.useCallback(() => requestOpen(false), [requestOpen]);
5803
- // A portaled panel has no server markup, so it only exists from the commit
5804
- // after hydration. Everything that measures or focuses it waits for that.
5840
+ function useFocusTrap(containerRef, options = {}) {
5841
+ const { active = true, initialFocusRef, restoreFocus = true } = options;
5842
+ // Portaled content has no server-rendered counterpart, so a portaled
5843
+ // container only exists from the commit after hydration.
5805
5844
  const hydrated = useIsHydrated();
5806
- const panelMounted = isOpen && (hydrated || !appendToBody);
5807
- const resolved = useAnchoredPosition(rootRef, panelRef, {
5808
- active: panelMounted,
5809
- position,
5810
- fixed: appendToBody,
5811
- // The gap comes from `--bulma-popover-offset` in the stylesheet, so the
5812
- // coordinates leave it out and `auto` reads it off the panel.
5813
- offset: 0,
5814
- styledGap: marginGap,
5815
- });
5816
- // The trigger renders first inside the wrapper. Read when needed rather
5817
- // than held, so a trigger that remounted is still the one found.
5818
- const triggerNode = React.useCallback(() => {
5819
- const first = rootRef.current?.firstElementChild;
5820
- return first && first !== panelRef.current ? first : null;
5821
- }, []);
5822
- const restoreFocusRef = React.useMemo(() => ({
5823
- get current() {
5824
- return triggerNode();
5825
- },
5826
- }), [triggerNode]);
5827
- useFocusTrap(panelRef, {
5828
- active: panelMounted && trapFocus,
5829
- restoreFocus: restoreFocusRef,
5830
- });
5831
- // Without the trap, focus still arrives in the panel and goes back to the
5832
- // trigger, on the trap's terms: only while the panel still holds it.
5845
+ // The container the trap is attached to, or `null` while it has none.
5846
+ const heldRef = React.useRef(null);
5847
+ // Bumped to run the trap again when the container in the ref changes.
5848
+ const [refChanges, setRefChanges] = React.useState(0);
5833
5849
  React.useEffect(() => {
5834
- const panel = panelRef.current;
5835
- if (!panelMounted || trapFocus || !panel)
5850
+ heldRef.current = null;
5851
+ if (!active || !hydrated)
5836
5852
  return undefined;
5837
- (firstTabStop(panel) ?? panel).focus();
5838
- return () => {
5839
- const doc = panel.ownerDocument;
5840
- const now = getDeepestActiveElement(doc);
5841
- if (!now || now === doc.body || panel.contains(now)) {
5842
- triggerNode()?.focus();
5843
- }
5844
- };
5845
- }, [panelMounted, trapFocus, triggerNode]);
5846
- React.useEffect(() => {
5847
- if (!panelMounted || !closeOnClickOutside)
5853
+ const container = containerRef.current;
5854
+ if (!container)
5848
5855
  return undefined;
5849
- const handlePointerDown = (e) => {
5850
- if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
5856
+ heldRef.current = container;
5857
+ const doc = container.ownerDocument;
5858
+ const opener = focusedElement(doc);
5859
+ // `initialFocusRef` counts only when focusing it puts focus inside. Focus
5860
+ // put outside would stay out, as Tab there never reaches the container's
5861
+ // listener, and focusing something that can't take focus (a heading
5862
+ // without `tabIndex`, a disabled control) leaves focus where it was.
5863
+ const initial = initialFocusRef?.current;
5864
+ const inTrap = initial && inside(container, initial) ? initial : null;
5865
+ inTrap?.focus();
5866
+ if (!inTrap || !inside(container, focusedElement(doc))) {
5867
+ (findTabStops(container)?.first ?? container).focus();
5868
+ }
5869
+ // Where focus goes if a Tab the trap let the browser handle takes it out,
5870
+ // held until that Tab has finished moving focus.
5871
+ let wrapTo = null;
5872
+ let wrapTimer;
5873
+ const handleKeyDown = (e) => {
5874
+ // A trap nested inside this one has already wrapped focus.
5875
+ if (e.key !== 'Tab' || e.defaultPrevented)
5876
+ return;
5877
+ const stops = findTabStops(container);
5878
+ if (!stops) {
5879
+ e.preventDefault();
5880
+ container.focus();
5851
5881
  return;
5852
5882
  }
5853
- close();
5854
- };
5855
- // The wrapper is on the page whenever the panel is. Its document, an
5856
- // iframe's when the tree renders into one, is where presses land.
5857
- const doc = rootRef.current.ownerDocument;
5858
- doc.addEventListener('pointerdown', handlePointerDown);
5859
- return () => doc.removeEventListener('pointerdown', handlePointerDown);
5860
- }, [panelMounted, closeOnClickOutside, close]);
5861
- const handlePanelKeyDown = (e) => {
5862
- if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
5863
- return;
5864
- // React bubbles the key here from portaled content as well, so a dialog
5865
- // of its own on the way (a nested popover, a picker's calendar) owns it.
5866
- const owner = nearestDialog(e.nativeEvent);
5867
- if (owner && owner !== panelRef.current)
5868
- return;
5869
- e.preventDefault();
5870
- close();
5871
- };
5872
- const triggerProps = trigger.props;
5873
- const handleTriggerClick = (e) => {
5874
- triggerProps.onClick?.(e);
5875
- if (!e.defaultPrevented)
5876
- requestOpen(!isOpen);
5877
- };
5878
- const handleTriggerKeyDown = (e) => {
5879
- triggerProps.onKeyDown?.(e);
5880
- // A key on something inside the trigger is that element's to answer.
5881
- if (e.defaultPrevented || e.target !== e.currentTarget)
5882
- return;
5883
- if (e.key === 'Escape') {
5884
- if (isOpen && closeOnEscape) {
5883
+ const current = stops.stopFor(focusedElement(doc));
5884
+ // Wrap whenever the browser has no stop left in that direction inside
5885
+ // the container. Comparing positions rather than testing `=== first`
5886
+ // also covers focus resting on the container or on an element outside
5887
+ // the tab order, which the browser would otherwise walk straight out of.
5888
+ if (e.shiftKey
5889
+ ? !follows(container, stops.first, current)
5890
+ : !follows(container, current, stops.last)) {
5885
5891
  e.preventDefault();
5886
- close();
5892
+ (e.shiftKey ? stops.last : stops.first).focus();
5893
+ return;
5887
5894
  }
5888
- return;
5889
- }
5890
- if (e.key !== 'Enter' && e.key !== ' ')
5891
- return;
5892
- if (handlesKeyItself(e.currentTarget, e.key))
5893
- return;
5894
- // Space would scroll the page; a held key repeats.
5895
- e.preventDefault();
5896
- if (!e.repeat)
5897
- requestOpen(!isOpen);
5898
- };
5899
- const needsButtonRole = typeof trigger.type === 'string' &&
5900
- !NATIVE_CONTROLS.has(trigger.type) &&
5901
- !(trigger.type === 'a' && triggerProps.href != null);
5902
- const clonedTrigger = React.cloneElement(trigger, {
5903
- ...(needsButtonRole
5904
- ? {
5905
- role: triggerProps.role ?? 'button',
5906
- tabIndex: triggerProps.tabIndex ?? 0,
5895
+ wrapTo = e.shiftKey ? stops.last : stops.first;
5896
+ clearTimeout(wrapTimer);
5897
+ wrapTimer = setTimeout(() => {
5898
+ wrapTo = null;
5899
+ });
5900
+ };
5901
+ // The browser moves focus as soon as the keydown is over, so focus that
5902
+ // lands outside while `wrapTo` is set got there by that Tab.
5903
+ const handleFocusIn = (e) => {
5904
+ if (!wrapTo || inside(container, e.composedPath()[0]))
5905
+ return;
5906
+ const target = wrapTo;
5907
+ wrapTo = null;
5908
+ target.focus();
5909
+ };
5910
+ container.addEventListener('keydown', handleKeyDown);
5911
+ doc.addEventListener('focusin', handleFocusIn, true);
5912
+ return () => {
5913
+ container.removeEventListener('keydown', handleKeyDown);
5914
+ doc.removeEventListener('focusin', handleFocusIn, true);
5915
+ clearTimeout(wrapTimer);
5916
+ let target = restoreFocus ? opener : null;
5917
+ // `null` is an object too, and a caller without types can pass it.
5918
+ if (restoreFocus && typeof restoreFocus === 'object') {
5919
+ // Read now rather than when the trap turned on: a trigger that
5920
+ // re-mounted in between has a new node.
5921
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5922
+ target = restoreFocus.current;
5907
5923
  }
5908
- : {}),
5909
- 'aria-haspopup': 'dialog',
5910
- 'aria-expanded': isOpen,
5911
- 'aria-controls': panelMounted ? panelId : undefined,
5912
- onClick: handleTriggerClick,
5913
- onKeyDown: handleTriggerKeyDown,
5914
- });
5915
- const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
5916
- const panelClasses = classNames(usePrefixedClassNames('popover-content', {
5917
- [`is-${resolved.position}`]: true,
5918
- 'is-portal': appendToBody,
5919
- }), contentClassName);
5920
- return (jsxRuntime.jsxs("div", { className: rootClasses, ...rest, ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx(PopoverPanel, { panelRef: panelRef, titleId: titleId, ariaLabel: ariaLabel, close: close, id: panelId, className: panelClasses, style: appendToBody
5921
- ? {
5922
- position: 'fixed',
5923
- top: resolved.top,
5924
- left: resolved.left,
5925
- }
5926
- : undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
5927
- insidePointerRef.current = e.nativeEvent;
5928
- }, children: children }) }))] }));
5929
- };
5930
- /**
5931
- * The open panel. It mounts with each opening, so it counts the
5932
- * `Popover.Header`s inside it afresh every time.
5933
- */
5934
- function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
5935
- // Headers register in a layout effect, after this first render, and this
5936
- // panel's own layout effect runs after theirs. Until then the panel assumes
5937
- // it has one, so a panel that does is named from its first commit, before
5938
- // focus moves in, and one that doesn't drops the reference before it paints.
5939
- const [titlesCounted, setTitlesCounted] = React.useState(false);
5940
- const [titleCount, setTitleCount] = React.useState(0);
5941
- const registerTitle = React.useCallback(() => {
5942
- setTitleCount(n => n + 1);
5943
- return () => setTitleCount(n => n - 1);
5944
- }, []);
5945
- useClientLayoutEffect(() => {
5946
- setTitlesCounted(true);
5947
- }, []);
5948
- const named = Boolean(ariaLabel) || titleCount > 0;
5924
+ // Hand focus back only while the trap still holds it: inside the
5925
+ // container, or dropped to <body> because the container left the page.
5926
+ const now = focusedElement(doc);
5927
+ if (target && (now === doc.body || inside(container, now))) {
5928
+ target.focus();
5929
+ }
5930
+ };
5931
+ }, [
5932
+ active,
5933
+ hydrated,
5934
+ containerRef,
5935
+ initialFocusRef,
5936
+ restoreFocus,
5937
+ refChanges,
5938
+ ]);
5939
+ // Nothing says when a ref is set, so after every render, a ref that no
5940
+ // longer matches what the trap holds (a container that mounted, left or was
5941
+ // replaced) runs it again. Effects run in order, so whenever the effect
5942
+ // above ran in this commit, it read the ref just before this does, and a
5943
+ // mismatch means the ref changed in a render it didn't run for. The render
5944
+ // a bump asks for runs it, so the two match and the trap settles. An empty
5945
+ // ref matches a trap holding nothing, so a container that never mounts
5946
+ // costs no render.
5947
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
5949
5948
  React.useEffect(() => {
5950
- if (titlesCounted && !named) {
5951
- warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
5952
- 'Pass ariaLabel, or render a Popover.Header inside it.');
5949
+ if (active && hydrated && containerRef.current !== heldRef.current) {
5950
+ setRefChanges(n => n + 1);
5953
5951
  }
5954
- }, [titlesCounted, named]);
5955
- const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
5956
- return (jsxRuntime.jsx(PopoverContext.Provider, { value: context, children: jsxRuntime.jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
5952
+ });
5957
5953
  }
5954
+
5955
+ // INTERNAL: deliberately not exported from src/index.ts.
5958
5956
  /**
5959
- * The popover's title, which names the panel for assistive technology.
5960
- *
5961
- * @function
5962
- * @param {PopoverHeaderProps} props - Props for the Popover.Header component.
5963
- * @returns {JSX.Element} The header element.
5957
+ * A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
5958
+ * side facing the anchor, negative on a portaled panel above it, so the
5959
+ * larger magnitude is the gap. In pixels.
5964
5960
  */
5965
- const PopoverHeader = ({ className, ...props }) => {
5966
- const context = React.useContext(PopoverContext);
5967
- const registerTitle = context?.registerTitle;
5968
- // Before paint, so the panel is named by the time it shows.
5969
- useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
5970
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5971
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
5972
- };
5961
+ function marginGap(panel) {
5962
+ const { marginTop, marginBottom } = getComputedStyle(panel);
5963
+ return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
5964
+ }
5973
5965
  /**
5974
- * The popover's main content area.
5975
- *
5976
- * @function
5977
- * @param {PopoverBodyProps} props - Props for the Popover.Body component.
5978
- * @returns {JSX.Element} The body element.
5966
+ * The corner that keeps a panel of this size in the viewport, preferring
5967
+ * below the anchor and lined up with its left edge.
5979
5968
  */
5980
- const PopoverBody = ({ className, ...props }) => {
5981
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5982
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
5983
- };
5969
+ function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
5970
+ const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
5971
+ const fitsRight = rect.left + panelWidth <= view.innerWidth;
5972
+ if (fitsBelow)
5973
+ return fitsRight ? 'bottom-left' : 'bottom-right';
5974
+ return fitsRight ? 'top-left' : 'top-right';
5975
+ }
5976
+ const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
5984
5977
  /**
5985
- * A row of actions along the bottom of the popover, aligned to the end.
5978
+ * Places a floating panel against its anchor: resolves `auto` to a corner
5979
+ * and, for a panel fixed to the viewport, works out its coordinates. It
5980
+ * measures again on resize and on any scroll of the anchor's window (an
5981
+ * iframe's, when the anchor renders into one), so a fixed panel follows its
5982
+ * anchor. A measurement that changes nothing doesn't render again.
5986
5983
  *
5987
- * @function
5988
- * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
5989
- * @returns {JSX.Element} The footer element.
5984
+ * Measuring happens in a layout effect, so the panel is placed before it
5985
+ * paints. It needs both elements on the page while `active` is on, and
5986
+ * keeps the last placement when either is missing.
5987
+ *
5988
+ * @function useAnchoredPosition
5989
+ * @param anchorRef - The element the panel opens from.
5990
+ * @param panelRef - The floating panel.
5991
+ * @param options - Whether to measure, the corner asked for, and how the panel is placed.
5992
+ * @returns The corner the panel opens from, with coordinates when it is fixed.
5990
5993
  */
5991
- const PopoverFooter = ({ className, ...props }) => {
5992
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5993
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
5994
- };
5994
+ function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
5995
+ const [resolved, setResolved] = React.useState({
5996
+ position: position === 'auto' ? 'bottom-left' : position,
5997
+ });
5998
+ useClientLayoutEffect(() => {
5999
+ if (!active)
6000
+ return undefined;
6001
+ const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
6002
+ window;
6003
+ const update = () => {
6004
+ const anchor = anchorRef.current;
6005
+ const panel = panelRef.current;
6006
+ if (!anchor || !panel)
6007
+ return;
6008
+ const rect = anchor.getBoundingClientRect();
6009
+ const { width, height } = panel.getBoundingClientRect();
6010
+ const corner = position === 'auto'
6011
+ ? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
6012
+ : position;
6013
+ const next = fixed
6014
+ ? {
6015
+ position: corner,
6016
+ top: corner.startsWith('bottom')
6017
+ ? rect.bottom + offset
6018
+ : rect.top - height - offset,
6019
+ left: corner.endsWith('left') ? rect.left : rect.right - width,
6020
+ }
6021
+ : { position: corner };
6022
+ setResolved(prev => (samePosition(prev, next) ? prev : next));
6023
+ };
6024
+ update();
6025
+ view.addEventListener('resize', update, { passive: true });
6026
+ view.addEventListener('scroll', update, { passive: true, capture: true });
6027
+ return () => {
6028
+ view.removeEventListener('resize', update);
6029
+ view.removeEventListener('scroll', update, { capture: true });
6030
+ };
6031
+ }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
6032
+ return resolved;
6033
+ }
6034
+
6035
+ const validButtonColors = [...validColors, 'text', 'ghost'];
5995
6036
  /**
5996
- * A `Button` that closes the popover, handing focus back to the trigger.
6037
+ * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5997
6038
  *
5998
6039
  * @function
5999
- * @param {PopoverCloseProps} props - Props for the Popover.Close component.
6000
- * @returns {JSX.Element} The close button.
6040
+ * @param {ButtonProps} props - Props for the Button component.
6041
+ * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
6042
+ * @returns {JSX.Element} The rendered button, anchor, or custom element.
6043
+ * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
6001
6044
  */
6002
- const PopoverClose = ({ onClick, ...props }) => {
6003
- const context = React.useContext(PopoverContext);
6004
- return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
6005
- onClick?.(e);
6006
- if (!e.defaultPrevented)
6007
- context?.close();
6008
- } }));
6009
- };
6010
- /** Popover with Header, Body, Footer and Close sub-components. */
6011
- const Popover = withSubComponents(PopoverComponent, {
6012
- Header: PopoverHeader,
6013
- Body: PopoverBody,
6014
- Footer: PopoverFooter,
6015
- Close: PopoverClose,
6016
- }, 'Popover');
6045
+ const Button = React.forwardRef(function Button(props, ref) {
6046
+ 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;
6047
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
6048
+ color: textColor,
6049
+ backgroundColor: bgColor,
6050
+ ...bulmaProps,
6051
+ });
6052
+ // Generate Bulma classes with prefix
6053
+ const bulmaClasses = usePrefixedClassNames('button', {
6054
+ [`is-${color}`]: color &&
6055
+ validButtonColors.includes(color),
6056
+ [`is-${size}`]: size,
6057
+ 'is-outlined': isOutlined,
6058
+ 'is-light': isLight,
6059
+ 'is-loading': isLoading,
6060
+ 'is-static': isStatic,
6061
+ 'is-disabled': isDisabled,
6062
+ 'is-rounded': isRounded,
6063
+ 'is-hovered': isHovered,
6064
+ 'is-focused': isFocused,
6065
+ 'is-active': isActive,
6066
+ 'is-inverted': isInverted,
6067
+ 'is-fullwidth': isFullwidth ?? isFullWidth,
6068
+ });
6069
+ // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
6070
+ const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
6071
+ if (Component !== 'button') {
6072
+ // Strip the form-control attributes a link-like element cannot carry, so a
6073
+ // JavaScript consumer or a spread object doesn't put them on the DOM. The
6074
+ // types no longer allow them through; this is the runtime backstop.
6075
+ //
6076
+ // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
6077
+ // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
6078
+ // its props reach it. Stripping names by their spelling alone broke that
6079
+ // promise silently: `<Button as={Custom} name="x" />` type-checked while
6080
+ // `Custom` never received the `name` it requires.
6081
+ //
6082
+ // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
6083
+ // targets this list was filtering them from. `autoFocus` is a global
6084
+ // attribute (React decides per element whether to honour it, which is not
6085
+ // ours to pre-empt), and `type` on an anchor is the MIME hint that
6086
+ // `ButtonProps<'a'>` now types it as.
6087
+ //
6088
+ // The types already answer this for a TypeScript caller: `ButtonProps<T>`
6089
+ // derives from `ComponentPropsWithoutRef<T>`, so only what the target
6090
+ // accepts can be passed. What is left is a runtime backstop for JavaScript
6091
+ // consumers and spread objects.
6092
+ //
6093
+ // Keep it to the two cases where a stray attribute does something. The
6094
+ // submit-overrides belong to a submit control and never make sense on the
6095
+ // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
6096
+ // `.button[disabled]` (background, border, shadow, opacity), so letting it
6097
+ // through to a `<span>` greys the element out — main stripped it, and not
6098
+ // stripping it would be a silent visual change on a types-only release.
6099
+ //
6100
+ // Everything else (`name`, `value`, `form`) is inert where it does not
6101
+ // belong, and enumerating which tags own it is what kept going wrong:
6102
+ // first all nine names on every non-button tag (so `<Button as="input"
6103
+ // name="query" value="Search">` submitted nothing), then a form-control
6104
+ // allowlist that still lost `formAction` on an input and `value` on an
6105
+ // option. Those now ride on the types instead.
6106
+ //
6107
+ // A custom component is exempt entirely — it owns its prop contract, and
6108
+ // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
6109
+ //
6110
+ // The elements that own `disabled`, per the HTML spec. A closed set, unlike
6111
+ // the open-ended question of which tag owns which attribute.
6112
+ const SUBMIT_OVERRIDES = [
6113
+ 'formAction',
6114
+ 'formEncType',
6115
+ 'formMethod',
6116
+ 'formNoValidate',
6117
+ 'formTarget',
6118
+ ];
6119
+ const OWNS_DISABLED = [
6120
+ 'button',
6121
+ 'fieldset',
6122
+ 'input',
6123
+ 'optgroup',
6124
+ 'option',
6125
+ 'select',
6126
+ 'textarea',
6127
+ ];
6128
+ // Two independent filters. They are not exclusive — an `<a>` owns neither
6129
+ // the submit-overrides nor `disabled`, so it takes both — and deriving them
6130
+ // from one destructure, or from one if/else, is what broke each of them in
6131
+ // turn: first the overrides came off every intrinsic (an `as="input"` lost
6132
+ // `formAction`), then scoping them to the anchor let `disabled` back onto
6133
+ // it.
6134
+ const forwardedRest = { ...rest };
6135
+ if (typeof Component === 'string' && !isCustomElement(Component)) {
6136
+ // Meaningless anywhere but a submit control, and an anchor is not one.
6137
+ if (Component === 'a') {
6138
+ for (const key of SUBMIT_OVERRIDES)
6139
+ delete forwardedRest[key];
6140
+ }
6141
+ // Visible via Bulma's `.button[disabled]`, so withheld from any element
6142
+ // that does not own it.
6143
+ if (!OWNS_DISABLED.includes(Component))
6144
+ delete forwardedRest.disabled;
6145
+ }
6146
+ return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
6147
+ ? // Guarded, because this handler is installed into whatever `as`
6148
+ // renders and a custom component's `onClick` need not be a DOM
6149
+ // event handler at all. One declaring `onClick: (v: string) =>
6150
+ // void` calls this with a string, and an unguarded
6151
+ // `e.preventDefault()` throws `is not a function`. Blocking the
6152
+ // real event is the point; a foreign payload is simply not ours
6153
+ // to act on.
6154
+ (e) => {
6155
+ if (typeof e?.preventDefault === 'function')
6156
+ e.preventDefault();
6157
+ }
6158
+ : onClick, ...forwardedRest, children: children }));
6159
+ }
6160
+ return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
6161
+ });
6162
+ Button.displayName = 'Button';
6017
6163
 
6018
- // INTERNAL: deliberately not exported from src/index.ts.
6164
+ const PopoverContext = React.createContext(null);
6165
+ /** Intrinsic elements that are controls already, and keep their own role. */
6166
+ const NATIVE_CONTROLS = new Set([
6167
+ 'button',
6168
+ 'input',
6169
+ 'select',
6170
+ 'textarea',
6171
+ 'summary',
6172
+ ]);
6019
6173
  /**
6020
- * Whether focus that just arrived on `el` is keyboard focus, as the browser
6021
- * decides it for `:focus-visible`: focus from a key or from the browser's
6022
- * own controls is, focus a press brings isn't, and focus a script moves
6023
- * follows whichever came last. A browser that can't match the selector
6024
- * throws, and then every focus counts, so keyboard users can still open it.
6174
+ * Whether the browser answers this key on `el` itself, by clicking it or by
6175
+ * using the key as input, so the trigger's click handler covers it.
6025
6176
  */
6026
- function isKeyboardFocus(el) {
6027
- try {
6028
- return el.matches(':focus-visible');
6029
- }
6030
- catch {
6177
+ function handlesKeyItself(el, key) {
6178
+ if (NATIVE_CONTROLS.has(el.localName))
6031
6179
  return true;
6032
- }
6180
+ return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
6033
6181
  }
6034
- /** Whether `node` is `container` or inside it, when there are both. */
6035
- const within = (container, node) => Boolean(container && node && container.contains(node));
6036
- /**
6037
- * Per document, the hooks that want Escape and the one listener they share
6038
- * while there are any, so a card inside another card doesn't add a second
6039
- * listener that closes both on one keypress.
6040
- */
6041
- const escapeListeners = new Map();
6042
- /** Counts openings across the page, so the most recent one is known. */
6043
- let openings = 0;
6044
- /**
6045
- * Adds `entry` to the hooks that want Escape on `doc`, starting the shared
6046
- * listener when it is the first.
6047
- *
6048
- * Escape takes back every open that is on its way, so nothing appears right
6049
- * after it, and closes one open floating element, chosen by these rules in
6050
- * turn:
6051
- *
6052
- * 1. One that focus is in, on its trigger or inside it, when there is one.
6053
- * 2. The innermost: one whose floating element holds no other's trigger.
6054
- * That follows nesting whether either one is portaled, since an inner
6055
- * trigger sits in the outer floating element either way.
6056
- * 3. The one that opened last.
6057
- *
6058
- * The keypress belongs to whatever has focus. When that is one of the open
6059
- * floating elements or their triggers, or nothing, the listener stops it in
6060
- * the capture phase, before anything around (a Popover, a Modal) sees it.
6061
- * When focus is anywhere else, such as a combobox or a dialog of its own,
6062
- * the floating element still closes, since hover put it there without
6063
- * focus, and the key goes on to that owner.
6064
- *
6065
- * @returns What to call when the hook stops wanting Escape.
6066
- */
6067
- function wantEscape(doc, entry) {
6068
- let listener = escapeListeners.get(doc);
6069
- if (!listener) {
6070
- const entries = new Set();
6071
- const handleKeyDown = (event) => {
6072
- if (event.key !== 'Escape')
6073
- return;
6074
- const open = [];
6075
- // A copy, since taking an open back can take its hook out of the set.
6076
- for (const each of [...entries]) {
6077
- if (each.openedAt)
6078
- open.push(each);
6079
- else
6080
- each.cancel();
6081
- }
6082
- const focused = getDeepestActiveElement(doc);
6083
- const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
6084
- const pool = holding.length > 0 ? holding : open;
6085
- let top;
6086
- for (const each of pool) {
6087
- const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
6088
- if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
6089
- top = each;
6090
- }
6091
- }
6092
- if (!top)
6093
- return;
6094
- if (holding.length > 0 || !focused || focused === doc.body) {
6095
- event.preventDefault();
6096
- event.stopPropagation();
6097
- }
6098
- top.close();
6099
- };
6100
- doc.addEventListener('keydown', handleKeyDown, true);
6101
- listener = {
6102
- entries,
6103
- stop: () => {
6104
- doc.removeEventListener('keydown', handleKeyDown, true);
6105
- escapeListeners.delete(doc);
6106
- },
6107
- };
6108
- escapeListeners.set(doc, listener);
6109
- }
6110
- const shared = listener;
6111
- shared.entries.add(entry);
6112
- return () => {
6113
- shared.entries.delete(entry);
6114
- if (shared.entries.size === 0)
6115
- shared.stop();
6116
- };
6182
+ /** The innermost dialog the event passed through, which is the one its Escape belongs to. */
6183
+ function nearestDialog(event) {
6184
+ return (event.composedPath().find(node => {
6185
+ const role = node.getAttribute?.('role');
6186
+ return role === 'dialog' || role === 'alertdialog';
6187
+ }) ?? null);
6117
6188
  }
6118
6189
  /**
6119
- * Decides when a floating element that opens on hover or keyboard focus asks
6120
- * to open and to close. It renders nothing, and leaves roles, ARIA and
6121
- * placement to the component that calls it.
6190
+ * 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.
6122
6191
  *
6123
- * - The pointer entering, or keyboard focus arriving on the trigger, asks to
6124
- * open after `openDelay`. A touch pointer doesn't count. Neither does focus
6125
- * that a press brings to the trigger: it counts when the browser shows it
6126
- * as `:focus-visible`.
6127
- * - Focus in the floating element holds it open however it got there,
6128
- * including focus in content it portals elsewhere, such as a nested card.
6129
- * Whether a click focuses what it lands on is up to the browser: Safari
6130
- * and Firefox on macOS don't focus a button on click, so there a clicked
6131
- * button doesn't hold it once the pointer leaves.
6132
- * - Once the pointer and focus have both left, it asks to close after
6133
- * `closeDelay`, and either coming back before then keeps it open. Nothing
6134
- * else closes it on a timer.
6135
- * - Escape closes it at once, wherever focus is, and stops there when focus
6136
- * is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
6137
- * keeps its own. With focus somewhere else, the key goes on after closing
6138
- * it. Inside another one, the inner one closes first. Escape before it
6139
- * opens takes the opening back and goes on its way. One key listener per
6140
- * document serves every hook on it, there only while one of them is open
6141
- * or on its way to open.
6142
- * - With `openOnTap`, a tap on the trigger opens it at once and the next tap
6143
- * on the trigger closes it. While a tap holds it open, a press outside the
6144
- * trigger and the floating element closes it, from a listener on the
6145
- * document that is there only then. Taps never count as hover, and mouse
6146
- * and pen work the same with it or without.
6147
- * - Once it closes, for any reason, whatever was holding it open has to
6148
- * leave and come back to open it again.
6149
- * - When the floating element closes with focus inside it, focus goes back
6150
- * to the trigger, unless the same update put it somewhere else. Handing it
6151
- * back doesn't count as focus arriving.
6192
+ * The panel is a non-modal `role="dialog"`: focus moves into it on open
6193
+ * and returns to the trigger on close, while the rest of the page stays
6194
+ * reachable by pointer and assistive technology. For hover-only hints use
6195
+ * `Tooltip`, and for a list of actions use `Dropdown`.
6152
6196
  *
6153
- * Nothing it scheduled runs after the component unmounts.
6197
+ * @function
6198
+ * @param {PopoverProps} props - Props for the Popover component.
6199
+ * @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
6154
6200
  *
6155
- * @function useHoverOpen
6156
- * @param options - The open state, how to ask for another, the delays, and the two elements.
6157
- * @returns Handlers for the element that holds the trigger.
6201
+ * @example
6202
+ * <Popover trigger={<Button>Filters</Button>}>
6203
+ * <Popover.Header>Filter rows</Popover.Header>
6204
+ * <Popover.Body>…</Popover.Body>
6205
+ * <Popover.Footer>
6206
+ * <Popover.Close>Done</Popover.Close>
6207
+ * </Popover.Footer>
6208
+ * </Popover>
6158
6209
  */
6159
- function useHoverOpen(options) {
6160
- const latestRef = React.useRef(options);
6161
- const stateRef = React.useRef({
6162
- pointerIn: false,
6163
- focusIn: false,
6164
- focusInFloating: false,
6165
- held: false,
6166
- opening: false,
6167
- asked: false,
6168
- restoring: false,
6210
+ const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
6211
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6212
+ const rootRef = React.useRef(null);
6213
+ const panelRef = React.useRef(null);
6214
+ // The last pointerdown React saw inside the panel. React's tree includes
6215
+ // content the panel portals elsewhere, which the DOM path doesn't.
6216
+ const insidePointerRef = React.useRef(null);
6217
+ const baseId = React.useId();
6218
+ const panelId = `${baseId}-panel`;
6219
+ const titleId = `${baseId}-title`;
6220
+ const isControlled = controlledOpen !== undefined;
6221
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
6222
+ const isOpen = controlledOpen ?? internalOpen;
6223
+ const requestOpen = React.useCallback((next) => {
6224
+ onOpenChange?.(next);
6225
+ if (!isControlled)
6226
+ setInternalOpen(next);
6227
+ }, [isControlled, onOpenChange]);
6228
+ const close = React.useCallback(() => requestOpen(false), [requestOpen]);
6229
+ // A portaled panel has no server markup, so it only exists from the commit
6230
+ // after hydration. Everything that measures or focuses it waits for that.
6231
+ const hydrated = useIsHydrated();
6232
+ const panelMounted = isOpen && (hydrated || !appendToBody);
6233
+ const resolved = useAnchoredPosition(rootRef, panelRef, {
6234
+ active: panelMounted,
6235
+ position,
6236
+ fixed: appendToBody,
6237
+ // The gap comes from `--bulma-popover-offset` in the stylesheet, so the
6238
+ // coordinates leave it out and `auto` reads it off the panel.
6239
+ offset: 0,
6240
+ styledGap: marginGap,
6169
6241
  });
6170
- // In the hooks that want Escape from the moment an open is on its way until
6171
- // the floating element closes, and out otherwise. Stamped with the order it
6172
- // opened in.
6173
- const listenForEscape = React.useCallback(function listen() {
6174
- const s = stateRef.current;
6175
- const entry = entryRef.current;
6176
- const { open, triggerRef } = latestRef.current;
6177
- if (!open)
6178
- entry.openedAt = 0;
6179
- else if (!entry.openedAt) {
6180
- openings += 1;
6181
- entry.openedAt = openings;
6182
- }
6183
- const wanted = open || s.opening || s.asked;
6184
- if (wanted === Boolean(s.releaseEscape))
6185
- return;
6186
- if (s.releaseEscape) {
6187
- s.releaseEscape();
6188
- s.releaseEscape = undefined;
6189
- return;
6190
- }
6191
- const doc = triggerRef.current?.ownerDocument ?? document;
6192
- s.releaseEscape = wantEscape(doc, entry);
6242
+ // The trigger renders first inside the wrapper. Read when needed rather
6243
+ // than held, so a trigger that remounted is still the one found.
6244
+ const triggerNode = React.useCallback(() => {
6245
+ const first = rootRef.current?.firstElementChild;
6246
+ return first && first !== panelRef.current ? first : null;
6193
6247
  }, []);
6194
- const entryRef = React.useRef({
6195
- openedAt: 0,
6196
- trigger: () => latestRef.current.triggerRef.current,
6197
- floating: () => latestRef.current.floatingRef.current,
6198
- // Only ever called while an open is on its way, so it is one or the
6199
- // other: still waiting on its delay, or asked for already.
6200
- cancel: () => {
6201
- const s = stateRef.current;
6202
- clearTimeout(s.timer);
6203
- // An ask already made is taken back by asking the other way.
6204
- if (s.asked)
6205
- latestRef.current.onOpenChange(false);
6206
- s.opening = false;
6207
- s.asked = false;
6208
- listenForEscape();
6209
- },
6210
- close: () => {
6211
- clearTimeout(stateRef.current.timer);
6212
- latestRef.current.onOpenChange(false);
6248
+ const restoreFocusRef = React.useMemo(() => ({
6249
+ get current() {
6250
+ return triggerNode();
6213
6251
  },
6252
+ }), [triggerNode]);
6253
+ useFocusTrap(panelRef, {
6254
+ active: panelMounted && trapFocus,
6255
+ restoreFocus: restoreFocusRef,
6214
6256
  });
6215
- // Asks to open or to close. Escape can take an ask to open back until it is
6216
- // answered: by the commit that follows it, or, when the caller keeps it
6217
- // closed without rendering at all, once the event that asked is over, by
6218
- // which time React has rendered any answer the caller set.
6219
- const ask = React.useCallback((next) => {
6220
- const s = stateRef.current;
6221
- s.asked = next;
6222
- latestRef.current.onOpenChange(next);
6223
- listenForEscape();
6224
- if (!next)
6225
- return;
6226
- clearTimeout(s.askTimer);
6227
- s.askTimer = setTimeout(() => {
6228
- // Still closed: the caller turned it down.
6229
- if (latestRef.current.open)
6230
- return;
6231
- s.asked = false;
6232
- s.tappedIn = undefined;
6233
- listenForEscape();
6234
- });
6235
- }, [listenForEscape]);
6236
- // Asks for what the pointer and focus call for, after its delay, when that
6237
- // changed since the last time and isn't the state already.
6238
- const settle = React.useCallback(() => {
6239
- const s = stateRef.current;
6240
- const held = s.pointerIn || s.focusIn;
6241
- if (held !== s.held) {
6242
- s.held = held;
6243
- clearTimeout(s.timer);
6244
- const { open, openDelay, closeDelay } = latestRef.current;
6245
- s.opening = held && !open;
6246
- if (held !== open) {
6247
- s.timer = setTimeout(() => {
6248
- s.opening = false;
6249
- if (latestRef.current.open !== held)
6250
- ask(held);
6251
- else
6252
- listenForEscape();
6253
- }, held ? openDelay : closeDelay);
6257
+ // Without the trap, focus still arrives in the panel and goes back to the
6258
+ // trigger, on the trap's terms: only while the panel still holds it.
6259
+ React.useEffect(() => {
6260
+ const panel = panelRef.current;
6261
+ if (!panelMounted || trapFocus || !panel)
6262
+ return undefined;
6263
+ (firstTabStop(panel) ?? panel).focus();
6264
+ return () => {
6265
+ const doc = panel.ownerDocument;
6266
+ const now = getDeepestActiveElement(doc);
6267
+ if (!now || now === doc.body || panel.contains(now)) {
6268
+ triggerNode()?.focus();
6254
6269
  }
6255
- }
6256
- listenForEscape();
6257
- }, [listenForEscape, ask]);
6258
- // While a tap holds the floating element open, a press outside the trigger
6259
- // and the floating element closes it. Set up again each time it opens or
6260
- // closes.
6261
- const listenForPress = React.useCallback(() => {
6262
- const s = stateRef.current;
6263
- s.stopPress?.();
6264
- s.stopPress = undefined;
6265
- const doc = latestRef.current.open ? s.tappedIn : undefined;
6266
- if (!doc)
6267
- return;
6268
- const handlePointerDown = (event) => {
6269
- // Inside is a press React brought to the holder, which covers content
6270
- // the floating element portals elsewhere, or one whose path runs
6271
- // through the trigger or the floating element, which covers a press
6272
- // that something inside stopped before React got it to the holder.
6273
- const { triggerRef, floatingRef } = latestRef.current;
6274
- if (event === s.pressedInside ||
6275
- isEventInside(event, triggerRef.current) ||
6276
- isEventInside(event, floatingRef.current)) {
6270
+ };
6271
+ }, [panelMounted, trapFocus, triggerNode]);
6272
+ React.useEffect(() => {
6273
+ if (!panelMounted || !closeOnClickOutside)
6274
+ return undefined;
6275
+ const handlePointerDown = (e) => {
6276
+ if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
6277
6277
  return;
6278
6278
  }
6279
- latestRef.current.onOpenChange(false);
6279
+ close();
6280
6280
  };
6281
+ // The wrapper is on the page whenever the panel is. Its document, an
6282
+ // iframe's when the tree renders into one, is where presses land.
6283
+ const doc = rootRef.current.ownerDocument;
6281
6284
  doc.addEventListener('pointerdown', handlePointerDown);
6282
- s.stopPress = () => doc.removeEventListener('pointerdown', handlePointerDown);
6283
- }, []);
6284
- useClientLayoutEffect(() => {
6285
- latestRef.current = options;
6286
- // The commit after an ask to open answers it, open or still closed. A
6287
- // tap that asked and still finds it closed was turned down.
6288
- const s = stateRef.current;
6289
- if (s.asked) {
6290
- s.asked = false;
6291
- if (!options.open)
6292
- s.tappedIn = undefined;
6293
- listenForEscape();
6294
- }
6295
- });
6296
- useClientLayoutEffect(() => {
6297
- const s = stateRef.current;
6298
- return () => {
6299
- clearTimeout(s.timer);
6300
- clearTimeout(s.askTimer);
6301
- s.opening = false;
6302
- s.asked = false;
6303
- s.releaseEscape?.();
6304
- s.releaseEscape = undefined;
6305
- s.stopPress?.();
6306
- s.stopPress = undefined;
6307
- };
6308
- }, []);
6309
- const { open } = options;
6310
- // Before paint, so a pointer or a key arriving next finds this settled.
6311
- useClientLayoutEffect(() => {
6312
- const s = stateRef.current;
6313
- if (!open) {
6314
- // The floating element left from under the pointer, if it was there,
6315
- // and no leave event says so. Focus resting on the trigger has done
6316
- // its job too, so neither opens it again until it comes back.
6317
- s.pointerIn = false;
6318
- s.focusIn = false;
6319
- s.tappedIn = undefined;
6320
- if (s.focusInFloating) {
6321
- s.focusInFloating = false;
6322
- const trigger = latestRef.current.triggerRef.current;
6323
- // Focus that went down with the floating element sits on <body>.
6324
- // Focus somewhere else was put there by the same update, so it stays.
6325
- if (trigger &&
6326
- trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
6327
- s.restoring = true;
6328
- trigger.focus();
6329
- s.restoring = false;
6330
- }
6285
+ return () => doc.removeEventListener('pointerdown', handlePointerDown);
6286
+ }, [panelMounted, closeOnClickOutside, close]);
6287
+ const handlePanelKeyDown = (e) => {
6288
+ if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
6289
+ return;
6290
+ // React bubbles the key here from portaled content as well, so a dialog
6291
+ // of its own on the way (a nested popover, a picker's calendar) owns it.
6292
+ const owner = nearestDialog(e.nativeEvent);
6293
+ if (owner && owner !== panelRef.current)
6294
+ return;
6295
+ e.preventDefault();
6296
+ close();
6297
+ };
6298
+ const triggerProps = trigger.props;
6299
+ const handleTriggerClick = (e) => {
6300
+ triggerProps.onClick?.(e);
6301
+ if (!e.defaultPrevented)
6302
+ requestOpen(!isOpen);
6303
+ };
6304
+ const handleTriggerKeyDown = (e) => {
6305
+ triggerProps.onKeyDown?.(e);
6306
+ // A key on something inside the trigger is that element's to answer.
6307
+ if (e.defaultPrevented || e.target !== e.currentTarget)
6308
+ return;
6309
+ if (e.key === 'Escape') {
6310
+ if (isOpen && closeOnEscape) {
6311
+ e.preventDefault();
6312
+ close();
6331
6313
  }
6314
+ return;
6332
6315
  }
6333
- settle();
6334
- listenForPress();
6335
- }, [open, settle, listenForPress]);
6336
- return React.useMemo(() => {
6337
- const inTrigger = (node) => within(latestRef.current.triggerRef.current, node);
6338
- return {
6339
- onPointerEnter: event => {
6340
- if (event.pointerType === 'touch')
6341
- return;
6342
- stateRef.current.pointerIn = true;
6343
- settle();
6344
- },
6345
- onPointerLeave: event => {
6346
- // A touch pointer never set pointerIn, so its leave has nothing to
6347
- // clear, and on a device with a mouse too it would close a card the
6348
- // mouse is still holding open.
6349
- if (event.pointerType === 'touch')
6350
- return;
6351
- stateRef.current.pointerIn = false;
6352
- settle();
6353
- },
6354
- onFocus: event => {
6355
- const s = stateRef.current;
6356
- // React brings focus here from the trigger and from the floating
6357
- // element's whole tree, portals included, so focus not in the
6358
- // trigger is in the floating element, wherever its DOM is.
6359
- s.focusInFloating = !inTrigger(event.target);
6360
- if (s.restoring)
6361
- return;
6362
- // Any focus in the floating element holds it. On the trigger, only
6363
- // keyboard focus does, so a click there opens nothing.
6364
- s.focusIn = s.focusInFloating || isKeyboardFocus(event.target);
6365
- settle();
6366
- },
6367
- onBlur: event => {
6368
- const s = stateRef.current;
6369
- const next = event.relatedTarget;
6370
- // Where focus goes next, as far as the DOM can say. Focus going into
6371
- // content the floating element portals elsewhere reads as leaving,
6372
- // and the focus event that follows straight after brings it back.
6373
- s.focusInFloating = within(latestRef.current.floatingRef.current, next);
6374
- // Moving between the trigger and the floating element isn't leaving.
6375
- if (s.focusInFloating || within(event.currentTarget, next))
6376
- return;
6377
- s.focusIn = false;
6378
- settle();
6379
- },
6380
- onPointerDown: event => {
6381
- stateRef.current.pressedInside = event.nativeEvent;
6382
- },
6383
- onPointerUp: event => {
6384
- const now = latestRef.current;
6385
- // With openOnTap, a tap on the trigger opens it at once, and the
6386
- // next one closes it. A tap anywhere else does nothing here.
6387
- if (!now.openOnTap ||
6388
- event.pointerType !== 'touch' ||
6389
- !inTrigger(event.target)) {
6390
- return;
6391
- }
6392
- stateRef.current.tappedIn = now.open
6393
- ? undefined
6394
- : event.target.ownerDocument;
6395
- ask(!now.open);
6396
- },
6397
- };
6398
- }, [settle, ask]);
6316
+ if (e.key !== 'Enter' && e.key !== ' ')
6317
+ return;
6318
+ if (handlesKeyItself(e.currentTarget, e.key))
6319
+ return;
6320
+ // Space would scroll the page; a held key repeats.
6321
+ e.preventDefault();
6322
+ if (!e.repeat)
6323
+ requestOpen(!isOpen);
6324
+ };
6325
+ const needsButtonRole = typeof trigger.type === 'string' &&
6326
+ !NATIVE_CONTROLS.has(trigger.type) &&
6327
+ !(trigger.type === 'a' && triggerProps.href != null);
6328
+ const clonedTrigger = React.cloneElement(trigger, {
6329
+ ...(needsButtonRole
6330
+ ? {
6331
+ role: triggerProps.role ?? 'button',
6332
+ tabIndex: triggerProps.tabIndex ?? 0,
6333
+ }
6334
+ : {}),
6335
+ 'aria-haspopup': 'dialog',
6336
+ 'aria-expanded': isOpen,
6337
+ 'aria-controls': panelMounted ? panelId : undefined,
6338
+ onClick: handleTriggerClick,
6339
+ onKeyDown: handleTriggerKeyDown,
6340
+ });
6341
+ const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
6342
+ const panelClasses = classNames(usePrefixedClassNames('popover-content', {
6343
+ [`is-${resolved.position}`]: true,
6344
+ 'is-portal': appendToBody,
6345
+ }), contentClassName);
6346
+ return (jsxRuntime.jsxs("div", { className: rootClasses, ...rest, ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx(PopoverPanel, { panelRef: panelRef, titleId: titleId, ariaLabel: ariaLabel, close: close, id: panelId, className: panelClasses, style: appendToBody
6347
+ ? {
6348
+ position: 'fixed',
6349
+ top: resolved.top,
6350
+ left: resolved.left,
6351
+ }
6352
+ : undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
6353
+ insidePointerRef.current = e.nativeEvent;
6354
+ }, children: children }) }))] }));
6355
+ };
6356
+ /**
6357
+ * The open panel. It mounts with each opening, so it counts the
6358
+ * `Popover.Header`s inside it afresh every time.
6359
+ */
6360
+ function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
6361
+ // Headers register in a layout effect, after this first render, and this
6362
+ // panel's own layout effect runs after theirs. Until then the panel assumes
6363
+ // it has one, so a panel that does is named from its first commit, before
6364
+ // focus moves in, and one that doesn't drops the reference before it paints.
6365
+ const [titlesCounted, setTitlesCounted] = React.useState(false);
6366
+ const [titleCount, setTitleCount] = React.useState(0);
6367
+ const registerTitle = React.useCallback(() => {
6368
+ setTitleCount(n => n + 1);
6369
+ return () => setTitleCount(n => n - 1);
6370
+ }, []);
6371
+ useClientLayoutEffect(() => {
6372
+ setTitlesCounted(true);
6373
+ }, []);
6374
+ const named = Boolean(ariaLabel) || titleCount > 0;
6375
+ React.useEffect(() => {
6376
+ if (titlesCounted && !named) {
6377
+ warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
6378
+ 'Pass ariaLabel, or render a Popover.Header inside it.');
6379
+ }
6380
+ }, [titlesCounted, named]);
6381
+ const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
6382
+ return (jsxRuntime.jsx(PopoverContext.Provider, { value: context, children: jsxRuntime.jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
6399
6383
  }
6384
+ /**
6385
+ * The popover's title, which names the panel for assistive technology.
6386
+ *
6387
+ * @function
6388
+ * @param {PopoverHeaderProps} props - Props for the Popover.Header component.
6389
+ * @returns {JSX.Element} The header element.
6390
+ */
6391
+ const PopoverHeader = ({ className, ...props }) => {
6392
+ const context = React.useContext(PopoverContext);
6393
+ const registerTitle = context?.registerTitle;
6394
+ // Before paint, so the panel is named by the time it shows.
6395
+ useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
6396
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6397
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
6398
+ };
6399
+ /**
6400
+ * The popover's main content area.
6401
+ *
6402
+ * @function
6403
+ * @param {PopoverBodyProps} props - Props for the Popover.Body component.
6404
+ * @returns {JSX.Element} The body element.
6405
+ */
6406
+ const PopoverBody = ({ className, ...props }) => {
6407
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6408
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
6409
+ };
6410
+ /**
6411
+ * A row of actions along the bottom of the popover, aligned to the end.
6412
+ *
6413
+ * @function
6414
+ * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
6415
+ * @returns {JSX.Element} The footer element.
6416
+ */
6417
+ const PopoverFooter = ({ className, ...props }) => {
6418
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6419
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
6420
+ };
6421
+ /**
6422
+ * A `Button` that closes the popover, handing focus back to the trigger.
6423
+ *
6424
+ * @function
6425
+ * @param {PopoverCloseProps} props - Props for the Popover.Close component.
6426
+ * @returns {JSX.Element} The close button.
6427
+ */
6428
+ const PopoverClose = ({ onClick, ...props }) => {
6429
+ const context = React.useContext(PopoverContext);
6430
+ return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
6431
+ onClick?.(e);
6432
+ if (!e.defaultPrevented)
6433
+ context?.close();
6434
+ } }));
6435
+ };
6436
+ /** Popover with Header, Body, Footer and Close sub-components. */
6437
+ const Popover = withSubComponents(PopoverComponent, {
6438
+ Header: PopoverHeader,
6439
+ Body: PopoverBody,
6440
+ Footer: PopoverFooter,
6441
+ Close: PopoverClose,
6442
+ }, 'Popover');
6400
6443
 
6401
- /** Runs the caller's handler on the wrapper, then the hover card's. */
6402
- function chain(own, ours) {
6403
- return event => {
6404
- own?.(event);
6405
- ours(event);
6406
- };
6407
- }
6408
6444
  /**
6409
6445
  * The `HoverCard` component shows a card previewing what's behind a link or a button while the pointer rests on it or keyboard focus is on it.
6410
6446
  *
@@ -6429,7 +6465,7 @@ function chain(own, ours) {
6429
6465
  */
6430
6466
  const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, openDelay = 600, closeDelay = 300, position = 'bottom-left', appendToBody = false, openOnTap = false, className, contentClassName, ...props }) => {
6431
6467
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6432
- const { onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6468
+ const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6433
6469
  const rootRef = React.useRef(null);
6434
6470
  const cardRef = React.useRef(null);
6435
6471
  const cardId = `${React.useId()}-card`;
@@ -6502,7 +6538,7 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6502
6538
  [`is-${resolved.position}`]: true,
6503
6539
  'is-portal': appendToBody,
6504
6540
  }), contentClassName);
6505
- return (jsxRuntime.jsxs("span", { className: rootClasses, ...attrs, onPointerEnter: chain(onPointerEnter, hover.onPointerEnter), onPointerLeave: chain(onPointerLeave, hover.onPointerLeave), onFocus: chain(onFocus, hover.onFocus), onBlur: chain(onBlur, hover.onBlur), onPointerDown: chain(onPointerDown, hover.onPointerDown), onPointerUp: chain(onPointerUp, hover.onPointerUp), ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx("div", { ref: cardRef, id: cardId, className: cardClasses, style: appendToBody
6541
+ return (jsxRuntime.jsxs("span", { className: rootClasses, ...attrs, onPointerEnter: chain$1(onPointerEnter, hover.onPointerEnter), onPointerOver: chain$1(onPointerOver, hover.onPointerOver), onPointerLeave: chain$1(onPointerLeave, hover.onPointerLeave), onFocus: chain$1(onFocus, hover.onFocus), onBlur: chain$1(onBlur, hover.onBlur), onPointerDown: chain$1(onPointerDown, hover.onPointerDown), onPointerUp: chain$1(onPointerUp, hover.onPointerUp), ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx("div", { ref: cardRef, id: cardId, className: cardClasses, style: appendToBody
6506
6542
  ? {
6507
6543
  position: 'fixed',
6508
6544
  top: resolved.top,
@@ -6511,6 +6547,570 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6511
6547
  : undefined, children: children }) }))] }));
6512
6548
  };
6513
6549
 
6550
+ // INTERNAL: deliberately not exported from src/index.ts.
6551
+ /** How long a touch or a pen has to stay down to open the menu, in milliseconds. */
6552
+ const LONG_PRESS_DELAY = 500;
6553
+ /** How far a long press can move before it counts as something else, in CSS pixels. */
6554
+ const LONG_PRESS_TOLERANCE = 10;
6555
+ /**
6556
+ * The last press no trigger is to start a long press for: one a trigger
6557
+ * took, or another pointer coming down while a press is held. A press
6558
+ * reaches every trigger it bubbles through, the innermost first and all
6559
+ * before the next press, so this is how a trigger around another one knows
6560
+ * the inner one took it, and how a trigger a second finger lands on knows to
6561
+ * leave it.
6562
+ */
6563
+ let claimed = null;
6564
+ /** Whether the event happened in the element its handler is on, rather than in content portaled out of it. */
6565
+ const inTrigger = (event) => event.currentTarget.contains(event.target);
6566
+ /**
6567
+ * Where a `contextmenu` asks to open: at its point when that is on the
6568
+ * element it was fired at, which it is for the pointer, and at that
6569
+ * element's bottom-left corner otherwise, which is where the browser puts
6570
+ * its own menu for a key.
6571
+ */
6572
+ function contextMenuPoint(event) {
6573
+ const { clientX: x, clientY: y } = event;
6574
+ const rect = event.target.getBoundingClientRect();
6575
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
6576
+ ? { x, y }
6577
+ : { x: rect.left, y: rect.bottom };
6578
+ }
6579
+ /**
6580
+ * Decides when a context menu asks to open, and where. It renders nothing,
6581
+ * and knows nothing about ARIA or the menu.
6582
+ *
6583
+ * - **`contextmenu`:** a right-click, and Shift+F10 or the context menu key
6584
+ * where the browser fires the event for them, asks to open at the point
6585
+ * `contextMenuPoint` works out, and prevents the browser's own menu. Each
6586
+ * one asks again, so a right-click elsewhere on the trigger moves it.
6587
+ * - **Long press:** a touch or a pen held still on the trigger for
6588
+ * `LONG_PRESS_DELAY` fires a `contextmenu` at what it landed on, so it
6589
+ * takes the same path, handlers on the way included. Moving more than
6590
+ * `LONG_PRESS_TOLERANCE`, lifting, another pointer coming down anywhere in
6591
+ * the document, or `pointercancel`, which is the browser taking the
6592
+ * gesture for a scroll or a zoom, cancels it, and a mouse never starts
6593
+ * one. A pointer that comes down while another is held starts no long
6594
+ * press of its own, on this trigger or another, so two fingers on two rows
6595
+ * open nothing. While the finger is down, and only then, a listener on the
6596
+ * document hears other pointers coming down. Where the
6597
+ * browser fires `contextmenu` for a long press itself, the first of the two
6598
+ * opens it, and the end of a touch that opened it is prevented, so lifting
6599
+ * doesn't click what is under the finger.
6600
+ * - **Who opens:** a handler that calls `preventDefault()` on the
6601
+ * `contextmenu` before this one keeps it from asking, which is how a
6602
+ * trigger's own handler opts out and how the innermost of nested triggers
6603
+ * wins. A long press goes to the innermost trigger that takes it. Events
6604
+ * from content portaled out of the trigger, which React brings here
6605
+ * anyway, are ignored.
6606
+ * - **`disabled`:** nothing asks, and the browser's menu shows.
6607
+ *
6608
+ * Nothing it scheduled runs after the component unmounts.
6609
+ *
6610
+ * @function useContextMenuTrigger
6611
+ * @param options - How to ask to open, and whether to.
6612
+ * @returns Handlers for the trigger element.
6613
+ */
6614
+ function useContextMenuTrigger({ onOpen, disabled, }) {
6615
+ const pressRef = React.useRef(null);
6616
+ /** A long press opened the menu, and the touch that made it hasn't ended. */
6617
+ const heldRef = React.useRef(false);
6618
+ const cancel = () => {
6619
+ clearTimeout(pressRef.current?.timer);
6620
+ pressRef.current?.stop();
6621
+ pressRef.current = null;
6622
+ };
6623
+ React.useEffect(() => () => {
6624
+ clearTimeout(pressRef.current?.timer);
6625
+ pressRef.current?.stop();
6626
+ pressRef.current = null;
6627
+ }, []);
6628
+ const isPress = (event) => pressRef.current?.pointerId === event.pointerId;
6629
+ return {
6630
+ onContextMenu: event => {
6631
+ if (disabled || event.defaultPrevented || !inTrigger(event))
6632
+ return;
6633
+ event.preventDefault();
6634
+ const press = pressRef.current;
6635
+ if (press) {
6636
+ if (press.opened)
6637
+ return;
6638
+ clearTimeout(press.timer);
6639
+ press.opened = true;
6640
+ heldRef.current = true;
6641
+ }
6642
+ onOpen(contextMenuPoint(event), event.currentTarget);
6643
+ },
6644
+ onPointerDown: event => {
6645
+ const native = event.nativeEvent;
6646
+ if (disabled ||
6647
+ (event.pointerType !== 'touch' && event.pointerType !== 'pen') ||
6648
+ claimed === native ||
6649
+ !inTrigger(event)) {
6650
+ return;
6651
+ }
6652
+ claimed = native;
6653
+ heldRef.current = false;
6654
+ const target = event.target;
6655
+ const doc = target.ownerDocument;
6656
+ // Another pointer coming down anywhere makes it a pinch or a second
6657
+ // touch, not a long press. In the capture phase, so it's heard before
6658
+ // any trigger it lands on, which then leaves it. The press's own
6659
+ // pointerdown has passed the document already, so it isn't heard.
6660
+ const another = (other) => {
6661
+ claimed = other;
6662
+ cancel();
6663
+ };
6664
+ doc.addEventListener('pointerdown', another, true);
6665
+ const press = {
6666
+ pointerId: event.pointerId,
6667
+ target,
6668
+ x: event.clientX,
6669
+ y: event.clientY,
6670
+ opened: false,
6671
+ stop: () => doc.removeEventListener('pointerdown', another, true),
6672
+ };
6673
+ press.timer = setTimeout(() => {
6674
+ const view = press.target.ownerDocument.defaultView;
6675
+ press.target.dispatchEvent(new view.MouseEvent('contextmenu', {
6676
+ bubbles: true,
6677
+ cancelable: true,
6678
+ composed: true,
6679
+ clientX: press.x,
6680
+ clientY: press.y,
6681
+ button: 2,
6682
+ }));
6683
+ }, LONG_PRESS_DELAY);
6684
+ pressRef.current = press;
6685
+ },
6686
+ onPointerMove: event => {
6687
+ const press = pressRef.current;
6688
+ if (!press || !isPress(event) || press.opened)
6689
+ return;
6690
+ const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y);
6691
+ if (moved > LONG_PRESS_TOLERANCE)
6692
+ cancel();
6693
+ },
6694
+ onPointerUp: event => {
6695
+ if (isPress(event))
6696
+ cancel();
6697
+ },
6698
+ onPointerCancel: event => {
6699
+ if (isPress(event))
6700
+ cancel();
6701
+ },
6702
+ onTouchEnd: event => {
6703
+ if (!heldRef.current)
6704
+ return;
6705
+ heldRef.current = false;
6706
+ event.preventDefault();
6707
+ },
6708
+ };
6709
+ }
6710
+
6711
+ // INTERNAL: deliberately not exported from src/index.ts.
6712
+ /**
6713
+ * Every menu-item role, so an item a caller gives `menuitemcheckbox` or
6714
+ * `menuitemradio` (a filter or sort menu) stays reachable.
6715
+ */
6716
+ const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
6717
+ /**
6718
+ * Whether a menu item can be reached and activated: it isn't disabled, as a
6719
+ * native control or through `aria-disabled="true"`.
6720
+ */
6721
+ function isEnabledMenuItem(item) {
6722
+ return (!item.hasAttribute('disabled') &&
6723
+ item.getAttribute('aria-disabled') !== 'true');
6724
+ }
6725
+ /** Whether `node` is one of `menu`'s items, enabled or not. */
6726
+ const isItemOf = (menu, node) => Boolean(node && menu.contains(node) && node.matches(MENU_ITEM_SELECTOR));
6727
+ /** The items in `menu` the keys reach, in document order. */
6728
+ function enabledMenuItems(menu) {
6729
+ return Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter(isEnabledMenuItem);
6730
+ }
6731
+ /** How long typed characters keep adding up to one search, in milliseconds. */
6732
+ const TYPEAHEAD_TIMEOUT = 500;
6733
+ /**
6734
+ * Whether the browser activates `item` on this key by itself: a `<button>`
6735
+ * answers Enter and Space, and a link with an `href` answers Enter.
6736
+ */
6737
+ function activatesItself(item, key) {
6738
+ if (item.tagName === 'BUTTON')
6739
+ return true;
6740
+ return key === 'Enter' && item.tagName === 'A' && item.hasAttribute('href');
6741
+ }
6742
+ /**
6743
+ * The text typing finds an item by: what it says, without what's hidden from
6744
+ * assistive technology, such as an icon whose glyph is a ligature's name.
6745
+ */
6746
+ function label(item) {
6747
+ let text = '';
6748
+ const read = (node) => {
6749
+ node.childNodes.forEach(child => {
6750
+ if (child.nodeType === 3)
6751
+ text += child.textContent;
6752
+ else if (child.nodeType === 1 &&
6753
+ child.getAttribute('aria-hidden') !== 'true') {
6754
+ read(child);
6755
+ }
6756
+ });
6757
+ };
6758
+ read(item);
6759
+ return text.trim().toLowerCase();
6760
+ }
6761
+ /**
6762
+ * The item typing `search` moves to, or none. A single character looks from
6763
+ * the item after the focused one, so typing the same letter again cycles
6764
+ * through the items that start with it, and so does a run of one letter
6765
+ * ("dd"). A longer search looks from the focused item, so typing more of
6766
+ * its own text keeps it.
6767
+ */
6768
+ function typeaheadMatch(items, search, current) {
6769
+ const repeated = Array.from(search).every(char => char === search[0]);
6770
+ const wanted = repeated ? search[0] : search;
6771
+ const start = current < 0 ? 0 : current + (wanted.length === 1 ? 1 : 0);
6772
+ for (let i = 0; i < items.length; i += 1) {
6773
+ const item = items[(start + i) % items.length];
6774
+ if (label(item).startsWith(wanted))
6775
+ return item;
6776
+ }
6777
+ return undefined;
6778
+ }
6779
+ /**
6780
+ * Moves through and activates the items of a `role="menu"` element from the
6781
+ * keyboard. Put the `onKeyDown` it returns on that element. It reads the
6782
+ * items from the element each time, so items that come and go are found,
6783
+ * and it renders nothing.
6784
+ *
6785
+ * It acts only while focus is on the menu element itself or on one of its
6786
+ * items, so anything else focusable inside the menu, such as a filter
6787
+ * field, keeps its own keys.
6788
+ *
6789
+ * - Down and Up move to the next and previous enabled item, wrapping, and
6790
+ * Home and End go to the first and last. From no item, Down goes to the
6791
+ * first and Up to the last.
6792
+ * - Enter and Space activate the focused item. A `<button>`, and a link with
6793
+ * an `href` for Enter, get the browser's own click, and anything else gets
6794
+ * one from here, so its `onClick` runs either way. A key held down
6795
+ * activates it once. A key handler on the item that called
6796
+ * `preventDefault()` has claimed the key, and nothing else runs it.
6797
+ * - A printable character moves to the next enabled item whose text starts
6798
+ * with what has been typed. Characters typed within `TYPEAHEAD_TIMEOUT` of
6799
+ * each other add up to one search, until `reset` starts a new one.
6800
+ *
6801
+ * Items are `MENU_ITEM_SELECTOR` matches, and disabled ones are skipped by
6802
+ * all of the above. Escape and Tab are left to the menu's owner, since what
6803
+ * closing means is up to it, and so is any other key.
6804
+ *
6805
+ * @function useMenuKeys
6806
+ * @returns The `keydown` handler for the menu element, and a way to forget
6807
+ * what has been typed.
6808
+ */
6809
+ function useMenuKeys() {
6810
+ const typedRef = React.useRef({ text: '' });
6811
+ React.useEffect(() => {
6812
+ const typed = typedRef.current;
6813
+ return () => clearTimeout(typed.timer);
6814
+ }, []);
6815
+ return React.useMemo(() => {
6816
+ const reset = () => {
6817
+ const typed = typedRef.current;
6818
+ clearTimeout(typed.timer);
6819
+ typed.text = '';
6820
+ };
6821
+ const onKeyDown = (event) => {
6822
+ const menu = event.currentTarget;
6823
+ const focused = getActiveElementInTree(menu);
6824
+ if (focused !== menu && !isItemOf(menu, focused))
6825
+ return;
6826
+ const items = enabledMenuItems(menu);
6827
+ if (!items.length)
6828
+ return;
6829
+ const current = items.indexOf(focused);
6830
+ switch (event.key) {
6831
+ case 'ArrowDown':
6832
+ event.preventDefault();
6833
+ items[current >= 0 ? (current + 1) % items.length : 0].focus();
6834
+ return;
6835
+ case 'ArrowUp':
6836
+ event.preventDefault();
6837
+ items[current >= 0
6838
+ ? (current - 1 + items.length) % items.length
6839
+ : items.length - 1].focus();
6840
+ return;
6841
+ case 'Home':
6842
+ event.preventDefault();
6843
+ items[0].focus();
6844
+ return;
6845
+ case 'End':
6846
+ event.preventDefault();
6847
+ items[items.length - 1].focus();
6848
+ return;
6849
+ case 'Enter':
6850
+ case ' ': {
6851
+ if (current < 0 || event.defaultPrevented)
6852
+ return;
6853
+ const item = items[current];
6854
+ if (activatesItself(item, event.key)) {
6855
+ // The browser clicks on Enter's keydown, and a held key sends one
6856
+ // per auto-repeat. Space clicks on keyup, once however long it's
6857
+ // held.
6858
+ if (event.repeat && event.key === 'Enter')
6859
+ event.preventDefault();
6860
+ return;
6861
+ }
6862
+ // Space would scroll the page, and a held key repeats.
6863
+ event.preventDefault();
6864
+ if (!event.repeat)
6865
+ item.click();
6866
+ return;
6867
+ }
6868
+ }
6869
+ if (event.key.length !== 1 ||
6870
+ event.ctrlKey ||
6871
+ event.metaKey ||
6872
+ event.altKey ||
6873
+ event.defaultPrevented) {
6874
+ return;
6875
+ }
6876
+ event.preventDefault();
6877
+ const typed = typedRef.current;
6878
+ typed.text += event.key.toLowerCase();
6879
+ clearTimeout(typed.timer);
6880
+ typed.timer = setTimeout(reset, TYPEAHEAD_TIMEOUT);
6881
+ typeaheadMatch(items, typed.text, current)?.focus();
6882
+ };
6883
+ return { onKeyDown, reset };
6884
+ }, []);
6885
+ }
6886
+
6887
+ /**
6888
+ * Where the menu goes along one axis, from the point at `at`: after it when
6889
+ * there's room, before it when there's room there instead, and otherwise
6890
+ * moved in until it fits in `room`.
6891
+ */
6892
+ function placeAlong(at, size, room) {
6893
+ if (at + size <= room)
6894
+ return { start: at, flip: false };
6895
+ if (at - size >= 0)
6896
+ return { start: at, flip: true };
6897
+ return { start: Math.max(0, room - size), flip: false };
6898
+ }
6899
+ /** Runs the trigger's own handler, then the context menu's. */
6900
+ function chain(own, ours) {
6901
+ return event => {
6902
+ own?.(event);
6903
+ ours(event);
6904
+ };
6905
+ }
6906
+ /**
6907
+ * The `ContextMenu` component opens a menu of actions where you right-click an element, such as a table row or a card, or long press it on a touch screen.
6908
+ *
6909
+ * Shift+F10 and the context menu key open it too, where the browser fires
6910
+ * `contextmenu` for them, which Windows and Linux do. The menu is Dropdown's
6911
+ * markup, portaled into the `portalContainer` a `ConfigProvider` sets, or
6912
+ * the end of the trigger's own document's `<body>`, so it never has server
6913
+ * markup. It opens below and to the right of the point, flips above or to
6914
+ * the left where there's no room, moves in where it fits neither way, and
6915
+ * scrolls inside when it's taller than the viewport. It doesn't follow the
6916
+ * trigger, since scrolling closes it. Inside a `Modal` or a shadow root,
6917
+ * point `portalContainer` inside it, so screen readers that honor
6918
+ * `aria-modal` reach the menu and the shadow root's styles apply.
6919
+ *
6920
+ * Opening moves focus to the first enabled item. The arrow keys, Home, End
6921
+ * and typing move between items, and Enter and Space activate one. Escape,
6922
+ * Tab, an item, a press outside, scrolling, resizing and the window losing
6923
+ * focus close it, handing focus back to what had it unless an item moved it
6924
+ * somewhere else. For a menu opened from a button use `Dropdown`.
6925
+ *
6926
+ * @function
6927
+ * @param {ContextMenuProps} props - Props for the ContextMenu component.
6928
+ * @returns {JSX.Element} The trigger, and the menu while open.
6929
+ *
6930
+ * @example
6931
+ * <ContextMenu
6932
+ * aria-label="Actions for report.pdf"
6933
+ * trigger={<Box>report.pdf</Box>}
6934
+ * >
6935
+ * <ContextMenu.Item as="button">Rename</ContextMenu.Item>
6936
+ * <ContextMenu.Divider />
6937
+ * <ContextMenu.Item as="button">Delete</ContextMenu.Item>
6938
+ * </ContextMenu>
6939
+ */
6940
+ const ContextMenuComponent = ({ trigger, children, onOpenChange, closeOnClick = true, disabled = false, className, onKeyDown, onClick, onContextMenu, ...props }) => {
6941
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6942
+ const { portalContainer } = useConfig();
6943
+ const [menu, setMenu] = React.useState(null);
6944
+ // Turning `disabled` on closes it.
6945
+ if (disabled && menu)
6946
+ setMenu(null);
6947
+ const open = menu !== null;
6948
+ const doc = menu?.doc;
6949
+ const placed = menu?.placed;
6950
+ const boxRef = React.useRef(null);
6951
+ const menuRef = React.useRef(null);
6952
+ const openerRef = React.useRef(null);
6953
+ const reportedRef = React.useRef(false);
6954
+ const menuKeys = useMenuKeys();
6955
+ const named = Boolean(props['aria-label'] || props['aria-labelledby']);
6956
+ const handlers = useContextMenuTrigger({
6957
+ disabled,
6958
+ onOpen: (point, triggerNode) => {
6959
+ const ownerDoc = triggerNode.ownerDocument;
6960
+ openerRef.current = getDeepestActiveElement(ownerDoc);
6961
+ setMenu({ point, doc: ownerDoc });
6962
+ },
6963
+ });
6964
+ // Measured once per opening, before it paints, against what a fixed box
6965
+ // resolves against: the root element's client area, which leaves out a
6966
+ // classic scrollbar. `useAnchoredPosition` and Tooltip read innerWidth and
6967
+ // innerHeight, which count it. A menu pinned to the viewport's edge would
6968
+ // sit under the scrollbar by that much, so this reads the narrower box.
6969
+ useClientLayoutEffect(() => {
6970
+ const menuNode = menuRef.current;
6971
+ if (!menu || menu.placed || !menuNode)
6972
+ return;
6973
+ const root = menuNode.ownerDocument.documentElement;
6974
+ const { width, height } = menuNode.getBoundingClientRect();
6975
+ const x = placeAlong(menu.point.x, width, root.clientWidth);
6976
+ const y = placeAlong(menu.point.y, height, root.clientHeight);
6977
+ setMenu({
6978
+ ...menu,
6979
+ placed: { left: x.start, top: y.start, right: x.flip, up: y.flip },
6980
+ });
6981
+ }, [menu]);
6982
+ const isPlaced = Boolean(placed);
6983
+ // What closes it while it's open, and focus back on closing. Nothing
6984
+ // listens while it's closed.
6985
+ React.useEffect(() => {
6986
+ const box = boxRef.current;
6987
+ const menuNode = menuRef.current;
6988
+ if (!doc || !box || !menuNode)
6989
+ return undefined;
6990
+ const view = doc.defaultView;
6991
+ // What had focus when it opened. This runs once per opening, so opening
6992
+ // again at a new point while it's open keeps it.
6993
+ const opener = openerRef.current;
6994
+ const close = () => setMenu(null);
6995
+ const closeOutside = (event) => {
6996
+ if (!isEventInside(event, box))
6997
+ close();
6998
+ };
6999
+ doc.addEventListener('pointerdown', closeOutside, true);
7000
+ view.addEventListener('resize', close);
7001
+ view.addEventListener('blur', close);
7002
+ return () => {
7003
+ doc.removeEventListener('pointerdown', closeOutside, true);
7004
+ view.removeEventListener('resize', close);
7005
+ view.removeEventListener('blur', close);
7006
+ // Back to what had focus while the menu still has it, or it dropped to
7007
+ // <body> when the menu left. An item that put it somewhere else, such
7008
+ // as a Modal or a rename field, keeps it there.
7009
+ const now = getDeepestActiveElement(doc);
7010
+ if (!now || now === doc.body || menuNode.contains(now))
7011
+ opener?.focus();
7012
+ };
7013
+ }, [doc]);
7014
+ // Focus in, and closing on scroll, once it's placed. Placed, it's inside
7015
+ // the viewport, so its first item has nothing to scroll into view, and
7016
+ // `preventScroll` makes sure: a scroll the focus caused would close it as
7017
+ // it opened. Opening again at a new point places it again, and leaves
7018
+ // focus where it is in the menu.
7019
+ React.useEffect(() => {
7020
+ const box = boxRef.current;
7021
+ const menuNode = menuRef.current;
7022
+ if (!doc || !isPlaced || !box || !menuNode)
7023
+ return undefined;
7024
+ if (!menuNode.contains(getDeepestActiveElement(doc))) {
7025
+ menuKeys.reset();
7026
+ (enabledMenuItems(menuNode)[0] ?? menuNode).focus({
7027
+ preventScroll: true,
7028
+ });
7029
+ }
7030
+ const closeOutside = (event) => {
7031
+ if (!isEventInside(event, box))
7032
+ setMenu(null);
7033
+ };
7034
+ doc.addEventListener('scroll', closeOutside, true);
7035
+ return () => doc.removeEventListener('scroll', closeOutside, true);
7036
+ }, [doc, isPlaced, menuKeys]);
7037
+ // Before paint, so what the caller does with it, such as highlighting the
7038
+ // row, shows with the menu rather than a frame behind it.
7039
+ useClientLayoutEffect(() => {
7040
+ if (open === reportedRef.current)
7041
+ return;
7042
+ reportedRef.current = open;
7043
+ onOpenChange?.(open);
7044
+ });
7045
+ React.useEffect(() => {
7046
+ if (open && !named) {
7047
+ warnOnce('ContextMenu:unnamed', '[bestax-bulma] <ContextMenu> opened a menu with no accessible ' +
7048
+ 'name. Pass aria-label or aria-labelledby.');
7049
+ }
7050
+ }, [open, named]);
7051
+ const handleKeyDown = (event) => {
7052
+ onKeyDown?.(event);
7053
+ // The menu has the keyboard while focus is in it. React brings its keys
7054
+ // up the tree to wherever the ContextMenu sits, where a table that moves
7055
+ // between rows on the arrow keys, or a Popover or a Modal waiting for
7056
+ // Escape, would take them too.
7057
+ event.stopPropagation();
7058
+ if (event.key === 'Escape' || event.key === 'Tab') {
7059
+ // Tab would go on from the end of <body>, past the rest of the page.
7060
+ event.preventDefault();
7061
+ setMenu(null);
7062
+ return;
7063
+ }
7064
+ menuKeys.onKeyDown(event);
7065
+ };
7066
+ const handleClick = (event) => {
7067
+ onClick?.(event);
7068
+ if (!closeOnClick)
7069
+ return;
7070
+ const item = event.target.closest(MENU_ITEM_SELECTOR);
7071
+ if (item && isEnabledMenuItem(item))
7072
+ setMenu(null);
7073
+ };
7074
+ const handleContextMenu = (event) => {
7075
+ onContextMenu?.(event);
7076
+ // No other menu over this one: not the browser's, and not a trigger's
7077
+ // that React brings the event up to.
7078
+ event.preventDefault();
7079
+ event.stopPropagation();
7080
+ };
7081
+ const own = trigger.props;
7082
+ const triggerClass = usePrefixedClassNames('context-menu');
7083
+ const clonedTrigger = React.cloneElement(trigger, {
7084
+ className: classNames(own.className, !disabled && triggerClass) || undefined,
7085
+ onContextMenu: chain(own.onContextMenu, handlers.onContextMenu),
7086
+ onPointerDown: chain(own.onPointerDown, handlers.onPointerDown),
7087
+ onPointerMove: chain(own.onPointerMove, handlers.onPointerMove),
7088
+ onPointerUp: chain(own.onPointerUp, handlers.onPointerUp),
7089
+ onPointerCancel: chain(own.onPointerCancel, handlers.onPointerCancel),
7090
+ onTouchEnd: chain(own.onTouchEnd, handlers.onTouchEnd),
7091
+ });
7092
+ const boxClasses = usePrefixedClassNames('context-menu-content', 'dropdown', {
7093
+ 'is-active': true,
7094
+ 'is-up': placed?.up,
7095
+ 'is-right': placed?.right,
7096
+ });
7097
+ const menuClasses = classNames(usePrefixedClassNames('dropdown-menu'), bulmaHelperClasses, className);
7098
+ const contentClasses = usePrefixedClassNames('dropdown-content');
7099
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [clonedTrigger, menu && (jsxRuntime.jsx(Portal, { container: portalContainer || menu.doc.body, children: jsxRuntime.jsx("div", { ref: boxRef, className: boxClasses, style: {
7100
+ top: placed?.top ?? menu.point.y,
7101
+ left: placed?.left ?? menu.point.x,
7102
+ }, children: jsxRuntime.jsx("div", { ...rest, ref: menuRef, role: "menu", tabIndex: -1, className: menuClasses, onKeyDown: handleKeyDown, onClick: handleClick, onContextMenu: handleContextMenu, children: jsxRuntime.jsx("div", { className: contentClasses, children: children }) }) }) }))] }));
7103
+ };
7104
+ /**
7105
+ * ContextMenu with Item and Divider sub-components, which are `Dropdown.Item`
7106
+ * and `Dropdown.Divider`, so items written for a Dropdown work here
7107
+ * unchanged.
7108
+ */
7109
+ const ContextMenu = withSubComponents(ContextMenuComponent, {
7110
+ Item: DropdownItem,
7111
+ Divider: DropdownDivider,
7112
+ }, 'ContextMenu');
7113
+
6514
7114
  /**
6515
7115
  * Individual Step component for use inside Steps.
6516
7116
  *
@@ -6658,6 +7258,11 @@ const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
6658
7258
  */
6659
7259
  const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
6660
7260
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
7261
+ const resolveContainer = usePortalContainer();
7262
+ const { portalContainer } = useConfig();
7263
+ // Where the configured default puts the sidebar. An `inline` one doesn't
7264
+ // portal, so a change to the default moves nothing.
7265
+ const defaultTarget = inline ? undefined : portalContainer;
6661
7266
  const sidebarRef = React.useRef(null);
6662
7267
  // Cleanup returned by a consumer's callback ref on attach, held until detach.
6663
7268
  const consumerCleanupRef = React.useRef(null);
@@ -6689,7 +7294,9 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
6689
7294
  // Prevent body scroll when sidebar is open. Ref-counted through the shared
6690
7295
  // helper so an overlapping Modal/Dialog/Loading doesn't unlock underneath.
6691
7296
  useScrollLock(isOpen && overlay);
6692
- // Focus trap (basic - focus sidebar when opened)
7297
+ // Focus trap (basic - focus sidebar when opened). A different
7298
+ // `defaultTarget` remounts the portaled sidebar, so it focuses the new
7299
+ // nodes then too.
6693
7300
  React.useEffect(() => {
6694
7301
  if (isOpen) {
6695
7302
  const element = sidebarRef.current;
@@ -6704,7 +7311,7 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
6704
7311
  }
6705
7312
  }
6706
7313
  }
6707
- }, [isOpen]);
7314
+ }, [isOpen, defaultTarget]);
6708
7315
  // Use combined ref
6709
7316
  const combinedRef = React.useCallback((node) => {
6710
7317
  sidebarRef.current =
@@ -6759,7 +7366,7 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
6759
7366
  }
6760
7367
  // Render to portal for proper stacking
6761
7368
  if (typeof document !== 'undefined') {
6762
- return reactDom.createPortal(sidebarContent, document.body);
7369
+ return reactDom.createPortal(sidebarContent, resolveContainer());
6763
7370
  }
6764
7371
  return null;
6765
7372
  });
@@ -6887,6 +7494,7 @@ function groupIntoPositionStacks(items, positionOf, containerPosition, order) {
6887
7494
  */
6888
7495
  const Toast = React.forwardRef(({ message, type = 'default', actionType, position = 'top-right', duration = 2000, indefinite = false, dismissible = true, closable = false, rounded = false, pauseOnHover = false, cancelable = true, actionText, cancelText, onAction, onClose, container, inline = false, className, ...props }, ref) => {
6889
7496
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
7497
+ const resolveContainer = usePortalContainer();
6890
7498
  const [isVisible, setIsVisible] = React.useState(true);
6891
7499
  const [isPaused, setIsPaused] = React.useState(false);
6892
7500
  const toastRef = React.useRef(null);
@@ -7012,7 +7620,7 @@ const Toast = React.forwardRef(({ message, type = 'default', actionType, positio
7012
7620
  }
7013
7621
  const toastContent = jsxRuntime.jsx("div", { className: containerClasses, children: toastElement });
7014
7622
  if (typeof document !== 'undefined') {
7015
- return reactDom.createPortal(toastContent, resolvePortalContainer(container));
7623
+ return reactDom.createPortal(toastContent, resolveContainer(container));
7016
7624
  }
7017
7625
  return null;
7018
7626
  });
@@ -7169,6 +7777,7 @@ const ToastContainer = ({ position = 'top-right' }) => {
7169
7777
  // Starts from the toasts already showing instead of an empty list, then
7170
7778
  // follows changes.
7171
7779
  const toastList = React.useSyncExternalStore(toast.subscribe, getVisibleToasts, getServerToasts);
7780
+ const resolveContainer = usePortalContainer();
7172
7781
  if (typeof document === 'undefined' || toastList.length === 0) {
7173
7782
  return null;
7174
7783
  }
@@ -7176,7 +7785,7 @@ const ToastContainer = ({ position = 'top-right' }) => {
7176
7785
  return reactDom.createPortal(stacks.map(stack => (jsxRuntime.jsx("div", { className: `toast-container is-${stack.position}`, children: stack.items.map(t => {
7177
7786
  const { queue: _queue, ...toastProps } = t.props;
7178
7787
  return (jsxRuntime.jsx(Toast, { ...toastProps, position: stack.position, onClose: () => toast.close(t.id) }, t.id));
7179
- }) }, stack.key))), document.body);
7788
+ }) }, stack.key))), resolveContainer());
7180
7789
  };
7181
7790
 
7182
7791
  /**
@@ -7233,11 +7842,14 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
7233
7842
  handleCancel();
7234
7843
  }
7235
7844
  }, [canCancel, handleCancel]);
7236
- // Moving into the portal remounts the Modal's subtree — these buttons
7237
- // included — so the focus effect below has to re-run against the new
7238
- // nodes, exactly as the Modal's own focus effect does.
7845
+ // Moving into the portal, or from one portal target to another, remounts
7846
+ // the Modal's subtree, these buttons included, so the focus effect below
7847
+ // has to re-run against the new nodes, exactly as the Modal's own focus
7848
+ // effect does, on the same inputs.
7239
7849
  const isHydrated = useIsHydrated();
7240
7850
  const isPortaled = Boolean(portal) && isHydrated;
7851
+ const { portalContainer } = useConfig();
7852
+ const defaultTarget = isPortaled && portal === true ? portalContainer : undefined;
7241
7853
  // Focus management — Dialog picks a specific button rather than the
7242
7854
  // Modal's default (first focusable/root); this effect runs after the
7243
7855
  // inner Modal's own focus-on-open effect, so it wins.
@@ -7246,7 +7858,7 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
7246
7858
  const buttonToFocus = focusCancel && showCancel ? cancelRef.current : confirmRef.current;
7247
7859
  buttonToFocus?.focus();
7248
7860
  }
7249
- }, [isOpen, focusCancel, showCancel, isPortaled]);
7861
+ }, [isOpen, focusCancel, showCancel, isPortaled, portal, defaultTarget]);
7250
7862
  // Use combined ref
7251
7863
  const combinedRef = React.useCallback((node) => {
7252
7864
  dialogRef.current =
@@ -7993,6 +8605,58 @@ const Buttons = withSubComponents(ButtonsComponent, {
7993
8605
  LinkButton,
7994
8606
  }, 'Buttons');
7995
8607
 
8608
+ /**
8609
+ * The `ToggleButton` component renders a button that stays pressed until it's pressed again, and says whether it's on through `aria-pressed`.
8610
+ *
8611
+ * It's always a `<button type="button">`, so it never submits a form and
8612
+ * carries no form value: a setting saved with a form is a `Checkbox` or a
8613
+ * `Switch`. A set of toggles that each turn on and off on their own goes in
8614
+ * `Buttons hasAddons` with `role="group"` and an `aria-label`. A set where only
8615
+ * one can be on is a radio group rather than a row of these: use `Radios`.
8616
+ *
8617
+ * @function
8618
+ * @param {ToggleButtonProps} props - Props for the ToggleButton component.
8619
+ * @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the `<button>`.
8620
+ * @returns {JSX.Element} The rendered toggle button.
8621
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | APG button pattern}
8622
+ *
8623
+ * @example
8624
+ * <ToggleButton onPressedChange={setMuted}>Mute</ToggleButton>
8625
+ *
8626
+ * @example
8627
+ * <Buttons hasAddons role="group" aria-label="Text style">
8628
+ * <ToggleButton aria-label="Bold" pressed={bold} onPressedChange={setBold}>
8629
+ * <Icon name="bold" />
8630
+ * </ToggleButton>
8631
+ * </Buttons>
8632
+ */
8633
+ const ToggleButton = React.forwardRef(function ToggleButton({ pressed: controlledPressed, defaultPressed = false, onPressedChange, color = 'link', className, onClick, ...props }, ref) {
8634
+ const isControlled = controlledPressed !== undefined;
8635
+ const [internalPressed, setInternalPressed] = React.useState(defaultPressed);
8636
+ const pressed = controlledPressed ?? internalPressed;
8637
+ // Prefixed like Button's own classes, which a hand-written
8638
+ // `className="is-selected"` could not be under ConfigProvider classPrefix.
8639
+ // Bulma lifts `is-selected` above its neighbours in `.buttons.has-addons`.
8640
+ const selectedClass = usePrefixedClassNames({ 'is-selected': pressed });
8641
+ // The caller's onClick runs first, so a `preventDefault()` there vetoes
8642
+ // the toggle, as it does in Radix, MUI and Zag. Enter and Space reach here
8643
+ // through the button's own click, and a disabled button gets no click.
8644
+ const handleClick = (event) => {
8645
+ onClick?.(event);
8646
+ if (event.defaultPrevented)
8647
+ return;
8648
+ const next = !pressed;
8649
+ onPressedChange?.(next);
8650
+ if (!isControlled)
8651
+ setInternalPressed(next);
8652
+ };
8653
+ // The props are spread first, so the four things a toggle depends on win
8654
+ // over anything a JavaScript caller or a spread object passes along: it
8655
+ // stays a button, never submits, and reports the state it's in.
8656
+ return (jsxRuntime.jsx(Button, { ...props, ref: ref, as: "button", type: "button", "aria-pressed": pressed, color: pressed ? color : undefined, className: classNames(selectedClass, className), onClick: handleClick }));
8657
+ });
8658
+ ToggleButton.displayName = 'ToggleButton';
8659
+
7996
8660
  /**
7997
8661
  * The `Code` component renders a styled `<code>` element with Bulma helper class integration.
7998
8662
  *
@@ -8881,6 +9545,7 @@ const NotificationContainer = ({ position = 'top-right' }) => {
8881
9545
  // the server render and the hydrating one render nothing, and the container
8882
9546
  // portals in from the render after hydration.
8883
9547
  const isHydrated = useIsHydrated();
9548
+ const resolveContainer = usePortalContainer();
8884
9549
  // The notifications the status region announces. Danger and warning
8885
9550
  // announce themselves.
8886
9551
  const politeItems = React.useMemo(() => items.filter(item => !isUrgentColor(item.options.color)), [items]);
@@ -8913,7 +9578,7 @@ const NotificationContainer = ({ position = 'top-right' }) => {
8913
9578
  return null;
8914
9579
  }
8915
9580
  const stacks = groupIntoPositionStacks(items, item => item.options.position, position, notificationStackOrder);
8916
- return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [stacks.map(stack => (jsxRuntime.jsx("div", { style: notificationStackStyle(stack.position), children: stack.items.map(item => (jsxRuntime.jsx(NotificationItem, { instance: item, onClose: notification.close, registerMessage: registerMessage }, item.id))) }, stack.key))), jsxRuntime.jsx(StatusRegion, { items: politeItems, describe: describe })] }), document.body);
9581
+ return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [stacks.map(stack => (jsxRuntime.jsx("div", { style: notificationStackStyle(stack.position), children: stack.items.map(item => (jsxRuntime.jsx(NotificationItem, { instance: item, onClose: notification.close, registerMessage: registerMessage }, item.id))) }, stack.key))), jsxRuntime.jsx(StatusRegion, { items: politeItems, describe: describe })] }), resolveContainer());
8917
9582
  };
8918
9583
 
8919
9584
  /**
@@ -12484,7 +13149,7 @@ const DEFAULT_PICKER_LABELS = {
12484
13149
  hours: 'hours',
12485
13150
  minutes: 'minutes',
12486
13151
  seconds: 'seconds',
12487
- ampm: 'ampm',
13152
+ ampm: 'AM/PM',
12488
13153
  increaseHours: 'Increase hours',
12489
13154
  decreaseHours: 'Decrease hours',
12490
13155
  increaseMinutes: 'Increase minutes',
@@ -12503,11 +13168,19 @@ const DEFAULT_PICKER_LABELS = {
12503
13168
  done: 'Done',
12504
13169
  time: 'Time',
12505
13170
  };
12506
- /** Merge user-supplied label overrides with the defaults. */
12507
- const mergeLabels = (overrides) => ({
12508
- ...DEFAULT_PICKER_LABELS,
12509
- ...(overrides ?? {}),
12510
- });
13171
+ /**
13172
+ * Merge user-supplied label overrides with the defaults. A key set to
13173
+ * `undefined` keeps its default, so a locale table that is missing a string
13174
+ * still names its control instead of leaving it with no accessible name.
13175
+ */
13176
+ const mergeLabels = (overrides) => {
13177
+ const merged = { ...DEFAULT_PICKER_LABELS };
13178
+ for (const [key, value] of Object.entries(overrides ?? {})) {
13179
+ if (value !== undefined)
13180
+ merged[key] = value;
13181
+ }
13182
+ return merged;
13183
+ };
12511
13184
 
12512
13185
  function isSameDay(a, b) {
12513
13186
  return (a.getFullYear() === b.getFullYear() &&
@@ -13840,6 +14513,7 @@ Calendar.displayName = 'Calendar';
13840
14513
  const isBrowser = typeof window !== 'undefined';
13841
14514
  const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'bottom-left', appendToBody = false, className, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, ariaLabelledBy, role = 'dialog', id, restoreFocusRef, initialFocusSelector, }) => {
13842
14515
  const panelRef = React.useRef(null);
14516
+ const resolveContainer = usePortalContainer();
13843
14517
  // Read as the trap turns on, once the panel's content has rendered.
13844
14518
  const initialFocusRef = React.useMemo(() => initialFocusSelector === undefined
13845
14519
  ? undefined
@@ -13916,7 +14590,7 @@ const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'botto
13916
14590
  : {};
13917
14591
  const panel = (jsxRuntime.jsx("div", { ref: panelRef, id: id, role: role, "aria-modal": role === 'dialog' ? 'false' : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, tabIndex: -1, className: classNames(panelClass, className), style: style, children: children }));
13918
14592
  if (appendToBody && isBrowser) {
13919
- return reactDom.createPortal(panel, document.body);
14593
+ return reactDom.createPortal(panel, resolveContainer());
13920
14594
  }
13921
14595
  return panel;
13922
14596
  };
@@ -18428,6 +19102,7 @@ exports.Columns = Columns;
18428
19102
  exports.ConfigProvider = ConfigProvider;
18429
19103
  exports.Container = Container;
18430
19104
  exports.Content = Content;
19105
+ exports.ContextMenu = ContextMenu;
18431
19106
  exports.Control = Control;
18432
19107
  exports.DateInput = DateInput;
18433
19108
  exports.DateInputBase = DateInputBase;
@@ -18558,6 +19233,7 @@ exports.TimeInputBase = TimeInputBase;
18558
19233
  exports.Title = Title;
18559
19234
  exports.Toast = Toast;
18560
19235
  exports.ToastContainer = ToastContainer;
19236
+ exports.ToggleButton = ToggleButton;
18561
19237
  exports.Tooltip = Tooltip;
18562
19238
  exports.Tr = Tr;
18563
19239
  exports.UnorderedList = UnorderedList;