@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.cjs.js +441 -111
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +442 -112
- package/dist/index.esm.js.map +1 -1
- package/dist/types/components/Dialog.d.ts +6 -0
- package/dist/types/components/Dropdown.d.ts +2 -1
- package/dist/types/components/Modal.d.ts +25 -1
- package/dist/types/components/Navbar.d.ts +14 -7
- package/dist/types/elements/Button.d.ts +3 -1
- package/dist/types/elements/Buttons.d.ts +2 -2
- package/dist/types/elements/LinkButton.d.ts +2 -2
- package/dist/types/helpers/portal.d.ts +15 -0
- package/dist/types/helpers/scrollLock.d.ts +9 -0
- package/dist/types/helpers/useIsHydrated.d.ts +12 -0
- package/package.json +1 -1
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,
|
|
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:
|
|
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
|
-
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
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
|
-
|
|
3776
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
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
|
|
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
|
|
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
|
/**
|