@allxsmith/bestax-bulma 5.29.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 (61) 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/HoverCard.js +3 -10
  6. package/dist/esm/components/HoverCard.js.map +1 -1
  7. package/dist/esm/components/Tooltip.js +36 -61
  8. package/dist/esm/components/Tooltip.js.map +1 -1
  9. package/dist/esm/elements/Button.js.map +1 -1
  10. package/dist/esm/elements/ToggleButton.js +60 -0
  11. package/dist/esm/elements/ToggleButton.js.map +1 -0
  12. package/dist/esm/form/DateTimeInputBase.js.map +1 -1
  13. package/dist/esm/form/TimeInputBase.js.map +1 -1
  14. package/dist/esm/form/_pickerInternals/pickerTypes.js +14 -6
  15. package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -1
  16. package/dist/esm/helpers/useContextMenuTrigger.js +166 -0
  17. package/dist/esm/helpers/useContextMenuTrigger.js.map +1 -0
  18. package/dist/esm/helpers/useHoverOpen.js +40 -6
  19. package/dist/esm/helpers/useHoverOpen.js.map +1 -1
  20. package/dist/esm/helpers/useMenuKeys.js +182 -0
  21. package/dist/esm/helpers/useMenuKeys.js.map +1 -0
  22. package/dist/extras.css +1 -1
  23. package/dist/extras.css.map +1 -1
  24. package/dist/index.cjs +2078 -1451
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.esm.js +2 -0
  27. package/dist/index.esm.js.map +1 -1
  28. package/dist/types/components/ContextMenu.d.ts +66 -0
  29. package/dist/types/components/Tooltip.d.ts +45 -4
  30. package/dist/types/elements/Button.d.ts +1 -1
  31. package/dist/types/elements/ToggleButton.d.ts +90 -0
  32. package/dist/types/form/DateTimeInputBase.d.ts +4 -1
  33. package/dist/types/form/TimeInputBase.d.ts +5 -2
  34. package/dist/types/form/_pickerInternals/pickerTypes.d.ts +16 -1
  35. package/dist/types/helpers/useContextMenuTrigger.d.ts +67 -0
  36. package/dist/types/helpers/useHoverOpen.d.ts +19 -3
  37. package/dist/types/helpers/useMenuKeys.d.ts +57 -0
  38. package/dist/types/index.d.ts +2 -0
  39. package/dist/types-cjs/components/ContextMenu.d.ts +66 -0
  40. package/dist/types-cjs/components/Tooltip.d.ts +45 -4
  41. package/dist/types-cjs/elements/Button.d.ts +1 -1
  42. package/dist/types-cjs/elements/ToggleButton.d.ts +90 -0
  43. package/dist/types-cjs/form/DateTimeInputBase.d.ts +4 -1
  44. package/dist/types-cjs/form/TimeInputBase.d.ts +5 -2
  45. package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +16 -1
  46. package/dist/types-cjs/helpers/useContextMenuTrigger.d.ts +67 -0
  47. package/dist/types-cjs/helpers/useHoverOpen.d.ts +19 -3
  48. package/dist/types-cjs/helpers/useMenuKeys.d.ts +57 -0
  49. package/dist/types-cjs/index.d.ts +2 -0
  50. package/dist/versions/bestax-no-dark-mode.css +1 -1
  51. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  52. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  53. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  54. package/dist/versions/bestax-no-helpers.css +1 -1
  55. package/dist/versions/bestax-no-helpers.css.map +1 -1
  56. package/dist/versions/bestax-prefixed.css +1 -1
  57. package/dist/versions/bestax-prefixed.css.map +1 -1
  58. package/package.json +1 -1
  59. package/src/scss/components/_contextmenu.scss +50 -0
  60. package/src/scss/components/_index.scss +1 -0
  61. package/src/scss/components/_tooltip.scss +76 -1
package/dist/index.cjs CHANGED
@@ -4895,1551 +4895,1552 @@ const CollapsesComponent = ({ multiple = false, value, defaultValue, onChange, s
4895
4895
  };
4896
4896
  const Collapses = withSubComponents(CollapsesComponent, { Collapse }, 'Collapses');
4897
4897
 
4898
+ // INTERNAL: deliberately not exported from src/index.ts.
4898
4899
  /**
4899
- * The `Tooltip` component displays helpful information when users hover over or focus on an element.
4900
- *
4901
- * @function
4902
- * @param {TooltipProps} props - Props for the Tooltip component.
4903
- * @returns {JSX.Element} The rendered tooltip wrapper.
4904
- *
4905
- * @example
4906
- * // Basic tooltip
4907
- * <Tooltip label="This is helpful info">
4908
- * <button>Hover me</button>
4909
- * </Tooltip>
4910
- *
4911
- * @example
4912
- * // Positioned tooltip with color
4913
- * <Tooltip label="Delete item" position="right" color="danger">
4914
- * <Icon icon="fas fa-trash" />
4915
- * </Tooltip>
4916
- *
4917
- * @example
4918
- * // Multiline tooltip
4919
- * <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
4920
- * <span>?</span>
4921
- * </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.
4922
4903
  */
4923
- 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 }) => {
4924
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4925
- const [isVisible, setIsVisible] = React.useState(false);
4926
- const [isHovering, setIsHovering] = React.useState(false);
4927
- const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
4928
- const [contentStyle, setContentStyle] = React.useState({});
4929
- const openTimeoutRef = React.useRef(null);
4930
- const closeTimeoutRef = React.useRef(null);
4931
- const wrapperRef = React.useRef(null);
4932
- const contentRef = React.useRef(null);
4933
- // Clear timeouts on unmount
4934
- React.useEffect(() => {
4935
- return () => {
4936
- if (openTimeoutRef.current) {
4937
- clearTimeout(openTimeoutRef.current);
4938
- }
4939
- if (closeTimeoutRef.current) {
4940
- clearTimeout(closeTimeoutRef.current);
4941
- }
4942
- };
4943
- }, []);
4944
- // Handle delayed visibility
4945
- React.useEffect(() => {
4946
- if (isHovering) {
4947
- if (closeTimeoutRef.current) {
4948
- clearTimeout(closeTimeoutRef.current);
4949
- closeTimeoutRef.current = null;
4950
- }
4951
- if (delay > 0) {
4952
- openTimeoutRef.current = setTimeout(() => {
4953
- setIsVisible(true);
4954
- }, delay);
4955
- }
4956
- else {
4957
- // eslint-disable-next-line react-hooks/set-state-in-effect -- show immediately on hover when no delay
4958
- setIsVisible(true);
4959
- }
4960
- }
4961
- else {
4962
- if (openTimeoutRef.current) {
4963
- clearTimeout(openTimeoutRef.current);
4964
- openTimeoutRef.current = null;
4965
- }
4966
- if (closeDelay > 0) {
4967
- closeTimeoutRef.current = setTimeout(() => {
4968
- setIsVisible(false);
4969
- }, closeDelay);
4970
- }
4971
- else {
4972
- setIsVisible(false);
4973
- }
4974
- }
4975
- }, [isHovering, delay, closeDelay]);
4976
- // Auto-placement and overflow correction
4977
- React.useLayoutEffect(() => {
4978
- const isAuto = position === 'auto';
4979
- if (!isAuto) {
4980
- // eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
4981
- setResolvedPosition(position);
4982
- }
4983
- const showing = isVisible || alwaysActive;
4984
- if (!showing) {
4985
- setContentStyle({});
4986
- return;
4987
- }
4988
- const wrapper = wrapperRef.current;
4989
- const content = contentRef.current;
4990
- if (!wrapper || !content)
4991
- return;
4992
- const wrapperRect = wrapper.getBoundingClientRect();
4993
- const margin = 40;
4994
- const edgePadding = 8; // minimum distance from viewport edge
4995
- // Step 1: determine position (auto mode only)
4996
- if (isAuto) {
4997
- const spaceTop = wrapperRect.top;
4998
- const spaceBottom = window.innerHeight - wrapperRect.bottom;
4999
- if (spaceTop >= margin) {
5000
- setResolvedPosition('top');
5001
- }
5002
- else if (spaceBottom >= margin) {
5003
- setResolvedPosition('bottom');
5004
- }
5005
- else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
5006
- setResolvedPosition('right');
5007
- }
5008
- else {
5009
- setResolvedPosition('left');
5010
- }
5011
- }
5012
- // Step 2: measure tooltip content and correct overflow.
5013
- // Use requestAnimationFrame so the resolved position class is applied first.
5014
- requestAnimationFrame(() => {
5015
- const contentEl = contentRef.current;
5016
- if (!contentEl)
5017
- return;
5018
- const contentRect = contentEl.getBoundingClientRect();
5019
- const style = {};
5020
- // For top/bottom positions, tooltip is centered horizontally via CSS
5021
- // (left: 50%; transform: translateX(-50%)).
5022
- // Check if it overflows left or right and apply a corrective shift.
5023
- const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
5024
- const effectivePos = pos || 'top'; // fallback, actual is set by state
5025
- const isVertical = effectivePos === 'top' ||
5026
- effectivePos === 'bottom' ||
5027
- resolvedPosition === 'top' ||
5028
- resolvedPosition === 'bottom';
5029
- if (isVertical) {
5030
- if (contentRect.left < edgePadding) {
5031
- // Overflows left — shift right
5032
- const shift = edgePadding - contentRect.left;
5033
- style.left = `calc(50% + ${shift}px)`;
5034
- style['--tooltip-arrow-offset'] =
5035
- `${-shift}px`;
5036
- }
5037
- else if (contentRect.right > window.innerWidth - edgePadding) {
5038
- // Overflows right — shift left
5039
- const shift = contentRect.right - (window.innerWidth - edgePadding);
5040
- style.left = `calc(50% - ${shift}px)`;
5041
- style['--tooltip-arrow-offset'] =
5042
- `${shift}px`;
5043
- }
5044
- }
5045
- else {
5046
- // For left/right positions, tooltip is centered vertically via CSS
5047
- // (top: 50%; transform: translateY(-50%)).
5048
- if (contentRect.top < edgePadding) {
5049
- const shift = edgePadding - contentRect.top;
5050
- style.top = `calc(50% + ${shift}px)`;
5051
- style['--tooltip-arrow-offset'] =
5052
- `${-shift}px`;
5053
- }
5054
- else if (contentRect.bottom > window.innerHeight - edgePadding) {
5055
- const shift = contentRect.bottom - (window.innerHeight - edgePadding);
5056
- style.top = `calc(50% - ${shift}px)`;
5057
- style['--tooltip-arrow-offset'] =
5058
- `${shift}px`;
5059
- }
5060
- }
5061
- setContentStyle(style);
5062
- });
5063
- }, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
5064
- const handleMouseEnter = React.useCallback(() => {
5065
- setIsHovering(true);
5066
- }, []);
5067
- const handleMouseLeave = React.useCallback(() => {
5068
- setIsHovering(false);
5069
- }, []);
5070
- const handleFocus = React.useCallback(() => {
5071
- setIsHovering(true);
5072
- }, []);
5073
- const handleBlur = React.useCallback(() => {
5074
- setIsHovering(false);
5075
- }, []);
5076
- // Generate Bulma classes
5077
- const tooltipClasses = usePrefixedClassNames('tooltip', {
5078
- 'is-active': alwaysActive || isVisible,
5079
- [`is-${resolvedPosition}`]: resolvedPosition,
5080
- [`is-${color}`]: color,
5081
- [`is-${size}`]: size,
5082
- 'is-multiline': multiline,
5083
- 'is-animated': animated,
5084
- 'is-square': square,
5085
- 'is-dashed': dashed,
5086
- });
5087
- const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
5088
- const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
5089
- 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 })] }));
5090
- };
4904
+ const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5091
4905
 
