@dloizides/ui-forms 1.11.1 → 1.13.1

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,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 1.11.0
3
+ ## 1.12.0
4
+
5
+ **`FormField` gained `labelHidden` — hide the visible label, keep the accessible name.**
6
+
7
+ A field whose placeholder already names it (a toolbar search box, an inline filter) does
8
+ not want a stacked visible label: the label wastes a row and, worse, pushes the input a
9
+ label-row lower than a bare button beside it, so the two misalign. Until now the only way
10
+ to drop the visible label was to blank `label` — which ALSO blanked the input's
11
+ `accessibilityLabel`, silently stripping the accessible name.
12
+
13
+ `labelHidden` removes ONLY the visible `<label>` row: `label` stays required and the input
14
+ keeps `accessibilityLabel={label}`, so a screen reader reads the same name and the field
15
+ still resolves by role + name. Additive and backward-compatible — default `false`, every
16
+ existing call renders unchanged.
17
+
18
+ ## 1.11.1
4
19
 
5
20
  **`ThemedTextInput` shipped no metrics, so every direct caller got a raw 19px input.**
6
21
 
package/dist/index.d.mts CHANGED
@@ -380,11 +380,22 @@ declare const FormActions: ({ onSave, onCancel, saveLabel, cancelLabel, saveHint
380
380
  */
381
381
  interface FormFieldProps extends Omit<TextInputProps, 'style'> {
382
382
  label: string;
383
+ /**
384
+ * Hide the VISIBLE label row while keeping the accessible name.
385
+ *
386
+ * For a field whose placeholder already names it (a toolbar search box, an inline
387
+ * filter) the stacked visible label both wastes a row and misaligns the input
388
+ * against a bare button beside it. Setting this drops ONLY the visible `<label>`:
389
+ * the input still carries `accessibilityLabel={label}`, so a screen reader reads
390
+ * the same name and `label` stays REQUIRED — you never lose the accessible name by
391
+ * blanking it, which is the trap this option exists to remove. Default `false`.
392
+ */
393
+ labelHidden?: boolean;
383
394
  required?: boolean;
384
395
  error?: string;
385
396
  containerStyle?: ViewStyle;
386
397
  }
387
- declare const FormField: ({ label, required, error, containerStyle, ...textInputProps }: FormFieldProps) => React.ReactElement;
398
+ declare const FormField: ({ label, labelHidden, required, error, containerStyle, ...textInputProps }: FormFieldProps) => React.ReactElement;
388
399
 
389
400
  /**
390
401
  * `ThemedTextInput` — the shared, readable + lively text-input primitive. A thin `<TextInput>`
@@ -722,17 +733,20 @@ declare function DateRangeControl({ value, onChange, onSubmit, fromLabel, toLabe
722
733
  /**
723
734
  * `AnchoredMenu` — the floating option list shared by `SelectControl` and `TypeaheadControl`.
724
735
  *
725
- * Renders an in-tree, absolutely-positioned popover under its anchor (the pattern aml-v2's
726
- * CountryPicker proved: `position:absolute; top:100%; zIndex; elevation`), so it floats over
727
- * sibling fields without a portal. On web it dismisses on outside-click / Escape; on native the
728
- * caller closes it on blur/select. Purely presentational + theme-flat, so it stays reusable.
736
+ * By default it renders an in-tree, absolutely-positioned popover under its anchor
737
+ * (`position:absolute; top:100%; zIndex; elevation`). Set {@link AnchoredMenuProps.usePortal} to
738
+ * render it — on web only — in a PORTAL to `document.body` with `position: fixed` at the trigger's
739
+ * measured rect instead: that escapes react-native-web's trapped `z-index: 0` stacking contexts and
740
+ * any ancestor `overflow: hidden`, so the menu is never painted under / clipped by later siblings
741
+ * (the fix for the Country typeahead menu that hid behind the content below it). See {@link menuPortal}.
742
+ * On web it dismisses on outside-click / Escape; on native the caller closes it on blur/select.
743
+ * Purely presentational + theme-flat, so it stays reusable.
729
744
  *
730
745
  * Promoted verbatim out of `@dloizides/ui-tables`' private `Filters/components/AnchoredMenu`.
731
746
  *
732
- * Deliberately in-tree (no `ui-layout` dependency). That was a considered choice in `ui-tables`
733
- * and it still holds here: `ui-forms` is a low-level primitive and must not take a runtime
734
- * dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph for
735
- * every consumer. Apps needing the responsive modal/bottom-sheet variant inject it instead.
747
+ * Deliberately no `ui-layout` dependency: `ui-forms` is a low-level primitive and must not take a
748
+ * runtime dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph
749
+ * for every consumer. The portal mechanism therefore lives locally in {@link menuPortal}.
736
750
  */
737
751
 
738
752
  /** The four theme colours the menu paints with — passed in, so the menu stays theme-flat. */
@@ -755,8 +769,16 @@ interface AnchoredMenuProps {
755
769
  testID: string;
756
770
  /** Node whose clicks count as "inside" (the anchor) so its own press doesn't self-dismiss. */
757
771
  anchorRef: React.RefObject<View | null>;
772
+ /**
773
+ * On web, render the popover in a portal to `document.body` with `position: fixed` at the anchor's
774
+ * measured rect, so it escapes react-native-web's trapped stacking contexts and any ancestor
775
+ * `overflow: hidden` (the fix for a menu that paints under / is clipped by the content below it).
776
+ * No effect on native. Default `false` — the existing in-tree behaviour, so `SelectControl`
777
+ * usages are unchanged; `TypeaheadControl` opts in.
778
+ */
779
+ usePortal?: boolean;
758
780
  }
759
- declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, }: AnchoredMenuProps): React.ReactElement;
781
+ declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, usePortal, }: AnchoredMenuProps): React.ReactElement;
760
782
 
