@allxsmith/bestax-bulma 5.40.0 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/bestax.css +1 -1
  2. package/dist/bestax.css.map +1 -1
  3. package/dist/esm/components/Carousel.js +282 -103
  4. package/dist/esm/components/Carousel.js.map +1 -1
  5. package/dist/esm/components/Menu.js +77 -2
  6. package/dist/esm/components/Menu.js.map +1 -1
  7. package/dist/esm/components/Modal.js +3 -3
  8. package/dist/esm/components/Modal.js.map +1 -1
  9. package/dist/esm/components/Popover.js +2 -2
  10. package/dist/esm/components/Popover.js.map +1 -1
  11. package/dist/esm/components/Sidebar.js +2 -2
  12. package/dist/esm/components/Sidebar.js.map +1 -1
  13. package/dist/esm/elements/Image.js +4 -12
  14. package/dist/esm/elements/Image.js.map +1 -1
  15. package/dist/esm/helpers/useFocusTrap.js +60 -41
  16. package/dist/esm/helpers/useFocusTrap.js.map +1 -1
  17. package/dist/extras.css +1 -1
  18. package/dist/extras.css.map +1 -1
  19. package/dist/index.cjs +442 -181
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.esm.js +1 -1
  22. package/dist/types/components/Carousel.d.ts +96 -8
  23. package/dist/types/components/Menu.d.ts +49 -0
  24. package/dist/types/elements/Image.d.ts +6 -1
  25. package/dist/types/helpers/useFocusTrap.d.ts +16 -8
  26. package/dist/types-cjs/components/Carousel.d.ts +96 -8
  27. package/dist/types-cjs/components/Menu.d.ts +49 -0
  28. package/dist/types-cjs/elements/Image.d.ts +6 -1
  29. package/dist/types-cjs/helpers/useFocusTrap.d.ts +16 -8
  30. package/dist/versions/bestax-no-dark-mode.css +1 -1
  31. package/dist/versions/bestax-no-dark-mode.css.map +1 -1
  32. package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
  33. package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
  34. package/dist/versions/bestax-no-helpers.css +1 -1
  35. package/dist/versions/bestax-no-helpers.css.map +1 -1
  36. package/dist/versions/bestax-prefixed.css +1 -1
  37. package/dist/versions/bestax-prefixed.css.map +1 -1
  38. package/package.json +6 -6
  39. package/src/scss/components/_carousel.scss +77 -24
  40. package/src/scss/components/_menu.scss +130 -3
package/dist/index.cjs CHANGED
@@ -3593,6 +3593,28 @@ const EmptyState = withSubComponents(EmptyStateRoot, {
3593
3593
  Actions: EmptyStateActions,
3594
3594
  }, 'EmptyState');
3595
3595
 
3596
+ // INTERNAL: deliberately not exported from src/index.ts.
3597
+ /** The major version of the React this bundle is running against. */
3598
+ const runningMajor = () => Number(React.version.split('.')[0]);
3599
+ /**
3600
+ * The prop that makes an element inert, spelled for the React in use, or
3601
+ * nothing when `inert` is false.
3602
+ *
3603
+ * Both majors must render `inert=""`, and each warns on the other's spelling
3604
+ * and then drops the attribute. React 19 treats `inert` as a boolean and
3605
+ * rejects the empty string. React 18 does not know the attribute, so it
3606
+ * passes a string through and rejects `true`.
3607
+ */
3608
+ const inertProps = (inert, major = runningMajor()) => {
3609
+ if (!inert)
3610
+ return {};
3611
+ // The cast is for React 18's spelling: this package's types are React 19's,
3612
+ // where `inert` is a boolean.
3613
+ return (major >= 19
3614
+ ? { inert: true }
3615
+ : { inert: '' });
3616
+ };
3617
+
3596
3618
  // Context to track MenuList nesting level
3597
3619
  const MenuListLevelContext = React.createContext(0);
3598
3620
  /**
@@ -3702,11 +3724,83 @@ const MenuItem = React.forwardRef(function MenuItem(itemProps, ref) {
3702
3724
  : {}), children: labelChildren }), nestedMenuLists] }));
3703
3725
  });
3704
3726
  MenuItem.displayName = 'MenuItem';
3727
+ /**
3728
+ * A section of the menu that opens and closes: its heading is a button drawn like `Menu.Label`, and its `Menu.List` of links shows while it's open.
3729
+ *
3730
+ * It's APG's disclosure navigation, for a sidebar of link groups: the links
3731
+ * stay ordinary links, each its own tab stop, and a closed section's list is
3732
+ * `hidden` and `inert`, so Tab and assistive tech skip it. It goes directly in
3733
+ * `Menu`, holding a `Menu.List`. Closing it while focus is on one of its links
3734
+ * moves focus to its heading.
3735
+ *
3736
+ * @function
3737
+ * @param {MenuSectionProps} props - Props for the MenuSection component.
3738
+ * @returns {JSX.Element} The rendered menu section.
3739
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/ | APG Disclosure Navigation Menu example}
3740
+ *
3741
+ * @example
3742
+ * <Menu>
3743
+ * <Menu.Section label="Components" defaultOpen>
3744
+ * <Menu.List>
3745
+ * <Menu.Item href="/button" active>Button</Menu.Item>
3746
+ * <Menu.Item href="/card">Card</Menu.Item>
3747
+ * </Menu.List>
3748
+ * </Menu.Section>
3749
+ * </Menu>
3750
+ */
3751
+ const MenuSection = ({ className, label, children, open: controlledOpen, defaultOpen = false, onOpenChange, ...props }) => {
3752
+ const { bulmaHelperClasses, rest } = useBulmaClasses(props);
3753
+ const sectionClass = usePrefixedClassNames('menu-section');
3754
+ const toggleClass = usePrefixedClassNames('menu-section-toggle');
3755
+ const labelClass = usePrefixedClassNames('menu-section-label');
3756
+ const chevronClass = usePrefixedClassNames('menu-section-chevron');
3757
+ const listId = React.useId();
3758
+ const toggleRef = React.useRef(null);
3759
+ const listRef = React.useRef(null);
3760
+ // The caller owns the state when `open` is set, and the section does
3761
+ // otherwise, as with Collapse. The button reports in both modes.
3762
+ const isControlled = controlledOpen !== undefined;
3763
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
3764
+ const isOpen = controlledOpen ?? internalOpen;
3765
+ const handleClick = () => {
3766
+ const next = !isOpen;
3767
+ onOpenChange?.(next);
3768
+ if (!isControlled)
3769
+ setInternalOpen(next);
3770
+ };
3771
+ // An app holding `open` can close a section while focus is on one of its
3772
+ // links, as a sidebar that keeps only the current route's section open
3773
+ // does after a link is followed. The browser then drops focus to <body>
3774
+ // a frame later, and the next Tab starts from the top of the page. The
3775
+ // heading is where the reader was, so focus goes there. A layout effect,
3776
+ // because it runs in the same task as the commit that hid the list, while
3777
+ // the link still has focus. A close from the button itself finds focus on
3778
+ // the button and leaves it.
3779
+ useClientLayoutEffect(() => {
3780
+ if (isOpen)
3781
+ return;
3782
+ const list = listRef.current;
3783
+ if (list.contains(getActiveElementInTree(list))) {
3784
+ toggleRef.current.focus();
3785
+ }
3786
+ }, [isOpen]);
3787
+ // A real button, so Enter and Space work with no key handler, and
3788
+ // `type="button"`, so a menu inside a form isn't submitted by it.
3789
+ //
3790
+ // A closed list is `hidden`, so it isn't drawn and find-in-page doesn't
3791
+ // reach it, and `inert`, so its links stay out of Tab and assistive tech
3792
+ // even if a stylesheet sets `display` on it and shows it anyway. The markup
3793
+ // is the same from the server and on both React majors, which `inertProps`
3794
+ // sees to. It stays mounted, so `aria-controls` always names an element
3795
+ // that exists.
3796
+ return (jsxRuntime.jsxs("div", { className: classNames(sectionClass, bulmaHelperClasses, className), ...rest, children: [jsxRuntime.jsxs("button", { ref: toggleRef, type: "button", className: toggleClass, "aria-expanded": isOpen, "aria-controls": listId, onClick: handleClick, children: [jsxRuntime.jsx("span", { className: labelClass, children: label }), jsxRuntime.jsx("span", { className: chevronClass, "aria-hidden": "true" })] }), jsxRuntime.jsx("div", { ref: listRef, id: listId, hidden: !isOpen, ...inertProps(!isOpen), children: children })] }));
3797
+ };
3705
3798
  // Attach static subcomponents
