@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.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,
@@ -1582,6 +1679,30 @@ function DateRangeControl({
1582
1679
  ] });
1583
1680
  }
1584
1681
 
1682
+ // src/groupSelection/groupSelection.ts
1683
+ var NONE = 0;
1684
+ function countSelected(selected, groupKeys) {
1685
+ return groupKeys.filter((key) => selected.includes(key)).length;
1686
+ }
1687
+ function resolveGroupSelection(selected, groupKeys) {
1688
+ const count = countSelected(selected, groupKeys);
1689
+ if (count === NONE) return { all: false, some: false };
1690
+ return count === groupKeys.length ? { all: true, some: false } : { all: false, some: true };
1691
+ }
1692
+ function groupAriaChecked(state) {
1693
+ if (state.all) return true;
1694
+ return state.some ? "mixed" : false;
1695
+ }
1696
+ function toggleGroupSelection(selected, groupKeys, order) {
1697
+ const { all } = resolveGroupSelection(selected, groupKeys);
1698
+ if (all) return selected.filter((key) => !groupKeys.includes(key));
1699
+ const merged = [...selected];
1700
+ groupKeys.forEach((key) => {
1701
+ if (!merged.includes(key)) merged.push(key);
1702
+ });
1703
+ return order === void 0 ? merged : order.filter((key) => merged.includes(key));
1704
+ }
1705
+
1585
1706
  exports.AnchoredMenu = AnchoredMenu;
1586
1707
  exports.ChipSelector = ChipSelector;
1587
1708
  exports.DEFAULT_MAX_RECENTS = DEFAULT_MAX_RECENTS;
@@ -1601,8 +1722,11 @@ exports.SelectControl = SelectControl;
1601
1722
  exports.ThemedTextInput = ThemedTextInput;
1602
1723
  exports.TypeaheadControl = TypeaheadControl;
1603
1724
  exports.controlStyles = controlStyles;
1725
+ exports.groupAriaChecked = groupAriaChecked;
1604
1726
  exports.isUnmatched = isUnmatched;
1727
+ exports.resolveGroupSelection = resolveGroupSelection;
1605
1728
  exports.suggestOptions = suggestOptions;
1729
+ exports.toggleGroupSelection = toggleGroupSelection;
1606
1730
  exports.useRecentValues = useRecentValues;
1607
1731
  exports.useThemedInput = useThemedInput;
1608
1732
  //# sourceMappingURL=index.js.map