@dloizides/ui-forms 1.22.0 → 1.23.1

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,59 @@ 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 rowInk(colors, isActive, isHighlighted) {
575
+ if (isHighlighted) return { title: colors.surface, detail: colors.surface };
576
+ return { title: isActive ? colors.brand : colors.text, detail: colors.textSecondary ?? colors.text };
577
+ }
578
+ function OptionRow({ option, isActive, isHighlighted, isListboxRow, menuWidth, hint, colors, testID, onSelect }) {
579
+ const hasDetail = option.detail !== void 0 && option.detail !== "";
580
+ const label = hasDetail ? `${option.label}, ${option.detail}` : option.label;
581
+ const roleProps = isListboxRow ? { role: "option", tabIndex: -1, id: optionDomId(testID, option.value), "aria-selected": isHighlighted } : { accessibilityRole: "menuitem", "aria-selected": isActive };
582
+ const isBold = isActive || isHighlighted;
583
+ const ink = rowInk(colors, isActive, isHighlighted);
584
+ return /* @__PURE__ */ jsxRuntime.jsx(
585
+ reactNative.Pressable,
586
+ {
587
+ ...roleProps,
588
+ accessibilityLabel: label,
589
+ accessibilityHint: hint,
590
+ accessibilityState: { selected: isActive },
591
+ onPress: () => onSelect(option.value),
592
+ style: [controlStyles.option, isHighlighted ? { backgroundColor: colors.text } : null],
593
+ testID: `${testID}-option-${option.value}`,
594
+ children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: isDetailBesideTitle(menuWidth) ? styles7.beside : styles7.stacked, children: [
595
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, styles7.shrink, { color: ink.title, fontWeight: isBold ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: option.label }),
596
+ hasDetail ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.detail, { color: ink.detail }], children: option.detail }) : null
597
+ ] })
598
+ }
599
+ );
600
+ }
553
601
  var IS_WEB3 = reactNative.Platform.OS === "web";
554
602
  var MENU_PORTAL_Z_INDEX = designTokens.Z_INDEX.popover;
555
603
  var MENU_TOP_GAP2 = 4;
