diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx new file mode 100644 index 0000000000..849c1557f0 --- /dev/null +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -0,0 +1,132 @@ +/*! + * Copyright (c) 2023-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { + Autocomplete as MuiAutocomplete, + AutocompleteProps as MuiAutocompleteProps, + InputBase, +} from "@mui/material"; +import { memo, useCallback } from "react"; + +import { Field } from "./Field"; + +export type AutocompleteProps< + OptionType, + HasMultipleChoices extends boolean | undefined, + IsCustomValueAllowed extends boolean | undefined +> = { + hasMultipleChoices?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["multiple"]; + hint?: string; + isCustomValueAllowed?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["freeSolo"]; + isDisabled?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["disabled"]; + isLoading?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["loading"]; + isReadOnly?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["readOnly"]; + label: string; + onChange?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["onChange"]; + options: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["options"]; + value?: MuiAutocompleteProps< + OptionType, + HasMultipleChoices, + undefined, + IsCustomValueAllowed + >["value"]; +}; + +const Autocomplete = < + OptionType, + HasMultipleChoices extends boolean | undefined, + IsCustomValueAllowed extends boolean | undefined +>({ + isCustomValueAllowed, + hasMultipleChoices, + isDisabled, + isLoading, + isReadOnly, + hint, + label, + onChange, + options, + value, +}: AutocompleteProps) => { + const renderInput = useCallback( + ({ InputLabelProps, InputProps, ...params }) => ( + ( + + )} + /> + ), + [hint, label] + ); + + return ( + + ); +}; + +const MemoizedAutocomplete = memo(Autocomplete) as typeof Autocomplete; + +export { MemoizedAutocomplete as Autocomplete }; diff --git a/packages/odyssey-react-mui/src/MenuItem.tsx b/packages/odyssey-react-mui/src/MenuItem.tsx index 7d09a2847e..2943bc2226 100644 --- a/packages/odyssey-react-mui/src/MenuItem.tsx +++ b/packages/odyssey-react-mui/src/MenuItem.tsx @@ -45,6 +45,5 @@ const MenuItem = forwardRef( ); const MemoizedMenuItem = memo(MenuItem); -MemoizedMenuItem.displayName = "MenuItem"; export { MemoizedMenuItem as MenuItem }; diff --git a/packages/odyssey-react-mui/src/index.ts b/packages/odyssey-react-mui/src/index.ts index ba941e406a..f705c36bd3 100644 --- a/packages/odyssey-react-mui/src/index.ts +++ b/packages/odyssey-react-mui/src/index.ts @@ -122,6 +122,7 @@ export { default as FavoriteIcon } from "@mui/icons-material/Favorite"; export { deepmerge, visuallyHidden } from "@mui/utils"; +export * from "./Autocomplete"; export * from "./Banner"; export * from "./Checkbox"; export * from "./CheckboxGroup"; diff --git a/packages/odyssey-react-mui/src/theme/components.tsx b/packages/odyssey-react-mui/src/theme/components.tsx index 89db056e35..96d9cf0cf4 100644 --- a/packages/odyssey-react-mui/src/theme/components.tsx +++ b/packages/odyssey-react-mui/src/theme/components.tsx @@ -36,6 +36,7 @@ import { CheckCircleFilledIcon, ChevronDownIcon, CloseCircleFilledIcon, + CloseIcon, InformationCircleFilledIcon, SubtractIcon, } from "../iconDictionary"; @@ -144,6 +145,68 @@ export const components: ThemeOptions["components"] = { }), }, }, + MuiAutocomplete: { + defaultProps: { + autoHighlight: true, + autoSelect: false, + blurOnSelect: false, + clearIcon: , + clearOnEscape: true, + disableClearable: false, + disabledItemsFocusable: false, + disableListWrap: false, + disablePortal: false, + filterSelectedOptions: false, + fullWidth: false, + handleHomeEndKeys: true, + includeInputInList: true, + limitTags: -1, + openOnFocus: false, + popupIcon: , + selectOnFocus: true, + }, + styleOverrides: { + clearIndicator: ({ theme }) => ({ + marginRight: "unset", + padding: theme.spacing(1), + }), + endAdornment: ({ theme, ownerState }) => ({ + display: "flex", + gap: theme.spacing(1), + top: `calc(${theme.spacing(2)} - ${theme.mixins.borderWidth})`, + right: theme.spacing(2), + maxHeight: "unset", + alignItems: "center", + whiteSpace: "nowrap", + color: theme.palette.action.active, + + ...(ownerState.disabled === true && { + display: "none", + }), + + ...(ownerState.readOnly === true && { + display: "none", + }), + }), + loading: ({ theme }) => ({ + paddingBlock: theme.spacing(3), + paddingInline: theme.spacing(4), + }), + popupIndicator: ({ theme }) => ({ + padding: theme.spacing(1), + marginRight: "unset", + }), + inputRoot: ({ theme, ownerState }) => ({ + ...(ownerState.readOnly === true && { + backgroundColor: theme.palette.grey[50], + + [`&:not(:hover)`]: { + borderColor: "transparent", + }, + }), + }), + }, + }, MuiBackdrop: { styleOverrides: { root: ({ ownerState }) => ({ @@ -468,19 +531,6 @@ export const components: ThemeOptions["components"] = { paddingInlineEnd: theme.spacing(2), }), - [`& .${chipClasses.deleteIcon}`]: { - WebkitTapHighlightColor: "transparent", - color: theme.palette.text.secondary, - fontSize: "1em", - cursor: "pointer", - margin: "0", - marginInlineStart: theme.spacing(2), - - "&:hover": { - color: theme.palette.text.primary, - }, - }, - [`&.${chipClasses.disabled}`]: { opacity: 1, pointerEvents: "none", @@ -539,10 +589,32 @@ export const components: ThemeOptions["components"] = { }, }, }), + + [`.${inputBaseClasses.root}.${inputBaseClasses.disabled} &`]: { + backgroundColor: theme.palette.grey[200], + }, }), + label: { padding: 0, }, + + deleteIcon: ({ theme }) => ({ + WebkitTapHighlightColor: "transparent", + color: theme.palette.text.secondary, + fontSize: "1em", + cursor: "pointer", + margin: "0", + marginInlineStart: theme.spacing(2), + + "&:hover": { + color: theme.palette.text.primary, + }, + + [`.${inputBaseClasses.root}.${inputBaseClasses.disabled} &`]: { + display: "none", + }, + }), }, }, MuiCircularProgress: { @@ -1112,9 +1184,6 @@ export const components: ThemeOptions["components"] = { }, }, MuiInputAdornment: { - defaultProps: { - variant: "outlined", - }, styleOverrides: { root: ({ theme, ownerState }) => ({ display: "flex", diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.mdx b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.mdx new file mode 100644 index 0000000000..9e1be94692 --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.mdx @@ -0,0 +1,79 @@ +import { ArgsTable, Canvas, Meta, Story } from "@storybook/addon-docs"; + + + +# Autocomplete + +Similar to Select, this input triggers a menu of options a user can select. Country and state Autocompletes are common examples. + + + + + +## Behavior + +Interacting with an Autocomplete displays a list of values to choose from. Users may filter the options list by typing. + +## Variants + +Odyssey provides support for both single and multi-value Autocompletes. + +### Single-select + +With the single-select variant, choosing a value will override any previous selection and close the Autocomplete. + +#### Enabled + + + + + +#### Disabled + + + + + +#### Read-only + + + + + +### Multi-Select + +The multi-Select variant allows users to select many values. + +#### Enabled + + + + + +#### Disabled + + + + + +#### Read-only + + + + + +## Loading state + +The loading state is displayed when retrieving values from the server or when data is unavailable. + + + + + +## Custom Values + +Autocomplete also supports user-submitted values via the `isCustomValueAllowed` prop. + + + + diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000000..e0d9a0a44c --- /dev/null +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,144 @@ +/*! + * Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { Autocomplete, AutocompleteProps } from "@okta/odyssey-react-mui"; +import { ComponentMeta, Story } from "@storybook/react"; + +import { MuiThemeDecorator } from "../../../../.storybook/components"; +import AutocompleteMdx from "./Autocomplete.mdx"; + +const storybookMeta: ComponentMeta = { + title: `MUI Components/Forms/Autocomplete`, + component: Autocomplete, + parameters: { + docs: { + page: AutocompleteMdx, + }, + }, + argTypes: { + label: { + control: "text", + defaultValue: "Destination", + }, + hint: { + control: "text", + defaultValue: "Select your destination in the Sol system.", + }, + isDisabled: { + control: "boolean", + }, + isCustomValueAllowed: { + control: "boolean", + }, + isLoading: { + control: "boolean", + }, + hasMultipleChoices: { + control: "boolean", + }, + isReadOnly: { + control: "boolean", + }, + }, + decorators: [MuiThemeDecorator], +}; + +export default storybookMeta; + +type StationType = { label: string }; + +// Top 100 films as rated by IMDb users. http://www.imdb.com/chart/top +const stations: ReadonlyArray = [ + { label: "Anderson Station" }, + { label: "Bara Gaon Complex" }, + { label: "Ceres" }, + { label: "Corley Station" }, + { label: "Deep Transfer Station Three" }, + { label: "Eros" }, + { label: "Free Navy Supply Depot" }, + { label: "Ganymede" }, + { label: "Gewitter Base" }, + { label: "Iapetus Station" }, + { label: "Kelso Station" }, + { label: "Laconian Transfer Station" }, + { label: "Mao Station" }, + { label: "Medina Station" }, + { label: "Nauvoo" }, + { label: "Oshima" }, + { label: "Osiris Station" }, + { label: "Pallas" }, + { label: "Phoebe Station" }, + { label: "Prospero Station" }, + { label: "Shirazi-Ma Complex" }, + { label: "Terryon Lock" }, + { label: "Thoth Station" }, + { label: "Tycho Station" }, + { label: "Vesta" }, +]; + +type AutocompleteType = AutocompleteProps< + StationType | undefined, + boolean | undefined, + boolean | undefined +>; + +const Template: Story = (args) => { + return ; +}; + +const EmptyTemplate: Story = (args) => { + return ; +}; + +export const Default = Template.bind({}); +Default.args = {}; + +export const disabled = Template.bind({}); +disabled.args = { + isDisabled: true, + value: { label: "Tycho Station" }, +}; + +export const isCustomValueAllowed = Template.bind({}); +isCustomValueAllowed.args = { + isCustomValueAllowed: true, +}; + +export const loading = EmptyTemplate.bind({}); +loading.args = { + isLoading: true, +}; + +export const multiple = Template.bind({}); +multiple.args = { + hasMultipleChoices: true, +}; + +export const multipleDisabled = Template.bind({}); +multipleDisabled.args = { + hasMultipleChoices: true, + isDisabled: true, + value: [{ label: "Tycho Station" }], +}; + +export const multipleReadOnly = Template.bind({}); +multipleReadOnly.args = { + hasMultipleChoices: true, + isReadOnly: true, + value: [{ label: "Tycho Station" }], +}; + +export const readOnly = Template.bind({}); +readOnly.args = { + isReadOnly: true, + value: { label: "Tycho Station" }, +}; diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Banner/Banner.mdx b/packages/odyssey-storybook/src/components/odyssey-mui/Banner/Banner.mdx index 01bc475acf..c5a7d9162e 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Banner/Banner.mdx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Banner/Banner.mdx @@ -68,10 +68,6 @@ Banner content should be succinct and direct. If possible, your content should b When including an action, be sure the link text clearly indicates where it leads. -## Props - - - ## References
diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.mdx b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.mdx index 8aea429d4b..d608b5443f 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.mdx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.mdx @@ -16,8 +16,6 @@ Interacting with a Select displays a list of values to choose from. Choosing a v Odyssey also supports a Multi-Select variant that allows users to select many values. -To support expected functionality and behaviors, Select relies on the Choices.js library. Odyssey provides fallback styling when Choices.js isn't available. - ## Variants Odyssey provides support for both single and multi-value Selects.