@dloizides/ui-forms 1.14.0 → 1.16.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
@@ -946,56 +946,19 @@ var FormField = ({
946
946
  }
947
947
  }
948
948
  );
949
+
950
+ // src/ChipSelector/constants.ts
949
951
  var TRANSPARENT_COLOR = "transparent";
950
- 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";
951
955
  var SOLID_RADIUS = 20;
952
956
  var OUTLINE_RADIUS = 999;
953
957
  var OUTLINE_PAD_H = 12;
954
958
  var OUTLINE_PAD_V = 5;
955
959
  var OUTLINE_FONT_SIZE = 13;
956
- var OUTLINE_TINT_ALPHA = 0.1;
957
- var HEX_SHORT_LENGTH2 = 3;
958
- var HEX_RADIX2 = 16;
959
- var RGB_R_START = 0;
960
- var RGB_R_END = 2;
961
- var RGB_G_END = 4;
962
- var RGB_B_END = 6;
963
- var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
964
- var CHIP_GUTTER = 8;
965
- var DEFAULT_CHIP_TESTID_PREFIX = "chip-selector-chip";
966
- var ON_BRAND_SCALE = "onBrand";
967
- var MAIN_STEP = "500";
968
- function onBrandInk(palette) {
969
- const scales = palette;
970
- return scales[ON_BRAND_SCALE]?.[MAIN_STEP] ?? WHITE_COLOR;
971
- }
972
- function withAlpha2(hex, alpha) {
973
- const normalized = hex.replace("#", "");
974
- const isShort = normalized.length === HEX_SHORT_LENGTH2;
975
- const full = isShort ? normalized.split("").map((c) => c + c).join("") : normalized;
976
- const r = parseInt(full.slice(RGB_R_START, RGB_R_END), HEX_RADIX2);
977
- const g = parseInt(full.slice(RGB_R_END, RGB_G_END), HEX_RADIX2);
978
- const b = parseInt(full.slice(RGB_G_END, RGB_B_END), HEX_RADIX2);
979
- const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
980
- return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
981
- }
982
- var styles5 = StyleSheet.create({
983
- chipContainer: {
984
- flexDirection: "row",
985
- flexWrap: "wrap",
986
- // `gap`, NOT per-chip margins plus a compensating negative container margin.
987
- //
988
- // The old form was `chipWrapper { marginRight: 8, marginBottom: 8 }` with
989
- // `chipContainer { marginBottom: -8 }`. Its comment blamed `Field`'s 16px container margin —
990
- // that was WRONG, and worth recording: F3 was briefed on the belief that the -8 compensated for
991
- // `Field` and had to move in lockstep with it. It did not. -8 cancelled the CHIP's own gutter
992
- // hanging off the last row, identically whether `Field` contributed 16, 0, or anything else.
993
- //
994
- // The pair is still the wrong tool: it holds only while the two constants agree, and it leaves
995
- // a real negative margin any measuring parent inherits. `gap` produces the byte-identical box
996
- // and is correct under BOTH of `Field`'s spacing models, not just the one it was tuned against.
997
- gap: CHIP_GUTTER
998
- },
960
+ var CHIP_ICON_GAP = 6;
961
+ var chipStyles = StyleSheet.create({
999
962
  chip: {
1000
963
  paddingHorizontal: 12,
1001
964
  paddingVertical: 6,
@@ -1009,28 +972,21 @@ var styles5 = StyleSheet.create({
1009
972
  borderRadius: OUTLINE_RADIUS,
1010
973
  borderWidth: 1
1011
974
  },
975
+ chipContent: { flexDirection: "row", alignItems: "center", gap: CHIP_ICON_GAP },
1012
976
  chipText: {},
1013
977
  chipTextOutline: { fontSize: OUTLINE_FONT_SIZE }
1014
978
  });
1015
- function chipIdentity(option, selected, config) {
1016
- return {
1017
- testID: `${config.prefix}-${String(option.value)}`,
1018
- // The English default is retained verbatim: erevna/katalogos assert the exact string
1019
- // `Selects Red`. Consumers that localize pass `optionAccessibilityHint`.
1020
- accessibilityHint: config.hint ?? `Selects ${option.label}`,
1021
- ariaPressed: config.multiple ? selected : void 0
1022
- };
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
+ ] });
1023
987
  }
1024
- function OutlineChip({
1025
- option,
1026
- selected,
1027
- disabled,
1028
- colors,
1029
- testID,
1030
- accessibilityHint,
1031
- ariaPressed,
1032
- onPress
1033
- }) {
988
+ function OutlineChip(props) {
989
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
1034
990
  const [hovered, setHovered] = React2.useState(false);
1035
991
  const active = selected || hovered;
1036
992
  const borderColor = active ? colors.primary : colors.border;
@@ -1039,49 +995,179 @@ function OutlineChip({
1039
995
  return /* @__PURE__ */ jsx(
1040
996
  Pressable,
1041
997
  {
998
+ ref: props.hostRef,
1042
999
  accessibilityHint,
1043
1000
  accessibilityLabel: option.label,
1044
- accessibilityRole: "button",
1001
+ accessibilityRole: props.accessibilityRole,
1045
1002
  accessibilityState: { selected, disabled },
1046
- "aria-pressed": ariaPressed,
1003
+ "aria-checked": props.ariaChecked,
1004
+ "aria-pressed": props.ariaPressed,
1047
1005
  disabled,
1048
1006
  hitSlop: CHIP_HIT_SLOP,
1007
+ tabIndex: props.tabIndex,
1049
1008
  testID,
1050
1009
  onHoverIn: () => setHovered(true),
1051
1010
  onHoverOut: () => setHovered(false),
1052
1011
  onPress,
1053
- 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
+ ) })
1054
1020
  }
1055
1021
  );
1056
1022
  }
1057
- function SolidChip({
1058
- option,
1059
- selected,
1060
- disabled,
1061
- colors,
1062
- testID,
1063
- accessibilityHint,
1064
- ariaPressed,
1065
- onPress
1066
- }) {
1023
+ function SolidChip(props) {
1024
+ const { option, selected, disabled, colors, testID, accessibilityHint, onPress } = props;
1067
1025
  const backgroundColor = selected ? colors.primary : TRANSPARENT_COLOR;
1068
1026
  const textColor = selected ? colors.onPrimary : colors.textStrong;
1027
+ const rovingTabStop = { tabIndex: props.tabIndex };
1069
1028
  return /* @__PURE__ */ jsx(
1070
1029
  TouchableOpacity,
1071
1030
  {
1031
+ ref: props.hostRef,
1072
1032
  accessibilityHint,
1073
1033
  accessibilityLabel: option.label,
1074
- accessibilityRole: "button",
1034
+ accessibilityRole: props.accessibilityRole,
1075
1035
  accessibilityState: { selected, disabled },
1076
- "aria-pressed": ariaPressed,
1036
+ "aria-checked": props.ariaChecked,
1037
+ "aria-pressed": props.ariaPressed,
1077
1038
  disabled,
1078
1039
  hitSlop: CHIP_HIT_SLOP,
1079
1040
  testID,
1080
1041
  onPress,
1081
- 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 }] }) })
1082
1044
  }
