@dloizides/ui-forms 1.14.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0
4
+
5
+ **`FormSwitch` now emits `aria-checked` / `aria-disabled` on web; `ChipSelector` gained an option icon slot and opt-in radio semantics.**
6
+
7
+ `FormSwitch` (A11Y-ARIA-CHECKED-1): react-native-web 0.21 emits nothing from `accessibilityState` on web, so the
8
+ switch wrapper rendered `role="switch"` with no `aria-checked` - an invalid switch that announced no state. The
9
+ state is now passed as literal `aria-checked` (always) and `aria-disabled` (only when disabled) web props;
10
+ `accessibilityState` also carries `disabled` for native.
11
+
12
+ `ChipSelector`:
13
+ - `ChipOption.icon?: React.ReactNode` - rendered before the label inside the chip, wrapped `aria-hidden`. Options
14
+ without an icon render the exact same tree as before.
15
+ - `singleSelectRole?: 'button' | 'radio'` (type `ChipSingleSelectRole`). Default `button` = unchanged. With
16
+ `radio` and `multiple` unset, the group is `role="radiogroup"` and each chip `role="radio"` + `aria-checked`.
17
+ Ignored for multi-select. Trade-off: RN-web renders a radio chip as a `<div>`, which activates on Enter but not
18
+ Space and has no arrow-key roving focus.
19
+
20
+ **Additive and backward-compatible.** No prop set = identical DOM for every existing consumer.
21
+
3
22
  ## 1.14.0
4
23
 
5
24
  **`FormField` gained an opt-in `recentKey` — remember the last N submitted values and offer them on focus.**
package/dist/index.d.mts CHANGED
@@ -525,27 +525,24 @@ declare function useRecentValues(key: string, max?: number): RecentValues;
525
525
  declare const DEFAULT_MAX_RECENTS = 10;
526
526
 
527
527
  /**
528
- * Chip/pill selector for selecting one or multiple options.
529
- *
530
- * Two visual variants (both fully theme-driven):
531
- * - `solid` (default) — the original filled-pill look: selected = solid primary fill with
532
- * white ink. Unchanged, so existing consumers (erevna / katalogos / kefi) render identically.
533
- * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
534
- * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
535
- * Hover (web) lifts the border/ink to the primary colour.
536
- *
537
- * The label row, the error line and the container spacing come from the shared `Field` wrapper —
538
- * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
539
- * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
540
- * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
541
- * column and read as a different weight of text. Now there is exactly one label implementation.
528
+ * Public + internal types for ChipSelector. Split out of `ChipSelector.tsx` so the component, its
529
+ * two chip variants and the radio keyboard hook can share them without a circular import.
542
530
  */
543
531
 
544
532
  /** Visual variant. `solid` = filled selected pill (default); `outline` = v1 tinted-outline. */
545
533
  type ChipVariant = 'solid' | 'outline';
534
+ /**
535
+ * Role of a SINGLE-select chip. `button` (default) = today's semantics; `radio` = opt-in
536
+ * `radiogroup` of `radio` chips carrying `aria-checked`. Ignored when `multiple` is set.
537
+ * A string union rather than a `const enum` to match `ChipVariant`: a const enum does not
538
+ * survive the package boundary for consumers compiled with `isolatedModules`.
539
+ */
540
+ type ChipSingleSelectRole = 'button' | 'radio';
546
541
  interface ChipOption<T> {
547
542
  value: T;
548
543
  label: string;
544
+ /** Optional decorative icon rendered before the label; hidden from assistive tech. */
545
+ icon?: React.ReactNode;
549
546
  }
