@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.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,59 @@ 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 rowInk(colors, isActive, isHighlighted) {
569
+ if (isHighlighted) return { title: colors.surface, detail: colors.surface };
570
+ return { title: isActive ? colors.brand : colors.text, detail: colors.textSecondary ?? colors.text };
571
+ }
572
+ function OptionRow({ option, isActive, isHighlighted, isListboxRow, menuWidth, hint, colors, testID, onSelect }) {
573
+ const hasDetail = option.detail !== void 0 && option.detail !== "";
574
+ const label = hasDetail ? `${option.label}, ${option.detail}` : option.label;
575
+ const roleProps = isListboxRow ? { role: "option", tabIndex: -1, id: optionDomId(testID, option.value), "aria-selected": isHighlighted } : { accessibilityRole: "menuitem", "aria-selected": isActive };
576
+ const isBold = isActive || isHighlighted;
577
+ const ink = rowInk(colors, isActive, isHighlighted);
578
+ return /* @__PURE__ */ jsx(
579
+ Pressable,
580
+ {
581
+ ...roleProps,
582
+ accessibilityLabel: label,
583
+ accessibilityHint: hint,
584
+ accessibilityState: { selected: isActive },
585
+ onPress: () => onSelect(option.value),
586
+ style: [controlStyles.option, isHighlighted ? { backgroundColor: colors.text } : null],
587
+ testID: `${testID}-option-${option.value}`,
588
+ children: /* @__PURE__ */ jsxs(View, { style: isDetailBesideTitle(menuWidth) ? styles7.beside : styles7.stacked, children: [
589
+ /* @__PURE__ */ jsx(Text, { style: [controlStyles.optionText, styles7.shrink, { color: ink.title, fontWeight: isBold ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: option.label }),
590
+ hasDetail ? /* @__PURE__ */ jsx(Text, { style: [styles7.detail, { color: ink.detail }], children: option.detail }) : null
591
+ ] })
592
+ }
593
+ );
594
+ }
547
595
  var IS_WEB3 = Platform.OS === "web";
548
596
  var MENU_PORTAL_Z_INDEX = Z_INDEX.popover;
549
597
  var MENU_TOP_GAP2 = 4;
598
+ var MENU_MIN_HEIGHT = 96;
599
+ var VIEWPORT_MARGIN = 8;
550
600
  function toDomNode(ref) {
551
601
  const node = ref.current;
552
602
  if (node === null || typeof node.getBoundingClientRect !== "function") return null;
@@ -559,8 +609,9 @@ function readAnchorRect(ref) {
559
609
  const r = node.getBoundingClientRect();
560
610
  return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
561
611
  }
562
- function buildPortalPopoverStyle(rect) {
612
+ function buildPortalPopoverStyle(rect, viewportBottom) {
563
613
  return {
614
+ ...viewportBottom !== void 0 ? { maxHeight: menuMaxHeight(rect.bottom, viewportBottom) } : null,
564
615
  // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
565
616
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
566
617
  position: "fixed",
@@ -607,10 +658,64 @@ function useAnchorRect(ref, enabled) {
607
658
  }, [ref, enabled]);
608
659
  return IS_WEB3 && enabled ? rect : null;
609
660
  }
661
+ function roomUnderAnchor(anchorBottom, viewportBottom) {
662
+ return viewportBottom - anchorBottom - MENU_TOP_GAP2 - VIEWPORT_MARGIN;
663
+ }
664
+ function menuMaxHeight(anchorBottom, viewportBottom) {
665
+ return Math.min(MENU_MAX_HEIGHT, Math.max(MENU_MIN_HEIGHT, roomUnderAnchor(anchorBottom, viewportBottom)));
666
+ }
667
+ function anchorScrollBlock(anchorBottom, viewportBottom) {
668
+ return roomUnderAnchor(anchorBottom, viewportBottom) < MENU_MAX_HEIGHT ? "start" : "nearest";
669
+ }
670
+ function readViewportBottom() {
671
+ const viewport = window.visualViewport ?? null;
672
+ return viewport === null ? window.innerHeight : viewport.offsetTop + viewport.height;
673
+ }
674
+ function useVisualViewportBottom(enabled) {
675
+ const [bottom, setBottom] = useState(null);
676
+ useLayoutEffect(() => {
677
+ if (!IS_WEB3 || !enabled) {
678
+ setBottom(null);
679
+ return void 0;
680
+ }
681
+ const update = () => setBottom(readViewportBottom());
682
+ update();
683
+ const viewport = window.visualViewport ?? null;
684
+ viewport?.addEventListener("resize", update);
685
+ viewport?.addEventListener("scroll", update);
686
+ window.addEventListener("resize", update);
687
+ return () => {
688
+ viewport?.removeEventListener("resize", update);
689
+ viewport?.removeEventListener("scroll", update);
690
+ window.removeEventListener("resize", update);
691
+ };
692
+ }, [enabled]);
693
+ return bottom;
694
+ }
695
+ function scrollNodeIntoView(node, block) {
696
+ if (node !== null && typeof node.scrollIntoView === "function") node.scrollIntoView({ block });
697
+ }
698
+ function scrollAnchorIntoView(ref, marginTop) {
699
+ if (!IS_WEB3) return;
700
+ const node = toDomNode(ref);
701
+ if (node === null) return;
702
+ node.style.scrollMarginTop = `${marginTop}px`;
703
+ scrollNodeIntoView(node, anchorScrollBlock(node.getBoundingClientRect().bottom, readViewportBottom()));
704
+ }
705
+ function useKeepAnchorInRoom(ref, marginTop, enabled) {
706
+ useLayoutEffect(() => {
707
+ if (!IS_WEB3 || !enabled) return void 0;
708
+ const place = () => scrollAnchorIntoView(ref, marginTop);
709
+ place();
710
+ const viewport = window.visualViewport ?? null;
711
+ viewport?.addEventListener("resize", place);
712
+ return () => viewport?.removeEventListener("resize", place);
713
+ }, [ref, marginTop, enabled]);
714
+ }
610
715
  var IS_WEB4 = typeof document !== "undefined";
611
716
  var ESCAPE_KEY = "Escape";
612
- var ACTIVE_WEIGHT = "700";
613
- var IDLE_WEIGHT = "400";
717
+ var LISTBOX_ROLE = { role: "listbox" };
718
+ var KEEP_FIELD_FOCUS = { onMouseDown: (event) => event.preventDefault() };
614
719
  function useDismiss(anchorRef, menuRef, onDismiss) {
615
720
  useEffect(() => {
616
721
  if (!IS_WEB4) return void 0;
@@ -633,6 +738,12 @@ function useDismiss(anchorRef, menuRef, onDismiss) {
633
738
  };
634
739
  }, [anchorRef, menuRef, onDismiss]);
635
740
  }
741
+ function useKeepHighlightInView(testID, highlightedValue) {
742
+ useEffect(() => {
743
+ if (!IS_WEB4 || highlightedValue === void 0 || highlightedValue === "") return;
744
+ scrollNodeIntoView(document.getElementById(optionDomId(testID, highlightedValue)), "nearest");
745
+ }, [testID, highlightedValue]);
746
+ }
636
747
  function AnchoredMenu({
637
748
  options,
638
749
  selectedValue,
@@ -643,38 +754,48 @@ function AnchoredMenu({
643
754
  accessibilityLabel,
644
755
  testID,
645
756
  anchorRef,
646
- usePortal = true
757
+ usePortal = true,
758
+ listbox
647
759
  }) {
648
760
  const menuRef = React2.useRef(null);
649
761
  useDismiss(anchorRef, menuRef, onDismiss);
762
+ useKeepHighlightInView(testID, listbox?.highlightedValue);
650
763
  const rect = useAnchorRect(anchorRef, usePortal);
651
- const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
652
- const menu = /* @__PURE__ */ jsx(
764
+ const viewportBottom = useVisualViewportBottom(usePortal);
765
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect, viewportBottom ?? void 0) : null;
766
+ const { width: windowWidth } = useWindowDimensions();
767
+ const menuWidth = rect?.width ?? windowWidth;
768
+ const isListbox = listbox !== void 0;
769
+ const statusText = listbox?.statusText;
770
+ const rows = options.map((option) => /* @__PURE__ */ jsx(
771
+ OptionRow,
772
+ {
773
+ colors,
774
+ hint: optionHint,
775
+ isActive: option.value === selectedValue,
776
+ isHighlighted: option.value === listbox?.highlightedValue,
777
+ isListboxRow: isListbox,
778
+ menuWidth,
779
+ option,
780
+ testID,
781
+ onSelect
782
+ },
783
+ option.value
784
+ ));
785
+ const listboxBody = options.length > 0 ? /* @__PURE__ */ jsx(View, { accessibilityLabel, id: listboxDomId(testID), ...LISTBOX_ROLE, testID: listboxDomId(testID), children: rows }) : null;
786
+ const menu = /* @__PURE__ */ jsxs(
653
787
  View,
654
788
  {
789
+ ...isListbox ? KEEP_FIELD_FOCUS : null,
655
790
  ref: menuRef,
656
- accessibilityLabel,
657
- accessibilityRole: "menu",
791
+ accessibilityLabel: isListbox ? void 0 : accessibilityLabel,
792
+ accessibilityRole: isListbox ? void 0 : "menu",
658
793
  style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
659
794
  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
- }) })
795
+ children: [
796
+ isListbox ? /* @__PURE__ */ jsx(Text, { role: "status", style: statusText !== void 0 ? [controlStyles.option, controlStyles.optionText, { color: colors.text }] : void 0, children: statusText ?? "" }) : null,
797
+ /* @__PURE__ */ jsx(ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: isListbox ? listboxBody : rows })
798
+ ]
678
799
  }
679
800
  );
680
801
  if (usePortal && IS_WEB4) return createPortal(menu, document.body);
@@ -1249,7 +1370,7 @@ function chipIdentity(option, selected, config) {
1249
1370
  ariaChecked: isRadio ? selected : void 0
1250
1371
  };
1251
1372
  }
1252
- var styles7 = StyleSheet.create({
1373
+ var styles8 = StyleSheet.create({
1253
1374
  chipContainer: {
1254
1375
  flexDirection: "row",
1255
1376
  flexWrap: "wrap",
@@ -1335,7 +1456,7 @@ var ChipSelector = ({
1335
1456
  View,
1336
1457
  {
1337
1458
  accessibilityRole: isRadioGroup ? "radiogroup" : void 0,
1338
- style: styles7.chipContainer,
1459
+ style: styles8.chipContainer,
1339
1460
  ...webFieldA11y({ describedById, hasError }),
1340
1461
  ...radio.groupProps,
1341
1462
  children: options.map(renderChip)
@@ -1345,7 +1466,7 @@ var ChipSelector = ({
1345
1466
  );
1346
1467
  };
1347
1468
  var DESCRIPTION_FONT_SIZE2 = 12;
1348
- var styles8 = StyleSheet.create({
1469
+ var styles9 = StyleSheet.create({
1349
1470
  container: {
1350
1471
  flexDirection: "row",
1351
1472
  justifyContent: "space-between",
@@ -1406,15 +1527,15 @@ var FormSwitch = ({
1406
1527
  accessible: false,
1407
1528
  disabled,
1408
1529
  onPress: toggle,
1409
- style: [styles8.container, containerStyle],
1530
+ style: [styles9.container, containerStyle],
1410
1531
  tabIndex: -1,
1411
1532
  testID: `${testID}-row`,
1412
1533
  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
1534
+ /* @__PURE__ */ jsxs(View, { style: styles9.textColumn, children: [
1535
+ /* @__PURE__ */ jsx(Text, { style: [styles9.label, themeStyles.label], children: label }),
1536
+ hasDescription ? /* @__PURE__ */ jsx(Text, { style: [styles9.description, themeStyles.description], children: description }) : null
1416
1537
  ] }),
1417
- /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: styles8.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsx(
1538
+ /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: styles9.switchSlot, testID: `${testID}-hitbox`, children: /* @__PURE__ */ jsx(
1418
1539
  Switch,
1419
1540
  {
1420
1541
  accessibilityHint: accessibilityHint ?? label,
@@ -1443,7 +1564,7 @@ var DISABLED_OPACITY = 0.5;
1443
1564
  var FOCUS_RING_WIDTH = 2;
1444
1565
  var WEB_NO_DEFAULT_OUTLINE = { outlineStyle: "none" };
1445
1566
  var FOCUS_RING_INSET = -6;
1446
- var styles9 = StyleSheet.create({
1567
+ var styles10 = StyleSheet.create({
1447
1568
  container: {
1448
1569
  flexDirection: "row",
1449
1570
  alignItems: "flex-start",
@@ -1548,8 +1669,8 @@ var FormCheckbox = ({
1548
1669
  onPress: toggle,
1549
1670
  role: "checkbox",
1550
1671
  style: [
1551
- styles9.container,
1552
- hasHint ? null : styles9.centered,
1672
+ styles10.container,
1673
+ hasHint ? null : styles10.centered,
1553
1674
  containerStyle,
1554
1675
  disabled ? { opacity: DISABLED_OPACITY } : null,
1555
1676
  Platform.OS === "web" ? WEB_NO_DEFAULT_OUTLINE : null
@@ -1557,13 +1678,13 @@ var FormCheckbox = ({
1557
1678
  testID,
1558
1679
  ...keyboardProps,
1559
1680
  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
1681
+ /* @__PURE__ */ jsxs(View, { style: [styles10.box, value ? themeStyles.boxOn : themeStyles.boxOff], testID: `${testID}-box`, children: [
1682
+ focused ? /* @__PURE__ */ jsx(View, { pointerEvents: "none", style: [styles10.focusRing, themeStyles.ring], testID: `${testID}-focus-ring` }) : null,
1683
+ value ? /* @__PURE__ */ jsx(Text, { style: [styles10.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null
1563
1684
  ] }),
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
1685
+ /* @__PURE__ */ jsxs(View, { style: styles10.textColumn, children: [
1686
+ /* @__PURE__ */ jsx(Text, { style: [styles10.label, themeStyles.label], children: label }),
1687
+ hasHint ? /* @__PURE__ */ jsx(Text, { nativeID: hintId, style: [styles10.hint, themeStyles.hint], children: hintText }) : null
1567
1688
  ] })
1568
1689
  ]
1569
1690
  }
@@ -1638,6 +1759,24 @@ function SelectControl({
1638
1759
  ) : null
1639
1760
  ] });
1640
1761
  }
1762
+ var CLOSE_DELAY_MS2 = 140;
1763
+ function useDelayedBlurClose(open, close) {
1764
+ const timerRef = useRef(null);
1765
+ const cancelClose = useCallback(() => {
1766
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
1767
+ timerRef.current = null;
1768
+ }, []);
1769
+ useEffect(() => cancelClose, [cancelClose]);
1770
+ const handleFocus = useCallback(() => {
1771
+ cancelClose();
1772
+ open();
1773
+ }, [cancelClose, open]);
1774
+ const handleBlur = useCallback(() => {
1775
+ cancelClose();
1776
+ timerRef.current = setTimeout(close, CLOSE_DELAY_MS2);
1777
+ }, [cancelClose, close]);
1778
+ return { handleFocus, handleBlur };
1779
+ }
1641
1780
 
1642
1781
  // src/controls/suggestOptions.ts
1643
1782
  var RANK_VALUE_EXACT = 0;
@@ -1670,94 +1809,212 @@ function isUnmatched(options, text) {
1670
1809
  if (needle === "") return false;
1671
1810
  return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1672
1811
  }
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);
1812
+ var DEFAULT_TYPEAHEAD_DEBOUNCE_MS = 250;
1813
+ var IDLE = { results: [], isLoading: false, hasFailed: false, hasSettled: false };
1814
+ function useTypeaheadSearch(load, query, enabled, debounceMs) {
1815
+ const [state, setState] = useState(IDLE);
1816
+ const loadRef = useRef(load);
1817
+ loadRef.current = load;
1818
+ useEffect(() => {
1819
+ if (!enabled) {
1820
+ setState(IDLE);
1821
+ return void 0;
1822
+ }
1823
+ setState((current) => ({ ...current, isLoading: true, hasFailed: false }));
1824
+ const controller = new AbortController();
1825
+ const settle = (next) => {
1826
+ if (!controller.signal.aborted) setState(next);
1827
+ };
1828
+ const timer = setTimeout(() => {
1829
+ Promise.resolve().then(() => loadRef.current?.(query, controller.signal) ?? []).then(
1830
+ (results) => settle({ results, isLoading: false, hasFailed: false, hasSettled: true }),
1831
+ () => settle({ results: [], isLoading: false, hasFailed: true, hasSettled: true })
1832
+ );
1833
+ }, debounceMs);
1834
+ return () => {
1835
+ clearTimeout(timer);
1836
+ controller.abort();
1837
+ };
1838
+ }, [query, enabled, debounceMs]);
1839
+ return state;
1840
+ }
1841
+
1842
+ // src/TypeaheadControl/hooks/useTypeaheadState.ts
1843
+ var NO_HIGHLIGHT = -1;
1844
+ var FORWARD = 1;
1845
+ var BACKWARD = -1;
1846
+ var NO_ROWS = [];
1847
+ function nextHighlight(current, count, delta) {
1848
+ if (count === 0) return NO_HIGHLIGHT;
1849
+ if (current === NO_HIGHLIGHT) return delta > 0 ? 0 : count - 1;
1850
+ return (current + delta + count) % count;
1851
+ }
1852
+ function searchStatusText(found, search) {
1853
+ if (found.hasFailed) return search.errorText;
1854
+ if (found.results.length > 0) return void 0;
1855
+ if (found.isLoading) return search.loadingText;
1856
+ return found.hasSettled ? search.emptyText : void 0;
1857
+ }
1858
+ function useTypeaheadRows(input, isOpen, isPicked) {
1859
+ const { options, search, value, minChars, maxSuggestions } = input;
1860
+ const query = value.trim();
1861
+ const isSearching = search !== void 0 && isOpen && !isPicked && query.length >= minChars;
1862
+ const found = useTypeaheadSearch(search?.load, query, isSearching, search?.debounceMs ?? DEFAULT_TYPEAHEAD_DEBOUNCE_MS);
1863
+ const staticRows = useMemo(() => {
1864
+ if (options === void 0 || options.some((o) => o.label === value)) return NO_ROWS;
1865
+ return suggestOptions(options, value, minChars, maxSuggestions);
1866
+ }, [options, value, minChars, maxSuggestions]);
1867
+ const foundRows = useMemo(() => found.results.slice(0, maxSuggestions), [found.results, maxSuggestions]);
1868
+ if (search === void 0) return { rows: staticRows, statusText: void 0 };
1869
+ return { rows: foundRows, statusText: searchStatusText(found, search) };
1870
+ }
1871
+ function useTypeaheadState(input) {
1872
+ const { search, value, onChange, onSubmit } = input;
1694
1873
  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]
1874
+ const [picked, setPicked] = useState(null);
1875
+ const [highlightIndex, setHighlightIndex] = useState(NO_HIGHLIGHT);
1876
+ const { rows, statusText } = useTypeaheadRows(input, isOpen, picked !== null && picked.label === value);
1877
+ const rowKey = rows.map((row) => row.value).join("\n");
1878
+ const highlighted = isOpen ? rows[highlightIndex] : void 0;
1879
+ useEffect(() => setHighlightIndex(NO_HIGHLIGHT), [rowKey]);
1880
+ useEffect(() => setPicked((current) => current !== null && current.label !== value ? null : current), [value]);
1881
+ const open = useCallback(() => setIsOpen(true), []);
1882
+ const close = useCallback(() => {
1883
+ setIsOpen(false);
1884
+ setHighlightIndex(NO_HIGHLIGHT);
1885
+ }, []);
1886
+ const pick = useCallback(
1887
+ (option) => {
1888
+ setPicked(option);
1889
+ onChange(option.label);
1890
+ search?.onPick(option);
1891
+ close();
1892
+ },
1893
+ [onChange, search, close]
1894
+ );
1895
+ const selectValue = useCallback(
1896
+ (optionValue) => {
1897
+ const option = rows.find((row) => row.value === optionValue);
1898
+ if (option !== void 0) pick(option);
1899
+ },
1900
+ [rows, pick]
1699
1901
  );
1700
- const showMenu = isOpen && suggestions.length > 0;
1701
1902
  const handleChange = useCallback(
1702
1903
  (next) => {
1904
+ if (picked !== null) {
1905
+ setPicked(null);
1906
+ search?.onPick(null);
1907
+ }
1703
1908
  onChange(next);
1704
1909
  setIsOpen(true);
1910
+ setHighlightIndex(NO_HIGHLIGHT);
1705
1911
  },
1706
- [onChange]
1707
- );
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]
1912
+ [picked, search, onChange]
1715
1913
  );
1716
- const handleBlur = useCallback(() => {
1717
- setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS2);
1718
- }, []);
1914
+ const handleKeyPress = useTypeaheadKeys({ rowCount: rows.length, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick });
1719
1915
  const handleSubmit = useCallback(() => {
1720
- setIsOpen(false);
1916
+ if (highlighted !== void 0) {
1917
+ pick(highlighted);
1918
+ return;
1919
+ }
1920
+ close();
1721
1921
  onSubmit?.();
1722
- }, [onSubmit]);
1922
+ }, [highlighted, pick, close, onSubmit]);
1923
+ return { rows, statusText, isOpen, picked, highlighted, open, close, selectValue, handleChange, handleKeyPress, handleSubmit };
1924
+ }
1925
+ function useTypeaheadKeys(input) {
1926
+ const { rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick } = input;
1927
+ return useCallback(
1928
+ (event) => {
1929
+ const { key } = event.nativeEvent;
1930
+ if (key === "ArrowDown" || key === "ArrowUp") {
1931
+ event.preventDefault();
1932
+ setIsOpen(true);
1933
+ setHighlightIndex((current) => nextHighlight(current, rowCount, key === "ArrowDown" ? FORWARD : BACKWARD));
1934
+ } else if (key === "Escape" && isOpen) {
1935
+ event.preventDefault();
1936
+ close();
1937
+ } else if (key === "Enter" && highlighted !== void 0) {
1938
+ event.preventDefault();
1939
+ pick(highlighted);
1940
+ }
1941
+ },
1942
+ [rowCount, isOpen, highlighted, setIsOpen, setHighlightIndex, close, pick]
1943
+ );
1944
+ }
1945
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1946
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1947
+ function comboboxAria(testID, isExpanded, highlighted) {
1948
+ return {
1949
+ role: "combobox",
1950
+ "aria-autocomplete": "list",
1951
+ "aria-expanded": isExpanded,
1952
+ "aria-controls": isExpanded ? listboxDomId(testID) : void 0,
1953
+ "aria-activedescendant": highlighted !== void 0 ? optionDomId(testID, highlighted.value) : void 0
1954
+ };
1955
+ }
1956
+ function TypeaheadControlImpl(props, ref) {
1957
+ const { value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint = "", error, minChars = DEFAULT_TYPEAHEAD_MIN_CHARS, style, testID } = props;
1958
+ const { id, describedById } = props;
1959
+ const search = "search" in props ? props.search : void 0;
1960
+ const options = "options" in props ? props.options : void 0;
1961
+ const maxSuggestions = ("search" in props ? props.search.maxResults : props.maxSuggestions) ?? DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1962
+ const { theme } = useUi();
1963
+ const { colors, palette } = theme;
1964
+ const anchorRef = useRef(null);
1965
+ const state = useTypeaheadState({ options, search, value, minChars, maxSuggestions, onChange, onSubmit });
1966
+ const { handleFocus, handleBlur } = useDelayedBlurClose(state.open, state.close);
1967
+ const showMenu = state.isOpen && (state.rows.length > 0 || state.statusText !== void 0);
1968
+ const isExpanded = state.isOpen && state.rows.length > 0;
1723
1969
  const hasError = error !== void 0 && error !== "";
1970
+ useKeepAnchorInRoom(anchorRef, search?.scrollMarginTop ?? 0, search !== void 0 && state.isOpen);
1724
1971
  return /* @__PURE__ */ jsxs(View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1725
1972
  /* @__PURE__ */ jsx(
1726
1973
  TextInput,
1727
1974
  {
1975
+ ref,
1976
+ id,
1977
+ ...comboboxAria(testID, isExpanded, state.highlighted),
1978
+ ...webFieldA11y({ describedById }),
1728
1979
  accessibilityLabel,
1729
1980
  accessibilityHint,
1730
1981
  autoCapitalize: "none",
1982
+ autoComplete: "off",
1731
1983
  autoCorrect: false,
1984
+ blurOnSubmit: search === void 0,
1732
1985
  placeholder,
1733
1986
  placeholderTextColor: colors.textSecondary,
1734
1987
  spellCheck: false,
1735
1988
  value,
1736
- onChangeText: handleChange,
1737
- onFocus: () => setIsOpen(true),
1989
+ onChangeText: state.handleChange,
1990
+ onFocus: handleFocus,
1738
1991
  onBlur: handleBlur,
1739
- onSubmitEditing: handleSubmit,
1992
+ onKeyPress: state.handleKeyPress,
1993
+ onSubmitEditing: state.handleSubmit,
1740
1994
  style: [controlStyles.input, { borderColor: hasError ? fieldErrorInk(theme) : colors.border, backgroundColor: colors.surface, color: colors.text }],
1741
- testID: `${testID}-input`
1995
+ testID: search?.inputTestID ?? `${testID}-input`
1742
1996
  }
1743
1997
  ),
1744
1998
  showMenu ? /* @__PURE__ */ jsx(
1745
1999
  AnchoredMenu,
1746
2000
  {
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"] },
2001
+ accessibilityLabel,
2002
+ options: state.rows,
2003
+ selectedValue: search !== void 0 ? state.picked?.value ?? "" : value,
2004
+ onSelect: state.selectValue,
2005
+ onDismiss: state.close,
2006
+ colors: { text: colors.text, textSecondary: colors.textSecondary, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1752
2007
  optionHint,
1753
2008
  testID,
1754
2009
  anchorRef,
2010
+ listbox: { highlightedValue: state.highlighted?.value ?? "", statusText: state.statusText },
1755
2011
  usePortal: true
1756
2012
  }
1757
2013
  ) : null,
1758
2014
  hasError ? /* @__PURE__ */ jsx(Text, { role: "alert", style: [controlStyles.error, { color: fieldErrorInk(theme) }], testID: `${testID}-error`, children: error }) : null
1759
2015
  ] });
1760
2016
  }
2017
+ var TypeaheadControl = forwardRef(TypeaheadControlImpl);
1761
2018
  var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1762
2019
  function DateInput(p) {
1763
2020
  return /* @__PURE__ */ jsxs(View, { style: controlStyles.dateCol, children: [
@@ -1858,7 +2115,7 @@ var ROW_GAP = 4;
1858
2115
  var TAB_PADDING = 4;
1859
2116
  var INDICATOR_WIDTH = 2;
1860
2117
  var TAB_FONT_SIZE = 12;
1861
- var styles10 = StyleSheet.create({
2118
+ var styles11 = StyleSheet.create({
1862
2119
  row: { flexDirection: "row", flexWrap: "nowrap", gap: ROW_GAP },
1863
2120
  tab: {
1864
2121
  borderBottomWidth: INDICATOR_WIDTH,
@@ -1898,13 +2155,13 @@ var StepperTab = ({
1898
2155
  accessibilityRole: "tab",
1899
2156
  accessibilityState: { selected },
1900
2157
  "aria-selected": selected,
1901
- style: [styles10.tab, { borderBottomColor: indicator }, ring.ringStyle],
2158
+ style: [styles11.tab, { borderBottomColor: indicator }, ring.ringStyle],
1902
2159
  testID,
1903
2160
  onBlur: ring.onBlur,
1904
2161
  onFocus: ring.onFocus,
1905
2162
  onPress: () => onStep(step.key),
1906
2163
  ...webKeyProps,
1907
- children: /* @__PURE__ */ jsx(Text, { style: [styles10.text, { color: theme.colors.text }, selected ? styles10.textSelected : void 0], children: step.label })
2164
+ children: /* @__PURE__ */ jsx(Text, { style: [styles11.text, { color: theme.colors.text }, selected ? styles11.textSelected : void 0], children: step.label })
1908
2165
  }
1909
2166
  );
1910
2167
  };
@@ -1914,7 +2171,7 @@ var StepperTabs = ({
1914
2171
  onStep,
1915
2172
  stepHint,
1916
2173
  testID
1917
- }) => /* @__PURE__ */ jsx(View, { accessibilityRole: "tablist", style: styles10.row, testID, children: steps.map((step) => /* @__PURE__ */ jsx(
2174
+ }) => /* @__PURE__ */ jsx(View, { accessibilityRole: "tablist", style: styles11.row, testID, children: steps.map((step) => /* @__PURE__ */ jsx(
1918
2175
  StepperTab,
1919
2176
  {
1920
2177
  hint: stepHint,
@@ -1933,7 +2190,7 @@ function resolveStepNeighbours(steps, current) {
1933
2190
  return { previous: steps[index - 1] ?? null, next: steps[index + 1] ?? null };
1934
2191
  }
1935
2192
  var ROW_GAP2 = 8;
1936
- var styles11 = StyleSheet.create({
2193
+ var styles12 = StyleSheet.create({
1937
2194
  cell: { flex: 1, minWidth: 0 },
1938
2195
  row: { flexDirection: "row", gap: ROW_GAP2 }
1939
2196
  });
@@ -1945,8 +2202,8 @@ var StepperNav = ({
1945
2202
  testID
1946
2203
  }) => {
1947
2204
  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(
2205
+ return /* @__PURE__ */ jsxs(View, { style: styles12.row, testID, children: [
2206
+ /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
1950
2207
  Button,
1951
2208
  {
1952
2209
  fullWidth: true,
@@ -1962,7 +2219,7 @@ var StepperNav = ({
1962
2219
  }
1963
2220
  }
1964
2221
  ) }),
1965
- next === null ? null : /* @__PURE__ */ jsx(View, { style: styles11.cell, children: /* @__PURE__ */ jsx(
2222
+ next === null ? null : /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
1966
2223
  Button,
1967
2224
  {
1968
2225
  fullWidth: true,
@@ -1976,12 +2233,29 @@ var StepperNav = ({
1976
2233
  ) })
1977
2234
  ] });
1978
2235
  };
2236
+ var STATUS_FONT = 16;
2237
+ var styles13 = StyleSheet.create({
2238
+ line: { fontSize: STATUS_FONT }
2239
+ });
2240
+ function FormStatusLine({ message, testID, style }) {
2241
+ const { theme } = useUi();
2242
+ if (message === null || message === void 0 || message === "") return null;
2243
+ return /* @__PURE__ */ jsx(Text, { role: "alert", style: [styles13.line, { color: theme.colors.text }, style], testID, children: message });
2244
+ }
2245
+ function useFocusFirstInvalid(refs) {
2246
+ const latest = useRef(refs);
2247
+ latest.current = refs;
2248
+ return useCallback((invalid) => {
2249
+ const first = invalid.map((field) => latest.current[field].current).find((node) => node !== null);
2250
+ first?.focus();
2251
+ }, []);
2252
+ }
1979
2253
  var DEFAULT_PREVIEW_PX = 48;
1980
2254
  var PREVIEW_RADIUS = 8;
1981
2255
  var GAP = 8;
1982
2256
  var TITLE_FONT_SIZE = 14;
1983
2257
  var HELP_FONT_SIZE = 13;
1984
- var styles12 = StyleSheet.create({
2258
+ var styles14 = StyleSheet.create({
1985
2259
  help: { flex: 1, fontSize: HELP_FONT_SIZE },
1986
2260
  root: { gap: GAP },
1987
2261
  tile: { alignItems: "center", flexDirection: "row", gap: GAP },
@@ -2003,11 +2277,11 @@ var UploadWithLinkField = ({
2003
2277
  const url = value ?? "";
2004
2278
  const [pasting, setPasting] = useState(url !== "");
2005
2279
  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: [
2280
+ return /* @__PURE__ */ jsxs(View, { style: styles14.root, children: [
2281
+ /* @__PURE__ */ jsx(Text, { style: [styles14.title, { color: theme.colors.text }], children: labels.title }),
2282
+ /* @__PURE__ */ jsxs(View, { style: styles14.tile, children: [
2009
2283
  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 })
2284
+ labels.help === void 0 ? null : /* @__PURE__ */ jsx(Text, { style: [styles14.help, { color: theme.colors.text }], children: labels.help })
2011
2285
  ] }),
2012
2286
  uploadControl,
2013
2287
  /* @__PURE__ */ jsx(
@@ -2018,12 +2292,12 @@ var UploadWithLinkField = ({
2018
2292
  accessibilityRole: "button",
2019
2293
  accessibilityState: { expanded: pasting },
2020
2294
  "aria-expanded": pasting,
2021
- style: [styles12.toggle, ring.ringStyle],
2295
+ style: [styles14.toggle, ring.ringStyle],
2022
2296
  testID: `${testID}-paste`,
2023
2297
  onBlur: ring.onBlur,
2024
2298
  onFocus: ring.onFocus,
2025
2299
  onPress: () => setPasting(!pasting),
2026
- children: /* @__PURE__ */ jsx(Text, { style: [styles12.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2300
+ children: /* @__PURE__ */ jsx(Text, { style: [styles14.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2027
2301
  }
2028
2302
  ),
2029
2303
  pasting ? /* @__PURE__ */ jsx(
@@ -2041,6 +2315,6 @@ var UploadWithLinkField = ({
2041
2315
  ] });
2042
2316
  };
2043
2317
 
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 };
2318
+ 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
2319
  //# sourceMappingURL=index.mjs.map
2046
2320
  //# sourceMappingURL=index.mjs.map