4906
+ // INTERNAL: deliberately not exported from src/index.ts.
5092
4907
  /**
5093
- * Whether `el` is editable while its parent is not, which makes it an editing
5094
- * host: the one tab stop an editable region gets. `contenteditable="false"`
5095
- * 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.
5096
4910
  */
5097
- function isEditingHost(el) {
5098
- const editable = (node) => {
5099
- const value = node.getAttribute('contenteditable')?.toLowerCase();
5100
- if (value === undefined)
5101
- return null;
5102
- if (value === '' || value === 'true' || value === 'plaintext-only') {
5103
- return true;
5104
- }
5105
- return value === 'false' ? false : null;
4911
+ function chain$1(own, ours) {
4912
+ return event => {
4913
+ own?.(event);
4914
+ ours(event);
5106
4915
  };
5107
- if (editable(el) !== true)
5108
- return false;
5109
- for (let node = el.parentElement; node; node = node.parentElement) {
5110
- const state = editable(node);
5111
- if (state !== null)
5112
- return !state;
5113
- }
5114
- return true;
5115
4916
  }
5116
- /** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
5117
- const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
5118
- Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
5119
4917
  /**
5120
- * Whether the browser puts `el` in the tab order, before anything that hides
5121
- * or disables it. The `tabIndex` property can't answer this on its own: it
5122
- * can read `-1` for an editable region or an `<embed>` that Tab still visits,
5123
- * and `0` for a `<video>` without controls that it doesn't. So a valid
5124
- * `tabindex` attribute decides, and otherwise the element's kind does.
5125
- * 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.
5126
4923
  */
5127
- function isTabbableKind(el) {
5128
- const tabindex = el.getAttribute('tabindex');
5129
- if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
5130
- return el.tabIndex >= 0;
4924
+ function isKeyboardFocus(el) {
4925
+ try {
4926
+ return el.matches(':focus-visible');
5131
4927
  }
5132
- switch (el.localName) {
5133
- case 'a':
5134
- case 'area':
5135
- return el.hasAttribute('href');
5136
- case 'input':
5137
- return el.type !== 'hidden';
5138
- case 'button':
5139
- case 'select':
5140
- case 'textarea':
5141
- case 'iframe':
5142
- case 'embed':
5143
- case 'object':
5144
- return true;
5145
- case 'audio':
5146
- case 'video':
5147
- return el.hasAttribute('controls');
5148
- case 'summary':
5149
- return isDetailsSummary(el);
5150
- default:
5151
- return isEditingHost(el);
4928
+ catch {
4929
+ return true;
5152
4930
  }
5153
4931
  }
4932
+ /** Whether `node` is `container` or inside it, when there are both. */
4933
+ const within = (container, node) => Boolean(container && node && container.contains(node));
5154
4934
  /**
5155
- * The node's parent in the flattened tree, the one the browser renders and
5156
- * tabs through: the slot it is assigned to, else its parent, else the host of
5157
- * the shadow root it tops, and nothing above the document. A closed shadow
5158
- * root hides its slots (`assignedSlot` reads `null` under one), so a child of
5159
- * 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.
5160
4938
  */
5161
- const flatParent = (node) => node.assignedSlot ??
5162
- node.parentElement ??
5163
- node.parentNode?.host ??
5164
- null;
4939
+ const escapeListeners = new Map();
4940
+ /** Counts openings across the page, so the most recent one is known. */
4941
+ let openings = 0;
5165
4942
  /**
5166
- * The element's children in the flattened tree, in the order they render: a
5167
- * host's shadow root content, a slot's assigned elements (or its fallback
5168
- * content when nothing is assigned), anything else's own children. A host
5169
- * whose shadow root is closed has no root to read, so its own children stand
5170
- * in, including any that no slot takes and that aren't rendered.
5171
- */
5172
- function flatChildren(el) {
5173
- if (el.shadowRoot)
5174
- return Array.from(el.shadowRoot.children);
5175
- if (el.localName === 'slot') {
5176
- const assigned = el.assignedNodes();
5177
- if (assigned.length > 0) {
5178
- return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
5179
- }
5180
- }
5181
- return Array.from(el.children);
5182
- }
5183
- const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
5184
- const isVisible = (el) => {
5185
- const { visibility } = getComputedStyle(el);
5186
- return visibility !== 'hidden' && visibility !== 'collapse';
5187
- };
5188
- /**
5189
- * Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
5190
- * (not inherited, so each element is checked) or a closed `<details>` it
5191
- * isn't the summary of. `top` itself isn't checked, and `null` walks to the
5192
- * top of the page.
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.
5193
4965
  */
5194
- function isShownUpTo(el, top) {
5195
- let below = null;
5196
- for (let node = el; node && node !== top; node = flatParent(node)) {
5197
- if (!isShown(node))
5198
- return false;
5199
- if (below &&
5200
- node.localName === 'details' &&
5201
- !node.hasAttribute('open') &&
5202
- !isDetailsSummary(below)) {
5203
- return false;
5204
- }
5205
- below = node;
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);
5206
5009
  }
5207
- return true;
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
+ };
5208
5017
  }
5209
5018
  /**
5210
- * The images that use the image map `area` belongs to. An image names its
5211
- * map with `usemap="#…"`, matching the map's `name` or `id`.
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.
5212
5060
  */
5213
- function imagesUsingMapOf(area) {
5214
- const map = area.closest('map');
5215
- if (!map)
5216
- return [];
5217
- const names = [map.getAttribute('name'), map.id].filter(name => name);
5218
- return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
5219
- const ref = img.getAttribute('usemap');
5220
- return ref.startsWith('#') && names.includes(ref.slice(1));
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,
5221
5071
  });
5222
- }
5223
- /**
5224
- * Whether an image map area is shown. An area has no box of its own: the
5225
- * HTML rendering rules give it `display: none`, though a browser may not
5226
- * report that. So it counts where an image using its map is shown, as long
5227
- * as nothing makes it inert.
5228
- */
5229
- function isAreaShown(area, container) {
5230
- for (let node = area; node && node !== container; node = flatParent(node)) {
5231
- if (node.hasAttribute('inert'))
5232
- return false;
5233
- }
5234
- return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
5235
- }
5236
- /**
5237
- * Whether Tab can land on `el`, one of the kinds it visits. A disabled control
5238
- * (including one inside a disabled `<fieldset>`), an inert subtree and
5239
- * anything not rendered are skipped. `visibility` is inherited, so the
5240
- * element's own computed value covers its ancestors.
5241
- */
5242
- function isRenderedAndEnabled(el, container) {
5243
- if (el.matches(':disabled'))
5244
- return false;
5245
- if (el.localName === 'area')
5246
- return isAreaShown(el, container);
5247
- return isShownUpTo(el, container) && isVisible(el);
5248
- }
5249
- /** A radio button with a name, which shares one tab stop with its group. */
5250
- const isGroupedRadio = (el) => el.localName === 'input' &&
5251
- el.type === 'radio' &&
5252
- el.name !== '';
5253
- /** Whether two radio buttons are in one group: same name, form owner and tree. */
5254
- const sameGroup = (a, b) => isGroupedRadio(b) &&
5255
- b.name === a.name &&
5256
- b.form === a.form &&
5257
- b.getRootNode() === a.getRootNode();
5258
- /**
5259
- * The checked button of `radio`'s group. The group isn't bounded by the trap,
5260
- * so this looks through the radio's whole tree.
5261
- */
5262
- const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
5263
- /** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
5264
- function flatChain(node, container) {
5265
- const chain = [];
5266
- for (let n = node; n && n !== container; n = flatParent(n)) {
5267
- chain.unshift(n);
5268
- }
5269
- return chain;
5270
- }
5271
- /**
5272
- * Whether `node` is inside the container in the flattened tree: in it, in a
5273
- * shadow root it holds, or slotted into it. A child of a closed-root host
5274
- * counts as inside wherever the host is, as its slot can't be seen.
5275
- */
5276
- function inside(container, node) {
5277
- for (let n = node; n; n = flatParent(n)) {
5278
- if (n === container)
5279
- return true;
5280
- }
5281
- return false;
5282
- }
5283
- /**
5284
- * `true` when Tab reaches `b` after `a`, which is their order in the
5285
- * flattened tree: a shadow host comes before its shadow root's content, and
5286
- * slotted content comes where its slot is. A closed root's slots can't be
5287
- * seen, so its host's children keep their place in the light DOM. Two
5288
- * flattened-tree siblings are always in one DOM tree, so their document
5289
- * position decides between them.
5290
- */
5291
- function follows(container, a, b) {
5292
- const chainA = flatChain(a, container);
5293
- const chainB = flatChain(b, container);
5294
- for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
5295
- const [x, y] = [chainA[i], chainB[i]];
5296
- if (x === y)
5297
- continue;
5298
- if (!x)
5299
- return true;
5300
- if (!y)
5301
- return false;
5302
- return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
5303
- }
5304
- return false;
5305
- }
5306
- /**
5307
- * Every element of a tabbable kind under `el` in the flattened tree, depth
5308
- * first, which is the order Tab visits them. Under a host whose shadow root
5309
- * is closed, that takes in the host's own children instead, rendered or not.
5310
- */
5311
- function collectCandidates(el, out = []) {
5312
- for (const child of flatChildren(el)) {
5313
- if (isTabbableKind(child))
5314
- out.push(child);
5315
- collectCandidates(child, out);
5316
- }
5317
- return out;
5318
- }
5319
- /**
5320
- * The first and last tab stops inside `container`, or `null` when there are
5321
- * none. Only the ends matter for wrapping, so the style checks run from each
5322
- * end until they find a stop rather than over every candidate.
5323
- *
5324
- * A radio group is one stop, the way the browser treats it: its checked
5325
- * button, or its first when none is checked. Counting every button would make
5326
- * the group's last one an end, and Tab from the checked one would leave. The
5327
- * group goes by name, form owner and tree, not by the trap, so a checked
5328
- * button outside the container that Tab can land on leaves the group no stop
5329
- * inside. One that Tab can't land on leaves the group as if none were checked.
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;
5084
+ }
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,
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]);
5321
+ }
5322
+
5323
+ /**
5324
+ * The `Tooltip` component displays helpful information when users hover over or focus on an element.
5325
+ *
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.
5330
5495
  */