604
+ var MENU_MIN_HEIGHT = 96;
605
+ var VIEWPORT_MARGIN = 8;
556
606
  function toDomNode(ref) {
557
607
  const node = ref.current;
558
608
  if (node === null || typeof node.getBoundingClientRect !== "function") return null;
@@ -565,8 +615,9 @@ function readAnchorRect(ref) {
565
615
  const r = node.getBoundingClientRect();
566
616
  return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
567
617
  }
568
- function buildPortalPopoverStyle(rect) {
618
+ function buildPortalPopoverStyle(rect, viewportBottom) {
569
619
  return {
620
+ ...viewportBottom !== void 0 ? { maxHeight: menuMaxHeight(rect.bottom, viewportBottom) } : null,
570
621
  // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
571
622
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
572
623
  position: "fixed",
@@ -613,10 +664,64 @@ function useAnchorRect(ref, enabled) {
613
664
  }, [ref, enabled]);
614
665
  return IS_WEB3 && enabled ? rect : null;
615
666
  }
667
+ function roomUnderAnchor(anchorBottom, viewportBottom) {
668
+ return viewportBottom - anchorBottom - MENU_TOP_GAP2 - VIEWPORT_MARGIN;
669
+ }
670
+ function menuMaxHeight(anchorBottom, viewportBottom) {
671
+ return Math.min(MENU_MAX_HEIGHT, Math.max(MENU_MIN_HEIGHT, roomUnderAnchor(anchorBottom, viewportBottom)));
672
+ }
673
+ function anchorScrollBlock(anchorBottom, viewportBottom) {
674
+ return roomUnderAnchor(anchorBottom, viewportBottom) < MENU_MAX_HEIGHT ? "start" : "nearest";
675
+ }
676
+ function readViewportBottom() {
677
+ const viewport = window.visualViewport ?? null;
678
+ return viewport === null ? window.innerHeight : viewport.offsetTop + viewport.height;
679
+ }
680
+ function useVisualViewportBottom(enabled) {
681
+ const [bottom, setBottom] = React2.useState(null);
682
+ React2.useLayoutEffect(() => {
683
+ if (!IS_WEB3 || !enabled) {
684
+ setBottom(null);
685
+ return void 0;
686
+ }
687
+ const update = () => setBottom(readViewportBottom());
688
+ update();
689
+ const viewport = window.visualViewport ?? null;
690
+ viewport?.addEventListener("resize", update);
691
+ viewport?.addEventListener("scroll", update);
692
+ window.addEventListener("resize", update);
693
+ return () => {
694
+ viewport?.removeEventListener("resize", update);
695
+ viewport?.removeEventListener("scroll", update);
696
+ window.removeEventListener("resize", update);
697
+ };
698
+ }, [enabled]);
699
+ return bottom;
700
+ }
701
+ function scrollNodeIntoView(node, block) {
702
+ if (node !== null && typeof node.scrollIntoView === "function") node.scrollIntoView({ block });
703
+ }
704
+ function scrollAnchorIntoView(ref, marginTop) {
705
+ if (!IS_WEB3) return;
706
+ const node = toDomNode(ref);
707
+ if (node === null) return;
708
+ node.style.scrollMarginTop = `${marginTop}px`;
709
+ scrollNodeIntoView(node, anchorScrollBlock(node.getBoundingClientRect().bottom, readViewportBottom()));
710
+ }
711
+ function useKeepAnchorInRoom(ref, marginTop, enabled) {
712
+ React2.useLayoutEffect(() => {
713
+ if (!IS_WEB3 || !enabled) return void 0;
714
+ const place = () => scrollAnchorIntoView(ref, marginTop);
715
+ place();
716
+ const viewport = window.visualViewport ?? null;
717
+ viewport?.addEventListener("resize", place);
718
+ return () => viewport?.removeEventListener("resize", place);
719
+ }, [ref, marginTop, enabled]);
720
+ }
616
721
  var IS_WEB4 = typeof document !== "undefined";
617
722
  var ESCAPE_KEY = "Escape";
618
- var ACTIVE_WEIGHT = "700";
619
- var IDLE_WEIGHT = "400";
723
+ var LISTBOX_ROLE = { role: "listbox" };
724
+ var KEEP_FIELD_FOCUS = { onMouseDown: (event) => event.preventDefault() };
620
725
  function useDismiss(anchorRef, menuRef, onDismiss) {
621
726
  React2.useEffect(() => {
622
727
  if (!IS_WEB4) return void 0;
@@ -639,6 +744,12 @@ function useDismiss(anchorRef, menuRef, onDismiss) {
639
744
  };
640
745
  }, [anchorRef, menuRef, onDismiss]);
641
746
  }
747
+ function useKeepHighlightInView(testID, highlightedValue) {
748
+ React2.useEffect(() => {
749
+ if (!IS_WEB4 || highlightedValue === void 0 || highlightedValue === "") return;
750
+ scrollNodeIntoView(document.getElementById(optionDomId(testID, highlightedValue)), "nearest");
751
+ }, [testID, highlightedValue]);
752
+ }
642
753
  function AnchoredMenu({
643
754
  options,
644
755
  selectedValue,
@@ -649,38 +760,48 @@ function AnchoredMenu({
649
760
  accessibilityLabel,
650
761
  testID,
651
762
  anchorRef,
652
- usePortal = true
763
+ usePortal = true,
764
+ listbox
653
765
  }) {
654
766
  const menuRef = React2__default.default.useRef(null);
655
767
  useDismiss(anchorRef, menuRef, onDismiss);
768
+ useKeepHighlightInView(testID, listbox?.highlightedValue);
656
769
  const rect = useAnchorRect(anchorRef, usePortal);
657
- const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
658
- const menu = /* @__PURE__ */ jsxRuntime.jsx(
770
+ const viewportBottom = useVisualViewportBottom(usePortal);
771
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect, viewportBottom ?? void 0) : null;
772
+ const { width: windowWidth } = reactNative.useWindowDimensions();
773
+ const menuWidth = rect?.width ?? windowWidth;
774
+ const isListbox = listbox !== void 0;
775
+ const statusText = listbox?.statusText;
776
+ const rows = options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
777
+ OptionRow,
778
+ {
779
+ colors,
780
+ hint: optionHint,
781
+ isActive: option.value === selectedValue,
782
+ isHighlighted: option.value === listbox?.highlightedValue,
783
+ isListboxRow: isListbox,
784
+ menuWidth,
785
+ option,
786
+ testID,
787
+ onSelect
788
+ },
789
+ option.value
790
+ ));
791
+ const listboxBody = options.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { accessibilityLabel, id: listboxDomId(testID), ...LISTBOX_ROLE, testID: listboxDomId(testID), children: rows }) : null;
792
+ const menu = /* @__PURE__ */ jsxRuntime.jsxs(
659
793
  reactNative.View,
660
794
  {
795
+ ...isListbox ? KEEP_FIELD_FOCUS : null,
661
796
  ref: menuRef,
662
- accessibilityLabel,
663
- accessibilityRole: "menu",
797
+ accessibilityLabel: isListbox ? void 0 : accessibilityLabel,
798
+ accessibilityRole: isListbox ? void 0 : "menu",
664
799
  style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
665
800
  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
- }) })
801
+ children: [
802
+ isListbox ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "status", style: statusText !== void 0 ? [controlStyles.option, controlStyles.optionText, { color: colors.text }] : void 0, children: statusText ?? "" }) : null,
803
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: isListbox ? listboxBody : rows })
804
+ ]
684
805
  }
