@dloizides/ui-forms 1.13.4 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@ var reactNative = require('react-native');
5
5
  var uiFeedback = require('@dloizides/ui-feedback');
6
6
  var jsxRuntime = require('react/jsx-runtime');
7
7
  var uiButtons = require('@dloizides/ui-buttons');
8
- var a11y = require('@dloizides/a11y');
9
8
  var reactDom = require('react-dom');
9
+ var a11y = require('@dloizides/a11y');
10
10
 
11
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
12
 
@@ -465,6 +465,142 @@ var controlStyles = reactNative.StyleSheet.create({
465
465
  error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
466
466
  });
467
467
  var IS_WEB3 = reactNative.Platform.OS === "web";
468
+ var MENU_PORTAL_Z_INDEX = 1e3;
469
+ var MENU_TOP_GAP2 = 4;
470
+ function toDomNode(ref) {
471
+ const node = ref.current;
472
+ if (node === null || typeof node.getBoundingClientRect !== "function") return null;
473
+ return node;
474
+ }
475
+ function readAnchorRect(ref) {
476
+ if (!IS_WEB3) return null;
477
+ const node = toDomNode(ref);
478
+ if (node === null) return null;
479
+ const r = node.getBoundingClientRect();
480
+ return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
481
+ }
482
+ function buildPortalPopoverStyle(rect) {
483
+ return {
484
+ // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
485
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
486
+ position: "fixed",
487
+ top: rect.bottom + MENU_TOP_GAP2,
488
+ left: rect.left,
489
+ width: rect.width,
490
+ zIndex: MENU_PORTAL_Z_INDEX
491
+ };
492
+ }
493
+ function useAnchorRect(ref, enabled) {
494
+ const [rect, setRect] = React2.useState(null);
495
+ React2.useLayoutEffect(() => {
496
+ if (!IS_WEB3 || !enabled) {
497
+ setRect(null);
498
+ return void 0;
499
+ }
500
+ let frame = null;
501
+ let disposed = false;
502
+ const apply = () => {
503
+ frame = null;
504
+ if (disposed) return;
505
+ setRect(readAnchorRect(ref));
506
+ };
507
+ const schedule = () => {
508
+ if (frame !== null) return;
509
+ frame = window.requestAnimationFrame(apply);
510
+ };
511
+ apply();
512
+ document.addEventListener("scroll", schedule, true);
513
+ window.addEventListener("resize", schedule);
514
+ const node = toDomNode(ref);
515
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : null;
516
+ if (observer !== null) {
517
+ if (node !== null) observer.observe(node);
518
+ observer.observe(document.body);
519
+ }
520
+ return () => {
521
+ disposed = true;
522
+ if (frame !== null) window.cancelAnimationFrame(frame);
523
+ document.removeEventListener("scroll", schedule, true);
524
+ window.removeEventListener("resize", schedule);
525
+ if (observer !== null) observer.disconnect();
526
+ };
527
+ }, [ref, enabled]);
528
+ return IS_WEB3 && enabled ? rect : null;
529
+ }
530
+ var IS_WEB4 = typeof document !== "undefined";
531
+ var ESCAPE_KEY = "Escape";
532
+ var ACTIVE_WEIGHT = "700";
533
+ var IDLE_WEIGHT = "400";
534
+ function useDismiss(anchorRef, menuRef, onDismiss) {
535
+ React2.useEffect(() => {
536
+ if (!IS_WEB4) return void 0;
537
+ const onMouseDown = (event) => {
538
+ const anchorNode = anchorRef.current;
539
+ const menuNode = menuRef.current;
540
+ const target = event.target instanceof Node ? event.target : null;
541
+ const insideAnchor = anchorNode !== null && target !== null && anchorNode.contains(target);
542
+ const insideMenu = menuNode !== null && target !== null && menuNode.contains(target);
543
+ if (!insideAnchor && !insideMenu) onDismiss();
544
+ };
545
+ const onKeyDown = (event) => {
546
+ if (event.key === ESCAPE_KEY) onDismiss();
547
+ };
548
+ document.addEventListener("mousedown", onMouseDown);
549
+ document.addEventListener("keydown", onKeyDown);
550
+ return () => {
551
+ document.removeEventListener("mousedown", onMouseDown);
552
+ document.removeEventListener("keydown", onKeyDown);
553
+ };
554
+ }, [anchorRef, menuRef, onDismiss]);
555
+ }
556
+ function AnchoredMenu({
557
+ options,
558
+ selectedValue,
559
+ onSelect,
560
+ onDismiss,
561
+ colors,
562
+ optionHint,
563
+ accessibilityLabel,
564
+ testID,
565
+ anchorRef,
566
+ usePortal = true
567
+ }) {
568
+ const menuRef = React2__default.default.useRef(null);
569
+ useDismiss(anchorRef, menuRef, onDismiss);
570
+ const rect = useAnchorRect(anchorRef, usePortal);
571
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
572
+ const menu = /* @__PURE__ */ jsxRuntime.jsx(
573
+ reactNative.View,
574
+ {
575
+ ref: menuRef,
576
+ accessibilityLabel,
577
+ accessibilityRole: "menu",
578
+ style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
579
+ testID: `${testID}-menu`,
580
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: options.map((opt) => {
581
+ const active = opt.value === selectedValue;
582
+ return /* @__PURE__ */ jsxRuntime.jsx(
583
+ reactNative.Pressable,
584
+ {
585
+ accessibilityRole: "menuitem",
586
+ accessibilityLabel: opt.label,
587
+ accessibilityHint: optionHint,
588
+ accessibilityState: { selected: active },
589
+ "aria-selected": active,
590
+ onPress: () => onSelect(opt.value),
591
+ style: controlStyles.option,
592
+ testID: `${testID}-option-${opt.value}`,
593
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
594
+ },
595
+ opt.value
596
+ );
597
+ }) })
598
+ }
599
+ );
600
+ if (usePortal && IS_WEB4) return reactDom.createPortal(menu, document.body);
601
+ return menu;
602
+ }
603
+ var IS_WEB5 = reactNative.Platform.OS === "web";
468
604
  var TRANSITION_PROPERTY = "border-color, background-color, box-shadow";
