panelui-native 0.36.0 → 0.40.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 (62) hide show
  1. package/README.md +13 -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/heatmap-chart/index.js +113 -6
  9. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  10. package/lib/module/components/input/index.js +10 -3
  11. package/lib/module/components/input/index.js.map +1 -1
  12. package/lib/module/components/pagination/index.js +519 -0
  13. package/lib/module/components/pagination/index.js.map +1 -0
  14. package/lib/module/components/ring-chart/index.js +183 -45
  15. package/lib/module/components/ring-chart/index.js.map +1 -1
  16. package/lib/module/components/select/index.js +116 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/steps/index.js +8 -6
  19. package/lib/module/components/steps/index.js.map +1 -1
  20. package/lib/module/components/table/index.js +100 -26
  21. package/lib/module/components/table/index.js.map +1 -1
  22. package/lib/module/components/timeline/index.js +8 -7
  23. package/lib/module/components/timeline/index.js.map +1 -1
  24. package/lib/module/index.js +2 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/theme/use-theme.js +1 -1
  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/heatmap-chart/index.d.ts +51 -3
  32. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/input/index.d.ts +18 -4
  34. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/pagination/index.d.ts +285 -0
  36. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/ring-chart/index.d.ts +75 -8
  38. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/select/index.d.ts +32 -0
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/steps/index.d.ts +8 -6
  42. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/table/index.d.ts +59 -17
  44. package/lib/typescript/src/components/table/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/timeline/index.d.ts +8 -7
  46. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  47. package/lib/typescript/src/index.d.ts +4 -2
  48. package/lib/typescript/src/index.d.ts.map +1 -1
  49. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/components/combobox/index.tsx +927 -0
  52. package/src/components/form/use-field.ts +23 -0
  53. package/src/components/form/use-form.ts +106 -16
  54. package/src/components/heatmap-chart/index.tsx +147 -5
  55. package/src/components/input/index.tsx +21 -7
  56. package/src/components/pagination/index.tsx +591 -0
  57. package/src/components/ring-chart/index.tsx +269 -47
  58. package/src/components/select/index.tsx +114 -15
  59. package/src/components/steps/index.tsx +8 -6
  60. package/src/components/table/index.tsx +146 -30
  61. package/src/components/timeline/index.tsx +8 -7
  62. package/src/index.ts +25 -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
 
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * ```tsx
13
13
  * <HeatmapChart data={weeks}>
14
+ * <HeatmapChart.Header title="Contributions" value="1,204" legend />
14
15
  * <HeatmapChart.YAxis />
15
16
  * <HeatmapChart.XAxis />
16
17
  * <HeatmapChart.Cells />
@@ -132,7 +133,7 @@ const MONTHS = [
132
133
  * composition stays a flat list of children instead of four nested slots the
133
134
  * caller has to remember the order of.
134
135
  */
135
- type Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend';
136
+ type Slot = 'cells' | 'x-axis' | 'y-axis' | 'rules' | 'tooltip' | 'legend' | 'header';
136
137
 
137
138
  export type HeatmapLayout = 'fluid' | 'fill';
138
139
 
@@ -566,6 +567,10 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
566
567
  onLayout={onLayout}
567
568
  className={cn('w-full', className)}
568
569
  >
570
+ {/* Above the axis gutter as well as the grid: the header is about the
571
+ whole chart, so it starts at the chart's edge, not the grid's. */}
572
+ {parts.header}
573
+
569
574
  {parts.x ? (
570
575
  <View style={{ paddingLeft: axisWidth }}>{parts.x}</View>
571
576
  ) : null}
@@ -605,6 +610,7 @@ interface Parts {
605
610
  y: React.ReactElement<{ width?: number }> | null;
606
611
  tooltip: ReactNode[];
607
612
  legend: ReactNode[];
613
+ header: ReactNode[];
608
614
  columns: number;
609
615
  }
610
616
 
@@ -617,6 +623,7 @@ function splitParts(children: ReactNode): Parts {
617
623
  y: null,
618
624
  tooltip: [],
619
625
  legend: [],
626
+ header: [],
620
627
  columns: 0,
621
628
  };
622
629
 
@@ -630,6 +637,7 @@ function splitParts(children: ReactNode): Parts {
630
637
  else if (slot === 'rules') parts.rules.push(keyed);
631
638
  else if (slot === 'tooltip') parts.tooltip.push(keyed);
632
639
  else if (slot === 'legend') parts.legend.push(keyed);
640
+ else if (slot === 'header') parts.header.push(keyed);
633
641
  else parts.cells.push(keyed);
634
642
  });
635
643
 
@@ -753,6 +761,12 @@ export interface HeatmapXAxisProps {
753
761
  * derived. Return an empty string to leave the column unlabelled.
754
762
  */
755
763
  formatLabel?: (date: Date, column: number) => string;
764
+ /**
765
+ * Column labels, left to right. Overrides the month names — for a grid whose
766
+ * columns are not weeks, where there is no month to change and so nothing to
767
+ * emit a label on.
768
+ */
769
+ labels?: string[];
756
770
  }
757
771
 
758
772
  /**
@@ -760,13 +774,21 @@ export interface HeatmapXAxisProps {
760
774
  *
761
775
  * A label is emitted where the month changes rather than at a fixed interval,
762
776
  * because months are not the same length — spacing them evenly puts "Mar" over
763
- * a week in February.
777
+ * a week in February. A grid whose columns are not weeks has no such signal, so
778
+ * it passes `labels` and gets one over every column.
764
779
  */
765
- function HeatmapXAxis({ className, formatLabel }: HeatmapXAxisProps) {
780
+ function HeatmapXAxis({ className, formatLabel, labels: given }: HeatmapXAxisProps) {
766
781
  const { data, grid } = useHeatmap('HeatmapChart.XAxis');
767
782
  const step = grid.size + grid.gap;
768
783
 
769
784
  const labels = useMemo(() => {
785
+ if (given) {
786
+ return given
787
+ .slice(0, data.length)
788
+ .map((label, column) => ({ column, label }))
789
+ .filter((entry) => entry.label);
790
+ }
791
+
770
792
  const out: { column: number; label: string }[] = [];
771
793
  let lastMonth = -1;
772
794
 
@@ -784,7 +806,7 @@ function HeatmapXAxis({ className, formatLabel }: HeatmapXAxisProps) {
784
806
  // collides with the next one. Drop it when it has no room.
785
807
  if (out.length > 1 && out[1]!.column - out[0]!.column < 3) out.shift();
786
808
  return out;
787
- }, [data, formatLabel]);
809
+ }, [data, formatLabel, given]);
788
810
 
789
811
  return (
790
812
  <View className={cn('h-4', className)} style={{ width: grid.width }}>
@@ -1002,9 +1024,16 @@ function HeatmapTooltip({
1002
1024
  }
1003
1025
  HeatmapTooltip.slot = 'tooltip' as Slot;
1004
1026
 
1027
+ /*
1028
+ * A cell with no date is not a cell with no data — it is a cell in a grid that
1029
+ * is not a calendar, and it has a count like any other. Saying "No data" over
1030
+ * it reports a hole in the data that is not there. Without a date there is
1031
+ * simply nothing to say after the number, so the number is the whole label,
1032
+ * and a grid that wants a noun for it passes `formatLabel`.
1033
+ */
1005
1034
  function defaultTooltipLabel(cell: HeatmapCell) {
1035
+ if (!cell.date) return `${cell.count}`;
1006
1036
  const count = `${cell.count} ${cell.count === 1 ? 'contribution' : 'contributions'}`;
1007
- if (!cell.date) return 'No data';
1008
1037
  const date = `${MONTHS[cell.date.getMonth()]} ${cell.date.getDate()}`;
1009
1038
  return `${count} on ${date}`;
1010
1039
  }
@@ -1061,7 +1090,120 @@ function HeatmapLegend({
1061
1090
  }
1062
1091
  HeatmapLegend.slot = 'legend' as Slot;
1063
1092
 
1093
+ /* -------------------------------------------------------------------------- */
1094
+ /* Header */
1095
+ /* -------------------------------------------------------------------------- */
1096
+
1097
+ export interface HeatmapHeaderProps extends ViewProps {
1098
+ className?: string;
1099
+ /** Small line above the value — what the grid is of. */
1100
+ title?: string;
1101
+ /** The readout. The largest thing on the card, and the first thing read. */
1102
+ value?: string;
1103
+ /** One muted line under the value — a period, a total, the held cell. */
1104
+ caption?: string;
1105
+ /**
1106
+ * Draw the ramp along the trailing edge, `Less ▢▢▢▢▢ More`. The key for a
1107
+ * grid that scrolls sideways, where `HeatmapChart.Legend` under the cells
1108
+ * would scroll away with them.
1109
+ */
1110
+ legend?: boolean;
1111
+ /** Text at the low end of the ramp, when `legend` is set. */
1112
+ lessLabel?: string;
1113
+ /** Text at the high end. */
1114
+ moreLabel?: string;
1115
+ /** Trailing slot — a control, a badge, a range picker. Wins over `legend`. */
1116
+ children?: ReactNode;
1117
+ }
1118
+
1119
+ /**
1120
+ * The strip above the grid: what the chart is of, what it currently reads, and
1121
+ * what the shading means.
1122
+ *
1123
+ * It belongs to the chart rather than to the card around it because it is about
1124
+ * the *grid* — the number changes as a finger moves across the cells, and the
1125
+ * ramp is the scale the chart itself derived. The card's header is a caption on
1126
+ * the tray the chart sits in; this is the chart introducing itself.
1127
+ *
1128
+ * The value is not derived here. Take it from `onActiveCellChange` and pass the
1129
+ * formatted string down, so one header can show a total when nothing is held
1130
+ * and a day's own count when something is.
1131
+ */
1132
+ function HeatmapHeader({
1133
+ className,
1134
+ title,
1135
+ value,
1136
+ caption,
1137
+ legend = false,
1138
+ lessLabel = 'Less',
1139
+ moreLabel = 'More',
1140
+ children,
1141
+ ...props
1142
+ }: HeatmapHeaderProps) {
1143
+ const { ramp, opacities, grid, cornerRadius } = useHeatmap('HeatmapChart.Header');
1144
+ // Small enough to sit on one line beside the text, whatever the cells are.
1145
+ const size = Math.max(Math.min(grid.size, 12), 8);
1146
+
1147
+ const trailing =
1148
+ children ??
1149
+ (legend ? (
1150
+ <View className="flex-row items-center gap-1.5">
1151
+ <Text size="xs" muted>
1152
+ {lessLabel}
1153
+ </Text>
1154
+ {ramp.map((fill, level) => (
1155
+ <Svg key={level} width={size} height={size}>
1156
+ <Rect
1157
+ x={0}
1158
+ y={0}
1159
+ width={size}
1160
+ height={size}
1161
+ rx={cornerRadius}
1162
+ ry={cornerRadius}
1163
+ fill={fill}
1164
+ fillOpacity={opacities[level]}
1165
+ />
1166
+ </Svg>
1167
+ ))}
1168
+ <Text size="xs" muted>
1169
+ {moreLabel}
1170
+ </Text>
1171
+ </View>
1172
+ ) : null);
1173
+
1174
+ return (
1175
+ <View
1176
+ {...props}
1177
+ className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1178
+ >
1179
+ <View className="flex-1 gap-0.5">
1180
+ {title ? (
1181
+ <Text size="xs" muted>
1182
+ {title}
1183
+ </Text>
1184
+ ) : null}
1185
+ {value ? (
1186
+ <Text size="xl" weight="bold">
1187
+ {value}
1188
+ </Text>
1189
+ ) : null}
1190
+ {caption ? (
1191
+ <Text size="xs" muted>
1192
+ {caption}
1193
+ </Text>
1194
+ ) : null}
1195
+ </View>
1196
+ {/* Shrinkable, unlike a view's default in React Native — held rigid, the
1197
+ ramp takes the width it wants and the caption wraps around it. */}
1198
+ {trailing ? <View className="shrink pt-1">{trailing}</View> : null}
1199
+ </View>
1200
+ );
1201
+ }
1202
+ HeatmapHeader.displayName = 'HeatmapChart.Header';
1203
+ HeatmapHeader.slot = 'header' as Slot;
1204
+
1064
1205
  export const HeatmapChart = Object.assign(HeatmapChartRoot, {
1206
+ Header: HeatmapHeader,
1065
1207
  Cells: HeatmapCells,
1066
1208
  Separator: HeatmapSeparator,
1067
1209
  XAxis: HeatmapXAxis,
@@ -27,6 +27,13 @@
27
27
  * field, so anything centred on the component as a whole drifts upward the
28
28
  * moment a label is added.
29
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
+ *
30
37
  * `InputGroup` is still the right answer for a decorator that is not part of
31
38
  * the field — a button attached to its end, a select bolted to its start, an
32
39
  * addon with its own background. It measures the same way; it just spans a
@@ -155,11 +162,18 @@ export interface InputProps
155
162
  /** Content inside the field, after the text — an icon, a unit, a count. */
156
163
  endContent?: ReactNode;
157
164
  /**
158
- * Let touches reach the content instead of falling through to the field.
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.
159
172
  *
160
- * Off by default: an icon is decoration, and a tap anywhere on a field
161
- * should put the caret in it rather than hitting a dead spot. Turn it on for
162
- * content that does something — a clear button, a show-password toggle.
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.
163
177
  */
164
178
  interactiveContent?: boolean;
165
179
  /**
@@ -205,7 +219,7 @@ export const Input = forwardRef<TextInput, InputProps>(
205
219
  disabled,
206
220
  startContent,
207
221
  endContent,
208
- interactiveContent = false,
222
+ interactiveContent = true,
209
223
  variant,
210
224
  size,
211
225
  avoidKeyboard = false,
@@ -342,7 +356,7 @@ export const Input = forwardRef<TextInput, InputProps>(
342
356
  {startContent ? (
343
357
  <View
344
358
  onLayout={handleStartLayout}
345
- pointerEvents={interactiveContent ? 'auto' : 'none'}
359
+ pointerEvents={interactiveContent ? 'box-none' : 'none'}
346
360
  accessibilityElementsHidden={!interactiveContent}
347
361
  importantForAccessibility={
348
362
  interactiveContent ? 'auto' : 'no-hide-descendants'
@@ -358,7 +372,7 @@ export const Input = forwardRef<TextInput, InputProps>(
358
372
  {endContent ? (
359
373
  <View
360
374
  onLayout={handleEndLayout}
361
- pointerEvents={interactiveContent ? 'auto' : 'none'}
375
+ pointerEvents={interactiveContent ? 'box-none' : 'none'}
362
376
  accessibilityElementsHidden={!interactiveContent}
363
377
  importantForAccessibility={
364
378
  interactiveContent ? 'auto' : 'no-hide-descendants'