@allxsmith/bestax-bulma 5.13.0 → 5.15.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.
package/dist/index.cjs.js CHANGED
@@ -1776,14 +1776,46 @@ const isBrowser$1 = (win, doc) => typeof win !== 'undefined' && typeof doc !== '
1776
1776
  *
1777
1777
  * @function
1778
1778
  * @param {DropdownProps} props - Props for the Dropdown component.
1779
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the root dropdown element.
1779
1780
  * @returns {JSX.Element} The rendered dropdown.
1780
1781
  * @see {@link https://bulma.io/documentation/components/dropdown/ | Bulma Dropdown documentation}
1781
1782
  */
1782
- const DropdownComponent = ({ label, children, className, menuClassName, active: activeProp, up, right, hoverable, disabled, onActiveChange, closeOnClick = true, id, ...props }) => {
1783
+ const DropdownComponent = React.forwardRef(function DropdownComponent({ label, children, className, menuClassName, active: activeProp, up, right, hoverable, disabled, onActiveChange, closeOnClick = true, id, ...props }, ref) {
1783
1784
  const [active, setActive] = React.useState(!!activeProp);
1784
1785
  const dropdownRef = React.useRef(null);
1785
1786
  const triggerRef = React.useRef(null);
1786
1787
  const pendingFocusRef = React.useRef(null);
1788
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
1789
+ const consumerCleanupRef = React.useRef(null);
1790
+ const setRefs = React.useCallback((node) => {
1791
+ dropdownRef.current =
1792
+ node;
1793
+ if (typeof ref === 'function') {
1794
+ // React 19 lets a callback ref return a cleanup function and detaches
1795
+ // by running it instead of calling the ref with `null`; React 18
1796
+ // discards the return value entirely. Returning it from here would be
1797
+ // a React-19-only contract, so instead we hold the cleanup and run it
1798
+ // ourselves on detach — a consumer's cleanup ref then behaves the same
1799
+ // on both majors of the CI matrix.
1800
+ if (node === null) {
1801
+ const consumerCleanup = consumerCleanupRef.current;
1802
+ consumerCleanupRef.current = null;
1803
+ if (consumerCleanup) {
1804
+ consumerCleanup();
1805
+ }
1806
+ else {
1807
+ ref(null);
1808
+ }
1809
+ return;
1810
+ }
1811
+ const cleanup = ref(node);
1812
+ consumerCleanupRef.current =
1813
+ typeof cleanup === 'function' ? cleanup : null;
1814
+ }
1815
+ else if (ref) {
1816
+ ref.current = node;
1817
+ }
1818
+ }, [ref]);
1787
1819
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
1788
1820
  // Generate Bulma classes with prefix
1789
1821
  const bulmaClasses = usePrefixedClassNames('dropdown', {
@@ -1951,8 +1983,8 @@ const DropdownComponent = ({ label, children, className, menuClassName, active:
1951
1983
  }
1952
1984
  };
1953
1985
  const dropdownClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
1954
- return (jsxRuntime.jsxs("div", { className: dropdownClasses, ref: dropdownRef, 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, children: jsxRuntime.jsx("div", { className: usePrefixedClassNames('dropdown-content'), onClick: handleMenuClick, tabIndex: -1, children: children }) })] }));
1955
- };
1986
+ 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, children: jsxRuntime.jsx("div", { className: usePrefixedClassNames('dropdown-content'), onClick: handleMenuClick, tabIndex: -1, children: children }) })] }));
1987
+ });
1956
1988
  /**
1957
1989
  * Bulma Dropdown item.
1958
1990
  *
@@ -2108,6 +2140,105 @@ const Message = withSubComponents(MessageComponent, {
2108
2140
  Body: MessageBody,
2109
2141
  }, 'Message');
2110
2142
 
2143
+ // Ref-counted across every caller (e.g. a Dialog rendering its own Modal)
2144
+ // so overlapping/nested locks don't fight over which one restores `overflow`.
2145
+ // Every overlay that locks body scroll — Modal, Dialog, Sidebar, Loading —
2146
+ // goes through here; a component setting `document.body.style.overflow`
2147
+ // itself would clear a lock another overlay still needs.
2148
+ let lockCount = 0;
2149
+ let originalOverflow = '';
2150
+ /**
2151
+ * Locks (and ref-counted-ly unlocks) `document.body` scrolling while `active`
2152
+ * is true. Safe to call from multiple components at once — the body scroll
2153
+ * is only restored once every active caller has released its lock.
2154
+ *
2155
+ * @function useScrollLock
2156
+ * @param active - Whether this caller wants the body scroll locked.
2157
+ */
2158
+ function useScrollLock(active) {
2159
+ React.useEffect(() => {
2160
+ if (!active)
2161
+ return undefined;
2162
+ lockCount++;
2163
+ if (lockCount === 1) {
2164
+ originalOverflow = document.body.style.overflow;
2165
+ document.body.style.overflow = 'hidden';
2166
+ }
2167
+ return () => {
2168
+ lockCount--;
2169
+ if (lockCount === 0) {
2170
+ document.body.style.overflow = originalOverflow;
2171
+ }
2172
+ };
2173
+ }, [active]);
2174
+ }
2175
+
2176
+ /**
2177
+ * Resolves a portal target: an `HTMLElement` is used directly, a non-empty
2178
+ * `string` is treated as a `document.querySelector` selector (falling back to
2179
+ * `document.body` when it matches nothing), and any falsy value — `undefined`
2180
+ * or the empty string — resolves to `document.body`.
2181
+ *
2182
+ * The empty string is deliberately treated as "no target" rather than passed
2183
+ * through: `document.querySelector('')` throws a `SyntaxError`, and callers
2184
+ * building a selector from state can easily hand us `''`.
2185
+ *
2186
+ * @function resolvePortalContainer
2187
+ * @param container - The requested portal target, if any.
2188
+ * @returns The resolved DOM node to portal into.
2189
+ */
2190
+ function resolvePortalContainer(container) {
2191
+ if (!container) {
2192
+ return document.body;
2193
+ }
2194
+ if (typeof container === 'string') {
2195
+ return (document.querySelector(container) ?? document.body);
2196
+ }
2197
+ return container;
2198
+ }
2199
+
2200
+ // "Are we past hydration?" as a store that never changes: React reads the
2201
+ // server snapshot both while server-rendering and while hydrating, and the
2202
+ // client snapshot from the first post-hydration render onward.
2203
+ const subscribeToNothing = () => () => { };
2204
+ const getClientSnapshot = () => true;
2205
+ const getServerSnapshot = () => false;
2206
+ /**
2207
+ * Reports whether the component has passed hydration.
2208
+ *
2209
+ * Returns `false` during server rendering and during the hydrating client
2210
+ * render, then `true` from the commit that follows. Use it to defer
2211
+ * DOM-only behaviour (such as portalling) past hydration so the first client
2212
+ * render matches the server markup instead of tripping hydration recovery.
2213
+ *
2214
+ * @function useIsHydrated
2215
+ * @returns `true` once the hydrating render has completed, otherwise `false`.
2216
+ */
2217
+ function useIsHydrated() {
2218
+ return React.useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot);
2219
+ }
2220
+
2221
+ /**
2222
+ * Every currently active modal, in the order they opened. Only the last entry
2223
+ * — the topmost modal — reacts to Escape and Tab, so one keypress can't
2224
+ * dismiss a whole stack (e.g. a `Dialog` opened on top of a `Modal`).
2225
+ */
2226
+ const activeModalStack = [];
2227
+ /**
2228
+ * Controls the modal can hand focus to. Disabled and hidden controls are
2229
+ * excluded because `focus()` on them is a no-op, which would leave focus
2230
+ * outside the modal.
2231
+ */
2232
+ const FOCUSABLE_SELECTOR$1 = 'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]';
2233
+ /**
2234
+ * The modal's tab stops, in document order. The selector alone is not the
2235
+ * tabbable set — `[href]` and `button` match regardless of `tabindex`, so an
2236
+ * `<a href tabIndex={-1}>` would otherwise be treated as a tab stop and let
2237
+ * Tab escape the modal when it sorts last. `el.tabIndex` is the browser's own
2238
+ * resolved value, so filtering on it drops every negative index (not just
2239
+ * `-1`) without a second selector to keep in sync.
2240
+ */
2241
+ const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR$1)).filter(el => el.tabIndex >= 0);
2111
2242
  /**
2112
2243
  * Modal.Background - Renders the modal background overlay.
2113
2244
  *
@@ -2238,7 +2369,7 @@ const ModalClose = ({ className, size = 'large', variant = 'delete', ...props })
2238
2369
  *
2239
2370
  * @see {@link https://bulma.io/documentation/components/modal/ | Bulma Modal documentation}
2240
2371
  */