550
547
  interface ChipSelectorProps<T> {
551
548
  /** Optional — with no label, no label row is rendered (no phantom gap above the chips). */
@@ -585,8 +582,37 @@ interface ChipSelectorProps<T> {
585
582
  * consumers keep their current announcement.
586
583
  */
587
584
  optionAccessibilityHint?: string;
585
+ /**
586
+ * Opt-in radio semantics for a SINGLE-select group: the group becomes `role="radiogroup"` and
587
+ * each chip `role="radio"` + `aria-checked`, with the WAI-ARIA radio group keyboard pattern
588
+ * (Space activates, arrow keys move focus + selection with wrap, one roving tab stop).
589
+ * Defaults to `button` (unchanged behaviour). Ignored when `multiple` is set — a multi chip
590
+ * stays a toggle button with `aria-pressed`.
591
+ */
592
+ singleSelectRole?: ChipSingleSelectRole;
588
593
  }
589
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
594
+
595
+ /**
596
+ * Chip/pill selector for selecting one or multiple options.
597
+ *
598
+ * Two visual variants (both fully theme-driven):
599
+ * - `solid` (default) — the original filled-pill look: selected = solid primary fill with
600
+ * white ink. Unchanged, so existing consumers (erevna / katalogos / kefi) render identically.
601
+ * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
602
+ * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
603
+ * Hover (web) lifts the border/ink to the primary colour.
604
+ *
605
+ * The label row, the error line and the container spacing come from the shared `Field` wrapper —
606
+ * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
607
+ * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
608
+ * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
609
+ * column and read as a different weight of text. Now there is exactly one label implementation.
610
+ *
611
+ * Structure: chip variants in `components/`, the radio keyboard wiring in `hooks/`, and the pure
612
+ * resolvers (`chipIdentity`, colours, radio key logic) in `utils/`.
613
+ */
614
+
615
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, singleSelectRole, }: ChipSelectorProps<T>) => React.ReactElement;
590
616
 
591
617
  /**
592
618
  * Reusable form switch/toggle with an optional description.
@@ -944,4 +970,4 @@ declare function suggestOptions(options: readonly ControlOption[], query: string
944
970
  /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
945
971
  declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
946
972
 
947
- export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, type RecentValues, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useRecentValues, useThemedInput };
973
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipSingleSelectRole, type ChipVariant, type ControlOption, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, type RecentValues, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useRecentValues, useThemedInput };
package/dist/index.d.ts CHANGED
@@ -525,27 +525,24 @@ declare function useRecentValues(key: string, max?: number): RecentValues;
525
525
  declare const DEFAULT_MAX_RECENTS = 10;
526
526
 
527
527
  /**
528
- * Chip/pill selector for selecting one or multiple options.
529
- *
530
- * Two visual variants (both fully theme-driven):
531
- * - `solid` (default) — the original filled-pill look: selected = solid primary fill with
532
- * white ink. Unchanged, so existing consumers (erevna / katalogos / kefi) render identically.
533
- * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
534
- * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
535
- * Hover (web) lifts the border/ink to the primary colour.
536
- *
537
- * The label row, the error line and the container spacing come from the shared `Field` wrapper —
538
- * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
539
- * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
540
- * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
541
- * column and read as a different weight of text. Now there is exactly one label implementation.
528
+ * Public + internal types for ChipSelector. Split out of `ChipSelector.tsx` so the component, its
529
+ * two chip variants and the radio keyboard hook can share them without a circular import.
542
530
  */
543
531
 
544
532
  /** Visual variant. `solid` = filled selected pill (default); `outline` = v1 tinted-outline. */
545
533
  type ChipVariant = 'solid' | 'outline';
534
+ /**
535
+ * Role of a SINGLE-select chip. `button` (default) = today's semantics; `radio` = opt-in
536
+ * `radiogroup` of `radio` chips carrying `aria-checked`. Ignored when `multiple` is set.
537
+ * A string union rather than a `const enum` to match `ChipVariant`: a const enum does not
538
+ * survive the package boundary for consumers compiled with `isolatedModules`.
539
+ */
540
+ type ChipSingleSelectRole = 'button' | 'radio';
546
541
  interface ChipOption<T> {
547
542
  value: T;
548
543
  label: string;
544
+ /** Optional decorative icon rendered before the label; hidden from assistive tech. */
545
+ icon?: React.ReactNode;
549
546
  }
