Skip to content

Commit

Permalink
feat: expose hidden choices reference
Browse files Browse the repository at this point in the history
  • Loading branch information
macyabbey-okta committed Nov 16, 2021
1 parent 0bc1986 commit a64597a
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 275 deletions.
1 change: 0 additions & 1 deletion packages/odyssey-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.5.0",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/jest-axe": "^3.5.1",
"@types/react": "^17.0.30",
Expand Down
85 changes: 31 additions & 54 deletions packages/odyssey-react/src/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,17 @@
* See the License for the specific language governing permissions and limitations under the License.
*/

import React from "react";
import React, { useState, useRef, useEffect } from "react";
import {
render,
fireEvent,
screen,
within,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Select } from ".";
import { Select, ChoicesHTMLSelectElement } from ".";

const listboxRole = "listbox";
const multipleInputRole = "textbox";
const optionRole = "option";
const label = "Select speed";
const name = "speed";
Expand All @@ -37,6 +34,27 @@ const tree = (props: Record<string, unknown> = {}) => (
</Select>
);

const RefTree = (props: Record<string, unknown> = {}) => {
const selectRef = useRef<ChoicesHTMLSelectElement>(null);

useEffect(() => {
if (selectRef?.current?.choices) {
selectRef?.current?.choices.setChoices([
{
value: "ref",
label: "Choices!",
},
]);
}
}, [selectRef]);

return (
<Select ref={selectRef} {...props} label={label} name={name}>
<Select.Option children={"No choices"} />
</Select>
);
};

const getSelectViaQuery = () =>
window.document.querySelector("select") as HTMLSelectElement;

Expand Down Expand Up @@ -152,58 +170,17 @@ describe("Select", () => {
/>;
});

/**
* WIP
*/
it("passes search text to onSearch if defined for multiple", async () => {
const myQuery = "my q";
let expectedSubstringEnd = 1;

const onSearch = async (searchText: string) => {
// Called in sequence
expect(searchText).toEqual(searchText.substring(0, expectedSubstringEnd));
expectedSubstringEnd += 1;
return expectedSubstringEnd === myQuery.length + 1;
};

render(tree({ onSearch: onSearch, multiple: true }));

const input = screen.getByRole(multipleInputRole) as HTMLInputElement;

await userEvent.type(input, myQuery, {
delay: 1,
});

// Called for every character
expect(expectedSubstringEnd).toEqual(myQuery.length + 1);
// List box not shown because showOptions was not invoked
expect(screen.getByText("Lightspeed")).not.toBeVisible();
});