2241
- const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, modalCardTitle, modalCardFoot, type, children, ...props }) => {
2372
+ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClose, className, textColor, bgColor, modalCardTitle, modalCardFoot, type, children, closeOnEscape = true, lockScroll = true, portal = false, role, 'aria-modal': ariaModalProp, ...props }, ref) {
2242
2373
  const { classPrefix } = useConfig();
2243
2374
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2244
2375
  color: textColor,
@@ -2247,6 +2378,133 @@ const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, m
2247
2378
  });
2248
2379
  // Support both active and isActive props
2249
2380
  const isModalActive = active ?? isActive ?? false;
2381
+ const modalRootRef = React.useRef(null);
2382
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
2383
+ const consumerCleanupRef = React.useRef(null);
2384
+ // The forwarded ref and the internal one must both see the node: focus
2385
+ // management, the scroll lock and the topmost-modal check all read
2386
+ // `modalRootRef`, while consumers expect their own ref to resolve.
2387
+ const combinedRef = React.useCallback((node) => {
2388
+ modalRootRef.current =
2389
+ node;
2390
+ if (typeof ref === 'function') {
2391
+ // React 19 lets a callback ref return a cleanup function and detaches
2392
+ // by running it instead of calling the ref with `null`; React 18
2393
+ // discards the return value entirely. Returning it from here would be
2394
+ // a React-19-only contract, so instead we hold the cleanup and run it
2395
+ // ourselves on detach — a consumer's cleanup ref then behaves the same
2396
+ // on both majors of the CI matrix. Same shape as `Dropdown`.
2397
+ if (node === null) {
2398
+ const consumerCleanup = consumerCleanupRef.current;
2399
+ consumerCleanupRef.current = null;
2400
+ if (consumerCleanup) {
2401
+ consumerCleanup();
2402
+ }
2403
+ else {
2404
+ ref(null);
2405
+ }
2406
+ return;
2407
+ }
2408
+ const cleanup = ref(node);
2409
+ consumerCleanupRef.current =
2410
+ typeof cleanup === 'function' ? cleanup : null;
2411
+ }
2412
+ else if (ref) {
2413
+ ref.current = node;
2414
+ }
2415
+ }, [ref]);
2416
+ const previouslyFocusedRef = React.useRef(null);
2417
+ const stackTokenRef = React.useRef({});
2418
+ const generatedTitleId = React.useId();
2419
+ // A portal has no server-rendered counterpart, so the server render and the
2420
+ // hydrating render have to stay inline and only move into the portal once
2421
+ // we're past hydration.
2422
+ const onClient = useIsHydrated();
2423
+ // Moving into the portal remounts the modal's subtree, so any effect holding
2424
+ // a DOM node from before the move is holding a detached one. Effects that
2425
+ // touch the modal's nodes key on this so they re-run against the new tree.
2426
+ const isPortaled = Boolean(portal) && onClient;
2427
+ useScrollLock(isModalActive && lockScroll);
2428
+ // Record open order so keyboard handling only applies to the topmost modal.
2429
+ React.useEffect(() => {
2430
+ if (!isModalActive)
2431
+ return undefined;
2432
+ const token = stackTokenRef.current;
2433
+ activeModalStack.push(token);
2434
+ return () => {
2435
+ const index = activeModalStack.indexOf(token);
2436
+ if (index !== -1)
2437
+ activeModalStack.splice(index, 1);
2438
+ };
2439
+ }, [isModalActive]);
2440
+ // Escape closes; Tab cycles inside the modal. Both only for the topmost one.
2441
+ React.useEffect(() => {
2442
+ if (!isModalActive)
2443
+ return undefined;
2444
+ const handleKeyDown = (e) => {
2445
+ const node = modalRootRef.current;
2446
+ if (activeModalStack[activeModalStack.length - 1] !== stackTokenRef.current) {
2447
+ return;
2448
+ }
2449
+ if (e.key === 'Escape') {
2450
+ if (closeOnEscape)
2451
+ onClose?.();
2452
+ return;
2453
+ }
2454
+ if (e.key !== 'Tab' || !node)
2455
+ return;
2456
+ // Keep Tab within the modal — `aria-modal` hides the rest of the page
2457
+ // from assistive technology, so the keyboard order has to agree.
2458
+ const focusable = getTabbable(node);
2459
+ const activeElement = document.activeElement;
2460
+ if (focusable.length === 0) {
2461
+ e.preventDefault();
2462
+ node.focus();
2463
+ return;
2464
+ }
2465
+ const first = focusable[0];
2466
+ const last = focusable[focusable.length - 1];
2467
+ const outside = !node.contains(activeElement);
2468
+ if (e.shiftKey) {
2469
+ if (outside || activeElement === first) {
2470
+ e.preventDefault();
2471
+ last.focus();
2472
+ }
2473
+ }
2474
+ else if (outside || activeElement === last) {
2475
+ e.preventDefault();
2476
+ first.focus();
2477
+ }
2478
+ };
2479
+ document.addEventListener('keydown', handleKeyDown);
2480
+ return () => document.removeEventListener('keydown', handleKeyDown);
2481
+ }, [isModalActive, closeOnEscape, onClose]);
2482
+ // Move focus into the modal on open, restore it on close
2483
+ React.useEffect(() => {
2484
+ if (!isModalActive)
2485
+ return undefined;
2486
+ previouslyFocusedRef.current = document.activeElement;
2487
+ const node = modalRootRef.current;
2488
+ const focusable = node ? getTabbable(node)[0] : undefined;
2489
+ (focusable ?? node)?.focus();
2490
+ return () => {
2491
+ // Only hand focus back if this modal still owns it: closing a background
2492
+ // modal must not pull focus out of one that is still open on top. A
2493
+ // removed subtree leaves focus on <body>, which still counts as ours.
2494
+ const activeElement = document.activeElement;
2495
+ if (activeElement &&
2496
+ activeElement !== document.body &&
2497
+ !node?.contains(activeElement)) {
2498
+ return;
2499
+ }
2500
+ previouslyFocusedRef.current?.focus?.();
2501
+ };
2502
+ // `isPortaled` flips once when a hydrated portal modal moves out of the
2503
+ // inline tree; re-running rebinds focus onto the remounted nodes. The
2504
+ // cleanup above restores focus to the pre-open element first (the detached
2505
+ // subtree has left focus on <body>), so the re-run re-records the same
2506
+ // element and restore-on-close still lands in the right place.
2507
+ }, [isModalActive, isPortaled]);
2250
2508
  // Check if children contain compound components