761
783
  declare const controlStyles: {
762
784
  /**
package/dist/index.d.ts CHANGED
@@ -380,11 +380,22 @@ declare const FormActions: ({ onSave, onCancel, saveLabel, cancelLabel, saveHint
380
380
  */
381
381
  interface FormFieldProps extends Omit<TextInputProps, 'style'> {
382
382
  label: string;
383
+ /**
384
+ * Hide the VISIBLE label row while keeping the accessible name.
385
+ *
386
+ * For a field whose placeholder already names it (a toolbar search box, an inline
387
+ * filter) the stacked visible label both wastes a row and misaligns the input
388
+ * against a bare button beside it. Setting this drops ONLY the visible `<label>`:
389
+ * the input still carries `accessibilityLabel={label}`, so a screen reader reads
390
+ * the same name and `label` stays REQUIRED — you never lose the accessible name by
391
+ * blanking it, which is the trap this option exists to remove. Default `false`.
392
+ */
393
+ labelHidden?: boolean;
383
394
  required?: boolean;
384
395
  error?: string;
385
396
  containerStyle?: ViewStyle;
386
397
  }
387
- declare const FormField: ({ label, required, error, containerStyle, ...textInputProps }: FormFieldProps) => React.ReactElement;
398
+ declare const FormField: ({ label, labelHidden, required, error, containerStyle, ...textInputProps }: FormFieldProps) => React.ReactElement;
388
399
 
389
400
  /**
390
401
  * `ThemedTextInput` — the shared, readable + lively text-input primitive. A thin `<TextInput>`
@@ -722,17 +733,20 @@ declare function DateRangeControl({ value, onChange, onSubmit, fromLabel, toLabe
722
733
  /**
723
734
  * `AnchoredMenu` — the floating option list shared by `SelectControl` and `TypeaheadControl`.
724
735
  *
725
- * Renders an in-tree, absolutely-positioned popover under its anchor (the pattern aml-v2's
726
- * CountryPicker proved: `position:absolute; top:100%; zIndex; elevation`), so it floats over
727
- * sibling fields without a portal. On web it dismisses on outside-click / Escape; on native the
728
- * caller closes it on blur/select. Purely presentational + theme-flat, so it stays reusable.
736
+ * By default it renders an in-tree, absolutely-positioned popover under its anchor
737
+ * (`position:absolute; top:100%; zIndex; elevation`). Set {@link AnchoredMenuProps.usePortal} to
738
+ * render it — on web only — in a PORTAL to `document.body` with `position: fixed` at the trigger's
739
+ * measured rect instead: that escapes react-native-web's trapped `z-index: 0` stacking contexts and
740
+ * any ancestor `overflow: hidden`, so the menu is never painted under / clipped by later siblings
741
+ * (the fix for the Country typeahead menu that hid behind the content below it). See {@link menuPortal}.
742
+ * On web it dismisses on outside-click / Escape; on native the caller closes it on blur/select.
743
+ * Purely presentational + theme-flat, so it stays reusable.
729
744
  *
730
745
  * Promoted verbatim out of `@dloizides/ui-tables`' private `Filters/components/AnchoredMenu`.
731
746
  *
732
- * Deliberately in-tree (no `ui-layout` dependency). That was a considered choice in `ui-tables`
733
- * and it still holds here: `ui-forms` is a low-level primitive and must not take a runtime
734
- * dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph for
735
- * every consumer. Apps needing the responsive modal/bottom-sheet variant inject it instead.
747
+ * Deliberately no `ui-layout` dependency: `ui-forms` is a low-level primitive and must not take a
748
+ * runtime dependency on the higher-level `ui-layout` `ModalDropdown` — that would invert the graph
749
+ * for every consumer. The portal mechanism therefore lives locally in {@link menuPortal}.
736
750
  */
737
751
 
738
752
  /** The four theme colours the menu paints with — passed in, so the menu stays theme-flat. */
@@ -755,8 +769,16 @@ interface AnchoredMenuProps {
755
769
  testID: string;
756
770
  /** Node whose clicks count as "inside" (the anchor) so its own press doesn't self-dismiss. */
757
771
  anchorRef: React.RefObject<View | null>;
772
+ /**
773
+ * On web, render the popover in a portal to `document.body` with `position: fixed` at the anchor's
774
+ * measured rect, so it escapes react-native-web's trapped stacking contexts and any ancestor
775
+ * `overflow: hidden` (the fix for a menu that paints under / is clipped by the content below it).
776
+ * No effect on native. Default `false` — the existing in-tree behaviour, so `SelectControl`
777
+ * usages are unchanged; `TypeaheadControl` opts in.
778
+ */
779
+ usePortal?: boolean;
758
780
  }
759
- declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, }: AnchoredMenuProps): React.ReactElement;
781
+ declare function AnchoredMenu({ options, selectedValue, onSelect, onDismiss, colors, optionHint, testID, anchorRef, usePortal, }: AnchoredMenuProps): React.ReactElement;
760
782
 