469
605
  var TRANSITION_DURATION = "0.14s";
470
606
  var TRANSITION_TIMING = "ease";
@@ -480,7 +616,7 @@ var GREEN_START = 3;
480
616
  var BLUE_START = 5;
481
617
  var CHANNEL_END_OFFSET = 2;
482
618
  function prefersReducedMotion() {
483
- if (!IS_WEB3 || typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
619
+ if (!IS_WEB5 || typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
484
620
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
485
621
  }
486
622
  function withAlpha(color, alpha) {
@@ -523,7 +659,7 @@ function useThemedInput(options = {}) {
523
659
  const borderColor = isFocused ? focusBorder : isHovered ? withAlpha(colors.text, HOVER_BORDER_ALPHA) : restBorder;
524
660
  const backgroundColor = isFocused ? colors.surface : colors.surfaceElevated;
525
661
  const base = { color: colors.text, borderColor, backgroundColor };
526
- if (!IS_WEB3) return base;
662
+ if (!IS_WEB5) return base;
527
663
  const ringColor = hasError ? errorColor : primary;
528
664
  const ring = isFocused ? `0 0 0 ${FOCUS_RING_WIDTH}px ${withAlpha(ringColor, FOCUS_RING_ALPHA)}` : "none";
529
665
  const webStyle = {
@@ -548,7 +684,7 @@ function useThemedInput(options = {}) {
548
684
  ]);
549
685
  const focusBind = React2.useMemo(() => ({ onFocus: handleFocus, onBlur: handleBlur }), [handleFocus, handleBlur]);
550
686
  const hoverBind = React2.useMemo(
551
- () => IS_WEB3 ? { onMouseEnter: handleHoverIn, onMouseLeave: handleHoverOut } : {},
687
+ () => IS_WEB5 ? { onMouseEnter: handleHoverIn, onMouseLeave: handleHoverOut } : {},
552
688
  [handleHoverIn, handleHoverOut]
553
689
  );
554
690
  return {
@@ -560,9 +696,9 @@ function useThemedInput(options = {}) {
560
696
  hoverBind
561
697
  };
562
698
  }
563
- var IS_WEB4 = reactNative.Platform.OS === "web";
699
+ var IS_WEB6 = reactNative.Platform.OS === "web";
564
700
  function webFieldA11y({ describedById, hasError = false, required = false }) {
565
- if (!IS_WEB4) {
701
+ if (!IS_WEB6) {
566
702
  return {};
567
703
  }
568
704
  return {
@@ -612,12 +748,187 @@ var ThemedTextInput = React2__default.default.forwardRef(function ThemedTextInpu
612
748
  }
613
749
  );
614
750
  });
751
+
752
+ // src/useRecentValues/recentValuesCore.ts
753
+ var STORAGE_PREFIX = "@dloizides/ui-forms:recent:";
754
+ var DEFAULT_MAX_RECENTS = 10;
755
+ function recentStorageKey(key) {
756
+ return `${STORAGE_PREFIX}${key}`;
757
+ }
758
+ function getStorage() {
759
+ try {
760
+ if (typeof window === "undefined") return null;
761
+ const storage = window.localStorage;
762
+ return storage !== null && storage !== void 0 ? storage : null;
763
+ } catch {
764
+ return null;
765
+ }
766
+ }
767
+ function readRecents(key) {
768
+ const storage = getStorage();
769
+ if (storage === null) return [];
770
+ try {
771
+ const raw = storage.getItem(recentStorageKey(key));
772
+ if (raw === null) return [];
773
+ const parsed = JSON.parse(raw);
774
+ if (!Array.isArray(parsed)) return [];
775
+ return parsed.filter((entry) => typeof entry === "string");
776
+ } catch {
777
+ return [];
778
+ }
779
+ }
780
+ function writeRecents(key, values) {
781
+ const storage = getStorage();
782
+ if (storage === null) return;
783
+ try {
784
+ storage.setItem(recentStorageKey(key), JSON.stringify(values));
785
+ } catch {
786
+ }
787
+ }
788
+ function clearRecents(key) {
789
+ const storage = getStorage();
790
+ if (storage === null) return;
791
+ try {
792
+ storage.removeItem(recentStorageKey(key));
793
+ } catch {
794
+ }
795
+ }
796
+ function computeNextRecents(current, value, max) {
797
+ const trimmed = value.trim();
798
+ if (trimmed === "") return [...current];
799
+ const lower = trimmed.toLowerCase();
800
+ const withoutMatch = current.filter((entry) => entry.toLowerCase() !== lower);
801
+ const promoted = [trimmed, ...withoutMatch];
802
+ const cap = max > 0 ? max : 0;
803
+ return promoted.slice(0, cap);
804
+ }
805
+ function recentsEqual(a, b) {
806
+ if (a.length !== b.length) return false;
807
+ return a.every((entry, index) => entry === b[index]);
808
+ }
809
+
810
+ // src/useRecentValues/useRecentValues.ts
811
+ function useRecentValues(key, max = DEFAULT_MAX_RECENTS) {
812
+ const [recents, setRecents] = React2.useState(() => readRecents(key).slice(0, max));
813
+ React2.useEffect(() => {
814
+ setRecents(readRecents(key).slice(0, max));
815
+ }, [key, max]);
816
+ const remember = React2.useCallback(
817
+ (value) => {
818
+ setRecents((current) => {
819
+ const next = computeNextRecents(current, value, max);
820
+ if (recentsEqual(next, current)) return current;
821
+ writeRecents(key, next);
822
+ return next;
823
+ });
824
+ },
825
+ [key, max]
826
+ );
827
+ const clear = React2.useCallback(() => {
828
+ clearRecents(key);
829
+ setRecents([]);
830
+ }, [key]);
831
+ return { recents, remember, clear };
832
+ }
833
+ var CLOSE_DELAY_MS = 140;
834
+ function RecentInputField({
835
+ recentKey,
836
+ recentMax,
837
+ label,
838
+ value,
839
+ testID,
840
+ onChangeText,
841
+ onSubmitEditing,
842
+ onFocus,
843
+ onBlur,
844
+ ...rest
845
+ }) {
846
+ const { theme } = uiFeedback.useUi();
847
+ const { colors, palette } = theme;
848
+ const { recents, remember } = useRecentValues(recentKey, recentMax);
849
+ const anchorRef = React2.useRef(null);
850
+ const latestText = React2.useRef(typeof value === "string" ? value : "");
851
+ const [isOpen, setIsOpen] = React2.useState(false);
852
+ const handleChange = React2.useCallback(
853
+ (text) => {
854
+ latestText.current = text;
855
+ onChangeText?.(text);
856
+ },
857
+ [onChangeText]
858
+ );
859
+ const handleFocus = React2.useCallback(
860
+ (event) => {
861
+ setIsOpen(true);
862
+ onFocus?.(event);
863
+ },
864
+ [onFocus]
865
+ );
866
+ const handleBlur = React2.useCallback(
867
+ (event) => {
868
+ if (latestText.current.trim() !== "") remember(latestText.current);
869
+ setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS);
870
+ onBlur?.(event);
871
+ },
872
+ [remember, onBlur]
873
+ );
874
+ const handleSubmit = React2.useCallback(
875
+ (event) => {
876
+ remember(latestText.current);
877
+ setIsOpen(false);
878
+ onSubmitEditing?.(event);
879
+ },
880
+ [remember, onSubmitEditing]
881
+ );
882
+ const pick = React2.useCallback(
883
+ (picked) => {
884
+ latestText.current = picked;
885
+ onChangeText?.(picked);
886
+ remember(picked);
887
+ setIsOpen(false);
888
+ },
889
+ [onChangeText, remember]
890
+ );
891
+ const options = recents.map((entry) => ({ label: entry, value: entry }));
892
+ const showMenu = isOpen && options.length > 0;
893
+ const recentTestID = testID !== void 0 ? `${testID}-recent` : "form-field-recent";
894
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: controlStyles.anchor, children: [
895
+ /* @__PURE__ */ jsxRuntime.jsx(
896
+ ThemedTextInput,
897
+ {
898
+ ...rest,
899
+ testID,
900
+ value,
901
+ onChangeText: handleChange,
902
+ onFocus: handleFocus,
903
+ onBlur: handleBlur,
904
+ onSubmitEditing: handleSubmit
905
+ }
906
+ ),
907
+ showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
908
+ AnchoredMenu,
909
+ {
910
+ accessibilityLabel: `Recent ${label} values`,
911
+ anchorRef,
912
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
913
+ onDismiss: () => setIsOpen(false),
914
+ onSelect: pick,
915
+ optionHint: `Fill ${label} with this recent value`,
916
+ options,
917
+ selectedValue: typeof value === "string" ? value : "",
918
+ testID: recentTestID,
919
+ usePortal: true
920
+ }
921
+ ) : null
922
+ ] });
923
+ }
615
924
  var FormField = ({
616
925
  label,
617
926
  labelHidden = false,
618
927
  required = false,
619
928
  error,
620
929
  containerStyle,
930
+ recentKey,
931
+ recentMax,
621
932
  ...textInputProps
622
933
  }) => /* @__PURE__ */ jsxRuntime.jsx(
623
934
  Field,
@@ -626,9 +937,8 @@ var FormField = ({
626
937
  error,
627
938
  label: labelHidden ? void 0 : label,
628
939
  required,
629
- children: ({ describedById, hasError, controlId }) => /* @__PURE__ */ jsxRuntime.jsx(
630
- ThemedTextInput,
631
- {
940
+ children: ({ describedById, hasError, controlId }) => {
941
+ const inputProps = {
632
942
  accessibilityHint: `Enter ${label}`,
633
943
  accessibilityLabel: label,
634
944
  describedById,
@@ -637,60 +947,24 @@ var FormField = ({
637
947
  requiredField: required,
638
948
  testID: "form-field-input",
639
949
  ...textInputProps
640
- }
641
- )
950
+ };
951
+ return recentKey !== void 0 && recentKey !== "" ? /* @__PURE__ */ jsxRuntime.jsx(RecentInputField, { label, recentKey, recentMax, ...inputProps }) : /* @__PURE__ */ jsxRuntime.jsx(ThemedTextInput, { ...inputProps });
952
+ }
642
953
  }
643
954
  );
955
+
956
+ // src/ChipSelector/constants.ts
644
957
  var TRANSPARENT_COLOR = "transparent";
645
- var WHITE_COLOR = "#fff";
958
+ var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
959
+ var CHIP_GUTTER = 8;
960
+ var DEFAULT_CHIP_TESTID_PREFIX = "chip-selector-chip";
646
961
  var SOLID_RADIUS = 20;
647
962
  var OUTLINE_RADIUS = 999;
648
963
  var OUTLINE_PAD_H = 12;
649
964
  var OUTLINE_PAD_V = 5;
650
965
  var OUTLINE_FONT_SIZE = 13;
651
- var OUTLINE_TINT_ALPHA = 0.1;
652
- var HEX_SHORT_LENGTH2 = 3;
653
- var HEX_RADIX2 = 16;
654
- var RGB_R_START = 0;
655
- var RGB_R_END = 2;
656
- var RGB_G_END = 4;
657
- var RGB_B_END = 6;
658
- var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
659
- var CHIP_GUTTER = 8;
660
- var DEFAULT_CHIP_TESTID_PREFIX = "chip-selector-chip";
661
- var ON_BRAND_SCALE = "onBrand";
662
- var MAIN_STEP = "500";
663
- function onBrandInk(palette) {
664
- const scales = palette;
665
- return scales[ON_BRAND_SCALE]?.[MAIN_STEP] ?? WHITE_COLOR;
666
- }
667
- function withAlpha2(hex, alpha) {
668
- const normalized = hex.replace("#", "");
669
- const isShort = normalized.length === HEX_SHORT_LENGTH2;
670
- const full = isShort ? normalized.split("").map((c) => c + c).join("") : normalized;
671
- const r = parseInt(full.slice(RGB_R_START, RGB_R_END), HEX_RADIX2);
672
- const g = parseInt(full.slice(RGB_R_END, RGB_G_END), HEX_RADIX2);
673
- const b = parseInt(full.slice(RGB_G_END, RGB_B_END), HEX_RADIX2);
674
- const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
675
- return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
676
- }
677
- var styles5 = reactNative.StyleSheet.create({
678
- chipContainer: {
679
- flexDirection: "row",
680
- flexWrap: "wrap",
681
- // `gap`, NOT per-chip margins plus a compensating negative container margin.
682
- //
683
- // The old form was `chipWrapper { marginRight: 8, marginBottom: 8 }` with
684
- // `chipContainer { marginBottom: -8 }`. Its comment blamed `Field`'s 16px container margin —
685
- // that was WRONG, and worth recording: F3 was briefed on the belief that the -8 compensated for
686
- // `Field` and had to move in lockstep with it. It did not. -8 cancelled the CHIP's own gutter
687
- // hanging off the last row, identically whether `Field` contributed 16, 0, or anything else.
688
- //
689
- // The pair is still the wrong tool: it holds only while the two constants agree, and it leaves
690
- // a real negative margin any measuring parent inherits. `gap` produces the byte-identical box
691
- // and is correct under BOTH of `Field`'s spacing models, not just the one it was tuned against.
692
- gap: CHIP_GUTTER
693
- },
966
+ var CHIP_ICON_GAP = 6;
967
+ var chipStyles = reactNative.StyleSheet.create({
694
968
  chip: {
695
969
  paddingHorizontal: 12,
696
970
  paddingVertical: 6,
@@ -704,28 +978,21 @@ var styles5 = reactNative.StyleSheet.create({
704
978
  borderRadius: OUTLINE_RADIUS,
705
979
  borderWidth: 1
706
980
  },
981
+ chipContent: { flexDirection: "row", alignItems: "center", gap: CHIP_ICON_GAP },
707
982
  chipText: {},
708
983
  chipTextOutline: { fontSize: OUTLINE_FONT_SIZE }
709
984
  });
710
- function chipIdentity(option, selected, config) {
711
- return {
712
- testID: `${config.prefix}-${String(option.value)}`,
713
- // The English default is retained verbatim: erevna/katalogos assert the exact string
714
- // `Selects Red`. Consumers that localize pass `optionAccessibilityHint`.
715
- accessibilityHint: config.hint ?? `Selects ${option.label}`,
716
- ariaPressed: config.multiple ? selected : void 0
717
- };
985
+ function ChipContent({ icon, label, textStyle }) {
986
+ const text = /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: textStyle, children: label });
987
+ if (icon === void 0 || icon === null)
988
+ return text;
989
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: chipStyles.chipContent, children: [
990
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { "aria-hidden": true, children: icon }),
991
+ text
992
+ ] });
718
993
  }
719
- function OutlineChip({
720
- option,
721
- selected,
722
- disabled,
723
- colors,
724
- testID,
725
- accessibilityHint,
726
- ariaPressed,
727
- onPress
728
- }) {
994
+ function OutlineChip(props) {
995
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
729
996
  const [hovered, setHovered] = React2__default.default.useState(false);
730
997
  const active = selected || hovered;
731
998
  const borderColor = active ? colors.primary : colors.border;
@@ -734,49 +1001,179 @@ function OutlineChip({
734
1001
  return /* @__PURE__ */ jsxRuntime.jsx(
735
1002
  reactNative.Pressable,
736
1003
  {
1004
+ ref: props.hostRef,
737
1005
  accessibilityHint,
738
1006
  accessibilityLabel: option.label,
739
- accessibilityRole: "button",
1007
+ accessibilityRole: props.accessibilityRole,
740
1008
  accessibilityState: { selected, disabled },
741
- "aria-pressed": ariaPressed,
1009
+ "aria-checked": props.ariaChecked,
1010
+ "aria-pressed": props.ariaPressed,
742
1011
  disabled,
743
1012
  hitSlop: CHIP_HIT_SLOP,
1013
+ tabIndex: props.tabIndex,
744
1014
  testID,
745
1015
  onHoverIn: () => setHovered(true),
746
1016
  onHoverOut: () => setHovered(false),
747
1017
  onPress,
748
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles5.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.chipText, styles5.chipTextOutline, { color: textColor }], children: option.label }) })
1018
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [chipStyles.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(
1019
+ ChipContent,
1020
+ {
1021
+ icon: option.icon,
1022
+ label: option.label,
1023
+ textStyle: [chipStyles.chipText, chipStyles.chipTextOutline, { color: textColor }]
1024
+ }
1025
+ ) })
749
1026
  }
750
1027
  );
751
1028
  }
