@dloizides/ui-forms 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -42,6 +42,40 @@ function resolveFieldMarker(required, optional) {
42
42
  if (optional) return "optional";
43
43
  return "none";
44
44
  }
45
+ var FIELD_STACK_MARGIN_BOTTOM = 16;
46
+ var FIELD_STACK_LABEL_MARGIN_BOTTOM = 4;
47
+ var FIELD_STACK_CHILD_MARGIN_TOP = 4;
48
+ var FIELD_CONTRACT_GAP = 6;
49
+ var fieldSpacingStyles = reactNative.StyleSheet.create({
50
+ stackContainer: { marginBottom: FIELD_STACK_MARGIN_BOTTOM },
51
+ stackLabel: { marginBottom: FIELD_STACK_LABEL_MARGIN_BOTTOM },
52
+ stackChild: { marginTop: FIELD_STACK_CHILD_MARGIN_TOP },
53
+ /**
54
+ * The gap model sets NO margins — not even zeroed ones. An explicit `marginBottom: 0` here would
55
+ * defeat a caller who legitimately wants to push one field down via `containerStyle`, because
56
+ * `containerStyle` composes AFTER this and a zero is indistinguishable from "unset" to a reader.
57
+ */
58
+ gapContainer: { gap: FIELD_CONTRACT_GAP },
59
+ none: {}
60
+ });
61
+ function resolveFieldSpacing(explicit, insideGrid) {
62
+ if (explicit !== void 0) return explicit;
63
+ return insideGrid ? "gap" : "stack";
64
+ }
65
+ function fieldSpacingStyleSet(spacing) {
66
+ if (spacing === "gap") {
67
+ return {
68
+ container: fieldSpacingStyles.gapContainer,
69
+ label: fieldSpacingStyles.none,
70
+ child: fieldSpacingStyles.none
71
+ };
72
+ }
73
+ return {
74
+ container: fieldSpacingStyles.stackContainer,
75
+ label: fieldSpacingStyles.stackLabel,
76
+ child: fieldSpacingStyles.stackChild
77
+ };
78
+ }
45
79
  var FIELD_FONT_SIZE = 13;
46
80
  var FIELD_FONT_WEIGHT = "600";
47
81
  var CONTROL_FONT_SIZE = 11;
@@ -62,27 +96,25 @@ var labelVariantStyles = reactNative.StyleSheet.create({
62
96
  function resolveLabelVariantStyle(variant) {
63
97
  return variant === "control" ? labelVariantStyles.control : labelVariantStyles.field;
64
98
  }
65
- var CONTAINER_MARGIN_BOTTOM = 16;
66
- var LABEL_MARGIN_BOTTOM = 4;
99
+ var FormGridContext = React__default.default.createContext(null);
100
+ var GapOwnedContext = React__default.default.createContext(false);
101
+ var FormGridProvider = FormGridContext.Provider;
102
+ var GapOwnedProvider = GapOwnedContext.Provider;
103
+ function useFormGridMetrics() {
104
+ return React__default.default.useContext(FormGridContext);
105
+ }
106
+ function useIsGapOwned() {
107
+ return React__default.default.useContext(GapOwnedContext);
108
+ }
67
109
  var ERROR_FONT_SIZE = 12;
68
- var ERROR_MARGIN_TOP = 4;
69
110
  var HINT_FONT_SIZE = 12;
70
- var HINT_MARGIN_TOP = 4;
71
111
  var OPTIONAL_MARK_FONT_WEIGHT = "400";
72
112
  var styles = reactNative.StyleSheet.create({
73
- container: {
74
- marginBottom: CONTAINER_MARGIN_BOTTOM
75
- },
76
- label: {
77
- marginBottom: LABEL_MARGIN_BOTTOM
78
- },
79
113
  hintText: {
80
- fontSize: HINT_FONT_SIZE,
81
- marginTop: HINT_MARGIN_TOP
114
+ fontSize: HINT_FONT_SIZE
82
115
  },
83
116
  errorText: {
84
- fontSize: ERROR_FONT_SIZE,
85
- marginTop: ERROR_MARGIN_TOP
117
+ fontSize: ERROR_FONT_SIZE
86
118
  },
87
119
  optionalMark: {
88
120
  fontWeight: OPTIONAL_MARK_FONT_WEIGHT
@@ -118,9 +150,12 @@ var Field = ({
118
150
  labelVariant,
119
151
  labelStyle,
120
152
  containerStyle,
153
+ spacing,
121
154
  testID
122
155
  }) => {
123
156
  const { theme } = uiFeedback.useUi();
157
+ const gapOwned = useIsGapOwned();
158
+ const spacingStyles = fieldSpacingStyleSet(resolveFieldSpacing(spacing, gapOwned));
124
159
  const { colors, semantic } = theme;
125
160
  const errorColor = semantic.error["500"];
126
161
  const hasError = hasFieldError(error);
@@ -142,7 +177,7 @@ var Field = ({
142
177
  );
143
178
  const describedById = joinDescribedBy(hasHint ? hintId : void 0, hasError ? errorId : void 0);
144
179
  const control = typeof children === "function" ? children({ describedById, hasError }) : children;
145
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles.container, containerStyle], testID, children: [
180
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [spacingStyles.container, containerStyle], testID, children: [
146
181
  hasLabel ? /* @__PURE__ */ jsxRuntime.jsx(
147
182
  FieldLabel,
148
183
  {
@@ -151,16 +186,107 @@ var Field = ({
151
186
  optionalLabel,
152
187
  optionalMarkStyle: [styles.optionalMark, themeStyles.optionalMark],
153
188
  requiredMarkStyle: themeStyles.requiredMark,
154
- style: [styles.label, resolveLabelVariantStyle(labelVariant), themeStyles.label, labelStyle]
189
+ style: [spacingStyles.label, resolveLabelVariantStyle(labelVariant), themeStyles.label, labelStyle]
155
190
  }
156
191
  ) : null,
157
192
  control,
158
- hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: hintId, style: [styles.hintText, themeStyles.hintText], children: hint }) : null,
159
- hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: errorId, role: "alert", style: [styles.errorText, themeStyles.errorText], children: error }) : null
193
+ hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: hintId, style: [styles.hintText, spacingStyles.child, themeStyles.hintText], children: hint }) : null,
194
+ hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { nativeID: errorId, role: "alert", style: [styles.errorText, spacingStyles.child, themeStyles.errorText], children: error }) : null
160
195
  ] });