761
783
  declare const controlStyles: {
762
784
  /**
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ var uiFeedback = require('@dloizides/ui-feedback');
6
6
  var jsxRuntime = require('react/jsx-runtime');
7
7
  var uiButtons = require('@dloizides/ui-buttons');
8
8
  var a11y = require('@dloizides/a11y');
9
+ var reactDom = require('react-dom');
9
10
 
10
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
12
 
@@ -612,23 +613,33 @@ var ThemedTextInput = React2__default.default.forwardRef(function ThemedTextInpu
612
613
  });
613
614
  var FormField = ({
614
615
  label,
616
+ labelHidden = false,
615
617
  required = false,
616
618
  error,
617
619
  containerStyle,
618
620
  ...textInputProps
619
- }) => /* @__PURE__ */ jsxRuntime.jsx(Field, { containerStyle, error, label, required, children: ({ describedById, hasError, controlId }) => /* @__PURE__ */ jsxRuntime.jsx(
620
- ThemedTextInput,
621
+ }) => /* @__PURE__ */ jsxRuntime.jsx(
622
+ Field,
621
623
  {
622
- accessibilityHint: `Enter ${label}`,
623
- accessibilityLabel: label,
624
- describedById,
625
- hasError,
626
- id: controlId,
627
- requiredField: required,
628
- testID: "form-field-input",
629
- ...textInputProps
624
+ containerStyle,
625
+ error,
626
+ label: labelHidden ? void 0 : label,
627
+ required,
628
+ children: ({ describedById, hasError, controlId }) => /* @__PURE__ */ jsxRuntime.jsx(
629
+ ThemedTextInput,
630
+ {
631
+ accessibilityHint: `Enter ${label}`,
632
+ accessibilityLabel: label,
633
+ describedById,
634
+ hasError,
635
+ id: controlId,
636
+ requiredField: required,
637
+ testID: "form-field-input",
638
+ ...textInputProps
639
+ }
640
+ )
630
641
  }
631
- ) });
642
+ );
632
643
  var TRANSPARENT_COLOR = "transparent";
633
644
  var WHITE_COLOR = "#fff";
634
645
  var SOLID_RADIUS = 20;
@@ -997,13 +1008,76 @@ var FormCheckbox = ({
997
1008
  }
998
1009
  );
999
1010
  };
