@dloizides/ui-forms 1.3.0 → 1.5.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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ - **Add `Field` — the generic label-over-control wrapper.** `FormField` is hard-wired to a text
6
+ input (`FormFieldProps extends Omit<TextInputProps,'style'>`), so any NON-text control (a
7
+ dropdown, a date picker, a chip selector) had no shared way to get a label — and ended up
8
+ visually misaligned next to its `FormField` siblings: the control's box started ~21px higher
9
+ (no label row) and it missed `FormField`'s `marginBottom: 16`, so wrapped rows lost their
10
+ vertical rhythm. This was hand-rolled in 5 places across zygos-web and aml-v2.
11
+ `Field` takes `{ label, required?, error?, children, containerStyle?, testID? }` and renders the
12
+ same label row / required mark / error line / spacing.
13
+ - **`FormField` now composes `Field` internally**, so the two can never drift apart — the metrics
14
+ live in exactly one place. `FormFieldProps` and every rendered attribute (`form-field-input`
15
+ testID, `aria-invalid` / `aria-describedby` / `aria-required`, the `role="alert"` error line)
16
+ are unchanged: fully backward-compatible.
17
+ - `Field`'s `children` may be a plain node (the common case) or a render function receiving
18
+ `{ describedById, hasError }`, so a custom control can wire the error line into its own
19
+ `aria-describedby` / invalid state exactly the way `FormField` does.
20
+
21
+ ## 1.4.0
22
+
23
+ - **Accessibility (WCAG 2.1 AA) hardening — additive + backward-compatible.**
24
+ - `FormField`: the error message is now programmatically tied to the input — the input gets
25
+ `aria-invalid` when errored and `aria-describedby` pointing at the error line (which is a
26
+ `role="alert"` live region), and `aria-required` when `required`. The required `*` is marked
27
+ decorative (`aria-hidden` / `accessibilityElementsHidden`) so a screen reader hears "required"
28
+ (via `aria-required`) instead of "star".
29
+ - `ThemedTextInput`: new optional `describedById` + `requiredField` props forward
30
+ `aria-describedby` / `aria-required`, and it emits `aria-invalid` from `hasError` (web only;
31
+ no-ops on native). Existing callers are unaffected.
32
+ - `FormCheckbox`: now keyboard-operable — the `role="checkbox"` Pressable renders a `<div>`
33
+ (no free Enter/Space), so an explicit web-only Enter/Space handler toggles it (WCAG 2.1.1).
34
+ - `ChipSelector`: chips gain a `hitSlop` so the touch target reaches ~44px WITHOUT changing the
35
+ rendered pill size, and expose `accessibilityState.disabled` when the selector is disabled.
36
+ - `useThemedInput`: the focus/hover CSS transition now collapses to instant when the user
37
+ requests `prefers-reduced-motion: reduce` (WCAG 2.3.3, web only).
38
+
3
39
  ## 1.3.0
4
40
 
5
41
  - `ChipSelector` gains a `variant` prop (`'solid' | 'outline'`, default `'solid'`). `solid` is the
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @dloizides/ui-forms
2
2
 
3
3
  Themable, brand-agnostic React Native (RN-web) **form** components for the dloizides.com
4
- portfolio: `FormField`, `ChipSelector`, `FormSwitch`. They read theme colours from the shared
4
+ portfolio: `Field`, `FormField`, `ChipSelector`, `FormSwitch`. They read theme colours from the shared
5
5
  `@dloizides/ui-feedback` UI context (`useUi`) — mount one `UiProvider` / `FeedbackUiProvider`
6
6
  at your app root and these components pick up your theme automatically.
7
7
 
@@ -24,6 +24,44 @@ import { FormField, ChipSelector, FormSwitch } from '@dloizides/ui-forms';
24
24
  <FormSwitch label="Email notifications" value={notify} onValueChange={setNotify} />