5331
- function findTabStops(container) {
5332
- const candidates = collectCandidates(container);
5333
- const checked = new Map();
5334
- const usable = (el) => {
5335
- let ok = checked.get(el);
5336
- if (ok === undefined) {
5337
- ok = isRenderedAndEnabled(el, container);
5338
- 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;
5339
5503
  }
5340
- return ok;
5341
- };
5342
- // Resolved once per group, filed under each of its buttons, since finding
5343
- // the checked one scans the radio's whole tree.
5344
- const groupStops = new Map();
5345
- const groupStop = (radio) => {
5346
- if (groupStops.has(radio))
5347
- return groupStops.get(radio);
5348
- const group = candidates.filter(el => sameGroup(radio, el));
5349
- const on = checkedInGroup(radio);
5350
- const stop = on && isTabbableKind(on) && usable(on)
5351
- ? group.find(el => el === on)
5352
- : group.find(usable);
5353
- for (const el of [radio, ...group])
5354
- groupStops.set(el, stop);
5355
- return stop;
5504
+ return value === 'false' ? false : null;
5356
5505
  };
5357
- const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
5358
- const first = candidates.find(isStop);
5359
- if (!first)
5360
- return null;
5361
- let last = first;
5362
- for (let i = candidates.length - 1; candidates[i] !== first; i--) {
5363
- if (isStop(candidates[i])) {
5364
- last = candidates[i];
5365
- break;
5366
- }
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;
5367
5512
  }
5368
- return {
5369
- first,
5370
- last,
5371
- stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
5372
- };
5373
- }
5374
- /**
5375
- * The first tab stop inside `container`, found the way the trap finds it, or
5376
- * `null` when there is none. For a component that moves focus into a panel
5377
- * without trapping it there. `src/index.ts` names its exports from this
5378
- * module, so this one stays internal.
5379
- */
5380
- function firstTabStop(container) {
5381
- return findTabStops(container)?.first ?? null;
5513
+ return true;
5382
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;
5383
5518
  /**
5384
- * The element that has focus in the container's own document (an iframe's,
5385
- * when it renders into one), inside open shadow roots too. Focus inside a
5386
- * closed one reads as its host, which the comparisons above place like any
5387
- * other element. Browsers fall back to <body> when nothing has
5388
- * focus. jsdom, where consumers run their tests, reports nothing once focus
5389
- * was inside a shadow root that was removed, so this falls back for it.
5390
- */
5391
- const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
5392
- /**
5393
- * Keeps keyboard focus inside a container while it is active: focus moves in
5394
- * when the trap turns on, Tab and Shift+Tab wrap between the first and last
5395
- * tab stops, and focus goes back where it came from when the trap turns off.
5396
- *
5397
- * Tab stops are what the browser's Tab visits, in document order: links,
5398
- * form controls, frames, media with controls, a `<details>` summary, editable
5399
- * regions, anything with `tabIndex={0}`, and the content of open shadow
5400
- * roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
5401
- * and a radio group counts once. Give the container `tabIndex={-1}` so it can
5402
- * hold focus itself when nothing inside can. The trap listens on the
5403
- * container, so a trap nested inside another handles Tab first and the outer
5404
- * one stands aside.
5405
- *
5406
- * The trap is about Tab. If a Tab it let through still takes focus out (a
5407
- * browser can leave out a stop the page has, such as links when its settings
5408
- * say to), it sends focus back to the other end. A pointer or a script moving
5409
- * focus out is left alone, because pulling focus back from a click is modal
5410
- * behaviour, and a modal also has to block the page behind it, which `Modal`
5411
- * does. Content the container renders through a portal lives elsewhere in
5412
- * the DOM, so it is outside the trap: render nested overlays inside the
5413
- * container, or give them a trap of their own. To trap content a `Portal`
5414
- * renders, pass a ref to the element inside the `Portal` as `containerRef`.
5415
- *
5416
- * The trap looks at `containerRef` again each time the component calling it
5417
- * renders. A container that mounts after the trap turns on, such as a panel
5418
- * waiting for a `Portal` target held in state, has it attach once that
5419
- * component renders with the container in the DOM. When the container leaves
5420
- * or is replaced, the trap lets go of it as it would turning off, handing
5421
- * focus back, and attaches to the next one.
5422
- * A child that mounts the container in a render of its own (from its own
5423
- * state, or as a `Suspense` boundary resolves) goes unseen until the
5424
- * component calling the hook renders again. The trap waits for hydration, so
5425
- * that component renders again when the page hydrates, and portaled content,
5426
- * which first appears then, is found.
5427
- *
5428
- * @function useFocusTrap
5429
- * @param containerRef - Ref to the element focus stays inside.
5430
- * @param options - When the trap is on, and where focus starts and ends.
5431
- *
5432
- * @example
5433
- * const panelRef = useRef<HTMLDivElement>(null);
5434
- * useFocusTrap(panelRef, { active: isOpen });
5435
- * return isOpen ? (
5436
- * <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
5437
- * …
5438
- * </div>
5439
- * ) : 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.
5440
5525
  */
5441
- function useFocusTrap(containerRef, options = {}) {
5442
- const { active = true, initialFocusRef, restoreFocus = true } = options;
5443
- // Portaled content has no server-rendered counterpart, so a portaled
5444
- // container only exists from the commit after hydration.
5445
- const hydrated = useIsHydrated();
5446
- // The container the trap is attached to, or `null` while it has none.
5447
- const heldRef = React.useRef(null);
5448
- // Bumped to run the trap again when the container in the ref changes.
5449
- const [refChanges, setRefChanges] = React.useState(0);
5450
- React.useEffect(() => {
5451
- heldRef.current = null;
5452
- if (!active || !hydrated)
5453
- return undefined;
5454
- const container = containerRef.current;
5455
- if (!container)
5456
- return undefined;
5457
- heldRef.current = container;
5458
- const doc = container.ownerDocument;
5459
- const opener = focusedElement(doc);
5460
- // `initialFocusRef` counts only when focusing it puts focus inside. Focus
5461
- // put outside would stay out, as Tab there never reaches the container's
5462
- // listener, and focusing something that can't take focus (a heading
5463
- // without `tabIndex`, a disabled control) leaves focus where it was.
5464
- const initial = initialFocusRef?.current;
5465
- const inTrap = initial && inside(container, initial) ? initial : null;
5466
- inTrap?.focus();
5467
- if (!inTrap || !inside(container, focusedElement(doc))) {
5468
- (findTabStops(container)?.first ?? container).focus();
5469
- }
5470
- // Where focus goes if a Tab the trap let the browser handle takes it out,
5471
- // held until that Tab has finished moving focus.
5472
- let wrapTo = null;
5473
- let wrapTimer;
5474
- const handleKeyDown = (e) => {
5475
- // A trap nested inside this one has already wrapped focus.
5476
- if (e.key !== 'Tab' || e.defaultPrevented)
5477
- return;
5478
- const stops = findTabStops(container);
5479
- if (!stops) {
5480
- e.preventDefault();
5481
- container.focus();
5482
- return;
5483
- }
5484
- const current = stops.stopFor(focusedElement(doc));
5485
- // Wrap whenever the browser has no stop left in that direction inside
5486
- // the container. Comparing positions rather than testing `=== first`
5487
- // also covers focus resting on the container or on an element outside
5488
- // the tab order, which the browser would otherwise walk straight out of.
5489
- if (e.shiftKey
5490
- ? !follows(container, stops.first, current)
5491
- : !follows(container, current, stops.last)) {
5492
- e.preventDefault();
5493
- (e.shiftKey ? stops.last : stops.first).focus();
5494
- return;
5495
- }
5496
- wrapTo = e.shiftKey ? stops.last : stops.first;
5497
- clearTimeout(wrapTimer);
5498
- wrapTimer = setTimeout(() => {
5499
- wrapTo = null;
5500
- });
5501
- };
5502
- // The browser moves focus as soon as the keydown is over, so focus that
5503
- // lands outside while `wrapTo` is set got there by that Tab.
5504
- const handleFocusIn = (e) => {
5505
- if (!wrapTo || inside(container, e.composedPath()[0]))
5506
- return;
5507
- const target = wrapTo;
5508
- wrapTo = null;
5509
- target.focus();
5510
- };
5511
- container.addEventListener('keydown', handleKeyDown);
5512
- doc.addEventListener('focusin', handleFocusIn, true);
5513
- return () => {
5514
- container.removeEventListener('keydown', handleKeyDown);
5515
- doc.removeEventListener('focusin', handleFocusIn, true);
5516
- clearTimeout(wrapTimer);
5517
- let target = restoreFocus ? opener : null;
5518
- // `null` is an object too, and a caller without types can pass it.
5519
- if (restoreFocus && typeof restoreFocus === 'object') {
5520
- // Read now rather than when the trap turned on: a trigger that
5521
- // re-mounted in between has a new node.
5522
- // eslint-disable-next-line react-hooks/exhaustive-deps
5523
- target = restoreFocus.current;
5524
- }
5525
- // Hand focus back only while the trap still holds it: inside the
5526
- // container, or dropped to <body> because the container left the page.
5527
- const now = focusedElement(doc);
5528
- if (target && (now === doc.body || inside(container, now))) {
5529
- target.focus();
5530
- }
5531
- };
5532
- }, [
5533
- active,
5534
- hydrated,
5535
- containerRef,
5536
- initialFocusRef,
5537
- restoreFocus,
5538
- refChanges,
5539
- ]);
5540
- // Nothing says when a ref is set, so after every render, a ref that no
5541
- // longer matches what the trap holds (a container that mounted, left or was
5542
- // replaced) runs it again. Effects run in order, so whenever the effect
5543
- // above ran in this commit, it read the ref just before this does, and a
5544
- // mismatch means the ref changed in a render it didn't run for. The render
5545
- // a bump asks for runs it, so the two match and the trap settles. An empty
5546
- // ref matches a trap holding nothing, so a container that never mounts
5547
- // costs no render.
5548
- // eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
5549
- React.useEffect(() => {
5550
- if (active && hydrated && containerRef.current !== heldRef.current) {
5551
- 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;
5552
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));
5553
5620
  });
5554
5621
  }