/**
* WIP
*/
it("Displays dropdown on search when showOptions is called", async () => {
const myQuery = "abc";
const loadingText = "Please wait...";

let searchIndex = 0;

const onSearch = async (_searchText: string, showOptions: () => void) => {
searchIndex += 1;
showOptions();
return searchIndex === myQuery.length;
};

render(
tree({ onSearch: onSearch, multiple: true, loadingText: loadingText })
it("Composer can use choices ref from dom", async () => {
const { getByText } = render(
<RefTree label={label} name={name} children={<div />} />
);

const input = screen.getByRole(multipleInputRole) as HTMLInputElement;
await userEvent.type(input, myQuery, {
delay: 1,
const listbox = screen.getAllByRole(listboxRole)[0];
fireEvent.mouseDown(listbox);

await waitFor(() => {
return getByText("Choices!");
});
expect(screen.getByRole(listboxRole)).toBeTruthy();
});

a11yCheck(() => render(tree()));
Expand Down
226 changes: 76 additions & 150 deletions packages/odyssey-react/src/components/Select/Select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
* See the License for the specific language governing permissions and limitations under the License.
*/

import React, { useCallback, useState } from "react";
import React, { useCallback } from "react";
import type { ChangeEvent, ReactElement, ComponentPropsWithRef } from "react";
import { SelectOption } from "../SelectOption";
import { SelectOptionGroup } from "../SelectOptionGroup";
import { useChoices } from "./useChoices";
import { ChoicesHTMLSelectElement, useChoices } from "./useChoices";
import {
forwardRefWithStatics,
useOid,
Expand All @@ -26,30 +26,6 @@ import type { SharedFieldTypes } from "../../Field/types";

import styles from "../Select.module.scss";
import { CaretDownIcon } from "../../Icon";
import { CircularLoadIndicator } from "../..";

/**
* For discussion - not implemented.
*
* Concept would be Select controlling what props are passed to VisibleComponent
* when the visibleOptions are rendered.
*
* Meets requirement of significantly customized visible options which is currently
* not achieveable given hiding of choices callbackOnCreateTemplates
*/

type VisibleOptionProps<U extends Record<string, unknown>> = {
label: string;
value: string;
data: U;
};

type OnSearchHandler<U> = U extends Record<string, unknown>
? (
searchText: string,
setVisibleOptions: (options: VisibleOptionProps<U>[]) => void
) => Promise<void>
: never;

interface CommonProps
extends SharedFieldTypes,
Expand Down Expand Up @@ -93,14 +69,19 @@ interface CommonProps
onChange?: (event?: ChangeEvent<HTMLSelectElement>, value?: string) => void;

/**
* The text that is shown when all options are already selected,
* or a user's search has returned no results.
* The text that is shown while choices are being populated.
*/
loadingText?: string;

/**
* The text that is shown when a user's search has returned no results.
*/
noResultsText?: string;
}

interface MultipleProps extends CommonProps {
multiple: true;

/**
* The text that is shown when a user has selected all possible choices.
*/
Expand All @@ -112,134 +93,78 @@ interface SingleProps extends CommonProps {
noChoicesText?: never;
}

interface SearchMultipleProps extends MultipleProps {
/**
* Callback executed when the select fires a search event.
* Loading state will be displayed until the promise resolves with true.
* @param {string} The user entered text
* @param {Function} A callback that can be used to show visible options which are
* out of sync with the children of the Select.
* @returns {boolean} Whether
*/
onSearch: OnSearchHandler<Record<string, unknown>>;
loadingText: string;
}

function isSearchMultiple(props: SelectProps): props is SearchMultipleProps {
const searchMultiple = props as SearchMultipleProps;
return (
searchMultiple.onSearch !== undefined &&
searchMultiple.loadingText !== undefined
);
}

export type SelectProps = MultipleProps | SingleProps | SearchMultipleProps;
export type SelectProps = MultipleProps | SingleProps;

/**
* Often referred to as a "dropdown menu" this input triggers a menu of
* options a user can select.
*/
let Select = forwardRefWithStatics<HTMLSelectElement, SelectProps, Statics>(
(props, ref) => {
const {
id,
children,
disabled = false,
name,
onChange,
required = true,
value,
error,
hint,
label,
optionalLabel,
noResultsText = "",
noChoicesText = "",
...rest
} = props;

const [loading, setLoading] = useState<boolean>(false);

const omitProps = useOmit(rest);

const oid = useOid(id);

const loadingText = isSearchMultiple(props) ? props.loadingText : "";

const baseUseChoicesProps = {
id: oid,
value,
noResultsText,
noChoicesText,
};

let useChoicesProps;

if (isSearchMultiple(props)) {
const { onSearch: composerSearch } = props;
useChoicesProps = {
...baseUseChoicesProps,
onSearch: useCallback(
async (...args: Parameters<typeof composerSearch>) => {
setLoading(true);
// TBD - debounce?
await composerSearch(...args);
setLoading(false);
},
[composerSearch]
),
loadingText: loadingText,
};
} else {
useChoicesProps = baseUseChoicesProps;
}

useChoices(useChoicesProps);

const handleChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
onChange?.(event, event.target.value);
},
[onChange]
);
let Select = forwardRefWithStatics<
ChoicesHTMLSelectElement,
SelectProps,
Statics
>((props, ref) => {
const {
id,
children,
disabled = false,
name,
onChange,
required = true,
value,
error,
hint,
label,
optionalLabel,
loadingText = "",
noResultsText = "",
noChoicesText = "",
...rest
} = props;

const omitProps = useOmit(rest);

const oid = useOid(id);

useChoices({ id: oid, value, loadingText, noResultsText, noChoicesText });

const handleChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
onChange?.(event, event.target.value);
},
[onChange]
);

return (
<Field
error={error}
hint={hint}
inputId={oid}
label={label}
optionalLabel={optionalLabel}
required={required}
>
<div className={styles.outer}>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select
{...omitProps}
id={oid}
name={name}
disabled={disabled}
required={required}
onChange={handleChange}
value={value}
ref={ref}
>
{children}
</select>
<span className={styles.indicator} role="presentation">
{loading ? (
<CircularLoadIndicator
aria-label={loadingText}
aria-valuetext={loadingText}
/>
) : null}
<CaretDownIcon />
</span>
</div>
</Field>
);
}
);
return (
<Field
error={error}
hint={hint}
inputId={oid}
label={label}
optionalLabel={optionalLabel}
required={required}
>
<div className={styles.outer}>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select
{...omitProps}
id={oid}
name={name}
disabled={disabled}
required={required}
onChange={handleChange}
value={value}
ref={ref}
>
{children}
</select>
<span className={styles.indicator} role="presentation">
<CaretDownIcon />
</span>
</div>
</Field>
);
});

Select.displayName = "Select";

Expand All @@ -253,4 +178,5 @@ Select.OptionGroup = SelectOptionGroup;

Select = withStyles(styles)(Select);

export type { ChoicesHTMLSelectElement };
export { Select };
Loading

0 comments on commit a64597a

Please sign in to comment.