panelui-native 0.35.2 → 0.38.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.
Files changed (60) hide show
  1. package/README.md +12 -1
  2. package/lib/module/components/combobox/index.js +718 -0
  3. package/lib/module/components/combobox/index.js.map +1 -0
  4. package/lib/module/components/form/use-field.js +18 -0
  5. package/lib/module/components/form/use-field.js.map +1 -1
  6. package/lib/module/components/form/use-form.js +91 -8
  7. package/lib/module/components/form/use-form.js.map +1 -1
  8. package/lib/module/components/input/index.js +142 -32
  9. package/lib/module/components/input/index.js.map +1 -1
  10. package/lib/module/components/otp-input/index.js +4 -1
  11. package/lib/module/components/otp-input/index.js.map +1 -1
  12. package/lib/module/components/panelside/index.js +1 -1
  13. package/lib/module/components/panelside/index.js.map +1 -1
  14. package/lib/module/components/select/index.js +116 -15
  15. package/lib/module/components/select/index.js.map +1 -1
  16. package/lib/module/components/textarea/index.js +3 -1
  17. package/lib/module/components/textarea/index.js.map +1 -1
  18. package/lib/module/components/time-picker/index.js +801 -0
  19. package/lib/module/components/time-picker/index.js.map +1 -0
  20. package/lib/module/icons/index.js +59 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/theme/use-theme.js +1 -1
  25. package/lib/module/utils/time.js +180 -0
  26. package/lib/module/utils/time.js.map +1 -0
  27. package/lib/typescript/src/components/combobox/index.d.ts +164 -0
  28. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -0
  29. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -1
  30. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  31. package/lib/typescript/src/components/input/index.d.ts +106 -0
  32. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/select/index.d.ts +32 -0
  35. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/time-picker/index.d.ts +104 -0
  38. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -0
  39. package/lib/typescript/src/icons/index.d.ts +4 -0
  40. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  41. package/lib/typescript/src/index.d.ts +5 -2
  42. package/lib/typescript/src/index.d.ts.map +1 -1
  43. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  44. package/lib/typescript/src/utils/time.d.ts +88 -0
  45. package/lib/typescript/src/utils/time.d.ts.map +1 -0
  46. package/package.json +1 -1
  47. package/src/components/combobox/index.tsx +927 -0
  48. package/src/components/form/use-field.ts +23 -0
  49. package/src/components/form/use-form.ts +106 -16
  50. package/src/components/input/index.tsx +185 -29
  51. package/src/components/otp-input/index.tsx +4 -1
  52. package/src/components/panelside/index.tsx +1 -1
  53. package/src/components/select/index.tsx +114 -15
  54. package/src/components/textarea/index.tsx +3 -1
  55. package/src/components/time-picker/index.tsx +971 -0
  56. package/src/icons/index.tsx +47 -0
  57. package/src/index.ts +35 -0
  58. package/src/theme/use-theme.ts +1 -1
  59. package/src/utils/time.ts +192 -0
  60. package/theme.css +32 -0
@@ -47,6 +47,29 @@ export function useField<T extends Record<string, any>, K extends keyof T>(
47
47
  // eslint-disable-next-line react-hooks/exhaustive-deps
48
48
  }, [registerValidator, name, !!validate]);
49
49
 
