@allxsmith/bestax-bulma 5.14.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.esm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
- import React, { createContext, useContext, useMemo, useState, useRef, useEffect, useCallback, useId, useLayoutEffect, forwardRef, Children, isValidElement, cloneElement } from 'react';
3
+ import React, { createContext, useContext, useMemo, useState, useRef, useEffect, forwardRef, useCallback, useSyncExternalStore, useId, useLayoutEffect, Children, isValidElement, cloneElement } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
 
6
6
  const ConfigContext = createContext({});
@@ -1774,14 +1774,46 @@ const isBrowser$1 = (win, doc) => typeof win !== 'undefined' && typeof doc !== '
1774
1774
  *
1775
1775
  * @function
1776
1776
  * @param {DropdownProps} props - Props for the Dropdown component.
1777
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the root dropdown element.
1777
1778
  * @returns {JSX.Element} The rendered dropdown.
1778
1779
  * @see {@link https://bulma.io/documentation/components/dropdown/ | Bulma Dropdown documentation}
1779
1780
  */
1780
- const DropdownComponent = ({ label, children, className, menuClassName, active: activeProp, up, right, hoverable, disabled, onActiveChange, closeOnClick = true, id, ...props }) => {
1781
+ const DropdownComponent = forwardRef(function DropdownComponent({ label, children, className, menuClassName, active: activeProp, up, right, hoverable, disabled, onActiveChange, closeOnClick = true, id, ...props }, ref) {
1781
1782
  const [active, setActive] = useState(!!activeProp);
1782
1783
  const dropdownRef = useRef(null);
1783
1784
  const triggerRef = useRef(null);
1784
1785
  const pendingFocusRef = useRef(null);
1786
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
1787
+ const consumerCleanupRef = useRef(null);
1788
+ const setRefs = useCallback((node) => {
1789
+ dropdownRef.current =
1790
+ node;
1791
+ if (typeof ref === 'function') {
1792
+ // React 19 lets a callback ref return a cleanup function and detaches
1793
+ // by running it instead of calling the ref with `null`; React 18
1794
+ // discards the return value entirely. Returning it from here would be
1795
+ // a React-19-only contract, so instead we hold the cleanup and run it
1796
+ // ourselves on detach — a consumer's cleanup ref then behaves the same
1797
+ // on both majors of the CI matrix.
1798
+ if (node === null) {
1799
+ const consumerCleanup = consumerCleanupRef.current;
1800
+ consumerCleanupRef.current = null;
1801
+ if (consumerCleanup) {
1802
+ consumerCleanup();
1803
+ }
1804
+ else {
1805
+ ref(null);
1806
+ }
1807
+ return;
1808
+ }
1809
+ const cleanup = ref(node);
1810
+ consumerCleanupRef.current =
1811
+ typeof cleanup === 'function' ? cleanup : null;
1812
+ }
1813
+ else if (ref) {
1814
+ ref.current = node;
1815
+ }
1816
+ }, [ref]);
1785
1817
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
1786
1818
  // Generate Bulma classes with prefix
1787
1819
  const bulmaClasses = usePrefixedClassNames('dropdown', {
@@ -1949,8 +1981,8 @@ const DropdownComponent = ({ label, children, className, menuClassName, active:
1949
1981
  }
1950
1982
  };
1951
1983
  const dropdownClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
1952
- return (jsxs("div", { className: dropdownClasses, ref: dropdownRef, id: id, "data-testid": "dropdown-root", ...rest, children: [jsx("div", { className: usePrefixedClassNames('dropdown-trigger'), children: 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: [jsx("span", { children: label }), jsx("span", { className: usePrefixedClassNames('icon', 'is-small'), "aria-hidden": "true", children: jsx("i", { className: "fas fa-angle-down" }) })] }) }), jsx("div", { className: classNames(usePrefixedClassNames('dropdown-menu'), menuClassName), id: id ? `${id}-menu` : undefined, role: "menu", "data-testid": "dropdown-menu", onKeyDown: handleMenuKeyDown, children: jsx("div", { className: usePrefixedClassNames('dropdown-content'), onClick: handleMenuClick, tabIndex: -1, children: children }) })] }));
1953
- };
1984
+ return (jsxs("div", { className: dropdownClasses, ref: setRefs, id: id, "data-testid": "dropdown-root", ...rest, children: [jsx("div", { className: usePrefixedClassNames('dropdown-trigger'), children: 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: [jsx("span", { children: label }), jsx("span", { className: usePrefixedClassNames('icon', 'is-small'), "aria-hidden": "true", children: jsx("i", { className: "fas fa-angle-down" }) })] }) }), jsx("div", { className: classNames(usePrefixedClassNames('dropdown-menu'), menuClassName), id: id ? `${id}-menu` : undefined, role: "menu", "data-testid": "dropdown-menu", onKeyDown: handleMenuKeyDown, children: jsx("div", { className: usePrefixedClassNames('dropdown-content'), onClick: handleMenuClick, tabIndex: -1, children: children }) })] }));
1985
+ });
1954
1986
  /**
1955
1987
  * Bulma Dropdown item.
1956
1988
  *
@@ -2106,6 +2138,105 @@ const Message = withSubComponents(MessageComponent, {
2106
2138
  Body: MessageBody,
2107
2139
  }, 'Message');
2108
2140
 
2141
+ // Ref-counted across every caller (e.g. a Dialog rendering its own Modal)
2142
+ // so overlapping/nested locks don't fight over which one restores `overflow`.
2143
+ // Every overlay that locks body scroll — Modal, Dialog, Sidebar, Loading —
2144
+ // goes through here; a component setting `document.body.style.overflow`
2145
+ // itself would clear a lock another overlay still needs.
2146
+ let lockCount = 0;
2147
+ let originalOverflow = '';
2148
+ /**
2149
+ * Locks (and ref-counted-ly unlocks) `document.body` scrolling while `active`
2150
+ * is true. Safe to call from multiple components at once — the body scroll
2151
+ * is only restored once every active caller has released its lock.
2152
+ *
2153
+ * @function useScrollLock
2154
+ * @param active - Whether this caller wants the body scroll locked.
2155
+ */
2156
+ function useScrollLock(active) {
2157
+ useEffect(() => {
2158
+ if (!active)
2159
+ return undefined;
2160
+ lockCount++;
2161
+ if (lockCount === 1) {
2162
+ originalOverflow = document.body.style.overflow;
2163
+ document.body.style.overflow = 'hidden';
2164
+ }
2165
+ return () => {
2166
+ lockCount--;
2167
+ if (lockCount === 0) {
2168
+ document.body.style.overflow = originalOverflow;
2169
+ }
2170
+ };
2171
+ }, [active]);
2172
+ }
2173
+
2174
+ /**
2175
+ * Resolves a portal target: an `HTMLElement` is used directly, a non-empty
2176
+ * `string` is treated as a `document.querySelector` selector (falling back to
2177
+ * `document.body` when it matches nothing), and any falsy value — `undefined`
2178
+ * or the empty string — resolves to `document.body`.
2179
+ *
2180
+ * The empty string is deliberately treated as "no target" rather than passed
2181
+ * through: `document.querySelector('')` throws a `SyntaxError`, and callers
2182
+ * building a selector from state can easily hand us `''`.
2183
+ *
2184
+ * @function resolvePortalContainer
2185
+ * @param container - The requested portal target, if any.
2186
+ * @returns The resolved DOM node to portal into.
2187
+ */
2188
+ function resolvePortalContainer(container) {
2189
+ if (!container) {
2190
+ return document.body;
2191
+ }
2192
+ if (typeof container === 'string') {
2193
+ return (document.querySelector(container) ?? document.body);
2194
+ }
2195
+ return container;
2196
+ }
2197
+
2198
+ // "Are we past hydration?" as a store that never changes: React reads the
2199
+ // server snapshot both while server-rendering and while hydrating, and the
2200
+ // client snapshot from the first post-hydration render onward.
2201
+ const subscribeToNothing = () => () => { };
2202
+ const getClientSnapshot = () => true;
2203
+ const getServerSnapshot = () => false;
2204
+ /**
2205
+ * Reports whether the component has passed hydration.
2206
+ *
2207
+ * Returns `false` during server rendering and during the hydrating client
2208
+ * render, then `true` from the commit that follows. Use it to defer
2209
+ * DOM-only behaviour (such as portalling) past hydration so the first client
2210
+ * render matches the server markup instead of tripping hydration recovery.
2211
+ *
2212
+ * @function useIsHydrated
2213
+ * @returns `true` once the hydrating render has completed, otherwise `false`.
2214
+ */
2215
+ function useIsHydrated() {
2216
+ return useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot);
2217
+ }
2218
+
2219
+ /**
2220
+ * Every currently active modal, in the order they opened. Only the last entry
2221
+ * — the topmost modal — reacts to Escape and Tab, so one keypress can't
2222
+ * dismiss a whole stack (e.g. a `Dialog` opened on top of a `Modal`).
2223
+ */
2224
+ const activeModalStack = [];
2225
+ /**
2226
+ * Controls the modal can hand focus to. Disabled and hidden controls are
2227
+ * excluded because `focus()` on them is a no-op, which would leave focus
2228
+ * outside the modal.
2229
+ */
2230
+ const FOCUSABLE_SELECTOR$1 = 'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]';
2231
+ /**
2232
+ * The modal's tab stops, in document order. The selector alone is not the
2233
+ * tabbable set — `[href]` and `button` match regardless of `tabindex`, so an
2234
+ * `<a href tabIndex={-1}>` would otherwise be treated as a tab stop and let
2235
+ * Tab escape the modal when it sorts last. `el.tabIndex` is the browser's own
2236
+ * resolved value, so filtering on it drops every negative index (not just
2237
+ * `-1`) without a second selector to keep in sync.
2238
+ */
2239
+ const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR$1)).filter(el => el.tabIndex >= 0);
2109
2240
  /**
2110
2241
  * Modal.Background - Renders the modal background overlay.
2111
2242
  *
@@ -2236,7 +2367,7 @@ const ModalClose = ({ className, size = 'large', variant = 'delete', ...props })
2236
2367
  *
2237
2368
  * @see {@link https://bulma.io/documentation/components/modal/ | Bulma Modal documentation}
2238
2369
  */