3706
3799
  const Menu = withSubComponents(MenuComponent, {
3707
3800
  Label: MenuLabel,
3708
3801
  List: MenuList,
3709
3802
  Item: MenuItem,
3803
+ Section: MenuSection,
3710
3804
  }, 'Menu');
3711
3805
 
3712
3806
  const validMessageSizes = ['small', 'medium', 'large'];
@@ -4004,7 +4098,7 @@ const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
4004
4098
  * can read `-1` for an editable region or an `<embed>` that Tab still visits,
4005
4099
  * and `0` for a `<video>` without controls that it doesn't. So a valid
4006
4100
  * `tabindex` attribute decides, and otherwise the element's kind does.
4007
- * Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
4101
+ * Internal like `focusTabStop`: `src/index.ts` names what it takes from here.
4008
4102
  */
4009
4103
  function isTabbableKind(el) {
4010
4104
  const tabindex = el.getAttribute('tabindex');
@@ -4237,31 +4331,25 @@ function findTabStops(container) {
4237
4331
  return stop;
4238
4332
  };
4239
4333
  const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
4240
- const first = candidates.find(isStop);
4241
- if (!first)
4242
- return null;
4243
- let last = first;
4244
- for (let i = candidates.length - 1; candidates[i] !== first; i--) {
4245
- if (isStop(candidates[i])) {
4246
- last = candidates[i];
4247
- break;
4334
+ // Checked only as they're reached, and the checks are kept, so finding
4335
+ // both ends costs what it did when only the ends were looked for.
4336
+ function* from(fromLast) {
4337
+ for (let i = 0; i < candidates.length; i++) {
4338
+ const el = candidates[fromLast ? candidates.length - 1 - i : i];
4339
+ if (isStop(el))
4340
+ yield el;
4248
4341
  }
4249
4342
  }
4343
+ const first = from(false).next().value;
4344
+ if (!first)
4345
+ return null;
4250
4346
  return {
4251
4347
  first,
4252
- last,
4348
+ last: from(true).next().value,
4253
4349
  stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
4350
+ from,
4254
4351
  };
4255
4352
  }
4256
- /**
4257
- * The first tab stop inside `container`, found the way the trap finds it, or
4258
- * `null` when there is none. For a component that moves focus into a panel
4259
- * itself rather than through the hook. `src/index.ts` names its exports from
4260
- * this module, so this one stays internal.
4261
- */
4262
- function firstTabStop(container) {
4263
- return findTabStops(container)?.first ?? null;
4264
- }
4265
4353
  /**
4266
4354
  * The element that has focus in the container's own document (an iframe's,
4267
4355
  * when it renders into one), inside open shadow roots too. Focus inside a
@@ -4271,24 +4359,47 @@ function firstTabStop(container) {
4271
4359
  * was inside a shadow root that was removed, so this falls back for it.
4272
4360
  */
4273
4361
  const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
4362
+ /**
4363
+ * Moves focus to the first tab stop inside `container`, or the last when
4364
+ * `fromLast`, and to the container when there is none. Every move a trap
4365
+ * makes goes through here: into the container as it opens, to the other end
4366
+ * on a wrap, and back in after a Tab the browser carried out.
4367
+ *
4368
+ * A stop that doesn't take focus is passed over for the next one in. An
4369
+ * `<object>` or `<embed>` with nothing to show is one: it counts as a stop by
4370
+ * its kind, but browsers refuse to focus it, and which ones they refuse
4371
+ * depends on the browser and on what has loaded, so only trying tells. A
4372
+ * stop took focus when focus moved, wherever it went (a stop can hand focus
4373
+ * on), or when focus was on it already. `src/index.ts` names its exports from
4374
+ * this module, so this one stays internal.
4375
+ */
4376
+ function focusTabStop(container, fromLast = false) {
4377
+ const doc = container.ownerDocument;
4378
+ for (const stop of findTabStops(container)?.from(fromLast) ?? []) {
4379
+ const before = focusedElement(doc);
4380
+ stop.focus();
4381
+ const now = focusedElement(doc);
4382
+ if (now !== before || inside(stop, now))
4383
+ return;
4384
+ }
4385
+ container.focus();
4386
+ }
4274
4387
  /**
4275
4388
  * Handles a Tab for a trap on `container`. When the browser has no tab stop
4276
4389
  * left in that direction inside the container, focus goes to the stop at the
4277
4390
  * other end and the key is marked used, and with no stops at all the
4278
4391
  * container takes focus. Focus outside the container goes in at that end
4279
- * too. Otherwise the browser moves focus, and this returns the end to send it
4280
- * to should that Tab take it out anyway.
4392
+ * too. Returns whether it moved focus; otherwise the browser does.
4281
4393
  */
4282
4394
  function wrapTab(e, container) {
4283
4395
  const stops = findTabStops(container);
4284
4396
  if (!stops) {
4285
4397
  e.preventDefault();
4286
4398
  container.focus();
4287
- return null;
4399
+ return true;
4288
4400
  }
4289
4401
  const focused = focusedElement(container.ownerDocument);
4290
4402
  const current = stops.stopFor(focused);
4291
- const end = e.shiftKey ? stops.last : stops.first;
4292
4403
  // Wrap whenever the browser has no stop left in that direction inside the
4293
4404
  // container. Comparing positions rather than testing `=== first` also
4294
4405
  // covers focus resting on the container or on an element outside the tab
@@ -4298,10 +4409,10 @@ function wrapTab(e, container) {
4298
4409
  ? !follows(container, stops.first, current)
4299
4410
  : !follows(container, current, stops.last))) {
4300
4411
  e.preventDefault();
4301
- end.focus();
4302
- return null;
4412
+ focusTabStop(container, e.shiftKey);
4413
+ return true;
4303
4414
  }
4304
- return end;
4415
+ return false;
4305
4416
  }
4306
4417
  /**
4307
4418
  * Holds Tab inside the element `container` returns until the returned
@@ -4313,7 +4424,7 @@ function wrapTab(e, container) {
4313
4424
  * overlay whose nodes are remounted while it's open, as moving to another
4314
4425
  * portal target does, stays trapped without attaching again. `holds` is asked
4315
4426
  * on every Tab, and the trap leaves the key alone while it says no. Internal
4316
- * like `firstTabStop`. It's for an overlay that holds Tab from a listener of
4427
+ * like `focusTabStop`. It's for an overlay that holds Tab from a listener of
4317
4428
  * its own on `window` and asks the overlay stack first, so it stands aside
4318
4429
  * while another overlay is open over it, which a trap listening on the
4319
4430
  * container can't do.
@@ -4324,9 +4435,10 @@ function trapTab(container, keyTarget, holds = () => true) {
4324
4435
  // A window and an element type their listeners differently, so the trap
4325
4436
  // listens through the base type both share.
4326
4437
  const keys = keyTarget;
4327
- // Where focus goes if a Tab the trap let the browser handle takes it out,
4328
- // held until that Tab has finished moving focus.
4329
- let wrapTo = null;
4438
+ // The end focus goes back to if a Tab the trap let the browser handle
4439
+ // takes it out (the last for Shift+Tab), held until that Tab has finished
4440
+ // moving focus, and `null` otherwise.
4441
+ let wrapFromLast = null;
4330
4442
  let wrapTimer;
4331
4443
  // `keyTarget` is any target, so the listener takes the base event type.
4332
4444
  const handleKeyDown = (event) => {
@@ -4335,24 +4447,25 @@ function trapTab(container, keyTarget, holds = () => true) {
4335
4447
  // A trap nested inside this one has already wrapped focus.
4336
4448
  if (e.key !== 'Tab' || e.defaultPrevented || !box || !holds())
4337
4449
  return;
4338
- const end = wrapTab(e, box);
4339
- if (!end)
4450
+ if (wrapTab(e, box))
4340
4451
  return;
4341
- wrapTo = end;
4452
+ wrapFromLast = e.shiftKey;
4342
4453
  clearTimeout(wrapTimer);
4343
4454
  wrapTimer = setTimeout(() => {
4344
- wrapTo = null;
4455
+ wrapFromLast = null;
4345
4456
  });
4346
4457
  };
4347
4458
  // The browser moves focus as soon as the keydown is over, so focus that
4348
- // lands outside while `wrapTo` is set got there by that Tab.
4459
+ // lands outside while `wrapFromLast` is set got there by that Tab.
4349
4460
  const handleFocusIn = (e) => {
4350
4461
  const box = container();
4351
- if (!wrapTo || !box || inside(box, e.composedPath()[0]))
4462
+ if (wrapFromLast === null || !box)
4352
4463
  return;
4353
- const target = wrapTo;
4354
- wrapTo = null;
4355
- target.focus();
4464
+ if (inside(box, e.composedPath()[0]))
4465
+ return;
4466
+ const fromLast = wrapFromLast;
4467
+ wrapFromLast = null;
4468
+ focusTabStop(box, fromLast);
4356
4469
  };
4357
4470
  keys.addEventListener('keydown', handleKeyDown);
4358
4471
  doc.addEventListener('focusin', handleFocusIn, true);
@@ -4438,7 +4551,7 @@ function useFocusTrap(containerRef, options = {}) {
4438
4551
  const inTrap = initial && inside(container, initial) ? initial : null;
4439
4552
  inTrap?.focus();
4440
4553
  if (!inTrap || !inside(container, focusedElement(doc))) {
4441
- (findTabStops(container)?.first ?? container).focus();
4554
+ focusTabStop(container);
4442
4555
  }
4443
4556
  const releaseTab = trapTab(() => container, container);
4444
4557
  return () => {
@@ -4727,8 +4840,8 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
4727
4840
  previouslyFocusedRef.current =
4728
4841
  getDeepestActiveElement();
4729
4842
  const node = modalRootRef.current;
4730
- const focusable = node ? firstTabStop(node) : null;
4731
- (focusable ?? node)?.focus();
4843
+ if (node)
4844
+ focusTabStop(node);
4732
4845
  return () => {
4733
4846
  // Only hand focus back if this modal still owns it: closing a background
4734
4847
  // modal must not pull focus out of one that is still open on top. A
@@ -6158,28 +6271,6 @@ const Loading = ({ active = false, isFullPage = false, size, color, canCancel =
6158
6271
  return (jsxRuntime.jsxs("div", { className: combinedClasses, role: "alert", "aria-busy": "true", "aria-label": "Loading", ...rest, ref: loadingRef, children: [jsxRuntime.jsx("div", { className: combinedOverlayClasses, onClick: handleOverlayClick, "aria-hidden": "true" }), jsxRuntime.jsxs("div", { className: loadingContentClass, children: [indicator ? (jsxRuntime.jsx("span", { className: loadingIconCustomClass, children: indicator })) : (jsxRuntime.jsx("span", { className: combinedIconClasses })), children && jsxRuntime.jsx("div", { className: loadingTextClass, children: children }), canCancel && (jsxRuntime.jsx("button", { type: "button", className: loadingCancelClass, onClick: onCancel, "aria-label": "Cancel loading", children: "Cancel" }))] })] }));
6159
6272
  };
6160
6273
 
6161
- // INTERNAL: deliberately not exported from src/index.ts.
6162
- /** The major version of the React this bundle is running against. */
6163
- const runningMajor = () => Number(React.version.split('.')[0]);
6164
- /**
6165
- * The prop that makes an element inert, spelled for the React in use, or
6166
- * nothing when `inert` is false.
6167
- *
6168
- * Both majors must render `inert=""`, and each warns on the other's spelling
6169
- * and then drops the attribute. React 19 treats `inert` as a boolean and
6170
- * rejects the empty string. React 18 does not know the attribute, so it
6171
- * passes a string through and rejects `true`.
6172
- */
6173
- const inertProps = (inert, major = runningMajor()) => {
6174
- if (!inert)
6175
- return {};
6176
- // The cast is for React 18's spelling: this package's types are React 19's,
6177
- // where `inert` is a boolean.
6178
- return (major >= 19
6179
- ? { inert: true }
6180
- : { inert: '' });
6181
- };
6182
-
6183
6274
  /**
6184
6275
  * `null` outside a group. A `Collapse` also resets it to `null` for its own
6185
6276
  * subtree, so a Collapse nested in an item's content keeps its own state
@@ -7277,7 +7368,7 @@ const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen
7277
7368
  const panel = panelRef.current;
7278
7369
  if (!panelMounted || trapFocus || !panel)
7279
7370
  return undefined;
7280
- (firstTabStop(panel) ?? panel).focus();
7371
+ focusTabStop(panel);
7281
7372
  return () => {
7282
7373
  const doc = panel.ownerDocument;
7283
7374
  const now = getDeepestActiveElement(doc);
@@ -8614,7 +8705,7 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
8614
8705
  // the sidebar's other controls out of reach. It leaves focus alone
8615
8706
  // then, and the cleanup below finds focus outside and leaves it too.
8616
8707
  if (overlay || !isModalOverlayOpen()) {
8617
- (firstTabStop(element) ?? element).focus();
8708
+ focusTabStop(element);
8618
8709
  }
8619
8710
  return () => {
8620
8711
  // Only while the sidebar still holds focus: closing one under another
@@ -9365,6 +9456,69 @@ const DefaultPrevIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org
9365
9456
  * @returns {JSX.Element} The rendered SVG chevron-right icon.
9366
9457
  */
9367
9458
  const DefaultNextIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: jsxRuntime.jsx("polyline", { points: "9 18 15 12 9 6" }) }));