5555
-
5556
- // INTERNAL: deliberately not exported from src/index.ts.
5557
5622
  /**
5558
- * `useLayoutEffect` in the browser, `useEffect` on the server. A server
5559
- * render runs neither, and React 18 warns about a layout effect there, so the
5560
- * 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.
5561
5627
  */
5562
- const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5563
-
5564
- // 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
+ }
5565
5635
  /**
5566
- * A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
5567
- * side facing the anchor, negative on a portaled panel above it, so the
5568
- * 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.
5569
5640
  */
5570
- function marginGap(panel) {
5571
- const { marginTop, marginBottom } = getComputedStyle(panel);
5572
- 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);
5573
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();
5574
5657
  /**
5575
- * The corner that keeps a panel of this size in the viewport, preferring
5576
- * 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.
5577
5660
  */
5578
- function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
5579
- const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
5580
- const fitsRight = rect.left + panelWidth <= view.innerWidth;
5581
- if (fitsBelow)
5582
- return fitsRight ? 'bottom-left' : 'bottom-right';
5583
- 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;
5584
5669
  }
5585
- const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
5586
5670
  /**
5587
- * Places a floating panel against its anchor: resolves `auto` to a corner
5588
- * and, for a panel fixed to the viewport, works out its coordinates. It
5589
- * measures again on resize and on any scroll of the anchor's window (an
5590
- * iframe's, when the anchor renders into one), so a fixed panel follows its
5591
- * anchor. A measurement that changes nothing doesn't render again.
5592
- *
5593
- * Measuring happens in a layout effect, so the panel is placed before it
5594
- * paints. It needs both elements on the page while `active` is on, and
5595
- * keeps the last placement when either is missing.
5596
- *
5597
- * @function useAnchoredPosition
5598
- * @param anchorRef - The element the panel opens from.
5599
- * @param panelRef - The floating panel.
5600
- * @param options - Whether to measure, the corner asked for, and how the panel is placed.
5601
- * @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.
5602
5674
  */
5603
- function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
5604
- const [resolved, setResolved] = React.useState({
5605
- position: position === 'auto' ? 'bottom-left' : position,
5606
- });
5607
- useClientLayoutEffect(() => {
5608
- if (!active)
5609
- return undefined;
5610
- const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
5611
- window;
5612
- const update = () => {
5613
- const anchor = anchorRef.current;
5614
- const panel = panelRef.current;
5615
- if (!anchor || !panel)
5616
- return;
5617
- const rect = anchor.getBoundingClientRect();
5618
- const { width, height } = panel.getBoundingClientRect();
5619
- const corner = position === 'auto'
5620
- ? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
5621
- : position;
5622
- const next = fixed
5623
- ? {
5624
- position: corner,
5625
- top: corner.startsWith('bottom')
5626
- ? rect.bottom + offset
5627
- : rect.top - height - offset,
5628
- left: corner.endsWith('left') ? rect.left : rect.right - width,
5629
- }
5630
- : { position: corner };
5631
- setResolved(prev => (samePosition(prev, next) ? prev : next));
5632
- };
5633
- update();
5634
- view.addEventListener('resize', update, { passive: true });
5635
- view.addEventListener('scroll', update, { passive: true, capture: true });
5636
- return () => {
5637
- view.removeEventListener('resize', update);
5638
- view.removeEventListener('scroll', update, { capture: true });
5639
- };
5640
- }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
5641
- 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;
5642
5681
  }
5643
-
5644
- const validButtonColors = [...validColors, 'text', 'ghost'];
5645
5682
  /**
5646
- * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5647
- *
5648
- * @function
5649
- * @param {ButtonProps} props - Props for the Button component.
5650
- * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
5651
- * @returns {JSX.Element} The rendered button, anchor, or custom element.
5652
- * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
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.
5653
5729
  */
