@dloizides/ui-forms 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -550,9 +550,55 @@ var controlStyles = reactNative.StyleSheet.create({
550
550
  optionText: { fontSize: OPTION_FONT },
551
551
  error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
552
552
  });
553
+ var ACTIVE_WEIGHT = "700";
554
+ var IDLE_WEIGHT = "400";
555
+ var DETAIL_FONT = 12;
556
+ var DETAIL_GAP = 12;
557
+ var STACK_GAP = 2;
558
+ var DETAIL_BESIDE_MIN_MENU_WIDTH = 400;
559
+ var styles7 = reactNative.StyleSheet.create({
560
+ beside: { alignItems: "baseline", flexDirection: "row", gap: DETAIL_GAP, justifyContent: "space-between" },
561
+ stacked: { gap: STACK_GAP },
562
+ shrink: { flexShrink: 1 },
563
+ detail: { flexShrink: 1, fontSize: DETAIL_FONT }
564
+ });
565
+ function optionDomId(testID, value) {
566
+ return `${testID}-option-${value}`.replace(/\s+/g, "_");
567
+ }
568
+ function listboxDomId(testID) {
569
+ return `${testID}-listbox`;
570
+ }
571
+ function isDetailBesideTitle(menuWidth) {
572
+ return menuWidth >= DETAIL_BESIDE_MIN_MENU_WIDTH;
573
+ }
574
+ function OptionRow({ option, isActive, isHighlighted, isListboxRow, menuWidth, hint, colors, testID, onSelect }) {
575
+ const hasDetail = option.detail !== void 0 && option.detail !== "";
576
+ const label = hasDetail ? `${option.label}, ${option.detail}` : option.label;
577
+ const roleProps = isListboxRow ? { role: "option", tabIndex: -1, id: optionDomId(testID, option.value), "aria-selected": isHighlighted } : { accessibilityRole: "menuitem", "aria-selected": isActive };
578
+ const isBold = isActive || isHighlighted;
579
+ const titleColor = isActive ? colors.brand : colors.text;
580
+ return /* @__PURE__ */ jsxRuntime.jsx(
581
+ reactNative.Pressable,
582
+ {
583
+ ...roleProps,
584
+ accessibilityLabel: label,
585
+ accessibilityHint: hint,
586
+ accessibilityState: { selected: isActive },
587
+ onPress: () => onSelect(option.value),
588
+ style: [controlStyles.option, isHighlighted ? { backgroundColor: colors.border } : null],
589
+ testID: `${testID}-option-${option.value}`,
590
+ children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: isDetailBesideTitle(menuWidth) ? styles7.beside : styles7.stacked, children: [
591
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, styles7.shrink, { color: titleColor, fontWeight: isBold ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: option.label }),
592
+ hasDetail ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.detail, { color: colors.textSecondary ?? colors.text }], children: option.detail }) : null
593
+ ] })
594
+ }
595
+ );
596
+ }
553
597
  var IS_WEB3 = reactNative.Platform.OS === "web";
554
598
  var MENU_PORTAL_Z_INDEX = designTokens.Z_INDEX.popover;
555
599
  var MENU_TOP_GAP2 = 4;
