@dloizides/ui-forms 1.6.0 → 1.8.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.mjs CHANGED
@@ -1,45 +1,116 @@
1
- import React, { useState, useCallback, useMemo } from 'react';
1
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
2
2
  import { StyleSheet, Platform, TextInput, View, Text, Switch, Pressable, TouchableOpacity } from 'react-native';
3
3
  import { useUi } from '@dloizides/ui-feedback';
4
4
  import { jsx, jsxs } from 'react/jsx-runtime';
5
+ import { Button } from '@dloizides/ui-buttons';
5
6
 
6
7
  // src/Field/Field.tsx
8
+ function FieldLabel({
9
+ label,
10
+ marker,
11
+ optionalLabel,
12
+ style,
13
+ requiredMarkStyle,
14
+ optionalMarkStyle
15
+ }) {
16
+ return /* @__PURE__ */ jsxs(Text, { style, children: [
17
+ label,
18
+ " ",
19
+ marker === "required" ? (
20
+ // The asterisk is decorative — the control's `aria-required` conveys "required" to
21
+ // assistive tech, so a screen reader says "required" instead of "star".
22
+ /* @__PURE__ */ jsx(Text, { "aria-hidden": true, accessibilityElementsHidden: true, importantForAccessibility: "no", style: requiredMarkStyle, children: "*" })
23
+ ) : null,
24
+ marker === "optional" && optionalLabel !== "" ? (
25
+ // Also decorative: "optional" is the ABSENCE of `aria-required`, which assistive tech
26
+ // already conveys. Rendering it to a screen reader would double-announce the same fact.
27
+ /* @__PURE__ */ jsx(Text, { "aria-hidden": true, accessibilityElementsHidden: true, importantForAccessibility: "no", style: optionalMarkStyle, children: optionalLabel })
28
+ ) : null
29
+ ] });
30
+ }
31
+
32
+ // src/Field/fieldMarkers.ts
33
+ var DEFAULT_OPTIONAL_LABEL = "(optional)";
34
+ function resolveFieldMarker(required, optional) {
35
+ if (required) return "required";
36
+ if (optional) return "optional";
37
+ return "none";
38
+ }
39
+ var FIELD_FONT_SIZE = 13;
40
+ var FIELD_FONT_WEIGHT = "600";
41
+ var CONTROL_FONT_SIZE = 11;
42
+ var CONTROL_FONT_WEIGHT = "700";
43
+ var CONTROL_LETTER_SPACING = 0.4;
44
+ var labelVariantStyles = StyleSheet.create({
45
+ field: {
46
+ fontSize: FIELD_FONT_SIZE,
47
+ fontWeight: FIELD_FONT_WEIGHT
48
+ },
49
+ control: {
50
+ fontSize: CONTROL_FONT_SIZE,
51
+ fontWeight: CONTROL_FONT_WEIGHT,
52
+ letterSpacing: CONTROL_LETTER_SPACING,
53
+ textTransform: "uppercase"
54
+ }
55
+ });
56
+ function resolveLabelVariantStyle(variant) {
57
+ return variant === "control" ? labelVariantStyles.control : labelVariantStyles.field;
58
+ }
7
59
  var CONTAINER_MARGIN_BOTTOM = 16;
8
- var LABEL_FONT_SIZE = 13;
9
- var LABEL_FONT_WEIGHT = "600";
10
60
  var LABEL_MARGIN_BOTTOM = 4;
11
61
  var ERROR_FONT_SIZE = 12;
12
62
  var ERROR_MARGIN_TOP = 4;
63
+ var HINT_FONT_SIZE = 12;
64
+ var HINT_MARGIN_TOP = 4;
65
+ var OPTIONAL_MARK_FONT_WEIGHT = "400";
13
66
  var styles = StyleSheet.create({
14
67
  container: {
15
68
  marginBottom: CONTAINER_MARGIN_BOTTOM
16
69
  },
17
70
  label: {
18
- marginBottom: LABEL_MARGIN_BOTTOM,
19
- fontSize: LABEL_FONT_SIZE,
20
- fontWeight: LABEL_FONT_WEIGHT
71
+ marginBottom: LABEL_MARGIN_BOTTOM
72
+ },
73
+ hintText: {
74
+ fontSize: HINT_FONT_SIZE,
75
+ marginTop: HINT_MARGIN_TOP
21
76
  },
22
77
  errorText: {
23
78
  fontSize: ERROR_FONT_SIZE,
24
79
  marginTop: ERROR_MARGIN_TOP
80
+ },
81
+ optionalMark: {
82
+ fontWeight: OPTIONAL_MARK_FONT_WEIGHT
25
83
  }
26
84
  });
27
85
  var fieldSeq = 0;