161
196
  };
162
- var ACTIONS_GAP = 12;
197
+ var FORM_GRID_DEFAULT_GAP = 12;
198
+ var FORM_GRID_DEFAULT_MIN_WIDTH = 220;
199
+ var FORM_GRID_DEFAULT_BASIS = 260;
163
200
  var styles2 = reactNative.StyleSheet.create({
201
+ grid: {
202
+ flexDirection: "row",
203
+ flexWrap: "wrap"
204
+ }
205
+ });
206
+ var FormGrid = ({
207
+ children,
208
+ gap = FORM_GRID_DEFAULT_GAP,
209
+ minWidth = FORM_GRID_DEFAULT_MIN_WIDTH,
210
+ basis = FORM_GRID_DEFAULT_BASIS,
211
+ style,
212
+ testID
213
+ }) => {
214
+ const metrics = React__default.default.useMemo(() => ({ minWidth, basis }), [minWidth, basis]);
215
+ return /* @__PURE__ */ jsxRuntime.jsx(FormGridProvider, { value: metrics, children: /* @__PURE__ */ jsxRuntime.jsx(GapOwnedProvider, { value: true, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles2.grid, { gap }, style], testID, children }) }) });
216
+ };
217
+ var GROW = 1;
218
+ var NO_GROW = 0;
219
+ var FormCell = ({
220
+ children,
221
+ minWidth,
222
+ basis,
223
+ grow = true,
224
+ style,
225
+ testID
226
+ }) => {
227
+ const metrics = useFormGridMetrics();
228
+ const resolvedMinWidth = minWidth ?? metrics?.minWidth ?? FORM_GRID_DEFAULT_MIN_WIDTH;
229
+ const resolvedBasis = basis ?? metrics?.basis ?? FORM_GRID_DEFAULT_BASIS;
230
+ const cellStyle = {
231
+ minWidth: resolvedMinWidth,
232
+ flexBasis: resolvedBasis,
233
+ flexGrow: grow ? GROW : NO_GROW
234
+ };
235
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [cellStyle, style], testID, children });
236
+ };
237
+ var SECTION_GAP = 10;
238
+ var DESCRIPTION_FONT_SIZE = 12;
239
+ var styles3 = reactNative.StyleSheet.create({
240
+ section: {
241
+ gap: SECTION_GAP
242
+ },
243
+ description: {
244
+ fontSize: DESCRIPTION_FONT_SIZE
245
+ }
246
+ });
247
+ var sectionSeq = 0;
248
+ function useSectionLegendId() {
249
+ const [id] = React__default.default.useState(() => {
250
+ sectionSeq += 1;
251
+ return `form-section-legend-${String(sectionSeq)}`;
252
+ });
253
+ return id;
254
+ }
255
+ var FormSection = ({
256
+ title,
257
+ description,
258
+ children,
259
+ labelVariant,
260
+ titleStyle,
261
+ style,
262
+ testID
263
+ }) => {
264
+ const { theme } = uiFeedback.useUi();
265
+ const { colors } = theme;
266
+ const legendId = useSectionLegendId();
267
+ const hasTitle = typeof title === "string" && title !== "";
268
+ const hasDescription = typeof description === "string" && description !== "";
269
+ const a11y = { "aria-labelledby": hasTitle ? legendId : void 0 };
270
+ return (
271
+ // The section spaces its children with `gap`, so it owns their vertical rhythm exactly as a
272
+ // `FormGrid` owns a row's — without this a `Field` placed directly in a section would stack its
273
+ // 16px margin on the section's gap and recreate the F3 bug one level up.
274
+ /* @__PURE__ */ jsxRuntime.jsx(GapOwnedProvider, { value: true, children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { role: "group", style: [styles3.section, style], testID, ...a11y, children: [
275
+ hasTitle ? /* @__PURE__ */ jsxRuntime.jsx(
276
+ reactNative.Text,
277
+ {
278
+ nativeID: legendId,
279
+ style: [resolveLabelVariantStyle(labelVariant), { color: colors.text }, titleStyle],
280
+ children: title
281
+ }
282
+ ) : null,
283
+ hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.description, { color: colors.textSecondary }], children: description }) : null,
284
+ children
285
+ ] }) })
286
+ );
287
+ };
288
+ var ACTIONS_GAP = 12;
289
+ var styles4 = reactNative.StyleSheet.create({
164
290
  container: {
165
291
  flexDirection: "row",
166
292
  justifyContent: "flex-end",
@@ -180,7 +306,7 @@ var FormActions = ({
180
306
  saveTestID = "save-button",
181
307
  cancelTestID = "cancel-button",
182
308
  testID
183
- }) => /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles2.container, containerStyle], testID, children: [
309
+ }) => /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles4.container, containerStyle], testID, children: [
184
310
  typeof onCancel === "function" ? /* @__PURE__ */ jsxRuntime.jsx(
185
311
  uiButtons.Button,
186
312
  {
@@ -333,7 +459,7 @@ var INPUT_PADDING_V = 10;
333
459
  var INPUT_PADDING_H = 12;
334
460
  var INPUT_BORDER_RADIUS = 8;
335
461
  var INPUT_BORDER_WIDTH = 1;
336
- var styles3 = reactNative.StyleSheet.create({
462
+ var styles5 = reactNative.StyleSheet.create({
337
463
  input: {
338
464
  paddingVertical: INPUT_PADDING_V,
339
465
  paddingHorizontal: INPUT_PADDING_H,
@@ -355,7 +481,7 @@ var FormField = ({
355
481
  describedById,
356
482
  hasError,
357
483
  requiredField: required,
358
- style: styles3.input,
484
+ style: styles5.input,
359
485
  testID: "form-field-input",
360
486
  ...textInputProps
361
487
  }
@@ -393,18 +519,22 @@ function withAlpha2(hex, alpha) {
393
519
  const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
394
520
  return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
395
521
  }
396
- var styles4 = reactNative.StyleSheet.create({
522
+ var styles6 = reactNative.StyleSheet.create({
397
523
  chipContainer: {
398
524
  flexDirection: "row",
399
525
  flexWrap: "wrap",
400
- // Each chip's bottom gutter also hangs off the LAST row, stacking on `Field`'s container
401
- // margin (8 + 16 = 24) and dropping the block below the `FormField` beside it. Cancelling it
402
- // is standard gutter compensation: inner rows keep their 8px, the block ends at Field's 16.
403
- marginBottom: -CHIP_GUTTER
404
- },
405
- chipWrapper: {
406
- marginRight: CHIP_GUTTER,
407
- marginBottom: CHIP_GUTTER
526
+ // `gap`, NOT per-chip margins plus a compensating negative container margin.
527
+ //
528
+ // The old form was `chipWrapper { marginRight: 8, marginBottom: 8 }` with
529
+ // `chipContainer { marginBottom: -8 }`. Its comment blamed `Field`'s 16px container margin —
530
+ // that was WRONG, and worth recording: F3 was briefed on the belief that the -8 compensated for
531
+ // `Field` and had to move in lockstep with it. It did not. -8 cancelled the CHIP's own gutter
532
+ // hanging off the last row, identically whether `Field` contributed 16, 0, or anything else.
533
+ //
534
+ // The pair is still the wrong tool: it holds only while the two constants agree, and it leaves
535
+ // a real negative margin any measuring parent inherits. `gap` produces the byte-identical box
536
+ // and is correct under BOTH of `Field`'s spacing models, not just the one it was tuned against.
537
+ gap: CHIP_GUTTER
408
538
  },
409
539
  chip: {
410
540
  paddingHorizontal: 12,
@@ -456,12 +586,11 @@ function OutlineChip({
456
586
  "aria-pressed": ariaPressed,
457
587
  disabled,
458
588
  hitSlop: CHIP_HIT_SLOP,
459
- style: styles4.chipWrapper,
460
589
  testID,
461
590
  onHoverIn: () => setHovered(true),
462
591
  onHoverOut: () => setHovered(false),
463
592
  onPress,
464
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles4.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.chipText, styles4.chipTextOutline, { color: textColor }], children: option.label }) })
593
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles6.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.chipText, styles6.chipTextOutline, { color: textColor }], children: option.label }) })
465
594
  }
466
595
  );
467
596
  }
@@ -487,10 +616,9 @@ function SolidChip({
487
616
  "aria-pressed": ariaPressed,
488
617
  disabled,
489
618
  hitSlop: CHIP_HIT_SLOP,
490
- style: styles4.chipWrapper,
491
619
  testID,
492
620
  onPress,
493
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles4.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.chipText, { color: textColor }], children: option.label }) })
621
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles6.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.chipText, { color: textColor }], children: option.label }) })
494
622
  }
