@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.mjs CHANGED
@@ -1,4 +1,4 @@
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';
@@ -713,7 +713,406 @@ var FormCheckbox = ({
713
713
  }
714
714
  );
715
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
+ }
716
1115
 
717
- export { ChipSelector, DEFAULT_OPTIONAL_LABEL, Field, FormActions, 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 };
718
1117
  //# sourceMappingURL=index.mjs.map
719
1118
  //# sourceMappingURL=index.mjs.map