9459
+ /**
9460
+ * The media queries Bulma's `fullhd`, `widescreen`, `desktop` and `tablet`
9461
+ * mixins open with, widest first, at Bulma's default breakpoints. The
9462
+ * stylesheet picks the count in view with those mixins; these tell the
9463
+ * script which count that is. Narrower than the last one is `mobile`.
9464
+ */
9465
+ const VIEWPORT_QUERIES = [
9466
+ 'screen and (min-width: 1408px)',
9467
+ 'screen and (min-width: 1216px)',
9468
+ 'screen and (min-width: 1024px)',
9469
+ 'screen and (min-width: 769px), print',
9470
+ ];
9471
+ /** `useBulmaViewport`'s answer below the tablet breakpoint. */
9472
+ const MOBILE = VIEWPORT_QUERIES.length;
9473
+ /**
9474
+ * The `--bulma-carousel-items-to-show*` variable each count prop writes, in
9475
+ * the order the component lists them: the viewports as above, mobile, and
9476
+ * last `itemsToShow` itself.
9477
+ */
9478
+ const COUNT_SUFFIXES = [
9479
+ '-fullhd',
9480
+ '-widescreen',
9481
+ '-desktop',
9482
+ '-tablet',
9483
+ '-mobile',
9484
+ '',
9485
+ ];
9486
+ /**
9487
+ * Which of Bulma's viewports the window is in: the index of the widest
9488
+ * `VIEWPORT_QUERIES` entry that matches, `MOBILE` below them all, or `-1`
9489
+ * when that isn't known (on the server, before the first layout effect, and
9490
+ * whenever `enabled` is off, so a carousel with one count everywhere
9491
+ * subscribes to nothing).
9492
+ *
9493
+ * @function
9494
+ * @param {boolean} enabled - Whether any viewport-specific count is set.
9495
+ * @returns {number} The viewport, as described above.
9496
+ */
9497
+ function useBulmaViewport(enabled) {
9498
+ const [viewport, setViewport] = React.useState(-1);
9499
+ useClientLayoutEffect(() => {
9500
+ if (!enabled || typeof window.matchMedia !== 'function')
9501
+ return undefined;
9502
+ const lists = VIEWPORT_QUERIES.map(query => window.matchMedia(query));
9503
+ const update = () => {
9504
+ const index = lists.findIndex(list => list.matches);
9505
+ setViewport(index < 0 ? MOBILE : index);
9506
+ };
9507
+ update();
9508
+ lists.forEach(list => list.addEventListener('change', update));
9509
+ return () => lists.forEach(list => list.removeEventListener('change', update));
9510
+ }, [enabled]);
9511
+ return enabled ? viewport : -1;
9512
+ }
9513
+ /**
9514
+ * A count prop as the whole number the carousel works with: rounded down,
9515
+ * and never below 1, so a zero or a stray `NaN` can't divide by nothing.
9516
+ *
9517
+ * @function
9518
+ * @param {number | undefined} count - The prop as given.
9519
+ * @returns {number | undefined} The count, or `undefined` when unset.
9520
+ */
9521
+ const wholeCount = (count) => count === undefined ? undefined : Math.max(1, Math.floor(count) || 1);
9368
9522
  /**
9369
9523
  * The `Carousel` component provides an image/content slider with navigation arrows and indicators.
9370
9524
  *
@@ -9391,15 +9545,28 @@ const DefaultNextIcon = () => (jsxRuntime.jsx("svg", { xmlns: "http://www.w3.org
9391
9545
  * <img src="image2.jpg" alt="Slide 2" />
9392
9546
  * </CarouselItem>
9393
9547
  * </Carousel>
9548
+ *
9549
+ * @example
9550
+ * // Three cards in view on desktop, two on tablet, one on mobile
9551
+ * <Carousel itemsToShow={1} itemsToShowTablet={2} itemsToShowDesktop={3}>
9552
+ * {cards.map(card => (
9553
+ * <CarouselItem key={card.id}>{card.body}</CarouselItem>
9554
+ * ))}
9555
+ * </Carousel>
9394
9556
  */