685
806
  );
686
807
  if (usePortal && IS_WEB4) return reactDom.createPortal(menu, document.body);
@@ -1255,7 +1376,7 @@ function chipIdentity(option, selected, config) {
1255
1376
  ariaChecked: isRadio ? selected : void 0
1256
1377
  };
1257
1378
  }
1258
- var styles7 = reactNative.StyleSheet.create({
1379
+ var styles8 = reactNative.StyleSheet.create({
1259
1380
  chipContainer: {
1260
1381
  flexDirection: "row",
1261
1382
  flexWrap: "wrap",
@@ -1341,7 +1462,7 @@ var ChipSelector = ({
1341
1462
  reactNative.View,
1342
1463
  {
1343
1464
  accessibilityRole: isRadioGroup ? "radiogroup" : void 0,
1344
- style: styles7.chipContainer,
1465
+ style: styles8.chipContainer,
1345
1466
  ...webFieldA11y({ describedById, hasError }),
1346
1467
  ...radio.groupProps,
1347
1468
  children: options.map(renderChip)
@@ -1351,7 +1472,7 @@ var ChipSelector = ({
1351
1472
  );
1352
1473
  };
1353
1474
  var DESCRIPTION_FONT_SIZE2 = 12;
1354
- var styles8 = reactNative.StyleSheet.create({
1475
+ var styles9 = reactNative.StyleSheet.create({
1355
1476
  container: {
1356
1477
  flexDirection: "row",
1357
1478
  justifyContent: "space-between",
@@ -1412,15 +1533,15 @@ var FormSwitch = ({
1412
1533
  accessible: false,
1413
1534
  disabled,
1414
1535
  onPress: toggle,
1415
- style: [styles8.container, containerStyle],
1536
+ style: [styles9.container, containerStyle],
1416
1537
  tabIndex: -1,
1417
1538
  testID: `${testID}-row`,
1418
1539
  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
1540
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles9.textColumn, children: [
1541
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.label, themeStyles.label], children: label }),
1542
+ hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles9.description, themeStyles.description], children: description }) : null
1422
1543
  ] }),
1423
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: styles8.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsxRuntime.jsx(
1544
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: styles9.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsxRuntime.jsx(
1424
1545
  reactNative.Switch,
1425
1546
  {
1426
1547
  accessibilityHint: accessibilityHint ?? label,
@@ -1449,7 +1570,7 @@ var DISABLED_OPACITY = 0.5;
1449
1570
  var FOCUS_RING_WIDTH = 2;
1450
1571
  var WEB_NO_DEFAULT_OUTLINE = { outlineStyle: "none" };
1451
1572
  var FOCUS_RING_INSET = -6;
1452
- var styles9 = reactNative.StyleSheet.create({
1573
+ var styles10 = reactNative.StyleSheet.create({
1453
1574
  container: {
1454
1575
  flexDirection: "row",
1455
1576
  alignItems: "flex-start",
@@ -1554,8 +1675,8 @@ var FormCheckbox = ({
1554
1675
  onPress: toggle,
1555
1676
  role: "checkbox",
1556
1677
  style: [
1557
- styles9.container,
1558
- hasHint ? null : styles9.centered,
1678
+ styles10.container,
1679
+ hasHint ? null : styles10.centered,
1559
1680
  containerStyle,
1560
1681
  disabled ? { opacity: DISABLED_OPACITY } : null,
1561
1682
  reactNative.Platform.OS === "web" ? WEB_NO_DEFAULT_OUTLINE : null
@@ -1563,13 +1684,13 @@ var FormCheckbox = ({
1563
1684
  testID,
1564
1685
  ...keyboardProps,
1565
1686
  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
1687
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles10.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1688
+ focused ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: [styles10.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1689
+ value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles10.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1569
1690
  ] }),
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
1691
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles10.textColumn, children: [
1692
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles10.label, themeStyles.label], children: label }),
1693
+ hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: hintId, style: [styles10.hint, themeStyles.hint], children: hintText }) : null
1573
1694
  ] })
1574
1695
  ]
1575
1696
  }
@@ -1644,6 +1765,24 @@ function SelectControl({
1644
1765
  ) : null
1645
1766
  ] });
1646
1767
  }
1768
+ var CLOSE_DELAY_MS2 = 140;
1769
+ function useDelayedBlurClose(open, close) {
1770
+ const timerRef = React2.useRef(null);
1771
+ const cancelClose = React2.useCallback(() => {
1772
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
1773
+ timerRef.current = null;
1774
+ }, []);
1775
+ React2.useEffect(() => cancelClose, [cancelClose]);
1776
+ const handleFocus = React2.useCallback(() => {
1777
+ cancelClose();
1778
+ open();
1779
+ }, [cancelClose, open]);
1780
+ const handleBlur = React2.useCallback(() => {
1781
+ cancelClose();
1782
+ timerRef.current = setTimeout(close, CLOSE_DELAY_MS2);
1783
+ }, [cancelClose, close]);
1784
+ return { handleFocus, handleBlur };
1785
+ }
1647
1786
 
1648
1787
  // src/controls/suggestOptions.ts
1649
1788
  var RANK_VALUE_EXACT = 0;
@@ -1676,94 +1815,212 @@ function isUnmatched(options, text) {
1676
1815
  if (needle === "") return false;
1677
1816
  return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1678
1817
  }
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);
1818
+ var DEFAULT_TYPEAHEAD_DEBOUNCE_MS = 250;
1819
+ var IDLE = { results: [], isLoading: false, hasFailed: false, hasSettled: false };
1820
+ function useTypeaheadSearch(load, query, enabled, debounceMs) {
1821
+ const [state, setState] = React2.useState(IDLE);
1822
+ const loadRef = React2.useRef(load);
1823
+ loadRef.current = load;
1824
+ React2.useEffect(() => {
1825
+ if (!enabled) {
1826
+ setState(IDLE);
1827
+ return void 0;
1828
+ }
1829
+ setState((current) => ({ ...current, isLoading: true, hasFailed: false }));
1830
+ const controller = new AbortController();
1831
+ const settle = (next) => {
1832
+ if (!controller.signal.aborted) setState(next);
1833
+ };
1834
+ const timer = setTimeout(() => {
1835
+ Promise.resolve().then(() => loadRef.current?.(query, controller.signal) ?? []).then(
1836
+ (results) => settle({ results, isLoading: false, hasFailed: false, hasSettled: true }),
1837
+ () => settle({ results: [], isLoading: false, hasFailed: true, hasSettled: true })
1838
+ );
1839
+ }, debounceMs);
1840
+ return () => {
1841
+ clearTimeout(timer);
1842
+ controller.abort();
1843
+ };
1844
+ }, [query, enabled, debounceMs]);
1845
+ return state;
1846
+ }
1847
+
1848
+ // src/TypeaheadControl/hooks/useTypeaheadState.ts
1849
+ var NO_HIGHLIGHT = -1;
1850
+ var FORWARD = 1;
1851
+ var BACKWARD = -1;
1852
+ var NO_ROWS = [];
1853
+ function nextHighlight(current, count, delta) {
1854
+ if (count === 0) return NO_HIGHLIGHT;
1855
+ if (current === NO_HIGHLIGHT) return delta > 0 ? 0 : count - 1;
1856
+ return (current + delta + count) % count;
1857
+ }
1858
+ function searchStatusText(found, search) {
1859
+ if (found.hasFailed) return search.errorText;
1860
+ if (found.results.length > 0) return void 0;
1861
+ if (found.isLoading) return search.loadingText;
1862
+ return found.hasSettled ? search.emptyText : void 0;
1863
+ }
1864
+ function useTypeaheadRows(input, isOpen, isPicked) {
1865
+ const { options, search, value, minChars, maxSuggestions } = input;
1866
+ const query = value.trim();
1867
+ const isSearching = search !== void 0 && isOpen && !isPicked && query.length >= minChars;
1868
+ const found = useTypeaheadSearch(search?.load, query, isSearching, search?.debounceMs ?? DEFAULT_TYPEAHEAD_DEBOUNCE_MS);
1869
+ const staticRows = React2.useMemo(() => {
1870
+ if (options === void 0 || options.some((o) => o.label === value)) return NO_ROWS;
1871
+ return suggestOptions(options, value, minChars, maxSuggestions);
1872
+ }, [options, value, minChars, maxSuggestions]);
1873
+ const foundRows = React2.useMemo(() => found.results.slice(0, maxSuggestions), [found.results, maxSuggestions]);
1874
+ if (search === void 0) return { rows: staticRows, statusText: void 0 };
1875
+ return { rows: foundRows, statusText: searchStatusText(found, search) };
1876
+ }
1877
+ function useTypeaheadState(input) {
1878
+ const { search, value, onChange, onSubmit } = input;
1700
1879
  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]
1880
+ const [picked, setPicked] = React2.useState(null);
1881
+ const [highlightIndex, setHighlightIndex] = React2.useState(NO_HIGHLIGHT);
1882
+ const { rows, statusText } = useTypeaheadRows(input, isOpen, picked !== null && picked.label === value);
1883
+ const rowKey = rows.map((row) => row.value).join("\n");
1884
+ const highlighted = isOpen ? rows[highlightIndex] : void 0;
1885
+ React2.useEffect(() => setHighlightIndex(NO_HIGHLIGHT), [rowKey]);
1886
+ React2.useEffect(() => setPicked((current) => current !== null && current.label !== value ? null : current), [value]);
1887
+ const open = React2.useCallback(() => setIsOpen(true), []);
1888
+ const close = React2.useCallback(() => {
1889
+ setIsOpen(false);
1890
+ setHighlightIndex(NO_HIGHLIGHT);
1891
+ }, []);
1892
+ const pick = React2.useCallback(
1893
+ (option) => {
1894
+ setPicked(option);
1895
+ onChange(option.label);
1896
+ search?.onPick(option);
1897
+ close();
1898
+ },
1899
+ [onChange, search, close]
1900
+ );
1901
+ const selectValue = React2.useCallback(
1902
+ (optionValue) => {
1903
+ const option = rows.find((row) => row.value === optionValue);
1904
+ if (option !== void 0) pick(option);
1905
+ },
1906
+ [rows, pick]
1705
1907
  );
1706
- const showMenu = isOpen && suggestions.length > 0;
1707
1908
  const handleChange = React2.useCallback(
1708
1909
  (next) => {
1910
+ if (picked !== null) {
1911
+ setPicked(null);
1912
+ search?.onPick(null);
1913
+ }
1709
1914
  onChange(next);
1710
1915
  setIsOpen(true);
1916
+ setHighlightIndex(NO_HIGHLIGHT);
1711
1917
  },
1712
- [onChange]
1713
- );
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]
1918
+ [picked, search, onChange]
1721
1919
  );
1722
- const handleBlur = React2.useCallback(() => {
1723
- setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS2);
1724
- }, []);
1920
+ const handleKeyPress = useTypeaheadKeys({ rowCount: rows.length, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick });
1725
1921
  const handleSubmit = React2.useCallback(() => {
1726
- setIsOpen(false);
1922
+ if (highlighted !== void 0) {
1923
+ pick(highlighted);
1924
+ return;
1925
+ }
1926
+ close();
1727
1927
  onSubmit?.();
1728
- }, [onSubmit]);
1928
+ }, [highlighted, pick, close, onSubmit]);
1929
+ return { rows, statusText, isOpen, picked, highlighted, open, close, selectValue, handleChange, handleKeyPress, handleSubmit };
1930
+ }
1931
+ function useTypeaheadKeys(input) {
1932
+ const { rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick } = input;
1933
+ return React2.useCallback(
1934
+ (event) => {
1935
+ const { key } = event.nativeEvent;
1936
+ if (key === "ArrowDown" || key === "ArrowUp") {
1937
+ event.preventDefault();
1938
+ setIsOpen(true);
1939
+ setHighlightIndex((current) => nextHighlight(current, rowCount, key === "ArrowDown" ? FORWARD : BACKWARD));
1940
+ } else if (key === "Escape" && isOpen) {
1941
+ event.preventDefault();
1942
+ close();
1943
+ } else if (key === "Enter" && highlighted !== void 0) {
1944
+ event.preventDefault();
1945
+ pick(highlighted);
1946
+ }
1947
+ },
1948
+ [rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick]
1949
+ );
1950
+ }
1951
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1952
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1953
+ function comboboxAria(testID, isExpanded, highlighted) {
1954
+ return {
1955
+ role: "combobox",
1956
+ "aria-autocomplete": "list",
1957
+ "aria-expanded": isExpanded,
1958
+ "aria-controls": isExpanded ? listboxDomId(testID) : void 0,
1959
+ "aria-activedescendant": highlighted !== void 0 ? optionDomId(testID, highlighted.value) : void 0
1960
+ };
1961
+ }
1962
+ function TypeaheadControlImpl(props, ref) {
1963
+ const { value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint = "", error, minChars = DEFAULT_TYPEAHEAD_MIN_CHARS, style, testID } = props;
1964
+ const { id, describedById } = props;
1965
+ const search = "search" in props ? props.search : void 0;
1966
+ const options = "options" in props ? props.options : void 0;
1967
+ const maxSuggestions = ("search" in props ? props.search.maxResults : props.maxSuggestions) ?? DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1968
+ const { theme } = uiFeedback.useUi();
1969
+ const { colors, palette } = theme;
1970
+ const anchorRef = React2.useRef(null);
1971
+ const state = useTypeaheadState({ options, search, value, minChars, maxSuggestions, onChange, onSubmit });
1972
+ const { handleFocus, handleBlur } = useDelayedBlurClose(state.open, state.close);
1973
+ const showMenu = state.isOpen && (state.rows.length > 0 || state.statusText !== void 0);
1974
+ const isExpanded = state.isOpen && state.rows.length > 0;
1729
1975
  const hasError = error !== void 0 && error !== "";
1976
+ useKeepAnchorInRoom(anchorRef, search?.scrollMarginTop ?? 0, search !== void 0 && state.isOpen);
1730
1977
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1731
1978
  /* @__PURE__ */ jsxRuntime.jsx(
1732
1979
  reactNative.TextInput,
1733
1980
  {
1981
+ ref,
1982
+ id,
1983
+ ...comboboxAria(testID, isExpanded, state.highlighted),
1984
+ ...webFieldA11y({ describedById }),
1734
1985
  accessibilityLabel,
1735
1986
  accessibilityHint,
1736
1987
  autoCapitalize: "none",
1988
+ autoComplete: "off",
1737
1989
  autoCorrect: false,
1990
+ blurOnSubmit: search === void 0,
1738
1991
  placeholder,
1739
1992
  placeholderTextColor: colors.textSecondary,
1740
1993
  spellCheck: false,
1741
1994
  value,
1742
- onChangeText: handleChange,
1743
- onFocus: () => setIsOpen(true),
1995
+ onChangeText: state.handleChange,
1996
+ onFocus: handleFocus,
1744
1997
  onBlur: handleBlur,
1745
- onSubmitEditing: handleSubmit,
1998
+ onKeyPress: state.handleKeyPress,
1999
+ onSubmitEditing: state.handleSubmit,
1746
2000
  style: [controlStyles.input, { borderColor: hasError ? fieldErrorInk(theme) : colors.border, backgroundColor: colors.surface, color: colors.text }],
1747
- testID: `${testID}-input`
2001
+ testID: search?.inputTestID ?? `${testID}-input`
1748
2002
  }
1749
2003
  ),
1750
2004
  showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
1751
2005
  AnchoredMenu,
1752
2006
  {
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"] },
2007
+ accessibilityLabel,
2008
+ options: state.rows,
2009
+ selectedValue: search !== void 0 ? state.picked?.value ?? "" : value,
2010
+ onSelect: state.selectValue,
2011
+ onDismiss: state.close,
2012
+ colors: { text: colors.text, textSecondary: colors.textSecondary, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1758
2013
  optionHint,
1759
2014
  testID,
1760
2015
  anchorRef,
2016
+ listbox: { highlightedValue: state.highlighted?.value ?? "", statusText: state.statusText },
1761
2017
  usePortal: true
1762
2018
  }
1763
2019
  ) : null,
1764
2020
  hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [controlStyles.error, { color: fieldErrorInk(theme) }], testID: `${testID}-error`, children: error }) : null
