@allxsmith/bestax-bulma 5.29.0 → 5.31.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 (67) 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/Dropdown.js +37 -85
  6. package/dist/esm/components/Dropdown.js.map +1 -1
  7. package/dist/esm/components/HoverCard.js +3 -10
  8. package/dist/esm/components/HoverCard.js.map +1 -1
  9. package/dist/esm/components/Tooltip.js +88 -63
  10. package/dist/esm/components/Tooltip.js.map +1 -1
  11. package/dist/esm/elements/Button.js.map +1 -1
  12. package/dist/esm/elements/Buttons.js +2 -0
  13. package/dist/esm/elements/Buttons.js.map +1 -1
  14. package/dist/esm/elements/ToggleButton.js +68 -0
  15. package/dist/esm/elements/ToggleButton.js.map +1 -0
  16. package/dist/esm/form/DateTimeInputBase.js.map +1 -1
  17. package/dist/esm/form/TimeInputBase.js.map +1 -1
  18. package/dist/esm/form/_pickerInternals/pickerTypes.js +14 -6
  19. package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -1
  20. package/dist/esm/helpers/useContextMenuTrigger.js +166 -0
  21. package/dist/esm/helpers/useContextMenuTrigger.js.map +1 -0
  22. package/dist/esm/helpers/useHoverOpen.js +40 -6
  23. package/dist/esm/helpers/useHoverOpen.js.map +1 -1
  24. package/dist/esm/helpers/useMenuKeys.js +182 -0
  25. package/dist/esm/helpers/useMenuKeys.js.map +1 -0
  26. package/dist/extras.css +1 -1
  27. package/dist/extras.css.map +1 -1
  28. package/dist/index.cjs +1896 -1261
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.esm.js +2 -0
  31. package/dist/index.esm.js.map +1 -1
  32. package/dist/types/components/ContextMenu.d.ts +66 -0
  33. package/dist/types/components/Tooltip.d.ts +63 -9
  34. package/dist/types/elements/Button.d.ts +1 -1
  35. package/dist/types/elements/Buttons.d.ts +1 -0
  36. package/dist/types/elements/ToggleButton.d.ts +100 -0
  37. package/dist/types/form/DateTimeInputBase.d.ts +4 -1
  38. package/dist/types/form/TimeInputBase.d.ts +5 -2
  39. package/dist/types/form/_pickerInternals/pickerTypes.d.ts +16 -1
  40. package/dist/types/helpers/useContextMenuTrigger.d.ts +67 -0
  41. package/dist/types/helpers/useHoverOpen.d.ts +19 -3
  42. package/dist/types/helpers/useMenuKeys.d.ts +57 -0
  43. package/dist/types/index.d.ts +2 -0
  44. package/dist/types-cjs/components/ContextMenu.d.ts +66 -0
  45. package/dist/types-cjs/components/Tooltip.d.ts +63 -9
  46. package/dist/types-cjs/elements/Button.d.ts +1 -1
  47. package/dist/types-cjs/elements/Buttons.d.ts +1 -0
  48. package/dist/types-cjs/elements/ToggleButton.d.ts +100 -0
  49. package/dist/types-cjs/form/DateTimeInputBase.d.ts +4 -1
  50. package/dist/types-cjs/form/TimeInputBase.d.ts +5 -2
  51. package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +16 -1
  52. package/dist/types-cjs/helpers/useContextMenuTrigger.d.ts +67 -0
  53. package/dist/types-cjs/helpers/useHoverOpen.d.ts +19 -3
  54. package/dist/types-cjs/helpers/useMenuKeys.d.ts +57 -0
  55. package/dist/types-cjs/index.d.ts +2 -0
  56. package/dist/versions/bestax-no-dark-mode.css +1 -1
  57. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  58. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  59. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  60. package/dist/versions/bestax-no-helpers.css +1 -1
  61. package/dist/versions/bestax-no-helpers.css.map +1 -1
  62. package/dist/versions/bestax-prefixed.css +1 -1
  63. package/dist/versions/bestax-prefixed.css.map +1 -1
  64. package/package.json +1 -1
  65. package/src/scss/components/_contextmenu.scss +50 -0
  66. package/src/scss/components/_index.scss +1 -0
  67. package/src/scss/components/_tooltip.scss +76 -1
package/dist/index.cjs CHANGED
@@ -2352,6 +2352,182 @@ function getDeepestActiveElement(doc = document) {
2352
2352
  return active;
2353
2353
  }
2354
2354
 
2355
+ // INTERNAL: deliberately not exported from src/index.ts.
2356
+ /**
2357
+ * Every menu-item role, so an item a caller gives `menuitemcheckbox` or
2358
+ * `menuitemradio` (a filter or sort menu) stays reachable.
2359
+ */
2360
+ const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
2361
+ /**
2362
+ * Whether a menu item can be reached and activated: it isn't disabled, as a
2363
+ * native control or through `aria-disabled="true"`.
2364
+ */
2365
+ function isEnabledMenuItem(item) {
2366
+ return (!item.hasAttribute('disabled') &&
2367
+ item.getAttribute('aria-disabled') !== 'true');
2368
+ }
2369
+ /** Whether `node` is one of `menu`'s items, enabled or not. */
2370
+ const isItemOf = (menu, node) => Boolean(node && menu.contains(node) && node.matches(MENU_ITEM_SELECTOR));
2371
+ /** The items in `menu` the keys reach, in document order. */
2372
+ function enabledMenuItems(menu) {
2373
+ return Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter(isEnabledMenuItem);
2374
+ }
2375
+ /** How long typed characters keep adding up to one search, in milliseconds. */
2376
+ const TYPEAHEAD_TIMEOUT = 500;
2377
+ /**
2378
+ * Whether the browser activates `item` on this key by itself: a `<button>`
2379
+ * answers Enter and Space, and a link with an `href` answers Enter.
2380
+ */
2381
+ function activatesItself(item, key) {
2382
+ if (item.tagName === 'BUTTON')
2383
+ return true;
2384
+ return key === 'Enter' && item.tagName === 'A' && item.hasAttribute('href');
2385
+ }
2386
+ /**
2387
+ * The text typing finds an item by: what it says, without what's hidden from
2388
+ * assistive technology, such as an icon whose glyph is a ligature's name.
2389
+ */
2390
+ function label(item) {
2391
+ let text = '';
2392
+ const read = (node) => {
2393
+ node.childNodes.forEach(child => {
2394
+ if (child.nodeType === 3)
2395
+ text += child.textContent;
2396
+ else if (child.nodeType === 1 &&
2397
+ child.getAttribute('aria-hidden') !== 'true') {
2398
+ read(child);
2399
+ }
2400
+ });
2401
+ };
2402
+ read(item);
2403
+ return text.trim().toLowerCase();
2404
+ }
2405
+ /**
2406
+ * The item typing `search` moves to, or none. A single character looks from
2407
+ * the item after the focused one, so typing the same letter again cycles
2408
+ * through the items that start with it, and so does a run of one letter
2409
+ * ("dd"). A longer search looks from the focused item, so typing more of
2410
+ * its own text keeps it.
2411
+ */
2412
+ function typeaheadMatch(items, search, current) {
2413
+ const repeated = Array.from(search).every(char => char === search[0]);
2414
+ const wanted = repeated ? search[0] : search;
2415
+ const start = current < 0 ? 0 : current + (wanted.length === 1 ? 1 : 0);
2416
+ for (let i = 0; i < items.length; i += 1) {
2417
+ const item = items[(start + i) % items.length];
2418
+ if (label(item).startsWith(wanted))
2419
+ return item;
2420
+ }
2421
+ return undefined;
2422
+ }
2423
+ /**
2424
+ * Moves through and activates the items of a `role="menu"` element from the
2425
+ * keyboard. Put the `onKeyDown` it returns on that element. It reads the
2426
+ * items from the element each time, so items that come and go are found,
2427
+ * and it renders nothing.
2428
+ *
2429
+ * It acts only while focus is on the menu element itself or on one of its
2430
+ * items, so anything else focusable inside the menu, such as a filter
2431
+ * field, keeps its own keys.
2432
+ *
2433
+ * - Down and Up move to the next and previous enabled item, wrapping, and
2434
+ * Home and End go to the first and last. From no item, Down goes to the
2435
+ * first and Up to the last.
2436
+ * - Enter and Space activate the focused item. A `<button>`, and a link with
2437
+ * an `href` for Enter, get the browser's own click, and anything else gets
2438
+ * one from here, so its `onClick` runs either way. A key held down
2439
+ * activates it once. A key handler on the item that called
2440
+ * `preventDefault()` has claimed the key, and nothing else runs it.
2441
+ * - A printable character moves to the next enabled item whose text starts
2442
+ * with what has been typed. Characters typed within `TYPEAHEAD_TIMEOUT` of
2443
+ * each other add up to one search, until `reset` starts a new one.
2444
+ *
2445
+ * Items are `MENU_ITEM_SELECTOR` matches, and disabled ones are skipped by
2446
+ * all of the above. Escape and Tab are left to the menu's owner, since what
2447
+ * closing means is up to it, and so is any other key.
2448
+ *
2449
+ * @function useMenuKeys
2450
+ * @returns The `keydown` handler for the menu element, and a way to forget
2451
+ * what has been typed.
2452
+ */
2453
+ function useMenuKeys() {
2454
+ const typedRef = React.useRef({ text: '' });
2455
+ React.useEffect(() => {
2456
+ const typed = typedRef.current;
2457
+ return () => clearTimeout(typed.timer);
2458
+ }, []);
2459
+ return React.useMemo(() => {
2460
+ const reset = () => {
2461
+ const typed = typedRef.current;
2462
+ clearTimeout(typed.timer);
2463
+ typed.text = '';
2464
+ };
2465
+ const onKeyDown = (event) => {
2466
+ const menu = event.currentTarget;
2467
+ const focused = getActiveElementInTree(menu);
2468
+ if (focused !== menu && !isItemOf(menu, focused))
2469
+ return;
2470
+ const items = enabledMenuItems(menu);
2471
+ if (!items.length)
2472
+ return;
2473
+ const current = items.indexOf(focused);
2474
+ switch (event.key) {
2475
+ case 'ArrowDown':
2476
+ event.preventDefault();
2477
+ items[current >= 0 ? (current + 1) % items.length : 0].focus();
2478
+ return;
2479
+ case 'ArrowUp':
2480
+ event.preventDefault();
2481
+ items[current >= 0
2482
+ ? (current - 1 + items.length) % items.length
2483
+ : items.length - 1].focus();
2484
+ return;
2485
+ case 'Home':
2486
+ event.preventDefault();
2487
+ items[0].focus();
2488
+ return;
2489
+ case 'End':
2490
+ event.preventDefault();
2491
+ items[items.length - 1].focus();
2492
+ return;
2493
+ case 'Enter':
2494
+ case ' ': {
2495
+ if (current < 0 || event.defaultPrevented)
2496
+ return;
2497
+ const item = items[current];
2498
+ if (activatesItself(item, event.key)) {
2499
+ // The browser clicks on Enter's keydown, and a held key sends one
2500
+ // per auto-repeat. Space clicks on keyup, once however long it's
2501
+ // held.
2502
+ if (event.repeat && event.key === 'Enter')
2503
+ event.preventDefault();
2504
+ return;
2505
+ }
2506
+ // Space would scroll the page, and a held key repeats.
2507
+ event.preventDefault();
2508
+ if (!event.repeat)
2509
+ item.click();
2510
+ return;
2511
+ }
2512
+ }
2513
+ if (event.key.length !== 1 ||
2514
+ event.ctrlKey ||
2515
+ event.metaKey ||
2516
+ event.altKey ||
2517
+ event.defaultPrevented) {
2518
+ return;
2519
+ }
2520
+ event.preventDefault();
2521
+ const typed = typedRef.current;
2522
+ typed.text += event.key.toLowerCase();
2523
+ clearTimeout(typed.timer);
2524
+ typed.timer = setTimeout(reset, TYPEAHEAD_TIMEOUT);
2525
+ typeaheadMatch(items, typed.text, current)?.focus();
2526
+ };
2527
+ return { onKeyDown, reset };
2528
+ }, []);
2529
+ }
2530
+
2355
2531
  /**
2356
2532
  * Checks if code is running in a browser environment.
2357
2533
  * @deprecated Dropdown's internal SSR guard, exported by accident; it will
@@ -2365,6 +2541,19 @@ const isBrowser$1 = (win, doc) => typeof win !== 'undefined' && typeof doc !== '
2365
2541
  /**
2366
2542
  * The `Dropdown` component provides Bulma's versatile dropdown menu for your Bulma React UI.
2367
2543
  *
2544
+ * Down, Enter and Space on the trigger open the menu on its first enabled
2545
+ * item, and Up opens it on its last. On the trigger of a menu that's already
2546
+ * open, Down and Up move focus to its first and last item. In the menu, the
2547
+ * arrow keys, Home and End move between the items, and Enter and Space run
2548
+ * the focused one the way a click does. Typing a letter moves to the next
2549
+ * item whose text starts with it, skipping disabled ones, and letters typed
2550
+ * in quick succession add up to one search. Typing searches once focus is in
2551
+ * the menu, so a letter typed on the trigger does nothing. Escape closes the
2552
+ * menu and focuses the trigger, and Tab closes it and lets focus move on.
2553
+ * Anything focusable in the menu that isn't an item, such as a field, keeps
2554
+ * its own keys apart from Escape and Tab. For a panel built around fields,
2555
+ * use `Popover`.
2556
+ *
2368
2557
  * @function
2369
2558
  * @param {DropdownProps} props - Props for the Dropdown component.
2370
2559
  * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the root dropdown element.
@@ -2375,6 +2564,8 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2375
2564
  const [active, setActive] = React.useState(!!activeProp);
2376
2565
  const dropdownRef = React.useRef(null);
2377
2566
  const triggerRef = React.useRef(null);
2567
+ const menuRef = React.useRef(null);
2568
+ const menuKeys = useMenuKeys();
2378
2569
  const pendingFocusRef = React.useRef(null);
2379
2570
  // Cleanup returned by a consumer's callback ref on attach, held until detach.
2380
2571
  const consumerCleanupRef = React.useRef(null);
@@ -2462,29 +2653,23 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2462
2653
  }
2463
2654
  }
2464
2655
  };
2465
- const getMenuItems = () => {
2466
- /* istanbul ignore next: dropdownRef.current is never null once mounted */
2467
- if (!dropdownRef.current)
2468
- return [];
2469
- return Array.from(
2470
- // Every menu-item role, so an item a caller gives `menuitemcheckbox`
2471
- // or `menuitemradio` (a filter or sort menu) stays reachable.
2472
- dropdownRef.current.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]')).filter(el => !el.hasAttribute('disabled') &&
2473
- el.getAttribute('aria-disabled') !== 'true');
2656
+ const focusEdgeItem = (edge) => {
2657
+ const items = enabledMenuItems(menuRef.current);
2658
+ items[edge === 'first' ? 0 : items.length - 1]?.focus();
2474
2659
  };