9395
- const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, interval = 5000, pauseOnHover = true, repeat = true, hasDrag = true, arrow = true, arrowHover = false, indicator = true, indicatorInside = false, indicatorPosition = 'bottom', indicatorStyle = 'dots', iconPrev, iconNext, iconLibrary, iconVariant, iconSize, iconFeatures, arrowBackground = true, arrowColor, ariaLabel = 'Image carousel', onChange, className, children, ...props }, ref) => {
9557
+ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, interval = 5000, pauseOnHover = true, repeat = true, hasDrag = true, itemsToShow, itemsToShowMobile, itemsToShowTablet, itemsToShowDesktop, itemsToShowWidescreen, itemsToShowFullhd, itemsToScroll, 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, style, children, ...props }, ref) => {
9396
9558
  const { bulmaHelperClasses, rest } = useBulmaClasses(props);
9397
9559
  const carouselRef = React.useRef(null);
9398
9560
  // Cleanup returned by a consumer's callback ref on attach, held until detach.
9399
9561
  const consumerCleanupRef = React.useRef(null);
9400
9562
  const containerRef = React.useRef(null);
9563
+ const slidesRef = React.useRef(null);
9564
+ // The item (a child of the track) that holds focus, until focus leaves
9565
+ // the items for somewhere else
9566
+ const focusedItemRef = React.useRef(undefined);
9401
9567
  const [internalValue, setInternalValue] = React.useState(0);
9402
9568
  const [isPaused, setIsPaused] = React.useState(false);
9569
+ const [isFocusInside, setIsFocusInside] = React.useState(false);
9403
9570
  const [isDragging, setIsDragging] = React.useState(false);
9404
9571
  const [dragStart, setDragStart] = React.useState(0);
9405
9572
  const [dragOffset, setDragOffset] = React.useState(0);
@@ -9409,64 +9576,105 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9409
9576
  // Get valid children
9410
9577
  const items = React.Children.toArray(children).filter(React.isValidElement);
9411
9578
  const itemCount = items.length;
9412
- // Use controlled or internal value
9413
- const activeIndex = controlledValue !== undefined ? controlledValue : internalValue;
9414
- // Sync displayIndex when controlled value changes externally. Intentional
9415
- // external-prop sync that must not run mid-wrap animation; covered by tests
9416
- // and certified in-browser.
9579
+ // The count in view. The viewport props come widest first, in the order
9580
+ // of VIEWPORT_QUERIES, then mobile; each min-width viewport falls back to
9581
+ // the narrower ones, and every viewport to `itemsToShow`, the cascade the
9582
+ // stylesheet's `--bulma-carousel-items-to-show-*` defaults spell out.
9583
+ const counts = [
9584
+ itemsToShowFullhd,
9585
+ itemsToShowWidescreen,
9586
+ itemsToShowDesktop,
9587
+ itemsToShowTablet,
9588
+ itemsToShowMobile,
9589
+ ].map(wholeCount);
9590
+ const baseCount = wholeCount(itemsToShow);
9591
+ const viewport = useBulmaViewport(counts.some(c => c !== undefined));
9592
+ const inView = (viewport === MOBILE
9593
+ ? counts[MOBILE]
9594
+ : viewport >= 0
9595
+ ? counts.slice(viewport, MOBILE).find(c => c !== undefined)
9596
+ : undefined) ??
9597
+ baseCount ??
9598
+ 1;
9599
+ // The props as the stylesheet reads them, so the widths are right from
9600
+ // the first paint, on the server too.
9601
+ const countStyle = {};
9602
+ [...counts, baseCount].forEach((count, i) => {
9603
+ if (count !== undefined) {
9604
+ countStyle[`--bulma-carousel-items-to-show${COUNT_SUFFIXES[i]}`] =
9605
+ count;
9606
+ }
9607
+ });
9608
+ const scroll = Math.min(wholeCount(itemsToScroll) ?? 1, inView);
9609
+ const looping = repeat && itemCount > inView;
9610
+ const lastStart = Math.max(0, itemCount - inView);
9611
+ // Where the view can start: every `scroll` items from the first, and
9612
+ // without a loop the last full view as well, which is what keeps the
9613
+ // end of the track from showing empty space.
9614
+ const positions = [];
9615
+ for (let p = 0; p < (looping ? itemCount : lastStart); p += scroll) {
9616
+ positions.push(p);
9617
+ }
9618
+ if (!looping)
9619
+ positions.push(lastStart);
9620
+ const lastPosition = positions[positions.length - 1];
9621
+ const canMove = positions.length > 1;
9622
+ const wrap = (index) => ((index % itemCount) + itemCount) % itemCount;
9623
+ const fit = (index) => looping ? wrap(index) : Math.min(Math.max(index, 0), lastStart);
9624
+ // Use controlled or internal value, as a start the view can show
9625
+ const activeIndex = fit(controlledValue ?? internalValue);
9626
+ // The indicator for the last position at or before the first item in view
9627
+ const activePosition = positions.reduce((found, position, index) => (position <= activeIndex ? index : found), 0);
9628
+ const nextIndex = positions[activePosition + 1];
9629
+ // From between two positions, the previous step is the one just behind
9630
+ const prevIndex = activeIndex > positions[activePosition]
9631
+ ? positions[activePosition]
9632
+ : positions[activePosition - 1];
9633
+ // Sync displayIndex when the value changes externally, or when the count
9634
+ // in view moves the start it fits to. Intentional external-prop sync that
9635
+ // must not run mid-wrap animation; covered by tests and certified
9636
+ // in-browser. Uncontrolled, the fitted start becomes the value too, so a
9637
+ // later change of count keeps what was first in view in view.
9417
9638
  React.useEffect(() => {
9418
- if (controlledValue !== undefined && !isWrapping) {
9639
+ if (!isWrapping) {
9419
9640
  // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing to controlled prop
9420
- setDisplayIndex(controlledValue);
9641
+ setDisplayIndex(activeIndex);
9642
+ if (controlledValue === undefined)
9643
+ setInternalValue(activeIndex);
9421
9644
  }
9422
- }, [controlledValue, isWrapping]);
9645
+ }, [activeIndex, isWrapping, controlledValue]);
9423
9646
  // Update active index