495
623
  );
496
624
  }
@@ -508,6 +636,7 @@ var ChipSelector = ({
508
636
  disabled = false,
509
637
  variant = "solid",
510
638
  containerStyle,
639
+ spacing,
511
640
  required = false,
512
641
  error,
513
642
  testID,
@@ -550,10 +679,21 @@ var ChipSelector = ({
550
679
  const key = String(option.value);
551
680
  return variant === "outline" ? /* @__PURE__ */ jsxRuntime.jsx(OutlineChip, { ...chipProps }, key) : /* @__PURE__ */ jsxRuntime.jsx(SolidChip, { ...chipProps }, key);
552
681
  }
553
- return /* @__PURE__ */ jsxRuntime.jsx(Field, { containerStyle, error, label, required, testID, children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles4.chipContainer, ...groupA11yProps(describedById, hasError), children: options.map(renderChip) }) });
682
+ return /* @__PURE__ */ jsxRuntime.jsx(
683
+ Field,
684
+ {
685
+ containerStyle,
686
+ error,
687
+ label,
688
+ required,
689
+ spacing,
690
+ testID,
691
+ children: ({ describedById, hasError }) => /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles6.chipContainer, ...groupA11yProps(describedById, hasError), children: options.map(renderChip) })
692
+ }
693
+ );
554
694
  };