2475
- // Focus the pending menu item once the menu becomes visible; the item is
2476
- // unfocusable while `display: none` applies, so this must wait for the
2477
- // `is-active` class to actually land in the DOM.
2660
+ // Once the menu shows, forget a search typed just before it last closed,
2661
+ // and focus the pending item. The item is unfocusable while
2662
+ // `display: none` applies, so this must wait for the `is-active` class to
2663
+ // actually land in the DOM.
2478
2664
  React.useEffect(() => {
2479
- if (!active || !pendingFocusRef.current)
2665
+ if (!active)
2480
2666
  return;
2481
- const items = getMenuItems();
2482
- if (items.length) {
2483
- const index = pendingFocusRef.current === 'first' ? 0 : items.length - 1;
2484
- items[index].focus();
2485
- }
2667
+ menuKeys.reset();
2668
+ if (!pendingFocusRef.current)
2669
+ return;
2670
+ focusEdgeItem(pendingFocusRef.current);
2486
2671
  pendingFocusRef.current = null;
2487
- }, [active]);
2672
+ }, [active, menuKeys]);
2488
2673
  const handleTriggerKeyDown = (e) => {
2489
2674
  if (disabled)
2490
2675
  return;
@@ -2497,9 +2682,7 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2497
2682
  onActiveChange?.(true);
2498
2683
  }
2499
2684
  else {
2500
- const items = getMenuItems();
2501
- if (items.length)
2502
- items[0].focus();
2685
+ focusEdgeItem('first');
2503
2686
  pendingFocusRef.current = null;
2504
2687
  }
2505
2688
  break;
@@ -2511,9 +2694,7 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2511
2694
  onActiveChange?.(true);
2512
2695
  }
2513
2696
  else {
2514
- const items = getMenuItems();
2515
- if (items.length)
2516
- items[items.length - 1].focus();
2697
+ focusEdgeItem('last');
2517
2698
  pendingFocusRef.current = null;
2518
2699
  }
2519
2700
  break;
@@ -2545,8 +2726,10 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2545
2726
  }
2546
2727
  };
2547
2728
  const handleMenuKeyDown = (e) => {
2548
- // Escape and Tab close the menu regardless of whether it has any focusable
2549
- // items — an empty or all-disabled menu must still honor the close contract.
2729
+ // Closing is Dropdown's: Escape and Tab close the menu from anywhere in
2730
+ // it, a field included, and an empty or all-disabled menu too. Moving
2731
+ // between the items, running them and typeahead are the hook's, which
2732
+ // acts only while focus is on the menu or one of its items.
2550
2733
  if (e.key === 'Escape') {
2551
2734
  e.preventDefault();
2552
2735
  setActive(false);
@@ -2559,66 +2742,10 @@ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, c
2559
2742
  onActiveChange?.(false);
2560
2743
  return;
2561
2744
  }
2562
- const items = getMenuItems();
2563
- if (!items.length)
2564
- return;
2565
- const currentIndex = items.indexOf(getActiveElementInTree(e.currentTarget));
2566
- switch (e.key) {
2567
- case 'ArrowDown': {
2568
- e.preventDefault();
2569
- const next = currentIndex >= 0 ? (currentIndex + 1) % items.length : 0;
2570
- items[next].focus();
2571
- break;
2572
- }
2573
- case 'ArrowUp': {
2574
- e.preventDefault();
2575
- const prev = currentIndex >= 0
2576
- ? (currentIndex - 1 + items.length) % items.length
2577
- : items.length - 1;
2578
- items[prev].focus();
2579
- break;
2580
- }
2581
- case 'Home':
2582
- e.preventDefault();
2583
- items[0].focus();
2584
- break;
2585
- case 'End':
2586
- e.preventDefault();
2587
- items[items.length - 1].focus();
2588
- break;
2589
- case 'Enter':
2590
- case ' ': {
2591
- // Activate the focused item by clicking it, so its `onClick` runs and
2592
- // closing follows `closeOnClick` as it does for the mouse. A caller's
2593
- // own key handler that prevented the default has claimed the key, and
2594
- // is left to it rather than followed by a second activation.
2595
- if (currentIndex < 0 || e.defaultPrevented)
2596
- break;
2597
- const item = items[currentIndex];
2598
- // Leave the browser's own activation alone, or the item runs twice: a
2599
- // `<button>` answers both keys, and a link with an `href` answers
2600
- // Enter. A link does not answer Space, so Space on a link is handled
2601
- // here too, which also keeps the page from scrolling.
2602
- if (item.tagName === 'BUTTON')
2603
- break;
2604
- if (e.key === 'Enter' &&
2605
- item.tagName === 'A' &&
2606
- item.hasAttribute('href')) {
2607
- break;
2608
- }
2609
- e.preventDefault();
2610
- // A held key sends a keydown per auto-repeat. Clicking on each would
2611
- // toggle a checkbox item over and over while `closeOnClick` is off,
2612
- // so only the first press activates. The default is still prevented
2613
- // on the repeats above, so a held Space does not scroll the page.
2614
- if (!e.repeat)
2615
- item.click();
2616
- break;
2617
- }
2618
- }
2745
+ menuKeys.onKeyDown(e);
2619
2746
  };
2620
2747
  const dropdownClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
2621
- return (jsxRuntime.jsxs("div", { className: dropdownClasses, ref: setRefs, id: id, "data-testid": "dropdown-root", ...rest, children: [jsxRuntime.jsx("div", { className: usePrefixedClassNames('dropdown-trigger'), children: jsxRuntime.jsxs("button", { ref: triggerRef, className: buttonClass, "aria-haspopup": "true", "aria-controls": id ? `${id}-menu` : undefined, "aria-expanded": active, onClick: handleToggle, onKeyDown: handleTriggerKeyDown, disabled: disabled, type: "button", children: [jsxRuntime.jsx("span", { children: label }), jsxRuntime.jsx("span", { className: usePrefixedClassNames('icon', 'is-small'), "aria-hidden": "true", children: jsxRuntime.jsx("i", { className: "fas fa-angle-down" }) })] }) }), jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('dropdown-menu'), menuClassName), id: id ? `${id}-menu` : undefined, role: "menu", "data-testid": "dropdown-menu", onKeyDown: handleMenuKeyDown,
2748
+ return (jsxRuntime.jsxs("div", { className: dropdownClasses, ref: setRefs, id: id, "data-testid": "dropdown-root", ...rest, children: [jsxRuntime.jsx("div", { className: usePrefixedClassNames('dropdown-trigger'), children: jsxRuntime.jsxs("button", { ref: triggerRef, className: buttonClass, "aria-haspopup": "true", "aria-controls": id ? `${id}-menu` : undefined, "aria-expanded": active, onClick: handleToggle, onKeyDown: handleTriggerKeyDown, disabled: disabled, type: "button", children: [jsxRuntime.jsx("span", { children: label }), jsxRuntime.jsx("span", { className: usePrefixedClassNames('icon', 'is-small'), "aria-hidden": "true", children: jsxRuntime.jsx("i", { className: "fas fa-angle-down" }) })] }) }), jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('dropdown-menu'), menuClassName), id: id ? `${id}-menu` : undefined, ref: menuRef, role: "menu", "data-testid": "dropdown-menu", onKeyDown: handleMenuKeyDown,
2622
2749
  // The menu, not its content, takes focus from a click between items,
2623
2750
  // so the menu owns nothing focusable but its items.
2624
2751
  tabIndex: -1, children: jsxRuntime.jsx("div", { className: usePrefixedClassNames('dropdown-content'), onClick: handleMenuClick, children: children }) })] }));
@@ -4895,349 +5022,580 @@ const CollapsesComponent = ({ multiple = false, value, defaultValue, onChange, s
4895
5022
  };
4896
5023
  const Collapses = withSubComponents(CollapsesComponent, { Collapse }, 'Collapses');
4897
5024
 
5025
+ // INTERNAL: deliberately not exported from src/index.ts.
4898
5026
  /**
4899
- * The `Tooltip` component displays helpful information when users hover over or focus on an element.
5027
+ * `useLayoutEffect` in the browser, `useEffect` on the server. A server
5028
+ * render runs neither, and React 18 warns about a layout effect there, so the
5029
+ * server is handed the plain one.
5030
+ */
5031
+ const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5032
+
5033
+ // INTERNAL: deliberately not exported from src/index.ts.
5034
+ /**
5035
+ * Runs the caller's own handler on the element that holds the trigger, then
5036
+ * the hook's, so neither replaces the other.
5037
+ */
5038
+ function chain$1(own, ours) {
5039
+ return event => {
5040
+ own?.(event);
5041
+ ours(event);
5042
+ };
5043
+ }
5044
+ /**
5045
+ * Whether focus that just arrived on `el` is keyboard focus, as the browser
5046
+ * decides it for `:focus-visible`: focus from a key or from the browser's
5047
+ * own controls is, focus a press brings isn't, and focus a script moves
5048
+ * follows whichever came last. A browser that can't match the selector
5049
+ * throws, and then every focus counts, so keyboard users can still open it.
5050
+ */
5051
+ function isKeyboardFocus(el) {
5052
+ try {
5053
+ return el.matches(':focus-visible');
5054
+ }
5055
+ catch {
5056
+ return true;
5057
+ }
5058
+ }
5059
+ /** Whether `node` is `container` or inside it, when there are both. */
5060
+ const within = (container, node) => Boolean(container && node && container.contains(node));
5061
+ /**
5062
+ * Per document, the hooks that want Escape and the one listener they share
5063
+ * while there are any, so a card inside another card doesn't add a second
5064
+ * listener that closes both on one keypress.
5065
+ */
5066
+ const escapeListeners = new Map();
5067
+ /** Counts openings across the page, so the most recent one is known. */
5068
+ let openings = 0;
5069
+ /**
5070
+ * Adds `entry` to the hooks that want Escape on `doc`, starting the shared
5071
+ * listener when it is the first.
4900
5072
  *
4901
- * @function
4902
- * @param {TooltipProps} props - Props for the Tooltip component.
4903
- * @returns {JSX.Element} The rendered tooltip wrapper.
5073
+ * Escape takes back every open that is on its way, so nothing appears right
5074
+ * after it, and closes one open floating element, chosen by these rules in
5075
+ * turn:
4904
5076
  *
4905
- * @example
4906
- * // Basic tooltip
4907
- * <Tooltip label="This is helpful info">
4908
- * <button>Hover me</button>
4909
- * </Tooltip>
5077
+ * 1. One that focus is in, on its trigger or inside it, when there is one.
5078
+ * 2. The innermost: one whose floating element holds no other's trigger.
5079
+ * That follows nesting whether either one is portaled, since an inner
5080
+ * trigger sits in the outer floating element either way.
5081
+ * 3. The one that opened last.
4910
5082
  *
4911
- * @example
4912
- * // Positioned tooltip with color
4913
- * <Tooltip label="Delete item" position="right" color="danger">
4914
- * <Icon icon="fas fa-trash" />
4915
- * </Tooltip>
5083
+ * The keypress belongs to whatever has focus. When that is one of the open
5084
+ * floating elements or their triggers, or nothing, the listener stops it in
5085
+ * the capture phase, before anything around (a Popover, a Modal) sees it,
5086
+ * unless the one it closes was set not to consume Escape.
5087
+ * When focus is anywhere else, such as a combobox or a dialog of its own,
5088
+ * the floating element still closes, since hover put it there without
5089
+ * focus, and the key goes on to that owner.
4916
5090
  *
4917
- * @example
4918
- * // Multiline tooltip
4919
- * <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
4920
- * <span>?</span>
4921
- * </Tooltip>
5091
+ * @returns What to call when the hook stops wanting Escape.
4922
5092
  */
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');
5093
+ function wantEscape(doc, entry) {
5094
+ let listener = escapeListeners.get(doc);
5095
+ if (!listener) {
5096
+ const entries = new Set();
5097
+ const handleKeyDown = (event) => {
5098
+ if (event.key !== 'Escape')
5099
+ return;
5100
+ const open = [];
5101
+ // A copy, since taking an open back can take its hook out of the set.
5102
+ for (const each of [...entries]) {
5103
+ if (each.openedAt)
5104
+ open.push(each);
5105
+ else
5106
+ each.cancel();
5007
5107
  }
5008
- else {
5009
- setResolvedPosition('left');
5108
+ const focused = getDeepestActiveElement(doc);
5109
+ const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
5110
+ const pool = holding.length > 0 ? holding : open;
5111
+ let top;
5112
+ for (const each of pool) {
5113
+ const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
5114
+ if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
5115
+ top = each;
5116
+ }
5010
5117
  }
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)
5118
+ if (!top)
5017
5119
  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
- }
5120
+ if (top.consumes() &&
5121
+ (holding.length > 0 || !focused || focused === doc.body)) {
5122
+ event.preventDefault();
5123
+ event.stopPropagation();
5060
5124
  }
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
- };
5091
-
5092
- /**
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.
5096
- */
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;
5106
- };
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
- }
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
- /**
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.
5126
- */
5127
- function isTabbableKind(el) {
5128
- const tabindex = el.getAttribute('tabindex');
5129
- if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
5130
- return el.tabIndex >= 0;
5131
- }
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);
5125
+ top.close();
5126
+ };
5127
+ doc.addEventListener('keydown', handleKeyDown, true);
5128
+ listener = {
5129
+ entries,
5130
+ stop: () => {
5131
+ doc.removeEventListener('keydown', handleKeyDown, true);
5132
+ escapeListeners.delete(doc);
5133
+ },
5134
+ };
5135
+ escapeListeners.set(doc, listener);
5152
5136
  }
