@dloizides/ui-forms 1.22.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.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import React2, { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from 'react';
1
+ import React2, { forwardRef, useState, useCallback, useMemo, useRef, useEffect, useLayoutEffect } from 'react';
2
2
  import { Platform, StyleSheet, TextInput, View, Text, useWindowDimensions, ScrollView, Pressable, Switch, Image, TouchableOpacity } from 'react-native';
3
3
  import { useUi } from '@dloizides/ui-feedback';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -544,9 +544,55 @@ var controlStyles = StyleSheet.create({
544
544
  optionText: { fontSize: OPTION_FONT },
545
545
  error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
546
546
  });
547
+ var ACTIVE_WEIGHT = "700";
548
+ var IDLE_WEIGHT = "400";
549
+ var DETAIL_FONT = 12;
550
+ var DETAIL_GAP = 12;
551
+ var STACK_GAP = 2;
552
+ var DETAIL_BESIDE_MIN_MENU_WIDTH = 400;
553
+ var styles7 = StyleSheet.create({
554
+ beside: { alignItems: "baseline", flexDirection: "row", gap: DETAIL_GAP, justifyContent: "space-between" },
555
+ stacked: { gap: STACK_GAP },
556
+ shrink: { flexShrink: 1 },
557
+ detail: { flexShrink: 1, fontSize: DETAIL_FONT }
558
+ });
559
+ function optionDomId(testID, value) {
560
+ return `${testID}-option-${value}`.replace(/\s+/g, "_");
561
+ }
562
+ function listboxDomId(testID) {
563
+ return `${testID}-listbox`;
564
+ }
565
+ function isDetailBesideTitle(menuWidth) {
566
+ return menuWidth >= DETAIL_BESIDE_MIN_MENU_WIDTH;
567
+ }
568
+ function OptionRow({ option, isActive, isHighlighted, isListboxRow, menuWidth, hint, colors, testID, onSelect }) {
569
+ const hasDetail = option.detail !== void 0 && option.detail !== "";
570
+ const label = hasDetail ? `${option.label}, ${option.detail}` : option.label;
571
+ const roleProps = isListboxRow ? { role: "option", tabIndex: -1, id: optionDomId(testID, option.value), "aria-selected": isHighlighted } : { accessibilityRole: "menuitem", "aria-selected": isActive };
572
+ const isBold = isActive || isHighlighted;
573
+ const titleColor = isActive ? colors.brand : colors.text;
574
+ return /* @__PURE__ */ jsx(
575
+ Pressable,
576
+ {
577
+ ...roleProps,
578
+ accessibilityLabel: label,
579
+ accessibilityHint: hint,
580
+ accessibilityState: { selected: isActive },
581
+ onPress: () => onSelect(option.value),
582
+ style: [controlStyles.option, isHighlighted ? { backgroundColor: colors.border } : null],
583
+ testID: `${testID}-option-${option.value}`,
584
+ children: /* @__PURE__ */ jsxs(View, { style: isDetailBesideTitle(menuWidth) ? styles7.beside : styles7.stacked, children: [
585
+ /* @__PURE__ */ jsx(Text, { style: [controlStyles.optionText, styles7.shrink, { color: titleColor, fontWeight: isBold ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: option.label }),
586
+ hasDetail ? /* @__PURE__ */ jsx(Text, { style: [styles7.detail, { color: colors.textSecondary ?? colors.text }], children: option.detail }) : null
587
+ ] })
588
+ }
589
+ );
590
+ }
547
591
  var IS_WEB3 = Platform.OS === "web";
548
592
  var MENU_PORTAL_Z_INDEX = Z_INDEX.popover;
549
593
  var MENU_TOP_GAP2 = 4;
594
+ var MENU_MIN_HEIGHT = 96;
595
+ var VIEWPORT_MARGIN = 8;
550
596
  function toDomNode(ref) {
551
597
  const node = ref.current;
552
598
  if (node === null || typeof node.getBoundingClientRect !== "function") return null;
@@ -559,8 +605,9 @@ function readAnchorRect(ref) {
559
605
  const r = node.getBoundingClientRect();
560
606
  return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
561
607
  }
562
- function buildPortalPopoverStyle(rect) {
608
+ function buildPortalPopoverStyle(rect, viewportBottom) {
563
609
  return {
610
+ ...viewportBottom !== void 0 ? { maxHeight: menuMaxHeight(rect.bottom, viewportBottom) } : null,
564
611
  // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
565
612
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
566
613
  position: "fixed",
@@ -607,10 +654,64 @@ function useAnchorRect(ref, enabled) {
607
654
  }, [ref, enabled]);
608
655
  return IS_WEB3 && enabled ? rect : null;
609
656
  }
657
+ function roomUnderAnchor(anchorBottom, viewportBottom) {
658
+ return viewportBottom - anchorBottom - MENU_TOP_GAP2 - VIEWPORT_MARGIN;
659
+ }
660
+ function menuMaxHeight(anchorBottom, viewportBottom) {
661
+ return Math.min(MENU_MAX_HEIGHT, Math.max(MENU_MIN_HEIGHT, roomUnderAnchor(anchorBottom, viewportBottom)));
662
+ }
663
+ function anchorScrollBlock(anchorBottom, viewportBottom) {
664
+ return roomUnderAnchor(anchorBottom, viewportBottom) < MENU_MAX_HEIGHT ? "start" : "nearest";
665
+ }
666
+ function readViewportBottom() {
667
+ const viewport = window.visualViewport ?? null;
668
+ return viewport === null ? window.innerHeight : viewport.offsetTop + viewport.height;
669
+ }
670
+ function useVisualViewportBottom(enabled) {
671
+ const [bottom, setBottom] = useState(null);
672
+ useLayoutEffect(() => {
673
+ if (!IS_WEB3 || !enabled) {
674
+ setBottom(null);
675
+ return void 0;
676
+ }
677
+ const update = () => setBottom(readViewportBottom());
678
+ update();
679
+ const viewport = window.visualViewport ?? null;
680
+ viewport?.addEventListener("resize", update);
681
+ viewport?.addEventListener("scroll", update);
682
+ window.addEventListener("resize", update);
683
+ return () => {
684
+ viewport?.removeEventListener("resize", update);
685
+ viewport?.removeEventListener("scroll", update);
686
+ window.removeEventListener("resize", update);
687
+ };
688
+ }, [enabled]);
689
+ return bottom;
690
+ }
691
+ function scrollNodeIntoView(node, block) {
692
+ if (node !== null && typeof node.scrollIntoView === "function") node.scrollIntoView({ block });
693
+ }
694
+ function scrollAnchorIntoView(ref, marginTop) {
695
+ if (!IS_WEB3) return;
696
+ const node = toDomNode(ref);
697
+ if (node === null) return;
698
+ node.style.scrollMarginTop = `${marginTop}px`;
699
+ scrollNodeIntoView(node, anchorScrollBlock(node.getBoundingClientRect().bottom, readViewportBottom()));
700
+ }
701
+ function useKeepAnchorInRoom(ref, marginTop, enabled) {
702
+ useLayoutEffect(() => {
703
+ if (!IS_WEB3 || !enabled) return void 0;
704
+ const place = () => scrollAnchorIntoView(ref, marginTop);
705
+ place();
706
+ const viewport = window.visualViewport ?? null;
707
+ viewport?.addEventListener("resize", place);
708
+ return () => viewport?.removeEventListener("resize", place);
709
+ }, [ref, marginTop, enabled]);
710
+ }
610
711
  var IS_WEB4 = typeof document !== "undefined";
611
712
  var ESCAPE_KEY = "Escape";
612
- var ACTIVE_WEIGHT = "700";
613
- var IDLE_WEIGHT = "400";
713
+ var LISTBOX_ROLE = { role: "listbox" };
714
+ var KEEP_FIELD_FOCUS = { onMouseDown: (event) => event.preventDefault() };
614
715
  function useDismiss(anchorRef, menuRef, onDismiss) {
615
716
  useEffect(() => {
616
717
  if (!IS_WEB4) return void 0;
@@ -633,6 +734,12 @@ function useDismiss(anchorRef, menuRef, onDismiss) {
633
734
  };
634
735
  }, [anchorRef, menuRef, onDismiss]);
635
736
  }
737
+ function useKeepHighlightInView(testID, highlightedValue) {
738
+ useEffect(() => {
739
+ if (!IS_WEB4 || highlightedValue === void 0 || highlightedValue === "") return;
740
+ scrollNodeIntoView(document.getElementById(optionDomId(testID, highlightedValue)), "nearest");
741
+ }, [testID, highlightedValue]);
742
+ }
636
743
  function AnchoredMenu({
637
744
  options,
638
745
  selectedValue,
@@ -643,38 +750,48 @@ function AnchoredMenu({
643
750
  accessibilityLabel,
644
751
  testID,
645
752
  anchorRef,
646
- usePortal = true
753
+ usePortal = true,
754
+ listbox
647
755
  }) {
648
756
  const menuRef = React2.useRef(null);
649
757
  useDismiss(anchorRef, menuRef, onDismiss);
758
+ useKeepHighlightInView(testID, listbox?.highlightedValue);
650
759
  const rect = useAnchorRect(anchorRef, usePortal);
651
- const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
652
- const menu = /* @__PURE__ */ jsx(
760
+ const viewportBottom = useVisualViewportBottom(usePortal);
761
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect, viewportBottom ?? void 0) : null;
762
+ const { width: windowWidth } = useWindowDimensions();
763
+ const menuWidth = rect?.width ?? windowWidth;
764
+ const isListbox = listbox !== void 0;
765
+ const statusText = listbox?.statusText;
766
+ const rows = options.map((option) => /* @__PURE__ */ jsx(
767
+ OptionRow,
768
+ {
769
+ colors,
770
+ hint: optionHint,
771
+ isActive: option.value === selectedValue,
772
+ isHighlighted: option.value === listbox?.highlightedValue,
773
+ isListboxRow: isListbox,
774
+ menuWidth,
775
+ option,
776
+ testID,
777
+ onSelect
778
+ },
779
+ option.value
780
+ ));
781
+ const listboxBody = options.length > 0 ? /* @__PURE__ */ jsx(View, { accessibilityLabel, id: listboxDomId(testID), ...LISTBOX_ROLE, testID: listboxDomId(testID), children: rows }) : null;
782
+ const menu = /* @__PURE__ */ jsxs(
653
783
  View,
654
784
  {
785
+ ...isListbox ? KEEP_FIELD_FOCUS : null,
655
786
  ref: menuRef,
656
- accessibilityLabel,
657
- accessibilityRole: "menu",
787
+ accessibilityLabel: isListbox ? void 0 : accessibilityLabel,
788
+ accessibilityRole: isListbox ? void 0 : "menu",
658
789
  style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
659
790
  testID: `${testID}-menu`,
660
- children: /* @__PURE__ */ jsx(ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: options.map((opt) => {
661
- const active = opt.value === selectedValue;
662
- return /* @__PURE__ */ jsx(
663
- Pressable,
664
- {
665
- accessibilityRole: "menuitem",
666
- accessibilityLabel: opt.label,
667
- accessibilityHint: optionHint,
668
- accessibilityState: { selected: active },
669
- "aria-selected": active,
670
- onPress: () => onSelect(opt.value),
671
- style: controlStyles.option,
672
- testID: `${testID}-option-${opt.value}`,
673
- children: /* @__PURE__ */ jsx(Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
674
- },
675
- opt.value
676
- );
677
- }) })
791
+ children: [
792
+ isListbox ? /* @__PURE__ */ jsx(Text, { role: "status", style: statusText !== void 0 ? [controlStyles.option, controlStyles.optionText, { color: colors.text }] : void 0, children: statusText ?? "" }) : null,
793
+ /* @__PURE__ */ jsx(ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: isListbox ? listboxBody : rows })
794
+ ]
678
795
  }
679
796
  );
680
797
  if (usePortal && IS_WEB4) return createPortal(menu, document.body);
@@ -1249,7 +1366,7 @@ function chipIdentity(option, selected, config) {
1249
1366
  ariaChecked: isRadio ? selected : void 0
1250
1367
  };
1251
1368
  }
1252
- var styles7 = StyleSheet.create({
1369
+ var styles8 = StyleSheet.create({
1253
1370
  chipContainer: {
1254
1371
  flexDirection: "row",
1255
1372
  flexWrap: "wrap",
@@ -1335,7 +1452,7 @@ var ChipSelector = ({
1335
1452
  View,
1336
1453
  {
1337
1454
  accessibilityRole: isRadioGroup ? "radiogroup" : void 0,
1338
- style: styles7.chipContainer,
1455
+ style: styles8.chipContainer,
1339
1456
  ...webFieldA11y({ describedById, hasError }),
1340
1457
  ...radio.groupProps,
1341
1458
  children: options.map(renderChip)
@@ -1345,7 +1462,7 @@ var ChipSelector = ({
1345
1462
  );
1346
1463
  };
1347
1464
  var DESCRIPTION_FONT_SIZE2 = 12;
1348
- var styles8 = StyleSheet.create({
1465
+ var styles9 = StyleSheet.create({
1349
1466
  container: {
1350
1467
  flexDirection: "row",
1351
1468
  justifyContent: "space-between",
@@ -1406,15 +1523,15 @@ var FormSwitch = ({
1406
1523
  accessible: false,
1407
1524
  disabled,
1408
1525
  onPress: toggle,
1409
- style: [styles8.container, containerStyle],
1526
+ style: [styles9.container, containerStyle],
1410
1527
  tabIndex: -1,
1411
1528
  testID: `${testID}-row`,
1412
1529
  children: [
1413
- /* @__PURE__ */ jsxs(View, { style: styles8.textColumn, children: [
1414
- /* @__PURE__ */ jsx(Text, { style: [styles8.label, themeStyles.label], children: label }),
1415
- hasDescription ? /* @__PURE__ */ jsx(Text, { style: [styles8.description, themeStyles.description], children: description }) : null
1530
+ /* @__PURE__ */ jsxs(View, { style: styles9.textColumn, children: [
1531
+ /* @__PURE__ */ jsx(Text, { style: [styles9.label, themeStyles.label], children: label }),
1532
+ hasDescription ? /* @__PURE__ */ jsx(Text, { style: [styles9.description, themeStyles.description], children: description }) : null
1416
1533
  ] }),
1417
- /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: styles8.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsx(
1534
+ /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: styles9.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsx(
1418
1535
  Switch,
1419
1536
  {
1420
1537
  accessibilityHint: accessibilityHint ?? label,
@@ -1443,7 +1560,7 @@ var DISABLED_OPACITY = 0.5;
1443
1560
  var FOCUS_RING_WIDTH = 2;
1444
1561
  var WEB_NO_DEFAULT_OUTLINE = { outlineStyle: "none" };
1445
1562
  var FOCUS_RING_INSET = -6;
1446
- var styles9 = StyleSheet.create({
1563
+ var styles10 = StyleSheet.create({
1447
1564
  container: {
1448
1565
  flexDirection: "row",
1449
1566
  alignItems: "flex-start",
@@ -1548,8 +1665,8 @@ var FormCheckbox = ({
1548
1665
  onPress: toggle,
1549
1666
  role: "checkbox",
1550
1667
  style: [
1551
- styles9.container,
1552
- hasHint ? null : styles9.centered,
1668
+ styles10.container,
1669
+ hasHint ? null : styles10.centered,
1553
1670
  containerStyle,
1554
1671
  disabled ? { opacity: DISABLED_OPACITY } : null,
1555
1672
  Platform.OS === "web" ? WEB_NO_DEFAULT_OUTLINE : null
@@ -1557,13 +1674,13 @@ var FormCheckbox = ({
1557
1674
  testID,
1558
1675
  ...keyboardProps,
1559
1676
  children: [
1560
- /* @__PURE__ */ jsxs(View, { style: [styles9.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1561
- focused ? /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: [styles9.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1562
- value ? /* @__PURE__ */ jsx(Text, { style: [styles9.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1677
+ /* @__PURE__ */ jsxs(View, { style: [styles10.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1678
+ focused ? /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: [styles10.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1679
+ value ? /* @__PURE__ */ jsx(Text, { style: [styles10.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1563
1680
  ] }),
1564
- /* @__PURE__ */ jsxs(View, { style: styles9.textColumn, children: [
1565
- /* @__PURE__ */ jsx(Text, { style: [styles9.label, themeStyles.label], children: label }),
1566
- hasHint ? /* @__PURE__ */ jsx(Text, { nativeID: hintId, style: [styles9.hint, themeStyles.hint], children: hintText }) : null
1681
+ /* @__PURE__ */ jsxs(View, { style: styles10.textColumn, children: [
1682
+ /* @__PURE__ */ jsx(Text, { style: [styles10.label, themeStyles.label], children: label }),
1683
+ hasHint ? /* @__PURE__ */ jsx(Text, { nativeID: hintId, style: [styles10.hint, themeStyles.hint], children: hintText }) : null
1567
1684
  ] })
1568
1685
  ]
1569
1686
  }
@@ -1670,94 +1787,212 @@ function isUnmatched(options, text) {
1670
1787
  if (needle === "") return false;
1671
1788
  return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1672
1789
  }
1673
- var CLOSE_DELAY_MS2 = 140;
1674
- var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1675
- var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1676
- function TypeaheadControl({
1677
- options,
1678
- value,
1679
- onChange,
1680
- onSubmit,
1681
- placeholder,
1682
- accessibilityLabel,
1683
- accessibilityHint,
1684
- optionHint = "",
1685
- error,
1686
- minChars = DEFAULT_TYPEAHEAD_MIN_CHARS,
1687
- maxSuggestions = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS,
1688
- style,
1689
- testID
1690
- }) {
1691
- const { theme } = useUi();
1692
- const { colors, palette } = theme;
1693
- const anchorRef = useRef(null);
1790
+ var DEFAULT_TYPEAHEAD_DEBOUNCE_MS = 250;
1791
+ var IDLE = { results: [], isLoading: false, hasFailed: false, hasSettled: false };
1792
+ function useTypeaheadSearch(load, query, enabled, debounceMs) {
1793
+ const [state, setState] = useState(IDLE);
1794
+ const loadRef = useRef(load);
1795
+ loadRef.current = load;
1796
+ useEffect(() => {
1797
+ if (!enabled) {
1798
+ setState(IDLE);
1799
+ return void 0;
1800
+ }
1801
+ setState((current) => ({ ...current, isLoading: true, hasFailed: false }));
1802
+ const controller = new AbortController();
1803
+ const settle = (next) => {
1804
+ if (!controller.signal.aborted) setState(next);
1805
+ };
1806
+ const timer = setTimeout(() => {
1807
+ Promise.resolve().then(() => loadRef.current?.(query, controller.signal) ?? []).then(
1808
+ (results) => settle({ results, isLoading: false, hasFailed: false, hasSettled: true }),
1809
+ () => settle({ results: [], isLoading: false, hasFailed: true, hasSettled: true })
1810
+ );
1811
+ }, debounceMs);
1812
+ return () => {
1813
+ clearTimeout(timer);
1814
+ controller.abort();
1815
+ };
1816
+ }, [query, enabled, debounceMs]);
1817
+ return state;
1818
+ }
1819
+
1820
+ // src/TypeaheadControl/hooks/useTypeaheadState.ts
1821
+ var NO_HIGHLIGHT = -1;
1822
+ var FORWARD = 1;
1823
+ var BACKWARD = -1;
1824
+ var NO_ROWS = [];
1825
+ function nextHighlight(current, count, delta) {
1826
+ if (count === 0) return NO_HIGHLIGHT;
1827
+ if (current === NO_HIGHLIGHT) return delta > 0 ? 0 : count - 1;
1828
+ return (current + delta + count) % count;
1829
+ }
1830
+ function searchStatusText(found, search) {
1831
+ if (found.hasFailed) return search.errorText;
1832
+ if (found.results.length > 0) return void 0;
1833
+ if (found.isLoading) return search.loadingText;
1834
+ return found.hasSettled ? search.emptyText : void 0;
1835
+ }
1836
+ function useTypeaheadRows(input, isOpen, isPicked) {
1837
+ const { options, search, value, minChars, maxSuggestions } = input;
1838
+ const query = value.trim();
1839
+ const isSearching = search !== void 0 && isOpen && !isPicked && query.length >= minChars;
1840
+ const found = useTypeaheadSearch(search?.load, query, isSearching, search?.debounceMs ?? DEFAULT_TYPEAHEAD_DEBOUNCE_MS);
1841
+ const staticRows = useMemo(() => {
1842
+ if (options === void 0 || options.some((o) => o.label === value)) return NO_ROWS;
1843
+ return suggestOptions(options, value, minChars, maxSuggestions);
1844
+ }, [options, value, minChars, maxSuggestions]);
1845
+ const foundRows = useMemo(() => found.results.slice(0, maxSuggestions), [found.results, maxSuggestions]);
1846
+ if (search === void 0) return { rows: staticRows, statusText: void 0 };
1847
+ return { rows: foundRows, statusText: searchStatusText(found, search) };
1848
+ }
1849
+ function useTypeaheadState(input) {
1850
+ const { search, value, onChange, onSubmit } = input;
1694
1851
  const [isOpen, setIsOpen] = useState(false);
1695
- const exactPick = useMemo(() => options.some((o) => o.label === value), [options, value]);
1696
- const suggestions = useMemo(
1697
- () => exactPick ? [] : suggestOptions(options, value, minChars, maxSuggestions),
1698
- [exactPick, options, value, minChars, maxSuggestions]
1852
+ const [picked, setPicked] = useState(null);
1853
+ const [highlightIndex, setHighlightIndex] = useState(NO_HIGHLIGHT);
1854
+ const { rows, statusText } = useTypeaheadRows(input, isOpen, picked !== null && picked.label === value);
1855
+ const rowKey = rows.map((row) => row.value).join("\n");
1856
+ const highlighted = isOpen ? rows[highlightIndex] : void 0;
1857
+ useEffect(() => setHighlightIndex(NO_HIGHLIGHT), [rowKey]);
1858
+ useEffect(() => setPicked((current) => current !== null && current.label !== value ? null : current), [value]);
1859
+ const open = useCallback(() => setIsOpen(true), []);
1860
+ const close = useCallback(() => {
1861
+ setIsOpen(false);
1862
+ setHighlightIndex(NO_HIGHLIGHT);
1863
+ }, []);
1864
+ const pick = useCallback(
1865
+ (option) => {
1866
+ setPicked(option);
1867
+ onChange(option.label);
1868
+ search?.onPick(option);
1869
+ close();
1870
+ },
1871
+ [onChange, search, close]
1872
+ );
1873
+ const selectValue = useCallback(
1874
+ (optionValue) => {
1875
+ const option = rows.find((row) => row.value === optionValue);
1876
+ if (option !== void 0) pick(option);
1877
+ },
1878
+ [rows, pick]
1699
1879
  );
1700
- const showMenu = isOpen && suggestions.length > 0;
1701
1880
  const handleChange = useCallback(
1702
1881
  (next) => {
1882
+ if (picked !== null) {
1883
+ setPicked(null);
1884
+ search?.onPick(null);
1885
+ }
1703
1886
  onChange(next);
1704
1887
  setIsOpen(true);
1888
+ setHighlightIndex(NO_HIGHLIGHT);
1705
1889
  },
1706
- [onChange]
1890
+ [picked, search, onChange]
1707
1891
  );
1708
- const pick = useCallback(
1709
- (optionValue) => {
1710
- const opt = options.find((o) => o.value === optionValue);
1711
- onChange(opt?.label ?? optionValue);
1712
- setIsOpen(false);
1713
- },
1714
- [options, onChange]
1715
- );
1716
- const handleBlur = useCallback(() => {
1717
- setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS2);
1718
- }, []);
1892
+ const handleKeyPress = useTypeaheadKeys({ rowCount: rows.length, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick });
1719
1893
  const handleSubmit = useCallback(() => {
1720
- setIsOpen(false);
1894
+ if (highlighted !== void 0) {
1895
+ pick(highlighted);
1896
+ return;
1897
+ }
1898
+ close();
1721
1899
  onSubmit?.();
1722
- }, [onSubmit]);
1900
+ }, [highlighted, pick, close, onSubmit]);
1901
+ return { rows, statusText, isOpen, picked, highlighted, open, close, selectValue, handleChange, handleKeyPress, handleSubmit };
1902
+ }
1903
+ function useTypeaheadKeys(input) {
1904
+ const { rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick } = input;
1905
+ return useCallback(
1906
+ (event) => {
1907
+ const { key } = event.nativeEvent;
1908
+ if (key === "ArrowDown" || key === "ArrowUp") {
1909
+ event.preventDefault();
1910
+ setIsOpen(true);
1911
+ setHighlightIndex((current) => nextHighlight(current, rowCount, key === "ArrowDown" ? FORWARD : BACKWARD));
1912
+ } else if (key === "Escape" && isOpen) {
1913
+ event.preventDefault();
1914
+ close();
1915
+ } else if (key === "Enter" && highlighted !== void 0) {
1916
+ event.preventDefault();
1917
+ pick(highlighted);
1918
+ }
1919
+ },
1920
+ [rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick]
1921
+ );
1922
+ }
1923
+ var CLOSE_DELAY_MS2 = 140;
1924
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1925
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1926
+ function comboboxAria(testID, isExpanded, highlighted) {
1927
+ return {
1928
+ role: "combobox",
1929
+ "aria-autocomplete": "list",
1930
+ "aria-expanded": isExpanded,
1931
+ "aria-controls": isExpanded ? listboxDomId(testID) : void 0,
1932
+ "aria-activedescendant": highlighted !== void 0 ? optionDomId(testID, highlighted.value) : void 0
1933
+ };
1934
+ }
1935
+ function TypeaheadControlImpl(props, ref) {
1936
+ const { value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint = "", error, minChars = DEFAULT_TYPEAHEAD_MIN_CHARS, style, testID } = props;
1937
+ const { id, describedById } = props;
1938
+ const search = "search" in props ? props.search : void 0;
1939
+ const options = "options" in props ? props.options : void 0;
1940
+ const maxSuggestions = ("search" in props ? props.search.maxResults : props.maxSuggestions) ?? DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1941
+ const { theme } = useUi();
1942
+ const { colors, palette } = theme;
1943
+ const anchorRef = useRef(null);
1944
+ const state = useTypeaheadState({ options, search, value, minChars, maxSuggestions, onChange, onSubmit });
1945
+ const showMenu = state.isOpen && (state.rows.length > 0 || state.statusText !== void 0);
1946
+ const isExpanded = state.isOpen && state.rows.length > 0;
1723
1947
  const hasError = error !== void 0 && error !== "";
1948
+ useKeepAnchorInRoom(anchorRef, search?.scrollMarginTop ?? 0, search !== void 0 && state.isOpen);
1724
1949
  return /* @__PURE__ */ jsxs(View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1725
1950
  /* @__PURE__ */ jsx(
1726
1951
  TextInput,
1727
1952
  {
1953
+ ref,
1954
+ id,
1955
+ ...comboboxAria(testID, isExpanded, state.highlighted),
1956
+ ...webFieldA11y({ describedById }),
1728
1957
  accessibilityLabel,
1729
1958
  accessibilityHint,
1730
1959
  autoCapitalize: "none",
1960
+ autoComplete: "off",
1731
1961
  autoCorrect: false,
1962
+ blurOnSubmit: search === void 0,
1732
1963
  placeholder,
1733
1964
  placeholderTextColor: colors.textSecondary,
1734
1965
  spellCheck: false,
1735
1966
  value,
1736
- onChangeText: handleChange,
1737
- onFocus: () => setIsOpen(true),
1738
- onBlur: handleBlur,
1739
- onSubmitEditing: handleSubmit,
1967
+ onChangeText: state.handleChange,
1968
+ onFocus: state.open,
1969
+ onBlur: () => setTimeout(state.close, CLOSE_DELAY_MS2),
1970
+ onKeyPress: state.handleKeyPress,
1971
+ onSubmitEditing: state.handleSubmit,
1740
1972
  style: [controlStyles.input, { borderColor: hasError ? fieldErrorInk(theme) : colors.border, backgroundColor: colors.surface, color: colors.text }],
1741
- testID: `${testID}-input`
1973
+ testID: search?.inputTestID ?? `${testID}-input`
1742
1974
  }
1743
1975
  ),
1744
1976
  showMenu ? /* @__PURE__ */ jsx(
1745
1977
  AnchoredMenu,
1746
1978
  {
1747
- options: suggestions,
1748
- selectedValue: value,
1749
- onSelect: pick,
1750
- onDismiss: () => setIsOpen(false),
1751
- colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1979
+ accessibilityLabel,
1980
+ options: state.rows,
1981
+ selectedValue: search !== void 0 ? state.picked?.value ?? "" : value,
1982
+ onSelect: state.selectValue,
1983
+ onDismiss: state.close,
1984
+ colors: { text: colors.text, textSecondary: colors.textSecondary, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1752
1985
  optionHint,
1753
1986
  testID,
1754
1987
  anchorRef,
1988
+ listbox: { highlightedValue: state.highlighted?.value ?? "", statusText: state.statusText },
1755
1989
  usePortal: true
1756
1990
  }
1757
1991
  ) : null,
1758
1992
  hasError ? /* @__PURE__ */ jsx(Text, { role: "alert", style: [controlStyles.error, { color: fieldErrorInk(theme) }], testID: `${testID}-error`, children: error }) : null
1759
1993
  ] });
1760
1994
  }
1995
+ var TypeaheadControl = forwardRef(TypeaheadControlImpl);
1761
1996
  var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1762
1997
  function DateInput(p) {
1763
1998
  return /* @__PURE__ */ jsxs(View, { style: controlStyles.dateCol, children: [
@@ -1858,7 +2093,7 @@ var ROW_GAP = 4;
1858
2093
  var TAB_PADDING = 4;
1859
2094
  var INDICATOR_WIDTH = 2;
1860
2095
  var TAB_FONT_SIZE = 12;
1861
- var styles10 = StyleSheet.create({
2096
+ var styles11 = StyleSheet.create({
1862
2097
  row: { flexDirection: "row", flexWrap: "nowrap", gap: ROW_GAP },
1863
2098
  tab: {
1864
2099
  borderBottomWidth: INDICATOR_WIDTH,
@@ -1898,13 +2133,13 @@ var StepperTab = ({
1898
2133
  accessibilityRole: "tab",
1899
2134
  accessibilityState: { selected },
1900
2135
  "aria-selected": selected,
1901
- style: [styles10.tab, { borderBottomColor: indicator }, ring.ringStyle],
2136
+ style: [styles11.tab, { borderBottomColor: indicator }, ring.ringStyle],
1902
2137
  testID,
1903
2138
  onBlur: ring.onBlur,
1904
2139
  onFocus: ring.onFocus,
1905
2140
  onPress: () => onStep(step.key),
1906
2141
  ...webKeyProps,
1907
- children: /* @__PURE__ */ jsx(Text, { style: [styles10.text, { color: theme.colors.text }, selected ? styles10.textSelected : void 0], children: step.label })
2142
+ children: /* @__PURE__ */ jsx(Text, { style: [styles11.text, { color: theme.colors.text }, selected ? styles11.textSelected : void 0], children: step.label })
1908
2143
  }
1909
2144
  );
1910
2145
  };
@@ -1914,7 +2149,7 @@ var StepperTabs = ({
1914
2149
  onStep,
1915
2150
  stepHint,
1916
2151
  testID
1917
- }) => /* @__PURE__ */ jsx(View, { accessibilityRole: "tablist", style: styles10.row, testID, children: steps.map((step) => /* @__PURE__ */ jsx(
2152
+ }) => /* @__PURE__ */ jsx(View, { accessibilityRole: "tablist", style: styles11.row, testID, children: steps.map((step) => /* @__PURE__ */ jsx(
1918
2153
  StepperTab,
1919
2154
  {
1920
2155
  hint: stepHint,
@@ -1933,7 +2168,7 @@ function resolveStepNeighbours(steps, current) {
1933
2168
  return { previous: steps[index - 1] ?? null, next: steps[index + 1] ?? null };
1934
2169
  }
1935
2170
  var ROW_GAP2 = 8;
1936
- var styles11 = StyleSheet.create({
2171
+ var styles12 = StyleSheet.create({
1937
2172
  cell: { flex: 1, minWidth: 0 },
1938
2173
  row: { flexDirection: "row", gap: ROW_GAP2 }
1939
2174
  });
@@ -1945,8 +2180,8 @@ var StepperNav = ({
1945
2180
  testID
1946
2181
  }) => {
1947
2182
  const { previous, next } = resolveStepNeighbours(steps, current);
1948
- return /* @__PURE__ */ jsxs(View, { style: styles11.row, testID, children: [
1949
- /* @__PURE__ */ jsx(View, { style: styles11.cell, children: /* @__PURE__ */ jsx(
2183
+ return /* @__PURE__ */ jsxs(View, { style: styles12.row, testID, children: [
2184
+ /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
1950
2185
  Button,
1951
2186
  {
1952
2187
  fullWidth: true,
@@ -1962,7 +2197,7 @@ var StepperNav = ({
1962
2197
  }
1963
2198
  }
1964
2199
  ) }),
1965
- next === null ? null : /* @__PURE__ */ jsx(View, { style: styles11.cell, children: /* @__PURE__ */ jsx(
2200
+ next === null ? null : /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
1966
2201
  Button,
1967
2202
  {
1968
2203
  fullWidth: true,
@@ -1976,12 +2211,29 @@ var StepperNav = ({
1976
2211
  ) })
1977
2212
  ] });
1978
2213
  };
2214
+ var STATUS_FONT = 16;
2215
+ var styles13 = StyleSheet.create({
2216
+ line: { fontSize: STATUS_FONT }
2217
+ });
2218
+ function FormStatusLine({ message, testID, style }) {
2219
+ const { theme } = useUi();
2220
+ if (message === null || message === void 0 || message === "") return null;
2221
+ return /* @__PURE__ */ jsx(Text, { role: "alert", style: [styles13.line, { color: theme.colors.text }, style], testID, children: message });
2222
+ }
2223
+ function useFocusFirstInvalid(refs) {
2224
+ const latest = useRef(refs);
2225
+ latest.current = refs;
2226
+ return useCallback((invalid) => {
2227
+ const first = invalid.map((field) => latest.current[field].current).find((node) => node !== null);
2228
+ first?.focus();
2229
+ }, []);
2230
+ }
1979
2231
  var DEFAULT_PREVIEW_PX = 48;
1980
2232
  var PREVIEW_RADIUS = 8;
1981
2233
  var GAP = 8;
1982
2234
  var TITLE_FONT_SIZE = 14;
1983
2235
  var HELP_FONT_SIZE = 13;
1984
- var styles12 = StyleSheet.create({
2236
+ var styles14 = StyleSheet.create({
1985
2237
  help: { flex: 1, fontSize: HELP_FONT_SIZE },
1986
2238
  root: { gap: GAP },
1987
2239
  tile: { alignItems: "center", flexDirection: "row", gap: GAP },
@@ -2003,11 +2255,11 @@ var UploadWithLinkField = ({
2003
2255
  const url = value ?? "";
2004
2256
  const [pasting, setPasting] = useState(url !== "");
2005
2257
  const previewBox = { borderRadius: PREVIEW_RADIUS, height: previewSize, width: previewSize };
2006
- return /* @__PURE__ */ jsxs(View, { style: styles12.root, children: [
2007
- /* @__PURE__ */ jsx(Text, { style: [styles12.title, { color: theme.colors.text }], children: labels.title }),
2008
- /* @__PURE__ */ jsxs(View, { style: styles12.tile, children: [
2258
+ return /* @__PURE__ */ jsxs(View, { style: styles14.root, children: [
2259
+ /* @__PURE__ */ jsx(Text, { style: [styles14.title, { color: theme.colors.text }], children: labels.title }),
2260
+ /* @__PURE__ */ jsxs(View, { style: styles14.tile, children: [
2009
2261
  url === "" ? /* @__PURE__ */ jsx(View, { style: [previewBox, { backgroundColor: theme.colors.border }] }) : /* @__PURE__ */ jsx(Image, { accessible: false, source: { uri: url }, style: previewBox, testID: `${testID}-preview` }),
2010
- labels.help === void 0 ? null : /* @__PURE__ */ jsx(Text, { style: [styles12.help, { color: theme.colors.text }], children: labels.help })
2262
+ labels.help === void 0 ? null : /* @__PURE__ */ jsx(Text, { style: [styles14.help, { color: theme.colors.text }], children: labels.help })
2011
2263
  ] }),
2012
2264
  uploadControl,
2013
2265
  /* @__PURE__ */ jsx(
@@ -2018,12 +2270,12 @@ var UploadWithLinkField = ({
2018
2270
  accessibilityRole: "button",
2019
2271
  accessibilityState: { expanded: pasting },
2020
2272
  "aria-expanded": pasting,
2021
- style: [styles12.toggle, ring.ringStyle],
2273
+ style: [styles14.toggle, ring.ringStyle],
2022
2274
  testID: `${testID}-paste`,
2023
2275
  onBlur: ring.onBlur,
2024
2276
  onFocus: ring.onFocus,
2025
2277
  onPress: () => setPasting(!pasting),
2026
- children: /* @__PURE__ */ jsx(Text, { style: [styles12.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2278
+ children: /* @__PURE__ */ jsx(Text, { style: [styles14.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2027
2279
  }
2028
2280
  ),
2029
2281
  pasting ? /* @__PURE__ */ jsx(
@@ -2041,6 +2293,6 @@ var UploadWithLinkField = ({
2041
2293
  ] });
2042
2294
  };
2043
2295
 
2044
- export { AlignToControl, AnchoredMenu, ChipSelector, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, Field, FormActions, FormCell, FormCheckbox, FormField, FormGrid, FormSection, FormSwitch, INLINE_FIELD_ROW_MIN_WIDTH, InlineFieldRow, SelectControl, StepperNav, StepperTabs, ThemedTextInput, TypeaheadControl, UploadWithLinkField, controlStyles, groupAriaChecked, inlineFieldRowLayout, isUnmatched, resolveGroupSelection, resolveStepNeighbours, suggestOptions, toggleGroupSelection, useRecentValues, useThemedInput };
2296
+ export { AlignToControl, AnchoredMenu, ChipSelector, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, Field, FormActions, FormCell, FormCheckbox, FormField, FormGrid, FormSection, FormStatusLine, FormSwitch, INLINE_FIELD_ROW_MIN_WIDTH, InlineFieldRow, SelectControl, StepperNav, StepperTabs, ThemedTextInput, TypeaheadControl, UploadWithLinkField, controlStyles, groupAriaChecked, inlineFieldRowLayout, isUnmatched, resolveGroupSelection, resolveStepNeighbours, suggestOptions, toggleGroupSelection, useFocusFirstInvalid, useRecentValues, useThemedInput };
2045
2297
  //# sourceMappingURL=index.mjs.map
2046
2298
  //# sourceMappingURL=index.mjs.map