5654
- const Button = React.forwardRef(function Button(props, ref) {
5655
- const { color, size, isLight, isRounded, isLoading, isStatic, isFullwidth, isFullWidth, isOutlined, isInverted, isFocused, isActive, isHovered, isDisabled, className, children, textColor, bgColor, as: Component = 'button', href, onClick, target, rel, ...bulmaProps } = props;
5656
- const { bulmaHelperClasses, rest } = useBulmaClasses({
5657
- color: textColor,
5658
- backgroundColor: bgColor,
5659
- ...bulmaProps,
5660
- });
5661
- // Generate Bulma classes with prefix
5662
- const bulmaClasses = usePrefixedClassNames('button', {
5663
- [`is-${color}`]: color &&
5664
- validButtonColors.includes(color),
5665
- [`is-${size}`]: size,
5666
- 'is-outlined': isOutlined,
5667
- 'is-light': isLight,
5668
- 'is-loading': isLoading,
5669
- 'is-static': isStatic,
5670
- 'is-disabled': isDisabled,
5671
- 'is-rounded': isRounded,
5672
- 'is-hovered': isHovered,
5673
- 'is-focused': isFocused,
5674
- 'is-active': isActive,
5675
- 'is-inverted': isInverted,
5676
- 'is-fullwidth': isFullwidth ?? isFullWidth,
5677
- });
5678
- // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
5679
- const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
5680
- if (Component !== 'button') {
5681
- // Strip the form-control attributes a link-like element cannot carry, so a
5682
- // JavaScript consumer or a spread object doesn't put them on the DOM. The
5683
- // types no longer allow them through; this is the runtime backstop.
5684
- //
5685
- // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
5686
- // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
5687
- // its props reach it. Stripping names by their spelling alone broke that
5688
- // promise silently: `<Button as={Custom} name="x" />` type-checked while
5689
- // `Custom` never received the `name` it requires.
5690
- //
5691
- // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
5692
- // targets this list was filtering them from. `autoFocus` is a global
5693
- // attribute (React decides per element whether to honour it, which is not
5694
- // ours to pre-empt), and `type` on an anchor is the MIME hint that
5695
- // `ButtonProps<'a'>` now types it as.
5696
- //
5697
- // The types already answer this for a TypeScript caller: `ButtonProps<T>`
5698
- // derives from `ComponentPropsWithoutRef<T>`, so only what the target
5699
- // accepts can be passed. What is left is a runtime backstop for JavaScript
5700
- // consumers and spread objects.
5701
- //
5702
- // Keep it to the two cases where a stray attribute does something. The
5703
- // submit-overrides belong to a submit control and never make sense on the
5704
- // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
5705
- // `.button[disabled]` (background, border, shadow, opacity), so letting it
5706
- // through to a `<span>` greys the element out — main stripped it, and not
5707
- // stripping it would be a silent visual change on a types-only release.
5708
- //
5709
- // Everything else (`name`, `value`, `form`) is inert where it does not
5710
- // belong, and enumerating which tags own it is what kept going wrong:
5711
- // first all nine names on every non-button tag (so `<Button as="input"
5712
- // name="query" value="Search">` submitted nothing), then a form-control
5713
- // allowlist that still lost `formAction` on an input and `value` on an
5714
- // option. Those now ride on the types instead.
5715
- //
5716
- // A custom component is exempt entirely — it owns its prop contract, and
5717
- // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
5718
- //
5719
- // The elements that own `disabled`, per the HTML spec. A closed set, unlike
5720
- // the open-ended question of which tag owns which attribute.
5721
- const SUBMIT_OVERRIDES = [
5722
- 'formAction',
5723
- 'formEncType',
5724
- 'formMethod',
5725
- 'formNoValidate',
5726
- 'formTarget',
5727
- ];
5728
- const OWNS_DISABLED = [
5729
- 'button',
5730
- 'fieldset',
5731
- 'input',
5732
- 'optgroup',
5733
- 'option',
5734
- 'select',
5735
- 'textarea',
5736
- ];
5737
- // Two independent filters. They are not exclusive — an `<a>` owns neither
5738
- // the submit-overrides nor `disabled`, so it takes both — and deriving them
5739
- // from one destructure, or from one if/else, is what broke each of them in
5740
- // turn: first the overrides came off every intrinsic (an `as="input"` lost
5741
- // `formAction`), then scoping them to the anchor let `disabled` back onto
5742
- // it.
5743
- const forwardedRest = { ...rest };
5744
- if (typeof Component === 'string' && !isCustomElement(Component)) {
5745
- // Meaningless anywhere but a submit control, and an anchor is not one.
5746
- if (Component === 'a') {
5747
- for (const key of SUBMIT_OVERRIDES)
5748
- delete forwardedRest[key];
5749
- }
5750
- // Visible via Bulma's `.button[disabled]`, so withheld from any element
5751
- // that does not own it.
5752
- if (!OWNS_DISABLED.includes(Component))
5753
- delete forwardedRest.disabled;
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;
5754
5765
  }
5755
- return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
5756
- ? // Guarded, because this handler is installed into whatever `as`
5757
- // renders and a custom component's `onClick` need not be a DOM
5758
- // event handler at all. One declaring `onClick: (v: string) =>
5759
- // void` calls this with a string, and an unguarded
5760
- // `e.preventDefault()` throws `is not a function`. Blocking the
5761
- // real event is the point; a foreign payload is simply not ours
5762
- // to act on.
5763
- (e) => {
5764
- if (typeof e?.preventDefault === 'function')
5765
- e.preventDefault();
5766
- }
5767
- : onClick, ...forwardedRest, children: children }));
5768
5766
  }
5769
- return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
5770
- });
5771
- Button.displayName = 'Button';
5772
-
5773
- const PopoverContext = React.createContext(null);
5774
- /** Intrinsic elements that are controls already, and keep their own role. */
5775
- const NATIVE_CONTROLS = new Set([
5776
- 'button',
5777
- 'input',
5778
- 'select',
5779
- 'textarea',
5780
- 'summary',
5781
- ]);
5767
+ return {
5768
+ first,
5769
+ last,
5770
+ stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
5771
+ };
5772
+ }
5782
5773
  /**
5783
- * Whether the browser answers this key on `el` itself, by clicking it or by
5784
- * 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.
5785
5778
  */
5786
- function handlesKeyItself(el, key) {
5787
- if (NATIVE_CONTROLS.has(el.localName))
5788
- return true;
5789
- return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
5790
- }
5791
- /** The innermost dialog the event passed through, which is the one its Escape belongs to. */
5792
- function nearestDialog(event) {
5793
- return (event.composedPath().find(node => {
5794
- const role = node.getAttribute?.('role');
5795
- return role === 'dialog' || role === 'alertdialog';
5796
- }) ?? null);
5779
+ function firstTabStop(container) {
5780
+ return findTabStops(container)?.first ?? null;
5797
5781
  }
5798
5782
  /**
5799
- * 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.
5800
5795
  *
5801
- * The panel is a non-modal `role="dialog"`: focus moves into it on open
5802
- * and returns to the trigger on close, while the rest of the page stays
5803
- * reachable by pointer and assistive technology. For hover-only hints use
5804
- * `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.
5805
5804
  *
5806
- * @function
5807
- * @param {PopoverProps} props - Props for the Popover component.
5808
- * @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.
5809
5830
  *
5810
5831
  * @example
5811
- * <Popover trigger={<Button>Filters</Button>}>
5812
- * <Popover.Header>Filter rows</Popover.Header>
5813
- * <Popover.Body>…</Popover.Body>
5814
- * <Popover.Footer>
5815
- * <Popover.Close>Done</Popover.Close>
5816
- * </Popover.Footer>
5817
- * </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;
5818
5839
  */
5819
- const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
5820
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
5821
- const rootRef = React.useRef(null);
5822
- const panelRef = React.useRef(null);
5823
- // The last pointerdown React saw inside the panel. React's tree includes
5824
- // content the panel portals elsewhere, which the DOM path doesn't.
5825
- const insidePointerRef = React.useRef(null);
5826
- const baseId = React.useId();
5827
- const panelId = `${baseId}-panel`;
5828
- const titleId = `${baseId}-title`;
5829
- const isControlled = controlledOpen !== undefined;
5830
- const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
5831
- const isOpen = controlledOpen ?? internalOpen;
5832
- const requestOpen = React.useCallback((next) => {
5833
- onOpenChange?.(next);
5834
- if (!isControlled)
5835
- setInternalOpen(next);
5836
- }, [isControlled, onOpenChange]);
5837
- const close = React.useCallback(() => requestOpen(false), [requestOpen]);
5838
- // A portaled panel has no server markup, so it only exists from the commit
5839
- // 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.
5840
5844
  const hydrated = useIsHydrated();
5841
- const panelMounted = isOpen && (hydrated || !appendToBody);
5842
- const resolved = useAnchoredPosition(rootRef, panelRef, {
5843
- active: panelMounted,
5844
- position,
5845
- fixed: appendToBody,
5846
- // The gap comes from `--bulma-popover-offset` in the stylesheet, so the
5847
- // coordinates leave it out and `auto` reads it off the panel.
5848
- offset: 0,
5849
- styledGap: marginGap,
5850
- });
5851
- // The trigger renders first inside the wrapper. Read when needed rather
5852
- // than held, so a trigger that remounted is still the one found.
5853
- const triggerNode = React.useCallback(() => {
5854
- const first = rootRef.current?.firstElementChild;
5855
- return first && first !== panelRef.current ? first : null;
5856
- }, []);
5857
- const restoreFocusRef = React.useMemo(() => ({
5858
- get current() {
5859
- return triggerNode();
5860
- },
5861
- }), [triggerNode]);
5862
- useFocusTrap(panelRef, {
5863
- active: panelMounted && trapFocus,
5864
- restoreFocus: restoreFocusRef,
5865
- });
5866
- // Without the trap, focus still arrives in the panel and goes back to the
5867
- // 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);
5868
5849
  React.useEffect(() => {
5869
- const panel = panelRef.current;
5870
- if (!panelMounted || trapFocus || !panel)
5850
+ heldRef.current = null;
5851
+ if (!active || !hydrated)
5871
5852
  return undefined;
5872
- (firstTabStop(panel) ?? panel).focus();
5873
- return () => {
5874
- const doc = panel.ownerDocument;
5875
- const now = getDeepestActiveElement(doc);
5876
- if (!now || now === doc.body || panel.contains(now)) {
5877
- triggerNode()?.focus();
5878
- }
5879
- };
5880
- }, [panelMounted, trapFocus, triggerNode]);
5881
- React.useEffect(() => {
5882
- if (!panelMounted || !closeOnClickOutside)
5853
+ const container = containerRef.current;
5854
+ if (!container)
5883
5855
  return undefined;
5884
- const handlePointerDown = (e) => {
5885
- 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();
5886
5881
  return;
5887
5882
  }
5888
- close();
5889
- };
5890
- // The wrapper is on the page whenever the panel is. Its document, an
5891
- // iframe's when the tree renders into one, is where presses land.
5892
- const doc = rootRef.current.ownerDocument;
5893
- doc.addEventListener('pointerdown', handlePointerDown);
5894
- return () => doc.removeEventListener('pointerdown', handlePointerDown);
5895
- }, [panelMounted, closeOnClickOutside, close]);
5896
- const handlePanelKeyDown = (e) => {
5897
- if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
5898
- return;
5899
- // React bubbles the key here from portaled content as well, so a dialog
5900
- // of its own on the way (a nested popover, a picker's calendar) owns it.
5901
- const owner = nearestDialog(e.nativeEvent);
5902
- if (owner && owner !== panelRef.current)
5903
- return;
5904
- e.preventDefault();
5905
- close();
5906
- };
5907
- const triggerProps = trigger.props;
5908
- const handleTriggerClick = (e) => {
5909
- triggerProps.onClick?.(e);
5910
- if (!e.defaultPrevented)
5911
- requestOpen(!isOpen);
5912
- };
5913
- const handleTriggerKeyDown = (e) => {
5914
- triggerProps.onKeyDown?.(e);
5915
- // A key on something inside the trigger is that element's to answer.
5916
- if (e.defaultPrevented || e.target !== e.currentTarget)
5917
- return;
5918
- if (e.key === 'Escape') {
5919
- 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)) {
5920
5891
  e.preventDefault();
5921
- close();
5892
+ (e.shiftKey ? stops.last : stops.first).focus();
5893
+ return;
5922
5894
  }
