@allxsmith/bestax-bulma 5.18.0 → 5.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/extras.css +1 -1
  4. package/dist/extras.css.map +1 -1
  5. package/dist/index.cjs +551 -124
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.esm.js +551 -124
  8. package/dist/index.esm.js.map +1 -1
  9. package/dist/types/components/Avatar.d.ts +17 -3
  10. package/dist/types/components/Dropdown.d.ts +7 -2
  11. package/dist/types/components/Menu.d.ts +6 -1
  12. package/dist/types/components/Toast.d.ts +15 -4
  13. package/dist/types/elements/Notification.d.ts +16 -6
  14. package/dist/types/form/Checkbox.d.ts +26 -2
  15. package/dist/types/form/DateTimeInputBase.d.ts +6 -1
  16. package/dist/types/form/Radio.d.ts +28 -2
  17. package/dist/types/form/Switch.d.ts +26 -2
  18. package/dist/types/form/TimeInputBase.d.ts +5 -1
  19. package/dist/types/helpers/Theme.d.ts +27 -1
  20. package/dist/types/helpers/buttonType.d.ts +19 -0
  21. package/dist/types/helpers/colorDeprecations.d.ts +4 -1
  22. package/dist/types/helpers/devWarnings.d.ts +8 -0
  23. package/dist/types/helpers/positionStacks.d.ts +33 -0
  24. package/dist/types-cjs/components/Avatar.d.ts +17 -3
  25. package/dist/types-cjs/components/Dropdown.d.ts +7 -2
  26. package/dist/types-cjs/components/Menu.d.ts +6 -1
  27. package/dist/types-cjs/components/Toast.d.ts +15 -4
  28. package/dist/types-cjs/elements/Notification.d.ts +16 -6
  29. package/dist/types-cjs/form/Checkbox.d.ts +26 -2
  30. package/dist/types-cjs/form/DateTimeInputBase.d.ts +6 -1
  31. package/dist/types-cjs/form/Radio.d.ts +28 -2
  32. package/dist/types-cjs/form/Switch.d.ts +26 -2
  33. package/dist/types-cjs/form/TimeInputBase.d.ts +5 -1
  34. package/dist/types-cjs/helpers/Theme.d.ts +27 -1
  35. package/dist/types-cjs/helpers/buttonType.d.ts +19 -0
  36. package/dist/types-cjs/helpers/colorDeprecations.d.ts +4 -1
  37. package/dist/types-cjs/helpers/devWarnings.d.ts +8 -0
  38. package/dist/types-cjs/helpers/positionStacks.d.ts +33 -0
  39. package/dist/versions/bestax-no-dark-mode.css +1 -1
  40. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  41. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  42. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  43. package/dist/versions/bestax-no-helpers.css +1 -1
  44. package/dist/versions/bestax-no-helpers.css.map +1 -1
  45. package/dist/versions/bestax-prefixed.css +1 -1
  46. package/dist/versions/bestax-prefixed.css.map +1 -1
  47. package/package.json +1 -1
  48. package/src/scss/form/_timeinput.scss +42 -14