9424
- const setActiveIndex = React.useCallback((index, syncDisplay = true) => {
9425
- let newIndex = index;
9426
- if (repeat) {
9427
- if (index < 0) {
9428
- newIndex = itemCount - 1;
9429
- }
9430
- else if (index >= itemCount) {
9431
- newIndex = 0;
9432
- }
9433
- }
9434
- else {
9435
- newIndex = Math.max(0, Math.min(index, itemCount - 1));
9436
- }
9647
+ const setActiveIndex = React.useCallback((index) => {
9437
9648
  if (controlledValue === undefined) {
9438
- setInternalValue(newIndex);
9439
- }
9440
- if (syncDisplay) {
9441
- setDisplayIndex(newIndex);
9649
+ setInternalValue(index);
9442
9650
  }
9443
- onChange?.(newIndex);
9444
- }, [controlledValue, itemCount, repeat, onChange]);
9651
+ setDisplayIndex(index);
9652
+ setIsWrapping(false);
9653
+ onChange?.(index);
9654
+ }, [controlledValue, onChange]);
9445
9655
  // Navigation handlers
9446
9656
  const goToPrev = React.useCallback(() => {
9447
- if (activeIndex === 0 && repeat && itemCount > 1) {
9448
- // Animate to clone of last slide (position -1)
9449
- setDisplayIndex(-1);
9450
- setIsWrapping(true);
9657
+ if (prevIndex !== undefined) {
9658
+ setActiveIndex(prevIndex);
9451
9659
  }
9452
- else {
9453
- setActiveIndex(activeIndex - 1);
9660
+ else if (looping) {
9661
+ // Animate to the clones before the first item, which show the last
9662
+ // position, and snap to the real one when the transition ends
9663
+ setDisplayIndex(lastPosition - itemCount);
9664
+ setIsWrapping(true);
9454
9665
  }
9455
- }, [activeIndex, repeat, itemCount, setActiveIndex]);
9666
+ }, [prevIndex, looping, lastPosition, itemCount, setActiveIndex]);
9456
9667
  const goToNext = React.useCallback(() => {
9457
- if (activeIndex === itemCount - 1 && repeat && itemCount > 1) {
9458
- // Animate to clone of first slide (position itemCount)
9668
+ if (nextIndex !== undefined) {
9669
+ setActiveIndex(nextIndex);
9670
+ }
9671
+ else if (looping) {
9672
+ // Animate to the clones after the last item, which show the first
9673
+ // position, and snap to the real one when the transition ends
9459
9674
  setDisplayIndex(itemCount);
9460
9675
  setIsWrapping(true);
9461
9676
  }
9462
- else {
9463
- setActiveIndex(activeIndex + 1);
9464
- }
9465
- }, [activeIndex, itemCount, repeat, setActiveIndex]);
9466
- const goToSlide = React.useCallback((index) => {
9467
- setIsWrapping(false);
9468
- setActiveIndex(index);
9469
- }, [setActiveIndex]);
9677
+ }, [nextIndex, looping, itemCount, setActiveIndex]);
9470
9678
  // Read when the timer fires or an arrow key arrives, so neither the