1765
2021
  ] });
1766
2022
  }
2023
+ var TypeaheadControl = React2.forwardRef(TypeaheadControlImpl);
1767
2024
  var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1768
2025
  function DateInput(p) {
1769
2026
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: controlStyles.dateCol, children: [
@@ -1864,7 +2121,7 @@ var ROW_GAP = 4;
1864
2121
  var TAB_PADDING = 4;
1865
2122
  var INDICATOR_WIDTH = 2;
1866
2123
  var TAB_FONT_SIZE = 12;
1867
- var styles10 = reactNative.StyleSheet.create({
2124
+ var styles11 = reactNative.StyleSheet.create({
1868
2125
  row: { flexDirection: "row", flexWrap: "nowrap", gap: ROW_GAP },
1869
2126
  tab: {
1870
2127
  borderBottomWidth: INDICATOR_WIDTH,
@@ -1904,13 +2161,13 @@ var StepperTab = ({
1904
2161
  accessibilityRole: "tab",
1905
2162
  accessibilityState: { selected },
1906
2163
  "aria-selected": selected,
1907
- style: [styles10.tab, { borderBottomColor: indicator }, ring.ringStyle],
2164
+ style: [styles11.tab, { borderBottomColor: indicator }, ring.ringStyle],
1908
2165
  testID,
1909
2166
  onBlur: ring.onBlur,
1910
2167
  onFocus: ring.onFocus,
1911
2168
  onPress: () => onStep(step.key),
1912
2169
  ...webKeyProps,
1913
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles10.text, { color: theme.colors.text }, selected ? styles10.textSelected : void 0], children: step.label })
2170
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles11.text, { color: theme.colors.text }, selected ? styles11.textSelected : void 0], children: step.label })
1914
2171
  }
1915
2172
  );
1916
2173
  };