package/dist/index.esm.js CHANGED
@@ -1235,6 +1235,27 @@ const ColumnsComponent = ({ className, textColor, color: _fieldColor, bgColor, i
1235
1235
  };
1236
1236
  const Columns = withSubComponents(ColumnsComponent, { Column }, 'Columns');
1237
1237
 
1238
+ /**
1239
+ * The `type` to render on a `<button>` a component defaults to `button`: the
1240
+ * caller's own value when it is `button`, `submit` or `reset`, and `button`
1241
+ * otherwise.
1242
+ *
1243
+ * A missing `type` is not the only way a button ends up submitting the form
1244
+ * around it. HTML reads an invalid value, such as the anchor's MIME-type
1245
+ * `type="text/html"`, as submit too, and a props spread carrying
1246
+ * `type: undefined` removes the attribute outright. Both can arrive through a
1247
+ * spread or an untyped caller the component's own types never see, so this
1248
+ * checks the value rather than trusting where it came from.
1249
+ *
1250
+ * Call it AFTER the forwarded props are spread and pass it the forwarded
1251
+ * `type`, so its result is the attribute that renders.
1252
+ */
1253
+ function buttonType(given) {
1254
+ return given === 'submit' || given === 'reset' || given === 'button'
1255
+ ? given
1256
+ : 'button';
1257
+ }
1258
+
1238
1259
  /**
1239
1260
  * Whether an `as` target is a custom element rather than a built-in tag.
1240
1261
  *
@@ -1250,6 +1271,30 @@ function isCustomElement(as) {
1250
1271
  return typeof as === 'string' && as.includes('-');
1251
1272
  }
1252
1273
 
1274
+ // INTERNAL: deliberately not exported from src/index.ts.
1275
+ const warnedKeys = new Set();
1276
+ // Fail closed: with no bundler and no Node (raw CDN ESM), reading `process`
1277
+ // throws and warnings stay off, so production can never warn by accident.
1278
+ const isDev = () => {
1279
+ try {
1280
+ return process.env.NODE_ENV !== 'production';
1281
+ }
1282
+ catch {
1283
+ return false;
1284
+ }
1285
+ };
1286
+ /**
1287
+ * Logs a console warning in development, once per `key` for the life of the
1288
+ * page. Safe to call during render: a re-render, or a second instance hitting
1289
+ * the same case, finds the key already recorded and stays quiet.
1290
+ */
1291
+ const warnOnce = (key, message) => {
1292
+ if (!isDev() || warnedKeys.has(key))
1293
+ return;
1294
+ warnedKeys.add(key);
1295
+ console.warn(message);
1296
+ };
1297
+
1253
1298
  const avatarColors = [
1254
1299
  'primary',
1255
1300
  'link',
@@ -1295,6 +1340,18 @@ const NON_INTERACTIVE_ROLES = [
1295
1340
  'presentation',
1296
1341
  'none',
1297
1342
  ];
1343
+ /**
1344
+ * Elements other than `a` that declare `target` themselves, so on them it is
1345
+ * the element's own attribute and not a link attribute Avatar withholds for
1346
+ * want of one. The link attribute warning leaves `target` out on these, the way
1347
+ * it leaves `rel` out everywhere. `area` and `base` are void elements, which
1348
+ * Avatar's content rules out, so `form` is the one a caller can reach.
1349
+ */
1350
+ const ELEMENTS_WITH_OWN_TARGET = [
1351
+ 'form',
1352
+ 'area',
1353
+ 'base',
1354
+ ];
1298
1355
  /**
1299
1356
  * Derives a small set of initials from a name (e.g. "Ada Lovelace" -> "AL").
1300
1357
  */
@@ -1430,6 +1487,50 @@ const Avatar = forwardRef(function Avatar(avatarProps, ref) {
1430
1487
  // Only forward link attributes when rendering an anchor or a custom (non-DOM)
1431
1488
  // component; a plain `as="div"` must not receive a stray `href`/`target`/`rel`.
1432
1489
  const isLinkLike = Tag === 'a' || typeof Tag !== 'string' || isCustomElement(Tag);
1490
+ // A plain element like a `div` declares no `href` or `target`, so the type
1491
+ // accepts them as Avatar's own while the drop below withholds them, and
1492
+ // without this they would vanish in silence (#733). Forwarding them would put
1493
+ // them where HTML has no such attribute, so the drop stays and development
1494
+ // reports it instead, naming only the ones actually passed.
1495
+ //
1496
+ // `rel` is withheld too but left out here. React declares it on every
1497
+ // element, so on a plain `as` it is that element's own attribute, and
1498
+ // "render it as a link" would be the wrong advice for it. `target` is the
1499
+ // same on an element that declares it, such as `form`, so it is left out
1500
+ // there. Whether to forward either one to such an element is a separate
1501
+ // question from this warning.
1502
+ //
1503
+ // Only for an `as` the caller wrote. Without one the element is Avatar's own
1504
+ // choice, and a message naming an `as` they never passed sends them looking
1505
+ // for it. A value counts when it is truthy, the same test that picks `'a'`
1506
+ // over `'figure'`, so an empty `href` from data asks for no link and draws no
1507
+ // warning. The key is the element plus the attributes passed, so a re-render
1508
+ // or a list of avatars warns once, while a different combination on the same
1509
+ // element still gets its own warning rather than hiding behind the first.
1510
+ //
1511
+ // Development-only is `warnOnce`'s job, as it is for the colour warnings, so
1512
+ // this block has no production check of its own. One here would have to read
1513
+ // `process` safely, and a `typeof process` test is not something a bundler
1514
+ // replaces: in a browser it is false, and the warning never fired in the
1515
+ // development builds it exists for. The cost is that production still
1516
+ // collects the attributes and builds the message for an avatar that needs
1517
+ // it, which is small and limited to the case the warning is about.
1518
+ if (as != null && !isLinkLike) {
1519
+ const dropped = Object.entries({
1520
+ href,
1521
+ target: ELEMENTS_WITH_OWN_TARGET.includes(as) ? undefined : target,
1522
+ })
1523
+ .filter(([, value]) => value)
1524
+ .map(([key]) => key);
1525
+ if (dropped.length > 0) {
1526
+ warnOnce(`Avatar:link-props-on-${as}:${dropped.join('+')}`, `[bestax-bulma] <Avatar as="${as}" ${dropped.join(' ')}>: this ` +
1527
+ `<${as}> renders without ` +
1528
+ `${dropped.map(key => `"${key}"`).join(' and ')}, because Avatar ` +
1529
+ `passes link attributes on only to a target that can be a link (an ` +
1530
+ `"a", a custom element, or a component). To make it a link, render ` +
1531
+ `it as="a" or pass a link component to "as".`);
1532
+ }
1533
+ }
1433
1534
  // Present-only, not `{ href, target, rel }`. An unconditional spread hands the
1434
1535
  // target these keys whatever the caller passed, and a key existing is not free:
1435
1536
  // a target that tests for one sees a link where there is none, and one that
@@ -1452,7 +1553,7 @@ const Avatar = forwardRef(function Avatar(avatarProps, ref) {
1452
1553
  // The decorative opt-out never applies to an interactive avatar — a link or
1453
1554
  // button must always expose an accessible name.
1454
1555
  const isDecorative = alt === '' && !isInteractive;
1455
- const a11yProps = showImage
1556
+ const a11yDefaults = showImage
1456
1557
  ? isInteractive && !accessibleName
1457
1558
  ? // The img alt normally names the control; with no alt/name (an API
1458
1559
  // returning only a photo URL) the link/button would be nameless.
@@ -1464,10 +1565,18 @@ const Avatar = forwardRef(function Avatar(avatarProps, ref) {
1464
1565
  ...(isInteractive ? {} : { role: 'img' }),
1465
1566
  'aria-label': accessibleName || name || 'Avatar',
1466
1567
  };
1467
- // A clickable avatar inside a form must not submit it; default the native
1468
- // button type (an explicit type passed through rest still wins).
1469
- const buttonTypeProps = Tag === 'button' ? { type: 'button' } : {};
1470
- return (jsxs(Tag, { ref: ref, className: combinedClasses, style: { ...sizeStyle, ...style }, ...buttonTypeProps, ...linkProps, ...a11yProps, ...rest, children: [showImage && (jsx("img", { ...imageProps, ref: imgRef, src: src, alt: accessibleName ?? '', onError: e => {
1568
+ // Each default yields to a value the caller passed, and is applied after
1569
+ // `rest` reading through it rather than spread before it. React treats an
1570
+ // `undefined` attribute as "remove it", and a spread carrying the key with no
1571
+ // value is how that arrives, so a default spread first was erased by it: an
1572
+ // `aria-label: undefined` left a button avatar nameless, and `role` and
1573
+ // `aria-hidden` went the same way. The button `type` below has the same
1574
+ // shape (#690).
1575
+ const a11yProps = Object.fromEntries(Object.entries(a11yDefaults).map(([key, fallback]) => [
1576
+ key,
1577
+ rest[key] ?? fallback,
1578
+ ]));
1579
+ return (jsxs(Tag, { ref: ref, className: combinedClasses, style: { ...sizeStyle, ...style }, ...linkProps, ...rest, ...a11yProps, ...(Tag === 'button' ? { type: buttonType(rest.type) } : {}), children: [showImage && (jsx("img", { ...imageProps, ref: imgRef, src: src, alt: accessibleName ?? '', onError: e => {
1471
1580
  imageProps?.onError?.(e);
1472
1581
  setErroredSrc(src);
1473
1582
  } }, src)), showInitials && (jsx("span", { className: initialsClass, children: resolvedInitials })), showIcon && icon, showDefaultIcon && jsx(DefaultAvatarIcon, {})] }));
@@ -1820,12 +1929,12 @@ const CardComponent = ({ className, children, textColor, color, bgColor, hasShad
1820
1929
  * The set withheld from a non-anchor `Card.FooterItem` (`span`/`button`): the
1821
1930
  * derived anchor-only attributes, minus `type` (also valid on a `<button>` as
1822
1931
  * `submit`/`button`/`reset`, so stripping it there would remove a working
1823
- * attribute — the same trade `DropdownItem`'s own `STRIP_FROM_NON_ANCHOR`
1824
- * accepts, in `./Dropdown.tsx`), plus `rel` (React declares it on
1825
- * `HTMLAttributes` for every element, so the derived set alone would not
1826
- * withhold it — the same addition `Level.Item` makes).
1932
+ * attribute; one set for both tags means a `type` also reaches a `<span>`,
1933
+ * which `Dropdown.Item` avoids by choosing its set per tag), plus `rel` (React
1934
+ * declares it on `HTMLAttributes` for every element, so the derived set alone
1935
+ * would not withhold it — the same addition `Level.Item` makes).
1827
1936
  */
1828
- const STRIP_FROM_NON_ANCHOR$2 = (() => {
1937
+ const STRIP_FROM_NON_ANCHOR$1 = (() => {
1829
1938
  const { type: _type, ...rest } = ANCHOR_ONLY_ATTRS;
1830
1939
  return { ...rest, rel: true };
1831
1940
  })();
@@ -1965,9 +2074,8 @@ const CardFooterItem = ({ as = 'span', className, children, color, bgColor, text
1965
2074
  if (as === 'a') {
1966
2075
  return (jsx("a", { className: itemClasses, ...omitAttrs(rest, STRIP_FROM_NON_BUTTON), children: children }));
1967
2076
  }
1968
- const forwarded = omitAttrs(rest, STRIP_FROM_NON_ANCHOR$2);
2077
+ const forwarded = omitAttrs(rest, STRIP_FROM_NON_ANCHOR$1);
1969
2078
  if (as === 'button') {
1970
- const forwardedType = forwarded.type;
1971
2079
  return (jsx("button", { className: itemClasses, ...forwarded,
1972
2080
  // A footer item button must not submit an enclosing form by default —
1973
2081
  // `<button>` defaults to `type="submit"`, and a Save/Cancel action row
@@ -1977,13 +2085,9 @@ const CardFooterItem = ({ as = 'span', className, children, color, bgColor, text
1977
2085
  // typed here as the `<a>` MIME string (`STRIP_FROM_NON_ANCHOR` above
1978
2086
  // says why it is not withheld), so `as="button" type="text/html"`
1979
2087
  // compiles, and HTML's INVALID-value default for a button's `type` is
1980
- // submit too. So anything that is not one of the three native button
1981
- // types falls back to `button` rather than being forwarded.
1982
- type: forwardedType === 'submit' ||
1983
- forwardedType === 'reset' ||
1984
- forwardedType === 'button'
1985
- ? forwardedType
1986
- : 'button', children: children }));
2088
+ // submit too. `buttonType` keeps `button`, `submit` and `reset` and
2089
+ // turns anything else into `button`.
2090
+ type: buttonType(forwarded.type), children: children }));
1987
2091
  }
1988
2092
  return (jsx("span", { className: itemClasses, ...omitAttrs(forwarded, STRIP_FROM_NON_BUTTON), children: children }));
1989
2093
  };
@@ -2223,6 +2327,35 @@ const DropdownComponent = forwardRef(function DropdownComponent({ label, childre
2223
2327
  e.preventDefault();
2224
2328
  items[items.length - 1].focus();
2225
2329
  break;
2330
+ case 'Enter':
2331
+ case ' ': {
2332
+ // Activate the focused item by clicking it, so its `onClick` runs and
2333
+ // closing follows `closeOnClick` as it does for the mouse. A caller's
2334
+ // own key handler that prevented the default has claimed the key, and
2335
+ // is left to it rather than followed by a second activation.
2336
+ if (currentIndex < 0 || e.defaultPrevented)
2337
+ break;
2338
+ const item = items[currentIndex];
2339
+ // Leave the browser's own activation alone, or the item runs twice: a
2340
+ // `<button>` answers both keys, and a link with an `href` answers
2341
+ // Enter. A link does not answer Space, so Space on a link is handled
2342
+ // here too, which also keeps the page from scrolling.
2343
+ if (item.tagName === 'BUTTON')
2344
+ break;
2345
+ if (e.key === 'Enter' &&
2346
+ item.tagName === 'A' &&
2347
+ item.hasAttribute('href')) {
2348
+ break;
2349
+ }
2350
+ e.preventDefault();
2351
+ // A held key sends a keydown per auto-repeat. Clicking on each would
2352
+ // toggle a checkbox item over and over while `closeOnClick` is off,
2353
+ // so only the first press activates. The default is still prevented
2354
+ // on the repeats above, so a held Space does not scroll the page.
2355
+ if (!e.repeat)
2356
+ item.click();
2357
+ break;
2358
+ }
2226
2359
  }
2227
2360
  };
2228
2361
  const dropdownClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
@@ -2232,19 +2365,16 @@ const DropdownComponent = forwardRef(function DropdownComponent({ label, childre
2232
2365
  * The anchor's attributes, minus the one a `<button>` legitimately takes.
2233
2366
  *
2234
2367
  * `type` stays: `as="button"` is a supported form and `type="submit"` is valid
2235
- * there, so stripping it would remove a working attribute. The exclusion is per
2236
- * COMPONENT where the reason is per TAG, so it also lets a `type` reach a
2237
- * `<div>`; selecting the set from the rendered element would close that, and
2238
- * moves output.
2239
- *
2240
- * This set differs from `Level.Item`'s in both directions, not just one. The
2241
- * `type` above, which Level strips and this component keeps, and `rel`, which
2242
- * Level adds and this component does not: React declares `rel` on
2368
+ * there, so stripping it would remove a working attribute. A `<div>` takes no
2369
+ * `type`, so it gets the whole `ANCHOR_ONLY_ATTRS` set instead. The set is
2370
+ * chosen by the rendered tag because that is where the reason lives (#692).
2371
+ *
2372
+ * Neither set withholds `rel`, which `Level.Item` does: React declares `rel` on
2243
2373
  * `HTMLAttributes` for every element, so withholding it would diverge from
2244
- * React's own typing — the call #641 recorded for `Navbar.Link`. Level
2374
+ * React's own typing, the call #641 recorded for `Navbar.Link`. Level
2245
2375
  * withholds it anyway, because it always has.
2246
2376
  */
2247
- const STRIP_FROM_NON_ANCHOR$1 = (() => {
2377
+ const STRIP_FROM_BUTTON = (() => {
2248
2378
  const { type: _type, ...rest } = ANCHOR_ONLY_ATTRS;
2249
2379
  return rest;
2250
2380
  })();
@@ -2274,19 +2404,27 @@ const DropdownItem = ((itemProps) => {
2274
2404
  // are not interchangeable and neither derives from the other.
2275
2405
  //
2276
2406
  // Menu's condition also admits a custom component and a custom element, which
2277
- // own their prop contracts. `as` is closed to three intrinsic tags here, so
2278
- // neither can arrive and the anchor test is the whole rule.
2279
- const forwarded = Component === 'a' ? rest : omitAttrs(rest, STRIP_FROM_NON_ANCHOR$1);
2407
+ // own their prop contracts. Here the set follows the tag: an `<a>` keeps all
2408
+ // of them, a `<button>` keeps `type`, and any other `as` keeps none of them.
2409
+ const forwarded = Component === 'a'
2410
+ ? rest
2411
+ : Component === 'button'
2412
+ ? omitAttrs(rest, STRIP_FROM_BUTTON)
2413
+ : omitAttrs(rest, ANCHOR_ONLY_ATTRS);
2280
2414
  return (jsx(Component, { className: classNames(usePrefixedClassNames('dropdown-item', {
2281
2415
  'is-active': active,
2282
- }), bulmaHelperClasses, className), tabIndex: 0, "data-testid": "dropdown-item", ...forwarded,
2416
+ }), bulmaHelperClasses, className), "data-testid": "dropdown-item", ...forwarded,
2417
+ // After `forwarded` for the same reason as `role` and `type` below: a
2418
+ // spread carrying `tabIndex: undefined` would otherwise erase the
2419
+ // default. The item keeps its menu role and its place in the arrow-key
2420
+ // order, but a `<div>` or an anchor without an `href` cannot take focus
2421
+ // without a tabindex, so the arrow keys stall on it.
2422
+ tabIndex: forwarded.tabIndex ?? 0,
2283
2423
  // After `forwarded` for the same reason as `type` below: a spread
2284
2424
  // carrying `role: undefined` would otherwise erase the default, and an
2285
2425
  // item with no role drops out of the menu and its arrow-key order.
2286
2426
  role: forwarded.role ?? 'menuitem', ...(Component === 'button'
2287
- ? {
2288
- type: forwarded.type ?? 'button',
2289
- }
2427
+ ? { type: buttonType(forwarded.type) }
2290
2428
  : {}), children: children }));
2291
2429
  });
2292
2430
  /**
@@ -2395,7 +2533,9 @@ const MenuItem = forwardRef(function MenuItem(itemProps, ref) {
2395
2533
  labelChildren.push(child);
2396
2534
  }
2397
2535
  });
2398
- return (jsxs("li", { className: className, "data-testid": testId, style: style, id: id, title: title, role: role, tabIndex: tabIndex, children: [jsx(Component, { ref: ref, className: itemClass || undefined, ...linkProps, children: labelChildren }), nestedMenuLists] }));
2536
+ return (jsxs("li", { className: className, "data-testid": testId, style: style, id: id, title: title, role: role, tabIndex: tabIndex, children: [jsx(Component, { ref: ref, className: itemClass || undefined, ...linkProps, ...(Component === 'button'
2537
+ ? { type: buttonType(linkProps.type) }
2538
+ : {}), children: labelChildren }), nestedMenuLists] }));
2399
2539
  });
2400
2540
  MenuItem.displayName = 'MenuItem';
2401
2541
  // Attach static subcomponents
@@ -3161,23 +3301,6 @@ const UNSTYLED_MODIFIER_COLORS = [
3161
3301
  'grey-lighter',
3162
3302
  ];
3163
3303
  const CSS_BACKED = 'primary, link, info, success, warning, danger, black, white, light, dark';
3164
- const warnedKeys = new Set();
3165
- // Fail closed: with no bundler and no Node (raw CDN ESM), reading `process`
3166
- // throws and warnings stay off, so production can never warn by accident.
3167
- const isDev = () => {
3168
- try {
3169
- return process.env.NODE_ENV !== 'production';
3170
- }
3171
- catch {
3172
- return false;
3173
- }
3174
- };
3175
- const warnOnce = (key, message) => {
3176
- if (!isDev() || warnedKeys.has(key))
3177
- return;
3178
- warnedKeys.add(key);
3179
- console.warn(message);
3180
- };
3181
3304
  /**
3182
3305
  * Dev warning for a `color` value whose `is-<color>` modifier has no shipped
3183
3306
  * CSS. `extraUnstyled` covers per-component dead values beyond the shared
@@ -4612,6 +4735,48 @@ const Sidebar = withSubComponents(SidebarComponent, {
4612
4735
  Footer: SidebarFooter,
4613
4736
  }, 'Sidebar');
4614
4737
 
4738
+ /**
4739
+ * Splits the items a container shows into one stack per position in use. An
4740
+ * item goes to its own position when it has one and to the container's
4741
+ * otherwise. A position with no items gets no stack.
4742
+ *
4743
+ * Stacks come back in `order`, so their order in the page follows the screen
4744
+ * rather than which position happened to be used first, and a stack keeps its
4745
+ * place while the others come and go. A position missing from `order` sorts
4746
+ * after the known ones.
4747
+ *
4748
+ * @function groupIntoPositionStacks
4749
+ * @param items - The items the container shows, in the order they were shown.
4750
+ * @param positionOf - Reads an item's own position, if it has one.
4751
+ * @param containerPosition - Where an item without a position of its own goes.
4752
+ * @param order - Every known position, in the order stacks should render.
4753
+ * @returns One stack per position in use.
4754
+ */
4755
+ function groupIntoPositionStacks(items, positionOf, containerPosition, order) {
4756
+ const byPosition = new Map();
4757
+ for (const item of items) {
4758
+ const position = positionOf(item) ?? containerPosition;
4759
+ const stack = byPosition.get(position);
4760
+ if (stack) {
4761
+ stack.push(item);
4762
+ }
4763
+ else {
4764
+ byPosition.set(position, [item]);
4765
+ }
4766
+ }
4767
+ const rank = (position) => {
4768
+ const index = order.indexOf(position);
4769
+ return index === -1 ? order.length : index;
4770
+ };
4771
+ // Array.prototype.sort is stable, so unknown positions keep the order they
4772
+ // were first used in.
4773
+ return Array.from(byPosition, ([position, stackItems]) => ({
4774
+ key: position === containerPosition ? 'container' : `at-${position}`,
4775
+ position,
4776
+ items: stackItems,
4777
+ })).sort((a, b) => rank(a.position) - rank(b.position));
4778
+ }
4779
+
4615
4780
  /**
4616
4781
  * The `Toast` component provides brief notification messages with optional action and cancel buttons.
4617
4782
  *
@@ -4766,11 +4931,26 @@ let toasts = [];
4766
4931
  // Queued toasts: FIFO queue, one at a time
4767
4932
  let queuedToasts = [];
4768
4933
  let currentQueuedToast = null;
4934
+ // The server has nowhere to portal a toast to, and its copy of this module is
4935
+ // shared by every request, so server rendering reads an empty list. Hydration
4936
+ // reads it too, which keeps the first client render matching the server's.
4937
+ const noToasts = [];
4938
+ const getServerToasts = () => noToasts;
4939
+ // What a container shows right now: the stacked toasts, then the queued one on
4940
+ // screen. ToastContainer renders from this rather than from updates alone, so
4941
+ // toasts raised before it mounted still appear. It is replaced rather than
4942
+ // mutated, and only when listeners are notified, because useSyncExternalStore
4943
+ // needs the same array back between changes. An empty list is `noToasts`
4944
+ // itself, so a container that hydrates with nothing to show reads the same
4945
+ // snapshot the server did and has no reason to render again.
4946
+ let visibleToasts = noToasts;
4947
+ const getVisibleToasts = () => visibleToasts;
4769
4948
  const notifyListeners = () => {
4770
4949
  const allVisible = [...toasts];
4771
4950
  if (currentQueuedToast) {
4772
4951
  allVisible.push(currentQueuedToast);
4773
4952
  }
4953
+ visibleToasts = allVisible.length > 0 ? allVisible : noToasts;
4774
4954
  toastListeners.forEach(listener => listener([...allVisible]));
4775
4955
  };
4776
4956
  const processQueuedToast = () => {
@@ -4873,26 +5053,38 @@ const toast = {
4873
5053
  return () => toastListeners.delete(listener);
4874
5054
  },
4875
5055
  };
5056
+ // The order ToastContainer renders its stacks in: across the top of the
5057
+ // screen, then across the bottom.
5058
+ const toastStackOrder = [
5059
+ 'top-left',
5060
+ 'top-center',
5061
+ 'top-right',
5062
+ 'bottom-left',
5063
+ 'bottom-center',
5064
+ 'bottom-right',
5065
+ ];
4876
5066
  /**
4877
5067
  * Container component for rendering programmatic toasts.
4878
- * Place once at your app root to enable the toast API.
5068
+ * Place once at your app root to enable the toast API. A toast shown with a
5069
+ * `position` appears there, and one shown without goes to the container's
5070
+ * `position`.
4879
5071
  *
4880
5072
  * @function
4881
5073
  * @param {{ position?: ToastPosition }} props - Container props.
4882
5074
  * @returns {JSX.Element | null} The rendered toast container, or null if empty.
4883
5075
  */
4884
- const ToastContainer = ({ position = 'top-right', }) => {
4885
- const [toastList, setToastList] = useState([]);
4886
- useEffect(() => {
4887
- return toast.subscribe(setToastList);
4888
- }, []);
5076
+ const ToastContainer = ({ position = 'top-right' }) => {
5077
+ // Starts from the toasts already showing instead of an empty list, then
5078
+ // follows changes.
5079
+ const toastList = useSyncExternalStore(toast.subscribe, getVisibleToasts, getServerToasts);
4889
5080
  if (typeof document === 'undefined' || toastList.length === 0) {
4890
5081
  return null;
4891
5082
  }
4892
- return createPortal(jsx("div", { className: `toast-container is-${position}`, children: toastList.map(t => {
5083
+ const stacks = groupIntoPositionStacks(toastList, t => t.props.position, position, toastStackOrder);
5084
+ return createPortal(stacks.map(stack => (jsx("div", { className: `toast-container is-${stack.position}`, children: stack.items.map(t => {
4893
5085
  const { queue: _queue, ...toastProps } = t.props;
4894
- return (jsx(Toast, { ...toastProps, position: position, onClose: () => toast.close(t.id) }, t.id));
4895
- }) }), document.body);
5086
+ return (jsx(Toast, { ...toastProps, position: stack.position, onClose: () => toast.close(t.id) }, t.id));
5087
+ }) }, stack.key))), document.body);
4896
5088
  };
4897
5089
 
4898
5090
  /**
@@ -5037,6 +5229,14 @@ const Dialog = forwardRef(({ isOpen, title, message, type = 'default', confirmTe
5037
5229
  Dialog.displayName = 'Dialog';
5038
5230
  let dialogListeners = new Set();
5039
5231
  let currentDialog = null;
5232
+ // DialogContainer renders from the open dialog rather than from updates alone,
5233
+ // so a dialog raised before it mounted still opens once it does.
5234
+ const getCurrentDialog = () => currentDialog;
5235
+ // The server renders no dialog: its copy of this module is shared by every
5236
+ // request, so the dialog it holds may belong to another page. Hydration reads
5237
+ // the same empty value, which keeps the first client render matching the
5238
+ // server's, and the dialog opens right after.
5239
+ const getServerDialog = () => null;
5040
5240
  const notifyDialogListeners = () => {
5041
5241
  dialogListeners.forEach(listener => listener(currentDialog));
5042
5242
  };
@@ -5098,10 +5298,8 @@ const dialog = {
5098
5298
  * @returns {JSX.Element | null} The rendered dialog, or null if none is active.
5099
5299
  */
5100
5300
  const DialogContainer = () => {
5101
- const [current, setCurrent] = React.useState(null);
5102
- useEffect(() => {
5103
- return dialog.subscribe(setCurrent);
5104
- }, []);
5301
+ // Starts from the dialog already open instead of none, then follows changes.
5302
+ const current = useSyncExternalStore(dialog.subscribe, getCurrentDialog, getServerDialog);
5105
5303
  if (!current) {
5106
5304
  return null;
5107
5305
  }
@@ -6175,11 +6373,28 @@ let notifications = [];
6175
6373
  // Queue support
6176
6374
  let queuedNotifications = [];
6177
6375
  let currentQueuedNotification = null;
6376
+ // The server has nowhere to portal a notification to, and its copy of this
6377
+ // module is shared by every request, so server rendering reads an empty list.
6378
+ // Hydration reads it too, which keeps the first client render matching the
6379
+ // server's.
6380
+ const noNotifications = [];
6381
+ const getServerNotifications = () => noNotifications;
6382
+ // What a container shows right now: the stacked notifications, then the queued
6383
+ // one on screen. NotificationContainer renders from this rather than from
6384
+ // updates alone, so notifications raised before it mounted still appear. It is
6385
+ // replaced rather than mutated, and only when listeners are notified, because
6386
+ // useSyncExternalStore needs the same array back between changes. An empty
6387
+ // list is `noNotifications` itself, so a container that hydrates with nothing
6388
+ // to show reads the same snapshot the server did and has no reason to render
6389
+ // again.
6390
+ let visibleNotifications = noNotifications;
6391
+ const getVisibleNotifications = () => visibleNotifications;
6178
6392
  const notifyNotificationListeners = () => {
6179
6393
  const allVisible = [...notifications];
6180
6394
  if (currentQueuedNotification) {
6181
6395
  allVisible.push(currentQueuedNotification);
6182
6396
  }
6397
+ visibleNotifications = allVisible.length > 0 ? allVisible : noNotifications;
6183
6398
  notificationListeners.forEach(listener => listener([...allVisible]));
6184
6399
  };
6185
6400
  const processQueuedNotification = () => {
@@ -6313,28 +6528,28 @@ const NotificationItem = ({ instance, onClose }) => {
6313
6528
  }, [pauseOnHover]);
6314
6529
  return (jsx(Notification, { color: color, isLight: isLight, hasDelete: hasDelete, onDelete: handleClose, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, style: { pointerEvents: 'auto' }, children: urgent ? (jsx("span", { role: "alert", "aria-live": "assertive", children: message })) : (jsx("span", { role: "status", "aria-live": "polite", children: message })) }));
6315
6530
  };
6531
+ // The order NotificationContainer renders its stacks in: across the top of the
6532
+ // screen, then across the bottom.
6533
+ const notificationStackOrder = [
6534
+ 'top-left',
6535
+ 'top',
6536
+ 'top-right',
6537
+ 'bottom-left',
6538
+ 'bottom',
6539
+ 'bottom-right',
6540
+ ];
6316
6541
  /**
6317
- * Container component for rendering programmatic notifications.
6318
- * Place once at your app root to enable the notification API. Mount it
6319
- * before calling `notification`: a notification shown while no container is
6320
- * mounted doesn't appear when one mounts, only alongside the next call.
6542
+ * Inline style that fixes a stack of notifications to its place on the screen.
6321
6543
  *
6322
6544
  * @function
6323
- * @param {{ position?: NotificationPosition }} props - Container props.
6324
- * @returns {JSX.Element | null} The rendered notification container, or null if empty.
6545
+ * @param {NotificationPosition} position - Where the stack sits.
6546
+ * @returns {React.CSSProperties} The stack's style.
6325
6547
  */
6326
- const NotificationContainer = ({ position = 'top-right' }) => {
6327
- const [items, setItems] = useState([]);
6328
- useEffect(() => {
6329
- return notification.subscribe(setItems);
6330
- }, []);
6331
- if (typeof document === 'undefined' || items.length === 0) {
6332
- return null;
6333
- }
6548
+ const notificationStackStyle = (position) => {
6334
6549
  const isBottom = position.startsWith('bottom');
6335
6550
  const isCenter = position === 'top' || position === 'bottom';
6336
6551
  const isRight = position.endsWith('right');
6337
- const containerStyle = {
6552
+ return {
6338
6553
  position: 'fixed',
6339
6554
  zIndex: 100,
6340
6555
  display: 'flex',
@@ -6350,7 +6565,27 @@ const NotificationContainer = ({ position = 'top-right' }) => {
6350
6565
  ? { right: 0, alignItems: 'flex-end' }
6351
6566
  : { left: 0, alignItems: 'flex-start' }),
6352
6567
  };
6353
- return createPortal(jsx("div", { style: containerStyle, children: items.map(item => (jsx(NotificationItem, { instance: item, onClose: notification.close }, item.id))) }), document.body);
6568
+ };
6569
+ /**
6570
+ * Container component for rendering programmatic notifications.
6571
+ * Place once at your app root to enable the notification API. A notification
6572
+ * shown with a `position` appears there, and one shown without goes to the
6573
+ * container's `position`, so the container renders a stack for each position
6574
+ * in use.
6575
+ *
6576
+ * @function
6577
+ * @param {{ position?: NotificationPosition }} props - Container props.
6578
+ * @returns {JSX.Element | null} The rendered notification container, or null if empty.
6579
+ */
6580
+ const NotificationContainer = ({ position = 'top-right' }) => {
6581
+ // Starts from the notifications already showing instead of an empty list,
6582
+ // then follows changes.
6583
+ const items = useSyncExternalStore(notification.subscribe, getVisibleNotifications, getServerNotifications);
6584
+ if (typeof document === 'undefined' || items.length === 0) {
6585
+ return null;
6586
+ }
6587
+ const stacks = groupIntoPositionStacks(items, item => item.options.position, position, notificationStackOrder);
6588
+ return createPortal(stacks.map(stack => (jsx("div", { style: notificationStackStyle(stack.position), children: stack.items.map(item => (jsx(NotificationItem, { instance: item, onClose: notification.close }, item.id))) }, stack.key))), document.body);
6354
6589
  };
6355
6590
 
6356
6591
  /**
@@ -6893,7 +7128,21 @@ const RadiosProvider = RadiosContext.Provider;
6893
7128
  const CheckboxesProvider = CheckboxesContext.Provider;
6894
7129
 
6895
7130
  /**
6896
- * Valid colors for the Checkbox component.
7131
+ * The values the Checkbox `color` prop accepts, as a readonly tuple.
7132
+ *
7133
+ * `CheckboxProps['color']` is typed from it, so the two list the same values.
7134
+ * Map over it to build a color picker, or check a value that arrives at
7135
+ * runtime before passing it in: the component adds no color class for a value
7136
+ * outside the tuple.
7137
+ *
7138
+ * @example
7139
+ * import { Checkbox, checkboxColors } from '@allxsmith/bestax-bulma';
7140
+ *
7141
+ * checkboxColors.map(color => (
7142
+ * <Checkbox key={color} color={color}>
7143
+ * {color}
7144
+ * </Checkbox>
7145
+ * ));
6897
7146
  */
6898
7147
  const checkboxColors = [
6899
7148
  'primary',
@@ -6904,7 +7153,17 @@ const checkboxColors = [
6904
7153
  'danger',
6905
7154
  ];
6906
7155
  /**
6907
- * Valid sizes for the Checkbox component.
7156
+ * The values the Checkbox `size` prop accepts, as a readonly tuple.
7157
+ *
7158
+ * `CheckboxProps['size']` is typed from it, so the two list the same values.
7159
+ * Use it to offer a size choice or to check a value that arrives at runtime:
7160
+ * the component adds no size class for a value outside the tuple. These are
7161
+ * element sizes, not the spacing scale in `validSizes`.
7162
+ *
7163
+ * @example
7164
+ * import { checkboxSizes } from '@allxsmith/bestax-bulma';
7165
+ *
7166
+ * type CheckboxSize = (typeof checkboxSizes)[number];
6908
7167
  */
6909
7168
  const checkboxSizes = ['small', 'normal', 'medium', 'large'];
6910
7169
  /**
@@ -7349,7 +7608,23 @@ label, labelSize, labelProps, horizontal, message, messageColor, fieldClassName,
7349
7608
  File.displayName = 'File';
7350
7609
 
7351
7610
  /**
7352
- * Valid colors for the Radio component.
7611
+ * The values the Radio `color` prop accepts, as a readonly tuple.
7612
+ *
7613
+ * `RadioProps['color']` is typed from it, so the two list the same values.
7614
+ * Map over it to build a color picker, or check a value that arrives at
7615
+ * runtime before passing it in: the component adds no color class for a value
7616
+ * outside the tuple.
7617
+ *
7618
+ * @example
7619
+ * import { Radio, Radios, radioColors } from '@allxsmith/bestax-bulma';
7620
+ *
7621
+ * <Radios name="accent" defaultValue="primary">
7622
+ * {radioColors.map(color => (
7623
+ * <Radio key={color} value={color} color={color}>
7624
+ * {color}
7625
+ * </Radio>
7626
+ * ))}
7627
+ * </Radios>;
7353
7628
  */
7354
7629
  const radioColors = [
7355
7630
  'primary',
@@ -7360,7 +7635,17 @@ const radioColors = [
7360
7635
  'danger',
7361
7636
  ];
7362
7637
  /**
7363
- * Valid sizes for the Radio component.
7638
+ * The values the Radio `size` prop accepts, as a readonly tuple.
7639
+ *
7640
+ * `RadioProps['size']` is typed from it, so the two list the same values. Use
7641
+ * it to offer a size choice or to check a value that arrives at runtime: the
7642
+ * component adds no size class for a value outside the tuple. These are
7643
+ * element sizes, not the spacing scale in `validSizes`.
7644
+ *
7645
+ * @example
7646
+ * import { radioSizes } from '@allxsmith/bestax-bulma';
7647
+ *
7648
+ * type RadioSize = (typeof radioSizes)[number];
7364
7649
  */
7365
7650
  const radioSizes = ['small', 'normal', 'medium', 'large'];
7366
7651
  /**
@@ -7491,7 +7776,21 @@ const RadiosComponent = ({ label, labelSize, labelProps, horizontal, message, me
7491
7776
  const Radios = withSubComponents(RadiosComponent, { Radio }, 'Radios');
7492
7777
 
7493
7778
  /**
7494
- * Valid colors for the Switch component.
7779
+ * The values the Switch `color` and `passiveType` props accept, as a readonly
7780
+ * tuple.
7781
+ *
7782
+ * Both props are typed from it, so they list the same values. Map over it to
7783
+ * build a color picker, or check a value that arrives at runtime before
7784
+ * passing it in: the component adds no class for a value outside the tuple.
7785
+ *
7786
+ * @example
7787
+ * import { Switch, switchColors } from '@allxsmith/bestax-bulma';
7788
+ *
7789
+ * switchColors.map(color => (
7790
+ * <Switch key={color} color={color} defaultChecked>
7791
+ * {color}
7792
+ * </Switch>
7793
+ * ));
7495
7794
  */
7496
7795
  const switchColors = [
7497
7796
  'primary',
@@ -7502,7 +7801,17 @@ const switchColors = [
7502
7801
  'danger',
7503
7802
  ];
7504
7803
  /**
7505
- * Valid sizes for the Switch component.
7804
+ * The values the Switch `size` prop accepts, as a readonly tuple.
7805
+ *
7806
+ * `SwitchProps['size']` is typed from it, so the two list the same values. Use
7807
+ * it to offer a size choice or to check a value that arrives at runtime: the
7808
+ * component adds no size class for a value outside the tuple. These are
7809
+ * element sizes, not the spacing scale in `validSizes`.
7810
+ *
7811
+ * @example
7812
+ * import { switchSizes } from '@allxsmith/bestax-bulma';
7813
+ *
7814
+ * type SwitchSize = (typeof switchSizes)[number];
7506
7815
  */
7507
7816
  const switchSizes = ['small', 'normal', 'medium', 'large'];
7508
7817
  /**
@@ -12998,17 +13307,130 @@ function cssVarToProp(varName) {
12998
13307
  .join('');
12999
13308
  }
13000
13309
  /**
13001
- * Mapping of camelCase prop names to their Bulma CSS variable counterparts.
13310
+ * Prop names `cssVarToProp` would mint that are already `BulmaOtherProps`
13311
+ * helper props: `--bulma-shadow` becomes `shadow` and `--bulma-radius` becomes
13312
+ * `radius`. They stay out of `bulmaVarPropMap`, so on Theme each is the helper
13313
+ * prop it is on every other component, and both variables are still reachable
13314
+ * through `bulmaVars`.
13002
13315
  *
13003
- * `--bulma-shadow` is excluded: `cssVarToProp` would mint it as `shadow`,
13004
- * which already exists as a `BulmaOtherProps` prop applied via
13005
- * `useBulmaClasses` — keeping it out of this map means that
13006
- * prop keeps its existing class-based meaning, while `--bulma-shadow` is
13007
- * still reachable through the `bulmaVars` object.
13316
+ * `radius` was missed here once and set `--bulma-radius` while typed as the
13317
+ * helper (#694). It still writes that variable, so what it did then keeps
13318
+ * working; see `themeRadiusVar`.
13319
+ */
13320
+ const helperPropNames = ['shadow', 'radius'];
13321
+ /**
13322
+ * Mapping of camelCase prop names to their Bulma CSS variable counterparts,
13323
+ * minus the `helperPropNames` above.
13008
13324
  */
13009
13325
  const bulmaVarPropMap = Object.fromEntries(bulmaCssVars
13010
13326
  .map(cssVar => [cssVarToProp(cssVar), cssVar])
13011
- .filter(([prop]) => prop !== 'shadow'));
13327
+ .filter(([prop]) => !helperPropNames.includes(prop)));
13328
+ /**
13329
+ * What each helper value of `radius` writes to `--bulma-radius` on a Theme,
13330
+ * alongside its class.
13331
+ *
13332
+ * Before #694, `radius="radiusless"` wrote `--bulma-radius: radiusless`. That
13333
+ * is not a length, and a declaration reading an invalid variable falls back
13334
+ * to its property's initial value, which for a border radius is 0. So
13335
+ * everything inside the Theme that takes its radius from that variable lost
13336
+ * it, and under `isRoot` so did everything on the page that does. Writing a
13337
+ * real 0 keeps what
13338
+ * people see and makes it valid CSS. Keyed by the helper values, so adding
13339
+ * one means saying what it writes.
13340
+ */
13341
+ const radiusHelperVars = {
13342
+ radiusless: '0',
13343
+ };
13344
+ /**
13345
+ * What `radius` writes to `--bulma-radius` on a Theme, or `undefined` when it
13346
+ * writes nothing.
13347
+ *
13348
+ * A helper value writes its `radiusHelperVars` entry. Any other non-empty
13349
+ * string is written as given: before #694 every `radius` on Theme set the
13350
+ * variable whatever its type said, and a JavaScript caller passing a length
13351
+ * got the radius it asked for. That keeps working so nothing breaks, but the
13352
+ * type rejects it, so it warns in development and points at `bulmaVars`.
13353
+ *
13354
+ * Only a string writes anything, because only a string ever set the variable
13355
+ * to something usable: an empty value or zero was dropped, and a boolean or
13356
+ * any other bare number is not a length. The helper ignores those too, the
13357
+ * way it does on every other component.
13358
+ */
13359
+ const themeRadiusVar = (radius) => {
13360
+ if (typeof radius !== 'string' || radius === '') {
13361
+ return undefined;
13362
+ }
13363
+ if (validRadii.includes(radius)) {
13364
+ return radiusHelperVars[radius];
13365
+ }
13366
+ warnOnce('Theme:radius-variable', `[bestax-bulma] <Theme radius="${radius}">: setting --bulma-radius ` +
13367
+ 'through the radius prop is deprecated and will stop working in a ' +
13368
+ 'future major version. On Theme, as on every other component, radius ' +
13369
+ `is the "${validRadii.join('", "')}" helper. Set the variable with ` +
13370
+ `bulmaVars={{ '--bulma-radius': '${radius}' }} instead.`);
13371
+ return radius;
13372
+ };
13373
+ /** The one `<style>` element every `isRoot` Theme writes into. */
13374
+ const ROOT_STYLE_ID = 'bestax-bulma-theme-vars';
13375
+ /**
13376
+ * The `:root` rules of every mounted `isRoot` Theme that has any, keyed by the
13377
+ * Theme's render order.
13378
+ *
13379
+ * Root Themes share one `<style>` element, so each keeps its rules here and
13380
+ * the element is rebuilt from all of them whenever one mounts, changes or
13381
+ * unmounts. Before this each Theme overwrote the element with only its own
13382
+ * rules, and the first to unmount removed it for all of them (#736).
13383
+ *
13384
+ * The key is a number each Theme takes when it first renders. React renders a
13385
+ * parent before its children and an earlier sibling before a later one, so an
13386
+ * inner or later-mounted root Theme sorts later, comes later in the
13387
+ * stylesheet, and wins a variable two of them set, as an inner scoped Theme
13388
+ * does. Effects would give the wrong answer for nesting: React runs a child's
13389
+ * effects before its parent's. Only the relative order matters, so a number
13390
+ * skipped by StrictMode calling the initializer twice, or by a render React
13391
+ * throws away, is harmless. The key never changes, so an update keeps its
13392
+ * place and re-rendering one Theme never changes which one wins.
13393
+ */
13394
+ const rootThemeRules = new Map();
13395
+ let nextRootOrder = 0;
13396
+ /**
13397
+ * Write every registered root Theme's rules into the shared element, creating
13398
+ * it when needed and removing it once no Theme has any rules left.
13399
+ */
13400
+ const renderRootThemeRules = () => {
13401
+ let element = document.getElementById(ROOT_STYLE_ID);
13402
+ if (rootThemeRules.size === 0) {
13403
+ element?.remove();
13404
+ return;
13405
+ }
13406
+ if (!element) {
13407
+ element = document.createElement('style');
13408
+ element.id = ROOT_STYLE_ID;
13409
+ document.head.appendChild(element);
13410
+ }
13411
+ element.textContent = [...rootThemeRules]
13412
+ .sort(([a], [b]) => a - b)
13413
+ .map(([, rules]) => rules)
13414
+ .join('\n');
13415
+ };
13416
+ /**
13417
+ * Record one Theme's `:root` rules, where an empty string means it has none,
13418
+ * and rebuild the shared element if that changed anything. A Theme with no
13419
+ * rules never touches the element, which is what it did before the registry
13420
+ * too.
13421
+ */
13422
+ const setRootThemeRules = (order, rules) => {
13423
+ if ((rootThemeRules.get(order) ?? '') === rules) {
13424
+ return;
13425
+ }
13426
+ if (rules) {
13427
+ rootThemeRules.set(order, rules);
13428
+ }
13429
+ else {
13430
+ rootThemeRules.delete(order);
13431
+ }
13432
+ renderRootThemeRules();
13433
+ };
13012
13434
  /**
13013
13435
  * Theme component that injects Bulma CSS variables either globally or locally.
13014
13436
  *
@@ -13025,7 +13447,11 @@ const bulmaVarPropMap = Object.fromEntries(bulmaCssVars
13025
13447
  * <Button color="primary">Themed</Button>
13026
13448
  * </Theme>
13027
13449
  */
13028
- const Theme = ({ bulmaVars = {}, children, className, isRoot = false, colorMode, ...restProps }) => {
13450
+ const Theme = ({ bulmaVars = {}, children, className, isRoot = false, colorMode, radius, ...restProps }) => {
13451
+ const radiusVar = themeRadiusVar(radius);
13452
+ const radiusHelper = validRadii.includes(radius)
13453
+ ? radius
13454
+ : undefined;
13029
13455
  // Extract Bulma variable props from restProps
13030
13456
  const { bulmaVarProps, otherProps } = useMemo(() => {
13031
13457
  const varProps = {};
@@ -13040,8 +13466,12 @@ const Theme = ({ bulmaVars = {}, children, className, isRoot = false, colorMode,
13040
13466
  }
13041
13467
  return { bulmaVarProps: varProps, otherProps: otherPropsObj };
13042
13468
  }, [restProps]);
13043
- // Use Bulma classes for styling (only when not isRoot)
13044
- const { bulmaHelperClasses, rest } = useBulmaClasses(otherProps);
13469
+ // Use Bulma classes for styling (only when not isRoot). Only a helper value
13470
+ // of `radius` reaches the helper; a legacy string went to the variable.
13471
+ const { bulmaHelperClasses, rest } = useBulmaClasses({
13472
+ ...otherProps,
13473
+ radius: radiusHelper,
13474
+ });
13045
13475
  // Merge bulmaVars and individual props, with props taking precedence
13046
13476
  const mergedVars = useMemo(() => {
13047
13477
  const vars = { ...bulmaVars };
@@ -13050,37 +13480,34 @@ const Theme = ({ bulmaVars = {}, children, className, isRoot = false, colorMode,
13050
13480
  vars[cssVar] = bulmaVarProps[propName];
13051
13481
  }
13052
13482
  }
13483
+ if (radiusVar !== undefined) {
13484
+ vars['--bulma-radius'] = radiusVar;
13485
+ }
13053
13486
  return vars;
13054
- }, [bulmaVars, bulmaVarProps]);
13055
- // Inject CSS variables globally at :root level
13056
- useEffect(() => {
13487
+ }, [bulmaVars, bulmaVarProps, radiusVar]);
13488
+ // This Theme's place among root Themes; see `rootThemeRules`.
13489
+ const [rootOrder] = useState(() => nextRootOrder++);
13490
+ // The `:root` rules this Theme contributes, or '' when it contributes none.
13491
+ const rootRules = useMemo(() => {
13057
13492
  if (!isRoot) {
13058
- return;
13059
- }
13060
- const validVars = Object.entries(mergedVars).filter(([key, value]) => bulmaCssVars.includes(key) && value);
13061
- if (validVars.length === 0) {
13062
- return;
13063
- }
13064
- // Create and inject a style element for global CSS variables
13065
- const styleId = 'bestax-bulma-theme-vars';
13066
- let styleElement = document.getElementById(styleId);
13067
- if (!styleElement) {
13068
- styleElement = document.createElement('style');
13069
- styleElement.id = styleId;
13070
- document.head.appendChild(styleElement);
13493
+ return '';
13071
13494
  }
13072
- const cssRules = validVars
13495
+ const cssRules = Object.entries(mergedVars)
13496
+ .filter(([key, value]) => bulmaCssVars.includes(key) && value)
13073
13497
  .map(([key, value]) => `${key}: ${value};`)
13074
13498
  .join(' ');
13075
- styleElement.textContent = `:root { ${cssRules} }`;
13076
- // Cleanup function to remove the style element when component unmounts
13077
- return () => {
13078
- const element = document.getElementById(styleId);
13079
- if (element) {
13080
- element.remove();
13081
- }
13082
- };
13499
+ return cssRules ? `:root { ${cssRules} }` : '';
13083
13500
  }, [mergedVars, isRoot]);
13501
+ // Inject CSS variables globally at :root level, alongside any other root
13502
+ // Themes. Clearing `isRoot` or every variable passes '', which withdraws
13503
+ // this Theme's rules.
13504
+ useEffect(() => {
13505
+ setRootThemeRules(rootOrder, rootRules);
13506
+ }, [rootOrder, rootRules]);
13507
+ // Withdraw them on unmount. Kept apart from the effect above so a change
13508
+ // rewrites the shared element in place rather than removing and recreating
13509
+ // it between the cleanup and the next run.
13510
+ useEffect(() => () => setRootThemeRules(rootOrder, ''), [rootOrder]);
13084
13511
  // Toggle Bulma's light/dark scheme by writing the `data-theme` attribute on
13085
13512
  // the document root (<html>). This is always global, even on a scoped Theme.
13086
13513
  // `'system'` removes the attribute so Bulma follows the OS preference.