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