@dloizides/ui-forms 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0
4
+
5
+ **Campaign F2 — the dense CONTROLS arrive.** `@dloizides/ui-tables` shipped a complete, themed,
6
+ a11y-wired set of form controls inside its `Filters` bar and exported NONE of them: its
7
+ `src/index.ts` exposed only `Filters`, `useFilterDraft`, `suggestOptions`, `isUnmatched` and
8
+ types. Six portals could SEE those controls working and none could import them, so the fleet
9
+ reimplemented them — **6 independent select implementations, 5 date fields, 2 typeaheads**. Same
10
+ shape as `MultiTenancy.EntityFrameworkCore` shipping no EF Core. This release makes them public.
11
+
12
+ Nothing here moves a pixel for an existing consumer: the metrics are the promoted originals,
13
+ unchanged. `CONTAINER_MARGIN_BOTTOM` and `ChipSelector`'s compensating `marginBottom:
14
+ -CHIP_GUTTER` remain deliberately untouched.
15
+
16
+ - **Add `SelectControl`** — single-select enum/status dropdown. Bordered trigger + chevron opening
17
+ the shared `AnchoredMenu`.
18
+ - **Add `TypeaheadControl`** — search-as-you-type combobox (aml-v2's CountryPicker, generalized).
19
+ Free typing is preserved (the value is raw text the caller normalises at query time); picking a
20
+ suggestion fills the canonical LABEL, not the value.
21
+ - **Add `DateRangeControl`** — inclusive from/to pair. Editing one side patches ONLY that side.
22
+ - **Add `AnchoredMenu`** — the floating option list both of the above compose. Kept in-tree with
23
+ NO `ui-layout` dependency; that was a considered choice in `ui-tables` and it still holds, since
24
+ depending on the higher-level `ModalDropdown` would invert the graph for every consumer.
25
+ - **Add `controlStyles`** — the tuned metrics, promoted verbatim. `ui-tables`' `Filters/styles.ts`
26
+ said the quiet part out loud in its own header: it "reconcile[d] the per-field styles that were
27
+ copy-pasted across aml-v2's filter files into one shared source" — and then locked the
28
+ reconciliation inside one bar where no other surface could reach it.
29
+ - **Add `suggestOptions` / `isUnmatched`** — the ranked substring matcher behind the typeahead.
30
+ - **Add `ControlOption` / `DateRangeValue`** types. `ui-tables` now ALIASES its long-standing
31
+ public `FilterOption` / `DateRangeValue` onto these, so there is one definition rather than two
32
+ structurally-compatible twins free to drift.
33
+
34
+ ### The controls are LABEL-FREE, on purpose
35
+
36
+ They render the control only. Compose them inside `Field` — with `labelVariant="control"` on a
37
+ dense surface — to get the label, hint and error slots. That is what retires `ui-tables`' private
38
+ `FieldShell` fork (see `@dloizides/ui-tables` 1.14.0) and leaves ONE label implementation for
39
+ every control instead of one per control.
40
+
41
+ ### i18n: this package still never calls `t`
42
+
43
+ Every string — `accessibilityLabel`, `placeholder`, `optionHint`, `error`, `fromLabel`/`toLabel`
44
+ — is a PRE-LOCALIZED prop. This is load-bearing, not stylistic: aml-v2 localizes through
45
+ `@dloizides/i18n` with positional `{0}` while the other six portals use i18next `{{p1}}`, so a
46
+ component that localized internally could not serve both. `SelectControl`'s `accessibilityLabel`
47
+ is therefore composed BY THE CALLER — it must carry the selection ("Status: Active", not
48
+ "Status"), and building that string needs a `t` call this package must not make.
49
+
50
+ ### Fixed — a WCAG defect carried in by the promoted code
51
+
52
+ `AnchoredMenu` marked the current option with `accessibilityState={{ selected }}` alone.
53
+ **react-native-web does not read `accessibilityState` at all** (verified against
54
+ `react-native-web@0.21`'s `createDOMProps`, which enumerates `aria-selected` /
55
+ `accessibilitySelected` and never `accessibilityState`), so on web the selected option was
56
+ distinguished by colour and font-weight ONLY — no screen reader could tell which option was
57
+ current (WCAG 1.4.1, use of colour). `aria-selected` is now passed alongside, so each platform
58
+ gets the channel it honours. The trigger already paired `aria-expanded` with `accessibilityState`;
59
+ the option row was simply missed when that was done. A test asserts it, and that test FAILED
60
+ before the fix.
61
+
3
62
  ## 1.7.0
4
63
 
5
64
  Additive wave implementing the shared cross-stack forms contract now written down at
package/README.md CHANGED
@@ -98,6 +98,48 @@ with no label no label row is rendered, so it drops into a toolbar or filter bar
98
98
  | `containerStyle` | `ViewStyle` | merged over the container |
99
99
  | `testID` | `string` | on the container |
100
100
 
101
+ ## Dense controls — `SelectControl` / `TypeaheadControl` / `DateRangeControl`
102
+
103
+ Promoted in 1.8.0 out of `@dloizides/ui-tables`' private filter bar, where they worked correctly
104
+ for six portals and were exported to none — which is why the fleet grew 6 selects, 5 date fields
105
+ and 2 typeaheads.
106
+
107
+ They are **label-free by design**. Compose them inside `Field` so there is ONE label
108
+ implementation for every control rather than one per control:
109
+
110
+ ```tsx
111
+ <Field label="Status" labelVariant="control" containerStyle={{ marginBottom: 0 }}>
112
+ <SelectControl
113
+ accessibilityLabel={t('filters.statusName', 'Status', selectedLabel)}
114
+ options={options}
115
+ testID="filters-status"
116
+ value={value}
117
+ onChange={setValue}
118
+ />
119
+ </Field>
120
+ ```
121
+
122
+ `labelVariant="control"` is the 11/700/uppercase voice from
123
+ [`FORMS.md`](../../../PROOViD/AMLService/AMLService/wwwroot/shared/FORMS.md) — the voice for
124
+ labels that are SCANNED (filter bars, toolbars) rather than READ.
125
+
126
+ ### Every string is pre-localized
127
+
128
+ This package never calls `t`. In particular `SelectControl`'s `accessibilityLabel` must be
129
+ composed by YOU and must carry the **selection** — "Status: Active", not "Status". The accessible
130
+ name REPLACES the trigger's visible text for a screen reader, so naming it with the field alone
131
+ silently hides what is selected.
132
+
133
+ | Control | Key props | testIDs |
134
+ |---|---|---|
135
+ | `SelectControl` | `options`, `value`, `onChange`, `placeholder`, `accessibilityLabel`, `optionHint` | `<testID>-trigger`, `-menu`, `-option-<value>` |
136
+ | `TypeaheadControl` | `options`, `value`, `onChange`, `onSubmit`, `error`, `minChars`, `maxSuggestions` | `<testID>-input`, `-menu`, `-error` |
137
+ | `DateRangeControl` | `value` (`{from,to}`), `onChange`, `onSubmit`, `fromLabel`, `toLabel` | `<testID>-range`, `-from`, `-to` |
138
+ | `AnchoredMenu` | the floating list the first two compose | `<testID>-menu` |
139
+
140
+ Also exported: `controlStyles` (the tuned metrics), `suggestOptions` / `isUnmatched` (the
141
+ typeahead's ranker), and the `ControlOption` / `DateRangeValue` types.
142
+
101
143
  ## testIDs
102
144
 
103
145
  `form-field-input`, `chip-selector-chip-<value>`, `form-switch` (overridable on FormSwitch).
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { StyleProp, TextStyle, ViewStyle, TextInputProps, TextInput } from 'react-native';
2
+ import { StyleProp, TextStyle, ViewStyle, TextInputProps, TextInput, View } from 'react-native';
3
3
 
4
4
  /**
5
5
  * The TWO legitimate label voices, per the shared forms contract
@@ -351,4 +351,277 @@ interface FormCheckboxProps {
351
351
  }
352
352
  declare const FormCheckbox: ({ label, value, onValueChange, onChange, hint, description, disabled, containerStyle, testID, accessibilityLabel, accessibilityHint, }: FormCheckboxProps) => React.ReactElement;
353
353
 
354
- export { type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, DEFAULT_OPTIONAL_LABEL, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, type UseThemedInputOptions, useThemedInput };
354
+ /**
355
+ * Shared value types for the dense CONTROLS (select / typeahead / date-range) promoted out of
356
+ * `@dloizides/ui-tables`' private filter bar.
357
+ *
358
+ * They live here, not in `ui-tables`, because a control's option list and a date range are form
359
+ * concepts — a filter bar is only one surface that happens to compose them. `ui-tables` aliases
360
+ * its long-standing public names (`FilterOption`, `DateRangeValue`) onto these, so there is ONE
361
+ * definition and the two can never drift into structurally-compatible-but-separate twins.
362
+ */
363
+ /** A single choice in a select / typeahead. `label` is PRE-LOCALIZED by the caller. */
364
+ interface ControlOption {
365
+ readonly label: string;
366
+ readonly value: string;
367
+ }
368
+ /** The inclusive from/to value of a date-range control (each an ISO `YYYY-MM-DD` string, or ''). */
369
+ interface DateRangeValue {
370
+ readonly from: string;
371
+ readonly to: string;
372
+ }
373
+
374
+ /**
375
+ * `SelectControl` — a single-select enum/status dropdown. A bordered trigger shows the selected
376
+ * option's label (or the placeholder when unset) plus a chevron; pressing it opens the shared
377
+ * {@link AnchoredMenu}. Selecting fires `onChange` and closes.
378
+ *
379
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/SelectField`, where six portals
380
+ * could see it working and none could import it — which is why the fleet grew SIX independent
381
+ * select implementations.
382
+ *
383
+ * The control is LABEL-FREE by design: wrap it in a `Field` (with `labelVariant="control"` on a
384
+ * dense surface) to get the label, hint and error slots. That keeps one label implementation for
385
+ * every control instead of one per control.
386
+ *
387
+ * i18n: this package never calls `t`. `accessibilityLabel`, `placeholder`, `accessibilityHint`
388
+ * and `optionHint` are PRE-LOCALIZED strings supplied by the caller — the only shape that can
389
+ * serve both aml-v2's positional `{0}` `@dloizides/i18n` and the other six portals' i18next
390
+ * `{{p1}}` from one component.
391
+ */
392
+
393
+ interface SelectControlProps {
394
+ options: readonly ControlOption[];
395
+ /** The selected option's `value`. `''` (or any unmatched value) renders the placeholder. */
396
+ value: string;
397
+ onChange: (value: string) => void;
398
+ /** PRE-LOCALIZED text shown when the value matches no option. */
399
+ placeholder?: string;
400
+ /**
401
+ * PRE-LOCALIZED accessible NAME of the trigger. It REPLACES the trigger's visible text for a
402
+ * screen reader, so it must carry the SELECTION too — "Status: Active", not just "Status",
403
+ * which announces the control but silently hides its value.
404
+ */
405
+ accessibilityLabel: string;
406
+ accessibilityHint?: string;
407
+ /** PRE-LOCALIZED accessibility hint applied to every option in the menu. */
408
+ optionHint?: string;
409
+ /** Merged LAST onto the anchor wrapper — the consumer always wins over the shared style. */
410
+ style?: StyleProp<ViewStyle>;
411
+ /** Trigger is `${testID}-trigger`, menu `${testID}-menu`, options `${testID}-option-${value}`. */
412
+ testID: string;
413
+ }
414
+ declare function SelectControl({ options, value, onChange, placeholder, accessibilityLabel, accessibilityHint, optionHint, style, testID, }: SelectControlProps): React.ReactElement;
415
+
416
+ /**
417
+ * `TypeaheadControl` — a search-as-you-type combobox (aml-v2's CountryPicker, generalized). A
418
+ * text input plus a floating {@link AnchoredMenu} of ranked suggestions ({@link suggestOptions});
419
+ * picking one fills the canonical label.
420
+ *
421
+ * Free typing is PRESERVED: the value is the raw text, so a caller can accept "CY", "cyprus" or
422
+ * "Cyprus (CY)" and normalise it themselves at query time, exactly as aml does. An optional
423
+ * PRE-LOCALIZED `error` shows a persistent banner and tints the border.
424
+ *
425
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/TypeaheadField`.
426
+ *
427
+ * Label-free by design — wrap in a `Field` for the label/hint slots.
428
+ */
429
+
430
+ /** Default min characters before suggestions surface. */
431
+ declare const DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
432
+ /** Default cap on rendered suggestions. */
433
+ declare const DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
434
+ interface TypeaheadControlProps {
435
+ options: readonly ControlOption[];
436
+ /** The raw text in the box — NOT necessarily an option value. */
437
+ value: string;
438
+ onChange: (value: string) => void;
439
+ /** Called on Enter/submit (after the menu closes). Omit if the surface has nothing to commit. */
440
+ onSubmit?: () => void;
441
+ placeholder?: string;
442
+ /** PRE-LOCALIZED accessible name of the input. */
443
+ accessibilityLabel: string;
444
+ accessibilityHint?: string;
445
+ /** PRE-LOCALIZED accessibility hint applied to every suggestion. */
446
+ optionHint?: string;
447
+ /** PRE-LOCALIZED inline error (e.g. "Unrecognised country"). Shows a persistent banner. */
448
+ error?: string;
449
+ /** Minimum characters before suggestions surface. Default 1. */
450
+ minChars?: number;
451
+ /** Cap on rendered suggestions. Default 8. */
452
+ maxSuggestions?: number;
453
+ /** Merged LAST onto the anchor wrapper. */
454
+ style?: StyleProp<ViewStyle>;
455
+ /** Input is `${testID}-input`, menu `${testID}-menu`, error `${testID}-error`. */
456
+ testID: string;
457
+ }
458
+ declare function TypeaheadControl({ options, value, onChange, onSubmit, placeholder, accessibilityLabel, accessibilityHint, optionHint, error, minChars, maxSuggestions, style, testID, }: TypeaheadControlProps): React.ReactElement;
459
+
460
+ /**
461
+ * `DateRangeControl` — an inclusive from/to pair (aml cases `from`/`to`, kefi audit `from`/`to`).
462
+ * Each side is a `YYYY-MM-DD` text input; on web the browser's native date UI is offered via
463
+ * `type="date"` (a react-native-web `TextInput` passes unknown props to the DOM input). Editing
464
+ * either side patches ONLY that side, so the other survives the edit.
465
+ *
466
+ * Promoted out of `@dloizides/ui-tables`' private `Filters/fields/DateRangeField`, which is why
467
+ * the fleet grew FIVE independent date fields.
468
+ *
469
+ * Label-free by design — the From/To sub-labels name the two HALVES; wrap in a `Field` for the
470
+ * range's own label.
471
+ */
472
+
473
+ interface DateRangeControlProps {
474
+ value: DateRangeValue;
475
+ onChange: (value: DateRangeValue) => void;
476
+ /** Called on Enter/submit in either side. */
477
+ onSubmit?: () => void;
478
+ /** PRE-LOCALIZED sub-label for the FROM input (e.g. "From"). Also its accessible name. */
479
+ fromLabel: string;
480
+ /** PRE-LOCALIZED sub-label for the TO input (e.g. "To"). Also its accessible name. */
481
+ toLabel: string;
482
+ fromPlaceholder?: string;
483
+ toPlaceholder?: string;
484
+ /** PRE-LOCALIZED hint applied to BOTH inputs. */
485
+ accessibilityHint?: string;
486
+ /** Merged LAST onto the row wrapper. */
487
+ style?: StyleProp<ViewStyle>;
488
+ /** Row is `${testID}-range`, inputs `${testID}-from` / `${testID}-to`. */
489
+ testID: string;
490
+ }
491
+ declare function DateRangeControl({ value, onChange, onSubmit, fromLabel, toLabel, fromPlaceholder, toPlaceholder, accessibilityHint, style, testID, }: DateRangeControlProps): React.ReactElement;
492
+
493
+ /**
494
+ * `AnchoredMenu` — the floating option list shared by `SelectControl` and `TypeaheadControl`.
495
+ *
496
+ * Renders an in-tree, absolutely-positioned popover under its anchor (the pattern aml-v2's
497
+ * CountryPicker proved: `position:absolute; top:100%; zIndex; elevation`), so it floats over
498
+ * sibling fields without a portal. On web it dismisses on outside-click / Escape; on native the
499
+ * caller closes it on blur/select. Purely presentational + theme-flat, so it stays reusable.
500
+ *
501
+ * Promoted verbatim out of `@dloizides/ui-tables`' private `Filters/components/AnchoredMenu`.
502
+ *
503
+ * Deliberately in-tree (no `ui-layout` dependency). That was a considered choice in `ui-tables`
504
+ * and it still holds here: `ui-forms` is a low-level primitive and must not take a runtime
505
+ * dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph for
506
+ * every consumer. Apps needing the responsive modal/bottom-sheet variant inject it instead.
507
+ */
508
+
509
+ /** The four theme colours the menu paints with — passed in, so the menu stays theme-flat. */
510
+ interface AnchoredMenuColors {
511
+ text: string;
512
+ border: string;
513
+ surface: string;
514
+ brand: string;
515
+ }
516
+ interface AnchoredMenuProps {
517
+ options: readonly ControlOption[];
518
+ /** The currently-selected value, highlighted in the list ('' = none). */
519
+ selectedValue: string;
520
+ onSelect: (value: string) => void;
521
+ onDismiss: () => void;
522
+ colors: AnchoredMenuColors;
523
+ /** PRE-LOCALIZED accessibility hint applied to every option. */
524
+ optionHint: string;
525
+ /** Base testID: the menu is `${testID}-menu`, each option `${testID}-option-${value}`. */
526
+ testID: string;
527
+ /** Node whose clicks count as "inside" (the anchor) so its own press doesn't self-dismiss. */
528
+ anchorRef: React.RefObject<View | null>;
529
+ }
530
+ declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, }: AnchoredMenuProps): React.ReactElement;
531
+
532
+ declare const controlStyles: {
533
+ /** The shared bordered box every dense text control renders in. */
534
+ input: {
535
+ borderWidth: number;
536
+ borderRadius: number;
537
+ paddingHorizontal: number;
538
+ paddingVertical: number;
539
+ fontSize: number;
540
+ };
541
+ /** The select trigger: a bordered field box matching the text inputs + a chevron. */
542
+ selectTrigger: {
543
+ flexDirection: "row";
544
+ alignItems: "center";
545
+ justifyContent: "space-between";
546
+ gap: number;
547
+ borderWidth: number;
548
+ borderRadius: number;
549
+ paddingHorizontal: number;
550
+ paddingVertical: number;
551
+ };
552
+ selectTriggerText: {
553
+ fontSize: number;
554
+ flexShrink: number;
555
+ };
556
+ chevron: {
557
+ fontSize: number;
558
+ };
559
+ dateRow: {
560
+ flexDirection: "row";
561
+ gap: number;
562
+ };
563
+ dateCol: {
564
+ gap: number;
565
+ flex: number;
566
+ };
567
+ /**
568
+ * The micro-caption above a sub-control (a date range's From/To) and beside a switch. Note it
569
+ * is deliberately NOT the `control` label voice: it carries no `700`/uppercase, because it
570
+ * names a HALF of a control that already has a `control`-voice label above it. Uppercasing it
571
+ * too would make one field shout twice.
572
+ */
573
+ subLabel: {
574
+ fontSize: number;
575
+ letterSpacing: number;
576
+ };
577
+ /** Positioning context for a floating `AnchoredMenu` (`position:absolute; top:100%`). */
578
+ anchor: {
579
+ position: "relative";
580
+ };
581
+ menu: {
582
+ position: "absolute";
583
+ top: "100%";
584
+ left: number;
585
+ marginTop: number;
586
+ minWidth: number;
587
+ maxHeight: number;
588
+ borderWidth: number;
589
+ borderRadius: number;
590
+ paddingVertical: number;
591
+ zIndex: number;
592
+ boxShadow: string;
593
+ elevation: number;
594
+ overflow: "hidden";
595
+ };
596
+ option: {
597
+ paddingHorizontal: number;
598
+ paddingVertical: number;
599
+ };
600
+ optionText: {
601
+ fontSize: number;
602
+ };
603
+ error: {
604
+ fontSize: number;
605
+ marginTop: number;
606
+ };
607
+ };
608
+
609
+ /**
610
+ * `suggestOptions` — the ranked substring matcher behind `TypeaheadControl`, generalized from
611
+ * aml-v2's `suggestCountries` and promoted verbatim out of `@dloizides/ui-tables`' private
612
+ * `Filters/utils/suggest.ts`.
613
+ *
614
+ * Ranks: value exact (0) > value prefix (1) > label prefix (2) > label contains (3); ties broken
615
+ * by `label.localeCompare`. Empty/short queries return `[]` — nothing surfaces until `minChars`
616
+ * is typed, mirroring a native `<datalist>`.
617
+ */
618
+
619
+ /**
620
+ * Rank + cap the options matching `query`. Returns `[]` until `query` (trimmed) is at least
621
+ * `minChars` long, then the best `limit` matches, ranked as documented above.
622
+ */
623
+ declare function suggestOptions(options: readonly ControlOption[], query: string, minChars: number, limit: number): readonly ControlOption[];
624
+ /** True when `text` is non-empty yet matches no option's value or label exactly (case-insensitive). */
625
+ declare function isUnmatched(options: readonly ControlOption[], text: string): boolean;
626
+
627
+ export { AnchoredMenu, type AnchoredMenuColors, type AnchoredMenuProps, type ChipOption, ChipSelector, type ChipSelectorProps, type ChipVariant, type ControlOption, DEFAULT_OPTIONAL_LABEL, DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS, DEFAULT_TYPEAHEAD_MIN_CHARS, DateRangeControl, type DateRangeControlProps, type DateRangeValue, Field, type FieldChildContext, type FieldChildren, type FieldLabelVariant, type FieldMarker, type FieldProps, FormActions, type FormActionsProps, FormCheckbox, type FormCheckboxProps, FormField, type FormFieldProps, FormSwitch, type FormSwitchProps, SelectControl, type SelectControlProps, type ThemedInput, ThemedTextInput, type ThemedTextInputProps, TypeaheadControl, type TypeaheadControlProps, type UseThemedInputOptions, controlStyles, isUnmatched, suggestOptions, useThemedInput };