555
- var DESCRIPTION_FONT_SIZE = 12;
556
- var styles5 = reactNative.StyleSheet.create({
695
+ var DESCRIPTION_FONT_SIZE2 = 12;
696
+ var styles7 = reactNative.StyleSheet.create({
557
697
  container: {
558
698
  flexDirection: "row",
559
699
  justifyContent: "space-between",
@@ -567,7 +707,7 @@ var styles5 = reactNative.StyleSheet.create({
567
707
  fontWeight: "600"
568
708
  },
569
709
  description: {
570
- fontSize: DESCRIPTION_FONT_SIZE,
710
+ fontSize: DESCRIPTION_FONT_SIZE2,
571
711
  marginTop: 2
572
712
  }
573
713
  });
@@ -593,13 +733,13 @@ var FormSwitch = ({
593
733
  reactNative.View,
594
734
  {
595
735
  style: [
596
- styles5.container,
736
+ styles7.container,
597
737
  containerStyle
598
738
  ],
599
739
  children: [
600
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles5.textColumn, children: [
601
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.label, themeStyles.label], children: label }),
602
- hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.description, themeStyles.description], children: description }) : null
740
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles7.textColumn, children: [
741
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.label, themeStyles.label], children: label }),
742
+ hasDescription ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.description, themeStyles.description], children: description }) : null
603
743
  ] }),
604
744
  /* @__PURE__ */ jsxRuntime.jsx(
605
745
  reactNative.Switch,
@@ -624,7 +764,7 @@ var BOX_SIZE = 20;
624
764
  var BOX_RADIUS = 5;
625
765
  var CHECK_FONT_SIZE = 14;
626
766
  var DISABLED_OPACITY = 0.5;
627
- var styles6 = reactNative.StyleSheet.create({
767
+ var styles8 = reactNative.StyleSheet.create({
628
768
  container: {
629
769
  flexDirection: "row",
630
770
  alignItems: "flex-start",
@@ -706,28 +846,439 @@ var FormCheckbox = ({
706
846
  disabled,
707
847
  onPress: toggle,
708
848
  role: "checkbox",
709
- style: [styles6.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
849
+ style: [styles8.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
710
850
  testID,
711
851
  ...keyboardProps,
712
852
  children: [
713
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles6.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
714
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles6.textColumn, children: [
715
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.label, themeStyles.label], children: label }),
716
- hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.hint, themeStyles.hint], children: hintText }) : null
853
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [styles8.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
854
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles8.textColumn, children: [
855
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.label, themeStyles.label], children: label }),
856
+ hasHint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.hint, themeStyles.hint], children: hintText }) : null
717
857
  ] })