600
+ var MENU_MIN_HEIGHT = 96;
601
+ var VIEWPORT_MARGIN = 8;
556
602
  function toDomNode(ref) {
557
603
  const node = ref.current;
558
604
  if (node === null || typeof node.getBoundingClientRect !== "function") return null;
@@ -565,8 +611,9 @@ function readAnchorRect(ref) {
565
611
  const r = node.getBoundingClientRect();
566
612
  return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
567
613
  }
568
- function buildPortalPopoverStyle(rect) {
614
+ function buildPortalPopoverStyle(rect, viewportBottom) {
569
615
  return {
616
+ ...viewportBottom !== void 0 ? { maxHeight: menuMaxHeight(rect.bottom, viewportBottom) } : null,
570
617
  // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
571
618
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
572
619
  position: "fixed",
@@ -613,10 +660,64 @@ function useAnchorRect(ref, enabled) {
613
660
  }, [ref, enabled]);
614
661
  return IS_WEB3 && enabled ? rect : null;
615
662
  }
663
+ function roomUnderAnchor(anchorBottom, viewportBottom) {
664
+ return viewportBottom - anchorBottom - MENU_TOP_GAP2 - VIEWPORT_MARGIN;
665
+ }
666
+ function menuMaxHeight(anchorBottom, viewportBottom) {
667
+ return Math.min(MENU_MAX_HEIGHT, Math.max(MENU_MIN_HEIGHT, roomUnderAnchor(anchorBottom, viewportBottom)));
668
+ }
669
+ function anchorScrollBlock(anchorBottom, viewportBottom) {
670
+ return roomUnderAnchor(anchorBottom, viewportBottom) < MENU_MAX_HEIGHT ? "start" : "nearest";
671
+ }
672
+ function readViewportBottom() {
673
+ const viewport = window.visualViewport ?? null;
674
+ return viewport === null ? window.innerHeight : viewport.offsetTop + viewport.height;
675
+ }
676
+ function useVisualViewportBottom(enabled) {
677
+ const [bottom, setBottom] = React2.useState(null);
678
+ React2.useLayoutEffect(() => {
679
+ if (!IS_WEB3 || !enabled) {
680
+ setBottom(null);
681
+ return void 0;
682
+ }
683
+ const update = () => setBottom(readViewportBottom());
684
+ update();
685
+ const viewport = window.visualViewport ?? null;
686
+ viewport?.addEventListener("resize", update);
687
+ viewport?.addEventListener("scroll", update);
688
+ window.addEventListener("resize", update);
689
+ return () => {
690
+ viewport?.removeEventListener("resize", update);
691
+ viewport?.removeEventListener("scroll", update);
692
+ window.removeEventListener("resize", update);
693
+ };
694
+ }, [enabled]);
695
+ return bottom;
696
+ }
697
+ function scrollNodeIntoView(node, block) {
698
+ if (node !== null && typeof node.scrollIntoView === "function") node.scrollIntoView({ block });
699
+ }
700
+ function scrollAnchorIntoView(ref, marginTop) {
701
+ if (!IS_WEB3) return;
702
+ const node = toDomNode(ref);
703
+ if (node === null) return;
704
+ node.style.scrollMarginTop = `${marginTop}px`;
705
+ scrollNodeIntoView(node, anchorScrollBlock(node.getBoundingClientRect().bottom, readViewportBottom()));
706
+ }
707
+ function useKeepAnchorInRoom(ref, marginTop, enabled) {
708
+ React2.useLayoutEffect(() => {
709
+ if (!IS_WEB3 || !enabled) return void 0;
710
+ const place = () => scrollAnchorIntoView(ref, marginTop);
711
+ place();
712
+ const viewport = window.visualViewport ?? null;
713
+ viewport?.addEventListener("resize", place);
714
+ return () => viewport?.removeEventListener("resize", place);
715
+ }, [ref, marginTop, enabled]);
716
+ }
616
717
  var IS_WEB4 = typeof document !== "undefined";
617
718
  var ESCAPE_KEY = "Escape";
618
- var ACTIVE_WEIGHT = "700";
619
- var IDLE_WEIGHT = "400";
719
+ var LISTBOX_ROLE = { role: "listbox" };
720
+ var KEEP_FIELD_FOCUS = { onMouseDown: (event) => event.preventDefault() };
620
721
  function useDismiss(anchorRef, menuRef, onDismiss) {
621
722
  React2.useEffect(() => {
622
723
  if (!IS_WEB4) return void 0;
@@ -639,6 +740,12 @@ function useDismiss(anchorRef, menuRef, onDismiss) {
639
740
  };
640
741
  }, [anchorRef, menuRef, onDismiss]);
641
742
  }
743
+ function useKeepHighlightInView(testID, highlightedValue) {
744
+ React2.useEffect(() => {
745
+ if (!IS_WEB4 || highlightedValue === void 0 || highlightedValue === "") return;
746
+ scrollNodeIntoView(document.getElementById(optionDomId(testID, highlightedValue)), "nearest");
747
+ }, [testID, highlightedValue]);
748
+ }
642
749
  function AnchoredMenu({
643
750
  options,
644
751
  selectedValue,
@@ -649,38 +756,48 @@ function AnchoredMenu({
649
756
  accessibilityLabel,
650
757
  testID,
651
758
  anchorRef,
652
- usePortal = true
759
+ usePortal = true,
760
+ listbox
653
761
  }) {
654
762
  const menuRef = React2__default.default.useRef(null);
655
763
  useDismiss(anchorRef, menuRef, onDismiss);
764
+ useKeepHighlightInView(testID, listbox?.highlightedValue);
656
765
  const rect = useAnchorRect(anchorRef, usePortal);
657
- const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
658
- const menu = /* @__PURE__ */ jsxRuntime.jsx(
766
+ const viewportBottom = useVisualViewportBottom(usePortal);
767
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect, viewportBottom ?? void 0) : null;
768
+ const { width: windowWidth } = reactNative.useWindowDimensions();
769
+ const menuWidth = rect?.width ?? windowWidth;
770
+ const isListbox = listbox !== void 0;
771
+ const statusText = listbox?.statusText;
772
+ const rows = options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
773
+ OptionRow,
774
+ {
775
+ colors,
776
+ hint: optionHint,
777
+ isActive: option.value === selectedValue,
778
+ isHighlighted: option.value === listbox?.highlightedValue,
779
+ isListboxRow: isListbox,
780
+ menuWidth,
781
+ option,
782
+ testID,
783
+ onSelect
784
+ },
785
+ option.value
786
+ ));
787
+ const listboxBody = options.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { accessibilityLabel, id: listboxDomId(testID), ...LISTBOX_ROLE, testID: listboxDomId(testID), children: rows }) : null;
788
+ const menu = /* @__PURE__ */ jsxRuntime.jsxs(
659
789
  reactNative.View,
660
790
  {
791
+ ...isListbox ? KEEP_FIELD_FOCUS : null,
661
792
  ref: menuRef,
662
- accessibilityLabel,
663
- accessibilityRole: "menu",
793
+ accessibilityLabel: isListbox ? void 0 : accessibilityLabel,
794
+ accessibilityRole: isListbox ? void 0 : "menu",
664
795
  style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
665
796
  testID: `${testID}-menu`,
666
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: options.map((opt) => {
667
- const active = opt.value === selectedValue;
668
- return /* @__PURE__ */ jsxRuntime.jsx(
669
- reactNative.Pressable,
670
- {
671
- accessibilityRole: "menuitem",
672
- accessibilityLabel: opt.label,
673
- accessibilityHint: optionHint,
674
- accessibilityState: { selected: active },
675
- "aria-selected": active,
676
- onPress: () => onSelect(opt.value),
677
- style: controlStyles.option,
678
- testID: `${testID}-option-${opt.value}`,
679
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
680
- },
681
- opt.value
682
- );
683
- }) })
797
+ children: [
798
+ isListbox ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "status", style: statusText !== void 0 ? [controlStyles.option, controlStyles.optionText, { color: colors.text }] : void 0, children: statusText ?? "" }) : null,
799
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: isListbox ? listboxBody : rows })
800
+ ]
684
801
  }