2251
2509
  const hasCompoundComponents = React.Children.toArray(children).some(child => React.isValidElement(child) &&
2252
2510
  (child.type === ModalBackground ||
@@ -2259,20 +2517,35 @@ const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, m
2259
2517
  });
2260
2518
  const deleteClass = usePrefixedClassNames('delete');
2261
2519
  const modalClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
2520
+ const resolvedRole = role ?? (isModalActive ? 'dialog' : undefined);
2521
+ const resolvedAriaModal = ariaModalProp ??
2522
+ (isModalActive && resolvedRole !== 'presentation' ? 'true' : undefined);
2523
+ // A dialog needs an accessible name: the legacy card title is wired up
2524
+ // automatically, compound users pass their own aria-label/aria-labelledby
2525
+ // (both arrive in `rest`, which is spread last and so wins).
2526
+ const titleId = modalCardTitle ? generatedTitleId : undefined;
2527
+ let modalElement;
2262
2528
  // If using compound components, render children as-is
2263
2529
  if (hasCompoundComponents) {
2264
- return (jsxRuntime.jsx("div", { className: modalClasses, ...rest, "data-testid": "modal", children: children }));
2265
- }
2266
- // Legacy API: EXPLICIT type wins; fallback to auto detection if not provided
2267
- let isModalCard;
2268
- if (type === 'card')
2269
- isModalCard = true;
2270
- else if (type === 'content')
2271
- isModalCard = false;
2272
- else
2273
- isModalCard = !!modalCardTitle || !!modalCardFoot;
2274
- return (jsxRuntime.jsxs("div", { className: modalClasses, ...rest, "data-testid": "modal", children: [jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxRuntime.jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxRuntime.jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsxRuntime.jsx("p", { className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsxRuntime.jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsxRuntime.jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsxRuntime.jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsxRuntime.jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
2275
- };
2530
+ modalElement = (jsxRuntime.jsx("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, tabIndex: -1, ...rest, "data-testid": "modal", children: children }));
2531
+ }
2532
+ else {
2533
+ // Legacy API: EXPLICIT type wins; fallback to auto detection if not provided
2534
+ let isModalCard;
2535
+ if (type === 'card')
2536
+ isModalCard = true;
2537
+ else if (type === 'content')
2538
+ isModalCard = false;
2539
+ else
2540
+ isModalCard = !!modalCardTitle || !!modalCardFoot;
2541
+ modalElement = (jsxRuntime.jsxs("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, "aria-labelledby": titleId, tabIndex: -1, ...rest, "data-testid": "modal", children: [jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxRuntime.jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxRuntime.jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsxRuntime.jsx("p", { id: titleId, className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsxRuntime.jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsxRuntime.jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsxRuntime.jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsxRuntime.jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
2542
+ }
2543
+ if (isPortaled) {
2544
+ const target = resolvePortalContainer(typeof portal === 'boolean' ? undefined : portal);
2545
+ return reactDom.createPortal(modalElement, target);
2546
+ }
2547
+ return modalElement;
2548
+ });
2276
2549
  const Modal = withSubComponents(ModalRoot, {
2277
2550
  Background: ModalBackground,
2278
2551
  Content: ModalContent,
@@ -2286,10 +2559,11 @@ const NavbarDropdownContext = React.createContext(null);
2286
2559
  *
2287
2560
  * @function
2288
2561
  * @param {NavbarProps} props - Props for the Navbar component.
2562
+ * @param {React.Ref<HTMLElement>} ref - Forwarded ref to the root `<nav>` element.
2289
2563
  * @returns {JSX.Element} The rendered navbar.
2290
2564
  * @see {@link https://bulma.io/documentation/components/navbar/ | Bulma Navbar documentation}
2291
2565
  */
2292
- const NavbarComponent = ({ className, textColor, bgColor, color, transparent, fixed, children, ...props }) => {
2566
+ const NavbarComponent = React.forwardRef(function NavbarComponent({ className, textColor, bgColor, color, transparent, fixed, children, ...props }, ref) {
2293
2567
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2294
2568
  color: textColor,
2295
2569
  backgroundColor: bgColor,
@@ -2302,8 +2576,8 @@ const NavbarComponent = ({ className, textColor, bgColor, color, transparent, fi
2302
2576
  [`is-fixed-${fixed}`]: fixed,
2303
2577
  });
2304
2578
  const navbarClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
2305
- return (jsxRuntime.jsx("nav", { className: navbarClasses, role: "navigation", "aria-label": "main navigation", ...rest, children: children }));
2306
- };
2579
+ return (jsxRuntime.jsx("nav", { ref: ref, className: navbarClasses, role: "navigation", "aria-label": "main navigation", ...rest, children: children }));
2580
+ });
2307
2581
  /**
2308
2582
  * For logo and branding (left side)
2309
2583
  *
@@ -2340,16 +2614,18 @@ const NavbarItem = ({ className, as: Component = 'a', active, textColor, bgColor
2340
2614
  *
2341
2615
  * @function
2342
2616
  * @param {NavbarBurgerProps} props - Props for the NavbarBurger component.
2617
+ * @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the burger button element.
2343
2618
  * @returns {JSX.Element} The rendered burger.
2344
2619
  */
2345
- const NavbarBurger = ({ className, active, children, ...props }) => {
2620
+ const NavbarBurger = React.forwardRef(function NavbarBurger({ className, active, children, ...props }, ref) {
2346
2621
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2347
2622
  ...props,
2348
2623
  });
2349
- return (jsxRuntime.jsxs("button", { type: "button", className: classNames(usePrefixedClassNames('navbar-burger', {
2624
+ return (jsxRuntime.jsxs("button", { ref: ref, type: "button", className: classNames(usePrefixedClassNames('navbar-burger', {
2350
2625
  'is-active': active,
2351
2626
  }), bulmaHelperClasses, className), "aria-label": props['aria-label'] || 'menu', "aria-expanded": props['aria-expanded'] ?? !!active, ...rest, children: [jsxRuntime.jsx("span", { "aria-hidden": "true" }), jsxRuntime.jsx("span", { "aria-hidden": "true" }), jsxRuntime.jsx("span", { "aria-hidden": "true" }), children] }));
2352
- };
2627
+ });
2628
+ NavbarBurger.displayName = 'NavbarBurger';
2353
2629
  /**
2354
2630
  * Collapsible content (contains `Navbar.Start` and `Navbar.End`)
2355
2631
  *
@@ -2396,9 +2672,10 @@ const NavbarEnd = ({ className, children, ...props }) => {
2396
2672
  *
2397
2673
  * @function
2398
2674
  * @param {NavbarLinkProps} props - Props for the NavbarLink component.
2675
+ * @param {React.Ref<HTMLAnchorElement | HTMLButtonElement>} ref - Forwarded ref to the rendered link or button element.
2399
2676
  * @returns {JSX.Element} The rendered navbar link.
2400
2677
  */
2401
- const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgColor, children, ...props }) => {
2678
+ const NavbarLink = React.forwardRef(function NavbarLink({ className, as: Component = 'a', arrowless, textColor, bgColor, children, ...props }, ref) {
2402
2679
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2403
2680
  color: textColor,
2404
2681
  backgroundColor: bgColor,
@@ -2431,7 +2708,7 @@ const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgCo
2431
2708
  return;
2432
2709
  dropdownContext.toggle();
2433
2710
  };
2434
- return (jsxRuntime.jsx(Component, { className: classNames(usePrefixedClassNames('navbar-link', {
2711
+ return (jsxRuntime.jsx(Component, { ref: ref, className: classNames(usePrefixedClassNames('navbar-link', {
2435
2712
  'is-arrowless': arrowless,
2436
2713
  }), bulmaHelperClasses, className), ...rest, ...(dropdownContext && {
2437
2714
  'aria-haspopup': 'true',
@@ -2443,15 +2720,17 @@ const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgCo
2443
2720
  onClick: handleClick,
2444
2721
  }),
2445
2722
  }), children: children }));
2446
- };
2723
+ });
2724
+ NavbarLink.displayName = 'NavbarLink';
2447
2725
  /**
2448
2726
  * Dropdown parent (with options for hover, up, right, active)
2449
2727
  *
2450
2728
  * @function
2451
2729
  * @param {NavbarDropdownProps} props - Props for the NavbarDropdown component.
2730
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the dropdown container element.
2452
2731
  * @returns {JSX.Element} The rendered dropdown.
2453
2732
  */
2454
- const NavbarDropdown = ({ className, right, up, hoverable, active: activeProp, onActiveChange, children, ...props }) => {
2733
+ const NavbarDropdown = React.forwardRef(function NavbarDropdown({ className, right, up, hoverable, active: activeProp, onActiveChange, children, ...props }, ref) {
2455
2734
  const [active, setActive] = React.useState(!!activeProp);
2456
2735
  React.useEffect(() => {
2457
2736
  // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing to controlled prop
@@ -2472,13 +2751,14 @@ const NavbarDropdown = ({ className, right, up, hoverable, active: activeProp, o
2472
2751
  setActive(false);
2473
2752
  onActiveChange?.(false);
2474
2753
  };
2475
- return (jsxRuntime.jsx(NavbarDropdownContext.Provider, { value: { active, toggle, close }, children: jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('navbar-item', 'has-dropdown', {
2754
+ return (jsxRuntime.jsx(NavbarDropdownContext.Provider, { value: { active, toggle, close }, children: jsxRuntime.jsx("div", { ref: ref, className: classNames(usePrefixedClassNames('navbar-item', 'has-dropdown', {
2476
2755
  'has-dropdown-up': up,
2477
2756
  'is-right': right,
2478
2757
  'is-hoverable': hoverable,
2479
2758
  'is-active': active,
2480
2759
  }), className), ...props, children: children }) }));
2481
- };
2760
+ });
2761
+ NavbarDropdown.displayName = 'NavbarDropdown';
2482
2762
  /**
2483
2763
  * Dropdown menu container
2484
2764
  *
@@ -2788,8 +3068,37 @@ function getIconClasses(library, name, variant, features) {
2788
3068
  const Icon = ({ className, textColor, bgColor, name, library, variant, features, libraryFeatures, // Deprecated but maintained for backward compatibility
2789
3069
  size, ariaLabel = 'icon', style, icon, // Capture and exclude the deprecated 'icon' prop from DOM
2790
3070
  color: _color, // Exclude 'color' prop if passed directly
2791
- containerClassName, ...restProps }) => {
2792
- // Handle deprecated 'icon' prop - parse it to extract the actual name
3071
+ containerClassName, children, ...restProps }) => {
3072
+ // Get the default icon library from context, fallback to 'fa' if not set
3073
+ const defaultLibrary = useIconLibrary();
3074
+ /**
3075
+ * Generates Bulma helper classes and separates out remaining props.
3076
+ * Note: variant, features, and libraryFeatures are excluded from props spread
3077
+ */
3078
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
3079
+ color: textColor,
3080
+ backgroundColor: bgColor,
3081
+ ...restProps,
3082
+ });
3083
+ // Hoisted unconditionally to respect rules-of-hooks; the branches below
3084
+ // pick which result to consume.
3085
+ const defaultIconClasses = usePrefixedClassNames('icon', {
3086
+ [`is-${size}`]: size,
3087
+ });
3088
+ const sizeModifierClass = usePrefixedClassNames(size ? `is-${size}` : undefined);
3089
+ const bulmaClasses = containerClassName
3090
+ ? containerClassName
3091
+ : defaultIconClasses;
3092
+ const iconContainerClasses = classNames(bulmaClasses, containerClassName && size ? sizeModifierClass : undefined, bulmaHelperClasses, className);
3093
+ if (children !== undefined) {
3094
+ // `IconChildrenProps`: render the caller's node (an inline SVG, a `react-icons`
3095
+ // component, …) in place of a class-based glyph. Library/variant/features don't apply.
3096
+ return (jsxRuntime.jsx("span", { className: iconContainerClasses, "aria-label": ariaLabel, style: style, ...rest, children: children }));
3097
+ }
3098
+ // `name` is guaranteed once `children` is absent (`IconProps` is a discriminated union of
3099
+ // the two, and `IconChildrenProps['children']` excludes `undefined` so `children={undefined}`
3100
+ // can't slip past into this branch) — the cast only matters for legacy callers that bypass
3101
+ // the type and rely solely on the deprecated `icon` prop below.
2793
3102
  let finalName = name;
2794
3103
  if (!name && icon) {
2795
3104
  // If icon prop is provided instead of name, try to parse it
@@ -2808,31 +3117,16 @@ containerClassName, ...restProps }) => {
2808
3117
  }
2809
3118
  }
2810
3119
  }
2811
- // Get the default icon library from context, fallback to 'fa' if not set
2812
- const defaultLibrary = useIconLibrary();
2813
3120
  const finalLibrary = library || defaultLibrary || 'fa';
2814
3121
  // Normalize a redundant leading library prefix (e.g. "fa-check" -> "check" when the
2815
3122
  // library is 'fa'), so `name` behaves identically with or without the prefix.
2816
3123
  finalName = stripRedundantLibraryPrefix(finalName, finalLibrary);
2817
- /**
2818
- * Generates Bulma helper classes and separates out remaining props.
2819
- * Note: variant, features, and libraryFeatures are excluded from props spread
2820
- */
2821
- const { bulmaHelperClasses, rest } = useBulmaClasses({
2822
- color: textColor,
2823
- backgroundColor: bgColor,
2824
- ...restProps,
2825
- });
2826
- // Hoisted unconditionally to respect rules-of-hooks; the ternaries below
2827
- // pick which result to consume.
2828
- const defaultIconClasses = usePrefixedClassNames('icon', {
2829
- [`is-${size}`]: size,
2830
- });
2831
- const sizeModifierClass = usePrefixedClassNames(size ? `is-${size}` : undefined);
2832
- const bulmaClasses = containerClassName
2833
- ? containerClassName
2834
- : defaultIconClasses;
2835
- const iconContainerClasses = classNames(bulmaClasses, containerClassName && size ? sizeModifierClass : undefined, bulmaHelperClasses, className);
3124
+ if (!finalName) {
3125
+ // No glyph to name. Unreachable through the public type, but a plain-JS caller (or one
3126
+ // that casts) can land here, and building a class off an absent name produced a bogus
3127
+ // `fa-undefined` glyph. Render the bare container instead, matching the `children` branch.
3128
+ return (jsxRuntime.jsx("span", { className: iconContainerClasses, "aria-label": ariaLabel, style: style, ...rest }));
3129
+ }
2836
3130
  // Backward compatibility: if libraryFeatures is provided, parse it for variant and features
2837
3131
  let finalVariant = variant;
2838
3132
  let finalFeatures = features;
@@ -2951,8 +3245,9 @@ const PanelTabs = ({ className, children, ...props }) => (jsxRuntime.jsx("p", {
2951
3245
  */
2952
3246
  const PanelBlock = ({ className, active, children, ...props }) => (jsxRuntime.jsx("a", { className: classNames(usePrefixedClassNames('panel-block', { 'is-active': active }), className), ...props, children: children }));
2953
3247
  /**
2954
- * Icon wrapper with panel styling (renders as `<span class="panel-icon"><i/></span>`).
2955
- * Accepts all Icon props (`name`, `variant`, `features`, etc.)
3248
+ * Icon wrapper with panel styling (renders as `<span class="panel-icon">`, containing an
3249
+ * `<i/>` when a `name` is given, or the custom node passed as `children`).
3250
+ * Accepts all Icon props (`name`, `variant`, `features`, etc.), or `children` for a custom node.
2956
3251
  *
2957
3252
  * @function
2958
3253
  * @param {PanelIconProps} props - Props for the PanelIcon component.
@@ -3233,16 +3528,10 @@ const Loading = ({ active = false, isFullPage = false, size, color, canCancel =
3233
3528
  document.addEventListener('keydown', handleKeyDown);
3234
3529
  return () => document.removeEventListener('keydown', handleKeyDown);
3235
3530
  }, [active, canCancel, onCancel]);
3236
- // Prevent body scroll when full page loading is active
3237
- React.useEffect(() => {
3238
- if (isFullPage && active) {
3239
- document.body.style.overflow = 'hidden';
3240
- return () => {
3241
- document.body.style.overflow = '';
3242
- };
3243
- }
3244
- return undefined;
3245
- }, [isFullPage, active]);
3531
+ // Prevent body scroll when full page loading is active. Ref-counted through
3532
+ // the shared helper so an overlapping Modal/Dialog/Sidebar doesn't have its
3533
+ // lock cleared when this one releases.
3534
+ useScrollLock(isFullPage && active);
3246
3535
  if (!active) {
3247
3536
  return null;
3248
3537
  }
@@ -3733,6 +4022,8 @@ const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
3733
4022
  const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
3734
4023
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
3735
4024
  const sidebarRef = React.useRef(null);
4025
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4026
+ const consumerCleanupRef = React.useRef(null);
3736
4027
  const resolvedFullwidth = isFullwidth ?? fullWidth ?? false;
3737
4028
  // Close handler
3738
4029
  const handleClose = React.useCallback(() => {
@@ -3758,17 +4049,9 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
3758
4049
  document.addEventListener('keydown', handleKeyDown);
3759
4050
  return () => document.removeEventListener('keydown', handleKeyDown);
3760
4051
  }, [isOpen, escapeClose, handleClose]);
3761
- // Prevent body scroll when sidebar is open
3762
- React.useEffect(() => {
3763
- if (isOpen && overlay) {
3764
- const originalOverflow = document.body.style.overflow;
3765
- document.body.style.overflow = 'hidden';
3766
- return () => {
3767
- document.body.style.overflow = originalOverflow;
3768
- };
3769
- }
3770
- return undefined;
3771
- }, [isOpen, overlay]);
4052
+ // Prevent body scroll when sidebar is open. Ref-counted through the shared
4053
+ // helper so an overlapping Modal/Dialog/Loading doesn't unlock underneath.
4054
+ useScrollLock(isOpen && overlay);
3772
4055
  // Focus trap (basic - focus sidebar when opened)
3773
4056
  React.useEffect(() => {
3774
4057
  if (isOpen) {
@@ -3790,7 +4073,28 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
3790
4073
  sidebarRef.current =
3791
4074
  node;
3792
4075
  if (typeof ref === 'function') {
3793
- ref(node);
4076
+ // React 19 lets a callback ref return a cleanup function and detaches by
4077
+ // running it instead of calling the ref with `null`; React 18 discards the
4078
+ // return value entirely. Returning it from here would be a React-19-only
4079
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4080
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Toast` and `Carousel`.
4081
+ // The form controls that merge a forwarded ref still discard the cleanup
4082
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4083
+ // `typeof ref === 'function'` for the current split.
4084
+ if (node === null) {
4085
+ const consumerCleanup = consumerCleanupRef.current;
4086
+ consumerCleanupRef.current = null;
4087
+ if (consumerCleanup) {
4088
+ consumerCleanup();
4089
+ }
4090
+ else {
4091
+ ref(null);
4092
+ }
4093
+ return;
4094
+ }
4095
+ const cleanup = ref(node);
4096
+ consumerCleanupRef.current =
4097
+ typeof cleanup === 'function' ? cleanup : null;
3794
4098
  }
3795
4099
  else if (ref) {
3796
4100
  ref.current = node;
@@ -3907,6 +4211,8 @@ const Toast = React.forwardRef(({ message, type = 'default', actionType, positio
3907
4211
  const [isVisible, setIsVisible] = React.useState(true);
3908
4212
  const [isPaused, setIsPaused] = React.useState(false);
3909
4213
  const toastRef = React.useRef(null);
4214
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4215
+ const consumerCleanupRef = React.useRef(null);
3910
4216
  const handleClose = React.useCallback(() => {
3911
4217
  setIsVisible(false);
3912
4218
  onClose?.();
@@ -3977,27 +4283,41 @@ const Toast = React.forwardRef(({ message, type = 'default', actionType, positio
3977
4283
  const toastActionClass = usePrefixedClassNames('toast-action');
3978
4284
  const toastButtonClass = usePrefixedClassNames('button');
3979
4285
  const toastCloseClass = usePrefixedClassNames('delete', 'is-small');
3980
- if (!isVisible) {
3981
- return null;
3982
- }
3983
- const resolveContainer = () => {
3984
- if (container) {
3985
- if (typeof container === 'string') {
3986
- return (document.querySelector(container) || document.body);
3987
- }
3988
- return container;
3989
- }
3990
- return document.body;
3991
- };
3992
- const setRef = (node) => {
4286
+ const setRef = React.useCallback((node) => {
3993
4287
  toastRef.current = node;
3994
4288
  if (typeof ref === 'function') {
3995
- ref(node);
4289
+ // React 19 lets a callback ref return a cleanup function and detaches by
4290
+ // running it instead of calling the ref with `null`; React 18 discards the
4291
+ // return value entirely. Returning it from here would be a React-19-only
4292
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4293
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Sidebar` and `Carousel`.
4294
+ // The form controls that merge a forwarded ref still discard the cleanup
4295
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4296
+ // `typeof ref === 'function'` for the current split. Memoized because an
4297
+ // unstable ref identity makes React detach and re-attach on every render,
4298
+ // which would run that cleanup each time.
4299
+ if (node === null) {
4300
+ const consumerCleanup = consumerCleanupRef.current;
4301
+ consumerCleanupRef.current = null;
4302
+ if (consumerCleanup) {
4303
+ consumerCleanup();
4304
+ }
4305
+ else {
4306
+ ref(null);
4307
+ }
4308
+ return;
4309
+ }
4310
+ const cleanup = ref(node);
4311
+ consumerCleanupRef.current =
4312
+ typeof cleanup === 'function' ? cleanup : null;
3996
4313
  }
3997
4314
  else if (ref) {
3998
4315
  ref.current = node;
3999
4316
  }
4000
- };
4317
+ }, [ref]);
4318
+ if (!isVisible) {
4319
+ return null;
4320
+ }
4001
4321
  const toastElement = (jsxRuntime.jsxs("div", { ref: setRef, className: combinedClasses, role: "alert", "aria-live": type === 'danger' || type === 'warning' ? 'assertive' : 'polite', onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onClick: dismissible ? handleClose : undefined, ...rest, children: [jsxRuntime.jsx("span", { className: toastMessageClass, children: message }), (cancelText || actionText) && (jsxRuntime.jsxs("div", { className: toastActionsClass, children: [cancelText && (jsxRuntime.jsx("span", { className: toastCancelClass, children: jsxRuntime.jsx("button", { type: "button", className: toastButtonClass, onClick: e => {
4002
4322
  e.stopPropagation();
4003
4323
  handleClose();
@@ -4013,7 +4333,7 @@ const Toast = React.forwardRef(({ message, type = 'default', actionType, positio
4013
4333
  }
4014
4334
  const toastContent = jsxRuntime.jsx("div", { className: containerClasses, children: toastElement });
4015
4335
  if (typeof document !== 'undefined') {
4016
- return reactDom.createPortal(toastContent, resolveContainer());
4336
+ return reactDom.createPortal(toastContent, resolvePortalContainer(container));
4017
4337
  }
4018
4338
  return null;
4019
4339
  });
@@ -4153,9 +4473,6 @@ const ToastContainer = ({ position = 'top-right', }) => {
4153
4473
  }) }), document.body);
4154
4474
  };
4155
4475
 
4156
- // Ref-counted body scroll lock for chained/overlapping dialogs
4157
- let _scrollLockCount = 0;
4158
- let _originalOverflow = '';
4159
4476
  /**
4160
4477
  * The `Dialog` component provides ready-made confirm and alert dialogs, so a destructive action stays one `await dialog.confirm()` call away.
4161
4478
  *
@@ -4187,9 +4504,11 @@ let _originalOverflow = '';
4187
4504
  * onCancel={() => setShowConfirm(false)}
4188
4505
  * />
4189
4506
  */
4190
- const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', confirmText = 'OK', cancelText = 'Cancel', onConfirm, onCancel, showCancel = true, canCancel = true, focusCancel = false, icon, className, ...props }, ref) => {
4507
+ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', confirmText = 'OK', cancelText = 'Cancel', onConfirm, onCancel, showCancel = true, canCancel = true, focusCancel = false, icon, portal, className, ...props }, ref) => {
4191
4508
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4192
4509
  const dialogRef = React.useRef(null);
4510
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4511
+ const consumerCleanupRef = React.useRef(null);
4193
4512
  const confirmRef = React.useRef(null);
4194
4513
  const cancelRef = React.useRef(null);
4195
4514
  // Handle cancel
@@ -4208,48 +4527,47 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
4208
4527
  handleCancel();
4209
4528
  }
4210
4529
  }, [canCancel, handleCancel]);
4211
- // Handle escape key
4212
- React.useEffect(() => {
4213
- if (!isOpen || !canCancel)
4214
- return undefined;
4215
- const handleKeyDown = (e) => {
4216
- if (e.key === 'Escape') {
4217
- handleCancel();
4218
- }
4219
- };
4220
- document.addEventListener('keydown', handleKeyDown);
4221
- return () => document.removeEventListener('keydown', handleKeyDown);
4222
- }, [isOpen, canCancel, handleCancel]);
4223
- // Focus management
4530
+ // Moving into the portal remounts the Modal's subtree — these buttons
4531
+ // included — so the focus effect below has to re-run against the new
4532
+ // nodes, exactly as the Modal's own focus effect does.
4533
+ const isHydrated = useIsHydrated();
4534
+ const isPortaled = Boolean(portal) && isHydrated;
4535
+ // Focus management Dialog picks a specific button rather than the
4536
+ // Modal's default (first focusable/root); this effect runs after the
4537
+ // inner Modal's own focus-on-open effect, so it wins.
4224
4538
  React.useEffect(() => {
4225
4539
  if (isOpen) {
4226
4540
  const buttonToFocus = focusCancel && showCancel ? cancelRef.current : confirmRef.current;
4227
4541
  buttonToFocus?.focus();
4228
4542
  }
4229
- }, [isOpen, focusCancel, showCancel]);
4230
- // Prevent body scroll (ref-counted so chained dialogs work correctly)
4231
- React.useEffect(() => {
4232
- if (isOpen) {
4233
- _scrollLockCount++;
4234
- if (_scrollLockCount === 1) {
4235
- _originalOverflow = document.body.style.overflow;
4236
- document.body.style.overflow = 'hidden';
4237
- }
4238
- return () => {
4239
- _scrollLockCount--;
4240
- if (_scrollLockCount === 0) {
4241
- document.body.style.overflow = _originalOverflow;
4242
- }
4243
- };
4244
- }
4245
- return undefined;
4246
- }, [isOpen]);
4543
+ }, [isOpen, focusCancel, showCancel, isPortaled]);
4247
4544
  // Use combined ref
4248
4545
  const combinedRef = React.useCallback((node) => {
4249
4546
  dialogRef.current =
4250
4547
  node;
4251
4548
  if (typeof ref === 'function') {
4252
- ref(node);
4549
+ // React 19 lets a callback ref return a cleanup function and detaches by
4550
+ // running it instead of calling the ref with `null`; React 18 discards the
4551
+ // return value entirely. Returning it from here would be a React-19-only
4552
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4553
+ // same shape as `Dropdown`, `Modal`, `Sidebar`, `Toast` and `Carousel`.
4554
+ // The form controls that merge a forwarded ref still discard the cleanup
4555
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4556
+ // `typeof ref === 'function'` for the current split.
4557
+ if (node === null) {
4558
+ const consumerCleanup = consumerCleanupRef.current;
4559
+ consumerCleanupRef.current = null;
4560
+ if (consumerCleanup) {
4561
+ consumerCleanup();
4562
+ }
4563
+ else {
4564
+ ref(null);
4565
+ }
4566
+ return;
4567
+ }
4568
+ const cleanup = ref(node);
4569
+ consumerCleanupRef.current =
4570
+ typeof cleanup === 'function' ? cleanup : null;
4253
4571
  }
4254
4572
  else if (ref) {
4255
4573
  ref.current = node;
@@ -4292,7 +4610,7 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
4292
4610
  if (!isOpen) {
4293
4611
  return null;
4294
4612
  }
4295
- return (jsxRuntime.jsxs(Modal, { isActive: isOpen, children: [jsxRuntime.jsx(Modal.Background, { onClick: handleBackgroundClick }), jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, role: "alertdialog", "aria-modal": "true", "aria-labelledby": title ? 'dialog-title' : undefined, "aria-describedby": "dialog-message", ...rest, children: [title && (jsxRuntime.jsxs("div", { className: headerClass, children: [displayIcon && jsxRuntime.jsx("span", { className: iconClass, children: displayIcon }), jsxRuntime.jsx("h3", { id: "dialog-title", className: titleClass, children: title })] })), jsxRuntime.jsx("div", { id: "dialog-message", className: bodyClass, children: message }), jsxRuntime.jsxs("div", { className: footerClass, children: [showCancel && (jsxRuntime.jsx("button", { ref: cancelRef, type: "button", className: cancelButtonClass, onClick: handleCancel, children: cancelText })), jsxRuntime.jsx("button", { ref: confirmRef, type: "button", className: confirmButtonClass, onClick: handleConfirm, children: confirmText })] })] })] }));
4613
+ return (jsxRuntime.jsxs(Modal, { isActive: isOpen, onClose: handleCancel, role: "presentation", portal: portal, children: [jsxRuntime.jsx(Modal.Background, { onClick: handleBackgroundClick }), jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, role: "alertdialog", "aria-modal": "true", "aria-labelledby": title ? 'dialog-title' : undefined, "aria-describedby": "dialog-message", ...rest, children: [title && (jsxRuntime.jsxs("div", { className: headerClass, children: [displayIcon && jsxRuntime.jsx("span", { className: iconClass, children: displayIcon }), jsxRuntime.jsx("h3", { id: "dialog-title", className: titleClass, children: title })] })), jsxRuntime.jsx("div", { id: "dialog-message", className: bodyClass, children: message }), jsxRuntime.jsxs("div", { className: footerClass, children: [showCancel && (jsxRuntime.jsx("button", { ref: cancelRef, type: "button", className: cancelButtonClass, onClick: handleCancel, children: cancelText })), jsxRuntime.jsx("button", { ref: confirmRef, type: "button", className: confirmButtonClass, onClick: handleConfirm, children: confirmText })] })] })] }));
4296
4614
  });
4297
4615
  Dialog.displayName = 'Dialog';
4298
4616
  let dialogListeners = new Set();
@@ -4375,10 +4693,11 @@ const validButtonColors = [...validColors, 'text', 'ghost'];
4375
4693
  *
4376
4694
  * @function
4377
4695
  * @param {ButtonProps} props - Props for the Button component.
4696
+ * @param {React.Ref<HTMLButtonElement | HTMLAnchorElement>} ref - Forwarded ref to the rendered button or anchor element.
4378
4697
  * @returns {JSX.Element} The rendered button or anchor element.
4379
4698
  * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
4380
4699
  */
4381
- const Button = ({ 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, ...props }) => {
4700
+ const Button = React.forwardRef(function Button({ 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, ...props }, ref) {
4382
4701
  const { bulmaHelperClasses, rest } = useBulmaClasses({
4383
4702
  color: textColor,
4384
4703
  backgroundColor: bgColor,
@@ -4408,12 +4727,13 @@ const Button = ({ color, size, isLight, isRounded, isLoading, isStatic, isFullwi
4408
4727
  // native/custom link-like elements (an <a>, a router Link, ...) don't
4409
4728
  // receive button-only HTML attributes.
4410
4729
  const { type: _type, disabled: _disabled, form: _form, formAction: _formAction, formEncType: _formEncType, formMethod: _formMethod, formNoValidate: _formNoValidate, formTarget: _formTarget, name: _name, value: _value, autoFocus: _autoFocus, ...anchorRest } = rest;
4411
- return (jsxRuntime.jsx(Component, { className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
4730
+ return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
4412
4731
  ? (e) => e.preventDefault()
4413
4732
  : onClick, ...anchorRest, children: children }));
4414
4733
  }
4415
- return (jsxRuntime.jsx("button", { className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
4416
- };
4734
+ return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
4735
+ });
4736
+ Button.displayName = 'Button';
4417
4737
 
4418
4738
  /**
4419
4739
  * Individual carousel item/slide.
@@ -4472,6 +4792,8 @@ const DefaultNextIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org
4472
4792
  const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, interval = 5000, pauseOnHover = true, repeat = true, hasDrag = true, arrow = true, arrowHover = false, indicator = true, indicatorInside = false, indicatorPosition = 'bottom', indicatorStyle = 'dots', iconPrev, iconNext, iconLibrary, iconVariant, iconSize, iconFeatures, arrowBackground = true, arrowColor, ariaLabel = 'Image carousel', onChange, className, children, ...props }, ref) => {
4473
4793
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4474
4794
  const carouselRef = React.useRef(null);
4795
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4796
+ const consumerCleanupRef = React.useRef(null);
4475
4797
  const containerRef = React.useRef(null);
4476
4798
  const [internalValue, setInternalValue] = React.useState(0);
4477
4799
  const [isPaused, setIsPaused] = React.useState(false);
@@ -4657,7 +4979,28 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
4657
4979
  carouselRef.current =
4658
4980
  node;
4659
4981
  if (typeof ref === 'function') {
4660
- ref(node);
4982
+ // React 19 lets a callback ref return a cleanup function and detaches by
4983
+ // running it instead of calling the ref with `null`; React 18 discards the
4984
+ // return value entirely. Returning it from here would be a React-19-only
4985
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4986
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Sidebar` and `Toast`.
4987
+ // The form controls that merge a forwarded ref still discard the cleanup
4988
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4989
+ // `typeof ref === 'function'` for the current split.
4990
+ if (node === null) {
4991
+ const consumerCleanup = consumerCleanupRef.current;
4992
+ consumerCleanupRef.current = null;
4993
+ if (consumerCleanup) {
4994
+ consumerCleanup();
4995
+ }
4996
+ else {
4997
+ ref(null);
4998
+ }
4999
+ return;
5000
+ }
5001
+ const cleanup = ref(node);
5002
+ consumerCleanupRef.current =
5003
+ typeof cleanup === 'function' ? cleanup : null;
4661
5004
  }
4662
5005
  else if (ref) {
4663
5006
  ref.current = node;
@@ -4922,6 +5265,7 @@ const Box = ({ className, textColor, color, bgColor, hasShadow = true, children,
4922
5265
  *
4923
5266
  * @function
4924
5267
  * @param {LinkButtonProps} props - Props for the LinkButton component.
5268
+ * @param {React.Ref<HTMLButtonElement | HTMLAnchorElement>} ref - Forwarded ref to the rendered button or anchor element.
4925
5269
  * @returns {JSX.Element} The rendered link-styled button element.
4926
5270
  *
4927
5271
  * @example
@@ -4932,11 +5276,12 @@ const Box = ({ className, textColor, color, bgColor, hasShadow = true, children,
4932
5276
  * // Underline variant with color
4933
5277
  * <LinkButton variant="underline" color="primary">Learn more</LinkButton>
4934
5278
  */
4935
- const LinkButton = ({ variant = 'text', color, className, ...props }) => {
5279
+ const LinkButton = React.forwardRef(function LinkButton({ variant = 'text', color, className, ...props }, ref) {
4936
5280
  const buttonColor = variant === 'underline' ? 'text' : variant;
4937
5281
  const prefixedClasses = usePrefixedClassNames('link-button', color && `link-button-${color}`, variant === 'underline' && 'link-button-underline');
4938
- return (jsxRuntime.jsx(Button, { color: buttonColor, className: classNames(prefixedClasses, className), ...props }));
4939
- };
5282
+ return (jsxRuntime.jsx(Button, { ref: ref, color: buttonColor, className: classNames(prefixedClasses, className), ...props }));
5283
+ });
5284
+ LinkButton.displayName = 'LinkButton';
4940
5285
 
4941
5286
  const validButtonsSizes = ['small', 'medium', 'large'];
4942
5287
  /**
@@ -5127,6 +5472,16 @@ const Figure = withSubComponents(FigureComponent, {
5127
5472
  Caption: FigureCaption,
5128
5473
  }, 'Figure');
5129
5474
 
5475
+ /**
5476
+ * Distinguishes an `IconProps` object from a plain custom node (an inline SVG, a `react-icons`
5477
+ * component, …) passed to a slot that accepts either.
5478
+ */
5479
+ function isIconProps$1(value) {
5480
+ return (typeof value === 'object' &&
5481
+ value !== null &&
5482
+ !React.isValidElement(value) &&
5483
+ ('name' in value || 'children' in value));
5484
+ }
5130
5485
  /**
5131
5486
  * The `IconText` component provides a Bulma-styled horizontal arrangement of one or more `Icon` components and optional text.
5132
5487
  *
@@ -5146,7 +5501,9 @@ const IconTextComponent = ({ className, textColor, color, bgColor, iconProps, ch
5146
5501
  });
5147
5502
  const bulmaClasses = usePrefixedClassNames('icon-text');
5148
5503
  const iconTextClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
5149
- return (jsxRuntime.jsx("span", { className: iconTextClasses, ...rest, children: items ? (items.map((item, index) => (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(Icon, { ...item.iconProps }), item.text && jsxRuntime.jsx("span", { children: item.text })] }, index)))) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [iconProps && jsxRuntime.jsx(Icon, { ...iconProps }), children && jsxRuntime.jsx("span", { children: children })] })) }));
5504
+ return (jsxRuntime.jsx("span", { className: iconTextClasses, ...rest, children: items ? (items.map((item, index) => (jsxRuntime.jsxs(React.Fragment, { children: [item.iconProps &&
5505
+ (isIconProps$1(item.iconProps) ? (jsxRuntime.jsx(Icon, { ...item.iconProps })) : (jsxRuntime.jsx(Icon, { children: item.iconProps }))), item.text && jsxRuntime.jsx("span", { children: item.text })] }, index)))) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [iconProps &&
5506
+ (isIconProps$1(iconProps) ? (jsxRuntime.jsx(Icon, { ...iconProps })) : (jsxRuntime.jsx(Icon, { children: iconProps }))), children && jsxRuntime.jsx("span", { children: children })] })) }));
5150
5507
  };
5151
5508
  const IconText = withSubComponents(IconTextComponent, { Icon }, 'IconText');
5152
5509
 
@@ -6057,6 +6414,16 @@ const Checkbox = React.forwardRef(({ color, size, className, children, textColor
6057
6414
  });
6058
6415
  Checkbox.displayName = 'Checkbox';
6059
6416
 
6417
+ /**
6418
+ * Distinguishes an `IconProps` object from a plain custom node (an inline SVG, a `react-icons`
6419
+ * component, …) passed to a slot that accepts either.
6420
+ */
6421
+ function isIconProps(value) {
6422
+ return (typeof value === 'object' &&
6423
+ value !== null &&
6424
+ !React.isValidElement(value) &&
6425
+ ('name' in value || 'children' in value));
6426
+ }
6060
6427
  const allowedColors = [...validColors, 'inherit', 'current'];
6061
6428
  /**
6062
6429
  * The `Control` component is a Bulma-styled wrapper for form controls (`Input`, `Select`, `TextArea`, etc.), supporting icons (left/right), loading state, expansion, size, and Bulma helper props for layout and color.
@@ -6088,15 +6455,19 @@ const Control = React.forwardRef(({ as = 'div', hasIconsLeft, hasIconsRight, isL
6088
6455
  backgroundColor: safeBgColor,
6089
6456
  ...restProps,
6090
6457
  });
6091
- // Prepare icon props for the shortcut
6092
- const leftIconProps = iconLeft ||
6458
+ // Prepare icon props for the shortcut. A truthiness test (not `!== undefined`)
6459
+ // so a falsy node from the idiomatic `iconLeft={cond && <Node/>}` / `iconLeft={maybe ?? null}`
6460
+ // pattern counts as "no icon" — otherwise `false`/`null` would reserve the icon column and
6461
+ // mount an empty `.icon` span. A falsy `iconLeft` also correctly falls through to the
6462
+ // `iconLeftName` shortcut, matching the pre-node behavior.
6463
+ const leftIconValue = iconLeft ||
6093
6464
  (iconLeftName
6094
6465
  ? {
6095
6466
  name: iconLeftName,
6096
6467
  size: iconLeftSize,
6097
6468
  }
6098
6469
  : undefined);
6099
- const rightIconProps = iconRight ||
6470
+ const rightIconValue = iconRight ||
6100
6471
  (iconRightName
6101
6472
  ? {
6102
6473
  name: iconRightName,
@@ -6104,15 +6475,17 @@ const Control = React.forwardRef(({ as = 'div', hasIconsLeft, hasIconsRight, isL
6104
6475
  }
6105
6476
  : undefined);
6106
6477
  const mainClass = usePrefixedClassNames('control', {
6107
- 'has-icons-left': hasIconsLeft || !!leftIconProps,
6108
- 'has-icons-right': hasIconsRight || !!rightIconProps,
6478
+ 'has-icons-left': hasIconsLeft || !!leftIconValue,
6479
+ 'has-icons-right': hasIconsRight || !!rightIconValue,
6109
6480
  'is-loading': isLoading,
6110
6481
  'is-expanded': isExpanded,
6111
6482
  [`is-${size}`]: !!size,
6112
6483
  });
6113
6484
  const controlClass = classNames(mainClass, bulmaHelperClasses, className);
6114
6485
  // --- FIX: Spread both restProps (for data-testid, etc) AND rest (from useBulmaClasses) ---
6115
- return (jsxRuntime.jsx(ControlProvider, { value: true, children: jsxRuntime.jsxs(Component, { className: controlClass, ref: ref, ...restProps, ...rest, children: [children, leftIconProps && leftIconProps.name && (jsxRuntime.jsx(Icon, { ...leftIconProps, className: prefixedClassNames(classPrefix, 'is-left') })), rightIconProps && rightIconProps.name && (jsxRuntime.jsx(Icon, { ...rightIconProps, className: prefixedClassNames(classPrefix, 'is-right') }))] }) }));
6486
+ return (jsxRuntime.jsx(ControlProvider, { value: true, children: jsxRuntime.jsxs(Component, { className: controlClass, ref: ref, ...restProps, ...rest, children: [children, leftIconValue &&
6487
+ (isIconProps(leftIconValue) ? (jsxRuntime.jsx(Icon, { ...leftIconValue, className: prefixedClassNames(classPrefix, 'is-left') })) : (jsxRuntime.jsx(Icon, { className: prefixedClassNames(classPrefix, 'is-left'), children: leftIconValue }))), rightIconValue &&
6488
+ (isIconProps(rightIconValue) ? (jsxRuntime.jsx(Icon, { ...rightIconValue, className: prefixedClassNames(classPrefix, 'is-right') })) : (jsxRuntime.jsx(Icon, { className: prefixedClassNames(classPrefix, 'is-right'), children: rightIconValue })))] }) }));
6116
6489
  });
6117
6490
  Control.displayName = 'Control';
6118
6491