@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/dist/index.js CHANGED
@@ -719,15 +719,423 @@ var FormCheckbox = ({
719
719
  }
720
720
  );
721
721
  };
722
+ var FIELD_GAP = 4;
723
+ var LABEL_FONT = 11;
724
+ var LABEL_LETTER_SPACING = 0.4;
725
+ var INPUT_RADIUS = 8;
726
+ var INPUT_PAD_H = 12;
727
+ var INPUT_PAD_V = 10;
728
+ var INPUT_FONT = 14;
729
+ var INPUT_BORDER = 1;
730
+ var DATE_ROW_GAP = 8;
731
+ var CHEVRON_FONT = 10;
732
+ var MENU_TOP_GAP = 4;
733
+ var MENU_RADIUS = 8;
734
+ var MENU_PAD_V = 4;
735
+ var MENU_OPT_PAD_H = 12;
736
+ var MENU_OPT_PAD_V = 8;
737
+ var MENU_MIN_WIDTH = 160;
738
+ var MENU_MAX_HEIGHT = 260;
739
+ var MENU_Z = 1e3;
740
+ var MENU_ELEVATION = 8;
741
+ var MENU_BOX_SHADOW = "0px 2px 8px rgba(0, 0, 0, 0.15)";
742
+ var OPTION_FONT = 14;
743
+ var ERROR_FONT = 12;
744
+ var ERROR_GAP = 3;
745
+ var controlStyles = reactNative.StyleSheet.create({
746
+ /** The shared bordered box every dense text control renders in. */
747
+ input: {
748
+ borderWidth: INPUT_BORDER,
749
+ borderRadius: INPUT_RADIUS,
750
+ paddingHorizontal: INPUT_PAD_H,
751
+ paddingVertical: INPUT_PAD_V,
752
+ fontSize: INPUT_FONT
753
+ },
754
+ /** The select trigger: a bordered field box matching the text inputs + a chevron. */
755
+ selectTrigger: {
756
+ flexDirection: "row",
757
+ alignItems: "center",
758
+ justifyContent: "space-between",
759
+ gap: DATE_ROW_GAP,
760
+ borderWidth: INPUT_BORDER,
761
+ borderRadius: INPUT_RADIUS,
762
+ paddingHorizontal: INPUT_PAD_H,
763
+ paddingVertical: INPUT_PAD_V
764
+ },
765
+ selectTriggerText: { fontSize: INPUT_FONT, flexShrink: 1 },
766
+ chevron: { fontSize: CHEVRON_FONT },
767
+ dateRow: { flexDirection: "row", gap: DATE_ROW_GAP },
768
+ dateCol: { gap: FIELD_GAP, flex: 1 },
769
+ /**
770
+ * The micro-caption above a sub-control (a date range's From/To) and beside a switch. Note it
771
+ * is deliberately NOT the `control` label voice: it carries no `700`/uppercase, because it
772
+ * names a HALF of a control that already has a `control`-voice label above it. Uppercasing it
773
+ * too would make one field shout twice.
774
+ */
775
+ subLabel: { fontSize: LABEL_FONT, letterSpacing: LABEL_LETTER_SPACING },
776
+ /** Positioning context for a floating `AnchoredMenu` (`position:absolute; top:100%`). */
777
+ anchor: { position: "relative" },
778
+ menu: {
779
+ position: "absolute",
780
+ top: "100%",
781
+ left: 0,
782
+ marginTop: MENU_TOP_GAP,
783
+ minWidth: MENU_MIN_WIDTH,
784
+ maxHeight: MENU_MAX_HEIGHT,
785
+ borderWidth: INPUT_BORDER,
786
+ borderRadius: MENU_RADIUS,
787
+ paddingVertical: MENU_PAD_V,
788
+ zIndex: MENU_Z,
789
+ boxShadow: MENU_BOX_SHADOW,
790
+ elevation: MENU_ELEVATION,
791
+ overflow: "hidden"
792
+ },
793
+ option: { paddingHorizontal: MENU_OPT_PAD_H, paddingVertical: MENU_OPT_PAD_V },
794
+ optionText: { fontSize: OPTION_FONT },
795
+ error: { fontSize: ERROR_FONT, marginTop: ERROR_GAP }
796
+ });
797
+ var IS_WEB4 = typeof document !== "undefined";
798
+ var ESCAPE_KEY = "Escape";
799
+ var ACTIVE_WEIGHT = "700";
800
+ var IDLE_WEIGHT = "400";
801
+ function useDismiss(anchorRef, menuRef, onDismiss) {
802
+ React.useEffect(() => {
803
+ if (!IS_WEB4) return void 0;
804
+ const onMouseDown = (event) => {
805
+ const anchorNode = anchorRef.current;
806
+ const menuNode = menuRef.current;
807
+ const target = event.target instanceof Node ? event.target : null;
808
+ const insideAnchor = anchorNode !== null && target !== null && anchorNode.contains(target);
809
+ const insideMenu = menuNode !== null && target !== null && menuNode.contains(target);
810
+ if (!insideAnchor && !insideMenu) onDismiss();
811
+ };
812
+ const onKeyDown = (event) => {
813
+ if (event.key === ESCAPE_KEY) onDismiss();
814
+ };
815
+ document.addEventListener("mousedown", onMouseDown);
816
+ document.addEventListener("keydown", onKeyDown);
817
+ return () => {
818
+ document.removeEventListener("mousedown", onMouseDown);
819
+ document.removeEventListener("keydown", onKeyDown);
820
+ };
821
+ }, [anchorRef, menuRef, onDismiss]);
822
+ }
823
+ function AnchoredMenu({
824
+ options,
825
+ selectedValue,
826
+ onSelect,
827
+ onDismiss,
828
+ colors,
829
+ optionHint,
830
+ testID,
831
+ anchorRef
832
+ }) {
833
+ const menuRef = React__default.default.useRef(null);
834
+ useDismiss(anchorRef, menuRef, onDismiss);
835
+ return /* @__PURE__ */ jsxRuntime.jsx(
836
+ reactNative.View,
837
+ {
838
+ ref: menuRef,
839
+ accessibilityRole: "menu",
840
+ style: [controlStyles.menu, { borderColor: colors.border, backgroundColor: colors.surface }],
841
+ testID: `${testID}-menu`,
842
+ children: options.map((opt) => {
843
+ const active = opt.value === selectedValue;
844
+ return /* @__PURE__ */ jsxRuntime.jsx(
845
+ reactNative.Pressable,
846
+ {
847
+ accessibilityRole: "menuitem",
848
+ accessibilityLabel: opt.label,
849
+ accessibilityHint: optionHint,
850
+ accessibilityState: { selected: active },
851
+ "aria-selected": active,
852
+ onPress: () => onSelect(opt.value),
853
+ style: controlStyles.option,
854
+ testID: `${testID}-option-${opt.value}`,
855
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.optionText, { color: active ? colors.brand : colors.text, fontWeight: active ? ACTIVE_WEIGHT : IDLE_WEIGHT }], children: opt.label })
856
+ },
857
+ opt.value
858
+ );
859
+ })
860
+ }
861
+ );
862
+ }
863
+ var CHEVRON_DOWN = "\u25BE";
864
+ var CHEVRON_UP = "\u25B4";
865
+ function SelectControl({
866
+ options,
867
+ value,
868
+ onChange,
869
+ placeholder = "",
870
+ accessibilityLabel,
871
+ accessibilityHint,
872
+ optionHint = "",
873
+ style,
874
+ testID
875
+ }) {
876
+ const { theme } = uiFeedback.useUi();
877
+ const { colors, palette } = theme;
878
+ const anchorRef = React.useRef(null);
879
+ const [isOpen, setIsOpen] = React.useState(false);
880
+ const selectedLabel = React.useMemo(() => {
881
+ const found = options.find((o) => o.value === value);
882
+ return found?.label ?? placeholder;
883
+ }, [options, placeholder, value]);
884
+ const close = React.useCallback(() => {
885
+ setIsOpen(false);
886
+ }, []);
887
+ const toggle = React.useCallback(() => {
888
+ setIsOpen((prev) => !prev);
889
+ }, []);
890
+ const select = React.useCallback(
891
+ (next) => {
892
+ onChange(next);
893
+ setIsOpen(false);
894
+ },
895
+ [onChange]
896
+ );
897
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
898
+ /* @__PURE__ */ jsxRuntime.jsxs(
899
+ reactNative.Pressable,
900
+ {
901
+ accessibilityRole: "button",
902
+ accessibilityLabel,
903
+ accessibilityHint,
904
+ accessibilityState: { expanded: isOpen },
905
+ "aria-expanded": isOpen,
906
+ onPress: toggle,
907
+ style: [controlStyles.selectTrigger, { borderColor: colors.border, backgroundColor: colors.surface }],
908
+ testID: `${testID}-trigger`,
909
+ children: [
910
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { numberOfLines: 1, style: [controlStyles.selectTriggerText, { color: value === "" ? colors.textSecondary : colors.text }], children: selectedLabel }),
911
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { "aria-hidden": true, style: [controlStyles.chevron, { color: colors.textSecondary }], children: isOpen ? CHEVRON_UP : CHEVRON_DOWN })
912
+ ]
913
+ }
914
+ ),
915
+ isOpen ? /* @__PURE__ */ jsxRuntime.jsx(
916
+ AnchoredMenu,
917
+ {
918
+ options,
919
+ selectedValue: value,
920
+ onSelect: select,
921
+ onDismiss: close,
922
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
923
+ optionHint,
924
+ testID,
925
+ anchorRef
926
+ }
927
+ ) : null
928
+ ] });
929
+ }
930
+
931
+ // src/controls/suggestOptions.ts
932
+ var RANK_VALUE_EXACT = 0;
933
+ var RANK_VALUE_PREFIX = 1;
934
+ var RANK_LABEL_PREFIX = 2;
935
+ var RANK_LABEL_CONTAINS = 3;
936
+ var NO_MATCH = -1;
937
+ function rankOf(option, needle) {
938
+ const value = option.value.toLowerCase();
939
+ const label = option.label.toLowerCase();
940
+ if (value === needle) return RANK_VALUE_EXACT;
941
+ if (value.startsWith(needle)) return RANK_VALUE_PREFIX;
942
+ if (label.startsWith(needle)) return RANK_LABEL_PREFIX;
943
+ if (label.includes(needle)) return RANK_LABEL_CONTAINS;
944
+ return NO_MATCH;
945
+ }
946
+ function suggestOptions(options, query, minChars, limit) {
947
+ const needle = query.trim().toLowerCase();
948
+ if (needle.length < minChars) return [];
949
+ const scored = [];
950
+ for (const option of options) {
951
+ const rank = rankOf(option, needle);
952
+ if (rank !== NO_MATCH) scored.push({ option, rank });
953
+ }
954
+ scored.sort((a, b) => a.rank !== b.rank ? a.rank - b.rank : a.option.label.localeCompare(b.option.label));
955
+ return scored.slice(0, limit).map((entry) => entry.option);
956
+ }
957
+ function isUnmatched(options, text) {
958
+ const needle = text.trim().toLowerCase();
959
+ if (needle === "") return false;
960
+ return !options.some((o) => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle);
961
+ }
962
+ var CLOSE_DELAY_MS = 140;
963
+ var DEFAULT_TYPEAHEAD_MIN_CHARS = 1;
964
+ var DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = 8;
965
+ function TypeaheadControl({
966
+ options,
967
+ value,
968
+ onChange,
969
+ onSubmit,
970
+ placeholder,
971
+ accessibilityLabel,
972
+ accessibilityHint,
973
+ optionHint = "",
974
+ error,
975
+ minChars = DEFAULT_TYPEAHEAD_MIN_CHARS,
976
+ maxSuggestions = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS,
977
+ style,
978
+ testID
979
+ }) {
980
+ const { theme } = uiFeedback.useUi();
981
+ const { colors, palette, semantic } = theme;
982
+ const anchorRef = React.useRef(null);
983
+ const [isOpen, setIsOpen] = React.useState(false);
984
+ const exactPick = React.useMemo(() => options.some((o) => o.label === value), [options, value]);
985
+ const suggestions = React.useMemo(
986
+ () => exactPick ? [] : suggestOptions(options, value, minChars, maxSuggestions),
987
+ [exactPick, options, value, minChars, maxSuggestions]
988
+ );
989
+ const showMenu = isOpen && suggestions.length > 0;
990
+ const handleChange = React.useCallback(
991
+ (next) => {
992
+ onChange(next);
993
+ setIsOpen(true);
994
+ },
995
+ [onChange]
996
+ );
997
+ const pick = React.useCallback(
998
+ (optionValue) => {
999
+ const opt = options.find((o) => o.value === optionValue);
1000
+ onChange(opt?.label ?? optionValue);
1001
+ setIsOpen(false);
1002
+ },
1003
+ [options, onChange]
1004
+ );
1005
+ const handleBlur = React.useCallback(() => {
1006
+ setTimeout(() => setIsOpen(false), CLOSE_DELAY_MS);
1007
+ }, []);
1008
+ const handleSubmit = React.useCallback(() => {
1009
+ setIsOpen(false);
1010
+ onSubmit?.();
1011
+ }, [onSubmit]);
1012
+ const hasError = error !== void 0 && error !== "";
1013
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { ref: anchorRef, style: [controlStyles.anchor, style], children: [
1014
+ /* @__PURE__ */ jsxRuntime.jsx(
1015
+ reactNative.TextInput,
1016
+ {
1017
+ accessibilityLabel,
1018
+ accessibilityHint,
1019
+ autoCapitalize: "none",
1020
+ autoCorrect: false,
1021
+ placeholder,
1022
+ placeholderTextColor: colors.textSecondary,
1023
+ spellCheck: false,
1024
+ value,
1025
+ onChangeText: handleChange,
1026
+ onFocus: () => setIsOpen(true),
1027
+ onBlur: handleBlur,
1028
+ onSubmitEditing: handleSubmit,
1029
+ style: [controlStyles.input, { borderColor: hasError ? semantic.error["500"] : colors.border, backgroundColor: colors.surface, color: colors.text }],
1030
+ testID: `${testID}-input`
1031
+ }
1032
+ ),
1033
+ showMenu ? /* @__PURE__ */ jsxRuntime.jsx(
1034
+ AnchoredMenu,
1035
+ {
1036
+ options: suggestions,
1037
+ selectedValue: value,
1038
+ onSelect: pick,
1039
+ onDismiss: () => setIsOpen(false),
1040
+ colors: { text: colors.text, border: colors.border, surface: colors.surface, brand: palette.primary["500"] },
1041
+ optionHint,
1042
+ testID,
1043
+ anchorRef
1044
+ }
1045
+ ) : null,
1046
+ hasError ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { role: "alert", style: [controlStyles.error, { color: semantic.error["500"] }], testID: `${testID}-error`, children: error }) : null
1047
+ ] });
1048
+ }
1049
+ var WEB_DATE_PROPS = typeof document !== "undefined" ? { type: "date" } : {};
1050
+ function DateInput(p) {
1051
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: controlStyles.dateCol, children: [
1052
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [controlStyles.subLabel, { color: p.muted }], children: p.label }),
1053
+ /* @__PURE__ */ jsxRuntime.jsx(
1054
+ reactNative.TextInput,
1055
+ {
1056
+ accessibilityLabel: p.label,
1057
+ accessibilityHint: p.hint,
1058
+ placeholder: p.placeholder,
1059
+ placeholderTextColor: p.muted,
1060
+ value: p.value,
1061
+ onChangeText: p.onChangeText,
1062
+ onSubmitEditing: p.onSubmit,
1063
+ style: [controlStyles.input, { borderColor: p.border, backgroundColor: p.surface, color: p.color }],
1064
+ testID: `${p.testID}-${p.side}`,
1065
+ ...WEB_DATE_PROPS
1066
+ }
1067
+ )
1068
+ ] });
1069
+ }
1070
+ function DateRangeControl({
1071
+ value,
1072
+ onChange,
1073
+ onSubmit,
1074
+ fromLabel,
1075
+ toLabel,
1076
+ fromPlaceholder,
1077
+ toPlaceholder,
1078
+ accessibilityHint,
1079
+ style,
1080
+ testID
1081
+ }) {
1082
+ const { theme } = uiFeedback.useUi();
1083
+ const { colors } = theme;
1084
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [controlStyles.dateRow, style], testID: `${testID}-range`, children: [
1085
+ /* @__PURE__ */ jsxRuntime.jsx(
1086
+ DateInput,
1087
+ {
1088
+ side: "from",
1089
+ label: fromLabel,
1090
+ placeholder: fromPlaceholder,
1091
+ value: value.from,
1092
+ color: colors.text,
1093
+ border: colors.border,
1094
+ surface: colors.surface,
1095
+ muted: colors.textSecondary,
1096
+ hint: accessibilityHint,
1097
+ testID,
1098
+ onChangeText: (from) => onChange({ from, to: value.to }),
1099
+ onSubmit
1100
+ }
1101
+ ),
1102
+ /* @__PURE__ */ jsxRuntime.jsx(
1103
+ DateInput,
1104
+ {
1105
+ side: "to",
1106
+ label: toLabel,
1107
+ placeholder: toPlaceholder,
1108
+ value: value.to,
1109
+ color: colors.text,
1110
+ border: colors.border,
1111
+ surface: colors.surface,
1112
+ muted: colors.textSecondary,
1113
+ hint: accessibilityHint,
1114
+ testID,
1115
+ onChangeText: (to) => onChange({ from: value.from, to }),
1116
+ onSubmit
1117
+ }
1118
+ )
1119
+ ] });
1120
+ }
722
1121
 