28
- function useFieldErrorId() {
29
- const [id] = React.useState(() => {
86
+ function useFieldIds() {
87
+ const [ids] = React.useState(() => {
30
88
  fieldSeq += 1;
31
- return `field-error-${String(fieldSeq)}`;
89
+ const seq = String(fieldSeq);
90
+ return { hintId: `field-hint-${seq}`, errorId: `field-error-${seq}` };
32
91
  });
33
- return id;
92
+ return ids;
93
+ }
94
+ function isPresent(value) {
95
+ return typeof value === "string" && value !== "";
34
96
  }
35
97
  function hasFieldError(error) {
36
- return typeof error === "string" && error !== "";
98
+ return isPresent(error);
99
+ }
100
+ function joinDescribedBy(hintId, errorId) {
101
+ const ids = [hintId, errorId].filter((id) => isPresent(id));
102
+ return ids.length > 0 ? ids.join(" ") : void 0;
37
103
  }
38
104
  var Field = ({
39
105
  label,
40
106
  children,
41
107
  required = false,
108
+ optional = false,
109
+ optionalLabel = DEFAULT_OPTIONAL_LABEL,
110
+ hint,
42
111
  error,
112
+ labelVariant,
113
+ labelStyle,
43
114
  containerStyle,
44
115
  testID
45
116
  }) => {
@@ -47,31 +118,89 @@ var Field = ({
47
118
  const { colors, semantic } = theme;
48
119
  const errorColor = semantic.error["500"];
49
120
  const hasError = hasFieldError(error);
50
- const hasLabel = typeof label === "string" && label !== "";
51
- const errorId = useFieldErrorId();
121
+ const hasHint = isPresent(hint);
122
+ const labelText = label ?? "";
123
+ const hasLabel = labelText !== "";
124
+ const marker = resolveFieldMarker(required, optional);
125
+ const { hintId, errorId } = useFieldIds();
52
126
  const themeStyles = React.useMemo(
53
127
  () => ({
54
128
  label: { color: colors.textSecondary },
55
129
  requiredMark: { color: errorColor },
130
+ // The optional marker is an aside, so it sits a step back from the label's own ink.
131
+ optionalMark: { color: colors.textSecondary },
132
+ hintText: { color: colors.textSecondary },
56
133
  errorText: { color: errorColor }
57
134
  }),
58
135
  [colors.textSecondary, errorColor]
59
136
  );
60
- const control = typeof children === "function" ? children({ describedById: hasError ? errorId : void 0, hasError }) : children;
137
+ const describedById = joinDescribedBy(hasHint ? hintId : void 0, hasError ? errorId : void 0);
138
+ const control = typeof children === "function" ? children({ describedById, hasError }) : children;
61
139
  return /* @__PURE__ */ jsxs(View, { style: [styles.container, containerStyle], testID, children: [
62
- hasLabel ? /* @__PURE__ */ jsxs(Text, { style: [styles.label, themeStyles.label], children: [
63
- label,
64
- " ",
65
- required ? (
66
- // The asterisk is decorative — the control's `aria-required` conveys "required" to
67
- // assistive tech, so a screen reader says "required" instead of "star".
68
- /* @__PURE__ */ jsx(Text, { "aria-hidden": true, accessibilityElementsHidden: true, importantForAccessibility: "no", style: themeStyles.requiredMark, children: "*" })
69
- ) : null
70
- ] }) : null,
140
+ hasLabel ? /* @__PURE__ */ jsx(
141
+ FieldLabel,
142
+ {
143
+ label: labelText,
144
+ marker,
145
+ optionalLabel,
146
+ optionalMarkStyle: [styles.optionalMark, themeStyles.optionalMark],
147
+ requiredMarkStyle: themeStyles.requiredMark,
148
+ style: [styles.label, resolveLabelVariantStyle(labelVariant), themeStyles.label, labelStyle]
149
+ }
150
+ ) : null,
71
151
  control,
152
+ hasHint ? /* @__PURE__ */ jsx(Text, { nativeID: hintId, style: [styles.hintText, themeStyles.hintText], children: hint }) : null,
72
153
  hasError ? /* @__PURE__ */ jsx(Text, { nativeID: errorId, role: "alert", style: [styles.errorText, themeStyles.errorText], children: error }) : null
73
154
  ] });
74
155
  };
156
+ var ACTIONS_GAP = 12;
157
+ var styles2 = StyleSheet.create({
158
+ container: {
159
+ flexDirection: "row",
160
+ justifyContent: "flex-end",
161
+ gap: ACTIONS_GAP
162
+ }
163
+ });
164
+ var FormActions = ({
165
+ onSave,
166
+ onCancel,
167
+ saveLabel,
168
+ cancelLabel = "",
169
+ saveHint,
170
+ cancelHint,
171
+ saving = false,
172
+ saveDisabled = false,
173
+ containerStyle,
174
+ saveTestID = "save-button",
175
+ cancelTestID = "cancel-button",
176
+ testID
177
+ }) => /* @__PURE__ */ jsxs(View, { style: [styles2.container, containerStyle], testID, children: [
178
+ typeof onCancel === "function" ? /* @__PURE__ */ jsx(
179
+ Button,
180
+ {
181
+ accessibilityHint: cancelHint ?? cancelLabel,
182
+ accessibilityLabel: cancelLabel,
183
+ disabled: saving,
184
+ label: cancelLabel,
185
+ testID: cancelTestID,
186
+ variant: "outline",
187
+ onPress: onCancel
188
+ }
189
+ ) : null,
190
+ /* @__PURE__ */ jsx(
191
+ Button,
192
+ {
193
+ accessibilityHint: saveHint ?? saveLabel,
194
+ accessibilityLabel: saveLabel,
195
+ disabled: saveDisabled,
196
+ label: saveLabel,
197
+ loading: saving,
198
+ testID: saveTestID,
199
+ variant: "primary",
200
+ onPress: onSave
201
+ }
202
+ )
203
+ ] });
75
204
  var IS_WEB = Platform.OS === "web";
76
205
  var TRANSITION_PROPERTY = "border-color, background-color, box-shadow";
77
206
  var TRANSITION_DURATION = "0.14s";
@@ -131,7 +260,8 @@ function useThemedInput(options = {}) {
131
260
  const backgroundColor = isFocused ? colors.surface : colors.surfaceElevated;
132
261
  const base = { color: colors.text, borderColor, backgroundColor };
133
262
  if (!IS_WEB) return base;
134
- const ring = isFocused && !hasError ? `0 0 0 ${FOCUS_RING_WIDTH}px ${withAlpha(primary, FOCUS_RING_ALPHA)}` : "none";
263
+ const ringColor = hasError ? errorColor : primary;
264
+ const ring = isFocused ? `0 0 0 ${FOCUS_RING_WIDTH}px ${withAlpha(ringColor, FOCUS_RING_ALPHA)}` : "none";
135
265
  const webStyle = {
136
266
  transitionProperty: TRANSITION_PROPERTY,
137
267
  // Collapse the eased transition to instant when the user prefers reduced motion.
@@ -197,7 +327,7 @@ var INPUT_PADDING_V = 10;
197
327
  var INPUT_PADDING_H = 12;
198
328
  var INPUT_BORDER_RADIUS = 8;
199
329
  var INPUT_BORDER_WIDTH = 1;
200
- var styles2 = StyleSheet.create({
330
+ var styles3 = StyleSheet.create({
201
331
  input: {
202
332
  paddingVertical: INPUT_PADDING_V,
203
333
  paddingHorizontal: INPUT_PADDING_H,
@@ -219,7 +349,7 @@ var FormField = ({
219
349
  describedById,
220
350
  hasError,
221
351
  requiredField: required,
222
- style: styles2.input,
352
+ style: styles3.input,
223
353
  testID: "form-field-input",
224
354
  ...textInputProps
225
355
  }
@@ -240,6 +370,13 @@ var RGB_G_END = 4;
240
370
  var RGB_B_END = 6;
241
371
  var CHIP_HIT_SLOP = { top: 10, bottom: 10, left: 4, right: 4 };
242
372
  var CHIP_GUTTER = 8;
373
+ var DEFAULT_CHIP_TESTID_PREFIX = "chip-selector-chip";
374
+ var ON_BRAND_SCALE = "onBrand";
375
+ var MAIN_STEP = "500";
376
+ function onBrandInk(palette) {
377
+ const scales = palette;
378
+ return scales[ON_BRAND_SCALE]?.[MAIN_STEP] ?? WHITE_COLOR;
379
+ }
243
380
  function withAlpha2(hex, alpha) {
244
381
  const normalized = hex.replace("#", "");
245
382
  const isShort = normalized.length === HEX_SHORT_LENGTH2;
@@ -250,7 +387,7 @@ function withAlpha2(hex, alpha) {
250
387
  const isValid = !Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b);
251
388
  return isValid ? `rgba(${r}, ${g}, ${b}, ${alpha})` : hex;
252
389
  }
253
- var styles3 = StyleSheet.create({
390
+ var styles4 = StyleSheet.create({
254
391
  chipContainer: {
255
392
  flexDirection: "row",
256
393
  flexWrap: "wrap",
@@ -279,11 +416,23 @@ var styles3 = StyleSheet.create({
279
416
  chipText: {},
280
417
  chipTextOutline: { fontSize: OUTLINE_FONT_SIZE }
281
418
  });
419
+ function chipIdentity(option, selected, config) {
420
+ return {
421
+ testID: `${config.prefix}-${String(option.value)}`,
422
+ // The English default is retained verbatim: erevna/katalogos assert the exact string
423
+ // `Selects Red`. Consumers that localize pass `optionAccessibilityHint`.
424
+ accessibilityHint: config.hint ?? `Selects ${option.label}`,
425
+ ariaPressed: config.multiple ? selected : void 0
426
+ };
427
+ }
282
428
  function OutlineChip({
283
429
  option,
284
430
  selected,
285
431
  disabled,
286
432
  colors,
433
+ testID,
434
+ accessibilityHint,
435
+ ariaPressed,
287
436
  onPress
288
437
  }) {
289
438
  const [hovered, setHovered] = React.useState(false);
@@ -294,18 +443,19 @@ function OutlineChip({
294
443
  return /* @__PURE__ */ jsx(
295
444
  Pressable,
296
445
  {
297
- accessibilityHint: `Selects ${option.label}`,
446
+ accessibilityHint,
298
447
  accessibilityLabel: option.label,
299
448
  accessibilityRole: "button",
300
449
  accessibilityState: { selected, disabled },
450
+ "aria-pressed": ariaPressed,
301
451
  disabled,
302
452
  hitSlop: CHIP_HIT_SLOP,
303
- style: styles3.chipWrapper,
304
- testID: `chip-selector-chip-${String(option.value)}`,
453
+ style: styles4.chipWrapper,
454
+ testID,
305
455
  onHoverIn: () => setHovered(true),
306
456
  onHoverOut: () => setHovered(false),
307
457
  onPress,
308
- children: /* @__PURE__ */ jsx(View, { style: [styles3.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsx(Text, { style: [styles3.chipText, styles3.chipTextOutline, { color: textColor }], children: option.label }) })
458
+ children: /* @__PURE__ */ jsx(View, { style: [styles4.chipOutline, { borderColor, backgroundColor }], children: /* @__PURE__ */ jsx(Text, { style: [styles4.chipText, styles4.chipTextOutline, { color: textColor }], children: option.label }) })
309
459
  }
310
460
  );
311
461
  }
@@ -314,23 +464,27 @@ function SolidChip({
314
464
  selected,
315
465
  disabled,
316
466
  colors,
467
+ testID,
468
+ accessibilityHint,
469
+ ariaPressed,
317
470
  onPress
318
471
  }) {
319
472
  const backgroundColor = selected ? colors.primary : TRANSPARENT_COLOR;
320
- const textColor = selected ? WHITE_COLOR : colors.textStrong;
473
+ const textColor = selected ? colors.onPrimary : colors.textStrong;
321
474
  return /* @__PURE__ */ jsx(
322
475
  TouchableOpacity,
323
476
  {
324
- accessibilityHint: `Selects ${option.label}`,
477
+ accessibilityHint,
325
478
  accessibilityLabel: option.label,
326
479
  accessibilityRole: "button",
327
480
  accessibilityState: { selected, disabled },
481
+ "aria-pressed": ariaPressed,
328
482
  disabled,
329
483
  hitSlop: CHIP_HIT_SLOP,
330
- style: styles3.chipWrapper,
331
- testID: `chip-selector-chip-${String(option.value)}`,
484
+ style: styles4.chipWrapper,
485
+ testID,
332
486
  onPress,
333
- children: /* @__PURE__ */ jsx(View, { style: [styles3.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsx(Text, { style: [styles3.chipText, { color: textColor }], children: option.label }) })
487
+ children: /* @__PURE__ */ jsx(View, { style: [styles4.chip, { borderColor: colors.border, backgroundColor }], children: /* @__PURE__ */ jsx(Text, { style: [styles4.chipText, { color: textColor }], children: option.label }) })
334
488
  }
335
489
  );
336
490
  }
@@ -350,7 +504,9 @@ var ChipSelector = ({
350
504
  containerStyle,
351
505
  required = false,
352
506
  error,
353
- testID
507
+ testID,
508
+ chipTestIDPrefix = DEFAULT_CHIP_TESTID_PREFIX,
509
+ optionAccessibilityHint
354
510
  }) => {
355
511
  const { theme } = useUi();
356
512
  const { colors, palette } = theme;
@@ -361,30 +517,37 @@ var ChipSelector = ({
361
517
  textStrong: colors.text,
362
518
  textMuted: colors.textSecondary,
363
519
  primary,
520
+ onPrimary: onBrandInk(palette),
364
521
  muted: colors.background,
365
522
  tint: withAlpha2(primary, OUTLINE_TINT_ALPHA)
366
523
  };
367
- }, [colors.border, colors.text, colors.textSecondary, colors.background, palette.primary]);
524
+ }, [colors.border, colors.text, colors.textSecondary, colors.background, palette]);
368
525
  function isSelected(optionValue) {
369
526
  if (multiple && Array.isArray(value))
370
527
  return value.includes(optionValue);
371
528
  return value === optionValue;
372
529
  }
373
530
  function renderChip(option) {
531
+ const selected = isSelected(option.value);
374
532
  const chipProps = {
375
533
  colors: chipColors,
376
534
  disabled,
377
535
  option,
378
- selected: isSelected(option.value),
536
+ selected,
537
+ ...chipIdentity(option, selected, {
538
+ prefix: chipTestIDPrefix,
539
+ hint: optionAccessibilityHint,
540
+ multiple
541
+ }),
379
542
  onPress: () => onChange(option.value)
380
543
  };
381
544
  const key = String(option.value);
382
545
  return variant === "outline" ? /* @__PURE__ */ jsx(OutlineChip, { ...chipProps }, key) : /* @__PURE__ */ jsx(SolidChip, { ...chipProps }, key);
383
546
  }
384
- return /* @__PURE__ */ jsx(Field, { containerStyle, error, label, required, testID, children: ({ describedById, hasError }) => /* @__PURE__ */ jsx(View, { style: styles3.chipContainer, ...groupA11yProps(describedById, hasError), children: options.map(renderChip) }) });
547
+ return /* @__PURE__ */ jsx(Field, { containerStyle, error, label, required, testID, children: ({ describedById, hasError }) => /* @__PURE__ */ jsx(View, { style: styles4.chipContainer, ...groupA11yProps(describedById, hasError), children: options.map(renderChip) }) });
385
548
  };
386
549
  var DESCRIPTION_FONT_SIZE = 12;
387
- var styles4 = StyleSheet.create({
550
+ var styles5 = StyleSheet.create({
388
551
  container: {
389
552
  flexDirection: "row",
390
553
  justifyContent: "space-between",
@@ -424,13 +587,13 @@ var FormSwitch = ({
424
587
  View,
425
588
  {
426
589
  style: [
427
- styles4.container,
590
+ styles5.container,
428
591
  containerStyle
429
592
  ],
430
593
  children: [
431
- /* @__PURE__ */ jsxs(View, { style: styles4.textColumn, children: [
432
- /* @__PURE__ */ jsx(Text, { style: [styles4.label, themeStyles.label], children: label }),
433
- hasDescription ? /* @__PURE__ */ jsx(Text, { style: [styles4.description, themeStyles.description], children: description }) : null
594
+ /* @__PURE__ */ jsxs(View, { style: styles5.textColumn, children: [
595
+ /* @__PURE__ */ jsx(Text, { style: [styles5.label, themeStyles.label], children: label }),
596
+ hasDescription ? /* @__PURE__ */ jsx(Text, { style: [styles5.description, themeStyles.description], children: description }) : null
434
597
  ] }),
435
598
  /* @__PURE__ */ jsx(
436
599
  Switch,
@@ -450,12 +613,12 @@ var FormSwitch = ({
450
613
  );
451
614
  };
452
615
  var ACTIVATION_KEYS = ["Enter", " ", "Spacebar"];
453
- var HINT_FONT_SIZE = 12;
616
+ var HINT_FONT_SIZE2 = 12;
454
617
  var BOX_SIZE = 20;
455
618
  var BOX_RADIUS = 5;
456
619
  var CHECK_FONT_SIZE = 14;
457
620
  var DISABLED_OPACITY = 0.5;
458
- var styles5 = StyleSheet.create({
621
+ var styles6 = StyleSheet.create({
459
622
  container: {
460
623
  flexDirection: "row",
461
624
  alignItems: "flex-start",
@@ -484,7 +647,7 @@ var styles5 = StyleSheet.create({
484
647
  fontWeight: "600"
485
648
  },
486
649
  hint: {
487
- fontSize: HINT_FONT_SIZE,
650
+ fontSize: HINT_FONT_SIZE2,
488
651
  marginTop: 2
489
652
  }
490
653
  });
@@ -537,20 +700,419 @@ var FormCheckbox = ({
537
700
  disabled,
538
701
  onPress: toggle,
539
702
  role: "checkbox",
540
- style: [styles5.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
703
+ style: [styles6.container, containerStyle, disabled ? { opacity: DISABLED_OPACITY } : null],
541
704
  testID,
542
705
  ...keyboardProps,
543
706
  children: [
544
- /* @__PURE__ */ jsx(View, { style: [styles5.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsx(Text, { style: [styles5.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
545
- /* @__PURE__ */ jsxs(View, { style: styles5.textColumn, children: [
546
- /* @__PURE__ */ jsx(Text, { style: [styles5.label, themeStyles.label], children: label }),
547
- hasHint ? /* @__PURE__ */ jsx(Text, { style: [styles5.hint, themeStyles.hint], children: hintText }) : null
707
+ /* @__PURE__ */ jsx(View, { style: [styles6.box, value ? themeStyles.boxOn : themeStyles.boxOff], children: value ? /* @__PURE__ */ jsx(Text, { style: [styles6.check, { color: colors.surface }], accessibilityElementsHidden: true, children: "\u2713" }) : null }),
708
+ /* @__PURE__ */ jsxs(View, { style: styles6.textColumn, children: [
709
+ /* @__PURE__ */ jsx(Text, { style: [styles6.label, themeStyles.label], children: label }),
710
+ hasHint ? /* @__PURE__ */ jsx(Text, { style: [styles6.hint, themeStyles.hint], children: hintText }) : null
548
711
  ] })
549
712
  ]
550
713
  }
551
714
  );
552
715
  };
716
+ var FIELD_GAP = 4;
717
+ var LABEL_FONT = 11;
718
+ var LABEL_LETTER_SPACING = 0.4;
719
+ var INPUT_RADIUS = 8;
720
+ var INPUT_PAD_H = 12;
721
+ var INPUT_PAD_V = 10;
722
+ var INPUT_FONT = 14;
723
+ var INPUT_BORDER = 1;
724
+ var DATE_ROW_GAP = 8;
725
+ var CHEVRON_FONT = 10;
726
+ var MENU_TOP_GAP = 4;
727
+ var MENU_RADIUS = 8;
728
+ var MENU_PAD_V = 4;
729
+ var MENU_OPT_PAD_H = 12;
730
+ var MENU_OPT_PAD_V = 8;
731
+ var MENU_MIN_WIDTH = 160;
732
+ var MENU_MAX_HEIGHT = 260;
733
+ var MENU_Z = 1e3;
734
+ var MENU_ELEVATION = 8;
735
+ var MENU_BOX_SHADOW = "0px 2px 8px rgba(0, 0, 0, 0.15)";
736
+ var OPTION_FONT = 14;
737
+ var ERROR_FONT = 12;
738
+ var ERROR_GAP = 3;
739
+ var controlStyles = StyleSheet.create({
740
+ /** The shared bordered box every dense text control renders in. */
741
+ input: {
742
+ borderWidth: INPUT_BORDER,
743
+ borderRadius: INPUT_RADIUS,
744
+ paddingHorizontal: INPUT_PAD_H,
745
+ paddingVertical: INPUT_PAD_V,
746
+ fontSize: INPUT_FONT
747
+ },
748
+ /** The select trigger: a bordered field box matching the text inputs + a chevron. */
749
+ selectTrigger: {
750
+ flexDirection: "row",
751
+ alignItems: "center",
752
+ justifyContent: "space-between",
753
+ gap: DATE_ROW_GAP,
754
+ borderWidth: INPUT_BORDER,
755
+ borderRadius: INPUT_RADIUS,
756
+ paddingHorizontal: INPUT_PAD_H,
757
+ paddingVertical: INPUT_PAD_V
758
+ },
759
+ selectTriggerText: { fontSize: INPUT_FONT, flexShrink: 1 },
760
+ chevron: { fontSize: CHEVRON_FONT },
761
+ dateRow: { flexDirection: "row", gap: DATE_ROW_GAP },
762
+ dateCol: { gap: FIELD_GAP, flex: 1 },
763
+ /**
764
+ * The micro-caption above a sub-control (a date range's From/To) and beside a switch. Note it
765
+ * is deliberately NOT the `control` label voice: it carries no `700`/uppercase, because it
766
+ * names a HALF of a control that already has a `control`-voice label above it. Uppercasing it
767
+ * too would make one field shout twice.
768
+ */
769
+ subLabel: { fontSize: LABEL_FONT, letterSpacing: LABEL_LETTER_SPACING },
770
+ /** Positioning context for a floating `AnchoredMenu` (`position:absolute; top:100%`). */
771
+ anchor: { position: "relative" },
772
+ menu: {
773
+ position: "absolute",
774
+ top: "100%",
775
+ left: 0,
776
+ marginTop: MENU_TOP_GAP,
777
+ minWidth: MENU_MIN_WIDTH,
778
+ maxHeight: MENU_MAX_HEIGHT,
779
+ borderWidth: INPUT_BORDER,
780
+ borderRadius: MENU_RADIUS,
781
+ paddingVertical: MENU_PAD_V,
782
+ zIndex: MENU_Z,
783
+ boxShadow: MENU_BOX_SHADOW,
784
+ elevation: MENU_ELEVATION,
785
+ overflow: "hidden"
786
+ },
787
+ option: { paddingHorizontal: MENU_OPT_PAD_H, paddingVertical: MENU_OPT_PAD_V },
788
+ optionText: { fontSize: OPTION_FONT },
789
+ error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
790
+ });
791
+ var IS_WEB4 = typeof document !== "undefined";
792
+ var ESCAPE_KEY = "Escape";
793
+ var ACTIVE_WEIGHT = "700";
794
+ var IDLE_WEIGHT = "400";
795
+ function useDismiss(anchorRef, menuRef, onDismiss) {
796
+ useEffect(() => {
797
+ if (!IS_WEB4) return void 0;
798
+ const onMouseDown = (event) => {
799
+ const anchorNode = anchorRef.current;
800
+ const menuNode = menuRef.current;
801
+ const target = event.target instanceof Node ? event.target : null;
802
+ const insideAnchor = anchorNode !== null && target !== null && anchorNode.contains(target);
803
+ const insideMenu = menuNode !== null && target !== null && menuNode.contains(target);
804
+ if (!insideAnchor && !insideMenu) onDismiss();
805
+ };
806
+ const onKeyDown = (event) => {
807
+ if (event.key === ESCAPE_KEY) onDismiss();
808
+ };
809
+ document.addEventListener("mousedown", onMouseDown);
810
+ document.addEventListener("keydown", onKeyDown);
811
+ return () => {
812
+ document.removeEventListener("mousedown", onMouseDown);
813
+ document.removeEventListener("keydown", onKeyDown);
814
+ };
815
+ }, [anchorRef, menuRef, onDismiss]);
816
+ }
817
+ function AnchoredMenu({
818
+ options,
819
+ selectedValue,
820
+ onSelect,
821
+ onDismiss,
822
+ colors,
823
+ optionHint,
824
+ testID,
825
+ anchorRef
826
+ }) {
827
+ const menuRef = React.useRef(null);
828
+ useDismiss(anchorRef, menuRef, onDismiss);
829
+ return /* @__PURE__ */ jsx(
830
+ View,
831
+ {
832
+ ref: menuRef,
833
+ accessibilityRole: "menu",
834
+ style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }],
835
+ testID: `${testID}-menu`,
836
+ children: options.map((opt) => {
837
+ const active = opt.value === selectedValue;
838
+ return /* @__PURE__ */ jsx(
839
+ Pressable,
840
+ {
841
+ accessibilityRole: "menuitem",
842
+ accessibilityLabel: opt.label,
843
+ accessibilityHint: optionHint,
844
+ accessibilityState: { selected: active },
845
+ "aria-selected": active,
846
+ onPress: () => onSelect(opt.value),
847
+ style: controlStyles.option,
848
+ testID: `${testID}-option-${opt.value}`,
849
+ children: /* @__PURE__ */ jsx(Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
850
+ },
851
+ opt.value
852
+ );
853
+ })
854
+ }
855
+ );
856
+ }
857
+ var CHEVRON_DOWN = "\u25BE";
858
+ var CHEVRON_UP = "\u25B4";
859
+ function SelectControl({
860
+ options,
861
+ value,
862
+ onChange,
863
+ placeholder = "",
864
+ accessibilityLabel,
865
+ accessibilityHint,
866
+ optionHint = "",
867
+ style,
868
+ testID
869
+ }) {
870
+ const { theme } = useUi();
871
+ const { colors, palette } = theme;
872
+ const anchorRef = useRef(null);
873
+ const [isOpen, setIsOpen] = useState(false);
874
+ const selectedLabel = useMemo(() => {
875
+ const found = options.find((o) => o.value === value);
876
+ return found?.label ?? placeholder;
877
+ }, [options, placeholder, value]);
878
+ const close = useCallback(() => {
879
+ setIsOpen(false);
880
+ }, []);
881
+ const toggle = useCallback(() => {
882
+ setIsOpen((prev) => !prev);
883
+ }, []);
884
+ const select = useCallback(
885
+ (next) => {
886
+ onChange(next);
887
+ setIsOpen(false);
888
+ },
889
+ [onChange]
890
+ );
891
+ return /* @__PURE__ */ jsxs(View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
892
+ /* @__PURE__ */ jsxs(
893
+ Pressable,
894
+ {
895
+ accessibilityRole: "button",
896
+ accessibilityLabel,
897
+ accessibilityHint,
898
+ accessibilityState: { expanded: isOpen },
899
+ "aria-expanded": isOpen,
900
+ onPress: toggle,
901
+ style: [controlStyles.selectTrigger, { borderColor: colors.border, backgroundColor: colors.surface }],
902
+ testID: `${testID}-trigger`,
903
+ children: [
904
+ /* @__PURE__ */ jsx(Text, { numberOfLines: 1, style: [controlStyles.selectTriggerText, { color: value === "" ? colors.textSecondary : colors.text }], children: selectedLabel }),
905
+ /* @__PURE__ */ jsx(Text, { "aria-hidden": true, style: [controlStyles.chevron, { color: colors.textSecondary }], children: isOpen ? CHEVRON_UP : CHEVRON_DOWN })
906
+ ]
907
+ }
908
+ ),
909
+ isOpen ? /* @__PURE__ */ jsx(
910
+ AnchoredMenu,
911
+ {
912
+ options,
913
+ selectedValue: value,
914
+ onSelect: select,
915
+ onDismiss: close,
916
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
917
+ optionHint,
918
+ testID,
919
+ anchorRef
920
+ }
921
+ ) : null
922
+ ] });
923
+ }
924
+
925
+ // src/controls/suggestOptions.ts
926
+ var RANK_VALUE_EXACT = 0;
927
+ var RANK_VALUE_PREFIX = 1;
928
+ var RANK_LABEL_PREFIX = 2;
929
+ var RANK_LABEL_CONTAINS = 3;
930
+ var NO_MATCH = -1;
931
+ function rankOf(option, needle) {
932
+ const value = option.value.toLowerCase();
933
+ const label = option.label.toLowerCase();
934
+ if (value === needle) return RANK_VALUE_EXACT;
935
+ if (value.startsWith(needle)) return RANK_VALUE_PREFIX;
936
+ if (label.startsWith(needle)) return RANK_LABEL_PREFIX;
937
+ if (label.includes(needle)) return RANK_LABEL_CONTAINS;
938
+ return NO_MATCH;
939
+ }
940
+ function suggestOptions(options, query, minChars, limit) {
941
+ const needle = query.trim().toLowerCase();
942
+ if (needle.length < minChars) return [];
943
+ const scored = [];
944
+ for (const option of options) {
945
+ const rank = rankOf(option, needle);
946
+ if (rank !== NO_MATCH) scored.push({ option, rank });
947
+ }
948
+ scored.sort((a, b) => a.rank !== b.rank ? a.rank - b.rank : a.option.label.localeCompare(b.option.label));
949
+ return scored.slice(0, limit).map((entry) => entry.option);
950
+ }
951
+ function isUnmatched(options, text) {
952
+ const needle = text.trim().toLowerCase();
953
+ if (needle === "") return false;
954
+ return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
955
+ }
956
+ var CLOSE_DELAY_MS = 140;
957
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
958
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
959
+ function TypeaheadControl({
960
+ options,
961
+ value,
962
+ onChange,
963
+ onSubmit,
964
+ placeholder,
965
+ accessibilityLabel,
966
+ accessibilityHint,
967
+ optionHint = "",
968
+ error,
969
+ minChars = DEFAULT_TYPEAHEAD_MIN_CHARS,
970
+ maxSuggestions = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS,
971
+ style,
972
+ testID
973
+ }) {
974
+ const { theme } = useUi();
975
+ const { colors, palette, semantic } = theme;
976
+ const anchorRef = useRef(null);
977
+ const [isOpen, setIsOpen] = useState(false);
978
+ const exactPick = useMemo(() => options.some((o) => o.label === value), [options, value]);
979
+ const suggestions = useMemo(
980
+ () => exactPick ? [] : suggestOptions(options, value, minChars, maxSuggestions),
981
+ [exactPick, options, value, minChars, maxSuggestions]
982
+ );
983
+ const showMenu = isOpen && suggestions.length > 0;
984
+ const handleChange = useCallback(
985
+ (next) => {
986
+ onChange(next);
987
+ setIsOpen(true);
988
+ },
989
+ [onChange]
990
+ );
991
+ const pick = useCallback(
992
+ (optionValue) => {
993
+ const opt = options.find((o) => o.value === optionValue);
994
+ onChange(opt?.label ?? optionValue);
995
+ setIsOpen(false);
996
+ },
997
+ [options, onChange]
998
+ );
999
+ const handleBlur = useCallback(() => {
1000
+ setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS);
1001
+ }, []);
1002
+ const handleSubmit = useCallback(() => {
1003
+ setIsOpen(false);
1004
+ onSubmit?.();
1005
+ }, [onSubmit]);
1006
+ const hasError = error !== void 0 && error !== "";
1007
+ return /* @__PURE__ */ jsxs(View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1008
+ /* @__PURE__ */ jsx(
1009
+ TextInput,
1010
+ {
1011
+ accessibilityLabel,
1012
+ accessibilityHint,
1013
+ autoCapitalize: "none",
1014
+ autoCorrect: false,
1015
+ placeholder,
1016
+ placeholderTextColor: colors.textSecondary,
1017
+ spellCheck: false,
1018
+ value,
1019
+ onChangeText: handleChange,
1020
+ onFocus: () => setIsOpen(true),
1021
+ onBlur: handleBlur,
1022
+ onSubmitEditing: handleSubmit,
1023
+ style: [controlStyles.input, { borderColor: hasError ? semantic.error["500"] : colors.border, backgroundColor: colors.surface, color: colors.text }],
1024
+ testID: `${testID}-input`
1025
+ }
1026
+ ),
1027
+ showMenu ? /* @__PURE__ */ jsx(
1028
+ AnchoredMenu,
1029
+ {
1030
+ options: suggestions,
1031
+ selectedValue: value,
1032
+ onSelect: pick,
1033
+ onDismiss: () => setIsOpen(false),
1034
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1035
+ optionHint,
1036
+ testID,
1037
+ anchorRef
1038
+ }
1039
+ ) : null,
1040
+ hasError ? /* @__PURE__ */ jsx(Text, { role: "alert", style: [controlStyles.error, { color: semantic.error["500"] }], testID: `${testID}-error`, children: error }) : null
1041
+ ] });
1042
+ }
1043
+ var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1044
+ function DateInput(p) {
1045
+ return /* @__PURE__ */ jsxs(View, { style: controlStyles.dateCol, children: [
1046
+ /* @__PURE__ */ jsx(Text, { style: [controlStyles.subLabel, { color: p.muted }], children: p.label }),
1047
+ /* @__PURE__ */ jsx(
1048
+ TextInput,
1049
+ {
1050
+ accessibilityLabel: p.label,
1051
+ accessibilityHint: p.hint,
1052
+ placeholder: p.placeholder,
1053
+ placeholderTextColor: p.muted,
1054
+ value: p.value,
1055
+ onChangeText: p.onChangeText,
1056
+ onSubmitEditing: p.onSubmit,
1057
+ style: [controlStyles.input, { borderColor: p.border, backgroundColor: p.surface, color: p.color }],
1058
+ testID: `${p.testID}-${p.side}`,
1059
+ ...WEB_DATE_PROPS
1060
+ }
1061
+ )
1062
+ ] });
1063
+ }
1064
+ function DateRangeControl({
1065
+ value,
1066
+ onChange,
1067
+ onSubmit,
1068
+ fromLabel,
1069
+ toLabel,
1070
+ fromPlaceholder,
1071
+ toPlaceholder,
1072
+ accessibilityHint,
1073
+ style,
1074
+ testID
1075
+ }) {
1076
+ const { theme } = useUi();
1077
+ const { colors } = theme;
1078
+ return /* @__PURE__ */ jsxs(View, { style: [controlStyles.dateRow, style], testID: `${testID}-range`, children: [
1079
+ /* @__PURE__ */ jsx(
1080
+ DateInput,
1081
+ {
1082
+ side: "from",
1083
+ label: fromLabel,
1084
+ placeholder: fromPlaceholder,
1085
+ value: value.from,
1086
+ color: colors.text,
1087
+ border: colors.border,
1088
+ surface: colors.surface,
1089
+ muted: colors.textSecondary,
1090
+ hint: accessibilityHint,
1091
+ testID,
1092
+ onChangeText: (from) => onChange({ from, to: value.to }),
1093
+ onSubmit
1094
+ }
1095
+ ),
1096
+ /* @__PURE__ */ jsx(
1097
+ DateInput,
1098
+ {
1099
+ side: "to",
1100
+ label: toLabel,
1101
+ placeholder: toPlaceholder,
1102
+ value: value.to,
1103
+ color: colors.text,
1104
+ border: colors.border,
1105
+ surface: colors.surface,
1106
+ muted: colors.textSecondary,
1107
+ hint: accessibilityHint,
1108
+ testID,
1109
+ onChangeText: (to) => onChange({ from: value.from, to }),
1110
+ onSubmit
1111
+ }
1112
+ )
1113
+ ] });
1114
+ }
553
1115
 
554
- export { ChipSelector, Field, FormCheckbox, FormField, FormSwitch, ThemedTextInput, useThemedInput };
1116
+ export { AnchoredMenu, ChipSelector, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, Field, FormActions, FormCheckbox, FormField, FormSwitch, SelectControl, ThemedTextInput, TypeaheadControl, controlStyles, isUnmatched, suggestOptions, useThemedInput };
555
1117
  //# sourceMappingURL=index.mjs.map
556
1118
  //# sourceMappingURL=index.mjs.map