685
802
  );
686
803
  if (usePortal && IS_WEB4) return reactDom.createPortal(menu, document.body);
@@ -1255,7 +1372,7 @@ function chipIdentity(option, selected, config) {
1255
1372
  ariaChecked: isRadio ? selected : void 0
1256
1373
  };
1257
1374
  }
1258
- var styles7 = reactNative.StyleSheet.create({
1375
+ var styles8 = reactNative.StyleSheet.create({
1259
1376
  chipContainer: {
1260
1377
  flexDirection: "row",
1261
1378
  flexWrap: "wrap",
@@ -1341,7 +1458,7 @@ var ChipSelector = ({
1341
1458
  reactNative.View,
1342
1459
  {
1343
1460
  accessibilityRole: isRadioGroup ? "radiogroup" : void 0,
1344
- style: styles7.chipContainer,
1461
+ style: styles8.chipContainer,
1345
1462
  ...webFieldA11y({ describedById, hasError }),
1346
1463
  ...radio.groupProps,
1347
1464
  children: options.map(renderChip)
@@ -1351,7 +1468,7 @@ var ChipSelector = ({
1351
1468
  );
1352
1469
  };
1353
1470
  var DESCRIPTION_FONT_SIZE2 = 12;
1354
- var styles8 = reactNative.StyleSheet.create({
1471
+ var styles9 = reactNative.StyleSheet.create({
1355
1472
  container: {
1356
1473
  flexDirection: "row",
1357
1474
  justifyContent: "space-between",
@@ -1412,15 +1529,15 @@ var FormSwitch = ({
1412
1529
  accessible: false,
1413
1530
  disabled,
1414
1531
  onPress: toggle,
1415
- style: [styles8.container, containerStyle],
1532
+ style: [styles9.container, containerStyle],
1416
1533
  tabIndex: -1,
1417
1534
  testID: `${testID}-row`,
1418
1535
  children: [
1419
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles8.textColumn, children: [
1420
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.label, themeStyles.label], children: label }),
1421
- hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.description, themeStyles.description], children: description }) : null
1536
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles9.textColumn, children: [
1537
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.label, themeStyles.label], children: label }),
1538
+ hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.description, themeStyles.description], children: description }) : null
1422
1539
  ] }),
1423
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: styles8.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsxRuntime.jsx(
1540
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: styles9.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsxRuntime.jsx(
1424
1541
  reactNative.Switch,
1425
1542
  {
1426
1543
  accessibilityHint: accessibilityHint ?? label,
@@ -1449,7 +1566,7 @@ var DISABLED_OPACITY = 0.5;
1449
1566
  var FOCUS_RING_WIDTH = 2;
1450
1567
  var WEB_NO_DEFAULT_OUTLINE = { outlineStyle: "none" };
1451
1568
  var FOCUS_RING_INSET = -6;
1452
- var styles9 = reactNative.StyleSheet.create({
1569
+ var styles10 = reactNative.StyleSheet.create({
1453
1570
  container: {
1454
1571
  flexDirection: "row",
1455
1572
  alignItems: "flex-start",
@@ -1554,8 +1671,8 @@ var FormCheckbox = ({
1554
1671
  onPress: toggle,
1555
1672
  role: "checkbox",
1556
1673
  style: [
1557
- styles9.container,
1558
- hasHint ? null : styles9.centered,
1674
+ styles10.container,
1675
+ hasHint ? null : styles10.centered,
1559
1676
  containerStyle,
1560
1677
  disabled ? { opacity: DISABLED_OPACITY } : null,
1561
1678
  reactNative.Platform.OS === "web" ? WEB_NO_DEFAULT_OUTLINE : null
@@ -1563,13 +1680,13 @@ var FormCheckbox = ({
1563
1680
  testID,
1564
1681
  ...keyboardProps,
1565
1682
  children: [
1566
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles9.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1567
- focused ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: [styles9.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1568
- value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1683
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles10.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1684
+ focused ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: [styles10.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1685
+ value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles10.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1569
1686
  ] }),
1570
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles9.textColumn, children: [
1571
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.label, themeStyles.label], children: label }),
1572
- hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: hintId, style: [styles9.hint, themeStyles.hint], children: hintText }) : null
1687
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles10.textColumn, children: [
1688
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles10.label, themeStyles.label], children: label }),
1689
+ hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: hintId, style: [styles10.hint, themeStyles.hint], children: hintText }) : null
1573
1690
  ] })
1574
1691
  ]
1575
1692
  }
@@ -1676,94 +1793,212 @@ function isUnmatched(options, text) {
1676
1793
  if (needle === "") return false;
1677
1794
  return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1678
1795
  }
1679
- var CLOSE_DELAY_MS2 = 140;
1680
- var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1681
- var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1682
- function TypeaheadControl({
1683
- options,
1684
- value,
1685
- onChange,
1686
- onSubmit,
1687
- placeholder,
1688
- accessibilityLabel,
1689
- accessibilityHint,
1690
- optionHint = "",
1691
- error,
1692
- minChars = DEFAULT_TYPEAHEAD_MIN_CHARS,
1693
- maxSuggestions = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS,
1694
- style,
1695
- testID
1696
- }) {
1697
- const { theme } = uiFeedback.useUi();
1698
- const { colors, palette } = theme;
1699
- const anchorRef = React2.useRef(null);
1796
+ var DEFAULT_TYPEAHEAD_DEBOUNCE_MS = 250;
1797
+ var IDLE = { results: [], isLoading: false, hasFailed: false, hasSettled: false };
1798
+ function useTypeaheadSearch(load, query, enabled, debounceMs) {
1799
+ const [state, setState] = React2.useState(IDLE);
1800
+ const loadRef = React2.useRef(load);
1801
+ loadRef.current = load;
1802
+ React2.useEffect(() => {
1803
+ if (!enabled) {
1804
+ setState(IDLE);
1805
+ return void 0;
1806
+ }
1807
+ setState((current) => ({ ...current, isLoading: true, hasFailed: false }));
1808
+ const controller = new AbortController();
1809
+ const settle = (next) => {
1810
+ if (!controller.signal.aborted) setState(next);
1811
+ };
1812
+ const timer = setTimeout(() => {
1813
+ Promise.resolve().then(() => loadRef.current?.(query, controller.signal) ?? []).then(
1814
+ (results) => settle({ results, isLoading: false, hasFailed: false, hasSettled: true }),
1815
+ () => settle({ results: [], isLoading: false, hasFailed: true, hasSettled: true })
1816
+ );
1817
+ }, debounceMs);
1818
+ return () => {
1819
+ clearTimeout(timer);
1820
+ controller.abort();
1821
+ };
1822
+ }, [query, enabled, debounceMs]);
1823
+ return state;
1824
+ }
1825
+
1826
+ // src/TypeaheadControl/hooks/useTypeaheadState.ts
1827
+ var NO_HIGHLIGHT = -1;
1828
+ var FORWARD = 1;
1829
+ var BACKWARD = -1;
1830
+ var NO_ROWS = [];
1831
+ function nextHighlight(current, count, delta) {
1832
+ if (count === 0) return NO_HIGHLIGHT;
1833
+ if (current === NO_HIGHLIGHT) return delta > 0 ? 0 : count - 1;
1834
+ return (current + delta + count) % count;
1835
+ }
1836
+ function searchStatusText(found, search) {
1837
+ if (found.hasFailed) return search.errorText;
1838
+ if (found.results.length > 0) return void 0;
1839
+ if (found.isLoading) return search.loadingText;
1840
+ return found.hasSettled ? search.emptyText : void 0;
1841
+ }
1842
+ function useTypeaheadRows(input, isOpen, isPicked) {
1843
+ const { options, search, value, minChars, maxSuggestions } = input;
1844
+ const query = value.trim();
1845
+ const isSearching = search !== void 0 && isOpen && !isPicked && query.length >= minChars;
1846
+ const found = useTypeaheadSearch(search?.load, query, isSearching, search?.debounceMs ?? DEFAULT_TYPEAHEAD_DEBOUNCE_MS);
1847
+ const staticRows = React2.useMemo(() => {
1848
+ if (options === void 0 || options.some((o) => o.label === value)) return NO_ROWS;
1849
+ return suggestOptions(options, value, minChars, maxSuggestions);
1850
+ }, [options, value, minChars, maxSuggestions]);
1851
+ const foundRows = React2.useMemo(() => found.results.slice(0, maxSuggestions), [found.results, maxSuggestions]);
1852
+ if (search === void 0) return { rows: staticRows, statusText: void 0 };
1853
+ return { rows: foundRows, statusText: searchStatusText(found, search) };
1854
+ }
1855
+ function useTypeaheadState(input) {
1856
+ const { search, value, onChange, onSubmit } = input;
1700
1857
  const [isOpen, setIsOpen] = React2.useState(false);
1701
- const exactPick = React2.useMemo(() => options.some((o) => o.label === value), [options, value]);
1702
- const suggestions = React2.useMemo(
1703
- () => exactPick ? [] : suggestOptions(options, value, minChars, maxSuggestions),
1704
- [exactPick, options, value, minChars, maxSuggestions]
1858
+ const [picked, setPicked] = React2.useState(null);
1859
+ const [highlightIndex, setHighlightIndex] = React2.useState(NO_HIGHLIGHT);
1860
+ const { rows, statusText } = useTypeaheadRows(input, isOpen, picked !== null && picked.label === value);
1861
+ const rowKey = rows.map((row) => row.value).join("\n");
1862
+ const highlighted = isOpen ? rows[highlightIndex] : void 0;
1863
+ React2.useEffect(() => setHighlightIndex(NO_HIGHLIGHT), [rowKey]);
1864
+ React2.useEffect(() => setPicked((current) => current !== null && current.label !== value ? null : current), [value]);
1865
+ const open = React2.useCallback(() => setIsOpen(true), []);
1866
+ const close = React2.useCallback(() => {
1867
+ setIsOpen(false);
1868
+ setHighlightIndex(NO_HIGHLIGHT);
1869
+ }, []);
1870
+ const pick = React2.useCallback(
1871
+ (option) => {
1872
+ setPicked(option);
1873
+ onChange(option.label);
1874
+ search?.onPick(option);
1875
+ close();
1876
+ },
1877
+ [onChange, search, close]
1878
+ );
1879
+ const selectValue = React2.useCallback(
1880
+ (optionValue) => {
1881
+ const option = rows.find((row) => row.value === optionValue);
1882
+ if (option !== void 0) pick(option);
1883
+ },
1884
+ [rows, pick]
1705
1885
  );
1706
- const showMenu = isOpen && suggestions.length > 0;
1707
1886
  const handleChange = React2.useCallback(
1708
1887
  (next) => {
1888
+ if (picked !== null) {
1889
+ setPicked(null);
1890
+ search?.onPick(null);
1891
+ }
1709
1892
  onChange(next);
1710
1893
  setIsOpen(true);
1894
+ setHighlightIndex(NO_HIGHLIGHT);
1711
1895
  },
1712
- [onChange]
1896
+ [picked, search, onChange]
1713
1897
  );
1714
- const pick = React2.useCallback(
1715
- (optionValue) => {
1716
- const opt = options.find((o) => o.value === optionValue);
1717
- onChange(opt?.label ?? optionValue);
1718
- setIsOpen(false);
1719
- },
1720
- [options, onChange]
1721
- );
1722
- const handleBlur = React2.useCallback(() => {
1723
- setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS2);
1724
- }, []);
1898
+ const handleKeyPress = useTypeaheadKeys({ rowCount: rows.length, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick });
1725
1899
  const handleSubmit = React2.useCallback(() => {
1726
- setIsOpen(false);
1900
+ if (highlighted !== void 0) {
1901
+ pick(highlighted);
1902
+ return;
1903
+ }
1904
+ close();
1727
1905
  onSubmit?.();
1728
- }, [onSubmit]);
1906
+ }, [highlighted, pick, close, onSubmit]);
1907
+ return { rows, statusText, isOpen, picked, highlighted, open, close, selectValue, handleChange, handleKeyPress, handleSubmit };
1908
+ }
1909
+ function useTypeaheadKeys(input) {
1910
+ const { rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick } = input;
1911
+ return React2.useCallback(
1912
+ (event) => {
1913
+ const { key } = event.nativeEvent;
1914
+ if (key === "ArrowDown" || key === "ArrowUp") {
1915
+ event.preventDefault();
1916
+ setIsOpen(true);
1917
+ setHighlightIndex((current) => nextHighlight(current, rowCount, key === "ArrowDown" ? FORWARD : BACKWARD));
1918
+ } else if (key === "Escape" && isOpen) {
1919
+ event.preventDefault();
1920
+ close();
1921
+ } else if (key === "Enter" && highlighted !== void 0) {
1922
+ event.preventDefault();
1923
+ pick(highlighted);
1924
+ }
1925
+ },
1926
+ [rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick]
1927
+ );
1928
+ }
1929
+ var CLOSE_DELAY_MS2 = 140;
1930
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1931
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1932
+ function comboboxAria(testID, isExpanded, highlighted) {
1933
+ return {
1934
+ role: "combobox",
1935
+ "aria-autocomplete": "list",
1936
+ "aria-expanded": isExpanded,
1937
+ "aria-controls": isExpanded ? listboxDomId(testID) : void 0,
1938
+ "aria-activedescendant": highlighted !== void 0 ? optionDomId(testID, highlighted.value) : void 0
1939
+ };
1940
+ }
1941
+ function TypeaheadControlImpl(props, ref) {
1942
+ const { value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint = "", error, minChars = DEFAULT_TYPEAHEAD_MIN_CHARS, style, testID } = props;
1943
+ const { id, describedById } = props;
1944
+ const search = "search" in props ? props.search : void 0;
1945
+ const options = "options" in props ? props.options : void 0;
1946
+ const maxSuggestions = ("search" in props ? props.search.maxResults : props.maxSuggestions) ?? DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1947
+ const { theme } = uiFeedback.useUi();
1948
+ const { colors, palette } = theme;
1949
+ const anchorRef = React2.useRef(null);
1950
+ const state = useTypeaheadState({ options, search, value, minChars, maxSuggestions, onChange, onSubmit });
1951
+ const showMenu = state.isOpen && (state.rows.length > 0 || state.statusText !== void 0);
1952
+ const isExpanded = state.isOpen && state.rows.length > 0;
1729
1953
  const hasError = error !== void 0 && error !== "";
1954
+ useKeepAnchorInRoom(anchorRef, search?.scrollMarginTop ?? 0, search !== void 0 && state.isOpen);
1730
1955
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1731
1956
  /* @__PURE__ */ jsxRuntime.jsx(
1732
1957
  reactNative.TextInput,
1733
1958
  {
1959
+ ref,
1960
+ id,
1961
+ ...comboboxAria(testID, isExpanded, state.highlighted),
1962
+ ...webFieldA11y({ describedById }),
1734
1963
  accessibilityLabel,
1735
1964
  accessibilityHint,
1736
1965
  autoCapitalize: "none",
1966
+ autoComplete: "off",
1737
1967
  autoCorrect: false,
1968
+ blurOnSubmit: search === void 0,
1738
1969
  placeholder,
1739
1970
  placeholderTextColor: colors.textSecondary,
1740
1971
  spellCheck: false,
1741
1972
  value,
1742
- onChangeText: handleChange,
1743
- onFocus: () => setIsOpen(true),
1744
- onBlur: handleBlur,
1745
- onSubmitEditing: handleSubmit,
1973
+ onChangeText: state.handleChange,
1974
+ onFocus: state.open,
1975
+ onBlur: () => setTimeout(state.close, CLOSE_DELAY_MS2),
1976
+ onKeyPress: state.handleKeyPress,
1977
+ onSubmitEditing: state.handleSubmit,
1746
1978
  style: [controlStyles.input, { borderColor: hasError ? fieldErrorInk(theme) : colors.border, backgroundColor: colors.surface, color: colors.text }],
1747
- testID: `${testID}-input`
1979
+ testID: search?.inputTestID ?? `${testID}-input`
1748
1980
  }
1749
1981
  ),
1750
1982
  showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
1751
1983
  AnchoredMenu,
1752
1984
  {
1753
- options: suggestions,
1754
- selectedValue: value,
1755
- onSelect: pick,
1756
- onDismiss: () => setIsOpen(false),
1757
- colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1985
+ accessibilityLabel,
1986
+ options: state.rows,
1987
+ selectedValue: search !== void 0 ? state.picked?.value ?? "" : value,
1988
+ onSelect: state.selectValue,
1989
+ onDismiss: state.close,
1990
+ colors: { text: colors.text, textSecondary: colors.textSecondary, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1758
1991
  optionHint,
1759
1992
  testID,
1760
1993
  anchorRef,
1994
+ listbox: { highlightedValue: state.highlighted?.value ?? "", statusText: state.statusText },
1761
1995
  usePortal: true
1762
1996
  }
1763
1997
  ) : null,
1764
1998
  hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [controlStyles.error, { color: fieldErrorInk(theme) }], testID: `${testID}-error`, children: error }) : null
1765
1999
  ] });