@@ -1920,7 +2177,7 @@ var StepperTabs = ({
1920
2177
  onStep,
1921
2178
  stepHint,
1922
2179
  testID
1923
- }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { accessibilityRole: "tablist", style: styles10.row, testID, children: steps.map((step) => /* @__PURE__ */ jsxRuntime.jsx(
2180
+ }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { accessibilityRole: "tablist", style: styles11.row, testID, children: steps.map((step) => /* @__PURE__ */ jsxRuntime.jsx(
1924
2181
  StepperTab,
1925
2182
  {
1926
2183
  hint: stepHint,
@@ -1939,7 +2196,7 @@ function resolveStepNeighbours(steps, current) {
1939
2196
  return { previous: steps[index - 1] ?? null, next: steps[index + 1] ?? null };
1940
2197
  }
1941
2198
  var ROW_GAP2 = 8;
1942
- var styles11 = reactNative.StyleSheet.create({
2199
+ var styles12 = reactNative.StyleSheet.create({
1943
2200
  cell: { flex: 1, minWidth: 0 },
1944
2201
  row: { flexDirection: "row", gap: ROW_GAP2 }
1945
2202
  });
@@ -1951,8 +2208,8 @@ var StepperNav = ({
1951
2208
  testID
1952
2209
  }) => {
1953
2210
  const { previous, next } = resolveStepNeighbours(steps, current);
1954
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles11.row, testID, children: [
1955
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles11.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
2211
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles12.row, testID, children: [
2212
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles12.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
1956
2213
  uiButtons.Button,
1957
2214
  {
1958
2215
  fullWidth: true,
@@ -1968,7 +2225,7 @@ var StepperNav = ({
1968
2225
  }
1969
2226
  }
1970
2227
  ) }),
1971
- next === null ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles11.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
2228
+ next === null ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles12.cell, children: /* @__PURE__ */ jsxRuntime.jsx(
1972
2229
  uiButtons.Button,
1973
2230
  {
1974
2231
  fullWidth: true,
@@ -1982,12 +2239,29 @@ var StepperNav = ({
1982
2239
  ) })
1983
2240
  ] });
1984
2241
  };
2242
+ var STATUS_FONT = 16;
2243
+ var styles13 = reactNative.StyleSheet.create({
2244
+ line: { fontSize: STATUS_FONT }
2245
+ });
2246
+ function FormStatusLine({ message, testID, style }) {
2247
+ const { theme } = uiFeedback.useUi();
2248
+ if (message === null || message === void 0 || message === "") return null;
2249
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [styles13.line, { color: theme.colors.text }, style], testID, children: message });
2250
+ }
2251
+ function useFocusFirstInvalid(refs) {
2252
+ const latest = React2.useRef(refs);
2253
+ latest.current = refs;
2254
+ return React2.useCallback((invalid) => {
2255
+ const first = invalid.map((field) => latest.current[field].current).find((node) => node !== null);
2256
+ first?.focus();
2257
+ }, []);
2258
+ }
1985
2259
  var DEFAULT_PREVIEW_PX = 48;
1986
2260
  var PREVIEW_RADIUS = 8;
1987
2261
  var GAP = 8;
1988
2262
  var TITLE_FONT_SIZE = 14;
1989
2263
  var HELP_FONT_SIZE = 13;
1990
- var styles12 = reactNative.StyleSheet.create({
2264
+ var styles14 = reactNative.StyleSheet.create({
1991
2265
  help: { flex: 1, fontSize: HELP_FONT_SIZE },
1992
2266
  root: { gap: GAP },
1993
2267
  tile: { alignItems: "center", flexDirection: "row", gap: GAP },
@@ -2009,11 +2283,11 @@ var UploadWithLinkField = ({
2009
2283
  const url = value ?? "";
2010
2284
  const [pasting, setPasting] = React2.useState(url !== "");
2011
2285
  const previewBox = { borderRadius: PREVIEW_RADIUS, height: previewSize, width: previewSize };
2012
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles12.root, children: [
2013
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles12.title, { color: theme.colors.text }], children: labels.title }),
2014
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles12.tile, children: [
2286
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles14.root, children: [
2287
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.title, { color: theme.colors.text }], children: labels.title }),
2288
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles14.tile, children: [
2015
2289
  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` }),
2016
- labels.help === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles12.help, { color: theme.colors.text }], children: labels.help })
2290
+ labels.help === void 0 ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.help, { color: theme.colors.text }], children: labels.help })
2017
2291
  ] }),
2018
2292
  uploadControl,
2019
2293
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2024,12 +2298,12 @@ var UploadWithLinkField = ({
2024
2298
  accessibilityRole: "button",
2025
2299
  accessibilityState: { expanded: pasting },
2026
2300
  "aria-expanded": pasting,
2027
- style: [styles12.toggle, ring.ringStyle],
2301
+ style: [styles14.toggle, ring.ringStyle],
2028
2302
  testID: `${testID}-paste`,
2029
2303
  onBlur: ring.onBlur,
2030
2304
  onFocus: ring.onFocus,
2031
2305
  onPress: () => setPasting(!pasting),
2032
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles12.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2306
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles14.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2033
2307
  }
2034
2308
  ),
2035
2309
  pasting ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -2062,6 +2336,7 @@ exports.FormCheckbox = FormCheckbox;
2062
2336
  exports.FormField = FormField;
2063
2337
  exports.FormGrid = FormGrid;
2064
2338
  exports.FormSection = FormSection;
2339
+ exports.FormStatusLine = FormStatusLine;
2065
2340
  exports.FormSwitch = FormSwitch;
2066
2341
  exports.INLINE_FIELD_ROW_MIN_WIDTH = INLINE_FIELD_ROW_MIN_WIDTH;
2067
2342
  exports.InlineFieldRow = InlineFieldRow;
@@ -2079,6 +2354,7 @@ exports.resolveGroupSelection = resolveGroupSelection;
2079
2354
  exports.resolveStepNeighbours = resolveStepNeighbours;
2080
2355
  exports.suggestOptions = suggestOptions;
2081
2356
  exports.toggleGroupSelection = toggleGroupSelection;
2357
+ exports.useFocusFirstInvalid = useFocusFirstInvalid;
2082
2358
  exports.useRecentValues = useRecentValues;
2083
2359
  exports.useThemedInput = useThemedInput;
2084
2360
  //# sourceMappingURL=index.js.map