2239
- const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, modalCardTitle, modalCardFoot, type, children, ...props }) => {
2370
+ const ModalRoot = 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) {
2240
2371
  const { classPrefix } = useConfig();
2241
2372
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2242
2373
  color: textColor,
@@ -2245,6 +2376,133 @@ const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, m
2245
2376
  });
2246
2377
  // Support both active and isActive props
2247
2378
  const isModalActive = active ?? isActive ?? false;
2379
+ const modalRootRef = useRef(null);
2380
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
2381
+ const consumerCleanupRef = useRef(null);
2382
+ // The forwarded ref and the internal one must both see the node: focus
2383
+ // management, the scroll lock and the topmost-modal check all read
2384
+ // `modalRootRef`, while consumers expect their own ref to resolve.
2385
+ const combinedRef = useCallback((node) => {
2386
+ modalRootRef.current =
2387
+ node;
2388
+ if (typeof ref === 'function') {
2389
+ // React 19 lets a callback ref return a cleanup function and detaches
2390
+ // by running it instead of calling the ref with `null`; React 18
2391
+ // discards the return value entirely. Returning it from here would be
2392
+ // a React-19-only contract, so instead we hold the cleanup and run it
2393
+ // ourselves on detach — a consumer's cleanup ref then behaves the same
2394
+ // on both majors of the CI matrix. Same shape as `Dropdown`.
2395
+ if (node === null) {
2396
+ const consumerCleanup = consumerCleanupRef.current;
2397
+ consumerCleanupRef.current = null;
2398
+ if (consumerCleanup) {
2399
+ consumerCleanup();
2400
+ }
2401
+ else {
2402
+ ref(null);
2403
+ }
2404
+ return;
2405
+ }
2406
+ const cleanup = ref(node);
2407
+ consumerCleanupRef.current =
2408
+ typeof cleanup === 'function' ? cleanup : null;
2409
+ }
2410
+ else if (ref) {
2411
+ ref.current = node;
2412
+ }
2413
+ }, [ref]);
2414
+ const previouslyFocusedRef = useRef(null);
2415
+ const stackTokenRef = useRef({});
2416
+ const generatedTitleId = useId();
2417
+ // A portal has no server-rendered counterpart, so the server render and the
2418
+ // hydrating render have to stay inline and only move into the portal once
2419
+ // we're past hydration.
2420
+ const onClient = useIsHydrated();
2421
+ // Moving into the portal remounts the modal's subtree, so any effect holding
2422
+ // a DOM node from before the move is holding a detached one. Effects that
2423
+ // touch the modal's nodes key on this so they re-run against the new tree.
2424
+ const isPortaled = Boolean(portal) && onClient;
2425
+ useScrollLock(isModalActive && lockScroll);
2426
+ // Record open order so keyboard handling only applies to the topmost modal.
2427
+ useEffect(() => {
2428
+ if (!isModalActive)
2429
+ return undefined;
2430
+ const token = stackTokenRef.current;
2431
+ activeModalStack.push(token);
2432
+ return () => {
2433
+ const index = activeModalStack.indexOf(token);
2434
+ if (index !== -1)
2435
+ activeModalStack.splice(index, 1);
2436
+ };
2437
+ }, [isModalActive]);
2438
+ // Escape closes; Tab cycles inside the modal. Both only for the topmost one.
2439
+ useEffect(() => {
2440
+ if (!isModalActive)
2441
+ return undefined;
2442
+ const handleKeyDown = (e) => {
2443
+ const node = modalRootRef.current;
2444
+ if (activeModalStack[activeModalStack.length - 1] !== stackTokenRef.current) {
2445
+ return;
2446
+ }
2447
+ if (e.key === 'Escape') {
2448
+ if (closeOnEscape)
2449
+ onClose?.();
2450
+ return;
2451
+ }
2452
+ if (e.key !== 'Tab' || !node)
2453
+ return;
2454
+ // Keep Tab within the modal — `aria-modal` hides the rest of the page
2455
+ // from assistive technology, so the keyboard order has to agree.
2456
+ const focusable = getTabbable(node);
2457
+ const activeElement = document.activeElement;
2458
+ if (focusable.length === 0) {
2459
+ e.preventDefault();
2460
+ node.focus();
2461
+ return;
2462
+ }
2463
+ const first = focusable[0];
2464
+ const last = focusable[focusable.length - 1];
2465
+ const outside = !node.contains(activeElement);
2466
+ if (e.shiftKey) {
2467
+ if (outside || activeElement === first) {
2468
+ e.preventDefault();
2469
+ last.focus();
2470
+ }
2471
+ }
2472
+ else if (outside || activeElement === last) {
2473
+ e.preventDefault();
2474
+ first.focus();
2475
+ }
2476
+ };
2477
+ document.addEventListener('keydown', handleKeyDown);
2478
+ return () => document.removeEventListener('keydown', handleKeyDown);
2479
+ }, [isModalActive, closeOnEscape, onClose]);
2480
+ // Move focus into the modal on open, restore it on close
2481
+ useEffect(() => {
2482
+ if (!isModalActive)
2483
+ return undefined;
2484
+ previouslyFocusedRef.current = document.activeElement;
2485
+ const node = modalRootRef.current;
2486
+ const focusable = node ? getTabbable(node)[0] : undefined;
2487
+ (focusable ?? node)?.focus();
2488
+ return () => {
2489
+ // Only hand focus back if this modal still owns it: closing a background
2490
+ // modal must not pull focus out of one that is still open on top. A
2491
+ // removed subtree leaves focus on <body>, which still counts as ours.
2492
+ const activeElement = document.activeElement;
2493
+ if (activeElement &&
2494
+ activeElement !== document.body &&
2495
+ !node?.contains(activeElement)) {
2496
+ return;
2497
+ }
2498
+ previouslyFocusedRef.current?.focus?.();
2499
+ };
2500
+ // `isPortaled` flips once when a hydrated portal modal moves out of the
2501
+ // inline tree; re-running rebinds focus onto the remounted nodes. The
2502
+ // cleanup above restores focus to the pre-open element first (the detached
2503
+ // subtree has left focus on <body>), so the re-run re-records the same
2504
+ // element and restore-on-close still lands in the right place.
2505
+ }, [isModalActive, isPortaled]);
2248
2506
  // Check if children contain compound components