5923
- return;
5924
- }
5925
- if (e.key !== 'Enter' && e.key !== ' ')
5926
- return;
5927
- if (handlesKeyItself(e.currentTarget, e.key))
5928
- return;
5929
- // Space would scroll the page; a held key repeats.
5930
- e.preventDefault();
5931
- if (!e.repeat)
5932
- requestOpen(!isOpen);
5933
- };
5934
- const needsButtonRole = typeof trigger.type === 'string' &&
5935
- !NATIVE_CONTROLS.has(trigger.type) &&
5936
- !(trigger.type === 'a' && triggerProps.href != null);
5937
- const clonedTrigger = React.cloneElement(trigger, {
5938
- ...(needsButtonRole
5939
- ? {
5940
- role: triggerProps.role ?? 'button',
5941
- 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;
5942
5923
  }
5943
- : {}),
5944
- 'aria-haspopup': 'dialog',
5945
- 'aria-expanded': isOpen,
5946
- 'aria-controls': panelMounted ? panelId : undefined,
5947
- onClick: handleTriggerClick,
5948
- onKeyDown: handleTriggerKeyDown,
5949
- });
5950
- const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
5951
- const panelClasses = classNames(usePrefixedClassNames('popover-content', {
5952
- [`is-${resolved.position}`]: true,
5953
- 'is-portal': appendToBody,
5954
- }), contentClassName);
5955
- 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
5956
- ? {
5957
- position: 'fixed',
5958
- top: resolved.top,
5959
- left: resolved.left,
5960
- }
5961
- : undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
5962
- insidePointerRef.current = e.nativeEvent;
5963
- }, children: children }) }))] }));
5964
- };
5965
- /**
5966
- * The open panel. It mounts with each opening, so it counts the
5967
- * `Popover.Header`s inside it afresh every time.
5968
- */
5969
- function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
5970
- // Headers register in a layout effect, after this first render, and this
5971
- // panel's own layout effect runs after theirs. Until then the panel assumes
5972
- // it has one, so a panel that does is named from its first commit, before
5973
- // focus moves in, and one that doesn't drops the reference before it paints.
5974
- const [titlesCounted, setTitlesCounted] = React.useState(false);
5975
- const [titleCount, setTitleCount] = React.useState(0);
5976
- const registerTitle = React.useCallback(() => {
5977
- setTitleCount(n => n + 1);
5978
- return () => setTitleCount(n => n - 1);
5979
- }, []);
5980
- useClientLayoutEffect(() => {
5981
- setTitlesCounted(true);
5982
- }, []);
5983
- 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
5984
5948
  React.useEffect(() => {
5985
- if (titlesCounted && !named) {
5986
- warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
5987
- 'Pass ariaLabel, or render a Popover.Header inside it.');
5949
+ if (active && hydrated && containerRef.current !== heldRef.current) {
5950
+ setRefChanges(n => n + 1);
5988
5951
  }
5989
- }, [titlesCounted, named]);
5990
- const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
5991
- 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
+ });
5992
5953
  }
5954
+
5955
+ // INTERNAL: deliberately not exported from src/index.ts.
5993
5956
  /**
5994
- * The popover's title, which names the panel for assistive technology.
5995
- *
5996
- * @function
5997
- * @param {PopoverHeaderProps} props - Props for the Popover.Header component.
5998
- * @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.
5999
5960
  */
6000
- const PopoverHeader = ({ className, ...props }) => {
6001
- const context = React.useContext(PopoverContext);
6002
- const registerTitle = context?.registerTitle;
6003
- // Before paint, so the panel is named by the time it shows.
6004
- useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
6005
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6006
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
6007
- };
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
+ }
6008
5965
  /**
6009
- * The popover's main content area.
6010
- *
6011
- * @function
6012
- * @param {PopoverBodyProps} props - Props for the Popover.Body component.
6013
- * @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.
6014
5968
  */
6015
- const PopoverBody = ({ className, ...props }) => {
6016
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6017
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
6018
- };
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;
6019
5977
  /**
6020
- * 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.
6021
5983
  *
6022
- * @function
6023
- * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
6024
- * @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.
6025
5993
  */
6026
- const PopoverFooter = ({ className, ...props }) => {
6027
- const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6028
- return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
6029
- };
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'];
6030
6036
  /**
6031
- * 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.
6032
6038
  *
6033
6039
  * @function
6034
- * @param {PopoverCloseProps} props - Props for the Popover.Close component.
6035
- * @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}
6036
6044
  */
6037
- const PopoverClose = ({ onClick, ...props }) => {
6038
- const context = React.useContext(PopoverContext);
6039
- return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
6040
- onClick?.(e);
6041
- if (!e.defaultPrevented)
6042
- context?.close();
6043
- } }));
6044
- };
6045
- /** Popover with Header, Body, Footer and Close sub-components. */
6046
- const Popover = withSubComponents(PopoverComponent, {
6047
- Header: PopoverHeader,
6048
- Body: PopoverBody,
6049
- Footer: PopoverFooter,
6050
- Close: PopoverClose,
6051
- }, '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';
6052
6163
 
6053
- // 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
+ ]);
6054
6173
  /**
6055
- * Whether focus that just arrived on `el` is keyboard focus, as the browser
6056
- * decides it for `:focus-visible`: focus from a key or from the browser's
6057
- * own controls is, focus a press brings isn't, and focus a script moves
6058
- * follows whichever came last. A browser that can't match the selector
6059
- * 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.
6060
6176
  */