9471
9679
  // timer nor the key listener starts over while `onChange` changes
9472
9680
  // identity, as an inline one does on every render of the parent.
@@ -9474,15 +9682,28 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9474
9682
  const goToNextRef = useLatestRef(goToNext);
9475
9683
  // Auto-play. Each slide gets the whole interval, so a slide change,
9476
9684
  // autoplay's own or the user's, starts the timer over. A render that
9477
- // leaves the slide where it is doesn't.
9685
+ // leaves the slide where it is doesn't. It waits while focus is inside
9686
+ // an item, so what someone is reading or using doesn't move away. The
9687
+ // timer reads where focus is when it fires, rather than trusting the
9688
+ // last focus event alone: a browser moving focus off an element that
9689
+ // went inert need not fire one, and the pause would never end. A focus
9690
+ // change still starts the timer over, so an item gets the whole
9691
+ // interval once focus leaves it.
9692
+ const isPlaying = autoplay && (looping || nextIndex !== undefined) && !isPaused;
9693
+ const isRotating = isPlaying && !isFocusInside;
9478
9694
  React.useEffect(() => {
9479
- if (!autoplay || isPaused || itemCount <= 1)
9695
+ if (!isPlaying)
9480
9696
  return undefined;
9481
9697
  const timer = setInterval(() => {
9698
+ const track = slidesRef.current;
9699
+ if (track?.contains(getActiveElementInTree(track)))
9700
+ return;
9701
+ if (isFocusInside)
9702
+ setIsFocusInside(false);
9482
9703
  goToNextRef.current();
9483
9704
  }, interval);
9484
9705
  return () => clearInterval(timer);
9485
- }, [autoplay, isPaused, interval, itemCount, activeIndex, goToNextRef]);
9706
+ }, [isPlaying, isFocusInside, interval, activeIndex, goToNextRef]);
9486
9707
  // Pause on hover