5137
+ const shared = listener;
5138
+ shared.entries.add(entry);
5139
+ return () => {
5140
+ shared.entries.delete(entry);
5141
+ if (shared.entries.size === 0)
5142
+ shared.stop();
5143
+ };
5153
5144
  }
5154
5145
  /**
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.
5160
- */
5161
- const flatParent = (node) => node.assignedSlot ??
5162
- node.parentElement ??
5163
- node.parentNode?.host ??
5164
- null;
5165
- /**
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.
5146
+ * Decides when a floating element that opens on hover or keyboard focus asks
5147
+ * to open and to close. It renders nothing, and leaves roles, ARIA and
5148
+ * placement to the component that calls it.
5149
+ *
5150
+ * - The pointer entering, or keyboard focus arriving on the trigger, asks to
5151
+ * open after `openDelay`. A touch pointer doesn't count. Neither does focus
5152
+ * that a press brings to the trigger: it counts when the browser shows it
5153
+ * as `:focus-visible`. The pointer arriving from outside a shadow root the
5154
+ * trigger renders into counts too, though React sends no enter for it.
5155
+ * - Focus in the floating element holds it open however it got there,
5156
+ * including focus in content it portals elsewhere, such as a nested card.
5157
+ * Whether a click focuses what it lands on is up to the browser: Safari
5158
+ * and Firefox on macOS don't focus a button on click, so there a clicked
5159
+ * button doesn't hold it once the pointer leaves.
5160
+ * - Once the pointer and focus have both left, it asks to close after
5161
+ * `closeDelay`, and either coming back before then keeps it open. Nothing
5162
+ * else closes it on a timer.
5163
+ * - Escape closes it at once, wherever focus is, and stops there when focus
5164
+ * is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
5165
+ * keeps its own. With focus somewhere else, or with `consumeEscape` off,
5166
+ * the key goes on after closing it. Inside another one, the inner one
5167
+ * closes first. Escape before it
5168
+ * opens takes the opening back and goes on its way. One key listener per
5169
+ * document serves every hook on it, there only while one of them is open
5170
+ * or on its way to open.
5171
+ * - With `openOnTap`, a tap on the trigger opens it at once and the next tap
5172
+ * on the trigger closes it. While a tap holds it open, a press outside the
5173
+ * trigger and the floating element closes it, from a listener on the
5174
+ * document that is there only then. Taps never count as hover, and mouse
5175
+ * and pen work the same with it or without.
5176
+ * - Once it closes, for any reason, whatever was holding it open has to
5177
+ * leave and come back to open it again.
5178
+ * - When the floating element closes with focus inside it, focus goes back
5179
+ * to the trigger, unless the same update put it somewhere else. Handing it
5180
+ * back doesn't count as focus arriving.
5181
+ *
5182
+ * Nothing it scheduled runs after the component unmounts.
5183
+ *
5184
+ * @function useHoverOpen
5185
+ * @param options - The open state, how to ask for another, the delays, and the two elements.
5186
+ * @returns Handlers for the element that holds the trigger.
5171
5187
  */
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);
5188
+ function useHoverOpen(options) {
5189
+ const latestRef = React.useRef(options);
5190
+ const stateRef = React.useRef({
5191
+ pointerIn: false,
5192
+ focusIn: false,
5193
+ focusInFloating: false,
5194
+ held: false,
5195
+ opening: false,
5196
+ asked: false,
5197
+ restoring: false,
5198
+ });
5199
+ // In the hooks that want Escape from the moment an open is on its way until
5200
+ // the floating element closes, and out otherwise. Stamped with the order it
5201
+ // opened in.
5202
+ const listenForEscape = React.useCallback(function listen() {
5203
+ const s = stateRef.current;
5204
+ const entry = entryRef.current;
5205
+ const { open, triggerRef } = latestRef.current;
5206
+ if (!open)
5207
+ entry.openedAt = 0;
5208
+ else if (!entry.openedAt) {
5209
+ openings += 1;
5210
+ entry.openedAt = openings;
5179
5211
  }
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.
5193
- */
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;
5212
+ const wanted = open || s.opening || s.asked;
5213
+ if (wanted === Boolean(s.releaseEscape))
5214
+ return;
5215
+ if (s.releaseEscape) {
5216
+ s.releaseEscape();
5217
+ s.releaseEscape = undefined;
5218
+ return;
5219
+ }
5220
+ const doc = triggerRef.current?.ownerDocument ?? document;
5221
+ s.releaseEscape = wantEscape(doc, entry);
5222
+ }, []);
5223
+ const entryRef = React.useRef({
5224
+ openedAt: 0,
5225
+ trigger: () => latestRef.current.triggerRef.current,
5226
+ floating: () => latestRef.current.floatingRef.current,
5227
+ // Only ever called while an open is on its way, so it is one or the
5228
+ // other: still waiting on its delay, or asked for already.
5229
+ cancel: () => {
5230
+ const s = stateRef.current;
5231
+ clearTimeout(s.timer);
5232
+ // An ask already made is taken back by asking the other way.
5233
+ if (s.asked)
5234
+ latestRef.current.onOpenChange(false);
5235
+ s.opening = false;
5236
+ s.asked = false;
5237
+ listenForEscape();
5238
+ },
5239
+ close: () => {
5240
+ clearTimeout(stateRef.current.timer);
5241
+ latestRef.current.onOpenChange(false);
5242
+ },
5243
+ consumes: () => latestRef.current.consumeEscape !== false,
5244
+ });
5245
+ // Asks to open or to close. Escape can take an ask to open back until it is
5246
+ // answered: by the commit that follows it, or, when the caller keeps it
5247
+ // closed without rendering at all, once the event that asked is over, by
5248
+ // which time React has rendered any answer the caller set.
5249
+ const ask = React.useCallback((next) => {
5250
+ const s = stateRef.current;
5251
+ s.asked = next;
5252
+ latestRef.current.onOpenChange(next);
5253
+ listenForEscape();
5254
+ if (!next)
5255
+ return;
5256
+ clearTimeout(s.askTimer);
5257
+ s.askTimer = setTimeout(() => {
5258
+ // Still closed: the caller turned it down.
5259
+ if (latestRef.current.open)
5260
+ return;
5261
+ s.asked = false;
5262
+ s.tappedIn = undefined;
5263
+ listenForEscape();
5264
+ });
5265
+ }, [listenForEscape]);
5266
+ // Asks for what the pointer and focus call for, after its delay, when that
5267
+ // changed since the last time and isn't the state already.
5268
+ const settle = React.useCallback(() => {
5269
+ const s = stateRef.current;
5270
+ const held = s.pointerIn || s.focusIn;
5271
+ if (held !== s.held) {
5272
+ s.held = held;
5273
+ clearTimeout(s.timer);
5274
+ const { open, openDelay, closeDelay } = latestRef.current;
5275
+ s.opening = held && !open;
5276
+ if (held !== open) {
5277
+ s.timer = setTimeout(() => {
5278
+ s.opening = false;
5279
+ if (latestRef.current.open !== held)
5280
+ ask(held);
5281
+ else
5282
+ listenForEscape();
5283
+ }, held ? openDelay : closeDelay);
5284
+ }
5285
+ }
5286
+ listenForEscape();
5287
+ }, [listenForEscape, ask]);
5288
+ // While a tap holds the floating element open, a press outside the trigger
5289
+ // and the floating element closes it. Set up again each time it opens or
5290
+ // closes.
5291
+ const listenForPress = React.useCallback(() => {
5292
+ const s = stateRef.current;
5293
+ s.stopPress?.();
5294
+ s.stopPress = undefined;
5295
+ const doc = latestRef.current.open ? s.tappedIn : undefined;
5296
+ if (!doc)
5297
+ return;
5298
+ const handlePointerDown = (event) => {
5299
+ // Inside is a press React brought to the holder, which covers content
5300
+ // the floating element portals elsewhere, or one whose path runs
5301
+ // through the trigger or the floating element, which covers a press
5302
+ // that something inside stopped before React got it to the holder.
5303
+ const { triggerRef, floatingRef } = latestRef.current;
5304
+ if (event === s.pressedInside ||
5305
+ isEventInside(event, triggerRef.current) ||
5306
+ isEventInside(event, floatingRef.current)) {
5307
+ return;
5308
+ }
5309
+ latestRef.current.onOpenChange(false);
5310
+ };
5311
+ doc.addEventListener('pointerdown', handlePointerDown);
5312
+ s.stopPress = () => doc.removeEventListener('pointerdown', handlePointerDown);
5313
+ }, []);
5314
+ useClientLayoutEffect(() => {
5315
+ latestRef.current = options;
5316
+ // The commit after an ask to open answers it, open or still closed. A
5317
+ // tap that asked and still finds it closed was turned down.
5318
+ const s = stateRef.current;
5319
+ if (s.asked) {
5320
+ s.asked = false;
5321
+ if (!options.open)
5322
+ s.tappedIn = undefined;
5323
+ listenForEscape();
5204
5324
  }
5205
- below = node;
5206
- }
5207
- return true;
5208
- }
5209
- /**
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`.
5212
- */
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));
5221
5325
  });
5326
+ useClientLayoutEffect(() => {
5327
+ const s = stateRef.current;
5328
+ return () => {
5329
+ clearTimeout(s.timer);
5330
+ clearTimeout(s.askTimer);
5331
+ s.opening = false;
5332
+ s.asked = false;
5333
+ s.releaseEscape?.();
5334
+ s.releaseEscape = undefined;
5335
+ s.stopPress?.();
5336
+ s.stopPress = undefined;
5337
+ };
5338
+ }, []);
5339
+ const { open } = options;
5340
+ // Before paint, so a pointer or a key arriving next finds this settled.
5341
+ useClientLayoutEffect(() => {
5342
+ const s = stateRef.current;
5343
+ if (!open) {
5344
+ // The floating element left from under the pointer, if it was there,
5345
+ // and no leave event says so. Focus resting on the trigger has done
5346
+ // its job too, so neither opens it again until it comes back.
5347
+ s.pointerIn = false;
5348
+ s.focusIn = false;
5349
+ s.tappedIn = undefined;
5350
+ if (s.focusInFloating) {
5351
+ s.focusInFloating = false;
5352
+ const trigger = latestRef.current.triggerRef.current;
5353
+ // Focus that went down with the floating element sits on <body>.
5354
+ // Focus somewhere else was put there by the same update, so it stays.
5355
+ if (trigger &&
5356
+ trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
5357
+ s.restoring = true;
5358
+ trigger.focus();
5359
+ s.restoring = false;
5360
+ }
5361
+ }
5362
+ }
5363
+ settle();
5364
+ listenForPress();
5365
+ }, [open, settle, listenForPress]);
5366
+ return React.useMemo(() => {
5367
+ const inTrigger = (node) => within(latestRef.current.triggerRef.current, node);
5368
+ return {
5369
+ onPointerEnter: event => {
5370
+ if (event.pointerType === 'touch')
5371
+ return;
5372
+ stateRef.current.pointerIn = true;
5373
+ settle();
5374
+ },
5375
+ onPointerOver: event => {
5376
+ // React makes the enter out of the pointerout on the element the
5377
+ // pointer left, and skips this pointerover when that element is its
5378
+ // own. Leaving page content for a trigger inside a shadow root, that
5379
+ // pointerout names only the shadow host, so no enter reaches here.
5380
+ // A pointerover from outside the holder and the floating element is
5381
+ // the pointer arriving, then. One from inside them is the pointer
5382
+ // moving about within, which doesn't bring back a closed one.
5383
+ const s = stateRef.current;
5384
+ if (s.pointerIn || event.pointerType === 'touch')
5385
+ return;
5386
+ const from = event.relatedTarget;
5387
+ if (within(event.currentTarget, from) ||
5388
+ within(latestRef.current.floatingRef.current, from)) {
5389
+ return;
5390
+ }
5391
+ s.pointerIn = true;
5392
+ settle();
5393
+ },
5394
+ onPointerLeave: event => {
5395
+ // A touch pointer never set pointerIn, so its leave has nothing to
5396
+ // clear, and on a device with a mouse too it would close a card the
5397
+ // mouse is still holding open.
5398
+ if (event.pointerType === 'touch')
5399
+ return;
5400
+ stateRef.current.pointerIn = false;
5401
+ settle();
5402
+ },
5403
+ onFocus: event => {
5404
+ const s = stateRef.current;
5405
+ // React brings focus here from the trigger and from the floating
5406
+ // element's whole tree, portals included, so focus not in the
5407
+ // trigger is in the floating element, wherever its DOM is.
5408
+ s.focusInFloating = !inTrigger(event.target);
5409
+ if (s.restoring)
5410
+ return;
5411
+ // Any focus in the floating element holds it. On the trigger, only
5412
+ // keyboard focus does, so a click there opens nothing.
5413
+ s.focusIn = s.focusInFloating || isKeyboardFocus(event.target);
5414
+ settle();
5415
+ },
5416
+ onBlur: event => {
5417
+ const s = stateRef.current;
5418
+ const next = event.relatedTarget;
5419
+ // Where focus goes next, as far as the DOM can say. Focus going into
5420
+ // content the floating element portals elsewhere reads as leaving,
5421
+ // and the focus event that follows straight after brings it back.
5422
+ s.focusInFloating = within(latestRef.current.floatingRef.current, next);
5423
+ // Moving between the trigger and the floating element isn't leaving.
5424
+ if (s.focusInFloating || within(event.currentTarget, next))
5425
+ return;
5426
+ s.focusIn = false;
5427
+ settle();
5428
+ },
5429
+ onPointerDown: event => {
5430
+ stateRef.current.pressedInside = event.nativeEvent;
5431
+ },
5432
+ onPointerUp: event => {
5433
+ const now = latestRef.current;
5434
+ // With openOnTap, a tap on the trigger opens it at once, and the
5435
+ // next one closes it. A tap anywhere else does nothing here.
5436
+ if (!now.openOnTap ||
5437
+ event.pointerType !== 'touch' ||
5438
+ !inTrigger(event.target)) {
5439
+ return;
5440
+ }
5441
+ stateRef.current.tappedIn = now.open
5442
+ ? undefined
5443
+ : event.target.ownerDocument;
5444
+ ask(!now.open);
5445
+ },
5446
+ };
5447
+ }, [settle, ask]);
5222
5448
  }