6061
- function isKeyboardFocus(el) {
6062
- try {
6063
- return el.matches(':focus-visible');
6064
- }
6065
- catch {
6177
+ function handlesKeyItself(el, key) {
6178
+ if (NATIVE_CONTROLS.has(el.localName))
6066
6179
  return true;
6067
- }
6180
+ return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
6068
6181
  }
6069
- /** Whether `node` is `container` or inside it, when there are both. */
6070
- const within = (container, node) => Boolean(container && node && container.contains(node));
6071
- /**
6072
- * Per document, the hooks that want Escape and the one listener they share
6073
- * while there are any, so a card inside another card doesn't add a second
6074
- * listener that closes both on one keypress.
6075
- */
6076
- const escapeListeners = new Map();
6077
- /** Counts openings across the page, so the most recent one is known. */
6078
- let openings = 0;
6079
- /**
6080
- * Adds `entry` to the hooks that want Escape on `doc`, starting the shared
6081
- * listener when it is the first.
6082
- *
6083
- * Escape takes back every open that is on its way, so nothing appears right
6084
- * after it, and closes one open floating element, chosen by these rules in
6085
- * turn:
6086
- *
6087
- * 1. One that focus is in, on its trigger or inside it, when there is one.
6088
- * 2. The innermost: one whose floating element holds no other's trigger.
6089
- * That follows nesting whether either one is portaled, since an inner
6090
- * trigger sits in the outer floating element either way.
6091
- * 3. The one that opened last.
6092
- *
6093
- * The keypress belongs to whatever has focus. When that is one of the open
6094
- * floating elements or their triggers, or nothing, the listener stops it in
6095
- * the capture phase, before anything around (a Popover, a Modal) sees it.
6096
- * When focus is anywhere else, such as a combobox or a dialog of its own,
6097
- * the floating element still closes, since hover put it there without
6098
- * focus, and the key goes on to that owner.
6099
- *
6100
- * @returns What to call when the hook stops wanting Escape.
6101
- */
6102
- function wantEscape(doc, entry) {
6103
- let listener = escapeListeners.get(doc);
6104
- if (!listener) {
6105
- const entries = new Set();
6106
- const handleKeyDown = (event) => {
6107
- if (event.key !== 'Escape')
6108
- return;
6109
- const open = [];
6110
- // A copy, since taking an open back can take its hook out of the set.
6111
- for (const each of [...entries]) {
6112
- if (each.openedAt)
6113
- open.push(each);
6114
- else
6115
- each.cancel();
6116
- }
6117
- const focused = getDeepestActiveElement(doc);
6118
- const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
6119
- const pool = holding.length > 0 ? holding : open;
6120
- let top;
6121
- for (const each of pool) {
6122
- const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
6123
- if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
6124
- top = each;
6125
- }
6126
- }
6127
- if (!top)
6128
- return;
6129
- if (holding.length > 0 || !focused || focused === doc.body) {
6130
- event.preventDefault();
6131
- event.stopPropagation();
6132
- }
6133
- top.close();
6134
- };
6135
- doc.addEventListener('keydown', handleKeyDown, true);
6136
- listener = {
6137
- entries,
6138
- stop: () => {
6139
- doc.removeEventListener('keydown', handleKeyDown, true);
6140
- escapeListeners.delete(doc);
6141
- },
6142
- };
6143
- escapeListeners.set(doc, listener);
6144
- }
6145
- const shared = listener;
6146
- shared.entries.add(entry);
6147
- return () => {
6148
- shared.entries.delete(entry);
6149
- if (shared.entries.size === 0)
6150
- shared.stop();
6151
- };
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);
6152
6188
  }
6153
6189
  /**
6154
- * Decides when a floating element that opens on hover or keyboard focus asks
6155
- * to open and to close. It renders nothing, and leaves roles, ARIA and
6156
- * 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.
6157
6191
  *
6158
- * - The pointer entering, or keyboard focus arriving on the trigger, asks to
6159
- * open after `openDelay`. A touch pointer doesn't count. Neither does focus
6160
- * that a press brings to the trigger: it counts when the browser shows it
6161
- * as `:focus-visible`.
6162
- * - Focus in the floating element holds it open however it got there,
6163
- * including focus in content it portals elsewhere, such as a nested card.
6164
- * Whether a click focuses what it lands on is up to the browser: Safari
6165
- * and Firefox on macOS don't focus a button on click, so there a clicked
6166
- * button doesn't hold it once the pointer leaves.
6167
- * - Once the pointer and focus have both left, it asks to close after
6168
- * `closeDelay`, and either coming back before then keeps it open. Nothing
6169
- * else closes it on a timer.
6170
- * - Escape closes it at once, wherever focus is, and stops there when focus
6171
- * is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
6172
- * keeps its own. With focus somewhere else, the key goes on after closing
6173
- * it. Inside another one, the inner one closes first. Escape before it
6174
- * opens takes the opening back and goes on its way. One key listener per
6175
- * document serves every hook on it, there only while one of them is open
6176
- * or on its way to open.
6177
- * - With `openOnTap`, a tap on the trigger opens it at once and the next tap
6178
- * on the trigger closes it. While a tap holds it open, a press outside the
6179
- * trigger and the floating element closes it, from a listener on the
6180
- * document that is there only then. Taps never count as hover, and mouse
6181
- * and pen work the same with it or without.
6182
- * - Once it closes, for any reason, whatever was holding it open has to
6183
- * leave and come back to open it again.
6184
- * - When the floating element closes with focus inside it, focus goes back
6185
- * to the trigger, unless the same update put it somewhere else. Handing it
6186
- * 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`.
6187
6196
  *
6188
- * 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.
6189
6200
  *
6190
- * @function useHoverOpen
6191
- * @param options - The open state, how to ask for another, the delays, and the two elements.
6192
- * @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>
6193
6209
  */
