diff --git a/package.json b/package.json index 785ceff27..52c6f7e1d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "devDependencies": { "@babel/core": "^7.13.1", "@emotion/babel-preset-css-prop": "^11.2.0", - "@emotion/jest": "^11.2.1", + "@emotion/jest": "^11.3.0", "@emotion/react": "^11.1.5", "@homebound/rtl-utils": "^1.39.0", "@homebound/tsconfig": "^1.0.2", @@ -71,7 +71,7 @@ "@testing-library/jest-dom": "^5.11.9", "@tsconfig/recommended": "^1.0.1", "@types/jest": "^26.0.20", - "@types/react": "^16.14.5", + "@types/react": "^17.0.5", "@types/react-dom": "^16.9.11", "@types/react-router-dom": "^5.1.7", "@types/tinycolor2": "^1.4.2", @@ -104,7 +104,7 @@ "ts-node": "^9.1.1", "tslib": "^2.1.0", "ttypescript": "^1.5.12", - "typescript": "^4.2.3", + "typescript": "^4.2.4", "typescript-transform-paths": "^2.0.1", "watch": "^1.0.2" } diff --git a/src/components/SelectField.stories.tsx b/src/components/SelectField.stories.tsx index 25aa8316e..d67acb984 100644 --- a/src/components/SelectField.stories.tsx +++ b/src/components/SelectField.stories.tsx @@ -1,5 +1,5 @@ import { Meta } from "@storybook/react"; -import { useState } from "react"; +import { Key, useState } from "react"; import { Icon, Icons, SelectField, SelectFieldProps } from "src/components"; import { Css } from "src/Css"; @@ -28,10 +28,8 @@ export function SelectFields() {

Regular

o.id} - getOptionLabel={(o) => o.name} getOptionMenuLabel={(o) => (
{o.icon && ( @@ -46,10 +44,8 @@ export function SelectFields() { o.id} - getOptionLabel={(o) => o.name} fieldDecoration={(o) => o.icon && } - selectedOption={options[1]} + value={options[1].id} getOptionMenuLabel={(o) => (
{o.icon && ( @@ -61,27 +57,9 @@ export function SelectFields() {
)} /> - o.id} - getOptionLabel={(o) => o.name} - disabled - /> - o.id} - getOptionLabel={(o) => o.name} - selectedOption={options[2]} - readOnly - /> - o.id} - getOptionLabel={(o) => o.name} - /> + + +
@@ -89,10 +67,8 @@ export function SelectFields() { o.id} - getOptionLabel={(o) => o.name} getOptionMenuLabel={(o) => (
{o.icon && ( @@ -108,10 +84,8 @@ export function SelectFields() { compact label="Favorite Icon - with field decoration" options={options} - getOptionValue={(o) => o.id} - getOptionLabel={(o) => o.name} fieldDecoration={(o) => o.icon && } - selectedOption={options[1]} + value={options[1].id} getOptionMenuLabel={(o) => (
{o.icon && ( @@ -123,44 +97,23 @@ export function SelectFields() {
)} /> - o.id} - getOptionLabel={(o) => o.name} - disabled - /> - o.id} - getOptionLabel={(o) => o.name} - selectedOption={options[2]} - readOnly - /> - o.id} - getOptionLabel={(o) => o.name} - /> + + +
); } -function TestSelectField( - props: Partial> & Pick, "getOptionLabel" | "getOptionValue" | "options">, -) { - const [selectedOption, setSelectedOption] = useState(props.selectedOption); +function TestSelectField(props: Omit, "onSelect">) { + const [selectedOption, setSelectedOption] = useState(props.value); return ( - setSelectedOption(o)} + + // The `as any` is due to something related to https://github.com/emotion-js/emotion/issues/2169 + // We may have to redo the conditional getOptionValue/getOptionLabel + {...(props as any)} + value={selectedOption} + onSelect={(v) => setSelectedOption(v)} errorMsg={selectedOption || props.disabled ? "" : "Select an option. Plus more error text to force it to wrap."} /> ); diff --git a/src/components/SelectField.tsx b/src/components/SelectField.tsx index 5bc9aaa67..2ed6d8045 100644 --- a/src/components/SelectField.tsx +++ b/src/components/SelectField.tsx @@ -20,55 +20,83 @@ import { Label } from "src/components/Label"; import { Css, Palette, px } from "src/Css"; import { BeamFocusableProps } from "src/interfaces"; -export interface SelectFieldProps extends BeamSelectFieldBaseProps { - getOptionLabel: (opt: T) => string; - getOptionMenuLabel?: (opt: T) => string | ReactNode; - getOptionValue: (opt: T) => Key; - onSelect: (opt: T | undefined) => void; - options: T[]; - selectedOption: T | undefined; +interface SelectFieldPropsBase extends BeamSelectFieldBaseProps { + /** Renders `opt` in the dropdown menu, defaults to the `getOptionLabel` prop. */ + getOptionMenuLabel?: (opt: O) => string | ReactNode; + /** The current value; it can be `undefined`, even if `V` cannot be. */ + value: V | undefined; + onSelect: (value: V, opt: O) => void; + options: O[]; } -export function SelectField(props: SelectFieldProps) { +type HasId = { id: V }; +type HasName = { name: string }; + +type MaybeOptionValue = O extends HasId + ? { getOptionValue?: (opt: O) => V } + : { getOptionValue: (opt: O) => V }; + +type MaybeOptionLabel = O extends HasName + ? { getOptionLabel?: (opt: O) => string } + : { getOptionLabel: (opt: O) => string }; + +// We use mapped types to conditionally require getOptionLabel, getOptionValue +export type SelectFieldProps = SelectFieldPropsBase & + MaybeOptionValue & + MaybeOptionLabel; + +/** + * Provides a non-native select/dropdown widget. + * + * The `O` type is a list of options to show, the `V` is the primitive value of a + * given `O` (i.e. it's id) that you want to use as the current/selected value. + * + * Note that the `O extends object` and `V extends Key` constraints come from react-aria, + * and so we cannot easily change them. + */ +export function SelectField(props: SelectFieldProps): JSX.Element { const { - getOptionLabel, + getOptionLabel = (opt: O) => (opt as HasName).name, // if unset, assume O implements HasName getOptionMenuLabel = getOptionLabel, - getOptionValue, + getOptionValue = (opt: O) => (opt as HasId).id, // if unset, assume O implements HasId onSelect, options, - selectedOption, + value, ...beamSelectFieldProps } = props; const { contains } = useFilter({ sensitivity: "base" }); + // Use the current value to find the option + const selectedOption = options.find((opt) => getOptionValue(opt) === value); + const [fieldState, setFieldState] = useState<{ isOpen: boolean; - selectedKey: Key | undefined; + selectedKey: V | undefined; inputValue: string; - filteredOptions: T[]; + filteredOptions: O[]; }>({ isOpen: false, - selectedKey: selectedOption && getOptionValue(selectedOption), + selectedKey: value, inputValue: selectedOption ? getOptionLabel(selectedOption) : "", filteredOptions: options, }); return ( - + {...beamSelectFieldProps} filteredOptions={fieldState.filteredOptions} inputValue={fieldState.inputValue} selectedKey={fieldState.selectedKey} onSelectionChange={(key) => { - const selectedItem = options.find((o) => getOptionValue(o) === key); + const newOption = options.find((o) => getOptionValue(o) === key); setFieldState({ isOpen: false, - inputValue: selectedItem ? getOptionLabel(selectedItem) : "", - selectedKey: key, + inputValue: newOption ? getOptionLabel(newOption) : "", + selectedKey: key as V, filteredOptions: options, }); - onSelect && onSelect(selectedItem); + onSelect && newOption && onSelect(getOptionValue(newOption), newOption); }} onInputChange={(value) => { setFieldState((prevState) => ({ @@ -387,11 +415,13 @@ function Option({ item, state }: { item: Node; state: Combo const getFieldWidth = (compact: boolean) => (compact ? 248 : 320); -interface BeamSelectFieldBaseProps extends BeamFocusableProps { +interface BeamSelectFieldBaseProps extends BeamFocusableProps { compact?: boolean; disabled?: boolean; errorMsg?: string; + /** Allow placing an icon/decoration within the input field. */ fieldDecoration?: (opt: T) => ReactNode; + /** Sets the form field label. */ label?: string; readOnly?: boolean; } diff --git a/yarn.lock b/yarn.lock index acd9ee5cd..98addcf15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1238,7 +1238,7 @@ dependencies: "@emotion/memoize" "0.7.4" -"@emotion/jest@^11.2.1": +"@emotion/jest@^11.3.0": version "11.3.0" resolved "https://registry.yarnpkg.com/@emotion/jest/-/jest-11.3.0.tgz#43bed6dcb47c8691b346cee231861ebc8f9b0016" integrity sha512-LZqYc3yerhic1IvAcEwBLRs1DsUt3oY7Oz6n+e+HU32iYOK/vpfzlhgmQURE94BHfv6eCOj6DV38f3jSnIkBkQ== @@ -3973,7 +3973,7 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/react@^16", "@types/react@^16.14.5": +"@types/react@^16": version "16.14.5" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.5.tgz#2c39b5cadefaf4829818f9219e5e093325979f4d" integrity sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw== @@ -3982,6 +3982,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^17.0.5": + version "17.0.5" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.5.tgz#3d887570c4489011f75a3fc8f965bf87d09a1bea" + integrity sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/retry@^0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -14834,7 +14843,7 @@ typescript-transform-paths@^2.0.1: dependencies: minimatch "^3.0.4" -typescript@^4.2.3: +typescript@^4.2.4: version "4.2.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==