5449
+
5223
5450
  /**
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.
5451
+ * Whether `el` is editable while its parent is not, which makes it an editing
5452
+ * host: the one tab stop an editable region gets. `contenteditable="false"`
5453
+ * turns editing off, and a missing or unknown value inherits the parent's.
5228
5454
  */
5229
- function isAreaShown(area, container) {
5230
- for (let node = area; node && node !== container; node = flatParent(node)) {
5231
- if (node.hasAttribute('inert'))
5232
- return false;
5455
+ function isEditingHost(el) {
5456
+ const editable = (node) => {
5457
+ const value = node.getAttribute('contenteditable')?.toLowerCase();
5458
+ if (value === undefined)
5459
+ return null;
5460
+ if (value === '' || value === 'true' || value === 'plaintext-only') {
5461
+ return true;
5462
+ }
5463
+ return value === 'false' ? false : null;
5464
+ };
5465
+ if (editable(el) !== true)
5466
+ return false;
5467
+ for (let node = el.parentElement; node; node = node.parentElement) {
5468
+ const state = editable(node);
5469
+ if (state !== null)
5470
+ return !state;
5233
5471
  }
5234
- return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
5472
+ return true;
5235
5473
  }
5474
+ /** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
5475
+ const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
5476
+ Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
5236
5477
  /**
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.
5478
+ * Whether the browser puts `el` in the tab order, before anything that hides
5479
+ * or disables it. The `tabIndex` property can't answer this on its own: it
5480
+ * can read `-1` for an editable region or an `<embed>` that Tab still visits,
5481
+ * and `0` for a `<video>` without controls that it doesn't. So a valid
5482
+ * `tabindex` attribute decides, and otherwise the element's kind does.
5483
+ * Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
5484
+ */
5485
+ function isTabbableKind(el) {
5486
+ const tabindex = el.getAttribute('tabindex');
5487
+ if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
5488
+ return el.tabIndex >= 0;
5489
+ }
5490
+ switch (el.localName) {
5491
+ case 'a':
5492
+ case 'area':
5493
+ return el.hasAttribute('href');
5494
+ case 'input':
5495
+ return el.type !== 'hidden';
5496
+ case 'button':
5497
+ case 'select':
5498
+ case 'textarea':
5499
+ case 'iframe':
5500
+ case 'embed':
5501
+ case 'object':
5502
+ return true;
5503
+ case 'audio':
5504
+ case 'video':
5505
+ return el.hasAttribute('controls');
5506
+ case 'summary':
5507
+ return isDetailsSummary(el);
5508
+ default:
5509
+ return isEditingHost(el);
5510
+ }
5511
+ }
5512
+ /**
5513
+ * The node's parent in the flattened tree, the one the browser renders and
5514
+ * tabs through: the slot it is assigned to, else its parent, else the host of
5515
+ * the shadow root it tops, and nothing above the document. A closed shadow
5516
+ * root hides its slots (`assignedSlot` reads `null` under one), so a child of
5517
+ * its host goes straight to the host, whether a slot takes it or not.
5518
+ */
5519
+ const flatParent = (node) => node.assignedSlot ??
5520
+ node.parentElement ??
5521
+ node.parentNode?.host ??
5522
+ null;
5523
+ /**
5524
+ * The element's children in the flattened tree, in the order they render: a
5525
+ * host's shadow root content, a slot's assigned elements (or its fallback
5526
+ * content when nothing is assigned), anything else's own children. A host
5527
+ * whose shadow root is closed has no root to read, so its own children stand
5528
+ * in, including any that no slot takes and that aren't rendered.
5529
+ */
5530
+ function flatChildren(el) {
5531
+ if (el.shadowRoot)
5532
+ return Array.from(el.shadowRoot.children);
5533
+ if (el.localName === 'slot') {
5534
+ const assigned = el.assignedNodes();
5535
+ if (assigned.length > 0) {
5536
+ return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
5537
+ }
5538
+ }
5539
+ return Array.from(el.children);
5540
+ }
5541
+ const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
5542
+ const isVisible = (el) => {
5543
+ const { visibility } = getComputedStyle(el);
5544
+ return visibility !== 'hidden' && visibility !== 'collapse';
5545
+ };
5546
+ /**
5547
+ * Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
5548
+ * (not inherited, so each element is checked) or a closed `<details>` it
5549
+ * isn't the summary of. `top` itself isn't checked, and `null` walks to the
5550
+ * top of the page.
5551
+ */
5552
+ function isShownUpTo(el, top) {
5553
+ let below = null;
5554
+ for (let node = el; node && node !== top; node = flatParent(node)) {
5555
+ if (!isShown(node))
5556
+ return false;
5557
+ if (below &&
5558
+ node.localName === 'details' &&
5559
+ !node.hasAttribute('open') &&
5560
+ !isDetailsSummary(below)) {
5561
+ return false;
5562
+ }
5563
+ below = node;
5564
+ }
5565
+ return true;
5566
+ }
5567
+ /**
5568
+ * The images that use the image map `area` belongs to. An image names its
5569
+ * map with `usemap="#…"`, matching the map's `name` or `id`.
5570
+ */
5571
+ function imagesUsingMapOf(area) {
5572
+ const map = area.closest('map');
5573
+ if (!map)
5574
+ return [];
5575
+ const names = [map.getAttribute('name'), map.id].filter(name => name);
5576
+ return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
5577
+ const ref = img.getAttribute('usemap');
5578
+ return ref.startsWith('#') && names.includes(ref.slice(1));
5579
+ });
5580
+ }
5581
+ /**
5582
+ * Whether an image map area is shown. An area has no box of its own: the
5583
+ * HTML rendering rules give it `display: none`, though a browser may not
5584
+ * report that. So it counts where an image using its map is shown, as long
5585
+ * as nothing makes it inert.
5586
+ */
5587
+ function isAreaShown(area, container) {
5588
+ for (let node = area; node && node !== container; node = flatParent(node)) {
5589
+ if (node.hasAttribute('inert'))
5590
+ return false;
5591
+ }
5592
+ return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
5593
+ }
5594
+ /**
5595
+ * Whether Tab can land on `el`, one of the kinds it visits. A disabled control
5596
+ * (including one inside a disabled `<fieldset>`), an inert subtree and
5597
+ * anything not rendered are skipped. `visibility` is inherited, so the
5598
+ * element's own computed value covers its ancestors.
5241
5599
  */
5242
5600
  function isRenderedAndEnabled(el, container) {
5243
5601
  if (el.matches(':disabled'))
@@ -5537,909 +5895,727 @@ function useFocusTrap(containerRef, options = {}) {
5537
5895
  restoreFocus,
5538
5896
  refChanges,
5539
5897
  ]);
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);
5552
- }
5553
- });
5554
- }
5555
-
5556
- // INTERNAL: deliberately not exported from src/index.ts.
5557
- /**
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.
5561
- */
5562
- const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
5563
-
5564
- // INTERNAL: deliberately not exported from src/index.ts.
5565
- /**
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.
5569
- */
5570
- function marginGap(panel) {
5571
- const { marginTop, marginBottom } = getComputedStyle(panel);
5572
- return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
5573
- }
5574
- /**
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.
5577
- */
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';
5584
- }
5585
- const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
5586
- /**
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.
5602
- */
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;
5642
- }
5643
-
5644
- const validButtonColors = [...validColors, 'text', 'ghost'];
5645
- /**
5646
- * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
5647
- *
5648
- * @function
5649
- * @param {ButtonProps} props - Props for the Button component.
5650
- * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
5651
- * @returns {JSX.Element} The rendered button, anchor, or custom element.
5652
- * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
5653
- */
5654
- const Button = 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;
5754
- }
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
- }
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
- ]);
5782
- /**
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.
5785
- */
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);
5898
+ // Nothing says when a ref is set, so after every render, a ref that no
5899
+ // longer matches what the trap holds (a container that mounted, left or was
5900
+ // replaced) runs it again. Effects run in order, so whenever the effect
5901
+ // above ran in this commit, it read the ref just before this does, and a
5902
+ // mismatch means the ref changed in a render it didn't run for. The render
5903
+ // a bump asks for runs it, so the two match and the trap settles. An empty
5904
+ // ref matches a trap holding nothing, so a container that never mounts
5905
+ // costs no render.
5906
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
5907
+ React.useEffect(() => {
5908
+ if (active && hydrated && containerRef.current !== heldRef.current) {
5909
+ setRefChanges(n => n + 1);
5910
+ }
5911
+ });
5797
5912
  }