1000
- var IS_WEB5 = typeof document !== "undefined";
1011
+ var IS_WEB5 = reactNative.Platform.OS === "web";
1012
+ var MENU_PORTAL_Z_INDEX = 1e3;
1013
+ var MENU_TOP_GAP2 = 4;
1014
+ function toDomNode(ref) {
1015
+ const node = ref.current;
1016
+ if (node === null || typeof node.getBoundingClientRect !== "function") return null;
1017
+ return node;
1018
+ }
1019
+ function readAnchorRect(ref) {
1020
+ if (!IS_WEB5) return null;
1021
+ const node = toDomNode(ref);
1022
+ if (node === null) return null;
1023
+ const r = node.getBoundingClientRect();
1024
+ return { top: r.top, left: r.left, width: r.width, bottom: r.bottom };
1025
+ }
1026
+ function buildPortalPopoverStyle(rect) {
1027
+ return {
1028
+ // RN's ViewStyle union omits 'fixed', but react-native-web honours it at runtime.
1029
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- web-only position value
1030
+ position: "fixed",
1031
+ top: rect.bottom + MENU_TOP_GAP2,
1032
+ left: rect.left,
1033
+ width: rect.width,
1034
+ zIndex: MENU_PORTAL_Z_INDEX
1035
+ };
1036
+ }
1037
+ function useAnchorRect(ref, enabled) {
1038
+ const [rect, setRect] = React2.useState(null);
1039
+ React2.useLayoutEffect(() => {
1040
+ if (!IS_WEB5 || !enabled) {
1041
+ setRect(null);
1042
+ return void 0;
1043
+ }
1044
+ let frame = null;
1045
+ let disposed = false;
1046
+ const apply = () => {
1047
+ frame = null;
1048
+ if (disposed) return;
1049
+ setRect(readAnchorRect(ref));
1050
+ };
1051
+ const schedule = () => {
1052
+ if (frame !== null) return;
1053
+ frame = window.requestAnimationFrame(apply);
1054
+ };
1055
+ apply();
1056
+ document.addEventListener("scroll", schedule, true);
1057
+ window.addEventListener("resize", schedule);
1058
+ const node = toDomNode(ref);
1059
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : null;
1060
+ if (observer !== null) {
1061
+ if (node !== null) observer.observe(node);
1062
+ observer.observe(document.body);
1063
+ }
1064
+ return () => {
1065
+ disposed = true;
1066
+ if (frame !== null) window.cancelAnimationFrame(frame);
1067
+ document.removeEventListener("scroll", schedule, true);
1068
+ window.removeEventListener("resize", schedule);
1069
+ if (observer !== null) observer.disconnect();
1070
+ };
1071
+ }, [ref, enabled]);
1072
+ return IS_WEB5 && enabled ? rect : null;
1073
+ }
1074
+ var IS_WEB6 = typeof document !== "undefined";
1001
1075
  var ESCAPE_KEY = "Escape";
1002
1076
  var ACTIVE_WEIGHT = "700";
1003
1077
  var IDLE_WEIGHT = "400";
1004
1078
  function useDismiss(anchorRef, menuRef, onDismiss) {
1005
1079
  React2.useEffect(() => {
1006
- if (!IS_WEB5) return void 0;
1080
+ if (!IS_WEB6) return void 0;
1007
1081
  const onMouseDown = (event) => {
1008
1082
  const anchorNode = anchorRef.current;
1009
1083
  const menuNode = menuRef.current;
@@ -1031,16 +1105,19 @@ function AnchoredMenu({
1031
1105
  colors,
1032
1106
  optionHint,
1033
1107
  testID,
1034
- anchorRef
1108
+ anchorRef,
1109
+ usePortal = false
1035
1110
  }) {
1036
1111
  const menuRef = React2__default.default.useRef(null);
1037
1112
  useDismiss(anchorRef, menuRef, onDismiss);
1038
- return /* @__PURE__ */ jsxRuntime.jsx(
1113
+ const rect = useAnchorRect(anchorRef, usePortal);
1114
+ const portalStyle = rect !== null ? buildPortalPopoverStyle(rect) : null;
1115
+ const menu = /* @__PURE__ */ jsxRuntime.jsx(
1039
1116
  reactNative.View,
1040
1117
  {
1041
1118
  ref: menuRef,
1042
1119
  accessibilityRole: "menu",
1043
- style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }],
1120
+ style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }, portalStyle],
1044
1121
  testID: `${testID}-menu`,
1045
1122
  children: options.map((opt) => {
1046
1123
  const active = opt.value === selectedValue;
@@ -1062,6 +1139,8 @@ function AnchoredMenu({
1062
1139
  })
1063
1140
  }
1064
1141
  );
1142
+ if (usePortal && IS_WEB6) return reactDom.createPortal(menu, document.body);
1143
+ return menu;
1065
1144
  }
1066
1145
  var CHEVRON_DOWN = "\u25BE";
1067
1146
  var CHEVRON_UP = "\u25B4";
@@ -1245,7 +1324,8 @@ function TypeaheadControl({
1245
1324
  colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1246
1325
  optionHint,
1247
1326
  testID,
1248
- anchorRef
1327
+ anchorRef,
1328
+ usePortal: true
1249
1329
  }
1250
1330
  ) : null,
1251
1331
  hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [controlStyles.error, { color: semantic.error["500"] }], testID: `${testID}-error`, children: error }) : null