50
+ /*
51
+ * A name the form never declared is the one mistake this hook cannot
52
+ * recover from: there is no value to hand a validator, so a rule as ordinary
53
+ * as `value.length > 0` throws on a field that was only blurred, and nothing
54
+ * the user types is ever submitted. Both failures point away from the cause,
55
+ * so it is named here instead. Development only — the check costs a key
56
+ * lookup, but the message is for whoever is writing the form.
57
+ */
58
+ const formValues = form.values;
59
+ useEffect(() => {
60
+ if (process.env.NODE_ENV === 'production') return;
61
+ if (name in formValues) return;
62
+ console.warn(
63
+ `[PanelUI] Form field "${String(name)}" is not declared in the form's ` +
64
+ `defaultValues, so its value is undefined and it will not be submitted. ` +
65
+ `Add "${String(name)}" to defaultValues — an empty field is still a ` +
66
+ `declared one ("" for text, false for a toggle).`
67
+ );
68
+ // The declared names come from `defaultValues`, which is read once, so
69
+ // this is a question about the name and nothing else.
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [name]);
72
+
50
73
  const { value, error, touched } = form.getFieldState(name);
51
74
 
52
75
  return {
@@ -24,6 +24,12 @@
24
24
  * `defaultValues` is read once, on the first render. If default values
25
25
  * arrive asynchronously, mount the form once you have them (e.g. behind a
26
26
  * loading check) rather than expecting the hook to pick up a later change.
27
+ *
28
+ * It also declares the fields. A field's `name` has to be a key of it —
29
+ * including the empty ones, as `''`, `false`, `null`. A name it does not
30
+ * declare has no value to give a validator and nothing to submit, so
31
+ * `useField` says so in development rather than letting the field quietly do
32
+ * nothing.
27
33
  */
28
34
  import { useCallback, useMemo, useReducer, useRef } from 'react';
29
35
 
@@ -123,6 +129,42 @@ function reducer<T extends Record<string, any>>(
123
129
  }
124
130
  }
125
131
 
132
+ /** Stands in for the message a rule that crashed never got to return. */
133
+ const VALIDATOR_THREW = 'This field could not be validated.';
134
+
135
+ /**
136
+ * Run one field's rule, and survive a rule that throws.
137
+ *
138
+ * A validator is caller code reached from a path nothing awaits — `onBlur`
139
+ * fires and forgets, and `handleSubmit` is wired straight to a press. A throw
140
+ * in there becomes an unhandled rejection: a red box whose message ("cannot
141
+ * read property 'length' of undefined") names neither the field nor the rule,
142
+ * and which takes the screen for one line of one validator.
143
+ *
144
+ * So it is caught, reported against the field by name, and counted as a
145
+ * failure. Counted, rather than waved through: a rule that crashed reached no
146
+ * verdict, and a form that submits past a rule that never ran is the worse of
147
+ * the two outcomes — it puts unchecked values somewhere they cannot be taken
148
+ * back from. The placeholder message is what stands between the two; the
149
+ * console entry beside it is for whoever can fix the rule.
150
+ */
151
+ async function runValidator<T extends Record<string, any>>(
152
+ name: keyof T,
153
+ validator: (value: any, values: T) => string | undefined | Promise<string | undefined>,
154
+ values: T
155
+ ): Promise<string | undefined> {
156
+ try {
157
+ return await validator(values[name], values);
158
+ } catch (error) {
159
+ console.error(
160
+ `[PanelUI] The validator for form field "${String(name)}" threw, so the ` +
161
+ `field is being treated as invalid.`,
162
+ error
163
+ );
164
+ return VALIDATOR_THREW;
165
+ }
166
+ }
167
+
126
168
  export function useForm<T extends Record<string, any>>({
127
169
  defaultValues,
128
170
  validate,
@@ -140,7 +182,22 @@ export function useForm<T extends Record<string, any>>({
140
182
  isSubmitting: false,
141
183
  });
142
184
 
185
+ /*
186
+ * The values as of the last *change*, not as of the last render.
187
+ *
188
+ * Validation runs in the same tick as the edit that triggered it —
189
+ * `validateOn="change"` calls `validateField` immediately after
190
+ * `setFieldValue`, and submit runs from a press handler. React state is a
191
+ * render behind at that point, so a validator reading it would judge the
192
+ * character before the one just typed, and a submit fired straight after an
193
+ * edit would submit the value before it. The ref is written on the way into
194
+ * the dispatch so both see what the user actually entered.
195
+ */
196
+ const valuesRef = useRef(state.values);
197
+ valuesRef.current = state.values;
198
+
143
199
  const setFieldValue = useCallback(<K extends keyof T>(name: K, value: T[K]) => {
200
+ valuesRef.current = { ...valuesRef.current, [name]: value } as T;
144
201
  dispatch({ type: 'SET_VALUE', name, value });
145
202
  }, []);
146
203
 
@@ -175,32 +232,64 @@ export function useForm<T extends Record<string, any>>({
175
232
  [state.values, state.errors, state.touched]
176
233
  );
177
234
 
178
- const validateField = useCallback(
179
- async <K extends keyof T>(name: K) => {
180
- const validator = validatorsRef.current[name];
181
- if (!validator) return true;
182
- const error = await validator(state.values[name], state.values);
183
- dispatch({ type: 'SET_ERROR', name, error });
184
- return !error;
185
- },
186
- [state.values]
187
- );
235
+ const validateField = useCallback(async <K extends keyof T>(name: K) => {
236
+ const validator = validatorsRef.current[name];
237
+ if (!validator) return true;
238
+ const values = valuesRef.current;
239
+ const error = await runValidator(name, validator, values);
240
+ dispatch({ type: 'SET_ERROR', name, error });
241
+ return !error;
242
+ }, []);
188
243
 
189
244
  const handleSubmit = useCallback(async () => {
190
- const names = Object.keys(state.values) as (keyof T)[];
245
+ const values = valuesRef.current;
246
+ /*
247
+ * Every field the form knows of, which is not the same as every key of
248
+ * `defaultValues`: a field can register a validator under a name that was
249
+ * never declared there. Submitting only the declared ones meant such a
250
+ * field was never validated and never blocked anything, so a form of
251
+ * required-but-undeclared fields submitted itself while still empty.
252
+ *
253
+ * It is still a mistake to name a field that `defaultValues` does not
254
+ * declare — its value stays undefined and never reaches `onSubmit` —
255
+ * which is why `useField` says so. Validating it anyway is what turns
256
+ * that mistake into a visible error instead of a silent submit.
257
+ */
258
+ const names = Array.from(
259
+ new Set([...Object.keys(values), ...Object.keys(validatorsRef.current)])
260
+ ) as (keyof T)[];
191
261
  dispatch({ type: 'TOUCH_ALL', names });
192
262
 
193
263
  const fieldErrorEntries = await Promise.all(
194
264
  names.map(async (name) => {
195
265
  const validator = validatorsRef.current[name];
196
266
  const error = validator
197
- ? await validator(state.values[name], state.values)
267
+ ? await runValidator(name, validator, values)
198
268
  : undefined;
199
269
  return [name, error] as const;
200
270
  })
201
271
  );
202
272
 
203
- const formErrors = validate ? await validate(state.values) : {};
273
+ /*
274
+ * Same reasoning as a field's rule, one level up: a cross-field check that
275
+ * throws must not take the screen, and must not let the submit through.
276
+ * It belongs to no single field, so it blocks without marking one — there
277
+ * is no field whose error line would be the honest place to put it.
278
+ */
279
+ let formErrors: FieldErrors<T> = {};
280
+ let formValidateThrew = false;
281
+ if (validate) {
282
+ try {
283
+ formErrors = await validate(values);
284
+ } catch (error) {
285
+ console.error(
286
+ '[PanelUI] The form-level validate() threw, so the form is being ' +
287
+ 'treated as invalid and the submit was not run.',
288
+ error
289
+ );
290
+ formValidateThrew = true;
291
+ }
292
+ }
204
293
 
205
294
  const cleared = Object.fromEntries(names.map((name) => [name, undefined]));
206
295
  const nextErrors: FieldErrors<T> = {
@@ -210,17 +299,18 @@ export function useForm<T extends Record<string, any>>({
210
299
  };
211
300
  dispatch({ type: 'SET_ERRORS', errors: nextErrors });
212
301
 
213
- if (Object.values(nextErrors).some(Boolean)) return;
302
+ if (formValidateThrew || Object.values(nextErrors).some(Boolean)) return;
214
303
 
215
304
  dispatch({ type: 'SUBMIT_START' });
216
305
  try {
217
- await onSubmit(state.values);
306
+ await onSubmit(values);
218
307
  } finally {
219
308
  dispatch({ type: 'SUBMIT_END' });
220
309
  }
221
- }, [state.values, validate, onSubmit]);
310
+ }, [validate, onSubmit]);
222
311
 
223
312
  const reset = useCallback((values: T = defaultsRef.current) => {
313
+ valuesRef.current = values;
224
314
  dispatch({ type: 'RESET', values });
225
315
  }, []);
226
316
 
@@ -14,13 +14,38 @@
14
14
  * ```tsx
15
15
  * <Input label="Email" placeholder="you@example.com" />
16
16
  * <Input variant="filled" size="lg" label="Name" isRequired />
17
+ * <Input label="Note" startContent={<PencilIcon size={18} />} />
17
18
  * ```
18
19
  *
19
- * For a field with an icon or a button attached, reach for `InputGroup` — it
20
- * measures its decorators and pads this field around them.
20
+ * ## An icon inside the field, and why it lives here
21
+ *
22
+ * `startContent` and `endContent` are measured and turned into padding on the
23
+ * text, so a value never runs underneath them however wide they turn out to
24
+ * be. They are positioned against the *field box* rather than against the
25
+ * component, which is the whole reason they are a prop rather than something
26
+ * you compose: a label and a description are laid out above and below the
27
+ * field, so anything centred on the component as a whole drifts upward the
28
+ * moment a label is added.
29
+ *
30
+ * Both are pressable. A button dropped in there — a clear ✕, a show-password
31
+ * eye — gets its own touches, while the padding around it stays transparent so
32
+ * a tap that misses still lands on the field and puts the caret in it. An icon
33
+ * that is only decoration should say so with `interactiveContent={false}`,
34
+ * which gives the whole field back to the caret and takes the icon out of the
35
+ * accessibility tree.
36
+ *
37
+ * `InputGroup` is still the right answer for a decorator that is not part of
38
+ * the field — a button attached to its end, a select bolted to its start, an
39
+ * addon with its own background. It measures the same way; it just spans a
40
+ * different box.
21
41
  */
22
- import { forwardRef, useCallback, useEffect, useState } from 'react';
23
- import { TextInput, View, type TextInputProps } from 'react-native';
42
+ import { forwardRef, useCallback, useEffect, useState, type ReactNode } from 'react';
43
+ import {
44
+ TextInput,
45
+ View,
46
+ type LayoutChangeEvent,
47
+ type TextInputProps,
48
+ } from 'react-native';
24
49
  import Animated, {
25
50
  interpolateColor,
26
51
  useAnimatedStyle,
@@ -30,8 +55,9 @@ import Animated, {
30
55
  import { tv, type VariantProps } from 'tailwind-variants';
31
56
  import { useCSSVariable } from 'uniwind';
32
57
  import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
58
+ import { IconColorProvider } from '../../icons';
33
59
  import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
34
- import { Text } from '../../primitives/text';
60
+ import { Text, textChildren } from '../../primitives/text';
35
61
  import { Label } from '../label';
36
62
 
37
63
  /** Long enough to read as a transition, short enough not to lag a fast tab. */
@@ -40,9 +66,25 @@ const FOCUS_DURATION = 150;
40
66
  const inputVariants = tv({
41
67
  slots: {
42
68
  container: 'w-full gap-1.5',
43
- field: 'w-full rounded-lg border text-foreground',
69
+ /*
70
+ * `font-normal` on a field that is not bold: this is a TextInput, so it
71
+ * does not go through the Text primitive and inherits nothing. The class
72
+ * is what an app's `--font-normal` token attaches to, and without it a
73
+ * custom font would stop at the edge of every field in the library.
74
+ */
75
+ field: 'w-full rounded-lg border font-normal text-foreground',
44
76
  description: 'text-sm text-muted-foreground',
45
77
  error: 'text-sm text-destructive',
78
+ /*
79
+ * The box the content is positioned against, and the reason this works
80
+ * where wrapping the whole component does not: it is the field alone, so
81
+ * a label above it and a description below it cannot move what is inside.
82
+ */
83
+ fieldBox: 'relative w-full',
84
+ startContent:
85
+ 'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2',
86
+ endContent:
87
+ 'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2',
46
88
  },
47
89
  variants: {
48
90
  variant: {
@@ -60,19 +102,33 @@ const inputVariants = tv({
60
102
  * below the middle of the box, a few pixels off whatever is beside them.
61
103
  * A length sets the size alone and leaves the line box the font's own.
62
104
  */
63
- sm: { field: 'h-10 px-3 text-[14px]' },
64
- md: { field: 'h-12 px-3.5 text-[16px]' },
65
- lg: { field: 'h-14 px-4 text-[16px]' },
105
+ // The content boxes take the field's own horizontal padding, so an icon
106
+ // sits at exactly the inset the text would have started at.
107
+ sm: { field: 'h-10 px-3 text-[14px]', startContent: 'px-3', endContent: 'px-3' },
108
+ md: { field: 'h-12 px-3.5 text-[16px]', startContent: 'px-3.5', endContent: 'px-3.5' },
109
+ lg: { field: 'h-14 px-4 text-[16px]', startContent: 'px-4', endContent: 'px-4' },
66
110
  },
67
111
  multiline: {
68
112
  // A multiline field grows, so a fixed height would crop it. Text starts
69
113
  // at the top rather than floating in the middle of an empty box — and
70
114
  // once there are several lines the leading is wanted back, since here it
71
115
  // separates the lines instead of pushing one off centre.
72
- true: { field: 'h-auto min-h-24 py-3 leading-normal' },
116
+ //
117
+ // The content follows the text to the top for the same reason: centred
118
+ // in a box that grows, an icon slides further from the line it belongs
119
+ // to with every line typed.
120
+ true: {
121
+ field: 'h-auto min-h-24 py-3 leading-normal',
122
+ startContent: 'bottom-auto py-3',
123
+ endContent: 'bottom-auto py-3',
124
+ },
73
125
  },
74
126
  disabled: {
75
- true: { field: 'opacity-[0.64]' },
127
+ true: {
128
+ field: 'opacity-[0.64]',
129
+ startContent: 'opacity-[0.64]',
130
+ endContent: 'opacity-[0.64]',
131
+ },
76
132
  },
77
133
  },
78
134
  defaultVariants: {
@@ -95,6 +151,31 @@ export interface InputProps
95
151
  /** Marks the field required — an asterisk on the label, and the a11y state. */
96
152
  isRequired?: boolean;
97
153
  disabled?: boolean;
154
+ /**
155
+ * Content inside the field, before the text — usually an icon. Measured, so
156
+ * the text is padded clear of it however wide it is.
157
+ *
158
+ * `start`, not `left`: it follows the reading direction, and swaps sides
159
+ * under `Direction dir="rtl"` along with the text it introduces.
160
+ */
161
+ startContent?: ReactNode;
162
+ /** Content inside the field, after the text — an icon, a unit, a count. */
163
+ endContent?: ReactNode;
164
+ /**
165
+ * Whether touches reach the content.
166
+ *
167
+ * On by default, so a button placed in the field — a clear ✕, a
168
+ * show-password eye, a unit picker — is pressable without anything else
169
+ * being passed. Only the content itself takes those touches: the padding
170
+ * around it is transparent, so a tap that misses the button still lands on
171
+ * the field and puts the caret in it.
172
+ *
173
+ * Turn it off for pure decoration, where the icon should not be a target at
174
+ * all and every pixel of the field should focus it. That also drops the
175
+ * content from the accessibility tree, which is right for an icon that only
176
+ * restates the label.
177
+ */
178
+ interactiveContent?: boolean;
98
179
  /**
99
180
  * Keep the field clear of the software keyboard. Moves by exactly the
100
181
  * overlap, and not at all when the field is already clear — or when the
@@ -136,6 +217,9 @@ export const Input = forwardRef<TextInput, InputProps>(
136
217
  errorMessage,
137
218
  isRequired,
138
219
  disabled,
220
+ startContent,
221
+ endContent,
222
+ interactiveContent = true,
139
223
  variant,
140
224
  size,
141
225
  avoidKeyboard = false,
@@ -151,6 +235,10 @@ export const Input = forwardRef<TextInput, InputProps>(
151
235
  ) => {
152
236
  const [focused, setFocused] = useState(false);
153
237
  const invalid = !!errorMessage;
238
+ // Measured so the text can be padded clear of the content, whatever it
239
+ // turns out to be — an icon, a unit, a two-word label.
240
+ const [startWidth, setStartWidth] = useState(0);
241
+ const [endWidth, setEndWidth] = useState(0);
154
242
 
155
243
  const placeholderColor = useCSSVariable('--color-muted-foreground');
156
244
  const restColor = useCSSVariable('--color-input');
@@ -207,6 +295,49 @@ export const Input = forwardRef<TextInput, InputProps>(
207
295
  [onBlur]
208
296
  );
209
297
 
298
+ const handleStartLayout = useCallback((event: LayoutChangeEvent) => {
299
+ setStartWidth(event.nativeEvent.layout.width);
300
+ }, []);
301
+
302
+ const handleEndLayout = useCallback((event: LayoutChangeEvent) => {
303
+ setEndWidth(event.nativeEvent.layout.width);
304
+ }, []);
305
+
306
+ /*
307
+ * The content is quieter than the value. An icon at the same weight as the
308
+ * text reads as part of what was typed, and the placeholder colour is
309
+ * already the field's own word for "not the value".
310
+ */
311
+ const iconColor = typeof placeholderColor === 'string' ? placeholderColor : undefined;
312
+
313
+ const field = (
314
+ <AnimatedTextInput
315
+ ref={ref}
316
+ editable={!disabled}
317
+ onFocus={handleFocus}
318
+ onBlur={handleBlur}
319
+ accessibilityLabel={label}
320
+ accessibilityState={{ disabled: !!disabled }}
321
+ aria-required={isRequired}
322
+ aria-invalid={invalid}
323
+ className={slots.field({ className })}
324
+ style={[
325
+ borderStyle,
326
+ // Only the side that has content, and only once it has been
327
+ // measured — a 0 here would wipe out the field's own padding.
328
+ startWidth ? { paddingStart: startWidth } : null,
329
+ endWidth ? { paddingEnd: endWidth } : null,
330
+ // Caller styles come last so InputGroup can pad the field around its
331
+ // decorators, but never last enough to drop the animated border.
332
+ style,
333
+ ]}
334
+ placeholderTextColor={
335
+ typeof placeholderColor === 'string' ? placeholderColor : undefined
336
+ }
337
+ {...props}
338
+ />
339
+ );
340
+
210
341
  const body = (
211
342
  <>
212
343
  {label ? (
@@ -214,24 +345,49 @@ export const Input = forwardRef<TextInput, InputProps>(
214
345
  {label}
215
346
  </Label>
216
347
  ) : null}
217
- <AnimatedTextInput
218
- ref={ref}
219
- editable={!disabled}
220
- onFocus={handleFocus}
221
- onBlur={handleBlur}
222
- accessibilityLabel={label}
223
- accessibilityState={{ disabled: !!disabled }}
224
- aria-required={isRequired}
225
- aria-invalid={invalid}
226
- className={slots.field({ className })}
227
- // Caller styles come last so InputGroup can pad the field around its
228
- // decorators, but never last enough to drop the animated border.
229
- style={[borderStyle, style]}
230
- placeholderTextColor={
231
- typeof placeholderColor === 'string' ? placeholderColor : undefined
232
- }
233
- {...props}
234
- />
348
+ {startContent || endContent ? (
349
+ /*
350
+ * Only wrapped when there is something to position. An extra view
351
+ * around every field in an app is a real cost in a long form, and
352
+ * the field is `w-full` either way so the wrapper changes nothing
353
+ * else about the layout.
354
+ */
355
+ <View className={slots.fieldBox()}>
356
+ {startContent ? (
357
+ <View
358
+ onLayout={handleStartLayout}
359
+ pointerEvents={interactiveContent ? 'box-none' : 'none'}
360
+ accessibilityElementsHidden={!interactiveContent}
361
+ importantForAccessibility={
362
+ interactiveContent ? 'auto' : 'no-hide-descendants'
363
+ }
364
+ className={slots.startContent()}
365
+ >
366
+ <IconColorProvider color={iconColor}>
367
+ {textChildren(startContent)}
368
+ </IconColorProvider>
369
+ </View>
370
+ ) : null}
371
+ {field}
372
+ {endContent ? (
373
+ <View
374
+ onLayout={handleEndLayout}
375
+ pointerEvents={interactiveContent ? 'box-none' : 'none'}
376
+ accessibilityElementsHidden={!interactiveContent}
377
+ importantForAccessibility={
378
+ interactiveContent ? 'auto' : 'no-hide-descendants'
379
+ }
380
+ className={slots.endContent()}
381
+ >
382
+ <IconColorProvider color={iconColor}>
383
+ {textChildren(endContent)}
384
+ </IconColorProvider>
385
+ </View>
386
+ ) : null}
387
+ </View>
388
+ ) : (
389
+ field
390
+ )}
235
391
  {errorMessage ? (
236
392
  <Text className={slots.error()}>{errorMessage}</Text>
237
393
  ) : description ? (
@@ -49,7 +49,10 @@ const otpVariants = tv({
49
49
  container: 'gap-1.5',
50
50
  row: 'flex-row items-center',
51
51
  cell: 'items-center justify-center rounded-lg border border-input bg-background',
52
- char: 'text-foreground',
52
+ /* The typed character is drawn by an Animated.Text rather than the Text
53
+ primitive, so its weight class has to be written here for an app's
54
+ --font-normal token to reach it. */
55
+ char: 'font-normal text-foreground',
53
56
  caret: 'w-0.5 rounded-full bg-foreground',
54
57
  separator: 'h-0.5 rounded-full bg-border',
55
58
  error: 'text-sm text-destructive',
@@ -740,7 +740,7 @@ function PanelsideSearch({
740
740
  * text and the placeholder sit below the middle of the row. A length
741
741
  * sets the size alone and leaves the line box the font's own.
742
742
  */
743
- className={cn('h-full flex-1 text-[16px] text-foreground', className)}
743
+ className={cn('h-full flex-1 font-normal text-[16px] text-foreground', className)}
744
744
  style={typeof textTint === 'string' ? { color: textTint } : undefined}
745
745
  accessibilityRole="search"
746
746
  returnKeyType="search"