5913
+
5914
+ /** An id list with `id` added at the end, unless it's in there already. */
5915
+ const withId = (ids, id) => [...new Set([...(ids ?? '').split(/\s+/), id])].filter(Boolean).join(' ');
5916
+ /**
5917
+ * The first element in the trigger that Tab lands on, judged by what it is
5918
+ * rather than by its styles, which can hide it for now. The trigger is all
5919
+ * of the wrapper that comes before the tooltip.
5920
+ */
5921
+ const triggerTabStop = (wrapper, tooltip) => {
5922
+ const elements = Array.from(wrapper.querySelectorAll('*'));
5923
+ return elements
5924
+ .slice(0, elements.indexOf(tooltip))
5925
+ .find(el => isTabbableKind(el) && !el.matches(':disabled'));
5926
+ };
5798
5927
  /**
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.
5928
+ * The `Tooltip` component displays helpful information when users hover over or focus on an element.
5800
5929
  *
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`.
5930
+ * Hovering the trigger or giving it keyboard focus shows it. Touch doesn't,
5931
+ * unless `openOnTap` is on, and neither does focus a click or a tap brings,
5932
+ * so tapping the trigger just does what the trigger does. The pointer can
5933
+ * move onto a shown tooltip. Escape hides it wherever focus is, without
5934
+ * moving the pointer or focus, and it stays hidden until one of them leaves
5935
+ * the trigger and comes back, or, with `openOnTap`, until the next tap on
5936
+ * the trigger. The key always goes on too, to whatever has focus or a
5937
+ * `Modal` or `Popover` around it, since a tooltip only adds to what's
5938
+ * there. The tooltip is its trigger's description, so a screen reader reads
5939
+ * it as the trigger takes focus, whether it's showing yet or not.
5805
5940
  *
5806
5941
  * @function
5807
- * @param {PopoverProps} props - Props for the Popover component.
5808
- * @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
5942
+ * @param {TooltipProps} props - Props for the Tooltip component.
5943
+ * @returns {JSX.Element} The rendered tooltip wrapper.
5809
5944
  *
5810
5945
  * @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>
5946
+ * // Basic tooltip
5947
+ * <Tooltip label="This is helpful info">
5948
+ * <button>Hover me</button>
5949
+ * </Tooltip>
5950
+ *
5951
+ * @example
5952
+ * // Positioned tooltip with color, on an icon-only button with a name of its own
5953
+ * <Tooltip label="Moves it to the trash" position="right" color="danger">
5954
+ * <Button aria-label="Delete">
5955
+ * <Icon name="fas fa-trash" />
5956
+ * </Button>
5957
+ * </Tooltip>
5958
+ *
5959
+ * @example
5960
+ * // Multiline tooltip
5961
+ * <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
5962
+ * <button>More info</button>
5963
+ * </Tooltip>
5818
5964
  */
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 }) => {
5965
+ 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 }) => {
5820
5966
  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
- 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.
5868
- React.useEffect(() => {
5869
- const panel = panelRef.current;
5870
- if (!panelMounted || trapFocus || !panel)
5871
- 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]);
5967
+ const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
5968
+ const [isVisible, setIsVisible] = React.useState(false);
5969
+ const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
5970
+ const [contentStyle, setContentStyle] = React.useState({});
5971
+ const wrapperRef = React.useRef(null);
5972
+ const contentRef = React.useRef(null);
5973
+ const tooltipId = `${React.useId()}-tooltip`;
5974
+ // Only a single element can be given the link, and a fragment takes no
5975
+ // props of its own.
5976
+ const linkable = React.isValidElement(children) &&
5977
+ children.type !== React.Fragment;
5978
+ // A screen reader reads the tooltip only when Tab lands on an element that
5979
+ // carries the link. Only the page shows whether it does, since a component
5980
+ // may not pass the attribute on, or what takes focus may sit inside the
5981
+ // element that took it. After every commit, since the children can change,
5982
+ // and only in development.
5881
5983
  React.useEffect(() => {
5882
- if (!panelMounted || !closeOnClickOutside)
5883
- return undefined;
5884
- const handlePointerDown = (e) => {
5885
- if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
5886
- return;
5887
- }
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)
5984
+ if (!isDev())
5917
5985
  return;
5918
- if (e.key === 'Escape') {
5919
- if (isOpen && closeOnEscape) {
5920
- e.preventDefault();
5921
- close();
5922
- }
5986
+ const stop = triggerTabStop(wrapperRef.current, contentRef.current);
5987
+ if (stop?.getAttribute('aria-describedby')?.split(/\s+/).includes(tooltipId)) {
5923
5988
  return;
5924
5989
  }
5925
- if (e.key !== 'Enter' && e.key !== ' ')
5990
+ warnOnce('Tooltip:unlinked-trigger', "[bestax-bulma] <Tooltip> can't describe its trigger, so a screen " +
5991
+ "reader won't read the tooltip: Tab reaches nothing in the trigger, " +
5992
+ "or what it reaches doesn't carry the aria-describedby that points " +
5993
+ 'at the tooltip. Pass one element that takes focus and passes ' +
5994
+ 'aria-describedby on to its DOM node, such as a button, a link or a ' +
5995
+ 'Span with tabIndex={0}.');
5996
+ });
5997
+ const hover = useHoverOpen({
5998
+ // The pointer and focus as they are. `active` shows it on top of that, so
5999
+ // dropping `active` leaves it shown while the pointer or focus holds it.
6000
+ // An ask to close can come while it's already hidden, which is no change.
6001
+ open: isVisible,
6002
+ onOpenChange: setIsVisible,
6003
+ openDelay: delay,
6004
+ closeDelay,
6005
+ // The wrapper stands in for the trigger. It holds only the children and
6006
+ // the tooltip, and nothing in the tooltip takes focus, so focus anywhere
6007
+ // in it is focus on the trigger.
6008
+ triggerRef: wrapperRef,
6009
+ floatingRef: contentRef,
6010
+ // Escape hides it and carries on, so a control in the trigger, or a
6011
+ // Modal around it, still gets the key.
6012
+ consumeEscape: false,
6013
+ openOnTap,
6014
+ });
6015
+ // Auto-placement and overflow correction
6016
+ React.useLayoutEffect(() => {
6017
+ const isAuto = position === 'auto';
6018
+ if (!isAuto) {
6019
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
6020
+ setResolvedPosition(position);
6021
+ }
6022
+ const showing = isVisible || alwaysActive;
6023
+ if (!showing) {
6024
+ setContentStyle({});
5926
6025
  return;
5927
- if (handlesKeyItself(e.currentTarget, e.key))
6026
+ }
6027
+ const wrapper = wrapperRef.current;
6028
+ const content = contentRef.current;
6029
+ if (!wrapper || !content)
5928
6030
  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,
6031
+ const wrapperRect = wrapper.getBoundingClientRect();
6032
+ const margin = 40;
6033
+ const edgePadding = 8; // minimum distance from viewport edge
6034
+ // Step 1: determine position (auto mode only)
6035
+ if (isAuto) {
6036
+ const spaceTop = wrapperRect.top;
6037
+ const spaceBottom = window.innerHeight - wrapperRect.bottom;
6038
+ if (spaceTop >= margin) {
6039
+ setResolvedPosition('top');
5942
6040
  }
5943
- : {}),
5944
- 'aria-haspopup': 'dialog',
5945
- 'aria-expanded': isOpen,
5946
- 'aria-controls': panelMounted ? panelId : undefined,
5947
- onClick: handleTriggerClick,
5948
- onKeyDown: handleTriggerKeyDown,
6041
+ else if (spaceBottom >= margin) {
6042
+ setResolvedPosition('bottom');
6043
+ }
6044
+ else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
6045
+ setResolvedPosition('right');
6046
+ }
6047
+ else {
6048
+ setResolvedPosition('left');
6049
+ }
6050
+ }
6051
+ // Step 2: measure tooltip content and correct overflow.
6052
+ // Use requestAnimationFrame so the resolved position class is applied first.
6053
+ requestAnimationFrame(() => {
6054
+ const contentEl = contentRef.current;
6055
+ if (!contentEl)
6056
+ return;
6057
+ const contentRect = contentEl.getBoundingClientRect();
6058
+ const style = {};
6059
+ // For top/bottom positions, tooltip is centered horizontally via CSS
6060
+ // (left: 50%; transform: translateX(-50%)).
6061
+ // Check if it overflows left or right and apply a corrective shift.
6062
+ const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
6063
+ const effectivePos = pos || 'top'; // fallback, actual is set by state
6064
+ const isVertical = effectivePos === 'top' ||
6065
+ effectivePos === 'bottom' ||
6066
+ resolvedPosition === 'top' ||
6067
+ resolvedPosition === 'bottom';
6068
+ if (isVertical) {
6069
+ if (contentRect.left < edgePadding) {
6070
+ // Overflows left — shift right
6071
+ const shift = edgePadding - contentRect.left;
6072
+ style.left = `calc(50% + ${shift}px)`;
6073
+ style['--tooltip-arrow-offset'] =
6074
+ `${-shift}px`;
6075
+ }
6076
+ else if (contentRect.right > window.innerWidth - edgePadding) {
6077
+ // Overflows right — shift left
6078
+ const shift = contentRect.right - (window.innerWidth - edgePadding);
6079
+ style.left = `calc(50% - ${shift}px)`;
6080
+ style['--tooltip-arrow-offset'] =
6081
+ `${shift}px`;
6082
+ }
6083
+ }
6084
+ else {
6085
+ // For left/right positions, tooltip is centered vertically via CSS
6086
+ // (top: 50%; transform: translateY(-50%)).
6087
+ if (contentRect.top < edgePadding) {
6088
+ const shift = edgePadding - contentRect.top;
6089
+ style.top = `calc(50% + ${shift}px)`;
6090
+ style['--tooltip-arrow-offset'] =
6091
+ `${-shift}px`;
6092
+ }
6093
+ else if (contentRect.bottom > window.innerHeight - edgePadding) {
6094
+ const shift = contentRect.bottom - (window.innerHeight - edgePadding);
6095
+ style.top = `calc(50% - ${shift}px)`;
6096
+ style['--tooltip-arrow-offset'] =
6097
+ `${shift}px`;
6098
+ }
6099
+ }
6100
+ setContentStyle(style);
6101
+ });
6102
+ }, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
6103
+ // Generate Bulma classes
6104
+ const tooltipClasses = usePrefixedClassNames('tooltip', {
6105
+ 'is-active': alwaysActive || isVisible,
6106
+ // Open by the pointer, focus or a tap, rather than shown by `active`
6107
+ // alone: only then does the tooltip, and the gap to it, take the pointer.
6108
+ 'is-open': isVisible,
6109
+ [`is-${resolvedPosition}`]: resolvedPosition,
6110
+ [`is-${color}`]: color,
6111
+ [`is-${size}`]: size,
6112
+ 'is-multiline': multiline,
6113
+ 'is-animated': animated,
6114
+ 'is-square': square,
6115
+ 'is-dashed': dashed,
5949
6116
  });
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 }) }))] }));
6117
+ const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
6118
+ const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
6119
+ 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: [linkable
6120
+ ? React.cloneElement(children, {
6121
+ 'aria-describedby': withId(children.props['aria-describedby'], tooltipId),
6122
+ })
6123
+ : children, jsxRuntime.jsx("span", { ref: contentRef, id: tooltipId, className: combinedTooltipClasses,
6124
+ // Hidden by `visibility: hidden` in the stylesheet, which keeps it out
6125
+ // of the accessibility tree and still lets it describe the trigger
6126
+ // (accname step 2A), so it takes no aria-hidden.
6127
+ role: "tooltip", style: Object.keys(contentStyle).length > 0 ? contentStyle : undefined, children: content || label })] }));
5964
6128
  };
6129
+
6130
+ // INTERNAL: deliberately not exported from src/index.ts.
5965
6131
  /**
5966
- * The open panel. It mounts with each opening, so it counts the
5967
- * `Popover.Header`s inside it afresh every time.
6132
+ * A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
6133
+ * side facing the anchor, negative on a portaled panel above it, so the
6134
+ * larger magnitude is the gap. In pixels.
5968
6135
  */
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;
5984
- 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.');
5988
- }
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 }) }));
6136
+ function marginGap(panel) {
6137
+ const { marginTop, marginBottom } = getComputedStyle(panel);
6138
+ return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
5992
6139
  }
5993
6140
  /**
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.
6141
+ * The corner that keeps a panel of this size in the viewport, preferring
6142
+ * below the anchor and lined up with its left edge.
5999
6143
  */
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
- };
6144
+ function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
6145
+ const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
6146
+ const fitsRight = rect.left + panelWidth <= view.innerWidth;
6147
+ if (fitsBelow)
6148
+ return fitsRight ? 'bottom-left' : 'bottom-right';
6149
+ return fitsRight ? 'top-left' : 'top-right';
6150
+ }
6151
+ const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
6008
6152
  /**
6009
- * The popover's main content area.
6153
+ * Places a floating panel against its anchor: resolves `auto` to a corner
6154
+ * and, for a panel fixed to the viewport, works out its coordinates. It
6155
+ * measures again on resize and on any scroll of the anchor's window (an
6156
+ * iframe's, when the anchor renders into one), so a fixed panel follows its
6157
+ * anchor. A measurement that changes nothing doesn't render again.
6010
6158
  *
6011
- * @function
6012
- * @param {PopoverBodyProps} props - Props for the Popover.Body component.
6013
- * @returns {JSX.Element} The body element.
6014
- */
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
- };
6019
- /**
6020
- * A row of actions along the bottom of the popover, aligned to the end.
6159
+ * Measuring happens in a layout effect, so the panel is placed before it
6160
+ * paints. It needs both elements on the page while `active` is on, and
6161
+ * keeps the last placement when either is missing.
6021
6162
  *
6022
- * @function
6023
- * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
6024
- * @returns {JSX.Element} The footer element.
6163
+ * @function useAnchoredPosition
6164
+ * @param anchorRef - The element the panel opens from.
6165
+ * @param panelRef - The floating panel.
6166
+ * @param options - Whether to measure, the corner asked for, and how the panel is placed.
6167
+ * @returns The corner the panel opens from, with coordinates when it is fixed.
6025
6168
  */
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
- };
6169
+ function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
6170
+ const [resolved, setResolved] = React.useState({
6171
+ position: position === 'auto' ? 'bottom-left' : position,
6172
+ });
6173
+ useClientLayoutEffect(() => {
6174
+ if (!active)
6175
+ return undefined;
6176
+ const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
6177
+ window;
6178
+ const update = () => {
6179
+ const anchor = anchorRef.current;
6180
+ const panel = panelRef.current;
6181
+ if (!anchor || !panel)
6182
+ return;
6183
+ const rect = anchor.getBoundingClientRect();
6184
+ const { width, height } = panel.getBoundingClientRect();
6185
+ const corner = position === 'auto'
6186
+ ? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
6187
+ : position;
6188
+ const next = fixed
6189
+ ? {
6190
+ position: corner,
6191
+ top: corner.startsWith('bottom')
6192
+ ? rect.bottom + offset
6193
+ : rect.top - height - offset,
6194
+ left: corner.endsWith('left') ? rect.left : rect.right - width,
6195
+ }
6196
+ : { position: corner };
6197
+ setResolved(prev => (samePosition(prev, next) ? prev : next));
6198
+ };
6199
+ update();
6200
+ view.addEventListener('resize', update, { passive: true });
6201
+ view.addEventListener('scroll', update, { passive: true, capture: true });
6202
+ return () => {
6203
+ view.removeEventListener('resize', update);
6204
+ view.removeEventListener('scroll', update, { capture: true });
6205
+ };
6206
+ }, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
6207
+ return resolved;
6208
+ }
6209
+
6210
+ const validButtonColors = [...validColors, 'text', 'ghost'];
6030
6211
  /**
6031
- * A `Button` that closes the popover, handing focus back to the trigger.
6212
+ * The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
6032
6213
  *
6033
6214
  * @function
6034
- * @param {PopoverCloseProps} props - Props for the Popover.Close component.
6035
- * @returns {JSX.Element} The close button.
6215
+ * @param {ButtonProps} props - Props for the Button component.
6216
+ * @param {React.Ref} ref - Forwarded ref to the element `as` renders.
6217
+ * @returns {JSX.Element} The rendered button, anchor, or custom element.
6218
+ * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
6036
6219
  */
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');
6220
+ const Button = React.forwardRef(function Button(props, ref) {
6221
+ 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;
6222
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
6223
+ color: textColor,
6224
+ backgroundColor: bgColor,
6225
+ ...bulmaProps,
6226
+ });
6227
+ // Generate Bulma classes with prefix
6228
+ const bulmaClasses = usePrefixedClassNames('button', {
6229
+ [`is-${color}`]: color &&
6230
+ validButtonColors.includes(color),
6231
+ [`is-${size}`]: size,
6232
+ 'is-outlined': isOutlined,
6233
+ 'is-light': isLight,
6234
+ 'is-loading': isLoading,
6235
+ 'is-static': isStatic,
6236
+ 'is-disabled': isDisabled,
6237
+ 'is-rounded': isRounded,
6238
+ 'is-hovered': isHovered,
6239
+ 'is-focused': isFocused,
6240
+ 'is-active': isActive,
6241
+ 'is-inverted': isInverted,
6242
+ 'is-fullwidth': isFullwidth ?? isFullWidth,
6243
+ });
6244
+ // Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
6245
+ const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
6246
+ if (Component !== 'button') {
6247
+ // Strip the form-control attributes a link-like element cannot carry, so a
6248
+ // JavaScript consumer or a spread object doesn't put them on the DOM. The
6249
+ // types no longer allow them through; this is the runtime backstop.
6250
+ //
6251
+ // Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
6252
+ // prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
6253
+ // its props reach it. Stripping names by their spelling alone broke that
6254
+ // promise silently: `<Button as={Custom} name="x" />` type-checked while
6255
+ // `Custom` never received the `name` it requires.
6256
+ //
6257
+ // `autoFocus` and `type` are deliberately NOT stripped: both are valid on
6258
+ // targets this list was filtering them from. `autoFocus` is a global
6259
+ // attribute (React decides per element whether to honour it, which is not
6260
+ // ours to pre-empt), and `type` on an anchor is the MIME hint that
6261
+ // `ButtonProps<'a'>` now types it as.
6262
+ //
6263
+ // The types already answer this for a TypeScript caller: `ButtonProps<T>`
6264
+ // derives from `ComponentPropsWithoutRef<T>`, so only what the target
6265
+ // accepts can be passed. What is left is a runtime backstop for JavaScript
6266
+ // consumers and spread objects.
6267
+ //
6268
+ // Keep it to the two cases where a stray attribute does something. The
6269
+ // submit-overrides belong to a submit control and never make sense on the
6270
+ // anchor path. `disabled` is the one that is VISIBLE: Bulma styles
6271
+ // `.button[disabled]` (background, border, shadow, opacity), so letting it
6272
+ // through to a `<span>` greys the element out — main stripped it, and not
6273
+ // stripping it would be a silent visual change on a types-only release.
6274
+ //
6275
+ // Everything else (`name`, `value`, `form`) is inert where it does not
6276
+ // belong, and enumerating which tags own it is what kept going wrong:
6277
+ // first all nine names on every non-button tag (so `<Button as="input"
6278
+ // name="query" value="Search">` submitted nothing), then a form-control
6279
+ // allowlist that still lost `formAction` on an input and `value` on an
6280
+ // option. Those now ride on the types instead.
6281
+ //
6282
+ // A custom component is exempt entirely — it owns its prop contract, and
6283
+ // `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
6284
+ //
6285
+ // The elements that own `disabled`, per the HTML spec. A closed set, unlike
6286
+ // the open-ended question of which tag owns which attribute.
6287
+ const SUBMIT_OVERRIDES = [
6288
+ 'formAction',
6289
+ 'formEncType',
6290
+ 'formMethod',
6291
+ 'formNoValidate',
6292
+ 'formTarget',
6293
+ ];
6294
+ const OWNS_DISABLED = [
6295
+ 'button',
6296
+ 'fieldset',
6297
+ 'input',
6298
+ 'optgroup',
6299
+ 'option',
6300
+ 'select',
6301
+ 'textarea',
6302
+ ];
6303
+ // Two independent filters. They are not exclusive — an `<a>` owns neither
6304
+ // the submit-overrides nor `disabled`, so it takes both — and deriving them
6305
+ // from one destructure, or from one if/else, is what broke each of them in
6306
+ // turn: first the overrides came off every intrinsic (an `as="input"` lost
6307
+ // `formAction`), then scoping them to the anchor let `disabled` back onto
6308
+ // it.
6309
+ const forwardedRest = { ...rest };
6310
+ if (typeof Component === 'string' && !isCustomElement(Component)) {
6311
+ // Meaningless anywhere but a submit control, and an anchor is not one.
6312
+ if (Component === 'a') {
6313
+ for (const key of SUBMIT_OVERRIDES)
6314
+ delete forwardedRest[key];
6315
+ }
6316
+ // Visible via Bulma's `.button[disabled]`, so withheld from any element
6317
+ // that does not own it.
6318
+ if (!OWNS_DISABLED.includes(Component))
6319
+ delete forwardedRest.disabled;
6320
+ }
6321
+ return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
6322
+ ? // Guarded, because this handler is installed into whatever `as`
6323
+ // renders and a custom component's `onClick` need not be a DOM
6324
+ // event handler at all. One declaring `onClick: (v: string) =>
6325
+ // void` calls this with a string, and an unguarded
6326
+ // `e.preventDefault()` throws `is not a function`. Blocking the
6327
+ // real event is the point; a foreign payload is simply not ours
6328
+ // to act on.
6329
+ (e) => {
6330
+ if (typeof e?.preventDefault === 'function')
6331
+ e.preventDefault();
6332
+ }
6333
+ : onClick, ...forwardedRest, children: children }));
6334
+ }
6335
+ return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
6336
+ });
6337
+ Button.displayName = 'Button';
6052
6338
 