1766
2000
  }
2001
+ var TypeaheadControl = React2.forwardRef(TypeaheadControlImpl);
1767
2002
  var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1768
2003
  function DateInput(p) {
1769
2004
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: controlStyles.dateCol, children: [
@@ -1860,6 +2095,209 @@ function toggleGroupSelection(selected, groupKeys, order) {
1860
2095
  });
1861
2096
  return order === void 0 ? merged : order.filter((key) => merged.includes(key));
1862
2097
  }
2098
+ var ROW_GAP = 4;
2099
+ var TAB_PADDING = 4;
2100
+ var INDICATOR_WIDTH = 2;
2101
+ var TAB_FONT_SIZE = 12;
2102
+ var styles11 = reactNative.StyleSheet.create({
2103
+ row: { flexDirection: "row", flexWrap: "nowrap", gap: ROW_GAP },
2104
+ tab: {
2105
+ borderBottomWidth: INDICATOR_WIDTH,
2106
+ flexBasis: 0,
2107
+ flexGrow: 1,
2108
+ flexShrink: 1,
2109
+ justifyContent: "center",
2110
+ minHeight: a11y.MIN_TOUCH_TARGET_PX,
2111
+ minWidth: 0,
2112
+ paddingHorizontal: TAB_PADDING
2113
+ },
2114
+ text: { fontSize: TAB_FONT_SIZE, textAlign: "center" },
2115
+ textSelected: { fontWeight: "700" }
2116
+ });
2117
+ var StepperTab = ({
2118
+ step,
2119
+ selected,
2120
+ hint,
2121
+ onStep,
2122
+ testID
2123
+ }) => {
2124
+ const { theme } = uiFeedback.useUi();
2125
+ const ring = uiButtons.useFocusRing();
2126
+ const indicator = selected ? theme.palette.primary["500"] : theme.colors.border;
2127
+ const webKeyProps = {
2128
+ onKeyDown: (event) => {
2129
+ if (event.key !== " ") return;
2130
+ event.preventDefault?.();
2131
+ onStep(step.key);
2132
+ }
2133
+ };
2134
+ return /* @__PURE__ */ jsxRuntime.jsx(
2135
+ reactNative.Pressable,
2136
+ {
2137
+ accessibilityHint: hint,
2138
+ accessibilityLabel: step.label,
2139
+ accessibilityRole: "tab",
2140
+ accessibilityState: { selected },
2141
+ "aria-selected": selected,
2142
+ style: [styles11.tab, { borderBottomColor: indicator }, ring.ringStyle],
2143
+ testID,
2144
+ onBlur: ring.onBlur,
2145
+ onFocus: ring.onFocus,
2146
+ onPress: () => onStep(step.key),
2147
+ ...webKeyProps,
2148
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles11.text, { color: theme.colors.text }, selected ? styles11.textSelected : void 0], children: step.label })
2149
+ }
2150
+ );
2151
+ };
2152
+ var StepperTabs = ({
2153
+ steps,
2154
+ current,
2155
+ onStep,
2156
+ stepHint,
2157
+ testID
2158
+ }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { accessibilityRole: "tablist", style: styles11.row, testID, children: steps.map((step) => /* @__PURE__ */ jsxRuntime.jsx(
2159
+ StepperTab,
2160
+ {
2161
+ hint: stepHint,
2162
+ selected: step.key === current,
2163
+ step,
2164
+ testID: `${testID}-${step.key}`,
2165
+ onStep
2166
+ },
2167
+ step.key
2168
+ )) });
2169
+
2170
+ // src/Stepper/stepperModel.ts
2171
+ function resolveStepNeighbours(steps, current) {
2172
+ const index = steps.findIndex((step) => step.key === current);
2173
+ if (index < 0) return { previous: null, next: null };
2174
+ return { previous: steps[index - 1] ?? null, next: steps[index + 1] ?? null };
2175
+ }
2176
+ var ROW_GAP2 = 8;
2177
+ var styles12 = reactNative.StyleSheet.create({
2178
+ cell: { flex: 1, minWidth: 0 },
2179
+ row: { flexDirection: "row", gap: ROW_GAP2 }
2180
+ });
2181
+ var StepperNav = ({
2182
+ steps,
2183
+ current,
2184
+ onStep,
2185
+ labels,
2186
+ testID
2187
+ }) => {
2188
+ const { previous, next } = resolveStepNeighbours(steps, current);
2189
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles12.row, testID, children: [
2190
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles12.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
2191
+ uiButtons.Button,
2192
+ {
2193
+ fullWidth: true,
2194
+ accessibilityHint: labels.backHint,
2195
+ accessibilityLabel: labels.back,
2196
+ autoLoading: false,
2197
+ disabled: previous === null,
2198
+ label: labels.back,
2199
+ testID: `${testID}-back`,
2200
+ variant: "outline",
2201
+ onPress: () => {
2202
+ if (previous !== null) onStep(previous.key);
2203
+ }
2204
+ }
2205
+ ) }),
2206
+ next === null ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles12.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
2207
+ uiButtons.Button,
2208
+ {
2209
+ fullWidth: true,
2210
+ accessibilityHint: labels.nextHint,
2211
+ accessibilityLabel: labels.next(next),
2212
+ autoLoading: false,
2213
+ label: labels.next(next),
2214
+ testID: `${testID}-next`,
2215
+ onPress: () => onStep(next.key)
2216
+ }
2217
+ ) })
2218
+ ] });
2219
+ };
2220
+ var STATUS_FONT = 16;
2221
+ var styles13 = reactNative.StyleSheet.create({
2222
+ line: { fontSize: STATUS_FONT }
2223
+ });
2224
+ function FormStatusLine({ message, testID, style }) {
2225
+ const { theme } = uiFeedback.useUi();
2226
+ if (message === null || message === void 0 || message === "") return null;
2227
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [styles13.line, { color: theme.colors.text }, style], testID, children: message });
2228
+ }
2229
+ function useFocusFirstInvalid(refs) {
2230
+ const latest = React2.useRef(refs);
2231
+ latest.current = refs;
2232
+ return React2.useCallback((invalid) => {
2233
+ const first = invalid.map((field) => latest.current[field].current).find((node) => node !== null);
2234
+ first?.focus();
2235
+ }, []);
2236
+ }
2237
+ var DEFAULT_PREVIEW_PX = 48;
2238
+ var PREVIEW_RADIUS = 8;
2239
+ var GAP = 8;
2240
+ var TITLE_FONT_SIZE = 14;
2241
+ var HELP_FONT_SIZE = 13;
2242
+ var styles14 = reactNative.StyleSheet.create({
2243
+ help: { flex: 1, fontSize: HELP_FONT_SIZE },
2244
+ root: { gap: GAP },
2245
+ tile: { alignItems: "center", flexDirection: "row", gap: GAP },
2246
+ title: { fontSize: TITLE_FONT_SIZE, fontWeight: "700" },
2247
+ toggle: { alignSelf: "flex-start", justifyContent: "center", minHeight: a11y.MIN_TOUCH_TARGET_PX },
2248
+ toggleText: { fontSize: TITLE_FONT_SIZE, fontWeight: "700", textDecorationLine: "underline" }
2249
+ });
2250
+ var UploadWithLinkField = ({
2251
+ value,
2252
+ onChange,
2253
+ labels,
2254
+ uploadControl,
2255
+ testID,
2256
+ disabled = false,
2257
+ previewSize = DEFAULT_PREVIEW_PX
2258
+ }) => {
2259
+ const { theme } = uiFeedback.useUi();
2260
+ const ring = uiButtons.useFocusRing();
2261
+ const url = value ?? "";
2262
+ const [pasting, setPasting] = React2.useState(url !== "");
2263
+ const previewBox = { borderRadius: PREVIEW_RADIUS, height: previewSize, width: previewSize };
2264
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles14.root, children: [
2265
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.title, { color: theme.colors.text }], children: labels.title }),
2266
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles14.tile, children: [
2267
+ url === "" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [previewBox, { backgroundColor: theme.colors.border }] }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Image, { accessible: false, source: { uri: url }, style: previewBox, testID: `${testID}-preview` }),
2268
+ labels.help === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.help, { color: theme.colors.text }], children: labels.help })
2269
+ ] }),
2270
+ uploadControl,
2271
+ /* @__PURE__ */ jsxRuntime.jsx(
2272
+ reactNative.Pressable,
2273
+ {
2274
+ accessibilityHint: labels.pasteToggleHint,
2275
+ accessibilityLabel: labels.pasteToggle,
2276
+ accessibilityRole: "button",
2277
+ accessibilityState: { expanded: pasting },
2278
+ "aria-expanded": pasting,
2279
+ style: [styles14.toggle, ring.ringStyle],
2280
+ testID: `${testID}-paste`,
2281
+ onBlur: ring.onBlur,
2282
+ onFocus: ring.onFocus,
2283
+ onPress: () => setPasting(!pasting),
2284
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2285
+ }
2286
+ ),
2287
+ pasting ? /* @__PURE__ */ jsxRuntime.jsx(
2288
+ FormField,
2289
+ {
2290
+ accessibilityHint: labels.linkHint,
2291
+ editable: !disabled,
2292
+ label: labels.link,
2293
+ placeholder: labels.linkPlaceholder,
2294
+ testID,
2295
+ value: url,
2296
+ onChangeText: onChange
2297
+ }
2298
+ ) : null
2299
+ ] });
2300
+ };
1863
2301
 