718
858
  ]
719
859
  }
720
860
  );
721
861
  };
862
+ var FIELD_GAP = 4;
863
+ var LABEL_FONT = 11;
864
+ var LABEL_LETTER_SPACING = 0.4;
865
+ var INPUT_RADIUS = 8;
866
+ var INPUT_PAD_H = 12;
867
+ var INPUT_PAD_V = 10;
868
+ var INPUT_FONT = 14;
869
+ var INPUT_BORDER = 1;
870
+ var DATE_ROW_GAP = 8;
871
+ var CHEVRON_FONT = 10;
872
+ var MENU_TOP_GAP = 4;
873
+ var MENU_RADIUS = 8;
874
+ var MENU_PAD_V = 4;
875
+ var MENU_OPT_PAD_H = 12;
876
+ var MENU_OPT_PAD_V = 8;
877
+ var MENU_MIN_WIDTH = 160;
878
+ var MENU_MAX_HEIGHT = 260;
879
+ var MENU_Z = 1e3;
880
+ var MENU_ELEVATION = 8;
881
+ var MENU_BOX_SHADOW = "0px 2px 8px rgba(0, 0, 0, 0.15)";
882
+ var OPTION_FONT = 14;
883
+ var ERROR_FONT = 12;
884
+ var ERROR_GAP = 3;
885
+ var controlStyles = reactNative.StyleSheet.create({
886
+ /** The shared bordered box every dense text control renders in. */
887
+ input: {
888
+ borderWidth: INPUT_BORDER,
889
+ borderRadius: INPUT_RADIUS,
890
+ paddingHorizontal: INPUT_PAD_H,
891
+ paddingVertical: INPUT_PAD_V,
892
+ fontSize: INPUT_FONT
893
+ },
894
+ /** The select trigger: a bordered field box matching the text inputs + a chevron. */
895
+ selectTrigger: {
896
+ flexDirection: "row",
897
+ alignItems: "center",
898
+ justifyContent: "space-between",
899
+ gap: DATE_ROW_GAP,
900
+ borderWidth: INPUT_BORDER,
901
+ borderRadius: INPUT_RADIUS,
902
+ paddingHorizontal: INPUT_PAD_H,
903
+ paddingVertical: INPUT_PAD_V
904
+ },
905
+ selectTriggerText: { fontSize: INPUT_FONT, flexShrink: 1 },
906
+ chevron: { fontSize: CHEVRON_FONT },
907
+ dateRow: { flexDirection: "row", gap: DATE_ROW_GAP },
908
+ dateCol: { gap: FIELD_GAP, flex: 1 },
909
+ /**
910
+ * The micro-caption above a sub-control (a date range's From/To) and beside a switch. Note it
911
+ * is deliberately NOT the `control` label voice: it carries no `700`/uppercase, because it
912
+ * names a HALF of a control that already has a `control`-voice label above it. Uppercasing it
913
+ * too would make one field shout twice.
914
+ */
915
+ subLabel: { fontSize: LABEL_FONT, letterSpacing: LABEL_LETTER_SPACING },
916
+ /** Positioning context for a floating `AnchoredMenu` (`position:absolute; top:100%`). */
917
+ anchor: { position: "relative" },
918
+ menu: {
919
+ position: "absolute",
920
+ top: "100%",
921
+ left: 0,
922
+ marginTop: MENU_TOP_GAP,
923
+ minWidth: MENU_MIN_WIDTH,
924
+ maxHeight: MENU_MAX_HEIGHT,
925
+ borderWidth: INPUT_BORDER,
926
+ borderRadius: MENU_RADIUS,
927
+ paddingVertical: MENU_PAD_V,
928
+ zIndex: MENU_Z,
929
+ boxShadow: MENU_BOX_SHADOW,
930
+ elevation: MENU_ELEVATION,
931
+ overflow: "hidden"
932
+ },
933
+ option: { paddingHorizontal: MENU_OPT_PAD_H, paddingVertical: MENU_OPT_PAD_V },
934
+ optionText: { fontSize: OPTION_FONT },
935
+ error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
936
+ });
937
+ var IS_WEB4 = typeof document !== "undefined";
938
+ var ESCAPE_KEY = "Escape";
939
+ var ACTIVE_WEIGHT = "700";
940
+ var IDLE_WEIGHT = "400";
941
+ function useDismiss(anchorRef, menuRef, onDismiss) {
942
+ React.useEffect(() => {
943
+ if (!IS_WEB4) return void 0;
944
+ const onMouseDown = (event) => {
945
+ const anchorNode = anchorRef.current;
946
+ const menuNode = menuRef.current;
947
+ const target = event.target instanceof Node ? event.target : null;
948
+ const insideAnchor = anchorNode !== null && target !== null && anchorNode.contains(target);
949
+ const insideMenu = menuNode !== null && target !== null && menuNode.contains(target);
950
+ if (!insideAnchor && !insideMenu) onDismiss();
951
+ };
952
+ const onKeyDown = (event) => {
953
+ if (event.key === ESCAPE_KEY) onDismiss();
954
+ };
955
+ document.addEventListener("mousedown", onMouseDown);
956
+ document.addEventListener("keydown", onKeyDown);
957
+ return () => {
958
+ document.removeEventListener("mousedown", onMouseDown);
959
+ document.removeEventListener("keydown", onKeyDown);
960
+ };
961
+ }, [anchorRef, menuRef, onDismiss]);
962
+ }
963
+ function AnchoredMenu({
964
+ options,
965
+ selectedValue,
966
+ onSelect,
967
+ onDismiss,
968
+ colors,
969
+ optionHint,
970
+ testID,
971
+ anchorRef
972
+ }) {
973
+ const menuRef = React__default.default.useRef(null);
974
+ useDismiss(anchorRef, menuRef, onDismiss);
975
+ return /* @__PURE__ */ jsxRuntime.jsx(
976
+ reactNative.View,
977
+ {
978
+ ref: menuRef,
979
+ accessibilityRole: "menu",
980
+ style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }],
981
+ testID: `${testID}-menu`,
982
+ children: options.map((opt) => {
983
+ const active = opt.value === selectedValue;
984
+ return /* @__PURE__ */ jsxRuntime.jsx(
985
+ reactNative.Pressable,
986
+ {
987
+ accessibilityRole: "menuitem",
988
+ accessibilityLabel: opt.label,
989
+ accessibilityHint: optionHint,
990
+ accessibilityState: { selected: active },
991
+ "aria-selected": active,
992
+ onPress: () => onSelect(opt.value),
993
+ style: controlStyles.option,
994
+ testID: `${testID}-option-${opt.value}`,
995
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
996
+ },
997
+ opt.value
998
+ );
999
+ })
1000
+ }
1001
+ );
1002
+ }
1003
+ var CHEVRON_DOWN = "\u25BE";
1004
+ var CHEVRON_UP = "\u25B4";
1005
+ function SelectControl({
1006
+ options,
1007
+ value,
1008
+ onChange,
1009
+ placeholder = "",
1010
+ accessibilityLabel,
1011
+ accessibilityHint,
1012
+ optionHint = "",
1013
+ style,
1014
+ testID
1015
+ }) {
1016
+ const { theme } = uiFeedback.useUi();
1017
+ const { colors, palette } = theme;
1018
+ const anchorRef = React.useRef(null);
1019
+ const [isOpen, setIsOpen] = React.useState(false);
1020
+ const selectedLabel = React.useMemo(() => {
1021
+ const found = options.find((o) => o.value === value);
1022
+ return found?.label ?? placeholder;
1023
+ }, [options, placeholder, value]);
1024
+ const close = React.useCallback(() => {
1025
+ setIsOpen(false);
1026
+ }, []);
1027
+ const toggle = React.useCallback(() => {
1028
+ setIsOpen((prev) => !prev);
1029
+ }, []);
1030
+ const select = React.useCallback(
1031
+ (next) => {
1032
+ onChange(next);
1033
+ setIsOpen(false);
1034
+ },
1035
+ [onChange]
1036
+ );
1037
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1038
+ /* @__PURE__ */ jsxRuntime.jsxs(
1039
+ reactNative.Pressable,
1040
+ {
1041
+ accessibilityRole: "button",
1042
+ accessibilityLabel,
1043
+ accessibilityHint,
1044
+ accessibilityState: { expanded: isOpen },
1045
+ "aria-expanded": isOpen,
1046
+ onPress: toggle,
1047
+ style: [controlStyles.selectTrigger, { borderColor: colors.border, backgroundColor: colors.surface }],
1048
+ testID: `${testID}-trigger`,
1049
+ children: [
1050
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { numberOfLines: 1, style: [controlStyles.selectTriggerText, { color: value === "" ? colors.textSecondary : colors.text }], children: selectedLabel }),
1051
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { "aria-hidden": true, style: [controlStyles.chevron, { color: colors.textSecondary }], children: isOpen ? CHEVRON_UP : CHEVRON_DOWN })
1052
+ ]
1053
+ }
1054
+ ),
1055
+ isOpen ? /* @__PURE__ */ jsxRuntime.jsx(
1056
+ AnchoredMenu,
1057
+ {
1058
+ options,
1059
+ selectedValue: value,
1060
+ onSelect: select,
1061
+ onDismiss: close,
1062
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1063
+ optionHint,
1064
+ testID,
1065
+ anchorRef
1066
+ }
1067
+ ) : null
1068
+ ] });
1069
+ }
1070
+
1071
+ // src/controls/suggestOptions.ts
1072
+ var RANK_VALUE_EXACT = 0;
1073
+ var RANK_VALUE_PREFIX = 1;
1074
+ var RANK_LABEL_PREFIX = 2;
1075
+ var RANK_LABEL_CONTAINS = 3;
1076
+ var NO_MATCH = -1;
1077
+ function rankOf(option, needle) {
1078
+ const value = option.value.toLowerCase();
1079
+ const label = option.label.toLowerCase();
1080
+ if (value === needle) return RANK_VALUE_EXACT;
1081
+ if (value.startsWith(needle)) return RANK_VALUE_PREFIX;
1082
+ if (label.startsWith(needle)) return RANK_LABEL_PREFIX;
1083
+ if (label.includes(needle)) return RANK_LABEL_CONTAINS;
1084
+ return NO_MATCH;
1085
+ }
1086
+ function suggestOptions(options, query, minChars, limit) {
1087
+ const needle = query.trim().toLowerCase();
1088
+ if (needle.length < minChars) return [];
1089
+ const scored = [];
1090
+ for (const option of options) {
1091
+ const rank = rankOf(option, needle);
1092
+ if (rank !== NO_MATCH) scored.push({ option, rank });
1093
+ }
1094
+ scored.sort((a, b) => a.rank !== b.rank ? a.rank - b.rank : a.option.label.localeCompare(b.option.label));
1095
+ return scored.slice(0, limit).map((entry) => entry.option);
1096
+ }
1097
+ function isUnmatched(options, text) {
1098
+ const needle = text.trim().toLowerCase();
1099
+ if (needle === "") return false;
1100
+ return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
1101
+ }
1102
+ var CLOSE_DELAY_MS = 140;
1103
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
1104
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
1105
+ function TypeaheadControl({
1106
+ options,
1107
+ value,
1108
+ onChange,
1109
+ onSubmit,
1110
+ placeholder,
1111
+ accessibilityLabel,
1112
+ accessibilityHint,
1113
+ optionHint = "",
1114
+ error,
1115
+ minChars = DEFAULT_TYPEAHEAD_MIN_CHARS,
1116
+ maxSuggestions = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS,
1117
+ style,
1118
+ testID
1119
+ }) {
1120
+ const { theme } = uiFeedback.useUi();
1121
+ const { colors, palette, semantic } = theme;
1122
+ const anchorRef = React.useRef(null);
1123
+ const [isOpen, setIsOpen] = React.useState(false);
1124
+ const exactPick = React.useMemo(() => options.some((o) => o.label === value), [options, value]);
1125
+ const suggestions = React.useMemo(
1126
+ () => exactPick ? [] : suggestOptions(options, value, minChars, maxSuggestions),
1127
+ [exactPick, options, value, minChars, maxSuggestions]
1128
+ );
1129
+ const showMenu = isOpen && suggestions.length > 0;
1130
+ const handleChange = React.useCallback(
1131
+ (next) => {
1132
+ onChange(next);
1133
+ setIsOpen(true);
1134
+ },
1135
+ [onChange]
1136
+ );
1137
+ const pick = React.useCallback(
1138
+ (optionValue) => {
1139
+ const opt = options.find((o) => o.value === optionValue);
1140
+ onChange(opt?.label ?? optionValue);
1141
+ setIsOpen(false);
1142
+ },
1143
+ [options, onChange]
1144
+ );
1145
+ const handleBlur = React.useCallback(() => {
1146
+ setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS);
1147
+ }, []);
1148
+ const handleSubmit = React.useCallback(() => {
1149
+ setIsOpen(false);
1150
+ onSubmit?.();
1151
+ }, [onSubmit]);
1152
+ const hasError = error !== void 0 && error !== "";
1153
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1154
+ /* @__PURE__ */ jsxRuntime.jsx(
1155
+ reactNative.TextInput,
1156
+ {
1157
+ accessibilityLabel,
1158
+ accessibilityHint,
1159
+ autoCapitalize: "none",
1160
+ autoCorrect: false,
1161
+ placeholder,
1162
+ placeholderTextColor: colors.textSecondary,
1163
+ spellCheck: false,
1164
+ value,
1165
+ onChangeText: handleChange,
1166
+ onFocus: () => setIsOpen(true),
1167
+ onBlur: handleBlur,
1168
+ onSubmitEditing: handleSubmit,
1169
+ style: [controlStyles.input, { borderColor: hasError ? semantic.error["500"] : colors.border, backgroundColor: colors.surface, color: colors.text }],
1170
+ testID: `${testID}-input`
1171
+ }
1172
+ ),
1173
+ showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
1174
+ AnchoredMenu,
1175
+ {
1176
+ options: suggestions,
1177
+ selectedValue: value,
1178
+ onSelect: pick,
1179
+ onDismiss: () => setIsOpen(false),
1180
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1181
+ optionHint,
1182
+ testID,
1183
+ anchorRef
1184
+ }
1185
+ ) : null,
1186
+ hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [controlStyles.error, { color: semantic.error["500"] }], testID: `${testID}-error`, children: error }) : null
1187
+ ] });
1188
+ }
1189
+ var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1190
+ function DateInput(p) {
1191
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: controlStyles.dateCol, children: [
1192
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.subLabel, { color: p.muted }], children: p.label }),
1193
+ /* @__PURE__ */ jsxRuntime.jsx(
1194
+ reactNative.TextInput,
1195
+ {
1196
+ accessibilityLabel: p.label,
1197
+ accessibilityHint: p.hint,
1198
+ placeholder: p.placeholder,
1199
+ placeholderTextColor: p.muted,
1200
+ value: p.value,
1201
+ onChangeText: p.onChangeText,
1202
+ onSubmitEditing: p.onSubmit,
1203
+ style: [controlStyles.input, { borderColor: p.border, backgroundColor: p.surface, color: p.color }],
1204
+ testID: `${p.testID}-${p.side}`,
1205
+ ...WEB_DATE_PROPS
1206
+ }
1207
+ )
1208
+ ] });
1209
+ }
1210
+ function DateRangeControl({
1211
+ value,
1212
+ onChange,
1213
+ onSubmit,
1214
+ fromLabel,
1215
+ toLabel,
1216
+ fromPlaceholder,
1217
+ toPlaceholder,
1218
+ accessibilityHint,
1219
+ style,
1220
+ testID
1221
+ }) {
1222
+ const { theme } = uiFeedback.useUi();
1223
+ const { colors } = theme;
1224
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [controlStyles.dateRow, style], testID: `${testID}-range`, children: [
1225
+ /* @__PURE__ */ jsxRuntime.jsx(
1226
+ DateInput,
1227
+ {
1228
+ side: "from",
1229
+ label: fromLabel,
1230
+ placeholder: fromPlaceholder,
1231
+ value: value.from,
1232
+ color: colors.text,
1233
+ border: colors.border,
1234
+ surface: colors.surface,
1235
+ muted: colors.textSecondary,
1236
+ hint: accessibilityHint,
1237
+ testID,
1238
+ onChangeText: (from) => onChange({ from, to: value.to }),
1239
+ onSubmit
1240
+ }
1241
+ ),
1242
+ /* @__PURE__ */ jsxRuntime.jsx(
1243
+ DateInput,
1244
+ {
1245
+ side: "to",
1246
+ label: toLabel,
1247
+ placeholder: toPlaceholder,
1248
+ value: value.to,
1249
+ color: colors.text,
1250
+ border: colors.border,
1251
+ surface: colors.surface,
1252
+ muted: colors.textSecondary,
1253
+ hint: accessibilityHint,
1254
+ testID,
1255
+ onChangeText: (to) => onChange({ from: value.from, to }),
1256
+ onSubmit
1257
+ }
1258
+ )
1259
+ ] });
1260
+ }
722
1261
 
