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==