@dloizides/ui-forms 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import React2, { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from 'react';
2
- import { Platform, StyleSheet, TextInput, View, Text, useWindowDimensions, ScrollView, Pressable, Switch, TouchableOpacity } from 'react-native';
1
+ import React2, { forwardRef, useState, useCallback, useMemo, useRef, useEffect, useLayoutEffect } from 'react';
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';
5
5
  import { Z_INDEX, focusRingOutline, readableScaleInk } from '@dloizides/design-tokens';
6
- import { themeFocusRingColor, Button } from '@dloizides/ui-buttons';
6
+ import { themeFocusRingColor, Button, useFocusRing } from '@dloizides/ui-buttons';
7
7
  import { createPortal } from 'react-dom';
8
8
  import { MIN_TOUCH_TARGET_PX } from '@dloizides/a11y';
9
9
 
@@ -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: [
@@ -1854,7 +2089,210 @@ function toggleGroupSelection(selected, groupKeys, order) {
1854
2089
  });
1855
2090
  return order === void 0 ? merged : order.filter((key) => merged.includes(key));
1856
2091
  }
2092
+ var ROW_GAP = 4;
2093
+ var TAB_PADDING = 4;
2094
+ var INDICATOR_WIDTH = 2;
2095
+ var TAB_FONT_SIZE = 12;
2096
+ var styles11 = StyleSheet.create({
2097
+ row: { flexDirection: "row", flexWrap: "nowrap", gap: ROW_GAP },
2098
+ tab: {
2099
+ borderBottomWidth: INDICATOR_WIDTH,
2100
+ flexBasis: 0,
2101
+ flexGrow: 1,
2102
+ flexShrink: 1,
2103
+ justifyContent: "center",
2104
+ minHeight: MIN_TOUCH_TARGET_PX,
2105
+ minWidth: 0,
2106
+ paddingHorizontal: TAB_PADDING
2107
+ },
2108
+ text: { fontSize: TAB_FONT_SIZE, textAlign: "center" },
2109
+ textSelected: { fontWeight: "700" }
2110
+ });
2111
+ var StepperTab = ({
2112
+ step,
2113
+ selected,
2114
+ hint,
2115
+ onStep,
2116
+ testID
2117
+ }) => {
2118
+ const { theme } = useUi();
2119
+ const ring = useFocusRing();
2120
+ const indicator = selected ? theme.palette.primary["500"] : theme.colors.border;
2121
+ const webKeyProps = {
2122
+ onKeyDown: (event) => {
2123
+ if (event.key !== " ") return;
2124
+ event.preventDefault?.();
2125
+ onStep(step.key);
2126
+ }
2127
+ };
2128
+ return /* @__PURE__ */ jsx(
2129
+ Pressable,
2130
+ {
2131
+ accessibilityHint: hint,
2132
+ accessibilityLabel: step.label,
2133
+ accessibilityRole: "tab",
2134
+ accessibilityState: { selected },
2135
+ "aria-selected": selected,
2136
+ style: [styles11.tab, { borderBottomColor: indicator }, ring.ringStyle],
2137
+ testID,
2138
+ onBlur: ring.onBlur,
2139
+ onFocus: ring.onFocus,
2140
+ onPress: () => onStep(step.key),
2141
+ ...webKeyProps,
2142
+ children: /* @__PURE__ */ jsx(Text, { style: [styles11.text, { color: theme.colors.text }, selected ? styles11.textSelected : void 0], children: step.label })
2143
+ }
2144
+ );
2145
+ };
2146
+ var StepperTabs = ({
2147
+ steps,
2148
+ current,
2149
+ onStep,
2150
+ stepHint,
2151
+ testID
2152
+ }) => /* @__PURE__ */ jsx(View, { accessibilityRole: "tablist", style: styles11.row, testID, children: steps.map((step) => /* @__PURE__ */ jsx(
2153
+ StepperTab,
2154
+ {
2155
+ hint: stepHint,
2156
+ selected: step.key === current,
2157
+ step,
2158
+ testID: `${testID}-${step.key}`,
2159
+ onStep
2160
+ },
2161
+ step.key
2162
+ )) });
2163
+
2164
+ // src/Stepper/stepperModel.ts
2165
+ function resolveStepNeighbours(steps, current) {
2166
+ const index = steps.findIndex((step) => step.key === current);
2167
+ if (index < 0) return { previous: null, next: null };
2168
+ return { previous: steps[index - 1] ?? null, next: steps[index + 1] ?? null };
2169
+ }
2170
+ var ROW_GAP2 = 8;
2171
+ var styles12 = StyleSheet.create({
2172
+ cell: { flex: 1, minWidth: 0 },
2173
+ row: { flexDirection: "row", gap: ROW_GAP2 }
2174
+ });
2175
+ var StepperNav = ({
2176
+ steps,
2177
+ current,
2178
+ onStep,
2179
+ labels,
2180
+ testID
2181
+ }) => {
2182
+ const { previous, next } = resolveStepNeighbours(steps, current);
2183
+ return /* @__PURE__ */ jsxs(View, { style: styles12.row, testID, children: [
2184
+ /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
2185
+ Button,
2186
+ {
2187
+ fullWidth: true,
2188
+ accessibilityHint: labels.backHint,
2189
+ accessibilityLabel: labels.back,
2190
+ autoLoading: false,
2191
+ disabled: previous === null,
2192
+ label: labels.back,
2193
+ testID: `${testID}-back`,
2194
+ variant: "outline",
2195
+ onPress: () => {
2196
+ if (previous !== null) onStep(previous.key);
2197
+ }
2198
+ }
2199
+ ) }),
2200
+ next === null ? null : /* @__PURE__ */ jsx(View, { style: styles12.cell, children: /* @__PURE__ */ jsx(
2201
+ Button,
2202
+ {
2203
+ fullWidth: true,
2204
+ accessibilityHint: labels.nextHint,
2205
+ accessibilityLabel: labels.next(next),
2206
+ autoLoading: false,
2207
+ label: labels.next(next),
2208
+ testID: `${testID}-next`,
2209
+ onPress: () => onStep(next.key)
2210
+ }
2211
+ ) })
2212
+ ] });
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
+ }
2231
+ var DEFAULT_PREVIEW_PX = 48;
2232
+ var PREVIEW_RADIUS = 8;
2233
+ var GAP = 8;
2234
+ var TITLE_FONT_SIZE = 14;
2235
+ var HELP_FONT_SIZE = 13;
2236
+ var styles14 = StyleSheet.create({
2237
+ help: { flex: 1, fontSize: HELP_FONT_SIZE },
2238
+ root: { gap: GAP },
2239
+ tile: { alignItems: "center", flexDirection: "row", gap: GAP },
2240
+ title: { fontSize: TITLE_FONT_SIZE, fontWeight: "700" },
2241
+ toggle: { alignSelf: "flex-start", justifyContent: "center", minHeight: MIN_TOUCH_TARGET_PX },
2242
+ toggleText: { fontSize: TITLE_FONT_SIZE, fontWeight: "700", textDecorationLine: "underline" }
2243
+ });
2244
+ var UploadWithLinkField = ({
2245
+ value,
2246
+ onChange,
2247
+ labels,
2248
+ uploadControl,
2249
+ testID,
2250
+ disabled = false,
2251
+ previewSize = DEFAULT_PREVIEW_PX
2252
+ }) => {
2253
+ const { theme } = useUi();
2254
+ const ring = useFocusRing();
2255
+ const url = value ?? "";
2256
+ const [pasting, setPasting] = useState(url !== "");
2257
+ const previewBox = { borderRadius: PREVIEW_RADIUS, height: previewSize, width: previewSize };
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: [
2261
+ url === "" ? /* @__PURE__ */ jsx(View, { style: [previewBox, { backgroundColor: theme.colors.border }] }) : /* @__PURE__ */ jsx(Image, { accessible: false, source: { uri: url }, style: previewBox, testID: `${testID}-preview` }),
2262
+ labels.help === void 0 ? null : /* @__PURE__ */ jsx(Text, { style: [styles14.help, { color: theme.colors.text }], children: labels.help })
2263
+ ] }),
2264
+ uploadControl,
2265
+ /* @__PURE__ */ jsx(
2266
+ Pressable,
2267
+ {
2268
+ accessibilityHint: labels.pasteToggleHint,
2269
+ accessibilityLabel: labels.pasteToggle,
2270
+ accessibilityRole: "button",
2271
+ accessibilityState: { expanded: pasting },
2272
+ "aria-expanded": pasting,
2273
+ style: [styles14.toggle, ring.ringStyle],
2274
+ testID: `${testID}-paste`,
2275
+ onBlur: ring.onBlur,
2276
+ onFocus: ring.onFocus,
2277
+ onPress: () => setPasting(!pasting),
2278
+ children: /* @__PURE__ */ jsx(Text, { style: [styles14.toggleText, { color: theme.colors.text }], children: labels.pasteToggle })
2279
+ }
2280
+ ),
2281
+ pasting ? /* @__PURE__ */ jsx(
2282
+ FormField,
2283
+ {
2284
+ accessibilityHint: labels.linkHint,
2285
+ editable: !disabled,
2286
+ label: labels.link,
2287
+ placeholder: labels.linkPlaceholder,
2288
+ testID,
2289
+ value: url,
2290
+ onChangeText: onChange
2291
+ }
2292
+ ) : null
2293
+ ] });
2294
+ };
1857
2295
 
1858
- 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, ThemedTextInput, TypeaheadControl, controlStyles, groupAriaChecked, inlineFieldRowLayout, isUnmatched, resolveGroupSelection, 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 };
1859
2297
  //# sourceMappingURL=index.mjs.map
1860
2298
  //# sourceMappingURL=index.mjs.map