2249
2507
  const hasCompoundComponents = React.Children.toArray(children).some(child => React.isValidElement(child) &&
2250
2508
  (child.type === ModalBackground ||
@@ -2257,20 +2515,35 @@ const ModalRoot = ({ active, isActive, onClose, className, textColor, bgColor, m
2257
2515
  });
2258
2516
  const deleteClass = usePrefixedClassNames('delete');
2259
2517
  const modalClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
2518
+ const resolvedRole = role ?? (isModalActive ? 'dialog' : undefined);
2519
+ const resolvedAriaModal = ariaModalProp ??
2520
+ (isModalActive && resolvedRole !== 'presentation' ? 'true' : undefined);
2521
+ // A dialog needs an accessible name: the legacy card title is wired up
2522
+ // automatically, compound users pass their own aria-label/aria-labelledby
2523
+ // (both arrive in `rest`, which is spread last and so wins).
2524
+ const titleId = modalCardTitle ? generatedTitleId : undefined;
2525
+ let modalElement;
2260
2526
  // If using compound components, render children as-is
2261
2527
  if (hasCompoundComponents) {
2262
- return (jsx("div", { className: modalClasses, ...rest, "data-testid": "modal", children: children }));
2263
- }
2264
- // Legacy API: EXPLICIT type wins; fallback to auto detection if not provided
2265
- let isModalCard;
2266
- if (type === 'card')
2267
- isModalCard = true;
2268
- else if (type === 'content')
2269
- isModalCard = false;
2270
- else
2271
- isModalCard = !!modalCardTitle || !!modalCardFoot;
2272
- return (jsxs("div", { className: modalClasses, ...rest, "data-testid": "modal", children: [jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsx("p", { className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
2273
- };
2528
+ modalElement = (jsx("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, tabIndex: -1, ...rest, "data-testid": "modal", children: children }));
2529
+ }
2530
+ else {
2531
+ // Legacy API: EXPLICIT type wins; fallback to auto detection if not provided
2532
+ let isModalCard;
2533
+ if (type === 'card')
2534
+ isModalCard = true;
2535
+ else if (type === 'content')
2536
+ isModalCard = false;
2537
+ else
2538
+ isModalCard = !!modalCardTitle || !!modalCardFoot;
2539
+ modalElement = (jsxs("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, "aria-labelledby": titleId, tabIndex: -1, ...rest, "data-testid": "modal", children: [jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsx("p", { id: titleId, className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
2540
+ }
2541
+ if (isPortaled) {
2542
+ const target = resolvePortalContainer(typeof portal === 'boolean' ? undefined : portal);
2543
+ return createPortal(modalElement, target);
2544
+ }
2545
+ return modalElement;
2546
+ });
2274
2547
  const Modal = withSubComponents(ModalRoot, {
2275
2548
  Background: ModalBackground,
2276
2549
  Content: ModalContent,
@@ -2284,10 +2557,11 @@ const NavbarDropdownContext = React.createContext(null);
2284
2557
  *
2285
2558
  * @function
2286
2559
  * @param {NavbarProps} props - Props for the Navbar component.
2560
+ * @param {React.Ref<HTMLElement>} ref - Forwarded ref to the root `<nav>` element.
2287
2561
  * @returns {JSX.Element} The rendered navbar.
2288
2562
  * @see {@link https://bulma.io/documentation/components/navbar/ | Bulma Navbar documentation}
2289
2563
  */
2290
- const NavbarComponent = ({ className, textColor, bgColor, color, transparent, fixed, children, ...props }) => {
2564
+ const NavbarComponent = forwardRef(function NavbarComponent({ className, textColor, bgColor, color, transparent, fixed, children, ...props }, ref) {
2291
2565
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2292
2566
  color: textColor,
2293
2567
  backgroundColor: bgColor,
@@ -2300,8 +2574,8 @@ const NavbarComponent = ({ className, textColor, bgColor, color, transparent, fi
2300
2574
  [`is-fixed-${fixed}`]: fixed,
2301
2575
  });
2302
2576
  const navbarClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
2303
- return (jsx("nav", { className: navbarClasses, role: "navigation", "aria-label": "main navigation", ...rest, children: children }));
2304
- };
2577
+ return (jsx("nav", { ref: ref, className: navbarClasses, role: "navigation", "aria-label": "main navigation", ...rest, children: children }));
2578
+ });
2305
2579
  /**
2306
2580
  * For logo and branding (left side)
2307
2581
  *
@@ -2338,16 +2612,18 @@ const NavbarItem = ({ className, as: Component = 'a', active, textColor, bgColor
2338
2612
  *
2339
2613
  * @function
2340
2614
  * @param {NavbarBurgerProps} props - Props for the NavbarBurger component.
2615
+ * @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the burger button element.
2341
2616
  * @returns {JSX.Element} The rendered burger.
2342
2617
  */
2343
- const NavbarBurger = ({ className, active, children, ...props }) => {
2618
+ const NavbarBurger = forwardRef(function NavbarBurger({ className, active, children, ...props }, ref) {
2344
2619
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2345
2620
  ...props,
2346
2621
  });
2347
- return (jsxs("button", { type: "button", className: classNames(usePrefixedClassNames('navbar-burger', {
2622
+ return (jsxs("button", { ref: ref, type: "button", className: classNames(usePrefixedClassNames('navbar-burger', {
2348
2623
  'is-active': active,
2349
2624
  }), bulmaHelperClasses, className), "aria-label": props['aria-label'] || 'menu', "aria-expanded": props['aria-expanded'] ?? !!active, ...rest, children: [jsx("span", { "aria-hidden": "true" }), jsx("span", { "aria-hidden": "true" }), jsx("span", { "aria-hidden": "true" }), children] }));
2350
- };
2625
+ });
2626
+ NavbarBurger.displayName = 'NavbarBurger';
2351
2627
  /**
2352
2628
  * Collapsible content (contains `Navbar.Start` and `Navbar.End`)
2353
2629
  *
@@ -2394,9 +2670,10 @@ const NavbarEnd = ({ className, children, ...props }) => {
2394
2670
  *
2395
2671
  * @function
2396
2672
  * @param {NavbarLinkProps} props - Props for the NavbarLink component.
2673
+ * @param {React.Ref<HTMLAnchorElement | HTMLButtonElement>} ref - Forwarded ref to the rendered link or button element.
2397
2674
  * @returns {JSX.Element} The rendered navbar link.
2398
2675
  */
2399
- const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgColor, children, ...props }) => {
2676
+ const NavbarLink = forwardRef(function NavbarLink({ className, as: Component = 'a', arrowless, textColor, bgColor, children, ...props }, ref) {
2400
2677
  const { bulmaHelperClasses, rest } = useBulmaClasses({
2401
2678
  color: textColor,
2402
2679
  backgroundColor: bgColor,
@@ -2429,7 +2706,7 @@ const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgCo
2429
2706
  return;
2430
2707
  dropdownContext.toggle();
2431
2708
  };
2432
- return (jsx(Component, { className: classNames(usePrefixedClassNames('navbar-link', {
2709
+ return (jsx(Component, { ref: ref, className: classNames(usePrefixedClassNames('navbar-link', {
2433
2710
  'is-arrowless': arrowless,
2434
2711
  }), bulmaHelperClasses, className), ...rest, ...(dropdownContext && {
2435
2712
  'aria-haspopup': 'true',
@@ -2441,15 +2718,17 @@ const NavbarLink = ({ className, as: Component = 'a', arrowless, textColor, bgCo
2441
2718
  onClick: handleClick,
2442
2719
  }),
2443
2720
  }), children: children }));
2444
- };
2721
+ });
2722
+ NavbarLink.displayName = 'NavbarLink';
2445
2723
  /**
2446
2724
  * Dropdown parent (with options for hover, up, right, active)
2447
2725
  *
2448
2726
  * @function
2449
2727
  * @param {NavbarDropdownProps} props - Props for the NavbarDropdown component.
2728
+ * @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the dropdown container element.
2450
2729
  * @returns {JSX.Element} The rendered dropdown.
2451
2730
  */
2452
- const NavbarDropdown = ({ className, right, up, hoverable, active: activeProp, onActiveChange, children, ...props }) => {
2731
+ const NavbarDropdown = forwardRef(function NavbarDropdown({ className, right, up, hoverable, active: activeProp, onActiveChange, children, ...props }, ref) {
2453
2732
  const [active, setActive] = useState(!!activeProp);
2454
2733
  useEffect(() => {
2455
2734
  // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing to controlled prop
@@ -2470,13 +2749,14 @@ const NavbarDropdown = ({ className, right, up, hoverable, active: activeProp, o
2470
2749
  setActive(false);
2471
2750
  onActiveChange?.(false);
2472
2751
  };
2473
- return (jsx(NavbarDropdownContext.Provider, { value: { active, toggle, close }, children: jsx("div", { className: classNames(usePrefixedClassNames('navbar-item', 'has-dropdown', {
2752
+ return (jsx(NavbarDropdownContext.Provider, { value: { active, toggle, close }, children: jsx("div", { ref: ref, className: classNames(usePrefixedClassNames('navbar-item', 'has-dropdown', {
2474
2753
  'has-dropdown-up': up,
2475
2754
  'is-right': right,
2476
2755
  'is-hoverable': hoverable,
2477
2756
  'is-active': active,
2478
2757
  }), className), ...props, children: children }) }));
2479
- };
2758
+ });
2759
+ NavbarDropdown.displayName = 'NavbarDropdown';
2480
2760
  /**
2481
2761
  * Dropdown menu container
2482
2762
  *
@@ -3246,16 +3526,10 @@ const Loading = ({ active = false, isFullPage = false, size, color, canCancel =
3246
3526
  document.addEventListener('keydown', handleKeyDown);
3247
3527
  return () => document.removeEventListener('keydown', handleKeyDown);
3248
3528
  }, [active, canCancel, onCancel]);
3249
- // Prevent body scroll when full page loading is active
3250
- React.useEffect(() => {
3251
- if (isFullPage && active) {
3252
- document.body.style.overflow = 'hidden';
3253
- return () => {
3254
- document.body.style.overflow = '';
3255
- };
3256
- }
3257
- return undefined;
3258
- }, [isFullPage, active]);
3529
+ // Prevent body scroll when full page loading is active. Ref-counted through
3530
+ // the shared helper so an overlapping Modal/Dialog/Sidebar doesn't have its
3531
+ // lock cleared when this one releases.
3532
+ useScrollLock(isFullPage && active);
3259
3533
  if (!active) {
3260
3534
  return null;
3261
3535
  }
@@ -3746,6 +4020,8 @@ const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
3746
4020
  const SidebarComponent = forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
3747
4021
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
3748
4022
  const sidebarRef = useRef(null);
4023
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4024
+ const consumerCleanupRef = useRef(null);
3749
4025
  const resolvedFullwidth = isFullwidth ?? fullWidth ?? false;
3750
4026
  // Close handler
3751
4027
  const handleClose = useCallback(() => {
@@ -3771,17 +4047,9 @@ const SidebarComponent = forwardRef(({ isOpen, onClose, position = 'left', width
3771
4047
  document.addEventListener('keydown', handleKeyDown);
3772
4048
  return () => document.removeEventListener('keydown', handleKeyDown);
3773
4049
  }, [isOpen, escapeClose, handleClose]);
3774
- // Prevent body scroll when sidebar is open
3775
- useEffect(() => {
3776
- if (isOpen && overlay) {
3777
- const originalOverflow = document.body.style.overflow;
3778
- document.body.style.overflow = 'hidden';
3779
- return () => {
3780
- document.body.style.overflow = originalOverflow;
3781
- };
3782
- }
3783
- return undefined;
3784
- }, [isOpen, overlay]);
4050
+ // Prevent body scroll when sidebar is open. Ref-counted through the shared
4051
+ // helper so an overlapping Modal/Dialog/Loading doesn't unlock underneath.
4052
+ useScrollLock(isOpen && overlay);
3785
4053
  // Focus trap (basic - focus sidebar when opened)
3786
4054
  useEffect(() => {
3787
4055
  if (isOpen) {
@@ -3803,7 +4071,28 @@ const SidebarComponent = forwardRef(({ isOpen, onClose, position = 'left', width
3803
4071
  sidebarRef.current =
3804
4072
  node;
3805
4073
  if (typeof ref === 'function') {
3806
- ref(node);
4074
+ // React 19 lets a callback ref return a cleanup function and detaches by
4075
+ // running it instead of calling the ref with `null`; React 18 discards the
4076
+ // return value entirely. Returning it from here would be a React-19-only
4077
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4078
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Toast` and `Carousel`.
4079
+ // The form controls that merge a forwarded ref still discard the cleanup
4080
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4081
+ // `typeof ref === 'function'` for the current split.
4082
+ if (node === null) {
4083
+ const consumerCleanup = consumerCleanupRef.current;
4084
+ consumerCleanupRef.current = null;
4085
+ if (consumerCleanup) {
4086
+ consumerCleanup();
4087
+ }
4088
+ else {
4089
+ ref(null);
4090
+ }
4091
+ return;
4092
+ }
4093
+ const cleanup = ref(node);
4094
+ consumerCleanupRef.current =
4095
+ typeof cleanup === 'function' ? cleanup : null;
3807
4096
  }
3808
4097
  else if (ref) {
3809
4098
  ref.current = node;
@@ -3920,6 +4209,8 @@ const Toast = forwardRef(({ message, type = 'default', actionType, position = 't
3920
4209
  const [isVisible, setIsVisible] = useState(true);
3921
4210
  const [isPaused, setIsPaused] = useState(false);
3922
4211
  const toastRef = useRef(null);
4212
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4213
+ const consumerCleanupRef = useRef(null);
3923
4214
  const handleClose = useCallback(() => {
3924
4215
  setIsVisible(false);
3925
4216
  onClose?.();
@@ -3990,27 +4281,41 @@ const Toast = forwardRef(({ message, type = 'default', actionType, position = 't
3990
4281
  const toastActionClass = usePrefixedClassNames('toast-action');
3991
4282
  const toastButtonClass = usePrefixedClassNames('button');
3992
4283
  const toastCloseClass = usePrefixedClassNames('delete', 'is-small');
3993
- if (!isVisible) {
3994
- return null;
3995
- }
3996
- const resolveContainer = () => {
3997
- if (container) {
3998
- if (typeof container === 'string') {
3999
- return (document.querySelector(container) || document.body);
4000
- }
4001
- return container;
4002
- }
4003
- return document.body;
4004
- };
4005
- const setRef = (node) => {
4284
+ const setRef = useCallback((node) => {
4006
4285
  toastRef.current = node;
4007
4286
  if (typeof ref === 'function') {
4008
- ref(node);
4287
+ // React 19 lets a callback ref return a cleanup function and detaches by
4288
+ // running it instead of calling the ref with `null`; React 18 discards the
4289
+ // return value entirely. Returning it from here would be a React-19-only
4290
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4291
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Sidebar` and `Carousel`.
4292
+ // The form controls that merge a forwarded ref still discard the cleanup
4293
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4294
+ // `typeof ref === 'function'` for the current split. Memoized because an
4295
+ // unstable ref identity makes React detach and re-attach on every render,
4296
+ // which would run that cleanup each time.
4297
+ if (node === null) {
4298
+ const consumerCleanup = consumerCleanupRef.current;
4299
+ consumerCleanupRef.current = null;
4300
+ if (consumerCleanup) {
4301
+ consumerCleanup();
4302
+ }
4303
+ else {
4304
+ ref(null);
4305
+ }
4306
+ return;
4307
+ }
4308
+ const cleanup = ref(node);
4309
+ consumerCleanupRef.current =
4310
+ typeof cleanup === 'function' ? cleanup : null;
4009
4311
  }
4010
4312
  else if (ref) {
4011
4313
  ref.current = node;
4012
4314
  }
4013
- };
4315
+ }, [ref]);
4316
+ if (!isVisible) {
4317
+ return null;
4318
+ }
4014
4319
  const toastElement = (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: [jsx("span", { className: toastMessageClass, children: message }), (cancelText || actionText) && (jsxs("div", { className: toastActionsClass, children: [cancelText && (jsx("span", { className: toastCancelClass, children: jsx("button", { type: "button", className: toastButtonClass, onClick: e => {
4015
4320
  e.stopPropagation();
4016
4321
  handleClose();
@@ -4026,7 +4331,7 @@ const Toast = forwardRef(({ message, type = 'default', actionType, position = 't
4026
4331
  }
4027
4332
  const toastContent = jsx("div", { className: containerClasses, children: toastElement });
4028
4333
  if (typeof document !== 'undefined') {
4029
- return createPortal(toastContent, resolveContainer());
4334
+ return createPortal(toastContent, resolvePortalContainer(container));
4030
4335
  }
4031
4336
  return null;
4032
4337
  });
@@ -4166,9 +4471,6 @@ const ToastContainer = ({ position = 'top-right', }) => {
4166
4471
  }) }), document.body);
4167
4472
  };
4168
4473
 
4169
- // Ref-counted body scroll lock for chained/overlapping dialogs
4170
- let _scrollLockCount = 0;
4171
- let _originalOverflow = '';
4172
4474
  /**
4173
4475
  * The `Dialog` component provides ready-made confirm and alert dialogs, so a destructive action stays one `await dialog.confirm()` call away.
4174
4476
  *
@@ -4200,9 +4502,11 @@ let _originalOverflow = '';
4200
4502
  * onCancel={() => setShowConfirm(false)}
4201
4503
  * />
4202
4504
  */
4203
- const Dialog = forwardRef(({ isOpen, title, message, type = 'default', confirmText = 'OK', cancelText = 'Cancel', onConfirm, onCancel, showCancel = true, canCancel = true, focusCancel = false, icon, className, ...props }, ref) => {
4505
+ const Dialog = forwardRef(({ isOpen, title, message, type = 'default', confirmText = 'OK', cancelText = 'Cancel', onConfirm, onCancel, showCancel = true, canCancel = true, focusCancel = false, icon, portal, className, ...props }, ref) => {
4204
4506
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4205
4507
  const dialogRef = useRef(null);
4508
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4509
+ const consumerCleanupRef = useRef(null);
4206
4510
  const confirmRef = useRef(null);
4207
4511
  const cancelRef = useRef(null);
4208
4512
  // Handle cancel
@@ -4221,48 +4525,47 @@ const Dialog = forwardRef(({ isOpen, title, message, type = 'default', confirmTe
4221
4525
  handleCancel();
4222
4526
  }
4223
4527
  }, [canCancel, handleCancel]);
4224
- // Handle escape key
4225
- useEffect(() => {
4226
- if (!isOpen || !canCancel)
4227
- return undefined;
4228
- const handleKeyDown = (e) => {
4229
- if (e.key === 'Escape') {
4230
- handleCancel();
4231
- }
4232
- };
4233
- document.addEventListener('keydown', handleKeyDown);
4234
- return () => document.removeEventListener('keydown', handleKeyDown);
4235
- }, [isOpen, canCancel, handleCancel]);
4236
- // Focus management
4528
+ // Moving into the portal remounts the Modal's subtree — these buttons
4529
+ // included — so the focus effect below has to re-run against the new
4530
+ // nodes, exactly as the Modal's own focus effect does.
4531
+ const isHydrated = useIsHydrated();
4532
+ const isPortaled = Boolean(portal) && isHydrated;
4533
+ // Focus management — Dialog picks a specific button rather than the
4534
+ // Modal's default (first focusable/root); this effect runs after the
4535
+ // inner Modal's own focus-on-open effect, so it wins.
4237
4536
  useEffect(() => {
4238
4537
  if (isOpen) {
4239
4538
  const buttonToFocus = focusCancel && showCancel ? cancelRef.current : confirmRef.current;
4240
4539
  buttonToFocus?.focus();
4241
4540
  }
4242
- }, [isOpen, focusCancel, showCancel]);
4243
- // Prevent body scroll (ref-counted so chained dialogs work correctly)
4244
- useEffect(() => {
4245
- if (isOpen) {
4246
- _scrollLockCount++;
4247
- if (_scrollLockCount === 1) {
4248
- _originalOverflow = document.body.style.overflow;
4249
- document.body.style.overflow = 'hidden';
4250
- }
4251
- return () => {
4252
- _scrollLockCount--;
4253
- if (_scrollLockCount === 0) {
4254
- document.body.style.overflow = _originalOverflow;
4255
- }
4256
- };
4257
- }
4258
- return undefined;
4259
- }, [isOpen]);
4541
+ }, [isOpen, focusCancel, showCancel, isPortaled]);
4260
4542
  // Use combined ref
4261
4543
  const combinedRef = useCallback((node) => {
4262
4544
  dialogRef.current =
4263
4545
  node;
4264
4546
  if (typeof ref === 'function') {
4265
- ref(node);
4547
+ // React 19 lets a callback ref return a cleanup function and detaches by
4548
+ // running it instead of calling the ref with `null`; React 18 discards the
4549
+ // return value entirely. Returning it from here would be a React-19-only
4550
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4551
+ // same shape as `Dropdown`, `Modal`, `Sidebar`, `Toast` and `Carousel`.
4552
+ // The form controls that merge a forwarded ref still discard the cleanup
4553
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4554
+ // `typeof ref === 'function'` for the current split.
4555
+ if (node === null) {
4556
+ const consumerCleanup = consumerCleanupRef.current;
4557
+ consumerCleanupRef.current = null;
4558
+ if (consumerCleanup) {
4559
+ consumerCleanup();
4560
+ }
4561
+ else {
4562
+ ref(null);
4563
+ }
4564
+ return;
4565
+ }
4566
+ const cleanup = ref(node);
4567
+ consumerCleanupRef.current =
4568
+ typeof cleanup === 'function' ? cleanup : null;
4266
4569
  }
4267
4570
  else if (ref) {
4268
4571
  ref.current = node;
@@ -4305,7 +4608,7 @@ const Dialog = forwardRef(({ isOpen, title, message, type = 'default', confirmTe
4305
4608
  if (!isOpen) {
4306
4609
  return null;
4307
4610
  }
4308
- return (jsxs(Modal, { isActive: isOpen, children: [jsx(Modal.Background, { onClick: handleBackgroundClick }), jsxs("div", { ref: combinedRef, className: combinedClasses, role: "alertdialog", "aria-modal": "true", "aria-labelledby": title ? 'dialog-title' : undefined, "aria-describedby": "dialog-message", ...rest, children: [title && (jsxs("div", { className: headerClass, children: [displayIcon && jsx("span", { className: iconClass, children: displayIcon }), jsx("h3", { id: "dialog-title", className: titleClass, children: title })] })), jsx("div", { id: "dialog-message", className: bodyClass, children: message }), jsxs("div", { className: footerClass, children: [showCancel && (jsx("button", { ref: cancelRef, type: "button", className: cancelButtonClass, onClick: handleCancel, children: cancelText })), jsx("button", { ref: confirmRef, type: "button", className: confirmButtonClass, onClick: handleConfirm, children: confirmText })] })] })] }));
4611
+ return (jsxs(Modal, { isActive: isOpen, onClose: handleCancel, role: "presentation", portal: portal, children: [jsx(Modal.Background, { onClick: handleBackgroundClick }), jsxs("div", { ref: combinedRef, className: combinedClasses, role: "alertdialog", "aria-modal": "true", "aria-labelledby": title ? 'dialog-title' : undefined, "aria-describedby": "dialog-message", ...rest, children: [title && (jsxs("div", { className: headerClass, children: [displayIcon && jsx("span", { className: iconClass, children: displayIcon }), jsx("h3", { id: "dialog-title", className: titleClass, children: title })] })), jsx("div", { id: "dialog-message", className: bodyClass, children: message }), jsxs("div", { className: footerClass, children: [showCancel && (jsx("button", { ref: cancelRef, type: "button", className: cancelButtonClass, onClick: handleCancel, children: cancelText })), jsx("button", { ref: confirmRef, type: "button", className: confirmButtonClass, onClick: handleConfirm, children: confirmText })] })] })] }));
4309
4612
  });
4310
4613
  Dialog.displayName = 'Dialog';
4311
4614
  let dialogListeners = new Set();
@@ -4388,10 +4691,11 @@ const validButtonColors = [...validColors, 'text', 'ghost'];
4388
4691
  *
4389
4692
  * @function
4390
4693
  * @param {ButtonProps} props - Props for the Button component.
4694
+ * @param {React.Ref<HTMLButtonElement | HTMLAnchorElement>} ref - Forwarded ref to the rendered button or anchor element.
4391
4695
  * @returns {JSX.Element} The rendered button or anchor element.
4392
4696
  * @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
4393
4697
  */
4394
- 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 }) => {
4698
+ const Button = 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) {
4395
4699
  const { bulmaHelperClasses, rest } = useBulmaClasses({
4396
4700
  color: textColor,
4397
4701
  backgroundColor: bgColor,
@@ -4421,12 +4725,13 @@ const Button = ({ color, size, isLight, isRounded, isLoading, isStatic, isFullwi
4421
4725
  // native/custom link-like elements (an <a>, a router Link, ...) don't
4422
4726
  // receive button-only HTML attributes.
4423
4727
  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;
4424
- return (jsx(Component, { className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
4728
+ return (jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
4425
4729
  ? (e) => e.preventDefault()
4426
4730
  : onClick, ...anchorRest, children: children }));
4427
4731
  }
4428
- return (jsx("button", { className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
4429
- };
4732
+ return (jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
4733
+ });
4734
+ Button.displayName = 'Button';
4430
4735
 
4431
4736
  /**
4432
4737
  * Individual carousel item/slide.
@@ -4485,6 +4790,8 @@ const DefaultNextIcon = () => (jsx("svg", { xmlns: "http://www.w3.org/2000/svg",
4485
4790
  const Carousel = 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) => {
4486
4791
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
4487
4792
  const carouselRef = useRef(null);
4793
+ // Cleanup returned by a consumer's callback ref on attach, held until detach.
4794
+ const consumerCleanupRef = useRef(null);
4488
4795
  const containerRef = useRef(null);
4489
4796
  const [internalValue, setInternalValue] = useState(0);
4490
4797
  const [isPaused, setIsPaused] = useState(false);
@@ -4670,7 +4977,28 @@ const Carousel = forwardRef(({ value: controlledValue, autoplay = false, interva
4670
4977
  carouselRef.current =
4671
4978
  node;
4672
4979
  if (typeof ref === 'function') {
4673
- ref(node);
4980
+ // React 19 lets a callback ref return a cleanup function and detaches by
4981
+ // running it instead of calling the ref with `null`; React 18 discards the
4982
+ // return value entirely. Returning it from here would be a React-19-only
4983
+ // contract, so we hold the cleanup and run it ourselves on detach — the
4984
+ // same shape as `Dropdown`, `Modal`, `Dialog`, `Sidebar` and `Toast`.
4985
+ // The form controls that merge a forwarded ref still discard the cleanup
4986
+ // and detach with `ref(null)`, so this is not yet library-wide — grep
4987
+ // `typeof ref === 'function'` for the current split.
4988
+ if (node === null) {
4989
+ const consumerCleanup = consumerCleanupRef.current;
4990
+ consumerCleanupRef.current = null;
4991
+ if (consumerCleanup) {
4992
+ consumerCleanup();
4993
+ }
4994
+ else {
4995
+ ref(null);
4996
+ }
4997
+ return;
4998
+ }
4999
+ const cleanup = ref(node);
5000
+ consumerCleanupRef.current =
5001
+ typeof cleanup === 'function' ? cleanup : null;
4674
5002
  }
4675
5003
  else if (ref) {
4676
5004
  ref.current = node;
@@ -4935,6 +5263,7 @@ const Box = ({ className, textColor, color, bgColor, hasShadow = true, children,
4935
5263
  *
4936
5264
  * @function
4937
5265
  * @param {LinkButtonProps} props - Props for the LinkButton component.
5266
+ * @param {React.Ref<HTMLButtonElement | HTMLAnchorElement>} ref - Forwarded ref to the rendered button or anchor element.
4938
5267
  * @returns {JSX.Element} The rendered link-styled button element.
4939
5268
  *
4940
5269
  * @example
@@ -4945,11 +5274,12 @@ const Box = ({ className, textColor, color, bgColor, hasShadow = true, children,
4945
5274
  * // Underline variant with color
4946
5275
  * <LinkButton variant="underline" color="primary">Learn more</LinkButton>
4947
5276
  */
4948
- const LinkButton = ({ variant = 'text', color, className, ...props }) => {
5277
+ const LinkButton = forwardRef(function LinkButton({ variant = 'text', color, className, ...props }, ref) {
4949
5278
  const buttonColor = variant === 'underline' ? 'text' : variant;
4950
5279
  const prefixedClasses = usePrefixedClassNames('link-button', color && `link-button-${color}`, variant === 'underline' && 'link-button-underline');
4951
- return (jsx(Button, { color: buttonColor, className: classNames(prefixedClasses, className), ...props }));
4952
- };
5280
+ return (jsx(Button, { ref: ref, color: buttonColor, className: classNames(prefixedClasses, className), ...props }));
5281
+ });
5282
+ LinkButton.displayName = 'LinkButton';
4953
5283
 
4954
5284
  const validButtonsSizes = ['small', 'medium', 'large'];
4955
5285
  /**