9487
9708
  const handleMouseEnter = React.useCallback(() => {
9488
9709
  if (pauseOnHover) {
@@ -9541,12 +9762,16 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9541
9762
  const handleTouchEnd = React.useCallback(() => {
9542
9763
  handleDragEnd();
9543
9764
  }, [handleDragEnd]);
9544
- // Keyboard navigation
9765
+ // Keyboard navigation, from the carousel's own controls. Inside an item
9766
+ // the keys are its content's (a field's caret, a slider), and a step
9767
+ // from there could take the focused item out of view.
9545
9768
  React.useEffect(() => {
9546
9769
  const handleKeyDown = (e) => {
9547
9770
  const node = carouselRef.current;
9548
- if (!node?.contains(getActiveElementInTree(node)))
9771
+ const active = getActiveElementInTree(node);
9772
+ if (!node?.contains(active) || slidesRef.current?.contains(active)) {
9549
9773
  return;
9774
+ }
9550
9775
  switch (e.key) {
9551
9776
  case 'ArrowLeft':
9552
9777
  e.preventDefault();
@@ -9561,22 +9786,14 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9561
9786
  document.addEventListener('keydown', handleKeyDown);
9562
9787
  return () => document.removeEventListener('keydown', handleKeyDown);
9563
9788
  }, [goToPrevRef, goToNextRef]);
9564
- // Handle wrap-around transition end
9565
- const handleTransitionEnd = React.useCallback(() => {
9566
- if (isWrapping) {
9567
- // Skip transition for the instant snap
9789
+ // Handle wrap-around transition end: the track's own, since an item's
9790
+ // content can end a transition of its own that bubbles up to here
9791
+ const handleTransitionEnd = React.useCallback((e) => {
9792
+ if (isWrapping && e.target === e.currentTarget) {
9793
+ // Skip transition for the instant snap from the clones to the
9794
+ // real items they copy
9568
9795
  setSkipTransition(true);
9569
- setIsWrapping(false);
9570
- if (displayIndex === itemCount) {
9571
- // Was at clone of first slide, snap to real first slide
9572
- setActiveIndex(0, false);
9573
- setDisplayIndex(0);
9574
- }
9575
- else if (displayIndex === -1) {
9576
- // Was at clone of last slide, snap to real last slide
9577
- setActiveIndex(itemCount - 1, false);
9578
- setDisplayIndex(itemCount - 1);
9579
- }
9796
+ setActiveIndex(((displayIndex % itemCount) + itemCount) % itemCount);
9580
9797
  // Re-enable transition after the snap
9581
9798
  requestAnimationFrame(() => {
9582
9799
  requestAnimationFrame(() => {
@@ -9618,8 +9835,8 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9618
9835
  }
9619
9836
  }, [ref]);
9620
9837
  // Can navigate
9621
- const canGoPrev = repeat || activeIndex > 0;
9622
- const canGoNext = repeat || activeIndex < itemCount - 1;
9838
+ const canGoPrev = looping || prevIndex !== undefined;
9839
+ const canGoNext = looping || nextIndex !== undefined;
9623
9840
  // Generate classes
9624
9841
  const carouselClasses = usePrefixedClassNames('carousel', {
9625
9842
  'is-overlay': indicatorInside,
@@ -9637,50 +9854,101 @@ const Carousel = React.forwardRef(({ value: controlledValue, autoplay = false, i
9637
9854
  'is-top': indicatorPosition === 'top',
9638
9855
  });
9639
9856
  const combinedClasses = classNames(carouselClasses, bulmaHelperClasses, className);
9640
- // Render slides with active state (includes clones for infinite loop)
9641
- const renderSlides = () => {
9642
- const slides = items.map((item, index) => {
9643
- if (React.isValidElement(item)) {
9644
- return React.cloneElement(item, {
9645
- key: index,
9646
- active: index === activeIndex,
9647
- });
9648
- }
9649
- return item;
9857
+ // Where the track is drawn from: mid-wrap the clones, otherwise a start
9858
+ // the view can show, so a change in the count in view never paints
9859
+ // empty space while the sync above catches up.
9860
+ const shownIndex = isWrapping ? displayIndex : fit(displayIndex);
9861
+ // Enough clones on each side to fill the widest view any viewport sets,
9862
+ // since the stylesheet, not this count, sizes what's on screen
9863
+ const cloneCount = looping
9864
+ ? Math.min(itemCount, Math.max(baseCount ?? 1, ...counts.map(c => c ?? 1)))
9865
+ : 0;
9866
+ // The track: a full view's worth of clones of the last items, the items,
9867
+ // and clones of the first ones, for a seamless loop. Each entry is the
9868
+ // item it shows and its key.
9869
+ const slots = items.map((_, index) => [
9870
+ index,
9871
+ index,
9872
+ ]);
9873
+ for (let k = 0; k < cloneCount; k++) {
9874
+ slots.splice(k, 0, [itemCount - cloneCount + k, `clone-before-${k}`]);
9875
+ slots.push([k, `clone-after-${k}`]);
9876
+ }
9877
+ // Render slides with active state (includes clones for infinite loop).
9878
+ // What's in view is a run of slots, so where a looping view runs past
9879
+ // the last item, the clones on screen are the live copies and the items
9880
+ // they copy, off screen, are the hidden ones: no item is exposed twice,
9881
+ // and the copy exposed is the one in view.
9882
+ const renderSlides = () => slots.map(([index, key], slot) => {
9883
+ const item = items[index];
9884
+ const own = item.props;
9885
+ const isShown = slot >= shownIndex + cloneCount &&
9886
+ slot < shownIndex + cloneCount + inView;
9887
+ return React.cloneElement(item, {
9888
+ key,
9889
+ active: isShown,
9890
+ role: own.role ?? 'group',
9891
+ 'aria-roledescription': own['aria-roledescription'] ?? 'slide',
9892
+ ...(own['aria-label'] || own['aria-labelledby']
9893
+ ? {}
9894
+ : { 'aria-label': `${index + 1} of ${itemCount}` }),
9895
+ // Out of view: hidden from assistive tech, and inert, so nothing
9896
+ // in it takes focus or a click
9897
+ ...(isShown ? {} : { 'aria-hidden': true, ...inertProps(true) }),
9650
9898
  });
9651
- // Add clones for seamless infinite loop when repeat is enabled
9652
- if (repeat && itemCount > 1) {
9653
- const lastItem = items[itemCount - 1];
9654
- const firstItem = items[0];
9655
- const lastClone = React.isValidElement(lastItem)
9656
- ? React.cloneElement(lastItem, {
9657
- key: 'clone-last',
9658
- active: false,
9659
- })
9660
- : null;
9661
- const firstClone = React.isValidElement(firstItem)
9662
- ? React.cloneElement(firstItem, {
9663
- key: 'clone-first',
9664
- active: false,
9665
- })
9666
- : null;
9667
- return [lastClone, ...slides, firstClone];
9899
+ });
9900
+ // Hand focus on when the item holding it leaves the view. An item out
9901
+ // of view is inert, and a browser drops focus from an inert element to
9902
+ // the page, so a swipe, a change of count with the window's width, a new
9903
+ // controlled value or the item's removal would leave a keyboard or
9904
+ // screen reader user nowhere. Focus goes to the first item in view,
9905
+ // given `tabindex="-1"` for it unless it has a tabindex of its own. A
9906
+ // layout effect runs before the browser gets to drop the focus; the
9907
+ // focused item's removal drops it at once, so focus on the page itself
9908
+ // still counts as the item's.
9909
+ useClientLayoutEffect(() => {
9910
+ const held = focusedItemRef.current;
9911
+ if (!held)
9912
+ return;
9913
+ const track = slidesRef.current;
9914
+ const start = shownIndex + cloneCount;
9915
+ const slot = Array.prototype.indexOf.call(track.children, held);
9916
+ if (slot >= start && slot < start + inView)
9917
+ return;
9918
+ const active = getActiveElementInTree(track);
9919
+ if (active !== held.ownerDocument.body && !held.contains(active)) {
9920
+ return;
9668
9921
  }
9669
- return slides;
9670
- };
9671
- return (jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, ...rest, children: [jsxRuntime.jsxs("div", { ref: containerRef, className: carouselContainerClass, onMouseDown: hasDrag ? handleMouseDown : undefined, onMouseMove: hasDrag && isDragging ? handleMouseMove : undefined, onMouseUp: hasDrag ? handleMouseUp : undefined, onMouseLeave: hasDrag ? handleMouseUp : undefined, onTouchStart: hasDrag ? handleTouchStart : undefined, onTouchMove: hasDrag ? handleTouchMove : undefined, onTouchEnd: hasDrag ? handleTouchEnd : undefined, style: {
9922
+ focusedItemRef.current = undefined;
9923
+ const first = track.children[start];
9924
+ if (!first)
9925
+ return;
9926
+ if (!first.hasAttribute('tabindex'))
9927
+ first.tabIndex = -1;
9928
+ first.focus({ preventScroll: true });
9929
+ }, [shownIndex, cloneCount, inView, itemCount]);
9930
+ return (jsxRuntime.jsxs("div", { ref: combinedRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "region", "aria-roledescription": "carousel", "aria-label": ariaLabel, style: { ...countStyle, ...style }, ...rest, children: [jsxRuntime.jsxs("div", { ref: containerRef, className: carouselContainerClass, onMouseDown: hasDrag ? handleMouseDown : undefined, onMouseMove: hasDrag && isDragging ? handleMouseMove : undefined, onMouseUp: hasDrag ? handleMouseUp : undefined, onMouseLeave: hasDrag ? handleMouseUp : undefined, onTouchStart: hasDrag ? handleTouchStart : undefined, onTouchMove: hasDrag ? handleTouchMove : undefined, onTouchEnd: hasDrag ? handleTouchEnd : undefined, style: {
9672
9931
  transform: isDragging ? `translateX(${dragOffset}px)` : undefined,
9673
9932
  cursor: hasDrag ? (isDragging ? 'grabbing' : 'grab') : undefined,
9674
- }, children: [jsxRuntime.jsx("div", { className: carouselSlidesClass, style: {
9675
- // When repeat is enabled, account for the prepended clone (+1 offset)
9676
- transform: `translateX(-${(repeat && itemCount > 1 ? displayIndex + 1 : displayIndex) * 100}%)`,
9933
+ }, children: [jsxRuntime.jsx("div", { ref: slidesRef, className: carouselSlidesClass, style: {
9934
+ // One item is the track's width plus a gap, over the count the
9935
+ // stylesheet has in view; account for the prepended clones
9936
+ transform: `translateX(calc(${-(shownIndex + cloneCount)} * (100% + var(--bulma-carousel-item-gap, 0px)) / var(--bulma-carousel-items-in-view, 1)))`,
9677
9937
  // Disable transition during drag or instant snap after wrap-around
9678
9938
  transition: isDragging || skipTransition
9679
9939
  ? 'none'
9680
9940
  : 'transform 0.3s ease-in-out',
9681
- }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && itemCount > 1 && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsxRuntime.jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultPrevIcon, {})) }), jsxRuntime.jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsxRuntime.jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultNextIcon, {})) })] }))] }), indicator && itemCount > 1 && (jsxRuntime.jsx("div", { className: carouselIndicatorClass, role: "tablist", children: items.map((_, index) => (jsxRuntime.jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
9682
- 'is-active': index === activeIndex,
9683
- }), onClick: () => goToSlide(index), role: "tab", "aria-selected": index === activeIndex, "aria-label": `Go to slide ${index + 1}` }, index))) }))] }));
9941
+ }, "aria-live": isRotating ? 'off' : 'polite', onFocus: e => {
9942
+ setIsFocusInside(true);
9943
+ focusedItemRef.current = Array.from(e.currentTarget.children).find(item => item.contains(e.target));
9944
+ }, onBlur: e => {
9945
+ const inside = e.currentTarget.contains(e.relatedTarget);
9946
+ setIsFocusInside(inside);
9947
+ if (!inside)
9948
+ focusedItemRef.current = undefined;
9949
+ }, onTransitionEnd: handleTransitionEnd, children: renderSlides() }), arrow && canMove && (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Button, { type: "button", className: carouselArrowPrevClass, onClick: goToPrev, isDisabled: !canGoPrev, "aria-label": "Previous slide", children: iconPrev ? (jsxRuntime.jsx(Icon, { name: iconPrev, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultPrevIcon, {})) }), jsxRuntime.jsx(Button, { type: "button", className: carouselArrowNextClass, onClick: goToNext, isDisabled: !canGoNext, "aria-label": "Next slide", children: iconNext ? (jsxRuntime.jsx(Icon, { name: iconNext, library: iconLibrary, variant: iconVariant, size: iconSize, features: iconFeatures })) : (jsxRuntime.jsx(DefaultNextIcon, {})) })] }))] }), indicator && canMove && (jsxRuntime.jsx("div", { className: carouselIndicatorClass, role: "tablist", children: positions.map((position, index) => (jsxRuntime.jsx(Button, { type: "button", className: prefixedClassNames(classPrefix, 'indicator-item', {
9950
+ 'is-active': index === activePosition,
9951
+ }), onClick: () => setActiveIndex(position), role: "tab", "aria-selected": index === activePosition, "aria-label": `Go to slide ${position + 1}` }, position))) }))] }));
9684
9952
  });