1122
+ exports.AnchoredMenu = AnchoredMenu;
723
1123
  exports.ChipSelector = ChipSelector;
724
1124
  exports.DEFAULT_OPTIONAL_LABEL = DEFAULT_OPTIONAL_LABEL;
1125
+ exports.DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS = DEFAULT_TYPEAHEAD_MAX_SUGGESTIONS;
1126
+ exports.DEFAULT_TYPEAHEAD_MIN_CHARS = DEFAULT_TYPEAHEAD_MIN_CHARS;
1127
+ exports.DateRangeControl = DateRangeControl;
725
1128
  exports.Field = Field;
726
1129
  exports.FormActions = FormActions;
727
1130
  exports.FormCheckbox = FormCheckbox;
728
1131
  exports.FormField = FormField;
729
1132
  exports.FormSwitch = FormSwitch;
1133
+ exports.SelectControl = SelectControl;
730
1134
  exports.ThemedTextInput = ThemedTextInput;
1135
+ exports.TypeaheadControl = TypeaheadControl;
1136
+ exports.controlStyles = controlStyles;
1137
+ exports.isUnmatched = isUnmatched;
1138
+ exports.suggestOptions = suggestOptions;
731
1139
  exports.useThemedInput = useThemedInput;
732
1140
  //# sourceMappingURL=index.js.map
733
1141
  //# sourceMappingURL=index.js.map