@terreno/ui 0.11.1 → 0.11.3
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/Common.d.ts +12 -12
- package/dist/Common.js.map +1 -1
- package/dist/PickerSelect.js +72 -17
- package/dist/PickerSelect.js.map +1 -1
- package/dist/SelectBadge.js +39 -4
- package/dist/SelectBadge.js.map +1 -1
- package/dist/WebDropdownMenu.d.ts +60 -0
- package/dist/WebDropdownMenu.js +63 -0
- package/dist/WebDropdownMenu.js.map +1 -0
- package/package.json +1 -1
- package/src/Common.ts +34 -13
- package/src/Modal.test.tsx +46 -1
- package/src/PickerSelect.tsx +109 -24
- package/src/SelectBadge.tsx +63 -5
- package/src/SelectBadge.web.test.tsx +75 -0
- package/src/WebDropdownMenu.test.tsx +180 -0
- package/src/WebDropdownMenu.tsx +183 -0
- package/src/__snapshots__/SelectBadge.test.tsx.snap +27 -0
- package/src/useConsentForms.test.ts +41 -19
- package/src/useSubmitConsent.test.ts +16 -5
package/src/Common.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type {CountryCode} from "libphonenumber-js";
|
|
2
2
|
import type React from "react";
|
|
3
3
|
import type {ReactElement, ReactNode} from "react";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ImageStyle,
|
|
6
|
+
ListRenderItemInfo,
|
|
7
|
+
StyleProp,
|
|
8
|
+
TextInput,
|
|
9
|
+
TextStyle,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from "react-native";
|
|
5
12
|
import type {DimensionValue} from "react-native/Libraries/StyleSheet/StyleSheetTypes";
|
|
6
13
|
import type {Styles} from "react-native-google-places-autocomplete";
|
|
7
14
|
import type {SvgProps} from "react-native-svg";
|
|
@@ -456,6 +463,7 @@ export interface BoxPropsBase {
|
|
|
456
463
|
lgColumn?: UnsignedUpTo12;
|
|
457
464
|
dangerouslySetInlineStyle?: {
|
|
458
465
|
__style: {
|
|
466
|
+
// noExplicitAny: escape hatch for arbitrary inline style values
|
|
459
467
|
[key: string]: any;
|
|
460
468
|
};
|
|
461
469
|
};
|
|
@@ -528,7 +536,7 @@ export interface BoxPropsBase {
|
|
|
528
536
|
|
|
529
537
|
onClick?: () => void | Promise<void>;
|
|
530
538
|
className?: string;
|
|
531
|
-
style?:
|
|
539
|
+
style?: StyleProp<ViewStyle>;
|
|
532
540
|
onHoverStart?: () => void | Promise<void>;
|
|
533
541
|
onHoverEnd?: () => void | Promise<void>;
|
|
534
542
|
scroll?: boolean;
|
|
@@ -554,7 +562,7 @@ export type BoxProps =
|
|
|
554
562
|
export type BoxColor = SurfaceColor | "transparent";
|
|
555
563
|
|
|
556
564
|
export interface ErrorBoundaryProps {
|
|
557
|
-
onError?: (error: Error, stack:
|
|
565
|
+
onError?: (error: Error, stack: string) => void;
|
|
558
566
|
children?: ReactNode;
|
|
559
567
|
}
|
|
560
568
|
|
|
@@ -652,7 +660,7 @@ export interface TextFieldProps extends BaseFieldProps, HelperTextProps, ErrorTe
|
|
|
652
660
|
multiline?: boolean;
|
|
653
661
|
rows?: number;
|
|
654
662
|
|
|
655
|
-
inputRef?:
|
|
663
|
+
inputRef?: (ref: TextInput | null) => void;
|
|
656
664
|
trimOnBlur?: boolean;
|
|
657
665
|
|
|
658
666
|
aiSuggestion?: AiSuggestionProps;
|
|
@@ -782,7 +790,7 @@ export interface ImageProps {
|
|
|
782
790
|
size?: string;
|
|
783
791
|
srcSet?: string;
|
|
784
792
|
fullWidth?: boolean;
|
|
785
|
-
style?:
|
|
793
|
+
style?: ImageStyle;
|
|
786
794
|
}
|
|
787
795
|
|
|
788
796
|
export interface BackButtonInterface {
|
|
@@ -850,14 +858,17 @@ export interface SplitPageProps {
|
|
|
850
858
|
loading?: boolean;
|
|
851
859
|
color?: SurfaceColor;
|
|
852
860
|
keyboardOffset?: number;
|
|
861
|
+
// noExplicitAny: ListRenderItemInfo generic type depends on the consumer's data shape
|
|
853
862
|
renderListViewItem: (itemInfo: ListRenderItemInfo<any>) => ReactElement | null;
|
|
854
863
|
renderListViewHeader?: () => ReactElement | null;
|
|
855
864
|
renderContent?: (index?: number) => ReactElement | ReactElement[] | null;
|
|
865
|
+
// noExplicitAny: list data type varies by consumer's data model
|
|
856
866
|
listViewData: any[];
|
|
857
|
-
listViewExtraData?:
|
|
867
|
+
listViewExtraData?: unknown;
|
|
858
868
|
listViewWidth?: number;
|
|
859
869
|
listViewMaxWidth?: number;
|
|
860
870
|
renderChild?: () => ReactChild;
|
|
871
|
+
// noExplicitAny: callback value type varies by consumer's data model
|
|
861
872
|
onSelectionChange?: (value?: any) => void | Promise<void>;
|
|
862
873
|
}
|
|
863
874
|
|
|
@@ -896,7 +907,7 @@ export interface AddressInterface {
|
|
|
896
907
|
export interface TransformValueOptions {
|
|
897
908
|
func?: (value: string) => string;
|
|
898
909
|
options?: {
|
|
899
|
-
[key: string]:
|
|
910
|
+
[key: string]: unknown;
|
|
900
911
|
};
|
|
901
912
|
}
|
|
902
913
|
|
|
@@ -1745,6 +1756,7 @@ export interface HeightActionSheetProps {
|
|
|
1745
1756
|
|
|
1746
1757
|
export interface HyperlinkProps {
|
|
1747
1758
|
linkDefault?: boolean;
|
|
1759
|
+
// noExplicitAny: type comes from external linkify-it library which lacks an exported type
|
|
1748
1760
|
linkify?: any;
|
|
1749
1761
|
linkStyle?: StyleProp<any>;
|
|
1750
1762
|
linkText?: string | ((url: string) => string);
|
|
@@ -1901,10 +1913,12 @@ export interface ModalProps {
|
|
|
1901
1913
|
/**
|
|
1902
1914
|
* The function to call when the primary button is clicked.
|
|
1903
1915
|
*/
|
|
1916
|
+
// noExplicitAny: callback value type varies by consumer context
|
|
1904
1917
|
primaryButtonOnClick?: (value?: any) => void | Promise<void>;
|
|
1905
1918
|
/**
|
|
1906
1919
|
* The function to call when the secondary button is clicked.
|
|
1907
1920
|
*/
|
|
1921
|
+
// noExplicitAny: callback value type varies by consumer context
|
|
1908
1922
|
secondaryButtonOnClick?: (value?: any) => void | Promise<void>;
|
|
1909
1923
|
}
|
|
1910
1924
|
|
|
@@ -1917,6 +1931,7 @@ export interface NumberPickerActionSheetProps {
|
|
|
1917
1931
|
}
|
|
1918
1932
|
|
|
1919
1933
|
export interface PageProps {
|
|
1934
|
+
// noExplicitAny: React Navigation type varies by navigation stack configuration
|
|
1920
1935
|
navigation?: any;
|
|
1921
1936
|
scroll?: boolean;
|
|
1922
1937
|
loading?: boolean;
|
|
@@ -1930,11 +1945,11 @@ export interface PageProps {
|
|
|
1930
1945
|
color?: SurfaceColor;
|
|
1931
1946
|
maxWidth?: number | string;
|
|
1932
1947
|
keyboardOffset?: number;
|
|
1933
|
-
footer?:
|
|
1948
|
+
footer?: ReactNode;
|
|
1934
1949
|
rightButton?: string;
|
|
1935
1950
|
rightButtonOnClick?: () => void;
|
|
1936
|
-
children?:
|
|
1937
|
-
onError?: (error: Error, stack:
|
|
1951
|
+
children?: ReactChildren;
|
|
1952
|
+
onError?: (error: Error, stack: string) => void;
|
|
1938
1953
|
}
|
|
1939
1954
|
|
|
1940
1955
|
export interface ProgressBarProps {
|
|
@@ -1957,7 +1972,7 @@ export interface RadioFieldProps {
|
|
|
1957
1972
|
export interface SignatureFieldProps {
|
|
1958
1973
|
disabled?: boolean; // default "default"
|
|
1959
1974
|
value?: string;
|
|
1960
|
-
onChange: (value:
|
|
1975
|
+
onChange: (value: string) => void;
|
|
1961
1976
|
title?: string; // default "Signature"
|
|
1962
1977
|
onStart?: () => void;
|
|
1963
1978
|
onEnd?: () => void;
|
|
@@ -2356,16 +2371,19 @@ export type TapToEditProps =
|
|
|
2356
2371
|
|
|
2357
2372
|
export interface BaseTapToEditProps extends Omit<FieldProps, "onChange" | "value"> {
|
|
2358
2373
|
title: string;
|
|
2374
|
+
// noExplicitAny: value type varies across TapToEdit field types (text, number, date, etc.)
|
|
2359
2375
|
value: any;
|
|
2360
2376
|
|
|
2361
2377
|
/**
|
|
2362
2378
|
* Not required if not editable.
|
|
2363
2379
|
*/
|
|
2380
|
+
// noExplicitAny: value type varies across TapToEdit field types
|
|
2364
2381
|
setValue?: (value: any) => void;
|
|
2365
2382
|
|
|
2366
2383
|
/**
|
|
2367
2384
|
* Not required if not editable.
|
|
2368
2385
|
*/
|
|
2386
|
+
// noExplicitAny: value type varies across TapToEdit field types
|
|
2369
2387
|
onSave?: (value: any) => void | Promise<void>;
|
|
2370
2388
|
|
|
2371
2389
|
/**
|
|
@@ -2378,6 +2396,7 @@ export interface BaseTapToEditProps extends Omit<FieldProps, "onChange" | "value
|
|
|
2378
2396
|
* Enable edit mode from outside the component.
|
|
2379
2397
|
*/
|
|
2380
2398
|
isEditing?: boolean;
|
|
2399
|
+
// noExplicitAny: input value type varies across TapToEdit field types
|
|
2381
2400
|
transform?: (value: any) => string;
|
|
2382
2401
|
/**
|
|
2383
2402
|
* Show a confirmation modal before saving the value.
|
|
@@ -2433,7 +2452,7 @@ export interface APIError {
|
|
|
2433
2452
|
source?: string;
|
|
2434
2453
|
pointer?: string;
|
|
2435
2454
|
parameter?: string;
|
|
2436
|
-
meta?: {[id: string]:
|
|
2455
|
+
meta?: {[id: string]: unknown};
|
|
2437
2456
|
};
|
|
2438
2457
|
}
|
|
2439
2458
|
|
|
@@ -2466,6 +2485,7 @@ export interface ModelFields {
|
|
|
2466
2485
|
|
|
2467
2486
|
export interface OpenAPISpec {
|
|
2468
2487
|
paths: {
|
|
2488
|
+
// noExplicitAny: OpenAPI path items are deeply accessed with chained property lookups
|
|
2469
2489
|
[key: string]: any;
|
|
2470
2490
|
};
|
|
2471
2491
|
}
|
|
@@ -2498,7 +2518,8 @@ export interface ModelAdminFieldConfig {
|
|
|
2498
2518
|
|
|
2499
2519
|
// The props for a custom column component for ModelAdmin.
|
|
2500
2520
|
export interface ModelAdminCustomComponentProps extends Omit<FieldProps, "name"> {
|
|
2501
|
-
|
|
2521
|
+
// noExplicitAny: document shape varies by model used with ModelAdmin
|
|
2522
|
+
doc: any;
|
|
2502
2523
|
fieldKey: string; // Dot notation representation of the field.
|
|
2503
2524
|
// user: User;
|
|
2504
2525
|
editing: boolean; // Allow for inline editing of the field.
|
package/src/Modal.test.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {describe, expect, it, mock} from "bun:test";
|
|
1
|
+
import {afterEach, describe, expect, it, mock} from "bun:test";
|
|
2
2
|
import {fireEvent} from "@testing-library/react-native";
|
|
3
3
|
|
|
4
|
+
import {isMobileDevice} from "./MediaQuery";
|
|
4
5
|
import {Modal} from "./Modal";
|
|
5
6
|
import {Text} from "./Text";
|
|
6
7
|
import {renderWithTheme} from "./test-utils";
|
|
@@ -355,3 +356,47 @@ describe("Modal", () => {
|
|
|
355
356
|
expect(stopPropagation).not.toHaveBeenCalled();
|
|
356
357
|
});
|
|
357
358
|
});
|
|
359
|
+
|
|
360
|
+
describe("Modal mobile branch", () => {
|
|
361
|
+
afterEach(() => {
|
|
362
|
+
(isMobileDevice as ReturnType<typeof mock>).mockImplementation(() => false);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("renders ActionSheet when isMobileDevice is true", () => {
|
|
366
|
+
(isMobileDevice as ReturnType<typeof mock>).mockImplementation(() => true);
|
|
367
|
+
const {toJSON} = renderWithTheme(
|
|
368
|
+
<Modal onDismiss={() => {}} title="Mobile Modal" visible>
|
|
369
|
+
<Text>Mobile Content</Text>
|
|
370
|
+
</Modal>
|
|
371
|
+
);
|
|
372
|
+
expect(toJSON()).toBeTruthy();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("renders ActionSheet with title and buttons on mobile", () => {
|
|
376
|
+
(isMobileDevice as ReturnType<typeof mock>).mockImplementation(() => true);
|
|
377
|
+
const {toJSON} = renderWithTheme(
|
|
378
|
+
<Modal
|
|
379
|
+
onDismiss={() => {}}
|
|
380
|
+
primaryButtonOnClick={() => {}}
|
|
381
|
+
primaryButtonText="Save"
|
|
382
|
+
secondaryButtonOnClick={() => {}}
|
|
383
|
+
secondaryButtonText="Cancel"
|
|
384
|
+
title="Mobile Actions"
|
|
385
|
+
visible
|
|
386
|
+
>
|
|
387
|
+
<Text>Content</Text>
|
|
388
|
+
</Modal>
|
|
389
|
+
);
|
|
390
|
+
expect(toJSON()).toBeTruthy();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("renders ActionSheet with persistOnBackgroundClick enabled", () => {
|
|
394
|
+
(isMobileDevice as ReturnType<typeof mock>).mockImplementation(() => true);
|
|
395
|
+
const {toJSON} = renderWithTheme(
|
|
396
|
+
<Modal onDismiss={() => {}} persistOnBackgroundClick title="Persistent Mobile" visible>
|
|
397
|
+
<Text>Content</Text>
|
|
398
|
+
</Modal>
|
|
399
|
+
);
|
|
400
|
+
expect(toJSON()).toBeTruthy();
|
|
401
|
+
});
|
|
402
|
+
});
|
package/src/PickerSelect.tsx
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
|
|
41
41
|
import {Icon} from "./Icon";
|
|
42
42
|
import {useTheme} from "./Theme";
|
|
43
|
+
import {useWebDropdownAnchor, WebDropdownMenu, type WebDropdownMenuOption} from "./WebDropdownMenu";
|
|
43
44
|
|
|
44
45
|
export const defaultStyles = StyleSheet.create({
|
|
45
46
|
chevron: {
|
|
@@ -129,6 +130,15 @@ export function RNPickerSelect({
|
|
|
129
130
|
const [doneDepressed, setDoneDepressed] = useState<boolean>(false);
|
|
130
131
|
const {theme} = useTheme();
|
|
131
132
|
|
|
133
|
+
// Web-only: anchor the custom dropdown menu to the trigger element so that
|
|
134
|
+
// Safari/Firefox/Chrome all render the same styled menu instead of the
|
|
135
|
+
// browser's native <select> UI.
|
|
136
|
+
const {
|
|
137
|
+
anchor: webAnchor,
|
|
138
|
+
measure: measureWebAnchor,
|
|
139
|
+
triggerRef: webTriggerRef,
|
|
140
|
+
} = useWebDropdownAnchor();
|
|
141
|
+
|
|
132
142
|
// On web, blur the active element before the picker modal opens to prevent
|
|
133
143
|
// "aria-hidden on a focused element" warnings from React Native Web.
|
|
134
144
|
useEffect(() => {
|
|
@@ -520,10 +530,61 @@ export function RNPickerSelect({
|
|
|
520
530
|
);
|
|
521
531
|
};
|
|
522
532
|
|
|
523
|
-
//
|
|
533
|
+
// Custom web dropdown. Rendering the native <Picker> on web delegates
|
|
534
|
+
// styling to each browser (Safari in particular looks very different from
|
|
535
|
+
// Chrome/Firefox). Instead, we render a styled trigger + popup menu so the
|
|
536
|
+
// dropdown looks identical across browsers and matches the Terreno design.
|
|
537
|
+
const openWebMenu = (): void => {
|
|
538
|
+
if (disabled) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
measureWebAnchor(() => {
|
|
542
|
+
setShowPicker(true);
|
|
543
|
+
if (onOpen) {
|
|
544
|
+
onOpen();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const closeWebMenu = (): void => {
|
|
550
|
+
setShowPicker(false);
|
|
551
|
+
if (onClose) {
|
|
552
|
+
onClose();
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Build the dropdown option list AND track each option's original index in
|
|
557
|
+
// `options` so `onValueChange` receives the same index that the native
|
|
558
|
+
// Picker would have reported (needed when a placeholder is present).
|
|
559
|
+
const {menuOptions: webMenuOptions, originalIndexes: webMenuOptionIndexes} = useMemo<{
|
|
560
|
+
menuOptions: WebDropdownMenuOption[];
|
|
561
|
+
originalIndexes: number[];
|
|
562
|
+
}>(() => {
|
|
563
|
+
const menuOptions: WebDropdownMenuOption[] = [];
|
|
564
|
+
const originalIndexes: number[] = [];
|
|
565
|
+
for (let i = 0; i < options.length; i++) {
|
|
566
|
+
const item = options[i];
|
|
567
|
+
if (!item || typeof item !== "object" || typeof item.label !== "string") {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
menuOptions.push({
|
|
571
|
+
color: item.color,
|
|
572
|
+
key: item.key,
|
|
573
|
+
label: item.label,
|
|
574
|
+
value: String(item.value ?? ""),
|
|
575
|
+
});
|
|
576
|
+
originalIndexes.push(i);
|
|
577
|
+
}
|
|
578
|
+
return {menuOptions, originalIndexes};
|
|
579
|
+
}, [options]);
|
|
580
|
+
|
|
524
581
|
const renderWeb = () => {
|
|
582
|
+
const displayLabel = selectedItem?.inputLabel ?? selectedItem?.label ?? "";
|
|
583
|
+
const selectedOriginalIdx = getSelectedItem(itemKey, value).idx;
|
|
584
|
+
const webSelectedIndex = webMenuOptionIndexes.indexOf(selectedOriginalIdx);
|
|
525
585
|
return (
|
|
526
586
|
<View
|
|
587
|
+
ref={webTriggerRef}
|
|
527
588
|
style={[
|
|
528
589
|
defaultStyles.viewContainer,
|
|
529
590
|
{
|
|
@@ -535,31 +596,55 @@ export function RNPickerSelect({
|
|
|
535
596
|
},
|
|
536
597
|
]}
|
|
537
598
|
>
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
style={
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
paddingVertical: 8,
|
|
551
|
-
width: "100%",
|
|
552
|
-
},
|
|
553
|
-
disabled && {
|
|
554
|
-
backgroundColor: theme.surface.neutralLight,
|
|
555
|
-
color: theme.text.secondaryLight,
|
|
556
|
-
opacity: 1,
|
|
557
|
-
},
|
|
558
|
-
]}
|
|
599
|
+
<Pressable
|
|
600
|
+
aria-role="button"
|
|
601
|
+
disabled={disabled}
|
|
602
|
+
onPress={openWebMenu}
|
|
603
|
+
style={{
|
|
604
|
+
alignItems: "center",
|
|
605
|
+
flexDirection: "row",
|
|
606
|
+
justifyContent: "space-between",
|
|
607
|
+
minHeight: 40,
|
|
608
|
+
paddingHorizontal: 8,
|
|
609
|
+
width: "100%",
|
|
610
|
+
}}
|
|
559
611
|
testID="web_picker"
|
|
612
|
+
{...touchableWrapperProps}
|
|
560
613
|
>
|
|
561
|
-
|
|
562
|
-
|
|
614
|
+
<Text
|
|
615
|
+
numberOfLines={1}
|
|
616
|
+
style={{
|
|
617
|
+
color: disabled ? theme.text.secondaryLight : theme.text.primary,
|
|
618
|
+
flex: 1,
|
|
619
|
+
paddingRight: 8,
|
|
620
|
+
}}
|
|
621
|
+
testID="text_input"
|
|
622
|
+
>
|
|
623
|
+
{displayLabel}
|
|
624
|
+
</Text>
|
|
625
|
+
<Icon
|
|
626
|
+
color={disabled ? "secondaryLight" : "primary"}
|
|
627
|
+
iconName={showPicker ? "angle-up" : "angle-down"}
|
|
628
|
+
size="sm"
|
|
629
|
+
/>
|
|
630
|
+
</Pressable>
|
|
631
|
+
<WebDropdownMenu
|
|
632
|
+
anchor={webAnchor}
|
|
633
|
+
onClose={closeWebMenu}
|
|
634
|
+
onSelect={(_val, idx) => {
|
|
635
|
+
const originalIndex = webMenuOptionIndexes[idx] ?? idx;
|
|
636
|
+
// Pass the original (non-stringified) value through so lodash
|
|
637
|
+
// `isEqual` matching in `getSelectedItem` works for number /
|
|
638
|
+
// object values.
|
|
639
|
+
const originalValue = options[originalIndex]?.value;
|
|
640
|
+
onValueChangeEvent(originalValue, originalIndex);
|
|
641
|
+
closeWebMenu();
|
|
642
|
+
}}
|
|
643
|
+
options={webMenuOptions}
|
|
644
|
+
selectedIndex={webSelectedIndex >= 0 ? webSelectedIndex : undefined}
|
|
645
|
+
testIDPrefix="web_dropdown"
|
|
646
|
+
visible={showPicker}
|
|
647
|
+
/>
|
|
563
648
|
</View>
|
|
564
649
|
);
|
|
565
650
|
};
|
package/src/SelectBadge.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import {Modal, Platform, Text, TouchableOpacity, View} from "react-native";
|
|
|
6
6
|
import type {FieldOption, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
|
|
7
7
|
import {Icon} from "./Icon";
|
|
8
8
|
import {useTheme} from "./Theme";
|
|
9
|
+
import {useWebDropdownAnchor, WebDropdownMenu, type WebDropdownMenuOption} from "./WebDropdownMenu";
|
|
9
10
|
|
|
10
11
|
export const SelectBadge = ({
|
|
11
12
|
value,
|
|
@@ -24,6 +25,15 @@ export const SelectBadge = ({
|
|
|
24
25
|
// Assures the badge display value persists when user scrolls through options
|
|
25
26
|
const [iosDisplayValue, setIosDisplayValue] = useState<string | undefined>(value);
|
|
26
27
|
|
|
28
|
+
// Web-only: anchor the custom dropdown menu to the trigger element so that
|
|
29
|
+
// Safari/Firefox/Chrome all render the same styled menu instead of the
|
|
30
|
+
// browser's native <select> UI.
|
|
31
|
+
const {
|
|
32
|
+
anchor: webAnchor,
|
|
33
|
+
measure: measureWebAnchor,
|
|
34
|
+
triggerRef: webTriggerRef,
|
|
35
|
+
} = useWebDropdownAnchor();
|
|
36
|
+
|
|
27
37
|
const secondaryBorderColors = {
|
|
28
38
|
custom: "#AAAAAA",
|
|
29
39
|
error: "#F39E9E",
|
|
@@ -190,14 +200,13 @@ export const SelectBadge = ({
|
|
|
190
200
|
selectedValue={findSelectedItem(value)?.value ?? undefined}
|
|
191
201
|
style={[
|
|
192
202
|
{
|
|
203
|
+
backgroundColor: "transparent",
|
|
193
204
|
color: "transparent",
|
|
194
205
|
height: "100%",
|
|
195
206
|
opacity: 0,
|
|
196
207
|
position: "absolute",
|
|
197
208
|
width: "100%",
|
|
198
209
|
},
|
|
199
|
-
// Android headless picker: transparent overlay to capture touches without visible UI.
|
|
200
|
-
Platform.OS !== "web" && {backgroundColor: "transparent"},
|
|
201
210
|
]}
|
|
202
211
|
>
|
|
203
212
|
{renderPickerItems()}
|
|
@@ -205,14 +214,59 @@ export const SelectBadge = ({
|
|
|
205
214
|
);
|
|
206
215
|
}, [disabled, findSelectedItem, value, handleOnChange, renderPickerItems]);
|
|
207
216
|
|
|
217
|
+
// Custom web dropdown. Rendering the native <Picker> on web delegates
|
|
218
|
+
// styling to each browser (Safari in particular looks very different from
|
|
219
|
+
// Chrome/Firefox). Instead, we render a styled popup menu anchored to the
|
|
220
|
+
// badge so the dropdown looks identical across browsers.
|
|
221
|
+
const webMenuOptions = useMemo<WebDropdownMenuOption[]>(
|
|
222
|
+
() => options.map((item) => ({key: item.key, label: item.label, value: item.value})),
|
|
223
|
+
[options]
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const renderWebPicker = useCallback(() => {
|
|
227
|
+
return (
|
|
228
|
+
<WebDropdownMenu
|
|
229
|
+
anchor={webAnchor}
|
|
230
|
+
minWidth={160}
|
|
231
|
+
onClose={() => setShowPicker(false)}
|
|
232
|
+
onSelect={(val) => handleOnChange(val)}
|
|
233
|
+
options={webMenuOptions}
|
|
234
|
+
optionTextStyle={{fontFamily: "text", fontSize: 12}}
|
|
235
|
+
selectedValue={value}
|
|
236
|
+
testIDPrefix="web_badge"
|
|
237
|
+
visible={showPicker}
|
|
238
|
+
width={undefined}
|
|
239
|
+
/>
|
|
240
|
+
);
|
|
241
|
+
}, [showPicker, webAnchor, webMenuOptions, value, handleOnChange]);
|
|
242
|
+
|
|
243
|
+
const openWebMenu = (): void => {
|
|
244
|
+
if (disabled) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
measureWebAnchor(() => {
|
|
248
|
+
setShowPicker(true);
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
208
252
|
return (
|
|
209
|
-
<View style={{alignItems: "flex-start", opacity: disabled ? 0.5 : 1}}>
|
|
253
|
+
<View ref={webTriggerRef} style={{alignItems: "flex-start", opacity: disabled ? 0.5 : 1}}>
|
|
210
254
|
<TouchableOpacity
|
|
211
255
|
accessibilityHint="Opens the options picker"
|
|
212
256
|
accessibilityLabel="Open select badge options"
|
|
213
257
|
aria-role="button"
|
|
214
258
|
disabled={disabled}
|
|
215
|
-
onPress={() =>
|
|
259
|
+
onPress={() => {
|
|
260
|
+
if (Platform.OS === "web") {
|
|
261
|
+
if (showPicker) {
|
|
262
|
+
setShowPicker(false);
|
|
263
|
+
} else {
|
|
264
|
+
openWebMenu();
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
setShowPicker(!showPicker);
|
|
269
|
+
}}
|
|
216
270
|
>
|
|
217
271
|
<View
|
|
218
272
|
style={{
|
|
@@ -274,7 +328,11 @@ export const SelectBadge = ({
|
|
|
274
328
|
</View>
|
|
275
329
|
</View>
|
|
276
330
|
</TouchableOpacity>
|
|
277
|
-
{Platform.OS === "ios"
|
|
331
|
+
{Platform.OS === "ios"
|
|
332
|
+
? renderIosPicker()
|
|
333
|
+
: Platform.OS === "web"
|
|
334
|
+
? renderWebPicker()
|
|
335
|
+
: renderPicker()}
|
|
278
336
|
</View>
|
|
279
337
|
);
|
|
280
338
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {afterAll, beforeAll, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {act, fireEvent} from "@testing-library/react-native";
|
|
3
|
+
import {Platform} from "react-native";
|
|
4
|
+
|
|
5
|
+
import {SelectBadge} from "./SelectBadge";
|
|
6
|
+
import {renderWithTheme} from "./test-utils";
|
|
7
|
+
|
|
8
|
+
// Force Platform.OS to "web" for this file so SelectBadge takes the web
|
|
9
|
+
// rendering branch (custom WebDropdownMenu instead of the native iOS Picker).
|
|
10
|
+
const originalOS = Platform.OS;
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
Object.defineProperty(Platform, "OS", {configurable: true, value: "web"});
|
|
13
|
+
});
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
Object.defineProperty(Platform, "OS", {configurable: true, value: originalOS});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("SelectBadge (web)", () => {
|
|
19
|
+
const options = [
|
|
20
|
+
{label: "Option A", value: "a"},
|
|
21
|
+
{label: "Option B", value: "b"},
|
|
22
|
+
{label: "Option C", value: "c"},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
it("renders the styled web dropdown menu (not the native picker) when opened", () => {
|
|
26
|
+
const {getByLabelText, getByTestId} = renderWithTheme(
|
|
27
|
+
<SelectBadge onChange={() => {}} options={options} value="a" />
|
|
28
|
+
);
|
|
29
|
+
expect(getByTestId("web_badge_modal").props.visible).toBe(false);
|
|
30
|
+
act(() => {
|
|
31
|
+
fireEvent.press(getByLabelText("Open select badge options"));
|
|
32
|
+
});
|
|
33
|
+
expect(getByTestId("web_badge_modal").props.visible).toBe(true);
|
|
34
|
+
expect(getByTestId("web_badge_menu")).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("invokes onChange with the selected value when an option is pressed", () => {
|
|
38
|
+
const handleChange = mock((_val: string) => {});
|
|
39
|
+
const {getByLabelText, getByTestId} = renderWithTheme(
|
|
40
|
+
<SelectBadge onChange={handleChange} options={options} value="a" />
|
|
41
|
+
);
|
|
42
|
+
act(() => {
|
|
43
|
+
fireEvent.press(getByLabelText("Open select badge options"));
|
|
44
|
+
});
|
|
45
|
+
act(() => {
|
|
46
|
+
fireEvent.press(getByTestId("web_badge_option_b"));
|
|
47
|
+
});
|
|
48
|
+
expect(handleChange).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(handleChange).toHaveBeenCalledWith("b");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("closes the menu when the backdrop is pressed", () => {
|
|
53
|
+
const {getByLabelText, getByTestId} = renderWithTheme(
|
|
54
|
+
<SelectBadge onChange={() => {}} options={options} value="a" />
|
|
55
|
+
);
|
|
56
|
+
act(() => {
|
|
57
|
+
fireEvent.press(getByLabelText("Open select badge options"));
|
|
58
|
+
});
|
|
59
|
+
expect(getByTestId("web_badge_modal").props.visible).toBe(true);
|
|
60
|
+
act(() => {
|
|
61
|
+
fireEvent.press(getByTestId("web_badge_backdrop"));
|
|
62
|
+
});
|
|
63
|
+
expect(getByTestId("web_badge_modal").props.visible).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not open the dropdown when disabled", () => {
|
|
67
|
+
const {getByLabelText, getByTestId} = renderWithTheme(
|
|
68
|
+
<SelectBadge disabled onChange={() => {}} options={options} value="a" />
|
|
69
|
+
);
|
|
70
|
+
act(() => {
|
|
71
|
+
fireEvent.press(getByLabelText("Open select badge options"));
|
|
72
|
+
});
|
|
73
|
+
expect(getByTestId("web_badge_modal").props.visible).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|