550
547
  interface ChipSelectorProps<T> {
551
548
  /** Optional — with no label, no label row is rendered (no phantom gap above the chips). */
@@ -585,8 +582,37 @@ interface ChipSelectorProps<T> {
585
582
  * consumers keep their current announcement.
586
583
  */
587
584
  optionAccessibilityHint?: string;
585
+ /**
586
+ * Opt-in radio semantics for a SINGLE-select group: the group becomes `role="radiogroup"` and
587
+ * each chip `role="radio"` + `aria-checked`, with the WAI-ARIA radio group keyboard pattern
588
+ * (Space activates, arrow keys move focus + selection with wrap, one roving tab stop).
589
+ * Defaults to `button` (unchanged behaviour). Ignored when `multiple` is set — a multi chip
590
+ * stays a toggle button with `aria-pressed`.
591
+ */
592
+ singleSelectRole?: ChipSingleSelectRole;
588
593
  }
589
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, }: ChipSelectorProps<T>) => React.ReactElement;
594
+
595
+ /**
596
+ * Chip/pill selector for selecting one or multiple options.
597
+ *
598
+ * Two visual variants (both fully theme-driven):
599
+ * - `solid` (default) — the original filled-pill look: selected = solid primary fill with
600
+ * white ink. Unchanged, so existing consumers (erevna / katalogos / kefi) render identically.
601
+ * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
602
+ * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
603
+ * Hover (web) lifts the border/ink to the primary colour.
604
+ *
605
+ * The label row, the error line and the container spacing come from the shared `Field` wrapper —
606
+ * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
607
+ * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
608
+ * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
609
+ * column and read as a different weight of text. Now there is exactly one label implementation.
610
+ *
611
+ * Structure: chip variants in `components/`, the radio keyboard wiring in `hooks/`, and the pure
612
+ * resolvers (`chipIdentity`, colours, radio key logic) in `utils/`.
613
+ */
614
+
615
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, spacing, required, error, testID, chipTestIDPrefix, optionAccessibilityHint, singleSelectRole, }: ChipSelectorProps<T>) => React.ReactElement;
590
616
 