752
- function SolidChip({
753
- option,
754
- selected,
755
- disabled,
756
- colors,
757
- testID,
758
- accessibilityHint,
759
- ariaPressed,
760
- onPress
761
- }) {
1029
+ function SolidChip(props) {
1030
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
762
1031
  const backgroundColor = selected ? colors.primary : TRANSPARENT_COLOR;
763
1032
  const textColor = selected ? colors.onPrimary : colors.textStrong;
1033
+ const rovingTabStop = { tabIndex: props.tabIndex };
764
1034
  return /* @__PURE__ */ jsxRuntime.jsx(
765
1035
  reactNative.TouchableOpacity,
766
1036
  {
1037
+ ref: props.hostRef,
767
1038
  accessibilityHint,
768
1039
  accessibilityLabel: option.label,
769
- accessibilityRole: "button",
1040
+ accessibilityRole: props.accessibilityRole,
770
1041
  accessibilityState: { selected, disabled },
771
- "aria-pressed": ariaPressed,
1042
+ "aria-checked": props.ariaChecked,
1043
+ "aria-pressed": props.ariaPressed,
772
1044
  disabled,
773
1045
  hitSlop: CHIP_HIT_SLOP,
774
1046
  testID,
775
1047
  onPress,
776
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles5.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.chipText, { color: textColor }], children: option.label }) })
1048
+ ...rovingTabStop,
1049
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [chipStyles.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(ChipContent, { icon: option.icon, label: option.label, textStyle: [chipStyles.chipText, { color: textColor }] }) })
777
1050
  }