25
25
  ```
26
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
+
27
65
  The injected theme needs `colors.{surface,text,textSecondary,border}`, `palette.primary['500']`
28
66
  and `semantic.error['500']` — supplied via `@dloizides/ui-feedback`'s provider. Without a provider
29
67
  the components fall back to a neutral default theme.
package/dist/index.d.mts CHANGED
@@ -1,12 +1,49 @@
1
1
  import React from 'react';
2
- import { TextInputProps, ViewStyle, TextInput, TextStyle } from 'react-native';
2
+ import { ViewStyle, TextInputProps, TextInput, TextStyle } from 'react-native';
3
+
4
+ /**
5
+ * Field — the generic "label over a control, error under it" wrapper.
6
+ *
7
+ * `FormField` is hard-wired to a text input, so any NON-text control (a dropdown, a date picker,
8
+ * a chip selector) had no shared way to get a label — and ended up visually misaligned next to
9
+ * its `FormField` siblings: its box started a label-row higher, and it missed the container's
10
+ * bottom margin so wrapped rows lost their vertical rhythm.
11
+ *
12
+ * `Field` is that label row + spacing, control-agnostic. Its metrics are the single source of
13
+ * truth: `FormField` composes `Field` internally, so the two can never drift apart.
14
+ *
15
+ * Children may be a plain node (the common case — just wrap your control) or a render function
16
+ * receiving `{ describedById, hasError }`, so a custom control can wire the error line into its
17
+ * own `aria-describedby` / invalid state exactly the way `FormField` does.
18
+ */
19
+
20
+ /** What a render-function child receives, so it can mirror `FormField`'s error wiring. */
21
+ interface FieldChildContext {
22
+ /** id of the rendered error line, or `undefined` when there is no error. */
23
+ describedById?: string;
24
+ hasError: boolean;
25
+ }
26
+ type FieldChildren = React.ReactNode | ((context: FieldChildContext) => React.ReactNode);
27
+ interface FieldProps {
28
+ label: string;
29
+ children: FieldChildren;
30
+ required?: boolean;
31
+ error?: string;
32
+ containerStyle?: ViewStyle;
33
+ testID?: string;
34
+ }
35
+ declare const Field: ({ label, children, required, error, containerStyle, testID, }: FieldProps) => React.ReactElement;
3
36
 
4
37
  /**
5
38
  * Reusable form field — labelled text input with optional required mark + error.
6
39
  *
7
- * The input itself is the shared `ThemedTextInput` primitive, so every FormField gets the
8
- * readable + lively treatment (dark text, subtle off-white rest background, brand focus
9
- * animation with a soft ring) for free, driven by the active theme.
40
+ * The label row, the required mark, the error line and the container spacing all come from the
41
+ * generic `Field` wrapper, so a `FormField` and a `Field`-wrapped dropdown sitting next to each
42
+ * other line up exactly and can never drift apart. FormField only adds the control itself.
43
+ *
44
+ * The input is the shared `ThemedTextInput` primitive, so every FormField gets the readable +
45
+ * lively treatment (dark text, subtle off-white rest background, brand focus animation with a
46
+ * soft ring) for free, driven by the active theme.
10
47
  */
11
48
 
12
49
  interface FormFieldProps extends Omit<TextInputProps, 'style'> {
@@ -33,6 +70,13 @@ declare const FormField: ({ label, required, error, containerStyle, ...textInput
33
70
  interface ThemedTextInputProps extends TextInputProps {
34
71
  /** Render with the error border colour (matches `FormField`'s error treatment). */
35
72
  hasError?: boolean;
73
+ /**
74
+ * id of an element describing this input (e.g. an error/hint line). Linked via
75
+ * `aria-describedby` on web so a screen reader reads it after the field's name. No-op native.
76
+ */
77
+ describedById?: string;
78
+ /** Marks the field required for assistive tech (`aria-required` on web). No-op on native. */
79
+ requiredField?: boolean;
36
80
  }
37
81
  declare const ThemedTextInput: React.ForwardRefExoticComponent<ThemedTextInputProps & React.RefAttributes<TextInput>>;
38
82
 
@@ -135,4 +179,4 @@ interface FormCheckboxProps {
135
179
  }
136
180
  declare const FormCheckbox: ({ label, value, onValueChange, onChange, hint, description, disabled, containerStyle, testID, accessibilityLabel, accessibilityHint, }: FormCheckboxProps) => React.ReactElement;
137
181
 
138
- export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
182
+ export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, Field, type FieldChildContext, type FieldChildren, type FieldProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
package/dist/index.d.ts CHANGED
@@ -1,12 +1,49 @@
1
1
  import React from 'react';
2
- import { TextInputProps, ViewStyle, TextInput, TextStyle } from 'react-native';
2
+ import { ViewStyle, TextInputProps, TextInput, TextStyle } from 'react-native';
3
+
4
+ /**
5
+ * Field — the generic "label over a control, error under it" wrapper.
6
+ *
7
+ * `FormField` is hard-wired to a text input, so any NON-text control (a dropdown, a date picker,
8
+ * a chip selector) had no shared way to get a label — and ended up visually misaligned next to
9
+ * its `FormField` siblings: its box started a label-row higher, and it missed the container's
10
+ * bottom margin so wrapped rows lost their vertical rhythm.
11
+ *
12
+ * `Field` is that label row + spacing, control-agnostic. Its metrics are the single source of
13
+ * truth: `FormField` composes `Field` internally, so the two can never drift apart.
14
+ *
15
+ * Children may be a plain node (the common case — just wrap your control) or a render function
16
+ * receiving `{ describedById, hasError }`, so a custom control can wire the error line into its
17
+ * own `aria-describedby` / invalid state exactly the way `FormField` does.
18
+ */
19
+
20
+ /** What a render-function child receives, so it can mirror `FormField`'s error wiring. */
21
+ interface FieldChildContext {
22
+ /** id of the rendered error line, or `undefined` when there is no error. */
23
+ describedById?: string;
24
+ hasError: boolean;
25
+ }
26
+ type FieldChildren = React.ReactNode | ((context: FieldChildContext) => React.ReactNode);
27
+ interface FieldProps {
28
+ label: string;
29
+ children: FieldChildren;
30
+ required?: boolean;
31
+ error?: string;
32
+ containerStyle?: ViewStyle;
33
+ testID?: string;
34
+ }
35
+ declare const Field: ({ label, children, required, error, containerStyle, testID, }: FieldProps) => React.ReactElement;
3
36
 
4
37
  /**
5
38
  * Reusable form field — labelled text input with optional required mark + error.
6
39
  *
7
- * The input itself is the shared `ThemedTextInput` primitive, so every FormField gets the
8
- * readable + lively treatment (dark text, subtle off-white rest background, brand focus
9
- * animation with a soft ring) for free, driven by the active theme.
40
+ * The label row, the required mark, the error line and the container spacing all come from the
41
+ * generic `Field` wrapper, so a `FormField` and a `Field`-wrapped dropdown sitting next to each
42
+ * other line up exactly and can never drift apart. FormField only adds the control itself.
43
+ *
44
+ * The input is the shared `ThemedTextInput` primitive, so every FormField gets the readable +
45
+ * lively treatment (dark text, subtle off-white rest background, brand focus animation with a
46
+ * soft ring) for free, driven by the active theme.
10
47
  */
11
48
 
12
49
  interface FormFieldProps extends Omit<TextInputProps, 'style'> {
@@ -33,6 +70,13 @@ declare const FormField: ({ label, required, error, containerStyle, ...textInput
33
70
  interface ThemedTextInputProps extends TextInputProps {
34
71
  /** Render with the error border colour (matches `FormField`'s error treatment). */
35
72
  hasError?: boolean;
73
+ /**
74
+ * id of an element describing this input (e.g. an error/hint line). Linked via
75
+ * `aria-describedby` on web so a screen reader reads it after the field's name. No-op native.
76
+ */
77
+ describedById?: string;
78
+ /** Marks the field required for assistive tech (`aria-required` on web). No-op on native. */
79
+ requiredField?: boolean;
36
80
  }
37
81
  declare const ThemedTextInput: React.ForwardRefExoticComponent<ThemedTextInputProps & React.RefAttributes<TextInput>>;
38
82
 
@@ -135,4 +179,4 @@ interface FormCheckboxProps {
135
179
  }
136
180
  declare const FormCheckbox: ({ label, value, onValueChange, onChange, hint, description, disabled, containerStyle, testID, accessibilityLabel, accessibilityHint, }: FormCheckboxProps) => React.ReactElement;
137
181
 
138
- export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
182
+ export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, Field, type FieldChildContext, type FieldChildren, type FieldProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
package/dist/index.js CHANGED
@@ -9,7 +9,74 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
9
 
10
10
  var React3__default = /*#__PURE__*/_interopDefault(React3);
11
11
 
12
- // src/FormField/FormField.tsx
12
+ // src/Field/Field.tsx
13
+ var CONTAINER_MARGIN_BOTTOM = 16;
14
+ var LABEL_FONT_SIZE = 13;
15
+ var LABEL_FONT_WEIGHT = "600";
16
+ var LABEL_MARGIN_BOTTOM = 4;
17
+ var ERROR_FONT_SIZE = 12;
18
+ var ERROR_MARGIN_TOP = 4;
19
+ var styles = reactNative.StyleSheet.create({
20
+ container: {
21
+ marginBottom: CONTAINER_MARGIN_BOTTOM
22
+ },
23
+ label: {
24
+ marginBottom: LABEL_MARGIN_BOTTOM,
25
+ fontSize: LABEL_FONT_SIZE,
26
+ fontWeight: LABEL_FONT_WEIGHT
27
+ },
28
+ errorText: {
29
+ fontSize: ERROR_FONT_SIZE,
30
+ marginTop: ERROR_MARGIN_TOP
31
+ }
32
+ });
33
+ var fieldSeq = 0;
34
+ function useFieldErrorId() {
35
+ const [id] = React3__default.default.useState(() => {
36
+ fieldSeq += 1;
37
+ return `field-error-${String(fieldSeq)}`;
38
+ });
39
+ return id;
40
+ }
41
+ function hasFieldError(error) {
42
+ return typeof error === "string" && error !== "";
43
+ }
44
+ var Field = ({
45
+ label,
46
+ children,
47
+ required = false,
48
+ error,
49
+ containerStyle,
50
+ testID
51
+ }) => {
52
+ const { theme } = uiFeedback.useUi();
53
+ const { colors, semantic } = theme;
54
+ const errorColor = semantic.error["500"];
55
+ const hasError = hasFieldError(error);
56
+ const errorId = useFieldErrorId();
57
+ const themeStyles = React3__default.default.useMemo(
58
+ () => ({
59
+ label: { color: colors.textSecondary },
60
+ requiredMark: { color: errorColor },
61
+ errorText: { color: errorColor }
62
+ }),
63
+ [colors.textSecondary, errorColor]
64
+ );
65
+ const control = typeof children === "function" ? children({ describedById: hasError ? errorId : void 0, hasError }) : children;
66
+ 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
+ label,
69
+ " ",
70
+ required ? (
71
+ // The asterisk is decorative — the control's `aria-required` conveys "required" to
72
+ // assistive tech, so a screen reader says "required" instead of "star".
73
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { "aria-hidden": true, accessibilityElementsHidden: true, importantForAccessibility: "no", style: themeStyles.requiredMark, children: "*" })
74
+ ) : null
75
+ ] }),
76
+ control,
77
+ hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: errorId, role: "alert", style: [styles.errorText, themeStyles.errorText], children: error }) : null
78
+ ] });
79
+ };
13
80
  var IS_WEB = reactNative.Platform.OS === "web";
14
81
  var TRANSITION_PROPERTY = "border-color, background-color, box-shadow";
15
82
  var TRANSITION_DURATION = "0.14s";
@@ -24,6 +91,10 @@ var RED_START = 1;
24
91
  var GREEN_START = 3;
25
92
  var BLUE_START = 5;
26
93
  var CHANNEL_END_OFFSET = 2;
94
+ function prefersReducedMotion() {
95
+ if (!IS_WEB || typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
96
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
97
+ }
27
98
  function withAlpha(color, alpha) {
28
99
  const hex = color.trim();
29
100
  const isHex = hex.charAt(0) === "#" && (hex.length === HEX_FULL_LENGTH || hex.length === HEX_SHORT_LENGTH);
@@ -68,7 +139,8 @@ function useThemedInput(options = {}) {
68
139
  const ring = isFocused && !hasError ? `0 0 0 ${FOCUS_RING_WIDTH}px ${withAlpha(primary, FOCUS_RING_ALPHA)}` : "none";
69
140
  const webStyle = {
70
141
  transitionProperty: TRANSITION_PROPERTY,
71
- transitionDuration: TRANSITION_DURATION,
142
+ // Collapse the eased transition to instant when the user prefers reduced motion.
143
+ transitionDuration: prefersReducedMotion() ? "0s" : TRANSITION_DURATION,
72
144
  transitionTimingFunction: TRANSITION_TIMING,
73
145
  boxShadow: ring,
74
146
  outlineStyle: "none"
@@ -99,19 +171,26 @@ function useThemedInput(options = {}) {
99
171
  hoverBind
100
172
  };
101
173
  }
102
- var ThemedTextInput = React3__default.default.forwardRef(function ThemedTextInput2({ hasError = false, onFocus, onBlur, style, placeholderTextColor, ...rest }, ref) {
174
+ 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) {
103
176
  const {
104
177
  style: themedStyle,
105
178
  placeholderTextColor: themedPlaceholder,
106
179
  focusBind,
107
180
  hoverBind
108
181
  } = useThemedInput({ hasError, onFocus, onBlur });
182
+ const webA11y = IS_WEB2 ? {
183
+ "aria-invalid": hasError ? true : void 0,
184
+ "aria-describedby": describedById,
185
+ "aria-required": requiredField ? true : void 0
186
+ } : {};
109
187
  return /* @__PURE__ */ jsxRuntime.jsx(
110
188
  reactNative.TextInput,
111
189
  {
112
190
  ref,
113
191
  placeholderTextColor: placeholderTextColor ?? themedPlaceholder,
114
192
  ...rest,
193
+ ...webA11y,
115
194
  ...hoverBind,
116
195
  onFocus: focusBind.onFocus,
117
196
  onBlur: focusBind.onBlur,
@@ -119,30 +198,16 @@ var ThemedTextInput = React3__default.default.forwardRef(function ThemedTextInpu
119
198
  }
120
199
  );
121
200
  });
122
- var CONTAINER_MARGIN_BOTTOM = 16;
123
201
  var INPUT_PADDING_V = 10;
124
202
  var INPUT_PADDING_H = 12;
125
203
  var INPUT_BORDER_RADIUS = 8;
126
- var ERROR_FONT_SIZE = 12;
127
- var LABEL_FONT_SIZE = 13;
128
- var styles = reactNative.StyleSheet.create({
129
- container: {
130
- marginBottom: CONTAINER_MARGIN_BOTTOM
131
- },
132
- label: {
133
- marginBottom: 4,
134
- fontSize: LABEL_FONT_SIZE,
135
- fontWeight: "600"
136
- },
204
+ var INPUT_BORDER_WIDTH = 1;
205
+ var styles2 = reactNative.StyleSheet.create({
137
206
  input: {
138
207
  paddingVertical: INPUT_PADDING_V,
139
208
  paddingHorizontal: INPUT_PADDING_H,
140
209
  borderRadius: INPUT_BORDER_RADIUS,
141
- borderWidth: 1
142
- },
143
- errorText: {
144
- fontSize: ERROR_FONT_SIZE,
145
- marginTop: 4
210
+ borderWidth: INPUT_BORDER_WIDTH
146
211
  }
147
212
  });
148
213
  var FormField = ({
@@ -151,39 +216,19 @@ var FormField = ({
151
216
  error,
152
217
  containerStyle,
153
218
  ...textInputProps
154
- }) => {
155
- const { theme } = uiFeedback.useUi();
156
- const { colors, semantic } = theme;
157
- const errorColor = semantic.error["500"];
158
- const hasError = typeof error === "string" && error !== "";
159
- const themeStyles = React3__default.default.useMemo(
160
- () => ({
161
- label: { color: colors.textSecondary },
162
- requiredMark: { color: errorColor },
163
- errorText: { color: errorColor }
164
- }),
165
- [colors.textSecondary, errorColor]
166
- );
167
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.container, containerStyle], children: [
168
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: [styles.label, themeStyles.label], children: [
169
- label,
170
- " ",
171
- required ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: themeStyles.requiredMark, children: "*" }) : null
172
- ] }),
173
- /* @__PURE__ */ jsxRuntime.jsx(
174
- ThemedTextInput,
175
- {
176
- accessibilityHint: `Enter ${label}`,
177
- accessibilityLabel: label,
178
- hasError,
179
- style: styles.input,
180
- testID: "form-field-input",
181
- ...textInputProps
182
- }
183
- ),
184
- hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles.errorText, themeStyles.errorText], children: error }) : null
185
- ] });
186
- };
219
+ }) => /* @__PURE__ */ jsxRuntime.jsx(Field, { containerStyle, error, label, required, children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(
220
+ ThemedTextInput,
221
+ {
222
+ accessibilityHint: `Enter ${label}`,
223
+ accessibilityLabel: label,
224
+ describedById,
225
+ hasError,
226
+ requiredField: required,
227
+ style: styles2.input,
228
+ testID: "form-field-input",
229
+ ...textInputProps
230
+ }
231
+ ) });
187
232
  var TRANSPARENT_COLOR = "transparent";
188
233
  var WHITE_COLOR = "#fff";
189
234
  var SOLID_RADIUS = 20;
@@ -198,6 +243,7 @@ var RGB_R_START = 0;
198
243
  var RGB_R_END = 2;
199
244
  var RGB_G_END = 4;
200
245
  var RGB_B_END = 6;
246
+ var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
201
247
  function withAlpha2(hex, alpha) {
202
248
  const normalized = hex.replace("#", "");
203
249
  const isShort = normalized.length === HEX_SHORT_LENGTH2;
@@ -208,7 +254,7 @@ function withAlpha2(hex, alpha) {
208
254
  const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
209
255
  return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
210
256
  }
211
- var styles2 = reactNative.StyleSheet.create({
257
+ var styles3 = reactNative.StyleSheet.create({
212
258
  label: {
213
259
  marginBottom: 8,
214
260
  fontWeight: "600"
@@ -255,14 +301,15 @@ function OutlineChip({
255
301
  accessibilityHint: `Selects ${option.label}`,
256
302
  accessibilityLabel: option.label,
257
303
  accessibilityRole: "button",
258
- accessibilityState: { selected },
304
+ accessibilityState: { selected, disabled },
259
305
  disabled,
260
- style: styles2.chipWrapper,
306
+ hitSlop: CHIP_HIT_SLOP,
307
+ style: styles3.chipWrapper,
261
308
  testID: `chip-selector-chip-${String(option.value)}`,
262
309
  onHoverIn: () => setHovered(true),
263
310
  onHoverOut: () => setHovered(false),
264
311
  onPress,
265
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles2.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.chipText, styles2.chipTextOutline, { color: textColor }], children: option.label }) })
312
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles3.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.chipText, styles3.chipTextOutline, { color: textColor }], children: option.label }) })
266
313
  }
267
314
  );
268
315
  }
@@ -281,12 +328,13 @@ function SolidChip({
281
328
  accessibilityHint: `Selects ${option.label}`,
282
329
  accessibilityLabel: option.label,
283
330
  accessibilityRole: "button",
284
- accessibilityState: { selected },
331
+ accessibilityState: { selected, disabled },
285
332
  disabled,
286
- style: styles2.chipWrapper,
333
+ hitSlop: CHIP_HIT_SLOP,
334
+ style: styles3.chipWrapper,
287
335
  testID: `chip-selector-chip-${String(option.value)}`,
288
336
  onPress,
289
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles2.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.chipText, { color: textColor }], children: option.label }) })
337
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles3.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.chipText, { color: textColor }], children: option.label }) })
290
338
  }
291
339
  );
292
340
  }
@@ -321,8 +369,8 @@ var ChipSelector = ({
321
369
  return value === optionValue;
322
370
  }
323
371
  return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: containerStyle, children: [
324
- hasLabel ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles2.label, labelStyle], children: label }) : null,
325
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles2.chipContainer, children: options.map((option) => {
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) => {
326
374
  const selected = isSelected(option.value);
327
375
  const onPress = () => onChange(option.value);
328
376
  return variant === "outline" ? /* @__PURE__ */ jsxRuntime.jsx(
@@ -350,7 +398,7 @@ var ChipSelector = ({
350
398
  ] });
351
399
  };
352
400
  var DESCRIPTION_FONT_SIZE = 12;
353
- var styles3 = reactNative.StyleSheet.create({
401
+ var styles4 = reactNative.StyleSheet.create({
354
402
  container: {
355
403
  flexDirection: "row",
356
404
  justifyContent: "space-between",
@@ -390,13 +438,13 @@ var FormSwitch = ({
390
438
  reactNative.View,
391
439
  {
392
440
  style: [
393
- styles3.container,
441
+ styles4.container,
394
442
  containerStyle
395
443
  ],
396
444
  children: [
397
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles3.textColumn, children: [
398
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.label, themeStyles.label], children: label }),
399
- hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.description, themeStyles.description], children: description }) : null
445
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.textColumn, children: [
446
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.label, themeStyles.label], children: label }),
447
+ hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.description, themeStyles.description], children: description }) : null
400
448
  ] }),
401
449
  /* @__PURE__ */ jsxRuntime.jsx(
402
450
  reactNative.Switch,
@@ -415,12 +463,13 @@ var FormSwitch = ({
415
463
  }
416
464
  );
417
465
  };
466
+ var ACTIVATION_KEYS = ["Enter", " ", "Spacebar"];
418
467
  var HINT_FONT_SIZE = 12;
419
468
  var BOX_SIZE = 20;
420
469
  var BOX_RADIUS = 5;
421
470
  var CHECK_FONT_SIZE = 14;
422
471
  var DISABLED_OPACITY = 0.5;
423
- var styles4 = reactNative.StyleSheet.create({
472
+ var styles5 = reactNative.StyleSheet.create({
424
473
  container: {
425
474
  flexDirection: "row",
426
475
  alignItems: "flex-start",
@@ -483,6 +532,14 @@ var FormCheckbox = ({
483
532
  if (onValueChange) onValueChange(next);
484
533
  if (onChange) onChange(next);
485
534
  };
535
+ const handleKeyDown = (event) => {
536
+ if (disabled) return;
537
+ const key = event.key ?? "";
538
+ if (!ACTIVATION_KEYS.includes(key)) return;
539
+ if (typeof event.preventDefault === "function") event.preventDefault();
540
+ toggle();
541
+ };
542
+ const keyboardProps = reactNative.Platform.OS === "web" ? { onKeyDown: handleKeyDown } : {};
486
543
  return /* @__PURE__ */ jsxRuntime.jsxs(
487
544
  reactNative.Pressable,
488
545
  {
@@ -494,13 +551,14 @@ var FormCheckbox = ({
494
551
  disabled,
495
552
  onPress: toggle,
496
553
  role: "checkbox",
497
- style: [styles4.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
554
+ style: [styles5.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
498
555
  testID,
556
+ ...keyboardProps,
499
557
  children: [
500
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles4.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
501
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.textColumn, children: [
502
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.label, themeStyles.label], children: label }),
503
- hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.hint, themeStyles.hint], children: hintText }) : null
558
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles5.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
559
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles5.textColumn, children: [
560
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.label, themeStyles.label], children: label }),
561
+ hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.hint, themeStyles.hint], children: hintText }) : null
504
562
  ] })
505
563
  ]
506
564
  }
@@ -508,6 +566,7 @@ var FormCheckbox = ({
508
566
  };
509
567
 
510
568
  exports.ChipSelector = ChipSelector;
569
+ exports.Field = Field;
511
570
  exports.FormCheckbox = FormCheckbox;
512
571
  exports.FormField = FormField;
513
572
  exports.FormSwitch = FormSwitch;