591
617
  /**
592
618
  * Reusable form switch/toggle with an optional description.
@@ -944,4 +970,4 @@ declare function suggestOptions(options: readonly ControlOption[], query: string
944
970
  /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
945
971
  declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
946
972
 
947
- export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, type RecentValues, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useRecentValues, useThemedInput };
973
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipSingleSelectRole, type ChipVariant, type ControlOption, DEFAULT_MAX_RECENTS, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, type FieldSpacing, FormActions, type FormActionsProps, FormCell, type FormCellProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormGrid, type FormGridProps, FormSection, type FormSectionProps, FormSwitch, type FormSwitchProps, type RecentValues, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useRecentValues, useThemedInput };
package/dist/index.js CHANGED
@@ -952,56 +952,19 @@ var FormField = ({
952
952
  }
953
953
  }
954
954
  );
955
+
956
+ // src/ChipSelector/constants.ts
955
957
  var TRANSPARENT_COLOR = "transparent";
956
- 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";
957
961
  var SOLID_RADIUS = 20;
958
962
  var OUTLINE_RADIUS = 999;
959
963
  var OUTLINE_PAD_H = 12;
960
964
  var OUTLINE_PAD_V = 5;
961
965
  var OUTLINE_FONT_SIZE = 13;
962
- var OUTLINE_TINT_ALPHA = 0.1;
963
- var HEX_SHORT_LENGTH2 = 3;
964
- var HEX_RADIX2 = 16;
965
- var RGB_R_START = 0;
966
- var RGB_R_END = 2;
967
- var RGB_G_END = 4;
968
- var RGB_B_END = 6;
969
- var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
970
- var CHIP_GUTTER = 8;
971
- var DEFAULT_CHIP_TESTID_PREFIX = "chip-selector-chip";
972
- var ON_BRAND_SCALE = "onBrand";
973
- var MAIN_STEP = "500";
974
- function onBrandInk(palette) {
975
- const scales = palette;
976
- return scales[ON_BRAND_SCALE]?.[MAIN_STEP] ?? WHITE_COLOR;
977
- }
978
- function withAlpha2(hex, alpha) {
979
- const normalized = hex.replace("#", "");
980
- const isShort = normalized.length === HEX_SHORT_LENGTH2;
981
- const full = isShort ? normalized.split("").map((c) => c + c).join("") : normalized;
982
- const r = parseInt(full.slice(RGB_R_START, RGB_R_END), HEX_RADIX2);
983
- const g = parseInt(full.slice(RGB_R_END, RGB_G_END), HEX_RADIX2);
984
- const b = parseInt(full.slice(RGB_G_END, RGB_B_END), HEX_RADIX2);
985
- const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
986
- return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
987
- }
988
- var styles5 = reactNative.StyleSheet.create({
989
- chipContainer: {
990
- flexDirection: "row",
991
- flexWrap: "wrap",
992
- // `gap`, NOT per-chip margins plus a compensating negative container margin.
993
- //
994
- // The old form was `chipWrapper { marginRight: 8, marginBottom: 8 }` with
995
- // `chipContainer { marginBottom: -8 }`. Its comment blamed `Field`'s 16px container margin —
996
- // that was WRONG, and worth recording: F3 was briefed on the belief that the -8 compensated for
997
- // `Field` and had to move in lockstep with it. It did not. -8 cancelled the CHIP's own gutter
998
- // hanging off the last row, identically whether `Field` contributed 16, 0, or anything else.
999
- //
1000
- // The pair is still the wrong tool: it holds only while the two constants agree, and it leaves
1001
- // a real negative margin any measuring parent inherits. `gap` produces the byte-identical box
1002
- // and is correct under BOTH of `Field`'s spacing models, not just the one it was tuned against.
1003
- gap: CHIP_GUTTER
1004
- },
966
+ var CHIP_ICON_GAP = 6;
967
+ var chipStyles = reactNative.StyleSheet.create({
1005
968
  chip: {
1006
969
  paddingHorizontal: 12,
1007
970
  paddingVertical: 6,
@@ -1015,28 +978,21 @@ var styles5 = reactNative.StyleSheet.create({
1015
978
  borderRadius: OUTLINE_RADIUS,
1016
979
  borderWidth: 1
1017
980
  },
981
+ chipContent: { flexDirection: "row", alignItems: "center", gap: CHIP_ICON_GAP },
1018
982
  chipText: {},
1019
983
  chipTextOutline: { fontSize: OUTLINE_FONT_SIZE }
1020
984
  });
1021
- function chipIdentity(option, selected, config) {
1022
- return {
1023
- testID: `${config.prefix}-${String(option.value)}`,
1024
- // The English default is retained verbatim: erevna/katalogos assert the exact string
1025
- // `Selects Red`. Consumers that localize pass `optionAccessibilityHint`.
1026
- accessibilityHint: config.hint ?? `Selects ${option.label}`,
1027
- ariaPressed: config.multiple ? selected : void 0
1028
- };
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
+ ] });
1029
993
  }
1030
- function OutlineChip({
1031
- option,
1032
- selected,
1033
- disabled,
1034
- colors,
1035
- testID,
1036
- accessibilityHint,
1037
- ariaPressed,
1038
- onPress
1039
- }) {
994
+ function OutlineChip(props) {
995
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
1040
996
  const [hovered, setHovered] = React2__default.default.useState(false);
1041
997
  const active = selected || hovered;
1042
998
  const borderColor = active ? colors.primary : colors.border;
@@ -1045,49 +1001,179 @@ function OutlineChip({
1045
1001
  return /* @__PURE__ */ jsxRuntime.jsx(
1046
1002
  reactNative.Pressable,
1047
1003
  {
1004
+ ref: props.hostRef,
1048
1005
  accessibilityHint,
1049
1006
  accessibilityLabel: option.label,
1050
- accessibilityRole: "button",
1007
+ accessibilityRole: props.accessibilityRole,
1051
1008
  accessibilityState: { selected, disabled },
1052
- "aria-pressed": ariaPressed,
1009
+ "aria-checked": props.ariaChecked,
1010
+ "aria-pressed": props.ariaPressed,
1053
1011
  disabled,
1054
1012
  hitSlop: CHIP_HIT_SLOP,
1013
+ tabIndex: props.tabIndex,
1055
1014
  testID,
1056
1015
  onHoverIn: () => setHovered(true),
1057
1016
  onHoverOut: () => setHovered(false),
1058
1017
  onPress,
1059
- 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
+ ) })
1060
1026
  }
1061
1027
  );
1062
1028
  }