1083
1045
  );
1084
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
+ });
1085
1171
  var ChipSelector = ({
1086
1172
  label,
1087
1173
  options,
@@ -1096,28 +1182,26 @@ var ChipSelector = ({
1096
1182
  error,
1097
1183
  testID,
1098
1184
  chipTestIDPrefix = DEFAULT_CHIP_TESTID_PREFIX,
1099
- optionAccessibilityHint
1185
+ optionAccessibilityHint,
1186
+ singleSelectRole = "button"
1100
1187
  }) => {
1188
+ const isRadioGroup = !multiple && singleSelectRole === "radio";
1101
1189
  const { theme } = useUi();
1102
1190
  const { colors, palette } = theme;
1103
- const chipColors = React2.useMemo(() => {
1104
- const primary = palette.primary["500"];
1105
- return {
1106
- border: colors.border,
1107
- textStrong: colors.text,
1108
- textMuted: colors.textSecondary,
1109
- primary,
1110
- onPrimary: onBrandInk(palette),
1111
- muted: colors.background,
1112
- tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
1113
- };
1114
- }, [colors.border, colors.text, colors.textSecondary, colors.background, palette]);
1191
+ const chipColors = React2.useMemo(() => buildChipColors(colors, palette), [colors, palette]);
1115
1192
  function isSelected(optionValue) {
1116
1193
  if (multiple && Array.isArray(value))
1117
1194
  return value.includes(optionValue);
1118
1195
  return value === optionValue;
1119
1196
  }
1120
- 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) {
1121
1205
  const selected = isSelected(option.value);
1122
1206
  const chipProps = {
1123
1207
  colors: chipColors,
@@ -1127,8 +1211,13 @@ var ChipSelector = ({
1127
1211
  ...chipIdentity(option, selected, {
1128
1212
  prefix: chipTestIDPrefix,
1129
1213
  hint: optionAccessibilityHint,
1130
- multiple
1214
+ multiple,
1215
+ radio: isRadioGroup
1131
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,
1132
1221
  onPress: () => onChange(option.value)
1133
1222
  };
1134
1223
  const key = String(option.value);
@@ -1143,7 +1232,16 @@ var ChipSelector = ({
1143
1232
  required,
1144
1233
  spacing,
1145
1234
  testID,
1146
- 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
+ )
1147
1245
  }
1148
1246
  );
1149
1247
  };
@@ -1201,8 +1299,7 @@ var FormSwitch = ({
1201
1299
  {
1202
1300
  accessibilityHint: accessibilityHint ?? label,
1203
1301
  accessibilityLabel: label,
1204
- accessibilityRole: "switch",
1205
- accessibilityState: { checked: value },
1302
+ accessibilityState: { checked: value, disabled },
1206
1303
  disabled,
1207
1304
  testID,
1208
1305
  value,
@@ -1576,6 +1673,30 @@ function DateRangeControl({
1576
1673
  ] });
1577
1674
  }
1578
1675
 
1579
- 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 };
1676
+ // src/groupSelection/groupSelection.ts
1677
+ var NONE = 0;
1678
+ function countSelected(selected, groupKeys) {
1679
+ return groupKeys.filter((key) => selected.includes(key)).length;
1680
+ }
1681
+ function resolveGroupSelection(selected, groupKeys) {
1682
+ const count = countSelected(selected, groupKeys);
1683
+ if (count === NONE) return { all: false, some: false };
1684
+ return count === groupKeys.length ? { all: true, some: false } : { all: false, some: true };
1685
+ }
1686
+ function groupAriaChecked(state) {
1687
+ if (state.all) return true;
1688
+ return state.some ? "mixed" : false;
1689
+ }
1690
+ function toggleGroupSelection(selected, groupKeys, order) {
1691
+ const { all } = resolveGroupSelection(selected, groupKeys);
1692
+ if (all) return selected.filter((key) => !groupKeys.includes(key));
1693
+ const merged = [...selected];
1694
+ groupKeys.forEach((key) => {
1695
+ if (!merged.includes(key)) merged.push(key);
1696
+ });
1697
+ return order === void 0 ? merged : order.filter((key) => merged.includes(key));
1698
+ }
1699
+
1700
+ 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, groupAriaChecked, isUnmatched, resolveGroupSelection, suggestOptions, toggleGroupSelection, useRecentValues, useThemedInput };
1580
1701
  //# sourceMappingURL=index.mjs.map
1581
1702
  //# sourceMappingURL=index.mjs.map