1262
+ exports.AnchoredMenu = AnchoredMenu;
723
1263
  exports.ChipSelector = ChipSelector;
724
1264
  exports.DEFAULT_OPTIONAL_LABEL = DEFAULT_OPTIONAL_LABEL;
1265
+ exports.DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1266
+ exports.DEFAULT_TYPEAHEAD_MIN_CHARS = DEFAULT_TYPEAHEAD_MIN_CHARS;
1267
+ exports.DateRangeControl = DateRangeControl;
725
1268
  exports.Field = Field;
726
1269
  exports.FormActions = FormActions;
1270
+ exports.FormCell = FormCell;
727
1271
  exports.FormCheckbox = FormCheckbox;
728
1272
  exports.FormField = FormField;
1273
+ exports.FormGrid = FormGrid;
1274
+ exports.FormSection = FormSection;
729
1275
  exports.FormSwitch = FormSwitch;
1276
+ exports.SelectControl = SelectControl;
730
1277
  exports.ThemedTextInput = ThemedTextInput;
1278
+ exports.TypeaheadControl = TypeaheadControl;
1279
+ exports.controlStyles = controlStyles;
1280
+ exports.isUnmatched = isUnmatched;
1281
+ exports.suggestOptions = suggestOptions;
731
1282
  exports.useThemedInput = useThemedInput;
732
1283
  //# sourceMappingURL=index.js.map
733
1284
  //# sourceMappingURL=index.js.map