1063
- function SolidChip({
1064
- option,
1065
- selected,
1066
- disabled,
1067
- colors,
1068
- testID,
1069
- accessibilityHint,
1070
- ariaPressed,
1071
- onPress
1072
- }) {
1029
+ function SolidChip(props) {
1030
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
1073
1031
  const backgroundColor = selected ? colors.primary : TRANSPARENT_COLOR;
1074
1032
  const textColor = selected ? colors.onPrimary : colors.textStrong;
1033
+ const rovingTabStop = { tabIndex: props.tabIndex };
1075
1034
  return /* @__PURE__ */ jsxRuntime.jsx(
1076
1035
  reactNative.TouchableOpacity,
1077
1036
  {
1037
+ ref: props.hostRef,
1078
1038
  accessibilityHint,
1079
1039
  accessibilityLabel: option.label,
1080
- accessibilityRole: "button",
1040
+ accessibilityRole: props.accessibilityRole,
1081
1041
  accessibilityState: { selected, disabled },
1082
- "aria-pressed": ariaPressed,
1042
+ "aria-checked": props.ariaChecked,
1043
+ "aria-pressed": props.ariaPressed,
1083
1044
  disabled,
1084
1045
  hitSlop: CHIP_HIT_SLOP,
1085
1046
  testID,
1086
1047
  onPress,
1087
- 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 }] }) })
1088
1050
  }
1089
1051
  );
1090
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
+ });
1091
1177
  var ChipSelector = ({
1092
1178
  label,
1093
1179
  options,
@@ -1102,28 +1188,26 @@ var ChipSelector = ({
1102
1188
  error,
1103
1189
  testID,
1104
1190
  chipTestIDPrefix = DEFAULT_CHIP_TESTID_PREFIX,
1105
- optionAccessibilityHint
1191
+ optionAccessibilityHint,
1192
+ singleSelectRole = "button"
1106
1193
  }) => {
1194
+ const isRadioGroup = !multiple && singleSelectRole === "radio";
1107
1195
  const { theme } = uiFeedback.useUi();
1108
1196
  const { colors, palette } = theme;
1109
- const chipColors = React2__default.default.useMemo(() => {
1110
- const primary = palette.primary["500"];
1111
- return {
1112
- border: colors.border,
1113
- textStrong: colors.text,
1114
- textMuted: colors.textSecondary,
1115
- primary,
1116
- onPrimary: onBrandInk(palette),
1117
- muted: colors.background,
1118
- tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
1119
- };
1120
- }, [colors.border, colors.text, colors.textSecondary, colors.background, palette]);
1197
+ const chipColors = React2__default.default.useMemo(() => buildChipColors(colors, palette), [colors, palette]);
1121
1198
  function isSelected(optionValue) {
1122
1199
  if (multiple && Array.isArray(value))
1123
1200
  return value.includes(optionValue);
1124
1201
  return value === optionValue;
1125
1202
  }
1126
- 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) {
1127
1211
  const selected = isSelected(option.value);
1128
1212
  const chipProps = {
1129
1213
  colors: chipColors,
@@ -1133,8 +1217,13 @@ var ChipSelector = ({
1133
1217
  ...chipIdentity(option, selected, {
1134
1218
  prefix: chipTestIDPrefix,
1135
1219
  hint: optionAccessibilityHint,
1136
- multiple
1220
+ multiple,
1221
+ radio: isRadioGroup
1137
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,
1138
1227
  onPress: () => onChange(option.value)
1139
1228
  };
1140
1229
  const key = String(option.value);
@@ -1149,7 +1238,16 @@ var ChipSelector = ({
1149
1238
  required,
1150
1239
  spacing,
1151
1240
  testID,
1152
- 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
+ )
1153
1251
  }
1154
1252
  );
1155
1253
  };
@@ -1207,8 +1305,7 @@ var FormSwitch = ({
1207
1305
  {
1208
1306
  accessibilityHint: accessibilityHint ?? label,
1209
1307
  accessibilityLabel: label,
1210
- accessibilityRole: "switch",
1211
- accessibilityState: { checked: value },
1308
+ accessibilityState: { checked: value, disabled },
1212
1309
  disabled,
1213
1310
  testID,
1214
1311
  value,