6053
- // INTERNAL: deliberately not exported from src/index.ts.
6339
+ const PopoverContext = React.createContext(null);
6340
+ /** Intrinsic elements that are controls already, and keep their own role. */
6341
+ const NATIVE_CONTROLS = new Set([
6342
+ 'button',
6343
+ 'input',
6344
+ 'select',
6345
+ 'textarea',
6346
+ 'summary',
6347
+ ]);
6054
6348
  /**
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.
6349
+ * Whether the browser answers this key on `el` itself, by clicking it or by
6350
+ * using the key as input, so the trigger's click handler covers it.
6060
6351
  */
6061
- function isKeyboardFocus(el) {
6062
- try {
6063
- return el.matches(':focus-visible');
6064
- }
6065
- catch {
6352
+ function handlesKeyItself(el, key) {
6353
+ if (NATIVE_CONTROLS.has(el.localName))
6066
6354
  return true;
6067
- }
6355
+ return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
6068
6356
  }
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
- };
6357
+ /** The innermost dialog the event passed through, which is the one its Escape belongs to. */
6358
+ function nearestDialog(event) {
6359
+ return (event.composedPath().find(node => {
6360
+ const role = node.getAttribute?.('role');
6361
+ return role === 'dialog' || role === 'alertdialog';
6362
+ }) ?? null);
6152
6363
  }
6153
6364
  /**
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.
6365
+ * 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
6366
  *
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.
6367
+ * The panel is a non-modal `role="dialog"`: focus moves into it on open
6368
+ * and returns to the trigger on close, while the rest of the page stays
6369
+ * reachable by pointer and assistive technology. For hover-only hints use
6370
+ * `Tooltip`, and for a list of actions use `Dropdown`.
6187
6371
  *
6188
- * Nothing it scheduled runs after the component unmounts.
6372
+ * @function
6373
+ * @param {PopoverProps} props - Props for the Popover component.
6374
+ * @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
6189
6375
  *
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.
6376
+ * @example
6377
+ * <Popover trigger={<Button>Filters</Button>}>
6378
+ * <Popover.Header>Filter rows</Popover.Header>
6379
+ * <Popover.Body>…</Popover.Body>
6380
+ * <Popover.Footer>
6381
+ * <Popover.Close>Done</Popover.Close>
6382
+ * </Popover.Footer>
6383
+ * </Popover>
6193
6384
  */
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,
6204
- });
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);
6385
+ const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
6386
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6387
+ const rootRef = React.useRef(null);
6388
+ const panelRef = React.useRef(null);
6389
+ // The last pointerdown React saw inside the panel. React's tree includes
6390
+ // content the panel portals elsewhere, which the DOM path doesn't.
6391
+ const insidePointerRef = React.useRef(null);
6392
+ const baseId = React.useId();
6393
+ const panelId = `${baseId}-panel`;
6394
+ const titleId = `${baseId}-title`;
6395
+ const isControlled = controlledOpen !== undefined;
6396
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
6397
+ const isOpen = controlledOpen ?? internalOpen;
6398
+ const requestOpen = React.useCallback((next) => {
6399
+ onOpenChange?.(next);
6400
+ if (!isControlled)
6401
+ setInternalOpen(next);
6402
+ }, [isControlled, onOpenChange]);
6403
+ const close = React.useCallback(() => requestOpen(false), [requestOpen]);
6404
+ // A portaled panel has no server markup, so it only exists from the commit
6405
+ // after hydration. Everything that measures or focuses it waits for that.
6406
+ const hydrated = useIsHydrated();
6407
+ const panelMounted = isOpen && (hydrated || !appendToBody);
6408
+ const resolved = useAnchoredPosition(rootRef, panelRef, {
6409
+ active: panelMounted,
6410
+ position,
6411
+ fixed: appendToBody,
6412
+ // The gap comes from `--bulma-popover-offset` in the stylesheet, so the
6413
+ // coordinates leave it out and `auto` reads it off the panel.
6414
+ offset: 0,
6415
+ styledGap: marginGap,
6416
+ });
6417
+ // The trigger renders first inside the wrapper. Read when needed rather
6418
+ // than held, so a trigger that remounted is still the one found.
6419
+ const triggerNode = React.useCallback(() => {
6420
+ const first = rootRef.current?.firstElementChild;
6421
+ return first && first !== panelRef.current ? first : null;
6228
6422
  }, []);
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);
6423
+ const restoreFocusRef = React.useMemo(() => ({
6424
+ get current() {
6425
+ return triggerNode();
6248
6426
  },
6427
+ }), [triggerNode]);
6428
+ useFocusTrap(panelRef, {
6429
+ active: panelMounted && trapFocus,
6430
+ restoreFocus: restoreFocusRef,
6249
6431
  });
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);
6432
+ // Without the trap, focus still arrives in the panel and goes back to the
6433
+ // trigger, on the trap's terms: only while the panel still holds it.
6434
+ React.useEffect(() => {
6435
+ const panel = panelRef.current;
6436
+ if (!panelMounted || trapFocus || !panel)
6437
+ return undefined;
6438
+ (firstTabStop(panel) ?? panel).focus();
6439
+ return () => {
6440
+ const doc = panel.ownerDocument;
6441
+ const now = getDeepestActiveElement(doc);
6442
+ if (!now || now === doc.body || panel.contains(now)) {
6443
+ triggerNode()?.focus();
6289
6444
  }
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)) {
6445
+ };
6446
+ }, [panelMounted, trapFocus, triggerNode]);
6447
+ React.useEffect(() => {
6448
+ if (!panelMounted || !closeOnClickOutside)
6449
+ return undefined;
6450
+ const handlePointerDown = (e) => {
6451
+ if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
6312
6452
  return;
6313
6453
  }
6314
- latestRef.current.onOpenChange(false);
6454
+ close();
6315
6455
  };
6456
+ // The wrapper is on the page whenever the panel is. Its document, an
6457
+ // iframe's when the tree renders into one, is where presses land.
6458
+ const doc = rootRef.current.ownerDocument;
6316
6459
  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();
6460
+ return () => doc.removeEventListener('pointerdown', handlePointerDown);
6461
+ }, [panelMounted, closeOnClickOutside, close]);
6462
+ const handlePanelKeyDown = (e) => {
6463
+ if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
6464
+ return;
6465
+ // React bubbles the key here from portaled content as well, so a dialog
6466
+ // of its own on the way (a nested popover, a picker's calendar) owns it.
6467
+ const owner = nearestDialog(e.nativeEvent);
6468
+ if (owner && owner !== panelRef.current)
6469
+ return;
6470
+ e.preventDefault();
6471
+ close();
6472
+ };
6473
+ const triggerProps = trigger.props;
6474
+ const handleTriggerClick = (e) => {
6475
+ triggerProps.onClick?.(e);
6476
+ if (!e.defaultPrevented)
6477
+ requestOpen(!isOpen);
6478
+ };
6479
+ const handleTriggerKeyDown = (e) => {
6480
+ triggerProps.onKeyDown?.(e);
6481
+ // A key on something inside the trigger is that element's to answer.
6482
+ if (e.defaultPrevented || e.target !== e.currentTarget)
6483
+ return;
6484
+ if (e.key === 'Escape') {
6485
+ if (isOpen && closeOnEscape) {
6486
+ e.preventDefault();
6487
+ close();
6488
+ }
6489
+ return;
6329
6490
  }
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
- }
6491
+ if (e.key !== 'Enter' && e.key !== ' ')
6492
+ return;
6493
+ if (handlesKeyItself(e.currentTarget, e.key))
6494
+ return;
6495
+ // Space would scroll the page; a held key repeats.
6496
+ e.preventDefault();
6497
+ if (!e.repeat)
6498
+ requestOpen(!isOpen);
6499
+ };
6500
+ const needsButtonRole = typeof trigger.type === 'string' &&
6501
+ !NATIVE_CONTROLS.has(trigger.type) &&
6502
+ !(trigger.type === 'a' && triggerProps.href != null);
6503
+ const clonedTrigger = React.cloneElement(trigger, {
6504
+ ...(needsButtonRole
6505
+ ? {
6506
+ role: triggerProps.role ?? 'button',
6507
+ tabIndex: triggerProps.tabIndex ?? 0,
6366
6508
  }
6509
+ : {}),
6510
+ 'aria-haspopup': 'dialog',
6511
+ 'aria-expanded': isOpen,
6512
+ 'aria-controls': panelMounted ? panelId : undefined,
6513
+ onClick: handleTriggerClick,
6514
+ onKeyDown: handleTriggerKeyDown,
6515
+ });
6516
+ const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
6517
+ const panelClasses = classNames(usePrefixedClassNames('popover-content', {
6518
+ [`is-${resolved.position}`]: true,
6519
+ 'is-portal': appendToBody,
6520
+ }), contentClassName);
6521
+ 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
6522
+ ? {
6523
+ position: 'fixed',
6524
+ top: resolved.top,
6525
+ left: resolved.left,
6526
+ }
6527
+ : undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
6528
+ insidePointerRef.current = e.nativeEvent;
6529
+ }, children: children }) }))] }));
6530
+ };
6531
+ /**
6532
+ * The open panel. It mounts with each opening, so it counts the
6533
+ * `Popover.Header`s inside it afresh every time.
6534
+ */
6535
+ function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
6536
+ // Headers register in a layout effect, after this first render, and this
6537
+ // panel's own layout effect runs after theirs. Until then the panel assumes
6538
+ // it has one, so a panel that does is named from its first commit, before
6539
+ // focus moves in, and one that doesn't drops the reference before it paints.
6540
+ const [titlesCounted, setTitlesCounted] = React.useState(false);
6541
+ const [titleCount, setTitleCount] = React.useState(0);
6542
+ const registerTitle = React.useCallback(() => {
6543
+ setTitleCount(n => n + 1);
6544
+ return () => setTitleCount(n => n - 1);
6545
+ }, []);
6546
+ useClientLayoutEffect(() => {
6547
+ setTitlesCounted(true);
6548
+ }, []);
6549
+ const named = Boolean(ariaLabel) || titleCount > 0;
6550
+ React.useEffect(() => {
6551
+ if (titlesCounted && !named) {
6552
+ warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
6553
+ 'Pass ariaLabel, or render a Popover.Header inside it.');
6367
6554
  }
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]);
6555
+ }, [titlesCounted, named]);
6556
+ const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
6557
+ 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
6558
  }