778
1051
  );
779
1052
  }
1053
+
1054
+ // src/ChipSelector/utils/radioKeyboard.ts
1055
+ var SPACE_KEYS = [" ", "Spacebar"];
1056
+ var NEXT_KEYS = ["ArrowRight", "ArrowDown"];
1057
+ var PREVIOUS_KEYS = ["ArrowLeft", "ArrowUp"];
1058
+ var ROVING_TAB_STOP = 0;
1059
+ var ROVING_SKIPPED = -1;
1060
+ function radioKeyTarget(key, index, count) {
1061
+ const isOutOfRange = index < 0 || index >= count;
1062
+ if (isOutOfRange)
1063
+ return null;
1064
+ if (SPACE_KEYS.includes(key))
1065
+ return index;
1066
+ const isArrow = NEXT_KEYS.includes(key) || PREVIOUS_KEYS.includes(key);
1067
+ if (!isArrow || count === 1)
1068
+ return null;
1069
+ const step = NEXT_KEYS.includes(key) ? 1 : -1;
1070
+ return (index + step + count) % count;
1071
+ }
1072
+ function radioTabIndex(index, selectedIndex, disabled) {
1073
+ if (disabled)
1074
+ return ROVING_SKIPPED;
1075
+ const tabStop = selectedIndex >= 0 ? selectedIndex : 0;
1076
+ return index === tabStop ? ROVING_TAB_STOP : ROVING_SKIPPED;
1077
+ }
1078
+
1079
+ // src/ChipSelector/hooks/useRadioKeyboard.ts
1080
+ var NO_GROUP_PROPS = {};
1081
+ function useRadioKeyboard(config) {
1082
+ const { enabled, disabled, values, selectedIndex, onChange } = config;
1083
+ const nodes = React2__default.default.useRef([]);
1084
+ const chipRef = React2__default.default.useCallback((index) => (node) => {
1085
+ nodes.current[index] = node;
1086
+ }, []);
1087
+ const onKeyDown = (event) => {
1088
+ if (disabled)
1089
+ return;
1090
+ const index = nodes.current.indexOf(event.target);
1091
+ const target = radioKeyTarget(event.key, index, values.length);
1092
+ const nextValue = target === null ? void 0 : values[target];
1093
+ if (target === null || nextValue === void 0)
1094
+ return;
1095
+ event.preventDefault();
1096
+ if (target !== index)
1097
+ nodes.current[target]?.focus?.();
1098
+ onChange(nextValue);
1099
+ };
1100
+ return {
1101
+ chipRef,
1102
+ tabIndexFor: (index) => enabled ? radioTabIndex(index, selectedIndex, disabled) : void 0,
1103
+ // `onKeyDown` is a web-only View prop RN's types do not declare; RNW forwards it to the DOM.
1104
+ groupProps: enabled ? { onKeyDown } : NO_GROUP_PROPS
1105
+ };
1106
+ }
1107
+
1108
+ // src/ChipSelector/utils/chipColors.ts
1109
+ var WHITE_COLOR = "#fff";
1110
+ var OUTLINE_TINT_ALPHA = 0.1;
1111
+ var HEX_SHORT_LENGTH2 = 3;
1112
+ var HEX_RADIX2 = 16;
1113
+ var RGB_R_START = 0;
1114
+ var RGB_R_END = 2;
1115
+ var RGB_G_END = 4;
1116
+ var RGB_B_END = 6;
1117
+ var ON_BRAND_SCALE = "onBrand";
1118
+ var MAIN_STEP = "500";
1119
+ function onBrandInk(palette) {
1120
+ const scales = palette;
1121
+ return scales[ON_BRAND_SCALE]?.[MAIN_STEP] ?? WHITE_COLOR;
1122
+ }
1123
+ function withAlpha2(hex, alpha) {
1124
+ const normalized = hex.replace("#", "");
1125
+ const isShort = normalized.length === HEX_SHORT_LENGTH2;
1126
+ const full = isShort ? normalized.split("").map((c) => c + c).join("") : normalized;
1127
+ const r = parseInt(full.slice(RGB_R_START, RGB_R_END), HEX_RADIX2);
1128
+ const g = parseInt(full.slice(RGB_R_END, RGB_G_END), HEX_RADIX2);
1129
+ const b = parseInt(full.slice(RGB_G_END, RGB_B_END), HEX_RADIX2);
1130
+ const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
1131
+ return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
1132
+ }
1133
+ function buildChipColors(colors, palette) {
1134
+ const primary = palette.primary[500];
1135
+ return {
1136
+ border: colors.border,
1137
+ textStrong: colors.text,
1138
+ textMuted: colors.textSecondary,
1139
+ primary,
1140
+ onPrimary: onBrandInk(palette),
1141
+ muted: colors.background,
1142
+ tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
1143
+ };
1144
+ }
1145
+
1146
+ // src/ChipSelector/utils/chipIdentity.ts
1147
+ function chipIdentity(option, selected, config) {
1148
+ const isRadio = config.radio === true && !config.multiple;
1149
+ return {
1150
+ testID: `${config.prefix}-${String(option.value)}`,
1151
+ // The English default is retained verbatim: erevna/katalogos assert the exact string
1152
+ // `Selects Red`. Consumers that localize pass `optionAccessibilityHint`.
1153
+ accessibilityHint: config.hint ?? `Selects ${option.label}`,
1154
+ ariaPressed: config.multiple ? selected : void 0,
1155
+ accessibilityRole: isRadio ? "radio" : "button",
1156
+ ariaChecked: isRadio ? selected : void 0
1157
+ };
1158
+ }
1159
+ var styles5 = reactNative.StyleSheet.create({
1160
+ chipContainer: {
1161
+ flexDirection: "row",
1162
+ flexWrap: "wrap",
1163
+ // `gap`, NOT per-chip margins plus a compensating negative container margin.
1164
+ //
1165
+ // The old form was `chipWrapper { marginRight: 8, marginBottom: 8 }` with
1166
+ // `chipContainer { marginBottom: -8 }`. Its comment blamed `Field`'s 16px container margin —
1167
+ // that was WRONG, and worth recording: F3 was briefed on the belief that the -8 compensated for
1168
+ // `Field` and had to move in lockstep with it. It did not. -8 cancelled the CHIP's own gutter
1169
+ // hanging off the last row, identically whether `Field` contributed 16, 0, or anything else.
1170
+ //
1171
+ // The pair is still the wrong tool: it holds only while the two constants agree, and it leaves
1172
+ // a real negative margin any measuring parent inherits. `gap` produces the byte-identical box
1173
+ // and is correct under BOTH of `Field`'s spacing models, not just the one it was tuned against.
1174
+ gap: CHIP_GUTTER
1175
+ }
1176
+ });
780
1177
  var ChipSelector = ({
781
1178
  label,
782
1179
  options,
@@ -791,28 +1188,26 @@ var ChipSelector = ({
791
1188
  error,
792
1189
  testID,
793
1190
  chipTestIDPrefix = DEFAULT_CHIP_TESTID_PREFIX,
794
- optionAccessibilityHint
1191
+ optionAccessibilityHint,
1192
+ singleSelectRole = "button"
795
1193
  }) => {
1194
+ const isRadioGroup = !multiple && singleSelectRole === "radio";
796
1195
  const { theme } = uiFeedback.useUi();
797
1196
  const { colors, palette } = theme;
798
- const chipColors = React2__default.default.useMemo(() => {
799
- const primary = palette.primary["500"];
800
- return {
801
- border: colors.border,
802
- textStrong: colors.text,
803
- textMuted: colors.textSecondary,
804
- primary,
805
- onPrimary: onBrandInk(palette),
806
- muted: colors.background,
807
- tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
808
- };
809
- }, [colors.border, colors.text, colors.textSecondary, colors.background, palette]);
1197
+ const chipColors = React2__default.default.useMemo(() => buildChipColors(colors, palette), [colors, palette]);
810
1198
  function isSelected(optionValue) {
811
1199
  if (multiple && Array.isArray(value))
812
1200
  return value.includes(optionValue);
813
1201
  return value === optionValue;
814
1202
  }
815
- function renderChip(option) {
1203
+ const radio = useRadioKeyboard({
1204
+ enabled: isRadioGroup,
1205
+ disabled,
1206
+ values: options.map((option) => option.value),
1207
+ selectedIndex: options.findIndex((option) => isSelected(option.value)),
1208
+ onChange
1209
+ });
1210
+ function renderChip(option, index) {
816
1211
  const selected = isSelected(option.value);
817
1212
  const chipProps = {
818
1213
  colors: chipColors,
@@ -822,8 +1217,13 @@ var ChipSelector = ({
822
1217
  ...chipIdentity(option, selected, {
823
1218
  prefix: chipTestIDPrefix,
824
1219
  hint: optionAccessibilityHint,
825
- multiple
1220
+ multiple,
1221
+ radio: isRadioGroup
826
1222
  }),
1223
+ // Radio mode only: roving tab stop + host node for arrow-key focus moves. Both stay
1224
+ // `undefined` otherwise, so button / multi chips keep their existing tab order.
1225
+ tabIndex: radio.tabIndexFor(index),
1226
+ hostRef: isRadioGroup ? radio.chipRef(index) : void 0,
827
1227
  onPress: () => onChange(option.value)
828
1228
  };
829
1229
  const key = String(option.value);
@@ -838,7 +1238,16 @@ var ChipSelector = ({
838
1238
  required,
839
1239
  spacing,
840
1240
  testID,
841
- children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles5.chipContainer, ...webFieldA11y({ describedById, hasError }), children: options.map(renderChip) })
1241
+ children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(
1242
+ reactNative.View,
1243
+ {
1244
+ accessibilityRole: isRadioGroup ? "radiogroup" : void 0,
1245
+ style: styles5.chipContainer,
1246
+ ...webFieldA11y({ describedById, hasError }),
1247
+ ...radio.groupProps,
1248
+ children: options.map(renderChip)
1249
+ }
1250
+ )
842
1251
  }
843
1252
  );
844
1253
  };
@@ -896,8 +1305,7 @@ var FormSwitch = ({
896
1305
  {
897
1306
  accessibilityHint: accessibilityHint ?? label,
898
1307
  accessibilityLabel: label,
899
- accessibilityRole: "switch",
900
- accessibilityState: { checked: value },
1308
+ accessibilityState: { checked: value, disabled },
901
1309
  disabled,
902
1310
  testID,
903
1311
  value,
@@ -1009,140 +1417,6 @@ var FormCheckbox = ({
1009
1417
  }
1010
1418
  );
1011
1419
  };
1012
- var IS_WEB5 = reactNative.Platform.OS === "web";
1013
- var MENU_PORTAL_Z_INDEX = 1e3;
1014
- var MENU_TOP_GAP2 = 4;
1015
- function toDomNode(ref) {
1016
- const node = ref.current;
1017
- if (node === null || typeof node.getBoundingClientRect !== "function") return null;
1018
- return node;
1019
- }
1020
- function readAnchorRect(ref) {
1021
- if (!IS_WEB5) return null;
1022
- const node = toDomNode(ref);
1023
- if (node === null) return null;
1024
- const r = node.getBoundingClientRect();
1025
- return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
1026
- }
1027
- function buildPortalPopoverStyle(rect) {
1028
- return {
1029
- // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
1030
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
1031
- position: "fixed",
1032
- top: rect.bottom + MENU_TOP_GAP2,
1033
- left: rect.left,
1034
- width: rect.width,
1035
- zIndex: MENU_PORTAL_Z_INDEX
1036
- };
1037
- }
1038
- function useAnchorRect(ref, enabled) {
1039
- const [rect, setRect] = React2.useState(null);
1040
- React2.useLayoutEffect(() => {
1041
- if (!IS_WEB5 || !enabled) {
1042
- setRect(null);
1043
- return void 0;
1044
- }
1045
- let frame = null;
1046
- let disposed = false;
1047
- const apply = () => {
1048
- frame = null;
1049
- if (disposed) return;
1050
- setRect(readAnchorRect(ref));
1051
- };
1052
- const schedule = () => {
1053
- if (frame !== null) return;
1054
- frame = window.requestAnimationFrame(apply);
1055
- };
1056
- apply();
1057
- document.addEventListener("scroll", schedule, true);
1058
- window.addEventListener("resize", schedule);
1059
- const node = toDomNode(ref);
1060
- const observer = typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : null;
1061
- if (observer !== null) {
1062
- if (node !== null) observer.observe(node);
1063
- observer.observe(document.body);
1064
- }
1065
- return () => {
1066
- disposed = true;
1067
- if (frame !== null) window.cancelAnimationFrame(frame);
1068
- document.removeEventListener("scroll", schedule, true);
1069
- window.removeEventListener("resize", schedule);
1070
- if (observer !== null) observer.disconnect();
1071
- };
1072
- }, [ref, enabled]);
1073
- return IS_WEB5 && enabled ? rect : null;
1074
- }
1075
- var IS_WEB6 = typeof document !== "undefined";
1076
- var ESCAPE_KEY = "Escape";
1077
- var ACTIVE_WEIGHT = "700";
1078
- var IDLE_WEIGHT = "400";
1079
- function useDismiss(anchorRef, menuRef, onDismiss) {
1080
- React2.useEffect(() => {
1081
- if (!IS_WEB6) return void 0;
1082
- const onMouseDown = (event) => {
1083
- const anchorNode = anchorRef.current;
1084
- const menuNode = menuRef.current;
1085
- const target = event.target instanceof Node ? event.target : null;
1086
- const insideAnchor = anchorNode !== null && target !== null && anchorNode.contains(target);
1087
- const insideMenu = menuNode !== null && target !== null && menuNode.contains(target);
1088
- if (!insideAnchor && !insideMenu) onDismiss();
1089
- };
1090
- const onKeyDown = (event) => {
1091
- if (event.key === ESCAPE_KEY) onDismiss();
1092
- };
1093
- document.addEventListener("mousedown", onMouseDown);
1094
- document.addEventListener("keydown", onKeyDown);
1095
- return () => {
1096
- document.removeEventListener("mousedown", onMouseDown);
1097
- document.removeEventListener("keydown", onKeyDown);
1098
- };
1099
- }, [anchorRef, menuRef, onDismiss]);
1100
- }
1101
- function AnchoredMenu({
1102
- options,
1103
- selectedValue,
1104
- onSelect,
1105
- onDismiss,
1106
- colors,
1107
- optionHint,
1108
- testID,
1109
- anchorRef,
1110
- usePortal = true
1111
- }) {
1112
- const menuRef = React2__default.default.useRef(null);
1113
- useDismiss(anchorRef, menuRef, onDismiss);
1114
- const rect = useAnchorRect(anchorRef, usePortal);
1115
- const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
1116
- const menu = /* @__PURE__ */ jsxRuntime.jsx(
1117
- reactNative.View,
1118
- {
1119
- ref: menuRef,
1120
- accessibilityRole: "menu",
1121
- style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
1122
- testID: `${testID}-menu`,
1123
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", nestedScrollEnabled: true, children: options.map((opt) => {
1124
- const active = opt.value === selectedValue;
1125
- return /* @__PURE__ */ jsxRuntime.jsx(
1126
- reactNative.Pressable,
1127
- {
1128
- accessibilityRole: "menuitem",
1129
- accessibilityLabel: opt.label,
1130
- accessibilityHint: optionHint,
1131
- accessibilityState: { selected: active },
1132
- "aria-selected": active,
1133
- onPress: () => onSelect(opt.value),
1134
- style: controlStyles.option,
1135
- testID: `${testID}-option-${opt.value}`,
1136
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
1137
- },
1138
- opt.value
1139
- );
1140
- }) })
1141
- }
1142
- );
1143
- if (usePortal && IS_WEB6) return reactDom.createPortal(menu, document.body);
1144
- return menu;
1145
- }
1146
1420
  var CHEVRON_DOWN = "\u25BE";
1147
1421
  var CHEVRON_UP = "\u25B4";
1148
1422
  function SelectControl({
@@ -1244,7 +1518,7 @@ function isUnmatched(options, text) {
1244
1518
  if (needle === "") return false;
1245
1519
  return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1246
1520
  }
1247
- var CLOSE_DELAY_MS = 140;
1521
+ var CLOSE_DELAY_MS2 = 140;
1248
1522
  var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1249
1523
  var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1250
1524
  function TypeaheadControl({
@@ -1288,7 +1562,7 @@ function TypeaheadControl({
1288
1562
  [options, onChange]
1289
1563
  );
1290
1564
  const handleBlur = React2.useCallback(() => {
1291
- setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS);
1565
+ setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS2);
1292
1566
  }, []);
1293
1567
  const handleSubmit = React2.useCallback(() => {
1294
1568
  setIsOpen(false);
@@ -1407,6 +1681,7 @@ function DateRangeControl({
1407
1681
 
1408
1682
  exports.AnchoredMenu = AnchoredMenu;
1409
1683
  exports.ChipSelector = ChipSelector;
1684
+ exports.DEFAULT_MAX_RECENTS = DEFAULT_MAX_RECENTS;
1410
1685
  exports.DEFAULT_OPTIONAL_LABEL = DEFAULT_OPTIONAL_LABEL;
1411
1686
  exports.DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1412
1687
  exports.DEFAULT_TYPEAHEAD_MIN_CHARS = DEFAULT_TYPEAHEAD_MIN_CHARS;
@@ -1425,6 +1700,7 @@ exports.TypeaheadControl = TypeaheadControl;
1425
1700
  exports.controlStyles = controlStyles;
1426
1701
  exports.isUnmatched = isUnmatched;
1427
1702
  exports.suggestOptions = suggestOptions;
1703
+ exports.useRecentValues = useRecentValues;
1428
1704
  exports.useThemedInput = useThemedInput;
1429
1705
  //# sourceMappingURL=index.js.map
1430
1706
  //# sourceMappingURL=index.js.map