@dloizides/ui-forms 1.5.0 → 1.6.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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0
4
+
5
+ - **`ChipSelector` now composes `Field` internally — its label was drifting.** It rendered its OWN
6
+ `<Text>` label whose metrics differed from `Field`'s on three of four axes: `marginBottom: 8`
7
+ (vs 4), NO `fontSize` at all so it fell back to RN's ~14 (vs 13), and `colors.text`
8
+ (vs `textSecondary`). A `ChipSelector` next to a `FormField` in a row/grid therefore started a
9
+ different distance down the column and read as a different weight of text. Found at 4 sites in
10
+ aml-v2 (`TenantWatchlistsEditor`, `CoverageSelector`, `CustomCoverageFields`,
11
+ `AdvancedMatching`); fixing it in the package fixes every consumer with zero app churn.
12
+ The local label styles are deleted — there is now exactly one label implementation in the kit.
13
+ - **`ChipSelectorProps` is unchanged and fully backward-compatible** — every existing prop keeps
14
+ its name, type and default, and `label` stays OPTIONAL. With no label (or an empty one) NO label
15
+ row is rendered, so composing `Field` cannot introduce a phantom gap above the chips.
16
+ - **`ChipSelector` gains `required`, `error` and `testID`** (all optional, all additive) now that
17
+ `Field` provides them for free. `error` renders the same `role="alert"` line as `FormField` and
18
+ is tied to the chip group via `aria-describedby` + `aria-invalid` (web only; no-op on native).
19
+ - **Spacing:** a `ChipSelector` now carries `Field`'s `marginBottom: 16` like every other field
20
+ (it previously had none). The chips' own 8px trailing gutter is cancelled on the group so the
21
+ block's total bottom spacing is exactly 16 — the same as a `FormField`, not 24. Pass
22
+ `containerStyle` to override, as before.
23
+ - **`Field`'s `label` is now optional** (widened from `label: string`, so existing callers are
24
+ unaffected). An absent or empty label renders no label row, letting a control whose label lives
25
+ elsewhere still use the shared shell for its error line and spacing.
26
+ - `FormSwitch` / `FormCheckbox` are deliberately NOT changed — see `src/labelRoles.test.tsx`.
27
+ Their labels are INLINE control labels (beside the control in a row, the row's primary text),
28
+ not field headers stacked above a control, so they have no alignment defect to fix and `Field`'s
29
+ smaller/dimmer header treatment would be a regression. They were already identical to each
30
+ other; a new test now enforces that they stay so.
31
+
3
32
  ## 1.5.0
4
33
 
5
34
  - **Add `Field` — the generic label-over-control wrapper.** `FormField` is hard-wired to a text
package/README.md CHANGED
@@ -1,75 +1,107 @@
1
- # @dloizides/ui-forms
2
-
3
- Themable, brand-agnostic React Native (RN-web) **form** components for the dloizides.com
4
- portfolio: `Field`, `FormField`, `ChipSelector`, `FormSwitch`. They read theme colours from the shared
5
- `@dloizides/ui-feedback` UI context (`useUi`) — mount one `UiProvider` / `FeedbackUiProvider`
6
- at your app root and these components pick up your theme automatically.
7
-
8
- ## Install
9
-
10
- ```bash
11
- npm install @dloizides/ui-forms @dloizides/ui-feedback
12
- ```
13
-
14
- Peer dependencies: `@dloizides/ui-feedback >= 1.1.0`, `react >= 18`, `react-native >= 0.74`
15
- (use `react-native-web` on web).
16
-
17
- ## Usage
18
-
19
- ```tsx
20
- import { FormField, ChipSelector, FormSwitch } from '@dloizides/ui-forms';
21
-
22
- <FormField label="Email" required error={errors.email} value={email} onChangeText={setEmail} />
23
- <ChipSelector label="Plan" options={plans} value={plan} onChange={setPlan} />
24
- <FormSwitch label="Email notifications" value={notify} onValueChange={setNotify} />
25
- ```
26
-
27
- ## `Field` — label any control, not just a text input
28
-
29
- `FormField` is hard-wired to a text input. For **any other control** — a dropdown, a date picker,
30
- a chip selector — wrap it in `Field` to get the identical label row, required mark, error line and
31
- bottom spacing. This is what keeps a dropdown aligned with the `FormField` next to it: without it
32
- the dropdown's box starts a label-row higher and the row loses its vertical rhythm.
33
-
34
- ```tsx
35
- import { Field } from '@dloizides/ui-forms';
36
-
37
- <Field label="Currency" required error={errors.currency}>
38
- <ModalDropdown options={currencies} value={currency} onChange={setCurrency} />
39
- </Field>
40
- ```
41
-
42
- `FormField` composes `Field` internally, so the two can never drift out of alignment.
43
-
44
- To wire the error line into a custom control's own `aria-describedby` / invalid state, pass a
45
- **function** child — it receives `{ describedById, hasError }` (`describedById` is `undefined`
46
- when there is no error):
47
-
48
- ```tsx
49
- <Field label="Country" error={errors.country}>
50
- {({ describedById, hasError }) => (
51
- <CountryPicker aria-describedby={describedById} hasError={hasError} value={country} onChange={setCountry} />
52
- )}
53
- </Field>
54
- ```
55
-
56
- | Prop | Type | Notes |
57
- |------|------|-------|
58
- | `label` | `string` | required |
59
- | `children` | `ReactNode \| (ctx) => ReactNode` | the control |
60
- | `required` | `boolean` | renders a decorative `*` (hidden from assistive tech) |
61
- | `error` | `string` | renders a `role="alert"` line, linked via the child's `describedById` |
62
- | `containerStyle` | `ViewStyle` | merged over the container |
63
- | `testID` | `string` | on the container |
64
-
65
- The injected theme needs `colors.{surface,text,textSecondary,border}`, `palette.primary['500']`
66
- and `semantic.error['500']` — supplied via `@dloizides/ui-feedback`'s provider. Without a provider
67
- the components fall back to a neutral default theme.
68
-
69
- ## testIDs
70
-
71
- `form-field-input`, `chip-selector-chip-<value>`, `form-switch` (overridable on FormSwitch).
72
-
73
- ## License
74
-
75
- MIT
1
+ # @dloizides/ui-forms
2
+
3
+ Themable, brand-agnostic React Native (RN-web) **form** components for the dloizides.com
4
+ portfolio: `Field`, `FormField`, `ChipSelector`, `FormSwitch`. They read theme colours from the shared
5
+ `@dloizides/ui-feedback` UI context (`useUi`) — mount one `UiProvider` / `FeedbackUiProvider`
6
+ at your app root and these components pick up your theme automatically.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @dloizides/ui-forms @dloizides/ui-feedback
12
+ ```
13
+
14
+ Peer dependencies: `@dloizides/ui-feedback >= 1.1.0`, `react >= 18`, `react-native >= 0.74`
15
+ (use `react-native-web` on web).
16
+
17
+ ## Usage
18
+
19
+ ```tsx
20
+ import { FormField, ChipSelector, FormSwitch } from '@dloizides/ui-forms';
21
+
22
+ <FormField label="Email" required error={errors.email} value={email} onChangeText={setEmail} />
23
+ <ChipSelector label="Plan" options={plans} value={plan} onChange={setPlan} />
24
+ <FormSwitch label="Email notifications" value={notify} onValueChange={setNotify} />
25
+ ```
26
+
27
+ ## `Field` — label any control, not just a text input
28
+
29
+ `FormField` is hard-wired to a text input. For **any other control** — a dropdown, a date picker,
30
+ a chip selector — wrap it in `Field` to get the identical label row, required mark, error line and
31
+ bottom spacing. This is what keeps a dropdown aligned with the `FormField` next to it: without it
32
+ the dropdown's box starts a label-row higher and the row loses its vertical rhythm.
33
+
34
+ ```tsx
35
+ import { Field } from '@dloizides/ui-forms';
36
+
37
+ <Field label="Currency" required error={errors.currency}>
38
+ <ModalDropdown options={currencies} value={currency} onChange={setCurrency} />
39
+ </Field>
40
+ ```
41
+
42
+ `FormField` **and** `ChipSelector` compose `Field` internally, so the label row can never drift
43
+ out of alignment between them — there is exactly one label implementation in the kit.
44
+
45
+ To wire the error line into a custom control's own `aria-describedby` / invalid state, pass a
46
+ **function** child — it receives `{ describedById, hasError }` (`describedById` is `undefined`
47
+ when there is no error):
48
+
49
+ ```tsx
50
+ <Field label="Country" error={errors.country}>
51
+ {({ describedById, hasError }) => (
52
+ <CountryPicker aria-describedby={describedById} hasError={hasError} value={country} onChange={setCountry} />
53
+ )}
54
+ </Field>
55
+ ```
56
+
57
+ | Prop | Type | Notes |
58
+ |------|------|-------|
59
+ | `label` | `string` | optional — an absent or empty label renders NO label row |
60
+ | `children` | `ReactNode \| (ctx) => ReactNode` | the control |
61
+ | `required` | `boolean` | renders a decorative `*` (hidden from assistive tech) |
62
+ | `error` | `string` | renders a `role="alert"` line, linked via the child's `describedById` |
63
+ | `containerStyle` | `ViewStyle` | merged over the container |
64
+ | `testID` | `string` | on the container |
65
+
66
+ The injected theme needs `colors.{surface,text,textSecondary,border}`, `palette.primary['500']`
67
+ and `semantic.error['500']` — supplied via `@dloizides/ui-feedback`'s provider. Without a provider
68
+ the components fall back to a neutral default theme.
69
+
70
+ ## `ChipSelector`
71
+
72
+ ```tsx
73
+ <ChipSelector
74
+ label="Plan"
75
+ required
76
+ error={errors.plan}
77
+ options={plans}
78
+ value={plan}
79
+ onChange={setPlan}
80
+ variant="outline"
81
+ />
82
+ ```
83
+
84
+ Its label, required mark, error line and bottom spacing come from `Field`. `label` is optional:
85
+ with no label no label row is rendered, so it drops into a toolbar or filter bar cleanly.
86
+
87
+ | Prop | Type | Notes |
88
+ |------|------|-------|
89
+ | `label` | `string` | optional — no label means no label row |
90
+ | `options` | `Array<{ value, label }>` | `value` is a `string` or `number` |
91
+ | `value` | `T \| T[]` | array when `multiple` |
92
+ | `onChange` | `(value: T) => void` | fires the pressed value; the parent owns toggle logic |
93
+ | `multiple` | `boolean` | selection is an array |
94
+ | `disabled` | `boolean` | disables every chip |
95
+ | `variant` | `'solid' \| 'outline'` | `solid` (default) = filled pill; `outline` = tinted outline |
96
+ | `required` | `boolean` | renders `Field`'s decorative `*` |
97
+ | `error` | `string` | `role="alert"` line, tied to the chip group via `aria-describedby` |
98
+ | `containerStyle` | `ViewStyle` | merged over the container |
99
+ | `testID` | `string` | on the container |
100
+
101
+ ## testIDs
102
+
103
+ `form-field-input`, `chip-selector-chip-<value>`, `form-switch` (overridable on FormSwitch).
104
+
105
+ ## License
106
+
107
+ MIT
package/dist/index.d.mts CHANGED
@@ -25,7 +25,13 @@ interface FieldChildContext {
25
25
  }
26
26
  type FieldChildren = React.ReactNode | ((context: FieldChildContext) => React.ReactNode);
27
27
  interface FieldProps {
28
- label: string;
28
+ /**
29
+ * Label shown above the control. Optional so a control whose label is supplied elsewhere (or
30
+ * which genuinely has none) can still use the shared shell for its error line + spacing: an
31
+ * absent or empty label renders NO label row at all, rather than an empty one that would add a
32
+ * phantom gap and misalign the control against its labelled siblings.
33
+ */
34
+ label?: string;
29
35
  children: FieldChildren;
30
36
  required?: boolean;
31
37
  error?: string;
@@ -117,6 +123,12 @@ declare function useThemedInput(options?: UseThemedInputOptions): ThemedInput;
117
123
  * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
118
124
  * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
119
125
  * Hover (web) lifts the border/ink to the primary colour.
126
+ *
127
+ * The label row, the error line and the container spacing come from the shared `Field` wrapper —
128
+ * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
129
+ * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
130
+ * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
131
+ * column and read as a different weight of text. Now there is exactly one label implementation.
120
132
  */
121
133
 
122
134
  /** Visual variant. `solid` = filled selected pill (default); `outline` = v1 tinted-outline. */
@@ -126,6 +138,7 @@ interface ChipOption<T> {
126
138
  label: string;
127
139
  }
128
140
  interface ChipSelectorProps<T> {
141
+ /** Optional — with no label, no label row is rendered (no phantom gap above the chips). */
129
142
  label?: string;
130
143
  options: Array<ChipOption<T>>;
131
144
  value: T | T[];
@@ -135,8 +148,13 @@ interface ChipSelectorProps<T> {
135
148
  /** Visual variant. `solid` (default) = filled selected pill; `outline` = v1 tinted-outline. */
136
149
  variant?: ChipVariant;
137
150
  containerStyle?: ViewStyle;
151
+ /** Marks the selection mandatory — renders `Field`'s decorative `*` next to the label. */
152
+ required?: boolean;
153
+ /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
154
+ error?: string;
155
+ testID?: string;
138
156
  }
139
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, }: ChipSelectorProps<T>) => React.ReactElement;
157
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, }: ChipSelectorProps<T>) => React.ReactElement;
140
158
 
141
159
  /**
142
160
  * Reusable form switch/toggle with an optional description.
package/dist/index.d.ts CHANGED
@@ -25,7 +25,13 @@ interface FieldChildContext {
25
25
  }
26
26
  type FieldChildren = React.ReactNode | ((context: FieldChildContext) => React.ReactNode);
27
27
  interface FieldProps {
28
- label: string;
28
+ /**
29
+ * Label shown above the control. Optional so a control whose label is supplied elsewhere (or
30
+ * which genuinely has none) can still use the shared shell for its error line + spacing: an
31
+ * absent or empty label renders NO label row at all, rather than an empty one that would add a
32
+ * phantom gap and misalign the control against its labelled siblings.
33
+ */
34
+ label?: string;
29
35
  children: FieldChildren;
30
36
  required?: boolean;
31
37
  error?: string;
@@ -117,6 +123,12 @@ declare function useThemedInput(options?: UseThemedInputOptions): ThemedInput;
117
123
  * - `outline` — the AML v1 console look: an outlined pill on a muted fill; selected = a subtle
118
124
  * tinted-outline (primary border + primary ink + a low-alpha primary wash), NOT a solid fill.
119
125
  * Hover (web) lifts the border/ink to the primary colour.
126
+ *
127
+ * The label row, the error line and the container spacing come from the shared `Field` wrapper —
128
+ * NOT from a local `<Text>`. Its own label used to drift from `Field` on three of four axes
129
+ * (`marginBottom: 8` vs 4, no `fontSize` at all so ~14 vs 13, `colors.text` vs `textSecondary`),
130
+ * so a `ChipSelector` next to a `FormField` in a row/grid started a different distance down the
131
+ * column and read as a different weight of text. Now there is exactly one label implementation.
120
132
  */
121
133
 
122
134
  /** Visual variant. `solid` = filled selected pill (default); `outline` = v1 tinted-outline. */
@@ -126,6 +138,7 @@ interface ChipOption<T> {
126
138
  label: string;
127
139
  }
128
140
  interface ChipSelectorProps<T> {
141
+ /** Optional — with no label, no label row is rendered (no phantom gap above the chips). */
129
142
  label?: string;
130
143
  options: Array<ChipOption<T>>;
131
144
  value: T | T[];
@@ -135,8 +148,13 @@ interface ChipSelectorProps<T> {
135
148
  /** Visual variant. `solid` (default) = filled selected pill; `outline` = v1 tinted-outline. */
136
149
  variant?: ChipVariant;
137
150
  containerStyle?: ViewStyle;
151
+ /** Marks the selection mandatory — renders `Field`'s decorative `*` next to the label. */
152
+ required?: boolean;
153
+ /** Validation message under the chips, wired to the chip group via `aria-describedby`. */
154
+ error?: string;
155
+ testID?: string;
138
156
  }
139
- declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, }: ChipSelectorProps<T>) => React.ReactElement;
157
+ declare const ChipSelector: <T extends string | number>({ label, options, value, onChange, multiple, disabled, variant, containerStyle, required, error, testID, }: ChipSelectorProps<T>) => React.ReactElement;
140
158
 
141
159
  /**
142
160
  * Reusable form switch/toggle with an optional description.
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
- var React3 = require('react');
3
+ var React = require('react');
4
4
  var reactNative = require('react-native');
5
5
  var uiFeedback = require('@dloizides/ui-feedback');
6
6
  var jsxRuntime = require('react/jsx-runtime');
7
7
 
8
8
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
9
 
10
- var React3__default = /*#__PURE__*/_interopDefault(React3);
10
+ var React__default = /*#__PURE__*/_interopDefault(React);
11
11
 
12
12
  // src/Field/Field.tsx
13
13
  var CONTAINER_MARGIN_BOTTOM = 16;
@@ -32,7 +32,7 @@ var styles = reactNative.StyleSheet.create({
32
32
  });
33
33
  var fieldSeq = 0;
34
34
  function useFieldErrorId() {
35
- const [id] = React3__default.default.useState(() => {
35
+ const [id] = React__default.default.useState(() => {
36
36
  fieldSeq += 1;
37
37
  return `field-error-${String(fieldSeq)}`;
38
38
  });
@@ -53,8 +53,9 @@ var Field = ({
53
53
  const { colors, semantic } = theme;
54
54
  const errorColor = semantic.error["500"];
55
55
  const hasError = hasFieldError(error);
56
+ const hasLabel = typeof label === "string" && label !== "";
56
57
  const errorId = useFieldErrorId();
57
- const themeStyles = React3__default.default.useMemo(
58
+ const themeStyles = React__default.default.useMemo(
58
59
  () => ({
59
60
  label: { color: colors.textSecondary },
60
61
  requiredMark: { color: errorColor },
@@ -64,7 +65,7 @@ var Field = ({
64
65
  );
65
66
  const control = typeof children === "function" ? children({ describedById: hasError ? errorId : void 0, hasError }) : children;
66
67
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.container, containerStyle], testID, children: [
67
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: [styles.label, themeStyles.label], children: [
68
+ hasLabel ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: [styles.label, themeStyles.label], children: [
68
69
  label,
69
70
  " ",
70
71
  required ? (
@@ -72,7 +73,7 @@ var Field = ({
72
73
  // assistive tech, so a screen reader says "required" instead of "star".
73
74
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { "aria-hidden": true, accessibilityElementsHidden: true, importantForAccessibility: "no", style: themeStyles.requiredMark, children: "*" })
74
75
  ) : null
75
- ] }),
76
+ ] }) : null,
76
77
  control,
77
78
  hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: errorId, role: "alert", style: [styles.errorText, themeStyles.errorText], children: error }) : null
78
79
  ] });
@@ -109,27 +110,27 @@ function useThemedInput(options = {}) {
109
110
  const { hasError = false, onFocus, onBlur } = options;
110
111
  const { theme } = uiFeedback.useUi();
111
112
  const { colors, palette, semantic } = theme;
112
- const [isFocused, setIsFocused] = React3.useState(false);
113
- const [isHovered, setIsHovered] = React3.useState(false);
114
- const handleFocus = React3.useCallback(
113
+ const [isFocused, setIsFocused] = React.useState(false);
114
+ const [isHovered, setIsHovered] = React.useState(false);
115
+ const handleFocus = React.useCallback(
115
116
  (e) => {
116
117
  setIsFocused(true);
117
118
  onFocus?.(e);
118
119
  },
119
120
  [onFocus]
120
121
  );
121
- const handleBlur = React3.useCallback(
122
+ const handleBlur = React.useCallback(
122
123
  (e) => {
123
124
  setIsFocused(false);
124
125
  onBlur?.(e);
125
126
  },
126
127
  [onBlur]
127
128
  );
128
- const handleHoverIn = React3.useCallback(() => setIsHovered(true), []);
129
- const handleHoverOut = React3.useCallback(() => setIsHovered(false), []);
129
+ const handleHoverIn = React.useCallback(() => setIsHovered(true), []);
130
+ const handleHoverOut = React.useCallback(() => setIsHovered(false), []);
130
131
  const primary = palette.primary["500"];
131
132
  const errorColor = semantic.error["500"];
132
- const style = React3.useMemo(() => {
133
+ const style = React.useMemo(() => {
133
134
  const restBorder = hasError ? errorColor : colors.border;
134
135
  const focusBorder = hasError ? errorColor : primary;
135
136
  const borderColor = isFocused ? focusBorder : isHovered ? withAlpha(colors.text, HOVER_BORDER_ALPHA) : restBorder;
@@ -157,8 +158,8 @@ function useThemedInput(options = {}) {
157
158
  primary,
158
159
  errorColor
159
160
  ]);
160
- const focusBind = React3.useMemo(() => ({ onFocus: handleFocus, onBlur: handleBlur }), [handleFocus, handleBlur]);
161
- const hoverBind = React3.useMemo(
161
+ const focusBind = React.useMemo(() => ({ onFocus: handleFocus, onBlur: handleBlur }), [handleFocus, handleBlur]);
162
+ const hoverBind = React.useMemo(
162
163
  () => IS_WEB ? { onMouseEnter: handleHoverIn, onMouseLeave: handleHoverOut } : {},
163
164
  [handleHoverIn, handleHoverOut]
164
165
  );
@@ -172,7 +173,7 @@ function useThemedInput(options = {}) {
172
173
  };
173
174
  }
174
175
  var IS_WEB2 = reactNative.Platform.OS === "web";
175
- var ThemedTextInput = React3__default.default.forwardRef(function ThemedTextInput2({ hasError = false, describedById, requiredField = false, onFocus, onBlur, style, placeholderTextColor, ...rest }, ref) {
176
+ var ThemedTextInput = React__default.default.forwardRef(function ThemedTextInput2({ hasError = false, describedById, requiredField = false, onFocus, onBlur, style, placeholderTextColor, ...rest }, ref) {
176
177
  const {
177
178
  style: themedStyle,
178
179
  placeholderTextColor: themedPlaceholder,
@@ -244,6 +245,7 @@ var RGB_R_END = 2;
244
245
  var RGB_G_END = 4;
245
246
  var RGB_B_END = 6;
246
247
  var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
248
+ var CHIP_GUTTER = 8;
247
249
  function withAlpha2(hex, alpha) {
248
250
  const normalized = hex.replace("#", "");
249
251
  const isShort = normalized.length === HEX_SHORT_LENGTH2;
@@ -255,17 +257,17 @@ function withAlpha2(hex, alpha) {
255
257
  return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
256
258
  }
257
259
  var styles3 = reactNative.StyleSheet.create({
258
- label: {
259
- marginBottom: 8,
260
- fontWeight: "600"
261
- },
262
260
  chipContainer: {
263
261
  flexDirection: "row",
264
- flexWrap: "wrap"
262
+ flexWrap: "wrap",
263
+ // Each chip's bottom gutter also hangs off the LAST row, stacking on `Field`'s container
264
+ // margin (8 + 16 = 24) and dropping the block below the `FormField` beside it. Cancelling it
265
+ // is standard gutter compensation: inner rows keep their 8px, the block ends at Field's 16.
266
+ marginBottom: -CHIP_GUTTER
265
267
  },
266
268
  chipWrapper: {
267
- marginRight: 8,
268
- marginBottom: 8
269
+ marginRight: CHIP_GUTTER,
270
+ marginBottom: CHIP_GUTTER
269
271
  },
270
272
  chip: {
271
273
  paddingHorizontal: 12,
@@ -290,7 +292,7 @@ function OutlineChip({
290
292
  colors,
291
293
  onPress
292
294
  }) {
293
- const [hovered, setHovered] = React3__default.default.useState(false);
295
+ const [hovered, setHovered] = React__default.default.useState(false);
294
296
  const active = selected || hovered;
295
297
  const borderColor = active ? colors.primary : colors.border;
296
298
  const backgroundColor = selected ? colors.tint : colors.muted;
@@ -338,6 +340,11 @@ function SolidChip({
338
340
  }
339
341
  );
340
342
  }
343
+ var IS_WEB3 = reactNative.Platform.OS === "web";
344
+ function groupA11yProps(describedById, hasError) {
345
+ if (!IS_WEB3) return {};
346
+ return { "aria-invalid": hasError ? true : void 0, "aria-describedby": describedById };
347
+ }
341
348
  var ChipSelector = ({
342
349
  label,
343
350
  options,
@@ -346,12 +353,14 @@ var ChipSelector = ({
346
353
  multiple = false,
347
354
  disabled = false,
348
355
  variant = "solid",
349
- containerStyle
356
+ containerStyle,
357
+ required = false,
358
+ error,
359
+ testID
350
360
  }) => {
351
361
  const { theme } = uiFeedback.useUi();
352
362
  const { colors, palette } = theme;
353
- const hasLabel = typeof label === "string" && label !== "";
354
- const chipColors = React3__default.default.useMemo(() => {
363
+ const chipColors = React__default.default.useMemo(() => {
355
364
  const primary = palette.primary["500"];
356
365
  return {
357
366
  border: colors.border,
@@ -362,40 +371,23 @@ var ChipSelector = ({
362
371
  tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
363
372
  };
364
373
  }, [colors.border, colors.text, colors.textSecondary, colors.background, palette.primary]);
365
- const labelStyle = React3__default.default.useMemo(() => ({ color: colors.text }), [colors.text]);
366
374
  function isSelected(optionValue) {
367
375
  if (multiple && Array.isArray(value))
368
376
  return value.includes(optionValue);
369
377
  return value === optionValue;
370
378
  }
371
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: containerStyle, children: [
372
- hasLabel ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.label, labelStyle], children: label }) : null,
373
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles3.chipContainer, children: options.map((option) => {
374
- const selected = isSelected(option.value);
375
- const onPress = () => onChange(option.value);
376
- return variant === "outline" ? /* @__PURE__ */ jsxRuntime.jsx(
377
- OutlineChip,
378
- {
379
- colors: chipColors,
380
- disabled,
381
- option,
382
- selected,
383
- onPress
384
- },
385
- String(option.value)
386
- ) : /* @__PURE__ */ jsxRuntime.jsx(
387
- SolidChip,
388
- {
389
- colors: chipColors,
390
- disabled,
391
- option,
392
- selected,
393
- onPress
394
- },
395
- String(option.value)
396
- );
397
- }) })
398
- ] });
379
+ function renderChip(option) {
380
+ const chipProps = {
381
+ colors: chipColors,
382
+ disabled,
383
+ option,
384
+ selected: isSelected(option.value),
385
+ onPress: () => onChange(option.value)
386
+ };
387
+ const key = String(option.value);
388
+ return variant === "outline" ? /* @__PURE__ */ jsxRuntime.jsx(OutlineChip, { ...chipProps }, key) : /* @__PURE__ */ jsxRuntime.jsx(SolidChip, { ...chipProps }, key);
389
+ }
390
+ return /* @__PURE__ */ jsxRuntime.jsx(Field, { containerStyle, error, label, required, testID, children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles3.chipContainer, ...groupA11yProps(describedById, hasError), children: options.map(renderChip) }) });
399
391
  };
400
392
  var DESCRIPTION_FONT_SIZE = 12;
401
393
  var styles4 = reactNative.StyleSheet.create({
@@ -429,7 +421,7 @@ var FormSwitch = ({
429
421
  const { theme } = uiFeedback.useUi();
430
422
  const { colors } = theme;
431
423
  const hasDescription = typeof description === "string" && description !== "";
432
- const themeStyles = React3__default.default.useMemo(() => {
424
+ const themeStyles = React__default.default.useMemo(() => {
433
425
  const labelStyle = { color: colors.text };
434
426
  const descriptionStyle = { color: colors.textSecondary };
435
427
  return { label: labelStyle, description: descriptionStyle };
@@ -520,7 +512,7 @@ var FormCheckbox = ({
520
512
  const brand = palette.primary["500"];
521
513
  const hintText = typeof hint === "string" && hint !== "" ? hint : description;
522
514
  const hasHint = typeof hintText === "string" && hintText !== "";
523
- const themeStyles = React3__default.default.useMemo(() => {
515
+ const themeStyles = React__default.default.useMemo(() => {
524
516
  const boxOn = { backgroundColor: brand, borderColor: brand };
525
517
  const boxOff = { backgroundColor: "transparent", borderColor: colors.border };
526
518
  const labelStyle = { color: colors.text };