1864
2302
  exports.AlignToControl = AlignToControl;
1865
2303
  exports.AnchoredMenu = AnchoredMenu;
@@ -1876,19 +2314,25 @@ exports.FormCheckbox = FormCheckbox;
1876
2314
  exports.FormField = FormField;
1877
2315
  exports.FormGrid = FormGrid;
1878
2316
  exports.FormSection = FormSection;
2317
+ exports.FormStatusLine = FormStatusLine;
1879
2318
  exports.FormSwitch = FormSwitch;
1880
2319
  exports.INLINE_FIELD_ROW_MIN_WIDTH = INLINE_FIELD_ROW_MIN_WIDTH;
1881
2320
  exports.InlineFieldRow = InlineFieldRow;
1882
2321
  exports.SelectControl = SelectControl;
2322
+ exports.StepperNav = StepperNav;
2323
+ exports.StepperTabs = StepperTabs;
1883
2324
  exports.ThemedTextInput = ThemedTextInput;
1884
2325
  exports.TypeaheadControl = TypeaheadControl;
2326
+ exports.UploadWithLinkField = UploadWithLinkField;
1885
2327
  exports.controlStyles = controlStyles;
1886
2328
  exports.groupAriaChecked = groupAriaChecked;
1887
2329
  exports.inlineFieldRowLayout = inlineFieldRowLayout;
1888
2330
  exports.isUnmatched = isUnmatched;
1889
2331
  exports.resolveGroupSelection = resolveGroupSelection;
2332
+ exports.resolveStepNeighbours = resolveStepNeighbours;
1890
2333
  exports.suggestOptions = suggestOptions;
1891
2334
  exports.toggleGroupSelection = toggleGroupSelection;
2335
+ exports.useFocusFirstInvalid = useFocusFirstInvalid;
1892
2336
  exports.useRecentValues = useRecentValues;
1893
2337
  exports.useThemedInput = useThemedInput;
1894
2338
  //# sourceMappingURL=index.js.map