9685
9953
  Carousel.displayName = 'Carousel';
9686
9954
 
@@ -11163,23 +11431,15 @@ const Image = ({ as, className, textColor, color, bgColor, size, isRounded, isRe
11163
11431
  backgroundColor: bgColor,
11164
11432
  ...props,
11165
11433
  });
11434
+ // Bulma reads `.has-ratio` only on a child of the ratio container, so the
11435
+ // container carries just its `is-<ratio>` class.
11166
11436
  const bulmaClasses = usePrefixedClassNames('image', {
11167
11437
  [`is-${size}`]: size,
11168
- 'has-ratio': size && typeof size === 'string' && size.includes('by'),
11169
11438
  });
11170
11439
  const imageClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
11171
11440
  // Default tag logic: if "as" is provided, use it.
11172
- // If not, use <figure> for aspect ratios or children, <div> otherwise.
11173
- let Tag;
11174
- if (as) {
11175
- Tag = as;
11176
- }
11177
- else if (size && typeof size === 'string' && size.includes('by')) {
11178
- Tag = 'figure';
11179
- }
11180
- else {
11181
- Tag = 'div';
11182
- }
11441
+ // If not, use <figure> for aspect ratios, <div> otherwise.
11442
+ const Tag = as ?? (size?.includes('by') ? 'figure' : 'div');
11183
11443
  const roundedClass = usePrefixedClassNames('is-rounded');
11184
11444
  const content = children ? (children) : (jsxRuntime.jsx("img", { className: classNames({ [roundedClass]: isRounded }) || undefined, src: src, alt: alt, ...(isRetina && src ? { srcSet: `${src} 2x` } : {}) }));
11185
11445
  return (jsxRuntime.jsx(Tag, { className: imageClasses, ...rest, children: content }));
@@ -21896,6 +22156,7 @@ exports.Menu = Menu;
21896
22156
  exports.MenuItem = MenuItem;
21897
22157
  exports.MenuLabel = MenuLabel;
21898
22158
  exports.MenuList = MenuList;
22159
+ exports.MenuSection = MenuSection;
21899
22160
  exports.Message = Message;
21900
22161
  exports.Modal = Modal;
21901
22162
  exports.Navbar = Navbar;