6559
+ /**
6560
+ * The popover's title, which names the panel for assistive technology.
6561
+ *
6562
+ * @function
6563
+ * @param {PopoverHeaderProps} props - Props for the Popover.Header component.
6564
+ * @returns {JSX.Element} The header element.
6565
+ */
6566
+ const PopoverHeader = ({ className, ...props }) => {
6567
+ const context = React.useContext(PopoverContext);
6568
+ const registerTitle = context?.registerTitle;
6569
+ // Before paint, so the panel is named by the time it shows.
6570
+ useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
6571
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6572
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
6573
+ };
6574
+ /**
6575
+ * The popover's main content area.
6576
+ *
6577
+ * @function
6578
+ * @param {PopoverBodyProps} props - Props for the Popover.Body component.
6579
+ * @returns {JSX.Element} The body element.
6580
+ */
6581
+ const PopoverBody = ({ className, ...props }) => {
6582
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6583
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
6584
+ };
6585
+ /**
6586
+ * A row of actions along the bottom of the popover, aligned to the end.
6587
+ *
6588
+ * @function
6589
+ * @param {PopoverFooterProps} props - Props for the Popover.Footer component.
6590
+ * @returns {JSX.Element} The footer element.
6591
+ */
6592
+ const PopoverFooter = ({ className, ...props }) => {
6593
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6594
+ return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
6595
+ };
6596
+ /**
6597
+ * A `Button` that closes the popover, handing focus back to the trigger.
6598
+ *
6599
+ * @function
6600
+ * @param {PopoverCloseProps} props - Props for the Popover.Close component.
6601
+ * @returns {JSX.Element} The close button.
6602
+ */
6603
+ const PopoverClose = ({ onClick, ...props }) => {
6604
+ const context = React.useContext(PopoverContext);
6605
+ return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
6606
+ onClick?.(e);
6607
+ if (!e.defaultPrevented)
6608
+ context?.close();
6609
+ } }));
6610
+ };
6611
+ /** Popover with Header, Body, Footer and Close sub-components. */
6612
+ const Popover = withSubComponents(PopoverComponent, {
6613
+ Header: PopoverHeader,
6614
+ Body: PopoverBody,
6615
+ Footer: PopoverFooter,
6616
+ Close: PopoverClose,
6617
+ }, 'Popover');
6435
6618
 
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
6619
  /**
6444
6620
  * 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
6621
  *
@@ -6464,7 +6640,7 @@ function chain(own, ours) {
6464
6640
  */
6465
6641
  const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, openDelay = 600, closeDelay = 300, position = 'bottom-left', appendToBody = false, openOnTap = false, className, contentClassName, ...props }) => {
6466
6642
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6467
- const { onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6643
+ const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
6468
6644
  const rootRef = React.useRef(null);
6469
6645
  const cardRef = React.useRef(null);
6470
6646
  const cardId = `${React.useId()}-card`;
@@ -6537,7 +6713,7 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6537
6713
  [`is-${resolved.position}`]: true,
6538
6714
  'is-portal': appendToBody,
6539
6715
  }), 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
6716
+ 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
6717
  ? {
6542
6718
  position: 'fixed',
6543
6719
  top: resolved.top,
@@ -6546,6 +6722,394 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
6546
6722
  : undefined, children: children }) }))] }));
6547
6723
  };
6548
6724
 
6725
+ // INTERNAL: deliberately not exported from src/index.ts.
6726
+ /** How long a touch or a pen has to stay down to open the menu, in milliseconds. */
6727
+ const LONG_PRESS_DELAY = 500;
6728
+ /** How far a long press can move before it counts as something else, in CSS pixels. */
6729
+ const LONG_PRESS_TOLERANCE = 10;
6730
+ /**
6731
+ * The last press no trigger is to start a long press for: one a trigger
6732
+ * took, or another pointer coming down while a press is held. A press
6733
+ * reaches every trigger it bubbles through, the innermost first and all
6734
+ * before the next press, so this is how a trigger around another one knows
6735
+ * the inner one took it, and how a trigger a second finger lands on knows to
6736
+ * leave it.
6737
+ */
6738
+ let claimed = null;
6739
+ /** Whether the event happened in the element its handler is on, rather than in content portaled out of it. */
6740
+ const inTrigger = (event) => event.currentTarget.contains(event.target);
6741
+ /**
6742
+ * Where a `contextmenu` asks to open: at its point when that is on the
6743
+ * element it was fired at, which it is for the pointer, and at that
6744
+ * element's bottom-left corner otherwise, which is where the browser puts
6745
+ * its own menu for a key.
6746
+ */
6747
+ function contextMenuPoint(event) {
6748
+ const { clientX: x, clientY: y } = event;
6749
+ const rect = event.target.getBoundingClientRect();
6750
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
6751
+ ? { x, y }
6752
+ : { x: rect.left, y: rect.bottom };
6753
+ }
6754
+ /**
6755
+ * Decides when a context menu asks to open, and where. It renders nothing,
6756
+ * and knows nothing about ARIA or the menu.
6757
+ *
6758
+ * - **`contextmenu`:** a right-click, and Shift+F10 or the context menu key
6759
+ * where the browser fires the event for them, asks to open at the point
6760
+ * `contextMenuPoint` works out, and prevents the browser's own menu. Each
6761
+ * one asks again, so a right-click elsewhere on the trigger moves it.
6762
+ * - **Long press:** a touch or a pen held still on the trigger for
6763
+ * `LONG_PRESS_DELAY` fires a `contextmenu` at what it landed on, so it
6764
+ * takes the same path, handlers on the way included. Moving more than
6765
+ * `LONG_PRESS_TOLERANCE`, lifting, another pointer coming down anywhere in
6766
+ * the document, or `pointercancel`, which is the browser taking the
6767
+ * gesture for a scroll or a zoom, cancels it, and a mouse never starts
6768
+ * one. A pointer that comes down while another is held starts no long
6769
+ * press of its own, on this trigger or another, so two fingers on two rows
6770
+ * open nothing. While the finger is down, and only then, a listener on the
6771
+ * document hears other pointers coming down. Where the
6772
+ * browser fires `contextmenu` for a long press itself, the first of the two
6773
+ * opens it, and the end of a touch that opened it is prevented, so lifting
6774
+ * doesn't click what is under the finger.
6775
+ * - **Who opens:** a handler that calls `preventDefault()` on the
6776
+ * `contextmenu` before this one keeps it from asking, which is how a
6777
+ * trigger's own handler opts out and how the innermost of nested triggers
6778
+ * wins. A long press goes to the innermost trigger that takes it. Events
6779
+ * from content portaled out of the trigger, which React brings here
6780
+ * anyway, are ignored.
6781
+ * - **`disabled`:** nothing asks, and the browser's menu shows.
6782
+ *
6783
+ * Nothing it scheduled runs after the component unmounts.
6784
+ *
6785
+ * @function useContextMenuTrigger
6786
+ * @param options - How to ask to open, and whether to.
6787
+ * @returns Handlers for the trigger element.
6788
+ */
6789
+ function useContextMenuTrigger({ onOpen, disabled, }) {
6790
+ const pressRef = React.useRef(null);
6791
+ /** A long press opened the menu, and the touch that made it hasn't ended. */
6792
+ const heldRef = React.useRef(false);
6793
+ const cancel = () => {
6794
+ clearTimeout(pressRef.current?.timer);
6795
+ pressRef.current?.stop();
6796
+ pressRef.current = null;
6797
+ };
6798
+ React.useEffect(() => () => {
6799
+ clearTimeout(pressRef.current?.timer);
6800
+ pressRef.current?.stop();
6801
+ pressRef.current = null;
6802
+ }, []);
6803
+ const isPress = (event) => pressRef.current?.pointerId === event.pointerId;
6804
+ return {
6805
+ onContextMenu: event => {
6806
+ if (disabled || event.defaultPrevented || !inTrigger(event))
6807
+ return;
6808
+ event.preventDefault();
6809
+ const press = pressRef.current;
6810
+ if (press) {
6811
+ if (press.opened)
6812
+ return;
6813
+ clearTimeout(press.timer);
6814
+ press.opened = true;
6815
+ heldRef.current = true;
6816
+ }
6817
+ onOpen(contextMenuPoint(event), event.currentTarget);
6818
+ },
6819
+ onPointerDown: event => {
6820
+ const native = event.nativeEvent;
6821
+ if (disabled ||
6822
+ (event.pointerType !== 'touch' && event.pointerType !== 'pen') ||
6823
+ claimed === native ||
6824
+ !inTrigger(event)) {
6825
+ return;
6826
+ }
6827
+ claimed = native;
6828
+ heldRef.current = false;
6829
+ const target = event.target;
6830
+ const doc = target.ownerDocument;
6831
+ // Another pointer coming down anywhere makes it a pinch or a second
6832
+ // touch, not a long press. In the capture phase, so it's heard before
6833
+ // any trigger it lands on, which then leaves it. The press's own
6834
+ // pointerdown has passed the document already, so it isn't heard.
6835
+ const another = (other) => {
6836
+ claimed = other;
6837
+ cancel();
6838
+ };
6839
+ doc.addEventListener('pointerdown', another, true);
6840
+ const press = {
6841
+ pointerId: event.pointerId,
6842
+ target,
6843
+ x: event.clientX,
6844
+ y: event.clientY,
6845
+ opened: false,
6846
+ stop: () => doc.removeEventListener('pointerdown', another, true),
6847
+ };
6848
+ press.timer = setTimeout(() => {
6849
+ const view = press.target.ownerDocument.defaultView;
6850
+ press.target.dispatchEvent(new view.MouseEvent('contextmenu', {
6851
+ bubbles: true,
6852
+ cancelable: true,
6853
+ composed: true,
6854
+ clientX: press.x,
6855
+ clientY: press.y,
6856
+ button: 2,
6857
+ }));
6858
+ }, LONG_PRESS_DELAY);
6859
+ pressRef.current = press;
6860
+ },
6861
+ onPointerMove: event => {
6862
+ const press = pressRef.current;
6863
+ if (!press || !isPress(event) || press.opened)
6864
+ return;
6865
+ const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y);
6866
+ if (moved > LONG_PRESS_TOLERANCE)
6867
+ cancel();
6868
+ },
6869
+ onPointerUp: event => {
6870
+ if (isPress(event))
6871
+ cancel();
6872
+ },
6873
+ onPointerCancel: event => {
6874
+ if (isPress(event))
6875
+ cancel();
6876
+ },
6877
+ onTouchEnd: event => {
6878
+ if (!heldRef.current)
6879
+ return;
6880
+ heldRef.current = false;
6881
+ event.preventDefault();
6882
+ },
6883
+ };
6884
+ }
6885
+
6886
+ /**
6887
+ * Where the menu goes along one axis, from the point at `at`: after it when
6888
+ * there's room, before it when there's room there instead, and otherwise
6889
+ * moved in until it fits in `room`.
6890
+ */
6891
+ function placeAlong(at, size, room) {
6892
+ if (at + size <= room)
6893
+ return { start: at, flip: false };
6894
+ if (at - size >= 0)
6895
+ return { start: at, flip: true };
6896
+ return { start: Math.max(0, room - size), flip: false };
6897
+ }
6898
+ /** Runs the trigger's own handler, then the context menu's. */
6899
+ function chain(own, ours) {
6900
+ return event => {
6901
+ own?.(event);
6902
+ ours(event);
6903
+ };
6904
+ }
6905
+ /**
6906
+ * 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.
6907
+ *
6908
+ * Shift+F10 and the context menu key open it too, where the browser fires
6909
+ * `contextmenu` for them, which Windows and Linux do. The menu is Dropdown's
6910
+ * markup, portaled into the `portalContainer` a `ConfigProvider` sets, or
6911
+ * the end of the trigger's own document's `<body>`, so it never has server
6912
+ * markup. It opens below and to the right of the point, flips above or to
6913
+ * the left where there's no room, moves in where it fits neither way, and
6914
+ * scrolls inside when it's taller than the viewport. It doesn't follow the
6915
+ * trigger, since scrolling closes it. Inside a `Modal` or a shadow root,
6916
+ * point `portalContainer` inside it, so screen readers that honor
6917
+ * `aria-modal` reach the menu and the shadow root's styles apply.
6918
+ *
6919
+ * Opening moves focus to the first enabled item. The arrow keys, Home, End
6920
+ * and typing move between items, and Enter and Space activate one. Escape,
6921
+ * Tab, an item, a press outside, scrolling, resizing and the window losing
6922
+ * focus close it, handing focus back to what had it unless an item moved it
6923
+ * somewhere else. For a menu opened from a button use `Dropdown`.
6924
+ *
6925
+ * @function
6926
+ * @param {ContextMenuProps} props - Props for the ContextMenu component.
6927
+ * @returns {JSX.Element} The trigger, and the menu while open.
6928
+ *
6929
+ * @example
6930
+ * <ContextMenu
6931
+ * aria-label="Actions for report.pdf"
6932
+ * trigger={<Box>report.pdf</Box>}
6933
+ * >
6934
+ * <ContextMenu.Item as="button">Rename</ContextMenu.Item>
6935
+ * <ContextMenu.Divider />
6936
+ * <ContextMenu.Item as="button">Delete</ContextMenu.Item>
6937
+ * </ContextMenu>
6938
+ */
6939
+ const ContextMenuComponent = ({ trigger, children, onOpenChange, closeOnClick = true, disabled = false, className, onKeyDown, onClick, onContextMenu, ...props }) => {
6940
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
6941
+ const { portalContainer } = useConfig();
6942
+ const [menu, setMenu] = React.useState(null);
6943
+ // Turning `disabled` on closes it.
6944
+ if (disabled && menu)
6945
+ setMenu(null);
6946
+ const open = menu !== null;
6947
+ const doc = menu?.doc;
6948
+ const placed = menu?.placed;
6949
+ const boxRef = React.useRef(null);
6950
+ const menuRef = React.useRef(null);
6951
+ const openerRef = React.useRef(null);
6952
+ const reportedRef = React.useRef(false);
6953
+ const menuKeys = useMenuKeys();
6954
+ const named = Boolean(props['aria-label'] || props['aria-labelledby']);
6955
+ const handlers = useContextMenuTrigger({
6956
+ disabled,
6957
+ onOpen: (point, triggerNode) => {
6958
+ const ownerDoc = triggerNode.ownerDocument;
6959
+ openerRef.current = getDeepestActiveElement(ownerDoc);
6960
+ setMenu({ point, doc: ownerDoc });
6961
+ },
6962
+ });
6963
+ // Measured once per opening, before it paints, against what a fixed box
6964
+ // resolves against: the root element's client area, which leaves out a
6965
+ // classic scrollbar. `useAnchoredPosition` and Tooltip read innerWidth and
6966
+ // innerHeight, which count it. A menu pinned to the viewport's edge would
6967
+ // sit under the scrollbar by that much, so this reads the narrower box.
6968
+ useClientLayoutEffect(() => {
6969
+ const menuNode = menuRef.current;
6970
+ if (!menu || menu.placed || !menuNode)
6971
+ return;
6972
+ const root = menuNode.ownerDocument.documentElement;
6973
+ const { width, height } = menuNode.getBoundingClientRect();
6974
+ const x = placeAlong(menu.point.x, width, root.clientWidth);
6975
+ const y = placeAlong(menu.point.y, height, root.clientHeight);
6976
+ setMenu({
6977
+ ...menu,
6978
+ placed: { left: x.start, top: y.start, right: x.flip, up: y.flip },
6979
+ });
6980
+ }, [menu]);
6981
+ const isPlaced = Boolean(placed);
6982
+ // What closes it while it's open, and focus back on closing. Nothing
6983
+ // listens while it's closed.
6984
+ React.useEffect(() => {
6985
+ const box = boxRef.current;
6986
+ const menuNode = menuRef.current;
6987
+ if (!doc || !box || !menuNode)
6988
+ return undefined;
6989
+ const view = doc.defaultView;
6990
+ // What had focus when it opened. This runs once per opening, so opening
6991
+ // again at a new point while it's open keeps it.
6992
+ const opener = openerRef.current;
6993
+ const close = () => setMenu(null);
6994
+ const closeOutside = (event) => {
6995
+ if (!isEventInside(event, box))
6996
+ close();
6997
+ };
6998
+ doc.addEventListener('pointerdown', closeOutside, true);
6999
+ view.addEventListener('resize', close);
7000
+ view.addEventListener('blur', close);
7001
+ return () => {
7002
+ doc.removeEventListener('pointerdown', closeOutside, true);
7003
+ view.removeEventListener('resize', close);
7004
+ view.removeEventListener('blur', close);
7005
+ // Back to what had focus while the menu still has it, or it dropped to
7006
+ // <body> when the menu left. An item that put it somewhere else, such
7007
+ // as a Modal or a rename field, keeps it there.
7008
+ const now = getDeepestActiveElement(doc);
7009
+ if (!now || now === doc.body || menuNode.contains(now))
7010
+ opener?.focus();
7011
+ };
7012
+ }, [doc]);
7013
+ // Focus in, and closing on scroll, once it's placed. Placed, it's inside
7014
+ // the viewport, so its first item has nothing to scroll into view, and
7015
+ // `preventScroll` makes sure: a scroll the focus caused would close it as
7016
+ // it opened. Opening again at a new point places it again, and leaves
7017
+ // focus where it is in the menu.
7018
+ React.useEffect(() => {
7019
+ const box = boxRef.current;
7020
+ const menuNode = menuRef.current;
7021
+ if (!doc || !isPlaced || !box || !menuNode)
7022
+ return undefined;
7023
+ if (!menuNode.contains(getDeepestActiveElement(doc))) {
7024
+ menuKeys.reset();
7025
+ (enabledMenuItems(menuNode)[0] ?? menuNode).focus({
7026
+ preventScroll: true,
7027
+ });
7028
+ }
7029
+ const closeOutside = (event) => {
7030
+ if (!isEventInside(event, box))
7031
+ setMenu(null);
7032
+ };
7033
+ doc.addEventListener('scroll', closeOutside, true);
7034
+ return () => doc.removeEventListener('scroll', closeOutside, true);
7035
+ }, [doc, isPlaced, menuKeys]);
7036
+ // Before paint, so what the caller does with it, such as highlighting the
7037
+ // row, shows with the menu rather than a frame behind it.
7038
+ useClientLayoutEffect(() => {
7039
+ if (open === reportedRef.current)
7040
+ return;
7041
+ reportedRef.current = open;
7042
+ onOpenChange?.(open);
7043
+ });
7044
+ React.useEffect(() => {
7045
+ if (open && !named) {
7046
+ warnOnce('ContextMenu:unnamed', '[bestax-bulma] <ContextMenu> opened a menu with no accessible ' +
7047
+ 'name. Pass aria-label or aria-labelledby.');
7048
+ }
7049
+ }, [open, named]);
7050
+ const handleKeyDown = (event) => {
7051
+ onKeyDown?.(event);
7052
+ // The menu has the keyboard while focus is in it. React brings its keys
7053
+ // up the tree to wherever the ContextMenu sits, where a table that moves
7054
+ // between rows on the arrow keys, or a Popover or a Modal waiting for
7055
+ // Escape, would take them too.
7056
+ event.stopPropagation();
7057
+ if (event.key === 'Escape' || event.key === 'Tab') {
7058
+ // Tab would go on from the end of <body>, past the rest of the page.
7059
+ event.preventDefault();
7060
+ setMenu(null);
7061
+ return;
7062
+ }
7063
+ menuKeys.onKeyDown(event);
7064
+ };
7065
+ const handleClick = (event) => {
7066
+ onClick?.(event);
7067
+ if (!closeOnClick)
7068
+ return;
7069
+ const item = event.target.closest(MENU_ITEM_SELECTOR);
7070
+ if (item && isEnabledMenuItem(item))
7071
+ setMenu(null);
7072
+ };
7073
+ const handleContextMenu = (event) => {
7074
+ onContextMenu?.(event);
7075
+ // No other menu over this one: not the browser's, and not a trigger's
7076
+ // that React brings the event up to.
7077
+ event.preventDefault();
7078
+ event.stopPropagation();
7079
+ };
7080
+ const own = trigger.props;
7081
+ const triggerClass = usePrefixedClassNames('context-menu');
7082
+ const clonedTrigger = React.cloneElement(trigger, {
7083
+ className: classNames(own.className, !disabled && triggerClass) || undefined,
7084
+ onContextMenu: chain(own.onContextMenu, handlers.onContextMenu),
7085
+ onPointerDown: chain(own.onPointerDown, handlers.onPointerDown),
7086
+ onPointerMove: chain(own.onPointerMove, handlers.onPointerMove),
7087
+ onPointerUp: chain(own.onPointerUp, handlers.onPointerUp),
7088
+ onPointerCancel: chain(own.onPointerCancel, handlers.onPointerCancel),
7089
+ onTouchEnd: chain(own.onTouchEnd, handlers.onTouchEnd),
7090
+ });
7091
+ const boxClasses = usePrefixedClassNames('context-menu-content', 'dropdown', {
7092
+ 'is-active': true,
7093
+ 'is-up': placed?.up,
7094
+ 'is-right': placed?.right,
7095
+ });
7096
+ const menuClasses = classNames(usePrefixedClassNames('dropdown-menu'), bulmaHelperClasses, className);
7097
+ const contentClasses = usePrefixedClassNames('dropdown-content');
7098
+ 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: {
7099
+ top: placed?.top ?? menu.point.y,
7100
+ left: placed?.left ?? menu.point.x,
7101
+ }, 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 }) }) }) }))] }));
7102
+ };
7103
+ /**
7104
+ * ContextMenu with Item and Divider sub-components, which are `Dropdown.Item`
7105
+ * and `Dropdown.Divider`, so items written for a Dropdown work here
7106
+ * unchanged.
7107
+ */
7108
+ const ContextMenu = withSubComponents(ContextMenuComponent, {
7109
+ Item: DropdownItem,
7110
+ Divider: DropdownDivider,
7111
+ }, 'ContextMenu');
7112
+
6549
7113
  /**
6550
7114
  * Individual Step component for use inside Steps.
6551
7115
  *
@@ -8008,6 +8572,66 @@ const LinkButton = React.forwardRef(function LinkButton({ variant = 'text', colo
8008
8572
  });
8009
8573
  LinkButton.displayName = 'LinkButton';
8010
8574
 
8575
+ /**
8576
+ * The `ToggleButton` component renders a button that stays pressed until it's pressed again, and says whether it's on through `aria-pressed`.
8577
+ *
8578
+ * It's always a `<button type="button">`, so it never submits a form and
8579
+ * carries no form value: a setting saved with a form is a `Checkbox` or a
8580
+ * `Switch`. A set of toggles that each turn on and off on their own goes in
8581
+ * `Buttons hasAddons` with `role="group"` and an `aria-label`. A set where only
8582
+ * one can be on is a radio group rather than a row of these: use `Radios`.
8583
+ *
8584
+ * @function
8585
+ * @param {ToggleButtonProps} props - Props for the ToggleButton component.
8586
+ * @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the `<button>`.
8587
+ * @returns {JSX.Element} The rendered toggle button.
8588
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | APG button pattern}
8589
+ *
8590
+ * @example
8591
+ * <ToggleButton onPressedChange={setMuted}>Mute</ToggleButton>
8592
+ *
8593
+ * @example
8594
+ * <Buttons hasAddons role="group" aria-label="Text style">
8595
+ * <ToggleButton aria-label="Bold" pressed={bold} onPressedChange={setBold}>
8596
+ * <Icon name="bold" />
8597
+ * </ToggleButton>
8598
+ * </Buttons>
8599
+ */
8600
+ const ToggleButton = React.forwardRef(function ToggleButton({ pressed: controlledPressed, defaultPressed = false, onPressedChange, color = 'link', isLoading, 'aria-busy': ariaBusy, 'aria-disabled': ariaDisabled, className, onClick, ...props }, ref) {
8601
+ const isControlled = controlledPressed !== undefined;
8602
+ const [internalPressed, setInternalPressed] = React.useState(defaultPressed);
8603
+ const pressed = controlledPressed ?? internalPressed;
8604
+ // Prefixed like Button's own classes, which a hand-written
8605
+ // `className="is-selected"` could not be under ConfigProvider classPrefix.
8606
+ // Bulma lifts `is-selected` above its neighbours in `.buttons.has-addons`.
8607
+ const selectedClass = usePrefixedClassNames({ 'is-selected': pressed });
8608
+ // The caller's onClick runs first, so a `preventDefault()` there vetoes
8609
+ // the toggle, as it does in Radix, MUI and Zag. Enter and Space reach here
8610
+ // through the button's own click, and a disabled button gets no click.
8611
+ //
8612
+ // A loading one gets the click but drops it. Bulma's spinner only stops a
8613
+ // pointer, so without this a key flipped a toggle the mouse couldn't.
8614
+ // `disabled` would stop both, but it takes focus away mid-save.
8615
+ const handleClick = (event) => {
8616
+ if (isLoading)
8617
+ return;
8618
+ onClick?.(event);
8619
+ if (event.defaultPrevented)
8620
+ return;
8621
+ const next = !pressed;
8622
+ onPressedChange?.(next);
8623
+ if (!isControlled)
8624
+ setInternalPressed(next);
8625
+ };
8626
+ // The props are spread first, so what a toggle depends on wins over
8627
+ // anything a JavaScript caller or a spread object passes along: it stays
8628
+ // a button, never submits, reports the state it's in, and says it's busy
8629
+ // and won't respond while it's loading. A caller's own `aria-busy` or
8630
+ // `aria-disabled` stays when it isn't loading.
8631
+ return (jsxRuntime.jsx(Button, { ...props, ref: ref, as: "button", type: "button", isLoading: isLoading, "aria-busy": isLoading || ariaBusy, "aria-disabled": isLoading || ariaDisabled, "aria-pressed": pressed, color: pressed ? color : undefined, className: classNames(selectedClass, className), onClick: handleClick }));
8632
+ });
8633
+ ToggleButton.displayName = 'ToggleButton';
8634
+
8011
8635
  const validButtonsSizes = ['small', 'medium', 'large'];
