@windstream/react-shared-components 0.2.48 → 0.2.49

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.
@@ -0,0 +1,159 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ import { AddressAutocomplete } from "./index";
4
+
5
+ import { fireEvent, render, screen } from "@testing-library/react";
6
+
7
+ const suggestions = [
8
+ {
9
+ address: {
10
+ addressLine1: "123 Dale Springs",
11
+ addressLine2: "Apt B",
12
+ city: "Binsport",
13
+ state: "NC",
14
+ postalCode: "17032",
15
+ providerId: "candidate-1",
16
+ },
17
+ mainText: "123 Dale Springs",
18
+ placeId: "place-1",
19
+ provider: "precisely",
20
+ },
21
+ ];
22
+
23
+ describe("AddressAutocomplete", () => {
24
+ it("requests suggestions and displays results at the minimum query length", () => {
25
+ const onChange = jest.fn();
26
+ render(
27
+ <AddressAutocomplete suggestions={suggestions} onChange={onChange} />
28
+ );
29
+
30
+ const input = screen.getByRole("combobox");
31
+ fireEvent.change(input, { target: { value: "12" } });
32
+ expect(
33
+ screen.queryByTestId("address-autocomplete-suggestions")
34
+ ).not.toBeInTheDocument();
35
+
36
+ fireEvent.change(input, { target: { value: "123" } });
37
+ expect(onChange).toHaveBeenLastCalledWith("123");
38
+ expect(
39
+ screen.getByTestId("address-autocomplete-suggestions")
40
+ ).toBeInTheDocument();
41
+ expect(screen.getByTestId("address-autocomplete-suggestions")).toHaveClass(
42
+ "max-h-[220px]",
43
+ "overflow-y-auto",
44
+ "p-0",
45
+ "border",
46
+ "border-input-border",
47
+ "bg-bg-surface",
48
+ "shadow-dark-drop"
49
+ );
50
+ expect(
51
+ screen.getByRole("button", { name: /123 Dale Springs/i })
52
+ ).toHaveClass("px-4");
53
+ expect(screen.getByRole("combobox").parentElement).toHaveClass("pl-4");
54
+ expect(screen.getAllByText("location_on")[0]).toHaveClass(
55
+ "text-icon-brand"
56
+ );
57
+ });
58
+
59
+ it("uses the input pin color for each suggestion pin", () => {
60
+ render(
61
+ <AddressAutocomplete
62
+ defaultValue="123"
63
+ suggestions={suggestions}
64
+ prefixIconClassName="text-input-icon-placeholder"
65
+ />
66
+ );
67
+
68
+ fireEvent.focus(screen.getByRole("combobox"));
69
+ const pins = screen.getAllByText("location_on");
70
+ expect(pins).toHaveLength(2);
71
+ pins.forEach(pin => {
72
+ expect(pin).toHaveClass("text-input-icon-placeholder");
73
+ expect(pin).toHaveClass(
74
+ "inline-flex",
75
+ "items-center",
76
+ "justify-center",
77
+ "leading-none"
78
+ );
79
+ });
80
+ });
81
+
82
+ it("returns the selected provider address without requesting again", () => {
83
+ const onChange = jest.fn();
84
+ const onSelectAddress = jest.fn();
85
+ render(
86
+ <AddressAutocomplete
87
+ defaultValue="123"
88
+ suggestions={suggestions}
89
+ onChange={onChange}
90
+ onSelectAddress={onSelectAddress}
91
+ />
92
+ );
93
+
94
+ fireEvent.focus(screen.getByRole("combobox"));
95
+ fireEvent.click(screen.getByRole("button", { name: /123 Dale Springs/i }));
96
+
97
+ expect(onSelectAddress).toHaveBeenCalledWith({
98
+ address: "123 Dale Springs, Apt B, Binsport, NC, 17032",
99
+ unit: "Apt B",
100
+ addressToSend: suggestions[0].address,
101
+ placeId: "place-1",
102
+ });
103
+ expect(onChange).not.toHaveBeenCalled();
104
+ expect(screen.getByRole("combobox")).toHaveValue(
105
+ "123 Dale Springs, Apt B, Binsport, NC, 17032"
106
+ );
107
+ });
108
+
109
+ it("clears the selected address when the input is emptied", () => {
110
+ const onSelectAddress = jest.fn();
111
+ render(
112
+ <AddressAutocomplete
113
+ defaultValue="123"
114
+ onSelectAddress={onSelectAddress}
115
+ />
116
+ );
117
+
118
+ fireEvent.change(screen.getByRole("combobox"), {
119
+ target: { value: "" },
120
+ });
121
+ expect(onSelectAddress).toHaveBeenCalledWith();
122
+ });
123
+
124
+ it("focuses and reports load when its provider becomes ready", () => {
125
+ const onLoadProvider = jest.fn();
126
+ const { rerender } = render(
127
+ <AddressAutocomplete isReady={false} onLoadProvider={onLoadProvider} />
128
+ );
129
+ const input = screen.getByRole("combobox");
130
+
131
+ expect(input).toBeDisabled();
132
+ expect(onLoadProvider).not.toHaveBeenCalled();
133
+
134
+ rerender(
135
+ <AddressAutocomplete isReady={true} onLoadProvider={onLoadProvider} />
136
+ );
137
+ expect(input).toHaveFocus();
138
+ expect(onLoadProvider).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it("dismisses suggestions after an outside click", () => {
142
+ render(
143
+ <div>
144
+ <AddressAutocomplete defaultValue="123" suggestions={suggestions} />
145
+ <button type="button">Outside</button>
146
+ </div>
147
+ );
148
+
149
+ fireEvent.focus(screen.getByRole("combobox"));
150
+ expect(
151
+ screen.getByTestId("address-autocomplete-suggestions")
152
+ ).toBeInTheDocument();
153
+
154
+ fireEvent.click(screen.getByRole("button", { name: "Outside" }));
155
+ expect(
156
+ screen.queryByTestId("address-autocomplete-suggestions")
157
+ ).not.toBeInTheDocument();
158
+ });
159
+ });
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import type {
5
+ AddressAutocompleteAddress,
6
+ AddressAutocompleteProps,
7
+ AddressAutocompleteSuggestion,
8
+ } from "./types";
9
+
10
+ import { Input } from "@shared/components/input";
11
+ import { MaterialIcon } from "@shared/components/material-icon";
12
+ import { useOutsideClick } from "@shared/hooks/use-outside-click";
13
+ import { cx } from "@shared/utils";
14
+
15
+ const formatSuggestion = <TAddress extends AddressAutocompleteAddress>(
16
+ suggestion: AddressAutocompleteSuggestion<TAddress>
17
+ ) => {
18
+ const { address, mainText, postalCode: suggestionPostalCode } = suggestion;
19
+ if (!address) {
20
+ return { primaryText: mainText, secondaryText: "", value: mainText };
21
+ }
22
+
23
+ const primaryText = [address.addressLine1, address.addressLine2]
24
+ .filter(Boolean)
25
+ .join(", ");
26
+ const secondaryText = [
27
+ address.city,
28
+ address.state,
29
+ address.postalCode || suggestionPostalCode,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(", ");
33
+
34
+ return {
35
+ primaryText: primaryText || mainText,
36
+ secondaryText,
37
+ value: [primaryText, secondaryText].filter(Boolean).join(", ") || mainText,
38
+ };
39
+ };
40
+
41
+ export const AddressAutocomplete = <
42
+ TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
43
+ >({
44
+ className,
45
+ containerClassName,
46
+ defaultValue = "",
47
+ errorText,
48
+ fixedPositionedSuggestions = false,
49
+ focusOnLoad = true,
50
+ hasError,
51
+ inputContainerClassName,
52
+ isIframe = false,
53
+ isLoading = false,
54
+ isReady = true,
55
+ mapPinIcon = true,
56
+ minQueryLength = 3,
57
+ onChange,
58
+ onFocus,
59
+ onLoadProvider,
60
+ onSelectAddress,
61
+ onSuggestionsChange,
62
+ optionsContainerClassName,
63
+ prefixIconClassName = "text-icon-brand",
64
+ suggestions = [],
65
+ value: controlledValue,
66
+ ...inputProps
67
+ }: AddressAutocompleteProps<TAddress>) => {
68
+ const inputRef = useRef<HTMLInputElement>(null);
69
+ const rootRef = useRef<HTMLDivElement>(null);
70
+ const suggestionsId = useId();
71
+ const [value, setValue] = useState(controlledValue ?? defaultValue);
72
+ const [showSuggestions, setShowSuggestions] = useState(false);
73
+ const pinIconClassName = cx(
74
+ "inline-flex items-center justify-center leading-none",
75
+ prefixIconClassName
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (controlledValue !== undefined) setValue(controlledValue);
80
+ }, [controlledValue]);
81
+
82
+ useEffect(() => {
83
+ if (isReady && focusOnLoad) inputRef.current?.focus();
84
+ }, [focusOnLoad, isReady]);
85
+
86
+ useEffect(() => {
87
+ if (isReady) onLoadProvider?.();
88
+ }, [isReady, onLoadProvider]);
89
+
90
+ useEffect(() => {
91
+ onSuggestionsChange?.(suggestions);
92
+ }, [onSuggestionsChange, suggestions]);
93
+
94
+ useOutsideClick(rootRef, () => setShowSuggestions(false));
95
+
96
+ const selectSuggestion = useCallback(
97
+ (suggestion: AddressAutocompleteSuggestion<TAddress>) => {
98
+ if (!suggestion.address) return;
99
+
100
+ const formatted = formatSuggestion(suggestion);
101
+ setValue(formatted.value);
102
+ setShowSuggestions(false);
103
+ onSelectAddress?.({
104
+ address: formatted.value,
105
+ unit: suggestion.address.addressLine2 || "",
106
+ addressToSend: suggestion.address,
107
+ placeId: suggestion.placeId || "",
108
+ });
109
+ },
110
+ [onSelectAddress]
111
+ );
112
+
113
+ const hasSuggestions =
114
+ showSuggestions &&
115
+ value.length >= minQueryLength &&
116
+ !isLoading &&
117
+ suggestions.length > 0;
118
+
119
+ return (
120
+ <div
121
+ ref={rootRef}
122
+ className={cx("relative min-w-[300px] text-text", containerClassName)}
123
+ >
124
+ <Input
125
+ {...inputProps}
126
+ ref={inputRef}
127
+ size="medium"
128
+ value={value}
129
+ className={cx(
130
+ "mx-auto w-full bg-transparent pl-2 placeholder:text-input-text-placeholder",
131
+ "body2",
132
+ className
133
+ )}
134
+ containerClassName={cx("pl-4", inputContainerClassName)}
135
+ loading={!isReady}
136
+ onChange={event => {
137
+ const nextValue = event.target.value;
138
+ setValue(nextValue);
139
+ setShowSuggestions(nextValue.length >= minQueryLength);
140
+ if (!nextValue) onSelectAddress?.();
141
+ onChange?.(nextValue);
142
+ }}
143
+ onFocus={event => {
144
+ setShowSuggestions(true);
145
+ onFocus?.(event);
146
+ }}
147
+ autoComplete="off"
148
+ role="combobox"
149
+ aria-autocomplete="list"
150
+ aria-controls={suggestionsId}
151
+ aria-expanded={hasSuggestions}
152
+ data-cy="address-autocomplete-input"
153
+ data-testid="address-autocomplete-input"
154
+ prefixIconName={mapPinIcon ? "location_on" : undefined}
155
+ prefixIconFill={true}
156
+ prefixIconClassName={pinIconClassName}
157
+ errorText={errorText}
158
+ state={errorText || hasError ? "error" : inputProps.state}
159
+ hasError={hasError}
160
+ />
161
+
162
+ {hasSuggestions && (
163
+ <ul
164
+ id={suggestionsId}
165
+ role="listbox"
166
+ data-cy="address-autocomplete-suggestions"
167
+ data-testid="address-autocomplete-suggestions"
168
+ className={cx(
169
+ "shadow-dark-drop absolute top-full z-20 m-0 mt-1 w-full list-none overflow-y-auto overscroll-contain rounded-lg border border-input-border bg-bg-surface p-0",
170
+ isIframe ? "max-h-[117px]" : "max-h-[220px]",
171
+ fixedPositionedSuggestions &&
172
+ (isIframe
173
+ ? "fixed z-50 max-h-[117px]"
174
+ : "fixed z-50 max-h-[220px]"),
175
+ optionsContainerClassName
176
+ )}
177
+ >
178
+ {suggestions.map((suggestion, index) => {
179
+ const { primaryText, secondaryText } = formatSuggestion(suggestion);
180
+ return (
181
+ <li
182
+ role="option"
183
+ aria-selected="false"
184
+ key={suggestion.placeId || `${suggestion.mainText}-${index}`}
185
+ >
186
+ <button
187
+ type="button"
188
+ className="flex w-full items-center px-4 py-4 text-left hover:bg-bg-surface-secondary"
189
+ onClick={() => selectSuggestion(suggestion)}
190
+ >
191
+ <MaterialIcon
192
+ fill={1}
193
+ name="location_on"
194
+ size={24}
195
+ weight="200"
196
+ className={cx("mr-4 min-w-[24px]", pinIconClassName)}
197
+ />
198
+ <span>
199
+ <span>{primaryText}</span>
200
+ {secondaryText && (
201
+ <small className="ml-1">{secondaryText}</small>
202
+ )}
203
+ </span>
204
+ </button>
205
+ </li>
206
+ );
207
+ })}
208
+ </ul>
209
+ )}
210
+ </div>
211
+ );
212
+ };
213
+
214
+ export type {
215
+ AddressAutocompleteAddress,
216
+ AddressAutocompleteProps,
217
+ AddressAutocompleteResult,
218
+ AddressAutocompleteSuggestion,
219
+ } from "./types";
@@ -0,0 +1,54 @@
1
+ import type { InputFieldProps } from "@shared/components/input";
2
+
3
+ export type AddressAutocompleteAddress = {
4
+ addressLine1?: string;
5
+ addressLine2?: string | null;
6
+ city?: string;
7
+ state?: string;
8
+ postalCode?: string;
9
+ };
10
+
11
+ export type AddressAutocompleteSuggestion<
12
+ TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
13
+ > = {
14
+ address?: TAddress;
15
+ mainText: string;
16
+ placeId?: string;
17
+ postalCode?: string;
18
+ provider?: string;
19
+ };
20
+
21
+ export type AddressAutocompleteResult<
22
+ TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
23
+ > = {
24
+ address: string;
25
+ unit: string;
26
+ addressToSend: TAddress;
27
+ placeId: string;
28
+ };
29
+
30
+ export type AddressAutocompleteProps<
31
+ TAddress extends AddressAutocompleteAddress = AddressAutocompleteAddress,
32
+ > = Omit<
33
+ InputFieldProps,
34
+ "defaultValue" | "loading" | "onChange" | "size" | "value"
35
+ > & {
36
+ value?: string;
37
+ defaultValue?: string;
38
+ suggestions?: AddressAutocompleteSuggestion<TAddress>[];
39
+ isLoading?: boolean;
40
+ isReady?: boolean;
41
+ focusOnLoad?: boolean;
42
+ minQueryLength?: number;
43
+ mapPinIcon?: boolean;
44
+ fixedPositionedSuggestions?: boolean;
45
+ isIframe?: boolean;
46
+ inputContainerClassName?: string;
47
+ optionsContainerClassName?: string;
48
+ onChange?: (value: string) => void;
49
+ onSelectAddress?: (result?: AddressAutocompleteResult<TAddress>) => void;
50
+ onSuggestionsChange?: (
51
+ suggestions: AddressAutocompleteSuggestion<TAddress>[]
52
+ ) => void;
53
+ onLoadProvider?: () => void;
54
+ };
@@ -7,10 +7,11 @@ import { NextImage } from "@shared/components/next-image";
7
7
  import { Text } from "@shared/components/text";
8
8
  import { cx } from "@shared/utils";
9
9
 
10
+ // Block (full-width) on mobile so the button fills the card; flex alignment only on desktop.
10
11
  const alignmentClasses: Record<string, string> = {
11
- left: "flex w-full justify-start",
12
- right: "flex w-full justify-end",
13
- center: "flex w-full justify-center",
12
+ left: "w-full lg:flex lg:justify-start",
13
+ right: "w-full lg:flex lg:justify-end",
14
+ center: "w-full lg:flex lg:justify-center",
14
15
  };
15
16
 
16
17
  export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
@@ -331,7 +331,8 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
331
331
  fullWidth={true}
332
332
  buttonClassName={cx(
333
333
  card.cta.buttonClassName,
334
- isCtaOnly && "w-[342px] max-w-full lg:w-[342px]"
334
+ // Narrower padding so long labels still fit the fixed width.
335
+ isCtaOnly && "w-[342px] max-w-full px-4 lg:w-[342px]"
335
336
  )}
336
337
  />
337
338
  </div>
package/src/index.ts CHANGED
@@ -8,6 +8,14 @@ export type { ButtonProps } from "./components/button";
8
8
  export { Input, InputField, Input as TextInput } from "./components/input";
9
9
  export type { InputProps, InputFieldProps } from "./components/input";
10
10
 
11
+ export { AddressAutocomplete } from "./components/precisely-address-autocomplete";
12
+ export type {
13
+ AddressAutocompleteAddress,
14
+ AddressAutocompleteProps,
15
+ AddressAutocompleteResult,
16
+ AddressAutocompleteSuggestion,
17
+ } from "./components/precisely-address-autocomplete";
18
+
11
19
  export { Image } from "./components/image";
12
20
  export type { ImageProps, ImageComponentProps } from "./components/image";
13
21