6194
- function useHoverOpen(options) {
6195
- const latestRef = React.useRef(options);
6196
- const stateRef = React.useRef({
6197
- pointerIn: false,
6198
- focusIn: false,
6199
- focusInFloating: false,
6200
- held: false,
6201
- opening: false,
6202
- asked: false,
6203
- 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,
6204
6241
  });
6205
- // In the hooks that want Escape from the moment an open is on its way until
6206
- // the floating element closes, and out otherwise. Stamped with the order it
6207
- // opened in.
6208
- const listenForEscape = React.useCallback(function listen() {
6209
- const s = stateRef.current;
6210
- const entry = entryRef.current;
6211
- const { open, triggerRef } = latestRef.current;
6212
- if (!open)
6213
- entry.openedAt = 0;
6214
- else if (!entry.openedAt) {
6215
- openings += 1;
6216
- entry.openedAt = openings;
6217
- }
6218
- const wanted = open || s.opening || s.asked;
6219
- if (wanted === Boolean(s.releaseEscape))
6220
- return;
6221
- if (s.releaseEscape) {
6222
- s.releaseEscape();
6223
- s.releaseEscape = undefined;
6224
- return;
6225
- }
6226
- const doc = triggerRef.current?.ownerDocument ?? document;
6227
- 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;
6228
6247
  }, []);
6229
- const entryRef = React.useRef({
6230
- openedAt: 0,
6231
- trigger: () => latestRef.current.triggerRef.current,
6232
- floating: () => latestRef.current.floatingRef.current,
6233
- // Only ever called while an open is on its way, so it is one or the
6234
- // other: still waiting on its delay, or asked for already.
6235
- cancel: () => {
6236
- const s = stateRef.current;
6237
- clearTimeout(s.timer);
6238
- // An ask already made is taken back by asking the other way.
6239
- if (s.asked)
6240
- latestRef.current.onOpenChange(false);
6241
- s.opening = false;
6242
- s.asked = false;
6243
- listenForEscape();
6244
- },
6245
- close: () => {
6246
- clearTimeout(stateRef.current.timer);
6247
- latestRef.current.onOpenChange(false);
6248
+ const restoreFocusRef = React.useMemo(() => ({
6249
+ get current() {
6250
+ return triggerNode();
6248
6251
  },
6252
+ }), [triggerNode]);
6253
+ useFocusTrap(panelRef, {
6254
+ active: panelMounted && trapFocus,
6255
+ restoreFocus: restoreFocusRef,
6249
6256
  });
6250
- // Asks to open or to close. Escape can take an ask to open back until it is
6251
- // answered: by the commit that follows it, or, when the caller keeps it
6252
- // closed without rendering at all, once the event that asked is over, by
6253
- // which time React has rendered any answer the caller set.
6254
- const ask = React.useCallback((next) => {
6255
- const s = stateRef.current;
6256
- s.asked = next;
6257
- latestRef.current.onOpenChange(next);
6258
- listenForEscape();
6259
- if (!next)
6260
- return;
6261
- clearTimeout(s.askTimer);
6262
- s.askTimer = setTimeout(() => {
6263
- // Still closed: the caller turned it down.
6264
- if (latestRef.current.open)
6265
- return;
6266
- s.asked = false;
6267
- s.tappedIn = undefined;
6268
- listenForEscape();
6269
- });
6270
- }, [listenForEscape]);
6271
- // Asks for what the pointer and focus call for, after its delay, when that
6272
- // changed since the last time and isn't the state already.
6273
- const settle = React.useCallback(() => {
6274
- const s = stateRef.current;
6275
- const held = s.pointerIn || s.focusIn;
6276
- if (held !== s.held) {
6277
- s.held = held;
6278
- clearTimeout(s.timer);
6279
- const { open, openDelay, closeDelay } = latestRef.current;
6280
- s.opening = held && !open;
6281
- if (held !== open) {
6282
- s.timer = setTimeout(() => {
6283
- s.opening = false;
6284
- if (latestRef.current.open !== held)
6285
- ask(held);
6286
- else
6287
- listenForEscape();
6288
- }, 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();
6289
6269
  }
6290
- }
6291
- listenForEscape();
6292
- }, [listenForEscape, ask]);
6293
- // While a tap holds the floating element open, a press outside the trigger
6294
- // and the floating element closes it. Set up again each time it opens or
6295
- // closes.
6296
- const listenForPress = React.useCallback(() => {
6297
- const s = stateRef.current;
6298
- s.stopPress?.();
6299
- s.stopPress = undefined;
6300
- const doc = latestRef.current.open ? s.tappedIn : undefined;
6301
- if (!doc)
6302
- return;
6303
- const handlePointerDown = (event) => {
6304
- // Inside is a press React brought to the holder, which covers content
6305
- // the floating element portals elsewhere, or one whose path runs
6306
- // through the trigger or the floating element, which covers a press
6307
- // that something inside stopped before React got it to the holder.
6308
- const { triggerRef, floatingRef } = latestRef.current;
6309
- if (event === s.pressedInside ||
6310
- isEventInside(event, triggerRef.current) ||
6311
- 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)) {
6312
6277
  return;
6313
6278
  }
6314
- latestRef.current.onOpenChange(false);
6279
+ close();
6315
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;
6316
6284
  doc.addEventListener('pointerdown', handlePointerDown);
6317
- s.stopPress = () => doc.removeEventListener('pointerdown', handlePointerDown);
6318
- }, []);
6319
- useClientLayoutEffect(() => {
6320
- latestRef.current = options;
6321
- // The commit after an ask to open answers it, open or still closed. A
6322
- // tap that asked and still finds it closed was turned down.
6323
- const s = stateRef.current;
6324
- if (s.asked) {
6325
- s.asked = false;
6326
- if (!options.open)
6327
- s.tappedIn = undefined;
6328
- listenForEscape();
6329
- }
6330
- });
6331
- useClientLayoutEffect(() => {
6332
- const s = stateRef.current;
6333
- return () => {
6334
- clearTimeout(s.timer);
6335
- clearTimeout(s.askTimer);
6336
- s.opening = false;
6337
- s.asked = false;
6338
- s.releaseEscape?.();
6339
- s.releaseEscape = undefined;
6340
- s.stopPress?.();
6341
- s.stopPress = undefined;
6342
- };
6343
- }, []);
6344
- const { open } = options;
6345
- // Before paint, so a pointer or a key arriving next finds this settled.
6346
- useClientLayoutEffect(() => {
6347
- const s = stateRef.current;
6348
- if (!open) {
6349
- // The floating element left from under the pointer, if it was there,
6350
- // and no leave event says so. Focus resting on the trigger has done
6351
- // its job too, so neither opens it again until it comes back.
6352
- s.pointerIn = false;
6353
- s.focusIn = false;
6354
- s.tappedIn = undefined;
6355
- if (s.focusInFloating) {
6356
- s.focusInFloating = false;
6357
- const trigger = latestRef.current.triggerRef.current;
6358
- // Focus that went down with the floating element sits on <body>.
6359
- // Focus somewhere else was put there by the same update, so it stays.
6360
- if (trigger &&
6361
- trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
6362
- s.restoring = true;
6363
- trigger.focus();
6364
- s.restoring = false;
6365
- }
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();
6366
6313
  }
6314
+ return;
6367
6315
  }
6368
- settle();
6369
- listenForPress();
6370
- }, [open, settle, listenForPress]);
6371
- return React.useMemo(() => {
6372
- const inTrigger = (node) => within(latestRef.current.triggerRef.current, node);
6373
- return {
6374
- onPointerEnter: event => {
6375
- if (event.pointerType === 'touch')
6376
- return;
6377
- stateRef.current.pointerIn = true;
6378
- settle();
6379
- },
6380
- onPointerLeave: event => {
6381
- // A touch pointer never set pointerIn, so its leave has nothing to
6382
- // clear, and on a device with a mouse too it would close a card the
6383
- // mouse is still holding open.
6384
- if (event.pointerType === 'touch')
6385
- return;
6386
- stateRef.current.pointerIn = false;
6387
- settle();
6388
- },
6389
- onFocus: event => {
6390
- const s = stateRef.current;
6391
- // React brings focus here from the trigger and from the floating
6392
- // element's whole tree, portals included, so focus not in the
6393
- // trigger is in the floating element, wherever its DOM is.
6394
- s.focusInFloating = !inTrigger(event.target);
6395
- if (s.restoring)
6396
- return;
6397
- // Any focus in the floating element holds it. On the trigger, only
6398
- // keyboard focus does, so a click there opens nothing.
6399
- s.focusIn = s.focusInFloating || isKeyboardFocus(event.target);
6400
- settle();
6401
- },
6402
- onBlur: event => {
6403
- const s = stateRef.current;
6404
- const next = event.relatedTarget;
6405
- // Where focus goes next, as far as the DOM can say. Focus going into
6406
- // content the floating element portals elsewhere reads as leaving,
6407
- // and the focus event that follows straight after brings it back.
6408
- s.focusInFloating = within(latestRef.current.floatingRef.current, next);
6409
- // Moving between the trigger and the floating element isn't leaving.
6410
- if (s.focusInFloating || within(event.currentTarget, next))
6411
- return;
6412
- s.focusIn = false;
6413
- settle();
6414
- },
6415
- onPointerDown: event => {
6416
- stateRef.current.pressedInside = event.nativeEvent;
6417
- },
6418
- onPointerUp: event => {
6419
- const now = latestRef.current;
6420
- // With openOnTap, a tap on the trigger opens it at once, and the
6421
- // next one closes it. A tap anywhere else does nothing here.
6422
- if (!now.openOnTap ||
6423
- event.pointerType !== 'touch' ||
6424
- !inTrigger(event.target)) {
6425
- return;
6426
- }
6427
- stateRef.current.tappedIn = now.open
6428
- ? undefined
6429
- : event.target.ownerDocument;
6430
- ask(!now.open);
6431
- },
6432
- };
6433
- }, [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 }) }));
6434
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');
6435
6443
 
6436
- /** Runs the caller's handler on the wrapper, then the hover card's. */
6437
- function chain(own, ours) {
6438
- return event => {
6439
- own?.(event);
6440
- ours(event);
6441
- };
6442
- }
6443
6444
  /**
6444
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.
6445
6446
  *
@@ -6464,7 +6465,7 @@ function chain(own, ours) {
6464
6465
  */
6465
6466
  const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, openDelay = 600, closeDelay = 300, position = 'bottom-left', appendToBody = false, openOnTap = false, className, contentClassName, ...props }) => {
6466
6467
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6467
- const { onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6468
+ const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6468
6469
  const rootRef = React.useRef(null);
6469
6470
  const cardRef = React.useRef(null);
6470
6471
  const cardId = `${React.useId()}-card`;
@@ -6537,7 +6538,7 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6537
6538
  [`is-${resolved.position}`]: true,
6538
6539
  'is-portal': appendToBody,
6539
6540
  }), contentClassName);
6540
- 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
6541
6542
  ? {
6542
6543
  position: 'fixed',
6543
6544
  top: resolved.top,
@@ -6546,6 +6547,570 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6546
6547
  : undefined, children: children }) }))] }));
6547
6548
  };
6548
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
+
6549
7114
  /**
6550
7115
  * Individual Step component for use inside Steps.
6551
7116
  *
@@ -8040,6 +8605,58 @@ const Buttons = withSubComponents(ButtonsComponent, {
8040
8605
  LinkButton,
8041
8606
  }, 'Buttons');
8042
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
+
8043
8660
  /**
8044
8661
  * The `Code` component renders a styled `<code>` element with Bulma helper class integration.
8045
8662
  *
@@ -12532,7 +13149,7 @@ const DEFAULT_PICKER_LABELS = {
12532
13149
  hours: 'hours',
12533
13150
  minutes: 'minutes',
12534
13151
  seconds: 'seconds',
12535
- ampm: 'ampm',
13152
+ ampm: 'AM/PM',
12536
13153
  increaseHours: 'Increase hours',
12537
13154
  decreaseHours: 'Decrease hours',
12538
13155
  increaseMinutes: 'Increase minutes',
@@ -12551,11 +13168,19 @@ const DEFAULT_PICKER_LABELS = {
12551
13168
  done: 'Done',
12552
13169
  time: 'Time',
12553
13170
  };
12554
- /** Merge user-supplied label overrides with the defaults. */
12555
- const mergeLabels = (overrides) => ({
12556
- ...DEFAULT_PICKER_LABELS,
12557
- ...(overrides ?? {}),
12558
- });
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
+ };
12559
13184
 
12560
13185
  function isSameDay(a, b) {
12561
13186
  return (a.getFullYear() === b.getFullYear() &&
@@ -18477,6 +19102,7 @@ exports.Columns = Columns;
18477
19102
  exports.ConfigProvider = ConfigProvider;
18478
19103
  exports.Container = Container;
18479
19104
  exports.Content = Content;
19105
+ exports.ContextMenu = ContextMenu;
18480
19106
  exports.Control = Control;
18481
19107
  exports.DateInput = DateInput;
18482
19108
  exports.DateInputBase = DateInputBase;
@@ -18607,6 +19233,7 @@ exports.TimeInputBase = TimeInputBase;
18607
19233
  exports.Title = Title;
18608
19234
  exports.Toast = Toast;
18609
19235
  exports.ToastContainer = ToastContainer;
19236
+ exports.ToggleButton = ToggleButton;
18610
19237
  exports.Tooltip = Tooltip;
18611
19238
  exports.Tr = Tr;
18612
19239
  exports.UnorderedList = UnorderedList;