8012
8636
  /**
8013
8637
  * The `Buttons` component lets you group multiple `Button` elements together with Bulma's spacing, alignment, and add-on features.
@@ -8038,6 +8662,7 @@ const ButtonsComponent = ({ className, textColor, color, bgColor, isCentered, is
8038
8662
  const Buttons = withSubComponents(ButtonsComponent, {
8039
8663
  Button,
8040
8664
  LinkButton,
8665
+ ToggleButton,
8041
8666
  }, 'Buttons');
8042
8667
 
8043
8668
  /**
@@ -12532,7 +13157,7 @@ const DEFAULT_PICKER_LABELS = {
12532
13157
  hours: 'hours',
12533
13158
  minutes: 'minutes',
12534
13159
  seconds: 'seconds',
12535
- ampm: 'ampm',
13160
+ ampm: 'AM/PM',
12536
13161
  increaseHours: 'Increase hours',
12537
13162
  decreaseHours: 'Decrease hours',
12538
13163
  increaseMinutes: 'Increase minutes',
@@ -12551,11 +13176,19 @@ const DEFAULT_PICKER_LABELS = {
12551
13176
  done: 'Done',
12552
13177
  time: 'Time',
12553
13178
  };
12554
- /** Merge user-supplied label overrides with the defaults. */
12555
- const mergeLabels = (overrides) => ({
12556
- ...DEFAULT_PICKER_LABELS,
12557
- ...(overrides ?? {}),
12558
- });
13179
+ /**
13180
+ * Merge user-supplied label overrides with the defaults. A key set to
13181
+ * `undefined` keeps its default, so a locale table that is missing a string
13182
+ * still names its control instead of leaving it with no accessible name.
13183
+ */
13184
+ const mergeLabels = (overrides) => {
13185
+ const merged = { ...DEFAULT_PICKER_LABELS };
13186
+ for (const [key, value] of Object.entries(overrides ?? {})) {
13187
+ if (value !== undefined)
13188
+ merged[key] = value;
13189
+ }
13190
+ return merged;
13191
+ };
12559
13192
 
12560
13193
  function isSameDay(a, b) {
12561
13194
  return (a.getFullYear() === b.getFullYear() &&
@@ -18477,6 +19110,7 @@ exports.Columns = Columns;
18477
19110
  exports.ConfigProvider = ConfigProvider;
18478
19111
  exports.Container = Container;
18479
19112
  exports.Content = Content;
19113
+ exports.ContextMenu = ContextMenu;
18480
19114
  exports.Control = Control;
18481
19115
  exports.DateInput = DateInput;
18482
19116
  exports.DateInputBase = DateInputBase;
@@ -18607,6 +19241,7 @@ exports.TimeInputBase = TimeInputBase;
18607
19241
  exports.Title = Title;
18608
19242
  exports.Toast = Toast;
18609
19243
  exports.ToastContainer = ToastContainer;
19244
+ exports.ToggleButton = ToggleButton;
18610
19245
  exports.Tooltip = Tooltip;
18611
19246
  exports.Tr = Tr;
18612
19247
  exports.UnorderedList = UnorderedList;