@deque/cauldron-react 6.22.3 → 6.23.0-canary.4ddfcf41

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.
@@ -6,6 +6,7 @@ interface BaseListboxProps extends PolymorphicProps<Omit<React.HTMLAttributes<HT
6
6
  navigation?: 'cycle' | 'bound';
7
7
  focusStrategy?: 'lastSelected' | 'first' | 'last';
8
8
  focusDisabledOptions?: boolean;
9
+ activeOption?: ListboxOption;
9
10
  onActiveChange?: (option: ListboxOption) => void;
10
11
  disabled?: boolean;
11
12
  }
package/lib/index.js CHANGED
@@ -1366,7 +1366,7 @@ var removeFocusTrapFromStack = function (focusTrap) {
1366
1366
  var focusTrapIndex = focusTrapStack.findIndex(function (trap) { return focusTrap.targetElement === trap.targetElement; });
1367
1367
  focusTrapStack.splice(focusTrapIndex, 1);
1368
1368
  };
1369
- function getActiveElement(target) {
1369
+ function getActiveElement$1(target) {
1370
1370
  var _a;
1371
1371
  return (((_a = target === null || target === void 0 ? void 0 : target.ownerDocument.activeElement) !== null && _a !== void 0 ? _a : document.activeElement) ||
1372
1372
  document.body);
@@ -1502,7 +1502,7 @@ function useFocusTrap(target, options) {
1502
1502
  if (!targetElement || disabled) {
1503
1503
  return;
1504
1504
  }
1505
- returnFocusElementRef.current = getActiveElement(targetElement);
1505
+ returnFocusElementRef.current = getActiveElement$1(targetElement);
1506
1506
  focusTrap.current = createFocusTrap(targetElement, initialFocusElement);
1507
1507
  return function () {
1508
1508
  var _a;
@@ -1776,6 +1776,38 @@ function getAutoAlignment(placement) {
1776
1776
  return null;
1777
1777
  }
1778
1778
  }
1779
+ /**
1780
+ * Prevents large overlays from shifting off-screen above the viewport. When an overlay
1781
+ * would overflow past the top edge, this middleware signals that it should flip to an
1782
+ * alternative placement to keep content visible and accessible.
1783
+ */
1784
+ var preventTopOverflowMiddleware = {
1785
+ name: 'preventTopOverflow',
1786
+ fn: function (state) {
1787
+ return tslib.__awaiter(this, void 0, void 0, function () {
1788
+ var overflow;
1789
+ return tslib.__generator(this, function (_a) {
1790
+ switch (_a.label) {
1791
+ case 0: return [4 /*yield*/, reactDom$1.detectOverflow(state, {
1792
+ rootBoundary: 'document'
1793
+ })];
1794
+ case 1:
1795
+ overflow = _a.sent();
1796
+ if ((overflow === null || overflow === void 0 ? void 0 : overflow.top) >= 0) {
1797
+ return [2 /*return*/, {
1798
+ reset: {
1799
+ // Replace the initial placement axis with 'bottom' while preserving alignment (start/end)
1800
+ // Examples: 'top' -> 'bottom', 'top-start' -> 'bottom-start'
1801
+ placement: (state.placement.replace(/\w+?(-(start|end))?$/i, 'bottom$1') || 'bottom')
1802
+ }
1803
+ }];
1804
+ }
1805
+ return [2 /*return*/, {}];
1806
+ }
1807
+ });
1808
+ });
1809
+ }
1810
+ };
1779
1811
  var AnchoredOverlay = React.forwardRef(function (_a, refProp) {
1780
1812
  var _b, _c;
1781
1813
  var as = _a.as, _d = _a.placement, initialPlacement = _d === void 0 ? 'auto' : _d, target = _a.target, children = _a.children, style = _a.style, _e = _a.open, open = _e === void 0 ? false : _e, offset = _a.offset, focusTrap = _a.focusTrap, focusTrapOptions = _a.focusTrapOptions, onOpenChange = _a.onOpenChange, onPlacementChange = _a.onPlacementChange, onShiftChange = _a.onShiftChange, portal = _a.portal, props = tslib.__rest(_a, ["as", "placement", "target", "children", "style", "open", "offset", "focusTrap", "focusTrapOptions", "onOpenChange", "onPlacementChange", "onShiftChange", "portal"]);
@@ -1795,7 +1827,11 @@ var AnchoredOverlay = React.forwardRef(function (_a, refProp) {
1795
1827
  : reactDom$1.flip({
1796
1828
  fallbackAxisSideDirection: 'start'
1797
1829
  }),
1798
- reactDom$1.shift({ crossAxis: false })
1830
+ reactDom$1.shift({
1831
+ crossAxis: false,
1832
+ boundary: 'clippingAncestors'
1833
+ }),
1834
+ preventTopOverflowMiddleware
1799
1835
  ].filter(Boolean),
1800
1836
  elements: {
1801
1837
  reference: resolveElement(target)
@@ -3669,12 +3705,13 @@ var optionMatchesValue = function (option, value) {
3669
3705
  option.value === value;
3670
3706
  };
3671
3707
  var Listbox = React.forwardRef(function (_a, ref) {
3672
- var _b = _a.as, Component = _b === void 0 ? 'ul' : _b, children = _a.children, defaultValue = _a.defaultValue, value = _a.value, _c = _a.navigation, navigation = _c === void 0 ? 'bound' : _c, _d = _a.focusStrategy, focusStrategy = _d === void 0 ? 'lastSelected' : _d, _e = _a.focusDisabledOptions, focusDisabledOptions = _e === void 0 ? false : _e, _f = _a.multiselect, multiselect = _f === void 0 ? false : _f, onKeyDown = _a.onKeyDown, onFocus = _a.onFocus, onSelectionChange = _a.onSelectionChange, onActiveChange = _a.onActiveChange, _g = _a.disabled, disabled = _g === void 0 ? false : _g, props = tslib.__rest(_a, ["as", "children", "defaultValue", "value", "navigation", "focusStrategy", "focusDisabledOptions", "multiselect", "onKeyDown", "onFocus", "onSelectionChange", "onActiveChange", "disabled"]);
3708
+ var _b = _a.as, Component = _b === void 0 ? 'ul' : _b, children = _a.children, defaultValue = _a.defaultValue, value = _a.value, _c = _a.navigation, navigation = _c === void 0 ? 'bound' : _c, _d = _a.focusStrategy, focusStrategy = _d === void 0 ? 'lastSelected' : _d, _e = _a.focusDisabledOptions, focusDisabledOptions = _e === void 0 ? false : _e, _f = _a.multiselect, multiselect = _f === void 0 ? false : _f, onKeyDown = _a.onKeyDown, onFocus = _a.onFocus, onSelectionChange = _a.onSelectionChange, controlledActiveOption = _a.activeOption, onActiveChange = _a.onActiveChange, _g = _a.disabled, disabled = _g === void 0 ? false : _g, props = tslib.__rest(_a, ["as", "children", "defaultValue", "value", "navigation", "focusStrategy", "focusDisabledOptions", "multiselect", "onKeyDown", "onFocus", "onSelectionChange", "activeOption", "onActiveChange", "disabled"]);
3673
3709
  var _h = tslib.__read(React.useState([]), 2), options = _h[0], setOptions = _h[1];
3674
- var _j = tslib.__read(React.useState(null), 2), activeOption = _j[0], setActiveOption = _j[1];
3710
+ var _j = tslib.__read(React.useState(controlledActiveOption || null), 2), activeOption = _j[0], setActiveOption = _j[1];
3675
3711
  var _k = tslib.__read(React.useState([]), 2), selectedOptions = _k[0], setSelectedOptions = _k[1];
3676
3712
  var listboxRef = useSharedRef(ref);
3677
3713
  var isControlled = typeof value !== 'undefined';
3714
+ var isActiveControlled = typeof controlledActiveOption !== 'undefined';
3678
3715
  React.useLayoutEffect(function () {
3679
3716
  if (!isControlled && selectedOptions.length > 0) {
3680
3717
  return;
@@ -3709,6 +3746,11 @@ var Listbox = React.forwardRef(function (_a, ref) {
3709
3746
  onActiveChange === null || onActiveChange === void 0 ? void 0 : onActiveChange(activeOption);
3710
3747
  }
3711
3748
  }, [activeOption]);
3749
+ React.useEffect(function () {
3750
+ if (isActiveControlled && controlledActiveOption !== activeOption) {
3751
+ setActiveOption(controlledActiveOption || null);
3752
+ }
3753
+ }, [isActiveControlled, controlledActiveOption]);
3712
3754
  var handleSelect = React.useCallback(function (option) {
3713
3755
  var _a;
3714
3756
  setActiveOption(option);
@@ -4925,28 +4967,142 @@ function useActionListContext() {
4925
4967
  return React.useContext(ActionListContext);
4926
4968
  }
4927
4969
 
4970
+ /**
4971
+ * Get an element's accessible name by its aria-label or text content
4972
+ */
4973
+ function getAccessibleLabel(element) {
4974
+ var _a;
4975
+ return (
4976
+ // We're explicitly ignoring that we _could_ use aria-labelledby here
4977
+ // because of the additional complexity that is needed in order to calculate
4978
+ // the accessible name of an aria-labelled by idref. We're reserving that behavior
4979
+ // for future implementation if it is determined to be needed.
4980
+ element.getAttribute('aria-label') || ((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim()) || '');
4981
+ }
4982
+ /**
4983
+ * Gets the active element based on the root element passed in
4984
+ */
4985
+ function getActiveElement(root) {
4986
+ var activeElement;
4987
+ if (document.activeElement === root &&
4988
+ root.hasAttribute('aria-activedescendant')) {
4989
+ activeElement = document.getElementById(
4990
+ // Validating attribute above with "hasAttribute"
4991
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4992
+ root.getAttribute('aria-activedescendant'));
4993
+ }
4994
+ else {
4995
+ activeElement = document.activeElement;
4996
+ }
4997
+ return root.contains(activeElement) ? activeElement : null;
4998
+ }
4999
+ /**
5000
+ * A hook that provides mnemonic navigation for keyboard users.
5001
+ *
5002
+ * Mnemonics allow users to quickly navigate to elements by typing the first
5003
+ * letter of the element's text content. Pressing the same letter repeatedly
5004
+ * cycles through all matching elements.
5005
+ */
5006
+ function useMnemonics(_a) {
5007
+ var elementOrRef = _a.elementOrRef, matchingElementsSelector = _a.matchingElementsSelector, onMatch = _a.onMatch, _b = _a.enabled, enabled = _b === void 0 ? true : _b;
5008
+ var containerRef = React.useRef();
5009
+ React.useEffect(function () {
5010
+ if (elementOrRef instanceof HTMLElement) {
5011
+ containerRef.current = elementOrRef;
5012
+ }
5013
+ else if (!!elementOrRef && 'current' in elementOrRef) {
5014
+ containerRef.current = elementOrRef === null || elementOrRef === void 0 ? void 0 : elementOrRef.current;
5015
+ }
5016
+ }, [elementOrRef]);
5017
+ React.useEffect(function () {
5018
+ if (!enabled || !containerRef.current) {
5019
+ return;
5020
+ }
5021
+ var keyboardHandler = function (event) {
5022
+ // Ignore keyboard events where a modifier key was pressed
5023
+ var hasModifier = event.ctrlKey || event.altKey || event.metaKey;
5024
+ if (hasModifier) {
5025
+ return;
5026
+ }
5027
+ // Ignore keyboard events where a non-alphanumeric character was pressed
5028
+ if (event.key.length !== 1 || !/[a-z\d]/i.test(event.key)) {
5029
+ return;
5030
+ }
5031
+ var container = containerRef.current;
5032
+ if (!container) {
5033
+ return;
5034
+ }
5035
+ // Prevent default behavior and stop propagation for mnemonic keys
5036
+ event.preventDefault();
5037
+ event.stopPropagation();
5038
+ var elements = Array.from(container.querySelectorAll(matchingElementsSelector !== null && matchingElementsSelector !== void 0 ? matchingElementsSelector : focusable__default["default"]));
5039
+ var matchingElements = elements.filter(function (element) {
5040
+ return getAccessibleLabel(element).toLowerCase()[0] ===
5041
+ event.key.toLowerCase();
5042
+ });
5043
+ if (!matchingElements.length) {
5044
+ return;
5045
+ }
5046
+ var currentActiveElement = getActiveElement(containerRef.current);
5047
+ var nextActiveElement = null;
5048
+ if (currentActiveElement) {
5049
+ nextActiveElement = matchingElements.find(function (element) {
5050
+ // Find the next matching element that is _after_ the current active element
5051
+ // within the collection of identified elements
5052
+ return !!(element.compareDocumentPosition(currentActiveElement) &
5053
+ Node.DOCUMENT_POSITION_PRECEDING);
5054
+ });
5055
+ }
5056
+ if (typeof onMatch === 'function') {
5057
+ onMatch(nextActiveElement !== null && nextActiveElement !== void 0 ? nextActiveElement : matchingElements[0]);
5058
+ }
5059
+ };
5060
+ var container = containerRef.current;
5061
+ container.addEventListener('keydown', keyboardHandler);
5062
+ return function () { return container.removeEventListener('keydown', keyboardHandler); };
5063
+ }, [enabled, containerRef, matchingElementsSelector, onMatch]);
5064
+ return containerRef;
5065
+ }
5066
+
4928
5067
  var ActionList = React.forwardRef(function (_a, ref) {
4929
5068
  var _b = _a.selectionType, selectionType = _b === void 0 ? null : _b, onAction = _a.onAction, className = _a.className, children = _a.children, props = tslib.__rest(_a, ["selectionType", "onAction", "className", "children"]);
4930
5069
  var actionListContext = useActionListContext();
4931
5070
  var activeElement = React.useRef();
5071
+ var _c = tslib.__read(React.useState(), 2), activeOption = _c[0], setActiveOption = _c[1];
4932
5072
  var handleActiveChange = React.useCallback(function (value) {
4933
5073
  activeElement.current = value === null || value === void 0 ? void 0 : value.element;
5074
+ setActiveOption(value);
4934
5075
  }, []);
4935
5076
  var handleAction = React.useCallback(function (key, event) {
4936
5077
  if (typeof onAction === 'function') {
4937
5078
  onAction(key, event);
4938
5079
  }
4939
5080
  }, [onAction]);
5081
+ var containerRef = useMnemonics({
5082
+ onMatch: function (element) {
5083
+ setActiveOption({
5084
+ element: element
5085
+ });
5086
+ },
5087
+ matchingElementsSelector: props.role === 'menu'
5088
+ ? '[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]'
5089
+ : '[role=option]'
5090
+ });
4940
5091
  return (
4941
5092
  // Note: we should be able to use listbox without passing a prop
4942
5093
  // value for "multiselect"
4943
5094
  // see: https://github.com/dequelabs/cauldron/issues/1890
4944
5095
  // @ts-expect-error this should be allowed
4945
- React__default["default"].createElement(Listbox, tslib.__assign({ ref: ref,
5096
+ React__default["default"].createElement(Listbox, tslib.__assign({ ref: function (element) {
5097
+ if (ref) {
5098
+ setRef(ref, element);
5099
+ }
5100
+ containerRef.current = element;
5101
+ },
4946
5102
  /* Listbox comes with an explicit role of "listbox", but we want to either
4947
5103
  * use the role from props, or default to the intrinsic role */
4948
5104
  // eslint-disable-next-line jsx-a11y/aria-role
4949
- role: undefined, "aria-multiselectable": actionListContext.role === 'listbox' ? undefined : null, className: classNames__default["default"]('ActionList', className) }, props, { onActiveChange: handleActiveChange, navigation: "bound" }),
5105
+ role: undefined, "aria-multiselectable": actionListContext.role === 'listbox' ? undefined : null, className: classNames__default["default"]('ActionList', className), activeOption: activeOption }, props, { onActiveChange: handleActiveChange, navigation: "bound" }),
4950
5106
  React__default["default"].createElement(ActionListProvider, { role: props.role || 'list', onAction: handleAction, selectionType: selectionType }, children)));
4951
5107
  });
4952
5108
  ActionList.displayName = 'ActionList';
@@ -0,0 +1,31 @@
1
+ import type { RefObject } from 'react';
2
+ import type { ElementOrRef } from '../types';
3
+ type useMnemonicsOptions = {
4
+ /**
5
+ * The container element or ref to use for matching elements for mnemonics.
6
+ * If not provided, the hook will return a ref that should be attached to a container.
7
+ */
8
+ elementOrRef?: ElementOrRef<HTMLElement>;
9
+ /**
10
+ * CSS selector to match for elements, defaults to focusable descendants.
11
+ */
12
+ matchingElementsSelector?: string;
13
+ /**
14
+ * Callback fired when a matching element is found via mnemonic keyboard entry.
15
+ */
16
+ onMatch: (element: HTMLElement) => void;
17
+ /**
18
+ * Whether mnemonic navigation is enabled. Defaults to true.
19
+ */
20
+ enabled?: boolean;
21
+ };
22
+ type useMnemonicsResults<T extends HTMLElement> = RefObject<T>;
23
+ /**
24
+ * A hook that provides mnemonic navigation for keyboard users.
25
+ *
26
+ * Mnemonics allow users to quickly navigate to elements by typing the first
27
+ * letter of the element's text content. Pressing the same letter repeatedly
28
+ * cycles through all matching elements.
29
+ */
30
+ export default function useMnemonics<T extends HTMLElement>({ elementOrRef, matchingElementsSelector, onMatch, enabled }: useMnemonicsOptions): useMnemonicsResults<T>;
31
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deque/cauldron-react",
3
- "version": "6.22.3",
3
+ "version": "6.23.0-canary.4ddfcf41",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Fully accessible react components library for Deque Cauldron",
6
6
  "homepage": "https://cauldron.dequelabs.com/",