From ec5df5f94f472628103eb1cfbecccc6afc2658bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 28 Jan 2022 13:43:21 +0100 Subject: [PATCH] [SelectUnstyled] Create unstyled select (+ hook) (#30113) Co-authored-by: Siriwat K --- .eslintrc.js | 1 + docs/pages/api-docs/multi-select-unstyled.js | 23 + .../pages/api-docs/multi-select-unstyled.json | 35 ++ docs/pages/api-docs/option-group-unstyled.js | 23 + .../pages/api-docs/option-group-unstyled.json | 29 + docs/pages/api-docs/option-unstyled.js | 23 + docs/pages/api-docs/option-unstyled.json | 23 + docs/pages/api-docs/select-unstyled.js | 23 + docs/pages/api-docs/select-unstyled.json | 35 ++ .../selects/UnstyledSelectControlled.js | 129 ++++ .../selects/UnstyledSelectControlled.tsx | 118 ++++ .../UnstyledSelectControlled.tsx.preview | 7 + .../UnstyledSelectCustomRenderValue.js | 136 +++++ .../UnstyledSelectCustomRenderValue.tsx | 126 ++++ ...nstyledSelectCustomRenderValue.tsx.preview | 5 + .../selects/UnstyledSelectGrouping.js | 177 ++++++ .../selects/UnstyledSelectGrouping.tsx | 158 +++++ .../UnstyledSelectGrouping.tsx.preview | 12 + .../selects/UnstyledSelectMultiple.js | 127 ++++ .../selects/UnstyledSelectMultiple.tsx | 118 ++++ .../UnstyledSelectMultiple.tsx.preview | 7 + .../selects/UnstyledSelectObjectValues.js | 140 +++++ .../selects/UnstyledSelectObjectValues.tsx | 134 +++++ .../UnstyledSelectObjectValues.tsx.preview | 10 + .../selects/UnstyledSelectRichOptions.js | 568 ++++++++++++++++++ .../selects/UnstyledSelectRichOptions.tsx | 560 +++++++++++++++++ .../UnstyledSelectRichOptions.tsx.preview | 14 + .../selects/UnstyledSelectSimple.js | 110 ++++ .../selects/UnstyledSelectSimple.tsx | 118 ++++ .../selects/UnstyledSelectSimple.tsx.preview | 5 + .../src/pages/components/selects/UseSelect.js | 127 ++++ .../pages/components/selects/UseSelect.tsx | 120 ++++ .../components/selects/UseSelect.tsx.preview | 1 + docs/src/pages/components/selects/selects.md | 83 ++- docs/src/pagesApi.js | 4 + .../multi-select-unstyled-pt.json | 18 + .../multi-select-unstyled-zh.json | 18 + .../multi-select-unstyled.json | 17 + .../option-group-unstyled-pt.json | 11 + .../option-group-unstyled-zh.json | 11 + .../option-group-unstyled.json | 11 + .../option-unstyled/option-unstyled-pt.json | 11 + .../option-unstyled/option-unstyled-zh.json | 11 + .../option-unstyled/option-unstyled.json | 11 + .../select-unstyled/select-unstyled-pt.json | 8 + .../select-unstyled/select-unstyled-zh.json | 8 + .../select-unstyled/select-unstyled.json | 17 + .../src/ButtonUnstyled/ButtonUnstyled.tsx | 4 - .../src/ButtonUnstyled/UseButtonProps.ts | 3 +- .../mui-base/src/ButtonUnstyled/useButton.ts | 13 +- .../defaultListboxReducer.test.ts | 316 ++++++++++ .../ListboxUnstyled/defaultListboxReducer.ts | 277 +++++++++ .../mui-base/src/ListboxUnstyled/index.ts | 4 + .../mui-base/src/ListboxUnstyled/types.ts | 174 ++++++ .../ListboxUnstyled/useControllableReducer.ts | 148 +++++ .../src/ListboxUnstyled/useListbox.ts | 194 ++++++ .../MultiSelectUnstyled.test.tsx | 162 +++++ .../MultiSelectUnstyled.tsx | 306 ++++++++++ .../MultiSelectUnstyledProps.ts | 30 + .../mui-base/src/MultiSelectUnstyled/index.ts | 4 + .../OptionGroupUnstyled.test.tsx | 29 + .../OptionGroupUnstyled.tsx | 123 ++++ .../OptionGroupUnstyledProps.ts | 43 ++ .../mui-base/src/OptionGroupUnstyled/index.ts | 7 + .../optionGroupUnstyledClasses.ts | 21 + .../OptionUnstyled/OptionUnstyled.test.tsx | 50 ++ .../src/OptionUnstyled/OptionUnstyled.tsx | 146 +++++ .../src/OptionUnstyled/OptionUnstyledProps.ts | 39 ++ packages/mui-base/src/OptionUnstyled/index.ts | 7 + .../OptionUnstyled/optionUnstyledClasses.tsx | 24 + .../src/PopperUnstyled/PopperUnstyled.js | 1 + .../SelectUnstyled/SelectUnstyled.test.tsx | 203 +++++++ .../src/SelectUnstyled/SelectUnstyled.tsx | 298 +++++++++ .../SelectUnstyled/SelectUnstyledContext.ts | 12 + .../src/SelectUnstyled/SelectUnstyledProps.ts | 82 +++ packages/mui-base/src/SelectUnstyled/index.ts | 12 + .../SelectUnstyled/selectUnstyledClasses.ts | 32 + .../mui-base/src/SelectUnstyled/useSelect.ts | 310 ++++++++++ .../src/SelectUnstyled/useSelectProps.ts | 46 ++ .../mui-base/src/SelectUnstyled/utils.tsx | 75 +++ packages/mui-base/src/index.d.ts | 12 + packages/mui-base/src/index.js | 14 + packages/mui-base/src/utils/areArraysEqual.ts | 12 + packages/mui-base/src/utils/index.ts | 1 + test/utils/describeConformanceUnstyled.tsx | 31 +- test/utils/fireDiscreteEvent.js | 11 + test/utils/userEvent.ts | 13 +- 87 files changed, 6748 insertions(+), 22 deletions(-) create mode 100644 docs/pages/api-docs/multi-select-unstyled.js create mode 100644 docs/pages/api-docs/multi-select-unstyled.json create mode 100644 docs/pages/api-docs/option-group-unstyled.js create mode 100644 docs/pages/api-docs/option-group-unstyled.json create mode 100644 docs/pages/api-docs/option-unstyled.js create mode 100644 docs/pages/api-docs/option-unstyled.json create mode 100644 docs/pages/api-docs/select-unstyled.js create mode 100644 docs/pages/api-docs/select-unstyled.json create mode 100644 docs/src/pages/components/selects/UnstyledSelectControlled.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectControlled.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectControlled.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectGrouping.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectGrouping.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectGrouping.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectMultiple.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectMultiple.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectMultiple.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectObjectValues.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectRichOptions.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx.preview create mode 100644 docs/src/pages/components/selects/UnstyledSelectSimple.js create mode 100644 docs/src/pages/components/selects/UnstyledSelectSimple.tsx create mode 100644 docs/src/pages/components/selects/UnstyledSelectSimple.tsx.preview create mode 100644 docs/src/pages/components/selects/UseSelect.js create mode 100644 docs/src/pages/components/selects/UseSelect.tsx create mode 100644 docs/src/pages/components/selects/UseSelect.tsx.preview create mode 100644 docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-pt.json create mode 100644 docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-zh.json create mode 100644 docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled.json create mode 100644 docs/translations/api-docs/option-group-unstyled/option-group-unstyled-pt.json create mode 100644 docs/translations/api-docs/option-group-unstyled/option-group-unstyled-zh.json create mode 100644 docs/translations/api-docs/option-group-unstyled/option-group-unstyled.json create mode 100644 docs/translations/api-docs/option-unstyled/option-unstyled-pt.json create mode 100644 docs/translations/api-docs/option-unstyled/option-unstyled-zh.json create mode 100644 docs/translations/api-docs/option-unstyled/option-unstyled.json create mode 100644 docs/translations/api-docs/select-unstyled/select-unstyled-pt.json create mode 100644 docs/translations/api-docs/select-unstyled/select-unstyled-zh.json create mode 100644 docs/translations/api-docs/select-unstyled/select-unstyled.json create mode 100644 packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts create mode 100644 packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts create mode 100644 packages/mui-base/src/ListboxUnstyled/index.ts create mode 100644 packages/mui-base/src/ListboxUnstyled/types.ts create mode 100644 packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts create mode 100644 packages/mui-base/src/ListboxUnstyled/useListbox.ts create mode 100644 packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx create mode 100644 packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.tsx create mode 100644 packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyledProps.ts create mode 100644 packages/mui-base/src/MultiSelectUnstyled/index.ts create mode 100644 packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.test.tsx create mode 100644 packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.tsx create mode 100644 packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyledProps.ts create mode 100644 packages/mui-base/src/OptionGroupUnstyled/index.ts create mode 100644 packages/mui-base/src/OptionGroupUnstyled/optionGroupUnstyledClasses.ts create mode 100644 packages/mui-base/src/OptionUnstyled/OptionUnstyled.test.tsx create mode 100644 packages/mui-base/src/OptionUnstyled/OptionUnstyled.tsx create mode 100644 packages/mui-base/src/OptionUnstyled/OptionUnstyledProps.ts create mode 100644 packages/mui-base/src/OptionUnstyled/index.ts create mode 100644 packages/mui-base/src/OptionUnstyled/optionUnstyledClasses.tsx create mode 100644 packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx create mode 100644 packages/mui-base/src/SelectUnstyled/SelectUnstyled.tsx create mode 100644 packages/mui-base/src/SelectUnstyled/SelectUnstyledContext.ts create mode 100644 packages/mui-base/src/SelectUnstyled/SelectUnstyledProps.ts create mode 100644 packages/mui-base/src/SelectUnstyled/index.ts create mode 100644 packages/mui-base/src/SelectUnstyled/selectUnstyledClasses.ts create mode 100644 packages/mui-base/src/SelectUnstyled/useSelect.ts create mode 100644 packages/mui-base/src/SelectUnstyled/useSelectProps.ts create mode 100644 packages/mui-base/src/SelectUnstyled/utils.tsx create mode 100644 packages/mui-base/src/utils/areArraysEqual.ts diff --git a/.eslintrc.js b/.eslintrc.js index 803659aa6b511b..e1ce56bb6eab88 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -249,6 +249,7 @@ module.exports = { // This most often reports data that is defined after the component definition. // This is safe to do and helps readability of the demo code since the data is mostly irrelevant. '@typescript-eslint/no-use-before-define': 'off', + 'react/prop-types': 'off', }, }, { diff --git a/docs/pages/api-docs/multi-select-unstyled.js b/docs/pages/api-docs/multi-select-unstyled.js new file mode 100644 index 00000000000000..aa99fc89b28b45 --- /dev/null +++ b/docs/pages/api-docs/multi-select-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './multi-select-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/multi-select-unstyled', + false, + /multi-select-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/multi-select-unstyled.json b/docs/pages/api-docs/multi-select-unstyled.json new file mode 100644 index 00000000000000..1ec22f2c8b5c0c --- /dev/null +++ b/docs/pages/api-docs/multi-select-unstyled.json @@ -0,0 +1,35 @@ +{ + "props": { + "autoFocus": { "type": { "name": "bool" } }, + "components": { + "type": { + "name": "shape", + "description": "{ Listbox?: elementType, Popper?: elementType, Root?: elementType }" + }, + "default": "{}" + }, + "componentsProps": { + "type": { + "name": "shape", + "description": "{ listbox?: object, popper?: object, root?: object }" + }, + "default": "{}" + }, + "defaultListboxOpen": { "type": { "name": "bool" } }, + "defaultValue": { "type": { "name": "array" }, "default": "[]" }, + "disabled": { "type": { "name": "bool" } }, + "listboxOpen": { "type": { "name": "bool" }, "default": "undefined" }, + "onChange": { "type": { "name": "func" } }, + "onListboxOpenChange": { "type": { "name": "func" } }, + "renderValue": { "type": { "name": "func" } }, + "value": { "type": { "name": "array" } } + }, + "name": "MultiSelectUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/api-docs/option-group-unstyled.js b/docs/pages/api-docs/option-group-unstyled.js new file mode 100644 index 00000000000000..f213d581bd8812 --- /dev/null +++ b/docs/pages/api-docs/option-group-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './option-group-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/option-group-unstyled', + false, + /option-group-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/option-group-unstyled.json b/docs/pages/api-docs/option-group-unstyled.json new file mode 100644 index 00000000000000..2bcd632ed94607 --- /dev/null +++ b/docs/pages/api-docs/option-group-unstyled.json @@ -0,0 +1,29 @@ +{ + "props": { + "component": { "type": { "name": "elementType" } }, + "components": { + "type": { + "name": "shape", + "description": "{ Label?: elementType, List?: elementType, Root?: elementType }" + }, + "default": "{}" + }, + "componentsProps": { + "type": { + "name": "shape", + "description": "{ label?: object, list?: object, root?: object }" + }, + "default": "{}" + }, + "disabled": { "type": { "name": "bool" } }, + "label": { "type": { "name": "node" } } + }, + "name": "OptionGroupUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLLIElement", + "filename": "/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/api-docs/option-unstyled.js b/docs/pages/api-docs/option-unstyled.js new file mode 100644 index 00000000000000..7c14dbcda7ef36 --- /dev/null +++ b/docs/pages/api-docs/option-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './option-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/option-unstyled', + false, + /option-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/option-unstyled.json b/docs/pages/api-docs/option-unstyled.json new file mode 100644 index 00000000000000..3acf875c8d1f9b --- /dev/null +++ b/docs/pages/api-docs/option-unstyled.json @@ -0,0 +1,23 @@ +{ + "props": { + "value": { "type": { "name": "any" }, "required": true }, + "component": { "type": { "name": "elementType" } }, + "components": { + "type": { "name": "shape", "description": "{ Root?: elementType }" }, + "default": "{}" + }, + "componentsProps": { + "type": { "name": "shape", "description": "{ root?: object }" }, + "default": "{}" + }, + "disabled": { "type": { "name": "bool" } } + }, + "name": "OptionUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLLIElement", + "filename": "/packages/mui-base/src/OptionUnstyled/OptionUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/api-docs/select-unstyled.js b/docs/pages/api-docs/select-unstyled.js new file mode 100644 index 00000000000000..71d1dc6fdb413e --- /dev/null +++ b/docs/pages/api-docs/select-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './select-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/select-unstyled', + false, + /select-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/select-unstyled.json b/docs/pages/api-docs/select-unstyled.json new file mode 100644 index 00000000000000..2f12c6b6738225 --- /dev/null +++ b/docs/pages/api-docs/select-unstyled.json @@ -0,0 +1,35 @@ +{ + "props": { + "autoFocus": { "type": { "name": "bool" } }, + "components": { + "type": { + "name": "shape", + "description": "{ Listbox?: elementType, Popper?: elementType, Root?: elementType }" + }, + "default": "{}" + }, + "componentsProps": { + "type": { + "name": "shape", + "description": "{ listbox?: object, popper?: object, root?: object }" + }, + "default": "{}" + }, + "defaultListboxOpen": { "type": { "name": "bool" } }, + "defaultValue": { "type": { "name": "any" } }, + "disabled": { "type": { "name": "bool" } }, + "listboxOpen": { "type": { "name": "bool" }, "default": "undefined" }, + "onChange": { "type": { "name": "func" } }, + "onListboxOpenChange": { "type": { "name": "func" } }, + "renderValue": { "type": { "name": "func" } }, + "value": { "type": { "name": "any" } } + }, + "name": "SelectUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLButtonElement", + "filename": "/packages/mui-base/src/SelectUnstyled/SelectUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/src/pages/components/selects/UnstyledSelectControlled.js b/docs/src/pages/components/selects/UnstyledSelectControlled.js new file mode 100644 index 00000000000000..4a81c962ec6d06 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectControlled.js @@ -0,0 +1,129 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +CustomSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +export default function UnstyledSelectsMultiple() { + const [value, setValue] = React.useState(10); + return ( +
+ + Ten + Twenty + Thirty + + +

Selected value: {value}

+
+ ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectControlled.tsx b/docs/src/pages/components/selects/UnstyledSelectControlled.tsx new file mode 100644 index 00000000000000..77a97c27758f12 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectControlled.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props: SelectUnstyledProps) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +export default function UnstyledSelectsMultiple() { + const [value, setValue] = React.useState(10); + return ( +
+ + Ten + Twenty + Thirty + + +

Selected value: {value}

+
+ ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectControlled.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectControlled.tsx.preview new file mode 100644 index 00000000000000..320b97c61c39e0 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectControlled.tsx.preview @@ -0,0 +1,7 @@ + + Ten + Twenty + Thirty + + +

Selected value: {value}

\ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.js b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.js new file mode 100644 index 00000000000000..14de6667a80d97 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.js @@ -0,0 +1,136 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +CustomSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +function renderValue(option) { + if (option == null) { + return Select an option...; + } + + return ( + + {option.label} ({option.value}) + + ); +} + +export default function UnstyledSelectCustomRenderValue() { + return ( + + Ten + Twenty + Thirty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx new file mode 100644 index 00000000000000..f7d140b88485ca --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, + SelectOption, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props: SelectUnstyledProps) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +function renderValue(option: SelectOption | null) { + if (option == null) { + return Select an option...; + } + + return ( + + {option.label} ({option.value}) + + ); +} + +export default function UnstyledSelectCustomRenderValue() { + return ( + + Ten + Twenty + Thirty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx.preview new file mode 100644 index 00000000000000..cd9bd85a491b05 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectCustomRenderValue.tsx.preview @@ -0,0 +1,5 @@ + + Ten + Twenty + Thirty + \ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectGrouping.js b/docs/src/pages/components/selects/UnstyledSelectGrouping.js new file mode 100644 index 00000000000000..6df0e1caa58a00 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectGrouping.js @@ -0,0 +1,177 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import OptionGroupUnstyled from '@mui/base/OptionGroupUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledGroupRoot = styled('li')` + list-style: none; +`; + +const StyledGroupHeader = styled('span')` + display: block; + padding: 10px 10px 4px 10px; + font-size: 0.75em; + text-transform: uppercase; +`; + +const StyledGroupOptions = styled('ul')` + list-style: none; + margin-left: 0; + padding: 0; + + > li { + padding-left: 20px; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +CustomSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +const CustomOptionGroup = React.forwardRef(function CustomOptionGroup(props, ref) { + const components = { + Root: StyledGroupRoot, + Label: StyledGroupHeader, + List: StyledGroupOptions, + ...props.components, + }; + + return ; +}); + +CustomOptionGroup.propTypes = { + /** + * The components used for each slot inside the OptionGroupUnstyled. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Label: PropTypes.elementType, + List: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +export default function UnstyledSelectGrouping() { + return ( + + + Frodo + Sam + Merry + Pippin + + + Galadriel + Legolas + + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx b/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx new file mode 100644 index 00000000000000..3941e1e4e3cffb --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx @@ -0,0 +1,158 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import OptionGroupUnstyled, { + OptionGroupUnstyledProps, +} from '@mui/base/OptionGroupUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption: typeof OptionUnstyled = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledGroupRoot = styled('li')` + list-style: none; +`; + +const StyledGroupHeader = styled('span')` + display: block; + padding: 10px 10px 4px 10px; + font-size: 0.75em; + text-transform: uppercase; +`; + +const StyledGroupOptions = styled('ul')` + list-style: none; + margin-left: 0; + padding: 0; + + > li { + padding-left: 20px; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props: SelectUnstyledProps) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +const CustomOptionGroup = React.forwardRef(function CustomOptionGroup( + props: OptionGroupUnstyledProps, + ref: React.ForwardedRef, +) { + const components: OptionGroupUnstyledProps['components'] = { + Root: StyledGroupRoot, + Label: StyledGroupHeader, + List: StyledGroupOptions, + ...props.components, + }; + + return ; +}); + +export default function UnstyledSelectGrouping() { + return ( + + + Frodo + Sam + Merry + Pippin + + + Galadriel + Legolas + + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx.preview new file mode 100644 index 00000000000000..f5a1372a09834b --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectGrouping.tsx.preview @@ -0,0 +1,12 @@ + + + Frodo + Sam + Merry + Pippin + + + Galadriel + Legolas + + \ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectMultiple.js b/docs/src/pages/components/selects/UnstyledSelectMultiple.js new file mode 100644 index 00000000000000..34062011453907 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectMultiple.js @@ -0,0 +1,127 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import MultiSelectUnstyled from '@mui/base/MultiSelectUnstyled'; +import { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomMultiSelect = React.forwardRef(function CustomMultiSelect(props, ref) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}); + +CustomMultiSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +export default function UnstyledSelectsMultiple() { + return ( + + Ten + Twenty + Thirty + Forty + Fifty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx b/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx new file mode 100644 index 00000000000000..84b134d5c81f17 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import MultiSelectUnstyled, { + MultiSelectUnstyledProps, +} from '@mui/base/MultiSelectUnstyled'; +import { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomMultiSelect = React.forwardRef(function CustomMultiSelect( + props: MultiSelectUnstyledProps, + ref: React.ForwardedRef, +) { + const components: MultiSelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}); + +export default function UnstyledSelectsMultiple() { + return ( + + Ten + Twenty + Thirty + Forty + Fifty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx.preview new file mode 100644 index 00000000000000..ae87fa1220c3d2 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectMultiple.tsx.preview @@ -0,0 +1,7 @@ + + Ten + Twenty + Thirty + Forty + Fifty + \ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectObjectValues.js b/docs/src/pages/components/selects/UnstyledSelectObjectValues.js new file mode 100644 index 00000000000000..aac4c55e6ad7bf --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectObjectValues.js @@ -0,0 +1,140 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +CustomSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +const characters = [ + { name: 'Frodo', race: 'Hobbit' }, + { name: 'Sam', race: 'Hobbit' }, + { name: 'Merry', race: 'Hobbit' }, + { name: 'Gandalf', race: 'Maia' }, + { name: 'Gimli', race: 'Dwarf' }, +]; + +export default function UnstyledSelectObjectValues() { + const [character, setCharacter] = React.useState(characters[0]); + return ( +
+ + {characters.map((c) => ( + + {c.name} + + ))} + + +

Selected character:

+
{JSON.stringify(character, null, 2)}
+
+ ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx b/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx new file mode 100644 index 00000000000000..c246ae9befb4e3 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +function CustomSelect(props: SelectUnstyledProps) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +} + +interface Character { + name: string; + race: string; +} + +const characters: Character[] = [ + { name: 'Frodo', race: 'Hobbit' }, + { name: 'Sam', race: 'Hobbit' }, + { name: 'Merry', race: 'Hobbit' }, + { name: 'Gandalf', race: 'Maia' }, + { name: 'Gimli', race: 'Dwarf' }, +]; + +export default function UnstyledSelectObjectValues() { + const [character, setCharacter] = React.useState(characters[0]); + return ( +
+ + {characters.map((c) => ( + + {c.name} + + ))} + + +

Selected character:

+
{JSON.stringify(character, null, 2)}
+
+ ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx.preview new file mode 100644 index 00000000000000..13b0bbf0ae77c3 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectObjectValues.tsx.preview @@ -0,0 +1,10 @@ + + {characters.map((c) => ( + + {c.name} + + ))} + + +

Selected character:

+
{JSON.stringify(character, null, 2)}
\ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectRichOptions.js b/docs/src/pages/components/selects/UnstyledSelectRichOptions.js new file mode 100644 index 00000000000000..dca3802a524759 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectRichOptions.js @@ -0,0 +1,568 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import { styled } from '@mui/system'; +import { PopperUnstyled } from '@mui/base'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 480px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } + + & img { + margin-right: 10px; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 480px; + border: 1px solid #ccc; + border-top: none; + color: #000; + max-height: 400px; + overflow: auto; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } + + & img { + margin-right: 10px; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomSelect = React.forwardRef(function CustomSelect(props, ref) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}); + +CustomSelect.propTypes = { + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), +}; + +export default function UnstyledSelectRichOptions() { + return ( + + {countries.map((c) => ( + + {`Flag + {c.label} ({c.code}) +{c.phone} + + ))} + + ); +} + +const countries = [ + { code: 'AD', label: 'Andorra', phone: '376' }, + { + code: 'AE', + label: 'United Arab Emirates', + phone: '971', + }, + { code: 'AF', label: 'Afghanistan', phone: '93' }, + { + code: 'AG', + label: 'Antigua and Barbuda', + phone: '1-268', + }, + { code: 'AI', label: 'Anguilla', phone: '1-264' }, + { code: 'AL', label: 'Albania', phone: '355' }, + { code: 'AM', label: 'Armenia', phone: '374' }, + { code: 'AO', label: 'Angola', phone: '244' }, + { code: 'AQ', label: 'Antarctica', phone: '672' }, + { code: 'AR', label: 'Argentina', phone: '54' }, + { code: 'AS', label: 'American Samoa', phone: '1-684' }, + { code: 'AT', label: 'Austria', phone: '43' }, + { + code: 'AU', + label: 'Australia', + phone: '61', + suggested: true, + }, + { code: 'AW', label: 'Aruba', phone: '297' }, + { code: 'AX', label: 'Alland Islands', phone: '358' }, + { code: 'AZ', label: 'Azerbaijan', phone: '994' }, + { + code: 'BA', + label: 'Bosnia and Herzegovina', + phone: '387', + }, + { code: 'BB', label: 'Barbados', phone: '1-246' }, + { code: 'BD', label: 'Bangladesh', phone: '880' }, + { code: 'BE', label: 'Belgium', phone: '32' }, + { code: 'BF', label: 'Burkina Faso', phone: '226' }, + { code: 'BG', label: 'Bulgaria', phone: '359' }, + { code: 'BH', label: 'Bahrain', phone: '973' }, + { code: 'BI', label: 'Burundi', phone: '257' }, + { code: 'BJ', label: 'Benin', phone: '229' }, + { code: 'BL', label: 'Saint Barthelemy', phone: '590' }, + { code: 'BM', label: 'Bermuda', phone: '1-441' }, + { code: 'BN', label: 'Brunei Darussalam', phone: '673' }, + { code: 'BO', label: 'Bolivia', phone: '591' }, + { code: 'BR', label: 'Brazil', phone: '55' }, + { code: 'BS', label: 'Bahamas', phone: '1-242' }, + { code: 'BT', label: 'Bhutan', phone: '975' }, + { code: 'BV', label: 'Bouvet Island', phone: '47' }, + { code: 'BW', label: 'Botswana', phone: '267' }, + { code: 'BY', label: 'Belarus', phone: '375' }, + { code: 'BZ', label: 'Belize', phone: '501' }, + { + code: 'CA', + label: 'Canada', + phone: '1', + suggested: true, + }, + { + code: 'CC', + label: 'Cocos (Keeling) Islands', + phone: '61', + }, + { + code: 'CD', + label: 'Congo, Democratic Republic of the', + phone: '243', + }, + { + code: 'CF', + label: 'Central African Republic', + phone: '236', + }, + { + code: 'CG', + label: 'Congo, Republic of the', + phone: '242', + }, + { code: 'CH', label: 'Switzerland', phone: '41' }, + { code: 'CI', label: "Cote d'Ivoire", phone: '225' }, + { code: 'CK', label: 'Cook Islands', phone: '682' }, + { code: 'CL', label: 'Chile', phone: '56' }, + { code: 'CM', label: 'Cameroon', phone: '237' }, + { code: 'CN', label: 'China', phone: '86' }, + { code: 'CO', label: 'Colombia', phone: '57' }, + { code: 'CR', label: 'Costa Rica', phone: '506' }, + { code: 'CU', label: 'Cuba', phone: '53' }, + { code: 'CV', label: 'Cape Verde', phone: '238' }, + { code: 'CW', label: 'Curacao', phone: '599' }, + { code: 'CX', label: 'Christmas Island', phone: '61' }, + { code: 'CY', label: 'Cyprus', phone: '357' }, + { code: 'CZ', label: 'Czech Republic', phone: '420' }, + { + code: 'DE', + label: 'Germany', + phone: '49', + suggested: true, + }, + { code: 'DJ', label: 'Djibouti', phone: '253' }, + { code: 'DK', label: 'Denmark', phone: '45' }, + { code: 'DM', label: 'Dominica', phone: '1-767' }, + { + code: 'DO', + label: 'Dominican Republic', + phone: '1-809', + }, + { code: 'DZ', label: 'Algeria', phone: '213' }, + { code: 'EC', label: 'Ecuador', phone: '593' }, + { code: 'EE', label: 'Estonia', phone: '372' }, + { code: 'EG', label: 'Egypt', phone: '20' }, + { code: 'EH', label: 'Western Sahara', phone: '212' }, + { code: 'ER', label: 'Eritrea', phone: '291' }, + { code: 'ES', label: 'Spain', phone: '34' }, + { code: 'ET', label: 'Ethiopia', phone: '251' }, + { code: 'FI', label: 'Finland', phone: '358' }, + { code: 'FJ', label: 'Fiji', phone: '679' }, + { + code: 'FK', + label: 'Falkland Islands (Malvinas)', + phone: '500', + }, + { + code: 'FM', + label: 'Micronesia, Federated States of', + phone: '691', + }, + { code: 'FO', label: 'Faroe Islands', phone: '298' }, + { + code: 'FR', + label: 'France', + phone: '33', + suggested: true, + }, + { code: 'GA', label: 'Gabon', phone: '241' }, + { code: 'GB', label: 'United Kingdom', phone: '44' }, + { code: 'GD', label: 'Grenada', phone: '1-473' }, + { code: 'GE', label: 'Georgia', phone: '995' }, + { code: 'GF', label: 'French Guiana', phone: '594' }, + { code: 'GG', label: 'Guernsey', phone: '44' }, + { code: 'GH', label: 'Ghana', phone: '233' }, + { code: 'GI', label: 'Gibraltar', phone: '350' }, + { code: 'GL', label: 'Greenland', phone: '299' }, + { code: 'GM', label: 'Gambia', phone: '220' }, + { code: 'GN', label: 'Guinea', phone: '224' }, + { code: 'GP', label: 'Guadeloupe', phone: '590' }, + { code: 'GQ', label: 'Equatorial Guinea', phone: '240' }, + { code: 'GR', label: 'Greece', phone: '30' }, + { + code: 'GS', + label: 'South Georgia and the South Sandwich Islands', + phone: '500', + }, + { code: 'GT', label: 'Guatemala', phone: '502' }, + { code: 'GU', label: 'Guam', phone: '1-671' }, + { code: 'GW', label: 'Guinea-Bissau', phone: '245' }, + { code: 'GY', label: 'Guyana', phone: '592' }, + { code: 'HK', label: 'Hong Kong', phone: '852' }, + { + code: 'HM', + label: 'Heard Island and McDonald Islands', + phone: '672', + }, + { code: 'HN', label: 'Honduras', phone: '504' }, + { code: 'HR', label: 'Croatia', phone: '385' }, + { code: 'HT', label: 'Haiti', phone: '509' }, + { code: 'HU', label: 'Hungary', phone: '36' }, + { code: 'ID', label: 'Indonesia', phone: '62' }, + { code: 'IE', label: 'Ireland', phone: '353' }, + { code: 'IL', label: 'Israel', phone: '972' }, + { code: 'IM', label: 'Isle of Man', phone: '44' }, + { code: 'IN', label: 'India', phone: '91' }, + { + code: 'IO', + label: 'British Indian Ocean Territory', + phone: '246', + }, + { code: 'IQ', label: 'Iraq', phone: '964' }, + { + code: 'IR', + label: 'Iran, Islamic Republic of', + phone: '98', + }, + { code: 'IS', label: 'Iceland', phone: '354' }, + { code: 'IT', label: 'Italy', phone: '39' }, + { code: 'JE', label: 'Jersey', phone: '44' }, + { code: 'JM', label: 'Jamaica', phone: '1-876' }, + { code: 'JO', label: 'Jordan', phone: '962' }, + { + code: 'JP', + label: 'Japan', + phone: '81', + suggested: true, + }, + { code: 'KE', label: 'Kenya', phone: '254' }, + { code: 'KG', label: 'Kyrgyzstan', phone: '996' }, + { code: 'KH', label: 'Cambodia', phone: '855' }, + { code: 'KI', label: 'Kiribati', phone: '686' }, + { code: 'KM', label: 'Comoros', phone: '269' }, + { + code: 'KN', + label: 'Saint Kitts and Nevis', + phone: '1-869', + }, + { + code: 'KP', + label: "Korea, Democratic People's Republic of", + phone: '850', + }, + { code: 'KR', label: 'Korea, Republic of', phone: '82' }, + { code: 'KW', label: 'Kuwait', phone: '965' }, + { code: 'KY', label: 'Cayman Islands', phone: '1-345' }, + { code: 'KZ', label: 'Kazakhstan', phone: '7' }, + { + code: 'LA', + label: "Lao People's Democratic Republic", + phone: '856', + }, + { code: 'LB', label: 'Lebanon', phone: '961' }, + { code: 'LC', label: 'Saint Lucia', phone: '1-758' }, + { code: 'LI', label: 'Liechtenstein', phone: '423' }, + { code: 'LK', label: 'Sri Lanka', phone: '94' }, + { code: 'LR', label: 'Liberia', phone: '231' }, + { code: 'LS', label: 'Lesotho', phone: '266' }, + { code: 'LT', label: 'Lithuania', phone: '370' }, + { code: 'LU', label: 'Luxembourg', phone: '352' }, + { code: 'LV', label: 'Latvia', phone: '371' }, + { code: 'LY', label: 'Libya', phone: '218' }, + { code: 'MA', label: 'Morocco', phone: '212' }, + { code: 'MC', label: 'Monaco', phone: '377' }, + { + code: 'MD', + label: 'Moldova, Republic of', + phone: '373', + }, + { code: 'ME', label: 'Montenegro', phone: '382' }, + { + code: 'MF', + label: 'Saint Martin (French part)', + phone: '590', + }, + { code: 'MG', label: 'Madagascar', phone: '261' }, + { code: 'MH', label: 'Marshall Islands', phone: '692' }, + { + code: 'MK', + label: 'Macedonia, the Former Yugoslav Republic of', + phone: '389', + }, + { code: 'ML', label: 'Mali', phone: '223' }, + { code: 'MM', label: 'Myanmar', phone: '95' }, + { code: 'MN', label: 'Mongolia', phone: '976' }, + { code: 'MO', label: 'Macao', phone: '853' }, + { + code: 'MP', + label: 'Northern Mariana Islands', + phone: '1-670', + }, + { code: 'MQ', label: 'Martinique', phone: '596' }, + { code: 'MR', label: 'Mauritania', phone: '222' }, + { code: 'MS', label: 'Montserrat', phone: '1-664' }, + { code: 'MT', label: 'Malta', phone: '356' }, + { code: 'MU', label: 'Mauritius', phone: '230' }, + { code: 'MV', label: 'Maldives', phone: '960' }, + { code: 'MW', label: 'Malawi', phone: '265' }, + { code: 'MX', label: 'Mexico', phone: '52' }, + { code: 'MY', label: 'Malaysia', phone: '60' }, + { code: 'MZ', label: 'Mozambique', phone: '258' }, + { code: 'NA', label: 'Namibia', phone: '264' }, + { code: 'NC', label: 'New Caledonia', phone: '687' }, + { code: 'NE', label: 'Niger', phone: '227' }, + { code: 'NF', label: 'Norfolk Island', phone: '672' }, + { code: 'NG', label: 'Nigeria', phone: '234' }, + { code: 'NI', label: 'Nicaragua', phone: '505' }, + { code: 'NL', label: 'Netherlands', phone: '31' }, + { code: 'NO', label: 'Norway', phone: '47' }, + { code: 'NP', label: 'Nepal', phone: '977' }, + { code: 'NR', label: 'Nauru', phone: '674' }, + { code: 'NU', label: 'Niue', phone: '683' }, + { code: 'NZ', label: 'New Zealand', phone: '64' }, + { code: 'OM', label: 'Oman', phone: '968' }, + { code: 'PA', label: 'Panama', phone: '507' }, + { code: 'PE', label: 'Peru', phone: '51' }, + { code: 'PF', label: 'French Polynesia', phone: '689' }, + { code: 'PG', label: 'Papua New Guinea', phone: '675' }, + { code: 'PH', label: 'Philippines', phone: '63' }, + { code: 'PK', label: 'Pakistan', phone: '92' }, + { code: 'PL', label: 'Poland', phone: '48' }, + { + code: 'PM', + label: 'Saint Pierre and Miquelon', + phone: '508', + }, + { code: 'PN', label: 'Pitcairn', phone: '870' }, + { code: 'PR', label: 'Puerto Rico', phone: '1' }, + { + code: 'PS', + label: 'Palestine, State of', + phone: '970', + }, + { code: 'PT', label: 'Portugal', phone: '351' }, + { code: 'PW', label: 'Palau', phone: '680' }, + { code: 'PY', label: 'Paraguay', phone: '595' }, + { code: 'QA', label: 'Qatar', phone: '974' }, + { code: 'RE', label: 'Reunion', phone: '262' }, + { code: 'RO', label: 'Romania', phone: '40' }, + { code: 'RS', label: 'Serbia', phone: '381' }, + { code: 'RU', label: 'Russian Federation', phone: '7' }, + { code: 'RW', label: 'Rwanda', phone: '250' }, + { code: 'SA', label: 'Saudi Arabia', phone: '966' }, + { code: 'SB', label: 'Solomon Islands', phone: '677' }, + { code: 'SC', label: 'Seychelles', phone: '248' }, + { code: 'SD', label: 'Sudan', phone: '249' }, + { code: 'SE', label: 'Sweden', phone: '46' }, + { code: 'SG', label: 'Singapore', phone: '65' }, + { code: 'SH', label: 'Saint Helena', phone: '290' }, + { code: 'SI', label: 'Slovenia', phone: '386' }, + { + code: 'SJ', + label: 'Svalbard and Jan Mayen', + phone: '47', + }, + { code: 'SK', label: 'Slovakia', phone: '421' }, + { code: 'SL', label: 'Sierra Leone', phone: '232' }, + { code: 'SM', label: 'San Marino', phone: '378' }, + { code: 'SN', label: 'Senegal', phone: '221' }, + { code: 'SO', label: 'Somalia', phone: '252' }, + { code: 'SR', label: 'Suriname', phone: '597' }, + { code: 'SS', label: 'South Sudan', phone: '211' }, + { + code: 'ST', + label: 'Sao Tome and Principe', + phone: '239', + }, + { code: 'SV', label: 'El Salvador', phone: '503' }, + { + code: 'SX', + label: 'Sint Maarten (Dutch part)', + phone: '1-721', + }, + { + code: 'SY', + label: 'Syrian Arab Republic', + phone: '963', + }, + { code: 'SZ', label: 'Swaziland', phone: '268' }, + { + code: 'TC', + label: 'Turks and Caicos Islands', + phone: '1-649', + }, + { code: 'TD', label: 'Chad', phone: '235' }, + { + code: 'TF', + label: 'French Southern Territories', + phone: '262', + }, + { code: 'TG', label: 'Togo', phone: '228' }, + { code: 'TH', label: 'Thailand', phone: '66' }, + { code: 'TJ', label: 'Tajikistan', phone: '992' }, + { code: 'TK', label: 'Tokelau', phone: '690' }, + { code: 'TL', label: 'Timor-Leste', phone: '670' }, + { code: 'TM', label: 'Turkmenistan', phone: '993' }, + { code: 'TN', label: 'Tunisia', phone: '216' }, + { code: 'TO', label: 'Tonga', phone: '676' }, + { code: 'TR', label: 'Turkey', phone: '90' }, + { + code: 'TT', + label: 'Trinidad and Tobago', + phone: '1-868', + }, + { code: 'TV', label: 'Tuvalu', phone: '688' }, + { + code: 'TW', + label: 'Taiwan, Province of China', + phone: '886', + }, + { + code: 'TZ', + label: 'United Republic of Tanzania', + phone: '255', + }, + { code: 'UA', label: 'Ukraine', phone: '380' }, + { code: 'UG', label: 'Uganda', phone: '256' }, + { + code: 'US', + label: 'United States', + phone: '1', + suggested: true, + }, + { code: 'UY', label: 'Uruguay', phone: '598' }, + { code: 'UZ', label: 'Uzbekistan', phone: '998' }, + { + code: 'VA', + label: 'Holy See (Vatican City State)', + phone: '379', + }, + { + code: 'VC', + label: 'Saint Vincent and the Grenadines', + phone: '1-784', + }, + { code: 'VE', label: 'Venezuela', phone: '58' }, + { + code: 'VG', + label: 'British Virgin Islands', + phone: '1-284', + }, + { + code: 'VI', + label: 'US Virgin Islands', + phone: '1-340', + }, + { code: 'VN', label: 'Vietnam', phone: '84' }, + { code: 'VU', label: 'Vanuatu', phone: '678' }, + { code: 'WF', label: 'Wallis and Futuna', phone: '681' }, + { code: 'WS', label: 'Samoa', phone: '685' }, + { code: 'XK', label: 'Kosovo', phone: '383' }, + { code: 'YE', label: 'Yemen', phone: '967' }, + { code: 'YT', label: 'Mayotte', phone: '262' }, + { code: 'ZA', label: 'South Africa', phone: '27' }, + { code: 'ZM', label: 'Zambia', phone: '260' }, + { code: 'ZW', label: 'Zimbabwe', phone: '263' }, +]; diff --git a/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx b/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx new file mode 100644 index 00000000000000..02d8e3341a3c59 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx @@ -0,0 +1,560 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import { styled } from '@mui/system'; +import { PopperUnstyled } from '@mui/base'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 480px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } + + & img { + margin-right: 10px; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 480px; + border: 1px solid #ccc; + border-top: none; + color: #000; + max-height: 400px; + overflow: auto; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } + + & img { + margin-right: 10px; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomSelect = React.forwardRef(function CustomSelect( + props: SelectUnstyledProps, + ref: React.ForwardedRef, +) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}); + +export default function UnstyledSelectRichOptions() { + return ( + + {countries.map((c) => ( + + {`Flag + {c.label} ({c.code}) +{c.phone} + + ))} + + ); +} + +const countries = [ + { code: 'AD', label: 'Andorra', phone: '376' }, + { + code: 'AE', + label: 'United Arab Emirates', + phone: '971', + }, + { code: 'AF', label: 'Afghanistan', phone: '93' }, + { + code: 'AG', + label: 'Antigua and Barbuda', + phone: '1-268', + }, + { code: 'AI', label: 'Anguilla', phone: '1-264' }, + { code: 'AL', label: 'Albania', phone: '355' }, + { code: 'AM', label: 'Armenia', phone: '374' }, + { code: 'AO', label: 'Angola', phone: '244' }, + { code: 'AQ', label: 'Antarctica', phone: '672' }, + { code: 'AR', label: 'Argentina', phone: '54' }, + { code: 'AS', label: 'American Samoa', phone: '1-684' }, + { code: 'AT', label: 'Austria', phone: '43' }, + { + code: 'AU', + label: 'Australia', + phone: '61', + suggested: true, + }, + { code: 'AW', label: 'Aruba', phone: '297' }, + { code: 'AX', label: 'Alland Islands', phone: '358' }, + { code: 'AZ', label: 'Azerbaijan', phone: '994' }, + { + code: 'BA', + label: 'Bosnia and Herzegovina', + phone: '387', + }, + { code: 'BB', label: 'Barbados', phone: '1-246' }, + { code: 'BD', label: 'Bangladesh', phone: '880' }, + { code: 'BE', label: 'Belgium', phone: '32' }, + { code: 'BF', label: 'Burkina Faso', phone: '226' }, + { code: 'BG', label: 'Bulgaria', phone: '359' }, + { code: 'BH', label: 'Bahrain', phone: '973' }, + { code: 'BI', label: 'Burundi', phone: '257' }, + { code: 'BJ', label: 'Benin', phone: '229' }, + { code: 'BL', label: 'Saint Barthelemy', phone: '590' }, + { code: 'BM', label: 'Bermuda', phone: '1-441' }, + { code: 'BN', label: 'Brunei Darussalam', phone: '673' }, + { code: 'BO', label: 'Bolivia', phone: '591' }, + { code: 'BR', label: 'Brazil', phone: '55' }, + { code: 'BS', label: 'Bahamas', phone: '1-242' }, + { code: 'BT', label: 'Bhutan', phone: '975' }, + { code: 'BV', label: 'Bouvet Island', phone: '47' }, + { code: 'BW', label: 'Botswana', phone: '267' }, + { code: 'BY', label: 'Belarus', phone: '375' }, + { code: 'BZ', label: 'Belize', phone: '501' }, + { + code: 'CA', + label: 'Canada', + phone: '1', + suggested: true, + }, + { + code: 'CC', + label: 'Cocos (Keeling) Islands', + phone: '61', + }, + { + code: 'CD', + label: 'Congo, Democratic Republic of the', + phone: '243', + }, + { + code: 'CF', + label: 'Central African Republic', + phone: '236', + }, + { + code: 'CG', + label: 'Congo, Republic of the', + phone: '242', + }, + { code: 'CH', label: 'Switzerland', phone: '41' }, + { code: 'CI', label: "Cote d'Ivoire", phone: '225' }, + { code: 'CK', label: 'Cook Islands', phone: '682' }, + { code: 'CL', label: 'Chile', phone: '56' }, + { code: 'CM', label: 'Cameroon', phone: '237' }, + { code: 'CN', label: 'China', phone: '86' }, + { code: 'CO', label: 'Colombia', phone: '57' }, + { code: 'CR', label: 'Costa Rica', phone: '506' }, + { code: 'CU', label: 'Cuba', phone: '53' }, + { code: 'CV', label: 'Cape Verde', phone: '238' }, + { code: 'CW', label: 'Curacao', phone: '599' }, + { code: 'CX', label: 'Christmas Island', phone: '61' }, + { code: 'CY', label: 'Cyprus', phone: '357' }, + { code: 'CZ', label: 'Czech Republic', phone: '420' }, + { + code: 'DE', + label: 'Germany', + phone: '49', + suggested: true, + }, + { code: 'DJ', label: 'Djibouti', phone: '253' }, + { code: 'DK', label: 'Denmark', phone: '45' }, + { code: 'DM', label: 'Dominica', phone: '1-767' }, + { + code: 'DO', + label: 'Dominican Republic', + phone: '1-809', + }, + { code: 'DZ', label: 'Algeria', phone: '213' }, + { code: 'EC', label: 'Ecuador', phone: '593' }, + { code: 'EE', label: 'Estonia', phone: '372' }, + { code: 'EG', label: 'Egypt', phone: '20' }, + { code: 'EH', label: 'Western Sahara', phone: '212' }, + { code: 'ER', label: 'Eritrea', phone: '291' }, + { code: 'ES', label: 'Spain', phone: '34' }, + { code: 'ET', label: 'Ethiopia', phone: '251' }, + { code: 'FI', label: 'Finland', phone: '358' }, + { code: 'FJ', label: 'Fiji', phone: '679' }, + { + code: 'FK', + label: 'Falkland Islands (Malvinas)', + phone: '500', + }, + { + code: 'FM', + label: 'Micronesia, Federated States of', + phone: '691', + }, + { code: 'FO', label: 'Faroe Islands', phone: '298' }, + { + code: 'FR', + label: 'France', + phone: '33', + suggested: true, + }, + { code: 'GA', label: 'Gabon', phone: '241' }, + { code: 'GB', label: 'United Kingdom', phone: '44' }, + { code: 'GD', label: 'Grenada', phone: '1-473' }, + { code: 'GE', label: 'Georgia', phone: '995' }, + { code: 'GF', label: 'French Guiana', phone: '594' }, + { code: 'GG', label: 'Guernsey', phone: '44' }, + { code: 'GH', label: 'Ghana', phone: '233' }, + { code: 'GI', label: 'Gibraltar', phone: '350' }, + { code: 'GL', label: 'Greenland', phone: '299' }, + { code: 'GM', label: 'Gambia', phone: '220' }, + { code: 'GN', label: 'Guinea', phone: '224' }, + { code: 'GP', label: 'Guadeloupe', phone: '590' }, + { code: 'GQ', label: 'Equatorial Guinea', phone: '240' }, + { code: 'GR', label: 'Greece', phone: '30' }, + { + code: 'GS', + label: 'South Georgia and the South Sandwich Islands', + phone: '500', + }, + { code: 'GT', label: 'Guatemala', phone: '502' }, + { code: 'GU', label: 'Guam', phone: '1-671' }, + { code: 'GW', label: 'Guinea-Bissau', phone: '245' }, + { code: 'GY', label: 'Guyana', phone: '592' }, + { code: 'HK', label: 'Hong Kong', phone: '852' }, + { + code: 'HM', + label: 'Heard Island and McDonald Islands', + phone: '672', + }, + { code: 'HN', label: 'Honduras', phone: '504' }, + { code: 'HR', label: 'Croatia', phone: '385' }, + { code: 'HT', label: 'Haiti', phone: '509' }, + { code: 'HU', label: 'Hungary', phone: '36' }, + { code: 'ID', label: 'Indonesia', phone: '62' }, + { code: 'IE', label: 'Ireland', phone: '353' }, + { code: 'IL', label: 'Israel', phone: '972' }, + { code: 'IM', label: 'Isle of Man', phone: '44' }, + { code: 'IN', label: 'India', phone: '91' }, + { + code: 'IO', + label: 'British Indian Ocean Territory', + phone: '246', + }, + { code: 'IQ', label: 'Iraq', phone: '964' }, + { + code: 'IR', + label: 'Iran, Islamic Republic of', + phone: '98', + }, + { code: 'IS', label: 'Iceland', phone: '354' }, + { code: 'IT', label: 'Italy', phone: '39' }, + { code: 'JE', label: 'Jersey', phone: '44' }, + { code: 'JM', label: 'Jamaica', phone: '1-876' }, + { code: 'JO', label: 'Jordan', phone: '962' }, + { + code: 'JP', + label: 'Japan', + phone: '81', + suggested: true, + }, + { code: 'KE', label: 'Kenya', phone: '254' }, + { code: 'KG', label: 'Kyrgyzstan', phone: '996' }, + { code: 'KH', label: 'Cambodia', phone: '855' }, + { code: 'KI', label: 'Kiribati', phone: '686' }, + { code: 'KM', label: 'Comoros', phone: '269' }, + { + code: 'KN', + label: 'Saint Kitts and Nevis', + phone: '1-869', + }, + { + code: 'KP', + label: "Korea, Democratic People's Republic of", + phone: '850', + }, + { code: 'KR', label: 'Korea, Republic of', phone: '82' }, + { code: 'KW', label: 'Kuwait', phone: '965' }, + { code: 'KY', label: 'Cayman Islands', phone: '1-345' }, + { code: 'KZ', label: 'Kazakhstan', phone: '7' }, + { + code: 'LA', + label: "Lao People's Democratic Republic", + phone: '856', + }, + { code: 'LB', label: 'Lebanon', phone: '961' }, + { code: 'LC', label: 'Saint Lucia', phone: '1-758' }, + { code: 'LI', label: 'Liechtenstein', phone: '423' }, + { code: 'LK', label: 'Sri Lanka', phone: '94' }, + { code: 'LR', label: 'Liberia', phone: '231' }, + { code: 'LS', label: 'Lesotho', phone: '266' }, + { code: 'LT', label: 'Lithuania', phone: '370' }, + { code: 'LU', label: 'Luxembourg', phone: '352' }, + { code: 'LV', label: 'Latvia', phone: '371' }, + { code: 'LY', label: 'Libya', phone: '218' }, + { code: 'MA', label: 'Morocco', phone: '212' }, + { code: 'MC', label: 'Monaco', phone: '377' }, + { + code: 'MD', + label: 'Moldova, Republic of', + phone: '373', + }, + { code: 'ME', label: 'Montenegro', phone: '382' }, + { + code: 'MF', + label: 'Saint Martin (French part)', + phone: '590', + }, + { code: 'MG', label: 'Madagascar', phone: '261' }, + { code: 'MH', label: 'Marshall Islands', phone: '692' }, + { + code: 'MK', + label: 'Macedonia, the Former Yugoslav Republic of', + phone: '389', + }, + { code: 'ML', label: 'Mali', phone: '223' }, + { code: 'MM', label: 'Myanmar', phone: '95' }, + { code: 'MN', label: 'Mongolia', phone: '976' }, + { code: 'MO', label: 'Macao', phone: '853' }, + { + code: 'MP', + label: 'Northern Mariana Islands', + phone: '1-670', + }, + { code: 'MQ', label: 'Martinique', phone: '596' }, + { code: 'MR', label: 'Mauritania', phone: '222' }, + { code: 'MS', label: 'Montserrat', phone: '1-664' }, + { code: 'MT', label: 'Malta', phone: '356' }, + { code: 'MU', label: 'Mauritius', phone: '230' }, + { code: 'MV', label: 'Maldives', phone: '960' }, + { code: 'MW', label: 'Malawi', phone: '265' }, + { code: 'MX', label: 'Mexico', phone: '52' }, + { code: 'MY', label: 'Malaysia', phone: '60' }, + { code: 'MZ', label: 'Mozambique', phone: '258' }, + { code: 'NA', label: 'Namibia', phone: '264' }, + { code: 'NC', label: 'New Caledonia', phone: '687' }, + { code: 'NE', label: 'Niger', phone: '227' }, + { code: 'NF', label: 'Norfolk Island', phone: '672' }, + { code: 'NG', label: 'Nigeria', phone: '234' }, + { code: 'NI', label: 'Nicaragua', phone: '505' }, + { code: 'NL', label: 'Netherlands', phone: '31' }, + { code: 'NO', label: 'Norway', phone: '47' }, + { code: 'NP', label: 'Nepal', phone: '977' }, + { code: 'NR', label: 'Nauru', phone: '674' }, + { code: 'NU', label: 'Niue', phone: '683' }, + { code: 'NZ', label: 'New Zealand', phone: '64' }, + { code: 'OM', label: 'Oman', phone: '968' }, + { code: 'PA', label: 'Panama', phone: '507' }, + { code: 'PE', label: 'Peru', phone: '51' }, + { code: 'PF', label: 'French Polynesia', phone: '689' }, + { code: 'PG', label: 'Papua New Guinea', phone: '675' }, + { code: 'PH', label: 'Philippines', phone: '63' }, + { code: 'PK', label: 'Pakistan', phone: '92' }, + { code: 'PL', label: 'Poland', phone: '48' }, + { + code: 'PM', + label: 'Saint Pierre and Miquelon', + phone: '508', + }, + { code: 'PN', label: 'Pitcairn', phone: '870' }, + { code: 'PR', label: 'Puerto Rico', phone: '1' }, + { + code: 'PS', + label: 'Palestine, State of', + phone: '970', + }, + { code: 'PT', label: 'Portugal', phone: '351' }, + { code: 'PW', label: 'Palau', phone: '680' }, + { code: 'PY', label: 'Paraguay', phone: '595' }, + { code: 'QA', label: 'Qatar', phone: '974' }, + { code: 'RE', label: 'Reunion', phone: '262' }, + { code: 'RO', label: 'Romania', phone: '40' }, + { code: 'RS', label: 'Serbia', phone: '381' }, + { code: 'RU', label: 'Russian Federation', phone: '7' }, + { code: 'RW', label: 'Rwanda', phone: '250' }, + { code: 'SA', label: 'Saudi Arabia', phone: '966' }, + { code: 'SB', label: 'Solomon Islands', phone: '677' }, + { code: 'SC', label: 'Seychelles', phone: '248' }, + { code: 'SD', label: 'Sudan', phone: '249' }, + { code: 'SE', label: 'Sweden', phone: '46' }, + { code: 'SG', label: 'Singapore', phone: '65' }, + { code: 'SH', label: 'Saint Helena', phone: '290' }, + { code: 'SI', label: 'Slovenia', phone: '386' }, + { + code: 'SJ', + label: 'Svalbard and Jan Mayen', + phone: '47', + }, + { code: 'SK', label: 'Slovakia', phone: '421' }, + { code: 'SL', label: 'Sierra Leone', phone: '232' }, + { code: 'SM', label: 'San Marino', phone: '378' }, + { code: 'SN', label: 'Senegal', phone: '221' }, + { code: 'SO', label: 'Somalia', phone: '252' }, + { code: 'SR', label: 'Suriname', phone: '597' }, + { code: 'SS', label: 'South Sudan', phone: '211' }, + { + code: 'ST', + label: 'Sao Tome and Principe', + phone: '239', + }, + { code: 'SV', label: 'El Salvador', phone: '503' }, + { + code: 'SX', + label: 'Sint Maarten (Dutch part)', + phone: '1-721', + }, + { + code: 'SY', + label: 'Syrian Arab Republic', + phone: '963', + }, + { code: 'SZ', label: 'Swaziland', phone: '268' }, + { + code: 'TC', + label: 'Turks and Caicos Islands', + phone: '1-649', + }, + { code: 'TD', label: 'Chad', phone: '235' }, + { + code: 'TF', + label: 'French Southern Territories', + phone: '262', + }, + { code: 'TG', label: 'Togo', phone: '228' }, + { code: 'TH', label: 'Thailand', phone: '66' }, + { code: 'TJ', label: 'Tajikistan', phone: '992' }, + { code: 'TK', label: 'Tokelau', phone: '690' }, + { code: 'TL', label: 'Timor-Leste', phone: '670' }, + { code: 'TM', label: 'Turkmenistan', phone: '993' }, + { code: 'TN', label: 'Tunisia', phone: '216' }, + { code: 'TO', label: 'Tonga', phone: '676' }, + { code: 'TR', label: 'Turkey', phone: '90' }, + { + code: 'TT', + label: 'Trinidad and Tobago', + phone: '1-868', + }, + { code: 'TV', label: 'Tuvalu', phone: '688' }, + { + code: 'TW', + label: 'Taiwan, Province of China', + phone: '886', + }, + { + code: 'TZ', + label: 'United Republic of Tanzania', + phone: '255', + }, + { code: 'UA', label: 'Ukraine', phone: '380' }, + { code: 'UG', label: 'Uganda', phone: '256' }, + { + code: 'US', + label: 'United States', + phone: '1', + suggested: true, + }, + { code: 'UY', label: 'Uruguay', phone: '598' }, + { code: 'UZ', label: 'Uzbekistan', phone: '998' }, + { + code: 'VA', + label: 'Holy See (Vatican City State)', + phone: '379', + }, + { + code: 'VC', + label: 'Saint Vincent and the Grenadines', + phone: '1-784', + }, + { code: 'VE', label: 'Venezuela', phone: '58' }, + { + code: 'VG', + label: 'British Virgin Islands', + phone: '1-284', + }, + { + code: 'VI', + label: 'US Virgin Islands', + phone: '1-340', + }, + { code: 'VN', label: 'Vietnam', phone: '84' }, + { code: 'VU', label: 'Vanuatu', phone: '678' }, + { code: 'WF', label: 'Wallis and Futuna', phone: '681' }, + { code: 'WS', label: 'Samoa', phone: '685' }, + { code: 'XK', label: 'Kosovo', phone: '383' }, + { code: 'YE', label: 'Yemen', phone: '967' }, + { code: 'YT', label: 'Mayotte', phone: '262' }, + { code: 'ZA', label: 'South Africa', phone: '27' }, + { code: 'ZM', label: 'Zambia', phone: '260' }, + { code: 'ZW', label: 'Zimbabwe', phone: '263' }, +]; diff --git a/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx.preview new file mode 100644 index 00000000000000..ec1c23c1a6d76d --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectRichOptions.tsx.preview @@ -0,0 +1,14 @@ + + {countries.map((c) => ( + + {`Flag + {c.label} ({c.code}) +{c.phone} + + ))} + \ No newline at end of file diff --git a/docs/src/pages/components/selects/UnstyledSelectSimple.js b/docs/src/pages/components/selects/UnstyledSelectSimple.js new file mode 100644 index 00000000000000..35dd64bdf590e5 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectSimple.js @@ -0,0 +1,110 @@ +import * as React from 'react'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomSelect = React.forwardRef(function CustomSelect(props, ref) { + const components = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}); + +export default function UnstyledSelectSimple() { + return ( + + Ten + Twenty + Thirty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectSimple.tsx b/docs/src/pages/components/selects/UnstyledSelectSimple.tsx new file mode 100644 index 00000000000000..4168de092201e5 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectSimple.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import SelectUnstyled, { + SelectUnstyledProps, + selectUnstyledClasses, +} from '@mui/base/SelectUnstyled'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const StyledButton = styled('button')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: #fff; + border: 1px solid #ccc; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: #000; + + &.${selectUnstyledClasses.focusVisible} { + outline: 4px solid rgba(100, 100, 100, 0.3); + } + + &.${selectUnstyledClasses.expanded} { + border-radius: 0.75em 0.75em 0 0; + + &::after { + content: '▴'; + } + } + + &::after { + content: '▾'; + float: right; + } +`; + +const StyledListbox = styled('ul')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 0; + margin: 0; + background-color: #fff; + min-width: 200px; + border: 1px solid #ccc; + border-top: none; + color: #000; +`; + +const StyledOption = styled(OptionUnstyled)` + list-style: none; + padding: 4px 10px; + margin: 0; + border-bottom: 1px solid #ddd; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${optionUnstyledClasses.disabled} { + color: #888; + } + + &.${optionUnstyledClasses.selected} { + background-color: rgba(25, 118, 210, 0.08); + } + + &.${optionUnstyledClasses.highlighted} { + background-color: #16d; + color: #fff; + } + + &.${optionUnstyledClasses.highlighted}.${optionUnstyledClasses.selected} { + background-color: #05e; + color: #fff; + } + + &:hover:not(.${optionUnstyledClasses.disabled}) { + background-color: #39e; + } +`; + +const StyledPopper = styled(PopperUnstyled)` + z-index: 1; +`; + +const CustomSelect = React.forwardRef(function CustomSelect( + props: SelectUnstyledProps, + ref: React.ForwardedRef, +) { + const components: SelectUnstyledProps['components'] = { + Root: StyledButton, + Listbox: StyledListbox, + Popper: StyledPopper, + ...props.components, + }; + + return ; +}) as ( + props: SelectUnstyledProps & React.RefAttributes, +) => JSX.Element; + +export default function UnstyledSelectSimple() { + return ( + + Ten + Twenty + Thirty + + ); +} diff --git a/docs/src/pages/components/selects/UnstyledSelectSimple.tsx.preview b/docs/src/pages/components/selects/UnstyledSelectSimple.tsx.preview new file mode 100644 index 00000000000000..d2b6f081a69b69 --- /dev/null +++ b/docs/src/pages/components/selects/UnstyledSelectSimple.tsx.preview @@ -0,0 +1,5 @@ + + Ten + Twenty + Thirty + \ No newline at end of file diff --git a/docs/src/pages/components/selects/UseSelect.js b/docs/src/pages/components/selects/UseSelect.js new file mode 100644 index 00000000000000..b6949b99e992fa --- /dev/null +++ b/docs/src/pages/components/selects/UseSelect.js @@ -0,0 +1,127 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useSelect } from '@mui/base'; +import { styled } from '@mui/system'; + +const Root = styled('div')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + position: relative; + display: inline-block; + vertical-align: baseline; + color: #000; +`; + +const Toggle = styled('div')` + min-width: 150px; + min-height: calc(1.5em + 10px); + padding: 5px; + background-color: var(--color, #333); + box-shadow: 0 5px 13px -3px var(--color, #333); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + cursor: default; + transition: background-color 0.2s ease, box-shadow 0.2s ease; + + & .placeholder { + opacity: 0.8; + } +`; + +const Listbox = styled('ul')` + background: #eee; + list-style: none; + padding: 0; + margin: 10px 0 0 0; + position: absolute; + height: auto; + transition: opacity 0.1s ease; + width: 100%; + box-shadow: 0 5px 13px -3px #333; + + &.hidden { + opacity: 0; + visibility: hidden; + transition: opacity 0.4s 0.5s ease, visibility 0.4s 0.5s step-end; + } + + & > li { + padding: 5px; + + &:hover { + background: #ccc; + } + + &[aria-selected='true'] { + background: #ccc; + } + } +`; + +function CustomSelect({ options, placeholder }) { + const listboxRef = React.useRef(null); + const [listboxVisible, setListboxVisible] = React.useState(false); + + const { getButtonProps, getListboxProps, getOptionProps, value } = useSelect({ + listboxRef, + options, + }); + + React.useEffect(() => { + if (listboxVisible) { + listboxRef.current?.focus(); + } + }, [listboxVisible]); + + return ( + setListboxVisible(true)} + onMouseOut={() => setListboxVisible(false)} + onFocus={() => setListboxVisible(true)} + onBlur={() => setListboxVisible(false)} + > + + {value ?? {placeholder ?? ' '}} + + + {options.map((option) => ( +
  • + {option.label} +
  • + ))} +
    +
    + ); +} + +CustomSelect.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + disabled: PropTypes.bool, + label: PropTypes.node, + value: PropTypes.string.isRequired, + }), + ).isRequired, + placeholder: PropTypes.string, +}; + +const options = [ + { + label: 'Red', + value: '#D32F2F', + }, + { + label: 'Green', + value: '#4CAF50', + }, + { + label: 'Blue', + value: '#2196F3', + }, +]; + +export default function UseSelect() { + return ; +} diff --git a/docs/src/pages/components/selects/UseSelect.tsx b/docs/src/pages/components/selects/UseSelect.tsx new file mode 100644 index 00000000000000..0ceaef16a6f8e4 --- /dev/null +++ b/docs/src/pages/components/selects/UseSelect.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { useSelect, SelectOption } from '@mui/base'; +import { styled } from '@mui/system'; + +const Root = styled('div')` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + position: relative; + display: inline-block; + vertical-align: baseline; + color: #000; +`; + +const Toggle = styled('div')` + min-width: 150px; + min-height: calc(1.5em + 10px); + padding: 5px; + background-color: var(--color, #333); + box-shadow: 0 5px 13px -3px var(--color, #333); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + cursor: default; + transition: background-color 0.2s ease, box-shadow 0.2s ease; + + & .placeholder { + opacity: 0.8; + } +`; + +const Listbox = styled('ul')` + background: #eee; + list-style: none; + padding: 0; + margin: 10px 0 0 0; + position: absolute; + height: auto; + transition: opacity 0.1s ease; + width: 100%; + box-shadow: 0 5px 13px -3px #333; + + &.hidden { + opacity: 0; + visibility: hidden; + transition: opacity 0.4s 0.5s ease, visibility 0.4s 0.5s step-end; + } + + & > li { + padding: 5px; + + &:hover { + background: #ccc; + } + + &[aria-selected='true'] { + background: #ccc; + } + } +`; + +interface Props { + options: SelectOption[]; + placeholder?: string; +} + +function CustomSelect({ options, placeholder }: Props) { + const listboxRef = React.useRef(null); + const [listboxVisible, setListboxVisible] = React.useState(false); + + const { getButtonProps, getListboxProps, getOptionProps, value } = useSelect({ + listboxRef, + options, + }); + + React.useEffect(() => { + if (listboxVisible) { + listboxRef.current?.focus(); + } + }, [listboxVisible]); + + return ( + setListboxVisible(true)} + onMouseOut={() => setListboxVisible(false)} + onFocus={() => setListboxVisible(true)} + onBlur={() => setListboxVisible(false)} + > + + {value ?? {placeholder ?? ' '}} + + + {options.map((option) => ( +
  • + {option.label} +
  • + ))} +
    +
    + ); +} + +const options = [ + { + label: 'Red', + value: '#D32F2F', + }, + { + label: 'Green', + value: '#4CAF50', + }, + { + label: 'Blue', + value: '#2196F3', + }, +]; + +export default function UseSelect() { + return ; +} diff --git a/docs/src/pages/components/selects/UseSelect.tsx.preview b/docs/src/pages/components/selects/UseSelect.tsx.preview new file mode 100644 index 00000000000000..f9beace90368f3 --- /dev/null +++ b/docs/src/pages/components/selects/UseSelect.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/src/pages/components/selects/selects.md b/docs/src/pages/components/selects/selects.md index 986ef7a93e736d..140693c6dd4a05 100644 --- a/docs/src/pages/components/selects/selects.md +++ b/docs/src/pages/components/selects/selects.md @@ -1,6 +1,6 @@ --- title: React Select component -components: Select, NativeSelect +components: Select, NativeSelect, SelectUnstyled, MultiSelectUnstyled, OptionUnstyled, OptionGroupUnstyled githubLabel: 'component: select' --- @@ -149,3 +149,84 @@ For a [native select](#native-select), you should mention a label by giving the ``` + +## Unstyled + +The Select also comes with an unstyled version. +It's ideal for doing heavy customizations and minimizing bundle size. + +### Unstyled component + +```jsx +import SelectUnstyled from '@mui/base/SelectUnstyled'; +``` + +#### Basic usage + +{{"demo": "pages/components/selects/UnstyledSelectSimple.js"}} + +The `SelectUnstyled` is a component that accepts generic props. +Due to Typescript limitations, this may cause unexpected behavior when wrapping the component in `forwardRef` (or other higher-order components). +In such cases, the generic argument will be defaulted to `unknown` and type suggestions will be incomplete. +To avoid this, manually cast the resulting component to the correct type (as shown above). + +The rest of the demos below will not use `forwardRef` for brevity. + +#### Controlled select + +The SelectUnstyled can be used as either uncontrolled (as shown in the demo above) or controlled component. + +{{"demo": "pages/components/selects/UnstyledSelectControlled.js"}} + +#### Usage with object values + +The unstyled select may be used with non-string values. + +{{"demo": "pages/components/selects/UnstyledSelectObjectValues.js"}} + +#### Customizing the selected value appearance + +It is possible to customize the selected value display by providing a function to the `renderValue` prop. +The element returned by this function will be rendered inside the select's button. + +{{"demo": "pages/components/selects/UnstyledSelectCustomRenderValue.js"}} + +#### Customizing the options' appearance + +Options don't have to be plain strings. +You can include custom elements to be rendered inside the listbox. + +{{"demo": "pages/components/selects/UnstyledSelectRichOptions.js"}} + +#### Grouping + +Options can be grouped, similarly to the how the native `select` element works. +Unlike the native `select`, however, the groups can be nested. + +Place the `Option` components inside `OptionGroup` to achieve this. + +{{"demo": "pages/components/selects/UnstyledSelectGrouping.js"}} + +#### Multiselect + +To be able to select multiple options at once, use the `MultiSelectUnstyled` component. + +```js +import { MultiSelectUnstyled } from '@mui/base/SelectUnstyled'; +``` + +{{"demo": "pages/components/selects/UnstyledSelectMultiple.js"}} + +### useSelect hook + +```js +import { useSelect } from '@mui/base/SelectUnstyled'; +``` + +If you need to use Select's functionality in another component, you can use the `useSelect` hook. +It enables maximal customizability at the cost of being low-level. + +The following example shows a select that opens when hovered over or focused. +It can be controlled by a mouse/touch or a keyboard. + +{{"demo": "pages/components/selects/UseSelect.js"}} diff --git a/docs/src/pagesApi.js b/docs/src/pagesApi.js index cfd631af9b7f5b..2f4500fca52a6c 100644 --- a/docs/src/pagesApi.js +++ b/docs/src/pagesApi.js @@ -97,8 +97,11 @@ module.exports = [ { pathname: '/api-docs/modal' }, { pathname: '/api-docs/modal-unstyled' }, { pathname: '/api-docs/month-picker' }, + { pathname: '/api-docs/multi-select-unstyled' }, { pathname: '/api-docs/native-select' }, { pathname: '/api-docs/no-ssr' }, + { pathname: '/api-docs/option-group-unstyled' }, + { pathname: '/api-docs/option-unstyled' }, { pathname: '/api-docs/outlined-input' }, { pathname: '/api-docs/pagination' }, { pathname: '/api-docs/pagination-item' }, @@ -113,6 +116,7 @@ module.exports = [ { pathname: '/api-docs/rating' }, { pathname: '/api-docs/scoped-css-baseline' }, { pathname: '/api-docs/select' }, + { pathname: '/api-docs/select-unstyled' }, { pathname: '/api-docs/skeleton' }, { pathname: '/api-docs/slide' }, { pathname: '/api-docs/slider' }, diff --git a/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-pt.json b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-pt.json new file mode 100644 index 00000000000000..62188d39c83fbe --- /dev/null +++ b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-pt.json @@ -0,0 +1,18 @@ +{ + "componentDescription": "", + "propDescriptions": { + "autoFocus": "If true, the select element is focused during the first mount", + "components": "The components used for each slot inside the Select. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "defaultListboxOpen": "If true, the select will be initially open.", + "defaultValue": "The default selected value. Use when the component is not controlled.", + "disabled": "If true, the select is disabled.", + "listboxId": "Id of the listbox element.", + "listboxOpen": "Controls the open state of the select's listbox.", + "onChange": "Callback fired when an option is selected.", + "onListboxOpenChange": "Callback fired when the component requests to be opened. Use in controlled mode (see listboxOpen).", + "renderValue": "Function that customizes the rendering of the selected values.", + "value": "The selected values. Set to an empty array to deselect all options." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-zh.json b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-zh.json new file mode 100644 index 00000000000000..62188d39c83fbe --- /dev/null +++ b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled-zh.json @@ -0,0 +1,18 @@ +{ + "componentDescription": "", + "propDescriptions": { + "autoFocus": "If true, the select element is focused during the first mount", + "components": "The components used for each slot inside the Select. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "defaultListboxOpen": "If true, the select will be initially open.", + "defaultValue": "The default selected value. Use when the component is not controlled.", + "disabled": "If true, the select is disabled.", + "listboxId": "Id of the listbox element.", + "listboxOpen": "Controls the open state of the select's listbox.", + "onChange": "Callback fired when an option is selected.", + "onListboxOpenChange": "Callback fired when the component requests to be opened. Use in controlled mode (see listboxOpen).", + "renderValue": "Function that customizes the rendering of the selected values.", + "value": "The selected values. Set to an empty array to deselect all options." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled.json b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled.json new file mode 100644 index 00000000000000..391b4a4265a624 --- /dev/null +++ b/docs/translations/api-docs/multi-select-unstyled/multi-select-unstyled.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "The foundation for building custom-styled multi-selection select components.", + "propDescriptions": { + "autoFocus": "If true, the select element is focused during the first mount", + "components": "The components used for each slot inside the Select. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "defaultListboxOpen": "If true, the select will be initially open.", + "defaultValue": "The default selected values. Use when the component is not controlled.", + "disabled": "If true, the select is disabled.", + "listboxOpen": "Controls the open state of the select's listbox.", + "onChange": "Callback fired when an option is selected.", + "onListboxOpenChange": "Callback fired when the component requests to be opened. Use in controlled mode (see listboxOpen).", + "renderValue": "Function that customizes the rendering of the selected values.", + "value": "The selected values. Set to an empty array to deselect all options." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-pt.json b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-pt.json new file mode 100644 index 00000000000000..aa4eef14157bb7 --- /dev/null +++ b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-pt.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionGroupUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true all the options in the group will be disabled.", + "label": "The human-readable description of the group." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-zh.json b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-zh.json new file mode 100644 index 00000000000000..aa4eef14157bb7 --- /dev/null +++ b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled-zh.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionGroupUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true all the options in the group will be disabled.", + "label": "The human-readable description of the group." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-group-unstyled/option-group-unstyled.json b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled.json new file mode 100644 index 00000000000000..66669de701e028 --- /dev/null +++ b/docs/translations/api-docs/option-group-unstyled/option-group-unstyled.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "An unstyled option group to be used within a SelectUnstyled.", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionGroupUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true all the options in the group will be disabled.", + "label": "The human-readable description of the group." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-unstyled/option-unstyled-pt.json b/docs/translations/api-docs/option-unstyled/option-unstyled-pt.json new file mode 100644 index 00000000000000..6686a68363a724 --- /dev/null +++ b/docs/translations/api-docs/option-unstyled/option-unstyled-pt.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true, the option will be disabled.", + "value": "The value of the option." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-unstyled/option-unstyled-zh.json b/docs/translations/api-docs/option-unstyled/option-unstyled-zh.json new file mode 100644 index 00000000000000..6686a68363a724 --- /dev/null +++ b/docs/translations/api-docs/option-unstyled/option-unstyled-zh.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true, the option will be disabled.", + "value": "The value of the option." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/option-unstyled/option-unstyled.json b/docs/translations/api-docs/option-unstyled/option-unstyled.json new file mode 100644 index 00000000000000..4761e640210511 --- /dev/null +++ b/docs/translations/api-docs/option-unstyled/option-unstyled.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "An unstyled option to be used within a SelectUnstyled.", + "propDescriptions": { + "component": "The component used for the Root slot. Either a string to use a HTML element or a component. This is equivalent to components.Root. If both are provided, the component is used.", + "components": "The components used for each slot inside the OptionUnstyled. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "disabled": "If true, the option will be disabled.", + "value": "The value of the option." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/select-unstyled/select-unstyled-pt.json b/docs/translations/api-docs/select-unstyled/select-unstyled-pt.json new file mode 100644 index 00000000000000..c23460f10014e5 --- /dev/null +++ b/docs/translations/api-docs/select-unstyled/select-unstyled-pt.json @@ -0,0 +1,8 @@ +{ + "componentDescription": "The foundation for building custom-styled select components.", + "propDescriptions": { + "multiple": "If true, it will be possible to select multiple values.", + "value": "The selected value. Set to null to deselect all options." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/select-unstyled/select-unstyled-zh.json b/docs/translations/api-docs/select-unstyled/select-unstyled-zh.json new file mode 100644 index 00000000000000..c23460f10014e5 --- /dev/null +++ b/docs/translations/api-docs/select-unstyled/select-unstyled-zh.json @@ -0,0 +1,8 @@ +{ + "componentDescription": "The foundation for building custom-styled select components.", + "propDescriptions": { + "multiple": "If true, it will be possible to select multiple values.", + "value": "The selected value. Set to null to deselect all options." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/select-unstyled/select-unstyled.json b/docs/translations/api-docs/select-unstyled/select-unstyled.json new file mode 100644 index 00000000000000..0968fcc67cb526 --- /dev/null +++ b/docs/translations/api-docs/select-unstyled/select-unstyled.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "The foundation for building custom-styled select components.", + "propDescriptions": { + "autoFocus": "If true, the select element is focused during the first mount", + "components": "The components used for each slot inside the Select. Either a string to use a HTML element or a component.", + "componentsProps": "The props used for each slot inside the Input.", + "defaultListboxOpen": "If true, the select will be initially open.", + "defaultValue": "The default selected value. Use when the component is not controlled.", + "disabled": "If true, the select is disabled.", + "listboxOpen": "Controls the open state of the select's listbox.", + "onChange": "Callback fired when an option is selected.", + "onListboxOpenChange": "Callback fired when the component requests to be opened. Use in controlled mode (see listboxOpen).", + "renderValue": "Function that customizes the rendering of the selected value.", + "value": "The selected value. Set to null to deselect all options." + }, + "classDescriptions": {} +} diff --git a/packages/mui-base/src/ButtonUnstyled/ButtonUnstyled.tsx b/packages/mui-base/src/ButtonUnstyled/ButtonUnstyled.tsx index b2143362f1816c..1bbf7959bdd1c9 100644 --- a/packages/mui-base/src/ButtonUnstyled/ButtonUnstyled.tsx +++ b/packages/mui-base/src/ButtonUnstyled/ButtonUnstyled.tsx @@ -152,10 +152,6 @@ ButtonUnstyled.propTypes /* remove-proptypes */ = { * @default false */ disabled: PropTypes.bool, - /** - * @ignore - */ - onClick: PropTypes.func, /** * @ignore */ diff --git a/packages/mui-base/src/ButtonUnstyled/UseButtonProps.ts b/packages/mui-base/src/ButtonUnstyled/UseButtonProps.ts index a9c7457c472217..b55873c6c7f321 100644 --- a/packages/mui-base/src/ButtonUnstyled/UseButtonProps.ts +++ b/packages/mui-base/src/ButtonUnstyled/UseButtonProps.ts @@ -22,9 +22,8 @@ export default interface UseButtonProps { */ disabled?: boolean; href?: string; - onClick?: React.MouseEventHandler; onFocusVisible?: React.FocusEventHandler; - ref: React.Ref; + ref?: React.Ref; tabIndex?: NonNullable['tabIndex']>; to?: string; /** diff --git a/packages/mui-base/src/ButtonUnstyled/useButton.ts b/packages/mui-base/src/ButtonUnstyled/useButton.ts index 637205c407733e..99ca1fee1acdac 100644 --- a/packages/mui-base/src/ButtonUnstyled/useButton.ts +++ b/packages/mui-base/src/ButtonUnstyled/useButton.ts @@ -98,6 +98,12 @@ export default function useButton(props: UseButtonProps) { const createHandleKeyDown = (otherHandlers: Record>) => (event: React.KeyboardEvent) => { + otherHandlers.onKeyDown?.(event); + + if (event.defaultPrevented) { + return; + } + if (event.target === event.currentTarget && isNonNativeButton() && event.key === ' ') { event.preventDefault(); } @@ -106,8 +112,6 @@ export default function useButton(props: UseButtonProps) { setActive(true); } - otherHandlers.onKeyDown?.(event); - // Keyboard accessibility for non interactive elements if ( event.target === event.currentTarget && @@ -115,8 +119,8 @@ export default function useButton(props: UseButtonProps) { event.key === 'Enter' && !disabled ) { - event.preventDefault(); otherHandlers.onClick?.(event); + event.preventDefault(); } }; @@ -153,6 +157,7 @@ export default function useButton(props: UseButtonProps) { }; const buttonProps: Record = {}; + if (hostElementName === 'BUTTON') { buttonProps.type = type ?? 'button'; buttonProps.disabled = disabled; @@ -191,9 +196,9 @@ export default function useButton(props: UseButtonProps) { return { tabIndex: disabled ? -1 : tabIndex, type, - ref: updateRef, ...buttonProps, ...mergedEventHandlers, + ref: updateRef, }; }; diff --git a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts new file mode 100644 index 00000000000000..9c2afbefd85cee --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts @@ -0,0 +1,316 @@ +import { expect } from 'chai'; +import { ActionTypes, ListboxAction, ListboxState } from './types'; +import defaultReducer from './defaultListboxReducer'; + +describe('useListbox defaultReducer', () => { + describe('action: setControlledValue', () => { + it("assigns the provided value to the state's selectedValue", () => { + const state: ListboxState = { + highlightedIndex: 42, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.setControlledValue, + value: 'foo', + props: { + options: [], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + const result = defaultReducer(state, action); + expect(result.selectedValue).to.equal('foo'); + }); + }); + + describe('action: blur', () => { + it('resets the highlightedIndex', () => { + const state: ListboxState = { + highlightedIndex: 42, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.blur, + event: {} as any, // not relevant + props: { + options: [], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(-1); + }); + }); + + describe('action: optionClick', () => { + it('sets the selectedValue to the clicked value', () => { + const state: ListboxState = { + highlightedIndex: 42, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.optionClick, + event: {} as any, // not relevant + props: { + options: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: false, + }, + option: 'two', + }; + + const result = defaultReducer(state, action); + expect(result.selectedValue).to.equal('two'); + }); + + it('add the clicked value to the selection if selectMultiple is set', () => { + const state: ListboxState = { + highlightedIndex: 42, + selectedValue: ['one'], + }; + + const action: ListboxAction = { + type: ActionTypes.optionClick, + event: {} as any, // not relevant + props: { + options: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: true, + }, + option: 'two', + }; + + const result = defaultReducer(state, action); + expect(result.selectedValue).to.deep.equal(['one', 'two']); + }); + + it('remove the clicked value from the selection if selectMultiple is set and it was selected already', () => { + const state: ListboxState = { + highlightedIndex: 42, + selectedValue: ['one', 'two'], + }; + + const action: ListboxAction = { + type: ActionTypes.optionClick, + event: {} as any, // not relevant + props: { + options: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: true, + }, + option: 'two', + }; + + const result = defaultReducer(state, action); + expect(result.selectedValue).to.deep.equal(['one']); + }); + }); + + describe('action: keyDown', () => { + describe('Home key is pressed', () => { + it('highlights the first non-disabled option if the first is disabled', () => { + const state: ListboxState = { + highlightedIndex: 3, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'Home', + } as any, + props: { + options: ['one', 'two', 'three', 'four', 'five'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: (_, index) => index === 0, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(1); + }); + }); + + describe('End key is pressed', () => { + it('highlights the last non-disabled option if the last is disabled', () => { + const state: ListboxState = { + highlightedIndex: 0, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'End', + } as any, + props: { + options: ['one', 'two', 'three', 'four', 'five'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: (_, index) => index === 4, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(3); + }); + }); + + describe('ArrowUp key is pressed', () => { + it('wraps the highlight around omitting disabled items', () => { + const state: ListboxState = { + highlightedIndex: 1, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'ArrowUp', + } as any, + props: { + options: ['one', 'two', 'three', 'four', 'five'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: (_, index) => index === 0 || index === 4, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(3); + }); + }); + + describe('ArrowDown key is pressed', () => { + it('wraps the highlight around omitting disabled items', () => { + const state: ListboxState = { + highlightedIndex: 3, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'ArrowDown', + } as any, + props: { + options: ['one', 'two', 'three', 'four', 'five'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: (_, index) => index === 0 || index === 4, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(1); + }); + + it('does not highlight any option if all are disabled', () => { + const state: ListboxState = { + highlightedIndex: -1, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'ArrowDown', + } as any, + props: { + options: ['one', 'two', 'three', 'four', 'five'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => true, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.highlightedIndex).to.equal(-1); + }); + }); + + describe('Enter key is pressed', () => { + it('selects the highlighted option', () => { + const state: ListboxState = { + highlightedIndex: 1, + selectedValue: null, + }; + + const action: ListboxAction = { + type: ActionTypes.keyDown, + event: { + key: 'Enter', + } as any, + props: { + options: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: false, + }, + }; + + const result = defaultReducer(state, action); + expect(result.selectedValue).to.equal('two'); + }); + + it('add the highlighted value to the selection if selectMultiple is set', () => { + const state: ListboxState = { + highlightedIndex: 1, + selectedValue: ['one'], + }; + + const action: ListboxAction = { + type: ActionTypes.optionClick, + event: { + key: 'Enter', + } as any, + props: { + options: ['one', 'two', 'three'], + disableListWrap: false, + disabledItemsFocusable: false, + isOptionDisabled: () => false, + optionComparer: (o, v) => o === v, + multiple: true, + }, + option: 'two', + }; + + const result = defaultReducer(state, action); + expect(result.selectedValue).to.deep.equal(['one', 'two']); + }); + }); + }); +}); diff --git a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts new file mode 100644 index 00000000000000..c788fa993b7731 --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts @@ -0,0 +1,277 @@ +import React from 'react'; +import { ListboxState, UseListboxStrictProps, ListboxAction, ActionTypes } from './types'; + +type OptionPredicate = (option: TOption, index: number) => boolean; + +const pageSize = 5; + +function findValidOptionToHighlight( + index: number, + lookupDirection: 'next' | 'previous', + options: TOption[], + focusDisabled: boolean, + isOptionDisabled: OptionPredicate, + wrapAround: boolean, +): number { + if (options.length === 0 || options.every((o, i) => isOptionDisabled(o, i))) { + return -1; + } + + let nextFocus = index; + + for (;;) { + // No valid options found + if ( + (!wrapAround && lookupDirection === 'next' && nextFocus === options.length) || + (!wrapAround && lookupDirection === 'previous' && nextFocus === -1) + ) { + return -1; + } + + const nextFocusDisabled = focusDisabled + ? false + : isOptionDisabled(options[nextFocus], nextFocus); + if (nextFocusDisabled) { + nextFocus += lookupDirection === 'next' ? 1 : -1; + if (wrapAround) { + nextFocus = (nextFocus + options.length) % options.length; + } + } else { + return nextFocus; + } + } +} + +function getNewHighlightedIndex( + options: TOption[], + previouslyHighlightedIndex: number, + diff: number | 'reset' | 'start' | 'end', + lookupDirection: 'previous' | 'next', + highlightDisabled: boolean, + isOptionDisabled: OptionPredicate, + wrapAround: boolean, +) { + const maxIndex = options.length - 1; + const defaultHighlightedIndex = -1; + let nextIndexCandidate: number; + + if (diff === 'reset') { + return defaultHighlightedIndex; + } + + if (diff === 'start') { + nextIndexCandidate = 0; + } else if (diff === 'end') { + nextIndexCandidate = maxIndex; + } else { + const newIndex = previouslyHighlightedIndex + diff; + + if (newIndex < 0) { + if ((!wrapAround && previouslyHighlightedIndex !== -1) || Math.abs(diff) > 1) { + nextIndexCandidate = 0; + } else { + nextIndexCandidate = maxIndex; + } + } else if (newIndex > maxIndex) { + if (!wrapAround || Math.abs(diff) > 1) { + nextIndexCandidate = maxIndex; + } else { + nextIndexCandidate = 0; + } + } else { + nextIndexCandidate = newIndex; + } + } + + const nextIndex = findValidOptionToHighlight( + nextIndexCandidate, + lookupDirection, + options, + highlightDisabled, + isOptionDisabled, + wrapAround, + ); + + return nextIndex; +} + +function handleOptionSelection( + option: TOption, + state: ListboxState, + props: UseListboxStrictProps, +): ListboxState { + const { multiple, optionComparer = (o, v) => o === v, isOptionDisabled = () => false } = props; + const { selectedValue } = state; + + const optionIndex = props.options.indexOf(option); + + if (isOptionDisabled(option, optionIndex)) { + return state; + } + + if (multiple) { + const selectedValues = (selectedValue as TOption[]) ?? []; + // if the option is already selected, remove it from the selection, otherwise add it + const newSelectedValues = selectedValues.some((sv) => optionComparer(sv, option)) + ? (selectedValue as TOption[]).filter((v) => !optionComparer(v, option)) + : [...((selectedValue as TOption[]) ?? []), option]; + + return { + selectedValue: newSelectedValues, + highlightedIndex: optionIndex, + }; + } + + if (selectedValue != null && optionComparer(option, selectedValue as TOption)) { + return state; + } + + return { + selectedValue: option, + highlightedIndex: optionIndex, + }; +} + +function handleKeyDown( + event: React.KeyboardEvent, + state: Readonly>, + props: UseListboxStrictProps, +): ListboxState { + const { options, isOptionDisabled, disableListWrap, disabledItemsFocusable } = props; + + const moveHighlight = ( + diff: number | 'reset' | 'start' | 'end', + direction: 'next' | 'previous', + wrapAround: boolean, + ) => { + return getNewHighlightedIndex( + options, + state.highlightedIndex, + diff, + direction, + disabledItemsFocusable ?? false, + isOptionDisabled ?? (() => false), + wrapAround, + ); + }; + + switch (event.key) { + case 'Home': + return { + ...state, + highlightedIndex: moveHighlight('start', 'next', false), + }; + + case 'End': + return { + ...state, + highlightedIndex: moveHighlight('end', 'previous', false), + }; + + case 'PageUp': + return { + ...state, + highlightedIndex: moveHighlight(-pageSize, 'previous', false), + }; + + case 'PageDown': + return { + ...state, + highlightedIndex: moveHighlight(pageSize, 'next', false), + }; + + case 'ArrowUp': + // TODO: extend current selection with Shift modifier + return { + ...state, + highlightedIndex: moveHighlight(-1, 'previous', !(disableListWrap ?? false)), + }; + + case 'ArrowDown': + // TODO: extend current selection with Shift modifier + return { + ...state, + highlightedIndex: moveHighlight(1, 'next', !(disableListWrap ?? false)), + }; + + case 'Enter': + case ' ': + if (state.highlightedIndex === -1 || options[state.highlightedIndex] === undefined) { + return state; + } + + return handleOptionSelection(options[state.highlightedIndex], state, props); + + default: + break; + } + + return state; +} + +function handleBlur(state: ListboxState): ListboxState { + return { + ...state, + highlightedIndex: -1, + }; +} + +function handleOptionsChange( + options: TOption[], + previousOptions: TOption[], + state: ListboxState, + props: UseListboxStrictProps, +): ListboxState { + const { multiple, optionComparer } = props; + + const highlightedOption = previousOptions[state.highlightedIndex]; + const hightlightedOptionNewIndex = options.findIndex((option) => + optionComparer(option, highlightedOption), + ); + + if (multiple) { + // exclude selected values that are no longer in the options + const selectedValues = (state.selectedValue as TOption[]) ?? []; + const newSelectedValues = selectedValues.filter((selectedValue) => + options.some((option) => optionComparer(option, selectedValue)), + ); + + return { + highlightedIndex: hightlightedOptionNewIndex, + selectedValue: newSelectedValues, + }; + } + + const newSelectedValue = + options.find((option) => optionComparer(option, state.selectedValue as TOption)) ?? null; + + return { + highlightedIndex: hightlightedOptionNewIndex, + selectedValue: newSelectedValue, + }; +} + +export default function defaultListboxReducer( + state: Readonly>, + action: ListboxAction, +): Readonly> { + const { type } = action; + + switch (type) { + case ActionTypes.keyDown: + return handleKeyDown(action.event, state, action.props); + case ActionTypes.optionClick: + return handleOptionSelection(action.option, state, action.props); + case ActionTypes.blur: + return handleBlur(state); + case ActionTypes.setControlledValue: + return { + ...state, + selectedValue: action.value, + }; + case ActionTypes.optionsChange: + return handleOptionsChange(action.options, action.previousOptions, state, action.props); + default: + return state; + } +} diff --git a/packages/mui-base/src/ListboxUnstyled/index.ts b/packages/mui-base/src/ListboxUnstyled/index.ts new file mode 100644 index 00000000000000..da303c2110531c --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/index.ts @@ -0,0 +1,4 @@ +export { default as useListbox } from './useListbox'; +export { default as defaultListboxReducer } from './defaultListboxReducer'; + +export * from './types'; diff --git a/packages/mui-base/src/ListboxUnstyled/types.ts b/packages/mui-base/src/ListboxUnstyled/types.ts new file mode 100644 index 00000000000000..03e418c13b56bf --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/types.ts @@ -0,0 +1,174 @@ +type UseListboxStrictPropsRequiredKeys = + | 'isOptionDisabled' + | 'disableListWrap' + | 'disabledItemsFocusable' + | 'optionComparer' + | 'multiple'; + +export type UseListboxStrictProps = Omit< + UseListboxProps, + UseListboxStrictPropsRequiredKeys +> & + Required, UseListboxStrictPropsRequiredKeys>>; + +export enum ActionTypes { + blur = 'blur', + focus = 'focus', + keyDown = 'keyDown', + optionClick = 'optionClick', + setControlledValue = 'setControlledValue', + optionsChange = 'optionsChange', +} + +interface OptionClickAction { + type: ActionTypes.optionClick; + option: TOption; + event: React.MouseEvent; + props: UseListboxStrictProps; +} + +interface FocusAction { + type: ActionTypes.focus; + event: React.FocusEvent; + props: UseListboxStrictProps; +} + +interface BlurAction { + type: ActionTypes.blur; + event: React.FocusEvent; + props: UseListboxStrictProps; +} + +interface KeyDownAction { + type: ActionTypes.keyDown; + event: React.KeyboardEvent; + props: UseListboxStrictProps; +} + +interface SetControlledValueAction { + type: ActionTypes.setControlledValue; + value: TOption | TOption[] | null; + props: UseListboxStrictProps; +} + +interface OptionsChangeAction { + type: ActionTypes.optionsChange; + options: TOption[]; + previousOptions: TOption[]; + props: UseListboxStrictProps; +} + +export type ListboxAction = + | OptionClickAction + | FocusAction + | BlurAction + | KeyDownAction + | SetControlledValueAction + | OptionsChangeAction; + +export interface ListboxState { + highlightedIndex: number; + selectedValue: TOption | TOption[] | null; +} + +export type ListboxReducer = ( + state: ListboxState, + action: ListboxAction, +) => ListboxState; + +interface UseListboxCommonProps { + /** + * Array of options to be rendered in the list. + */ + options: TOption[]; + /** + * Id attribute of the listbox. + */ + id?: string; + /** + * A function that determines if a particular option is disabled. + * @default () => false + */ + isOptionDisabled?: (option: TOption, index: number) => boolean; + /** + * A function that tests equality between two options. + * @default (a, b) => a === b + */ + optionComparer?: (optionA: TOption, optionB: TOption) => boolean; + /** + * If `true`, the highlight will not wrap around the list if arrow keys are used. + * @default false + */ + disableListWrap?: boolean; + /** + * If `true`, it will be possible to highlight disabled options. + * @default false + */ + disabledItemsFocusable?: boolean; + /** + * A function that generates the id attribute of individual options. + */ + optionIdGenerator?: (option: TOption, index: number) => string; + /** + * Custom state reducer function. It calculates the new state (highlighted and selected options) + * based on the previous one and the performed action. + */ + stateReducer?: ListboxReducer; + /** + * Callback fired when the highlighted option changes. + */ + onHighlightChange?: (option: TOption | null) => void; + listboxRef?: React.Ref; +} + +interface UseSingleSelectListboxProps extends UseListboxCommonProps { + /** + * The default selected value. Use when the component is not controlled. + */ + defaultValue?: TOption | null; + /** + * If `true`, the component will allow to select multiple options. + * @default false + */ + multiple?: false; + /** + * The selected value. Use when the component is controlled. + */ + value?: TOption | null; + /** + * Callback fired when the value changes. + */ + onChange?: (value: TOption) => void; +} + +interface UseMultiSelectListboxProps extends UseListboxCommonProps { + /** + * The default selected value. Use when the component is not controlled. + */ + defaultValue?: TOption[]; + /** + * If `true`, the component will allow to select multiple options. + * @default false + */ + multiple: true; + /** + * The selected value. Use when the component is controlled. + */ + value?: TOption[]; + /** + * Callback fired when the value changes. + */ + onChange?: (value: TOption[]) => void; +} + +export type UseListboxProps = + | UseSingleSelectListboxProps + | UseMultiSelectListboxProps; + +export interface OptionState { + disabled: boolean; + highlighted: boolean; + index: number; + // option: TOption; + selected: boolean; +} diff --git a/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts b/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts new file mode 100644 index 00000000000000..43720aaf4b0461 --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { unstable_useControlled as useControlled } from '@mui/utils'; +import { + ActionTypes, + ListboxAction, + ListboxReducer, + ListboxState, + UseListboxProps, + UseListboxStrictProps, +} from './types'; +import areArraysEqual from '../utils/areArraysEqual'; + +/** + * Triggers change event handlers when reducer returns changed state. + */ +function useReducerReturnValueHandler( + state: ListboxState, + value: TOption | TOption[] | null, + options: UseListboxProps['options'], + optionComparer: React.RefObject<(option: TOption, value: TOption) => boolean>, + setValueState: (newValue: TOption | TOption[] | null) => void, + onValueChange: UseListboxProps['onChange'], + onHighlightChange: UseListboxProps['onHighlightChange'], +) { + const valueRef = React.useRef(value); + valueRef.current = value; + + const onValueChangeRef = React.useRef(onValueChange); + onValueChangeRef.current = onValueChange; + + const onHighlightChangeRef = React.useRef(onHighlightChange); + onHighlightChangeRef.current = onHighlightChange; + + React.useEffect(() => { + if (Array.isArray(state.selectedValue)) { + if (areArraysEqual(state.selectedValue, valueRef.current as TOption[])) { + return; + } + } else if ( + (state.selectedValue == null && valueRef.current == null) || + (state.selectedValue != null && + valueRef.current != null && + optionComparer.current!(state.selectedValue as TOption, valueRef.current as TOption)) + ) { + return; + } + + setValueState(state.selectedValue); + if (state.selectedValue != null) { + // @ts-ignore We know that selectedValue has the correct type depending on `selectMultiple` prop. + onValueChangeRef.current?.(state.selectedValue); + } + }, [state.selectedValue, setValueState, optionComparer]); + + React.useEffect(() => { + // Fire the highlightChange event when reducer returns changed `highlightedIndex`. + if (state.highlightedIndex === -1) { + onHighlightChangeRef.current?.(null); + } else { + onHighlightChangeRef.current?.(options[state.highlightedIndex]); + } + }, [state.highlightedIndex, options]); +} + +export default function useControllableReducer( + internalReducer: ListboxReducer, + externalReducer: ListboxReducer | undefined, + props: UseListboxStrictProps, +): [ListboxState, (action: ListboxAction) => void] { + const { + value: controlledValue, + defaultValue, + onChange: onValueChange, + onHighlightChange, + options, + optionComparer, + } = props; + + const propsRef = React.useRef(props); + propsRef.current = props; + + const [value, setValueState] = useControlled({ + controlled: controlledValue, + default: defaultValue, + name: 'useListbox', + }); + + const previousValueRef = React.useRef(null); + + const [state, dispatch] = React.useReducer>( + externalReducer ?? internalReducer, + { + highlightedIndex: -1, + selectedValue: value, + } as ListboxState, + ); + + const optionComparerRef = React.useRef(optionComparer); + optionComparerRef.current = optionComparer; + + React.useEffect(() => { + // Detect external changes to the controlled `value` prop and update the state. + if (controlledValue === undefined) { + return; + } + + if ( + Array.isArray(controlledValue) && + Array.isArray(previousValueRef.current) && + areArraysEqual( + previousValueRef.current as TOption[], + controlledValue as TOption[], + optionComparerRef.current, + ) + ) { + // `value` is an array and it did not change. + return; + } + + if ( + !Array.isArray(controlledValue) && + controlledValue != null && + previousValueRef.current != null && + optionComparerRef.current(controlledValue as TOption, previousValueRef.current as TOption) + ) { + // `value` is a single option and it did not change. + return; + } + + previousValueRef.current = controlledValue; + dispatch({ + type: ActionTypes.setControlledValue, + value: controlledValue, + props: propsRef.current, + }); + }, [controlledValue]); + + useReducerReturnValueHandler( + state, + value, + options, + optionComparerRef, + setValueState, + onValueChange, + onHighlightChange, + ); + return [state, dispatch]; +} diff --git a/packages/mui-base/src/ListboxUnstyled/useListbox.ts b/packages/mui-base/src/ListboxUnstyled/useListbox.ts new file mode 100644 index 00000000000000..49e74608f259d0 --- /dev/null +++ b/packages/mui-base/src/ListboxUnstyled/useListbox.ts @@ -0,0 +1,194 @@ +import * as React from 'react'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import { UseListboxProps, UseListboxStrictProps, ActionTypes, OptionState } from './types'; +import defaultReducer from './defaultListboxReducer'; +import useControllableReducer from './useControllableReducer'; +import areArraysEqual from '../utils/areArraysEqual'; + +const defaultOptionComparer = (optionA: TOption, optionB: TOption) => optionA === optionB; + +export default function useListbox(props: UseListboxProps) { + const { + disableListWrap = false, + disabledItemsFocusable = false, + id, + options, + multiple = false, + isOptionDisabled = () => false, + optionComparer = defaultOptionComparer, + stateReducer: externalReducer, + listboxRef: externalListboxRef, + } = props; + + function defaultIdGenerator(_: TOption, index: number) { + return `${id}-option-${index}`; + } + + const optionIdGenerator = props.optionIdGenerator ?? defaultIdGenerator; + + const propsWithDefaults: UseListboxStrictProps = { + ...props, + disableListWrap, + disabledItemsFocusable, + isOptionDisabled, + multiple, + optionComparer, + }; + + const listboxRef = React.useRef(null); + const handleRef = useForkRef(externalListboxRef, listboxRef); + + const [{ highlightedIndex, selectedValue }, dispatch] = useControllableReducer( + defaultReducer, + externalReducer, + propsWithDefaults, + ); + + const previousOptions = React.useRef([]); + + React.useEffect(() => { + if (areArraysEqual(previousOptions.current, options, optionComparer)) { + return; + } + + dispatch({ + type: ActionTypes.optionsChange, + options, + previousOptions: previousOptions.current, + props: propsWithDefaults, + }); + + previousOptions.current = options; + + // No need to re-run this effect if props change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options, optionComparer, dispatch]); + + const createHandleOptionClick = + (option: TOption, other: Record>) => + (event: React.MouseEvent) => { + other.onClick?.(event); + if (event.defaultPrevented) { + return; + } + + event.preventDefault(); + + dispatch({ + type: ActionTypes.optionClick, + option, + event, + props: propsWithDefaults, + }); + }; + + const createHandleKeyDown = + (other: Record>) => + (event: React.KeyboardEvent) => { + other.onKeyDown?.(event); + + if (event.defaultPrevented) { + return; + } + + const keysToPreventDefault = [ + ' ', + 'Enter', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'PageUp', + 'PageDown', + ]; + + if (keysToPreventDefault.includes(event.key)) { + event.preventDefault(); + } + + dispatch({ + type: ActionTypes.keyDown, + event, + props: propsWithDefaults, + }); + }; + + const createHandleBlur = + (other: Record>) => (event: React.FocusEvent) => { + other.onBlur?.(event); + + if (event.defaultPrevented) { + return; + } + + if (listboxRef.current?.contains(document.activeElement)) { + // focus is within the listbox + return; + } + + dispatch({ + type: ActionTypes.blur, + event, + props: propsWithDefaults, + }); + }; + + const getRootProps = (other: Record> = {}) => { + return { + ...other, + 'aria-activedescendant': + highlightedIndex >= 0 + ? optionIdGenerator(options[highlightedIndex], highlightedIndex) + : undefined, + id, + onBlur: createHandleBlur(other), + onKeyDown: createHandleKeyDown(other), + role: 'listbox', + tabIndex: 0, + ref: handleRef, + }; + }; + + const getOptionState = (option: TOption) => { + let selected: boolean; + const index = options.findIndex((opt) => optionComparer(opt, option)); + if (multiple) { + selected = ((selectedValue as TOption[]) ?? []).some( + (value) => value != null && optionComparer(option, value), + ); + } else { + selected = optionComparer(option, selectedValue as TOption); + } + + const disabled = isOptionDisabled(option, index); + + return { + index, + option, + selected, + disabled, + highlighted: highlightedIndex === index, + } as OptionState; + }; + + const getOptionProps = (option: TOption, other: Record> = {}) => { + const { selected, disabled } = getOptionState(option); + const index = options.findIndex((opt) => optionComparer(opt, option)); + + return { + 'aria-disabled': disabled || undefined, + 'aria-selected': selected, + id: optionIdGenerator(option, index), + onClick: createHandleOptionClick(option, other), + role: 'option', + }; + }; + + return { + getRootProps, + getOptionProps, + getOptionState, + selectedOption: selectedValue, + highlightedOption: options[highlightedIndex] ?? null, + }; +} diff --git a/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx new file mode 100644 index 00000000000000..6ac80ab946f3ac --- /dev/null +++ b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.test.tsx @@ -0,0 +1,162 @@ +import * as React from 'react'; +import { expect } from 'chai'; + +import MultiSelectUnstyled from '@mui/base/MultiSelectUnstyled'; +import { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled from '@mui/base/OptionUnstyled'; +import OptionGroupUnstyled from '@mui/base/OptionGroupUnstyled'; +import { + createMount, + createRenderer, + describeConformanceUnstyled, + userEvent, + act, +} from 'test/utils'; + +describe('MultiSelectUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + const componentToTest = ( + + + 1 + + + ); + + describeConformanceUnstyled(componentToTest, () => ({ + inheritComponent: 'button', + render, + mount, + refInstanceof: window.HTMLButtonElement, + testComponentPropWith: 'span', + muiName: 'MuiSelect', + slots: { + root: { + expectedClassName: selectUnstyledClasses.root, + }, + listbox: { + expectedClassName: selectUnstyledClasses.listbox, + testWithElement: 'ul', + }, + popper: { + expectedClassName: selectUnstyledClasses.popper, + testWithElement: null, + }, + }, + })); + + describe('keyboard navigation', () => { + ['Enter', ' ', 'ArrowDown', 'ArrowUp'].forEach((key) => { + it(`opens the dropdown when the "${key}" key is pressed on the button`, () => { + // can't use the default native `button` as it doesn't treat enter or space press as a click + const { getByRole } = render(); + const button = getByRole('button'); + act(() => { + button.focus(); + }); + userEvent.keyPress(button, { key }); + + expect(button).to.have.attribute('aria-expanded', 'true'); + expect(getByRole('listbox')).not.to.equal(null); + expect(document.activeElement).to.equal(getByRole('listbox')); + }); + }); + + describe('item selection', () => { + ['Enter', ' '].forEach((key) => + it(`selects a highlighted item using the "${key}" key`, () => { + const { getByRole } = render( + + 1 + 2 + , + ); + + const button = getByRole('button'); + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '1' + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + userEvent.keyPress(listbox, { key }); + + expect(button).to.have.text('2'); + }), + ); + }); + + it('closes the listbox without selecting an option when "Escape" is pressed', () => { + const { getByRole, queryByRole } = render( + + 1 + 2 + , + ); + + const button = getByRole('button'); + + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + userEvent.keyPress(listbox, { key: 'Escape' }); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(button).to.have.text('1'); + expect(queryByRole('listbox')).to.equal(null); + }); + }); + + it('closes the listbox without selecting an option when focus is lost', () => { + const { getByRole, getByTestId } = render( +
    + + 1 + 2 + +

    + focus target +

    +
    , + ); + + const button = getByRole('button'); + + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + + const focusTarget = getByTestId('focus-target'); + act(() => { + focusTarget.focus(); + }); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(button).to.have.text('1'); + }); + + it('focuses the listbox after it is opened', () => { + const { getByRole } = render( + + 1 + , + ); + + const button = getByRole('button'); + act(() => { + button.click(); + }); + + expect(document.activeElement).to.equal(getByRole('listbox')); + }); +}); diff --git a/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.tsx b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.tsx new file mode 100644 index 00000000000000..accdc757ce6aa1 --- /dev/null +++ b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyled.tsx @@ -0,0 +1,306 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { + unstable_useForkRef as useForkRef, + unstable_useControlled as useControlled, +} from '@mui/utils'; +import MultiSelectUnstyledProps, { + MultiSelectUnstyledOwnerState, +} from './MultiSelectUnstyledProps'; +import { flattenOptionGroups, getOptionsFromChildren } from '../SelectUnstyled/utils'; +import useSelect from '../SelectUnstyled/useSelect'; +import { SelectChild, SelectOption } from '../SelectUnstyled/useSelectProps'; +import { appendOwnerState } from '../utils'; +import PopperUnstyled from '../PopperUnstyled'; +import { + SelectUnstyledContext, + SelectUnstyledContextType, +} from '../SelectUnstyled/SelectUnstyledContext'; +import composeClasses from '../composeClasses'; +import { getSelectUnstyledUtilityClass } from '../SelectUnstyled/selectUnstyledClasses'; + +function defaultRenderMultipleValues(selectedOptions: SelectOption[]) { + return {selectedOptions.map((o) => o.label).join(', ')}; +} + +function useUtilityClasses(ownerState: MultiSelectUnstyledOwnerState) { + const { active, disabled, open, focusVisible } = ownerState; + + const slots = { + root: [ + 'root', + disabled && 'disabled', + focusVisible && 'focusVisible', + active && 'active', + open && 'expanded', + ], + listbox: ['listbox', disabled && 'disabled'], + popper: ['popper'], + }; + + return composeClasses(slots, getSelectUnstyledUtilityClass, {}); +} + +/** + * The foundation for building custom-styled multi-selection select components. + */ +const MultiSelectUnstyled = React.forwardRef(function MultiSelectUnstyled( + props: MultiSelectUnstyledProps, + ref: React.ForwardedRef, +) { + const { + autoFocus, + children, + className, + component, + components = {}, + componentsProps = {}, + defaultListboxOpen = false, + defaultValue = [], + disabled: disabledProp, + listboxOpen: listboxOpenProp, + onChange, + onListboxOpenChange, + value: valueProp, + ...other + } = props; + + const renderValue = props.renderValue ?? defaultRenderMultipleValues; + + const [groupedOptions, setGroupedOptions] = React.useState[]>([]); + const options = React.useMemo(() => flattenOptionGroups(groupedOptions), [groupedOptions]); + const [listboxOpen, setListboxOpen] = useControlled({ + controlled: listboxOpenProp, + default: defaultListboxOpen, + name: 'MultiSelectUnstyled', + state: 'listboxOpen', + }); + + React.useEffect(() => { + setGroupedOptions(getOptionsFromChildren(children)); + }, [children]); + + const [buttonDefined, setButtonDefined] = React.useState(false); + const buttonRef = React.useRef(null); + + const Button = component ?? components.Root ?? 'button'; + const ListboxRoot = components.Listbox ?? 'ul'; + const Popper = components.Popper ?? PopperUnstyled; + + const handleButtonRefChange = (element: HTMLElement | null) => { + buttonRef.current = element; + + if (element != null) { + setButtonDefined(true); + } + }; + + const handleButtonRef = useForkRef(ref, handleButtonRefChange); + + React.useEffect(() => { + if (autoFocus) { + buttonRef.current!.focus(); + } + }, [autoFocus]); + + const handleOpenChange = (isOpen: boolean) => { + setListboxOpen(isOpen); + onListboxOpenChange?.(isOpen); + }; + + const { + buttonActive, + buttonFocusVisible, + disabled, + getButtonProps, + getListboxProps, + getOptionProps, + getOptionState, + value, + } = useSelect({ + buttonComponent: Button, + buttonRef: handleButtonRef, + defaultValue, + disabled: disabledProp, + listboxId: componentsProps.listbox?.id, + listboxRef: componentsProps.listbox?.ref, + multiple: true, + onChange, + onOpenChange: handleOpenChange, + open: listboxOpen, + options, + value: valueProp, + }); + + const ownerState: MultiSelectUnstyledOwnerState = { + ...props, + active: buttonActive, + defaultListboxOpen, + disabled, + focusVisible: buttonFocusVisible, + open: listboxOpen, + renderValue, + value, + }; + + const classes = useUtilityClasses(ownerState); + + const selectedOptions = React.useMemo(() => { + if (value == null) { + return []; + } + return options.filter((o) => (value as TValue[]).includes(o.value)); + }, [options, value]); + + const buttonProps = appendOwnerState( + Button, + { + ...other, + ...componentsProps.root, + ...getButtonProps(), + className: clsx(className, componentsProps.root?.className, classes.root), + }, + ownerState, + ); + + const listboxProps = appendOwnerState( + ListboxRoot, + { + ...componentsProps.listbox, + ...getListboxProps(), + className: clsx(componentsProps.listbox?.className, classes.listbox), + }, + ownerState, + ); + + const popperProps = appendOwnerState( + Popper, + { + open: listboxOpen, + anchorEl: buttonRef.current, + placement: 'bottom-start', + disablePortal: true, + role: undefined, + ...componentsProps.popper, + className: clsx(componentsProps.popper?.className, classes.popper), + }, + ownerState, + ); + + const context: SelectUnstyledContextType = { + getOptionProps, + getOptionState, + }; + + return ( + + + {buttonDefined && ( + + + + {children} + + + + )} + + ); +}); + +MultiSelectUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * If `true`, the select element is focused during the first mount + * @default false + */ + autoFocus: PropTypes.bool, + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + component: PropTypes.elementType, + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps: PropTypes.shape({ + listbox: PropTypes.object, + popper: PropTypes.object, + root: PropTypes.object, + }), + /** + * If `true`, the select will be initially open. + * @default false + */ + defaultListboxOpen: PropTypes.bool, + /** + * The default selected values. Use when the component is not controlled. + * @default [] + */ + defaultValue: PropTypes.array, + /** + * If `true`, the select is disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * Controls the open state of the select's listbox. + * @default undefined + */ + listboxOpen: PropTypes.bool, + /** + * Callback fired when an option is selected. + */ + onChange: PropTypes.func, + /** + * Callback fired when the component requests to be opened. + * Use in controlled mode (see listboxOpen). + */ + onListboxOpenChange: PropTypes.func, + /** + * Function that customizes the rendering of the selected values. + */ + renderValue: PropTypes.func, + /** + * The selected values. + * Set to an empty array to deselect all options. + */ + value: PropTypes.array, +} as any; + +/** + * The foundation for building custom-styled multi-selection select components. + * + * Demos: + * + * - [Selects](https://mui.com/components/selects/) + * + * API: + * + * - [MultiSelectUnstyled API](https://mui.com/api/multi-select-unstyled/) + */ +export default MultiSelectUnstyled as ( + props: MultiSelectUnstyledProps & React.RefAttributes, +) => JSX.Element | null; diff --git a/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyledProps.ts b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyledProps.ts new file mode 100644 index 00000000000000..1da1a61899c8e9 --- /dev/null +++ b/packages/mui-base/src/MultiSelectUnstyled/MultiSelectUnstyledProps.ts @@ -0,0 +1,30 @@ +import { SelectOption, SelectUnstyledCommonProps } from '../SelectUnstyled'; + +export default interface MultiSelectUnstyledProps + extends SelectUnstyledCommonProps { + /** + * The default selected values. Use when the component is not controlled. + * @default [] + */ + defaultValue?: TValue[]; + /** + * Callback fired when an option is selected. + */ + onChange?: (value: TValue[]) => void; + /** + * Function that customizes the rendering of the selected values. + */ + renderValue?: (option: SelectOption[]) => React.ReactNode; + /** + * The selected values. + * Set to an empty array to deselect all options. + */ + value?: TValue[]; +} + +export interface MultiSelectUnstyledOwnerState extends MultiSelectUnstyledProps { + active: boolean; + disabled: boolean; + open: boolean; + focusVisible: boolean; +} diff --git a/packages/mui-base/src/MultiSelectUnstyled/index.ts b/packages/mui-base/src/MultiSelectUnstyled/index.ts new file mode 100644 index 00000000000000..2ecb3b5676a0ef --- /dev/null +++ b/packages/mui-base/src/MultiSelectUnstyled/index.ts @@ -0,0 +1,4 @@ +export { default } from './MultiSelectUnstyled'; + +export type { default as MultiSelectUnstyledProps } from './MultiSelectUnstyledProps'; +export * from './MultiSelectUnstyledProps'; diff --git a/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.test.tsx b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.test.tsx new file mode 100644 index 00000000000000..dd48c7c426d993 --- /dev/null +++ b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createMount, createRenderer, describeConformanceUnstyled } from 'test/utils'; +import OptionGroupUnstyled, { optionGroupUnstyledClasses } from '@mui/base/OptionGroupUnstyled'; + +describe('OptionGroupUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'li', + render, + mount, + refInstanceof: window.HTMLLIElement, + testComponentPropWith: 'span', + muiName: 'MuiOptionGroupUnstyled', + slots: { + root: { + expectedClassName: optionGroupUnstyledClasses.root, + }, + label: { + expectedClassName: optionGroupUnstyledClasses.label, + }, + list: { + expectedClassName: optionGroupUnstyledClasses.list, + }, + }, + skip: ['ownerStatePropagation'], // the component does not have its own state + })); +}); diff --git a/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.tsx b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.tsx new file mode 100644 index 00000000000000..f5a519cf8bdb2c --- /dev/null +++ b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyled.tsx @@ -0,0 +1,123 @@ +import clsx from 'clsx'; +import PropTypes from 'prop-types'; +import React from 'react'; +import composeClasses from '../composeClasses'; +import { getOptionGroupUnstyledUtilityClass } from './optionGroupUnstyledClasses'; +import OptionGroupUnstyledProps from './OptionGroupUnstyledProps'; + +function useUtilityClasses(disabled: boolean) { + const slots = { + root: ['root', disabled && 'disabled'], + label: ['label'], + list: ['list'], + }; + + return composeClasses(slots, getOptionGroupUnstyledUtilityClass, {}); +} + +/** + * An unstyled option group to be used within a SelectUnstyled. + * + * Demos: + * + * - [Selects](https://mui.com/components/selects/) + * + * API: + * + * - [OptionGroupUnstyled API](https://mui.com/api/option-group-unstyled/) + */ +const OptionGroupUnstyled = React.forwardRef(function OptionGroupUnstyled( + props: OptionGroupUnstyledProps, + ref: React.ForwardedRef, +) { + const { + className, + component, + components = {}, + disabled = false, + componentsProps = {}, + ...other + } = props; + + const Root = component || components?.Root || 'li'; + const Label = components?.Label || 'span'; + const List = components?.List || 'ul'; + + const classes = useUtilityClasses(disabled); + + const rootProps = { + ...other, + ref, + ...componentsProps.root, + className: clsx(classes.root, className, componentsProps.root?.className), + }; + + const labelProps = { + ...componentsProps.label, + className: clsx(classes.label, componentsProps.label?.className), + }; + + const listProps = { + ...componentsProps.list, + className: clsx(classes.list, componentsProps.list?.className), + }; + + return ( + + + {props.children} + + ); +}); + +OptionGroupUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to components.Root. + * If both are provided, the component is used. + */ + component: PropTypes.elementType, + /** + * The components used for each slot inside the OptionGroupUnstyled. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Label: PropTypes.elementType, + List: PropTypes.elementType, + Root: PropTypes.elementType, + }), + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps: PropTypes.shape({ + label: PropTypes.object, + list: PropTypes.object, + root: PropTypes.object, + }), + /** + * If `true` all the options in the group will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * The human-readable description of the group. + */ + label: PropTypes.node, +} as any; + +export default OptionGroupUnstyled; diff --git a/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyledProps.ts b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyledProps.ts new file mode 100644 index 00000000000000..5e462b1ffc9e55 --- /dev/null +++ b/packages/mui-base/src/OptionGroupUnstyled/OptionGroupUnstyledProps.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +export interface OptionGroupUnstyledComponentsPropsOverrides {} + +export default interface OptionGroupUnstyledProps { + /** + * The human-readable description of the group. + */ + label?: React.ReactNode; + className?: string; + children?: React.ReactNode; + /** + * If `true` all the options in the group will be disabled. + * @default false + */ + disabled?: boolean; + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to components.Root. + * If both are provided, the component is used. + */ + component?: React.ElementType; + /** + * The components used for each slot inside the OptionGroupUnstyled. + * Either a string to use a HTML element or a component. + * @default {} + */ + components?: { + Root?: React.ElementType; + Label?: React.ElementType; + List?: React.ElementType; + }; + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps?: { + root?: React.ComponentPropsWithRef<'li'> & OptionGroupUnstyledComponentsPropsOverrides; + label?: React.ComponentPropsWithRef<'span'> & OptionGroupUnstyledComponentsPropsOverrides; + list?: React.ComponentPropsWithRef<'ul'> & OptionGroupUnstyledComponentsPropsOverrides; + }; +} diff --git a/packages/mui-base/src/OptionGroupUnstyled/index.ts b/packages/mui-base/src/OptionGroupUnstyled/index.ts new file mode 100644 index 00000000000000..97f745fecdc80d --- /dev/null +++ b/packages/mui-base/src/OptionGroupUnstyled/index.ts @@ -0,0 +1,7 @@ +export { default } from './OptionGroupUnstyled'; + +export type { default as OptionGroupUnstyledProps } from './OptionGroupUnstyledProps'; +export * from './OptionGroupUnstyledProps'; + +export { default as optionGroupUnstyledClasses } from './optionGroupUnstyledClasses'; +export * from './optionGroupUnstyledClasses'; diff --git a/packages/mui-base/src/OptionGroupUnstyled/optionGroupUnstyledClasses.ts b/packages/mui-base/src/OptionGroupUnstyled/optionGroupUnstyledClasses.ts new file mode 100644 index 00000000000000..3758d1d0e9f978 --- /dev/null +++ b/packages/mui-base/src/OptionGroupUnstyled/optionGroupUnstyledClasses.ts @@ -0,0 +1,21 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface OptionGroupUnstyledClasses { + root: string; + label: string; + list: string; +} + +export type OptionGroupUnstyledClassKey = keyof OptionGroupUnstyledClasses; + +export function getOptionGroupUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('MuiOptionGroupUnstyled', slot); +} + +const optionGroupUnstyledClasses: OptionGroupUnstyledClasses = generateUtilityClasses( + 'MuiOptionGroupUnstyled', + ['root', 'label', 'list'], +); + +export default optionGroupUnstyledClasses; diff --git a/packages/mui-base/src/OptionUnstyled/OptionUnstyled.test.tsx b/packages/mui-base/src/OptionUnstyled/OptionUnstyled.test.tsx new file mode 100644 index 00000000000000..6b53fc684c3ee5 --- /dev/null +++ b/packages/mui-base/src/OptionUnstyled/OptionUnstyled.test.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { createMount, createRenderer, describeConformanceUnstyled } from 'test/utils'; +import OptionUnstyled, { optionUnstyledClasses } from '@mui/base/OptionUnstyled'; +import { SelectUnstyledContext } from '@mui/base/SelectUnstyled'; + +const dummyGetOptionState = () => ({ + disabled: false, + highlighted: false, + selected: false, + index: 0, +}); + +const dummyGetOptionProps = () => ({}); + +describe('OptionUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'li', + render: (node) => { + return render( + + {node} + , + ); + }, + mount: (node: React.ReactNode) => { + const wrapper = mount( + + {node} + , + ); + return wrapper.childAt(0); + }, + refInstanceof: window.HTMLLIElement, + testComponentPropWith: 'span', + muiName: 'MuiOptionUnstyled', + slots: { + root: { + expectedClassName: optionUnstyledClasses.root, + }, + }, + skip: ['reactTestRenderer'], // Need to be wrapped in SelectUnstyledContext + })); +}); diff --git a/packages/mui-base/src/OptionUnstyled/OptionUnstyled.tsx b/packages/mui-base/src/OptionUnstyled/OptionUnstyled.tsx new file mode 100644 index 00000000000000..6e2f122777978c --- /dev/null +++ b/packages/mui-base/src/OptionUnstyled/OptionUnstyled.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import clsx from 'clsx'; +import PropTypes from 'prop-types'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import { OptionState } from '../ListboxUnstyled'; +import composeClasses from '../composeClasses'; +import OptionUnstyledProps from './OptionUnstyledProps'; +import { SelectUnstyledContext } from '../SelectUnstyled/SelectUnstyledContext'; +import { getOptionUnstyledUtilityClass } from './optionUnstyledClasses'; +import appendOwnerState from '../utils/appendOwnerState'; + +function useUtilityClasses(ownerState: OptionState) { + const { disabled, highlighted, selected } = ownerState; + + const slots = { + root: ['root', disabled && 'disabled', highlighted && 'highlighted', selected && 'selected'], + }; + + return composeClasses(slots, getOptionUnstyledUtilityClass, {}); +} + +/** + * An unstyled option to be used within a SelectUnstyled. + */ +const OptionUnstyled = React.forwardRef(function OptionUnstyled( + props: OptionUnstyledProps, + ref: React.ForwardedRef, +) { + const { + children, + className, + component, + components = {}, + componentsProps = {}, + disabled, + value, + ...other + } = props; + + const selectContext = React.useContext(SelectUnstyledContext); + if (!selectContext) { + throw new Error('OptionUnstyled must be used within a SelectUnstyled'); + } + + const Root = component || components.Root || 'li'; + + const selectOption = { + value, + label: children, + disabled, + }; + + const optionState = selectContext.getOptionState(selectOption); + const optionProps = selectContext.getOptionProps(selectOption); + + const ownerState = { + ...props, + ...optionState, + }; + + const optionRef = React.useRef(null); + const handleRef = useForkRef(ref, optionRef); + + React.useEffect(() => { + if (optionState.highlighted) { + optionRef.current?.scrollIntoView?.({ block: 'nearest' }); + } + }, [optionState.highlighted]); + + const classes = useUtilityClasses(ownerState); + + const rootProps = appendOwnerState( + Root, + { + ...other, + ref: handleRef, + ...optionProps, + ...componentsProps.root, + className: clsx(classes.root, className, componentsProps.root?.className), + }, + ownerState, + ); + + return {children}; +}); + +OptionUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to components.Root. + * If both are provided, the component is used. + */ + component: PropTypes.elementType, + /** + * The components used for each slot inside the OptionUnstyled. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Root: PropTypes.elementType, + }), + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps: PropTypes.shape({ + root: PropTypes.object, + }), + /** + * If `true`, the option will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * The value of the option. + */ + value: PropTypes.any.isRequired, +} as any; + +/** + * An unstyled option to be used within a SelectUnstyled. + * + * Demos: + * + * - [Selects](https://mui.com/components/selects/) + * + * API: + * + * - [OptionUnstyled API](https://mui.com/api/option-unstyled/) + */ +export default React.memo(OptionUnstyled) as ( + props: OptionUnstyledProps & React.RefAttributes, +) => JSX.Element | null; diff --git a/packages/mui-base/src/OptionUnstyled/OptionUnstyledProps.ts b/packages/mui-base/src/OptionUnstyled/OptionUnstyledProps.ts new file mode 100644 index 00000000000000..3314bfb981be9d --- /dev/null +++ b/packages/mui-base/src/OptionUnstyled/OptionUnstyledProps.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +export interface OptionUnstyledComponentsPropsOverrides {} + +export default interface OptionUnstyledProps { + /** + * The value of the option. + */ + value: TValue; + children?: React.ReactNode; + /** + * If `true`, the option will be disabled. + * @default false + */ + disabled?: boolean; + className?: string; + /** + * The component used for the Root slot. + * Either a string to use a HTML element or a component. + * This is equivalent to components.Root. + * If both are provided, the component is used. + */ + component?: React.ElementType; + /** + * The components used for each slot inside the OptionUnstyled. + * Either a string to use a HTML element or a component. + * @default {} + */ + components?: { + Root?: React.ElementType; + }; + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps?: { + root?: React.ComponentPropsWithRef<'li'> & OptionUnstyledComponentsPropsOverrides; + }; +} diff --git a/packages/mui-base/src/OptionUnstyled/index.ts b/packages/mui-base/src/OptionUnstyled/index.ts new file mode 100644 index 00000000000000..ae096d9ef42987 --- /dev/null +++ b/packages/mui-base/src/OptionUnstyled/index.ts @@ -0,0 +1,7 @@ +export { default } from './OptionUnstyled'; + +export type { default as OptionUnstyledProps } from './OptionUnstyledProps'; +export * from './OptionUnstyledProps'; + +export { default as optionUnstyledClasses } from './optionUnstyledClasses'; +export * from './optionUnstyledClasses'; diff --git a/packages/mui-base/src/OptionUnstyled/optionUnstyledClasses.tsx b/packages/mui-base/src/OptionUnstyled/optionUnstyledClasses.tsx new file mode 100644 index 00000000000000..20d6a48b64e720 --- /dev/null +++ b/packages/mui-base/src/OptionUnstyled/optionUnstyledClasses.tsx @@ -0,0 +1,24 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface OptionUnstyledClasses { + root: string; + disabled: string; + selected: string; + highlighted: string; +} + +export type OptionUnstyledClassKey = keyof OptionUnstyledClasses; + +export function getOptionUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('MuiOptionUnstyled', slot); +} + +const optionUnstyledClasses: OptionUnstyledClasses = generateUtilityClasses('MuiOptionUnstyled', [ + 'root', + 'disabled', + 'selected', + 'highlighted', +]); + +export default optionUnstyledClasses; diff --git a/packages/mui-base/src/PopperUnstyled/PopperUnstyled.js b/packages/mui-base/src/PopperUnstyled/PopperUnstyled.js index d582fd6b5a7c14..f1b246cae511a6 100644 --- a/packages/mui-base/src/PopperUnstyled/PopperUnstyled.js +++ b/packages/mui-base/src/PopperUnstyled/PopperUnstyled.js @@ -45,6 +45,7 @@ const PopperTooltip = React.forwardRef(function PopperTooltip(props, ref) { disablePortal, modifiers, open, + ownerState, placement: initialPlacement, popperOptions, popperRef: popperRefProp, diff --git a/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx new file mode 100644 index 00000000000000..c0c709feb8982c --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.test.tsx @@ -0,0 +1,203 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import SelectUnstyled, { selectUnstyledClasses } from '@mui/base/SelectUnstyled'; +import OptionUnstyled from '@mui/base/OptionUnstyled'; +import OptionGroupUnstyled from '@mui/base/OptionGroupUnstyled'; +import { + createMount, + createRenderer, + describeConformanceUnstyled, + fireEvent, + userEvent, + act, +} from 'test/utils'; + +describe('SelectUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + const componentToTest = ( + + + 1 + + + ); + + describeConformanceUnstyled(componentToTest, () => ({ + inheritComponent: 'button', + render, + mount, + refInstanceof: window.HTMLButtonElement, + testComponentPropWith: 'span', + muiName: 'MuiSelectUnstyled', + slots: { + root: { + expectedClassName: selectUnstyledClasses.root, + }, + listbox: { + expectedClassName: selectUnstyledClasses.listbox, + testWithElement: 'ul', + }, + popper: { + expectedClassName: selectUnstyledClasses.popper, + testWithElement: null, + }, + }, + })); + + describe('keyboard navigation', () => { + ['Enter', ' ', 'ArrowDown', 'ArrowUp'].forEach((key) => { + it(`opens the dropdown when the "${key}" key is pressed on the button`, () => { + // can't use the default native `button` as it doesn't treat enter or space press as a click + const { getByRole } = render(); + const button = getByRole('button'); + act(() => { + button.focus(); + }); + userEvent.keyPress(button, { key }); + + expect(button).to.have.attribute('aria-expanded', 'true'); + expect(getByRole('listbox')).not.to.equal(null); + expect(document.activeElement).to.equal(getByRole('listbox')); + }); + }); + + ['Enter', ' ', 'Escape'].forEach((key) => { + it(`closes the dropdown when the "${key}" key is pressed`, () => { + const { getByRole, queryByRole } = render( + + 1 + , + ); + const button = getByRole('button'); + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + userEvent.keyPress(listbox, { key }); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(queryByRole('listbox')).to.equal(null); + }); + }); + + describe('item selection', () => { + ['Enter', ' '].forEach((key) => + it(`selects a highlighted item using the "${key}" key`, () => { + const { getByRole } = render( + + 1 + 2 + , + ); + + const button = getByRole('button'); + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '1' + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + userEvent.keyPress(listbox, { key }); + + expect(button).to.have.text('2'); + }), + ); + }); + + it('closes the listbox without selecting an option when "Escape" is pressed', () => { + const { getByRole } = render( + + 1 + 2 + , + ); + + const button = getByRole('button'); + + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + userEvent.keyPress(listbox, { key: 'Escape' }); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(button).to.have.text('1'); + }); + }); + + it('closes the listbox without selecting an option when focus is lost', () => { + const { getByRole, getByTestId } = render( +
    + + 1 + 2 + +

    + focus target +

    +
    , + ); + + const button = getByRole('button'); + + act(() => { + button.click(); + }); + + const listbox = getByRole('listbox'); + userEvent.keyPress(listbox, { key: 'ArrowDown' }); // highlights '2' + + const focusTarget = getByTestId('focus-target'); + act(() => { + focusTarget.focus(); + }); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(button).to.have.text('1'); + }); + + it('closes the listbox when already selected option is selected again with a click', () => { + const { getByRole, getByTestId } = render( + + + 1 + + 2 + , + ); + + const button = getByRole('button'); + + act(() => { + button.click(); + }); + + const selectedOption = getByTestId('selected-option'); + fireEvent.click(selectedOption); + + expect(button).to.have.attribute('aria-expanded', 'false'); + expect(button).to.have.text('1'); + }); + + it('focuses the listbox after it is opened', () => { + const { getByRole } = render( + + 1 + , + ); + + const button = getByRole('button'); + act(() => { + button.click(); + }); + + expect(document.activeElement).to.equal(getByRole('listbox')); + }); +}); diff --git a/packages/mui-base/src/SelectUnstyled/SelectUnstyled.tsx b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.tsx new file mode 100644 index 00000000000000..d76cadc678004d --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/SelectUnstyled.tsx @@ -0,0 +1,298 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { + unstable_useForkRef as useForkRef, + unstable_useControlled as useControlled, +} from '@mui/utils'; +import { SelectUnstyledOwnerState, SelectUnstyledProps } from './SelectUnstyledProps'; +import { flattenOptionGroups, getOptionsFromChildren } from './utils'; +import useSelect from './useSelect'; +import { SelectChild, SelectOption } from './useSelectProps'; +import { appendOwnerState } from '../utils'; +import PopperUnstyled from '../PopperUnstyled'; +import { SelectUnstyledContext, SelectUnstyledContextType } from './SelectUnstyledContext'; +import composeClasses from '../composeClasses'; +import { getSelectUnstyledUtilityClass } from './selectUnstyledClasses'; + +function defaultRenderSingleValue(selectedOption: SelectOption | null) { + return selectedOption?.label ?? ''; +} + +function useUtilityClasses(ownerState: SelectUnstyledOwnerState) { + const { active, disabled, open, focusVisible } = ownerState; + + const slots = { + root: [ + 'root', + disabled && 'disabled', + focusVisible && 'focusVisible', + active && 'active', + open && 'expanded', + ], + listbox: ['listbox', disabled && 'disabled'], + popper: ['popper'], + }; + + return composeClasses(slots, getSelectUnstyledUtilityClass, {}); +} + +/** + * The foundation for building custom-styled select components. + */ +const SelectUnstyled = React.forwardRef(function SelectUnstyled( + props: SelectUnstyledProps, + ref: React.ForwardedRef, +) { + const { + autoFocus, + children, + className, + component, + components = {}, + componentsProps = {}, + defaultValue, + defaultListboxOpen = false, + disabled: disabledProp, + listboxOpen: listboxOpenProp, + onChange, + onListboxOpenChange, + renderValue: renderValueProp, + value: valueProp, + ...other + } = props; + + const renderValue = renderValueProp ?? defaultRenderSingleValue; + + const [groupedOptions, setGroupedOptions] = React.useState[]>([]); + const options = React.useMemo(() => flattenOptionGroups(groupedOptions), [groupedOptions]); + const [listboxOpen, setListboxOpen] = useControlled({ + controlled: listboxOpenProp, + default: defaultListboxOpen, + name: 'SelectUnstyled', + state: 'listboxOpen', + }); + + React.useEffect(() => { + setGroupedOptions(getOptionsFromChildren(children)); + }, [children]); + + const [buttonDefined, setButtonDefined] = React.useState(false); + const buttonRef = React.useRef(null); + + const Button = component ?? components.Root ?? 'button'; + const ListboxRoot = components.Listbox ?? 'ul'; + const Popper = components.Popper ?? PopperUnstyled; + + const handleButtonRefChange = (element: HTMLElement | null) => { + buttonRef.current = element; + + if (element != null) { + setButtonDefined(true); + } + }; + + const handleButtonRef = useForkRef(ref, handleButtonRefChange); + + React.useEffect(() => { + if (autoFocus) { + buttonRef.current!.focus(); + } + }, [autoFocus]); + + const handleOpenChange = (isOpen: boolean) => { + setListboxOpen(isOpen); + onListboxOpenChange?.(isOpen); + }; + + const { + buttonActive, + buttonFocusVisible, + disabled, + getButtonProps, + getListboxProps, + getOptionProps, + getOptionState, + value, + } = useSelect({ + buttonComponent: Button, + buttonRef: handleButtonRef, + defaultValue, + disabled: disabledProp, + listboxId: componentsProps.listbox?.id, + listboxRef: componentsProps.listbox?.ref, + multiple: false, + onChange, + onOpenChange: handleOpenChange, + open: listboxOpen, + options, + value: valueProp, + }); + + const ownerState: SelectUnstyledOwnerState = { + ...props, + active: buttonActive, + defaultListboxOpen, + disabled, + focusVisible: buttonFocusVisible, + open: listboxOpen, + renderValue, + value, + }; + + const classes = useUtilityClasses(ownerState); + + const selectedOptions = React.useMemo(() => { + return options.find((o) => value === o.value); + }, [options, value]); + + const buttonProps = appendOwnerState( + Button, + { + ...other, + ...componentsProps.root, + ...getButtonProps(), + className: clsx(className, componentsProps.root?.className, classes.root), + }, + ownerState, + ); + + const listboxProps = appendOwnerState( + ListboxRoot, + { + ...componentsProps.listbox, + ...getListboxProps(), + className: clsx(componentsProps.listbox?.className, classes.listbox), + }, + ownerState, + ); + + const popperProps = appendOwnerState( + Popper, + { + open: listboxOpen, + anchorEl: buttonRef.current, + placement: 'bottom-start', + disablePortal: true, + role: undefined, + ...componentsProps.popper, + className: clsx(componentsProps.popper?.className, classes.popper), + }, + ownerState, + ); + + const context: SelectUnstyledContextType = { + getOptionProps, + getOptionState, + }; + + return ( + + + {buttonDefined && ( + + + + {children} + + + + )} + + ); +}); + +SelectUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * If `true`, the select element is focused during the first mount + * @default false + */ + autoFocus: PropTypes.bool, + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + component: PropTypes.elementType, + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Popper: PropTypes.elementType, + Root: PropTypes.elementType, + }), + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps: PropTypes.shape({ + listbox: PropTypes.object, + popper: PropTypes.object, + root: PropTypes.object, + }), + /** + * If `true`, the select will be initially open. + * @default false + */ + defaultListboxOpen: PropTypes.bool, + /** + * The default selected value. Use when the component is not controlled. + */ + defaultValue: PropTypes /* @typescript-to-proptypes-ignore */.any, + /** + * If `true`, the select is disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * Controls the open state of the select's listbox. + * @default undefined + */ + listboxOpen: PropTypes.bool, + /** + * Callback fired when an option is selected. + */ + onChange: PropTypes.func, + /** + * Callback fired when the component requests to be opened. + * Use in controlled mode (see listboxOpen). + */ + onListboxOpenChange: PropTypes.func, + /** + * Function that customizes the rendering of the selected value. + */ + renderValue: PropTypes.func, + /** + * The selected value. + * Set to `null` to deselect all options. + */ + value: PropTypes /* @typescript-to-proptypes-ignore */.any, +} as any; + +/** + * The foundation for building custom-styled select components. + * + * Demos: + * + * - [Selects](https://mui.com/components/selects/) + * + * API: + * + * - [SelectUnstyled API](https://mui.com/api/select-unstyled/) + */ +export default SelectUnstyled as ( + props: SelectUnstyledProps & React.RefAttributes, +) => JSX.Element | null; diff --git a/packages/mui-base/src/SelectUnstyled/SelectUnstyledContext.ts b/packages/mui-base/src/SelectUnstyled/SelectUnstyledContext.ts new file mode 100644 index 00000000000000..31ea8c407954f6 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/SelectUnstyledContext.ts @@ -0,0 +1,12 @@ +import React from 'react'; +import { OptionState } from '../ListboxUnstyled'; +import { SelectOption } from './useSelectProps'; + +export interface SelectUnstyledContextType { + getOptionState: (value: SelectOption) => OptionState; + getOptionProps: (option: SelectOption) => Record; +} + +export const SelectUnstyledContext = React.createContext( + undefined, +); diff --git a/packages/mui-base/src/SelectUnstyled/SelectUnstyledProps.ts b/packages/mui-base/src/SelectUnstyled/SelectUnstyledProps.ts new file mode 100644 index 00000000000000..ba3e79b7228265 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/SelectUnstyledProps.ts @@ -0,0 +1,82 @@ +import { SelectOption } from './useSelectProps'; +import PopperUnstyled from '../PopperUnstyled'; + +export interface SelectUnstyledComponentsPropsOverrides {} + +export interface SelectUnstyledCommonProps { + /** + * If `true`, the select element is focused during the first mount + * @default false + */ + autoFocus?: boolean; + children?: React.ReactNode; + className?: string; + component?: React.ElementType; + /** + * The components used for each slot inside the Select. + * Either a string to use a HTML element or a component. + * @default {} + */ + components?: { + Root?: React.ElementType; + Listbox?: React.ElementType; + Popper?: React.ElementType; + }; + /** + * The props used for each slot inside the Input. + * @default {} + */ + componentsProps?: { + root?: React.ComponentPropsWithRef<'button'> & SelectUnstyledComponentsPropsOverrides; + listbox?: React.ComponentPropsWithRef<'ul'> & SelectUnstyledComponentsPropsOverrides; + popper?: React.ComponentPropsWithRef & + SelectUnstyledComponentsPropsOverrides; + }; + /** + * If `true`, the select is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the select will be initially open. + * @default false + */ + defaultListboxOpen?: boolean; + /** + * Controls the open state of the select's listbox. + * @default undefined + */ + listboxOpen?: boolean; + /** + * Callback fired when the component requests to be opened. + * Use in controlled mode (see listboxOpen). + */ + onListboxOpenChange?: (isOpen: boolean) => void; +} + +export interface SelectUnstyledProps extends SelectUnstyledCommonProps { + /** + * The default selected value. Use when the component is not controlled. + */ + defaultValue?: TValue | null; + /** + * Callback fired when an option is selected. + */ + onChange?: (value: TValue | null) => void; + /** + * Function that customizes the rendering of the selected value. + */ + renderValue?: (option: SelectOption | null) => React.ReactNode; + /** + * The selected value. + * Set to `null` to deselect all options. + */ + value?: TValue | null; +} + +export interface SelectUnstyledOwnerState extends SelectUnstyledProps { + active: boolean; + disabled: boolean; + open: boolean; + focusVisible: boolean; +} diff --git a/packages/mui-base/src/SelectUnstyled/index.ts b/packages/mui-base/src/SelectUnstyled/index.ts new file mode 100644 index 00000000000000..57f82154318ea3 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/index.ts @@ -0,0 +1,12 @@ +export { default } from './SelectUnstyled'; + +export * from './SelectUnstyledContext'; + +export { default as selectUnstyledClasses } from './selectUnstyledClasses'; +export * from './selectUnstyledClasses'; + +export * from './SelectUnstyledProps'; + +export { default as useSelect } from './useSelect'; + +export * from './useSelectProps'; diff --git a/packages/mui-base/src/SelectUnstyled/selectUnstyledClasses.ts b/packages/mui-base/src/SelectUnstyled/selectUnstyledClasses.ts new file mode 100644 index 00000000000000..341becc73f57e7 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/selectUnstyledClasses.ts @@ -0,0 +1,32 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface SelectUnstyledClasses { + root: string; + button: string; + listbox: string; + popper: string; + active: string; + expanded: string; + disabled: string; + focusVisible: string; +} + +export type SelectUnstyledClassKey = keyof SelectUnstyledClasses; + +export function getSelectUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('MuiSelectUnstyled', slot); +} + +const selectUnstyledClasses: SelectUnstyledClasses = generateUtilityClasses('MuiSelectUnstyled', [ + 'root', + 'button', + 'listbox', + 'popper', + 'active', + 'expanded', + 'disabled', + 'focusVisible', +]); + +export default selectUnstyledClasses; diff --git a/packages/mui-base/src/SelectUnstyled/useSelect.ts b/packages/mui-base/src/SelectUnstyled/useSelect.ts new file mode 100644 index 00000000000000..8fbb2813c3503d --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/useSelect.ts @@ -0,0 +1,310 @@ +import * as React from 'react'; +import { + unstable_useControlled as useControlled, + unstable_useForkRef as useForkRef, +} from '@mui/utils'; +import { useButton } from '../ButtonUnstyled'; +import { + SelectOption, + UseSelectMultiProps, + UseSelectProps, + UseSelectSingleProps, +} from './useSelectProps'; +import { + ListboxReducer, + useListbox, + defaultListboxReducer, + ActionTypes, + UseListboxProps, + OptionState, +} from '../ListboxUnstyled'; + +interface UseSelectCommonResult { + buttonActive: boolean; + buttonFocusVisible: boolean; + disabled: boolean; + getButtonProps: (otherHandlers?: Record>) => Record; + getListboxProps: (otherHandlers?: Record>) => Record; + getOptionProps: ( + option: SelectOption, + otherHandlers?: Record>, + ) => Record; + getOptionState: (option: SelectOption) => OptionState; +} + +interface UseSelectSingleResult extends UseSelectCommonResult { + value: TValue | null; +} + +interface UseSelectMultiResult extends UseSelectCommonResult { + value: TValue[]; +} + +function useSelect(props: UseSelectSingleProps): UseSelectSingleResult; +function useSelect(props: UseSelectMultiProps): UseSelectMultiResult; +function useSelect(props: UseSelectProps) { + const { + buttonComponent, + buttonRef: buttonRefProp, + defaultValue, + disabled = false, + listboxId, + listboxRef: listboxRefProp, + multiple = false, + onChange, + onOpenChange, + open, + options, + value: valueProp, + } = props; + + const buttonRef = React.useRef(null); + const handleButtonRef = useForkRef(buttonRefProp, buttonRef); + + const listboxRef = React.useRef(null); + const intermediaryListboxRef = useForkRef(listboxRefProp, listboxRef); + + const [value, setValue] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'SelectUnstyled', + state: 'value', + }); + + // prevents closing the listbox on keyUp right after opening it + const ignoreEnterKeyUp = React.useRef(false); + + // prevents reopening the listbox when button is clicked + // (listbox closes on lost focus, then immediately reopens on click) + const ignoreClick = React.useRef(false); + + // Ensure the listbox is focused after opening + const [listboxFocusRequested, requestListboxFocus] = React.useState(false); + + const focusListboxIfRequested = React.useCallback(() => { + if (listboxFocusRequested && listboxRef.current != null) { + listboxRef.current.focus(); + requestListboxFocus(false); + } + }, [listboxFocusRequested]); + + const updateListboxRef = (listboxElement: HTMLUListElement) => { + listboxRef.current = listboxElement; + focusListboxIfRequested(); + }; + + const handleListboxRef = useForkRef(intermediaryListboxRef, updateListboxRef); + + React.useEffect(() => { + focusListboxIfRequested(); + }, [focusListboxIfRequested]); + + React.useEffect(() => { + requestListboxFocus(open ?? false); + }, [open]); + + const createHandleMouseDown = + (otherHandlers?: Record>) => + (event: React.MouseEvent) => { + otherHandlers?.onMouseDown?.(event); + if (!event.defaultPrevented && open) { + ignoreClick.current = true; + } + }; + + const createHandleButtonClick = + (otherHandlers?: Record>) => (event: React.MouseEvent) => { + otherHandlers?.onClick?.(event); + if (!event.defaultPrevented && !ignoreClick.current) { + onOpenChange?.(!open); + } + + ignoreClick.current = false; + }; + + const createHandleButtonKeyDown = + (otherHandlers?: Record>) => (event: React.KeyboardEvent) => { + otherHandlers?.onKeyDown?.(event); + if (event.defaultPrevented) { + return; + } + + if (event.key === 'Enter') { + ignoreEnterKeyUp.current = true; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + onOpenChange?.(true); + } + }; + + const createHandleListboxKeyUp = + (otherHandlers?: Record>) => (event: React.KeyboardEvent) => { + otherHandlers?.onKeyUp?.(event); + if (event.defaultPrevented) { + return; + } + + const closingKeys = multiple ? ['Escape'] : ['Escape', 'Enter', ' ']; + + if (open && !ignoreEnterKeyUp.current && closingKeys.includes(event.key)) { + buttonRef?.current?.focus(); + } + + ignoreEnterKeyUp.current = false; + }; + + const createHandleListboxItemClick = + (otherHandlers?: Record>) => (event: React.MouseEvent) => { + otherHandlers?.onClick?.(event); + if (event.defaultPrevented) { + return; + } + + if (!multiple) { + onOpenChange?.(false); + } + }; + + const createHandleListboxBlur = + (otherHandlers?: Record>) => (event: React.FocusEvent) => { + otherHandlers?.blur?.(event); + if (!event.defaultPrevented) { + onOpenChange?.(false); + } + }; + + const listboxReducer: ListboxReducer> = (state, action) => { + const newState = defaultListboxReducer(state, action); + + // change selection when listbox is closed + if ( + action.type === ActionTypes.keyDown && + !open && + (action.event.key === 'ArrowUp' || action.event.key === 'ArrowDown') + ) { + const optionToSelect = action.props.options[newState.highlightedIndex]; + + return { + ...newState, + selectedValue: optionToSelect, + }; + } + + if ( + action.type === ActionTypes.blur || + action.type === ActionTypes.setControlledValue || + action.type === ActionTypes.optionsChange + ) { + const selectedOptionIndex = action.props.options.findIndex((o) => + action.props.optionComparer(o, newState.selectedValue as SelectOption), + ); + + return { + ...newState, + highlightedIndex: selectedOptionIndex, + }; + } + + return newState; + }; + + const { + getRootProps: getButtonProps, + active: buttonActive, + focusVisible: buttonFocusVisible, + } = useButton({ + component: buttonComponent, + disabled, + ref: handleButtonRef, + }); + + const selectedOption = React.useMemo( + () => + props.multiple + ? props.options.filter((o) => (value as TValue[]).includes(o.value)) + : props.options.find((o) => o.value === value) ?? null, + [props.multiple, props.options, value], + ); + + let useListboxParameters: UseListboxProps>; + + if (props.multiple) { + useListboxParameters = { + id: listboxId, + isOptionDisabled: (o) => o?.disabled ?? false, + optionComparer: (o, v) => o?.value === v?.value, + listboxRef: handleListboxRef, + multiple: true, + onChange: (newOptions) => { + setValue(newOptions.map((o) => o.value)); + (onChange as (value: TValue[]) => void)?.(newOptions.map((o) => o.value)); + }, + options, + value: selectedOption as SelectOption[], + }; + } else { + useListboxParameters = { + id: listboxId, + isOptionDisabled: (o) => o?.disabled ?? false, + optionComparer: (o, v) => o?.value === v?.value, + listboxRef: handleListboxRef, + multiple: false, + onChange: (option: SelectOption | null) => { + setValue(option?.value ?? null); + (onChange as (value: TValue | null) => void)?.(option?.value ?? null); + }, + options, + stateReducer: listboxReducer, + value: selectedOption as SelectOption | null, + }; + } + + const { + getRootProps: getListboxProps, + getOptionProps, + getOptionState, + highlightedOption, + } = useListbox(useListboxParameters); + + React.useDebugValue({ value, open, highlightedOption }); + + return { + buttonActive, + buttonFocusVisible, + disabled, + getButtonProps: (otherHandlers?: Record>) => { + return { + ...getButtonProps({ + ...otherHandlers, + onClick: createHandleButtonClick(otherHandlers), + onMouseDown: createHandleMouseDown(otherHandlers), + onKeyDown: createHandleButtonKeyDown(otherHandlers), + }), + 'aria-expanded': open, + 'aria-haspopup': 'listbox' as const, + }; + }, + getListboxProps: (otherHandlers?: Record>) => + getListboxProps({ + ...otherHandlers, + onBlur: createHandleListboxBlur(otherHandlers), + onKeyUp: createHandleListboxKeyUp(otherHandlers), + }), + getOptionProps: ( + option: SelectOption, + otherHandlers?: Record>, + ) => { + return getOptionProps(option, { + ...otherHandlers, + onClick: createHandleListboxItemClick(otherHandlers), + }); + }, + getOptionState, + open, + value, + }; +} + +export default useSelect; diff --git a/packages/mui-base/src/SelectUnstyled/useSelectProps.ts b/packages/mui-base/src/SelectUnstyled/useSelectProps.ts new file mode 100644 index 00000000000000..7c15f68aac5a57 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/useSelectProps.ts @@ -0,0 +1,46 @@ +export interface SelectOption { + value: TValue; + label: React.ReactNode; + disabled?: boolean; +} + +export interface SelectOptionGroup { + options: SelectChild[]; + label: React.ReactNode; + disabled?: boolean; +} + +export type SelectChild = SelectOption | SelectOptionGroup; + +export function isOptionGroup( + child: SelectChild, +): child is SelectOptionGroup { + return !!(child as SelectOptionGroup).options; +} + +interface UseSelectCommonProps { + buttonComponent?: React.ElementType; + buttonRef?: React.Ref; + disabled?: boolean; + listboxId?: string; + listboxRef?: React.Ref; + onOpenChange?: (open: boolean) => void; + open?: boolean; + options: SelectOption[]; +} + +export interface UseSelectSingleProps extends UseSelectCommonProps { + defaultValue?: TValue | null; + multiple?: false; + onChange?: (value: TValue | null) => void; + value?: TValue | null; +} + +export interface UseSelectMultiProps extends UseSelectCommonProps { + defaultValue?: TValue[]; + multiple: true; + onChange?: (value: TValue[]) => void; + value?: TValue[]; +} + +export type UseSelectProps = UseSelectSingleProps | UseSelectMultiProps; diff --git a/packages/mui-base/src/SelectUnstyled/utils.tsx b/packages/mui-base/src/SelectUnstyled/utils.tsx new file mode 100644 index 00000000000000..94768bceda3662 --- /dev/null +++ b/packages/mui-base/src/SelectUnstyled/utils.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { OptionUnstyledProps } from '../OptionUnstyled'; +import { OptionGroupUnstyledProps } from '../OptionGroupUnstyled'; +import { isOptionGroup, SelectChild, SelectOption, SelectOptionGroup } from './useSelectProps'; + +export function areOptionsEqual( + option1: SelectOption, + option2: SelectOption, +) { + return ( + option1.label === option2.label && + option1.value === option2.value && + option1.disabled === option2.disabled + ); +} + +export function getOptionsFromChildren(children: React.ReactNode): SelectChild[] { + if (children == null) { + return []; + } + + const selectChildren: SelectChild[] = []; + + React.Children.forEach(children, (node: React.ReactNode) => { + const nodeChildren = (node as React.ReactElement)?.props?.children; + if ((node as React.ReactElement)?.props?.value === undefined) { + if (nodeChildren != null) { + const element = node as React.ReactElement; + + const group: SelectOptionGroup = { + options: getOptionsFromChildren(nodeChildren), + label: element.props.label, + disabled: element.props.disabled ?? false, + }; + + selectChildren.push(group); + } + + return; + } + + const element = node as React.ReactElement>; + + const option = { + value: element.props.value, + label: element.props.children, + disabled: element.props.disabled ?? false, + }; + + selectChildren.push(option); + }); + + return selectChildren ?? []; +} + +export function flattenOptionGroups( + groupedOptions: SelectChild[], + isGroupDisabled: boolean = false, +): SelectOption[] { + let flatOptions: SelectOption[] = []; + groupedOptions.forEach((optionOrGroup) => { + if (isOptionGroup(optionOrGroup)) { + flatOptions = flatOptions.concat( + flattenOptionGroups(optionOrGroup.options, optionOrGroup.disabled), + ); + } else { + flatOptions.push({ + ...optionOrGroup, + disabled: isGroupDisabled || optionOrGroup.disabled, + }); + } + }); + + return flatOptions; +} diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts index e1fad2f98aed07..0e153d58869f2d 100644 --- a/packages/mui-base/src/index.d.ts +++ b/packages/mui-base/src/index.d.ts @@ -32,14 +32,26 @@ export * from './InputUnstyled'; export { default as ModalUnstyled } from './ModalUnstyled'; export * from './ModalUnstyled'; +export { default as MultiSelectUnstyled } from './MultiSelectUnstyled'; +export * from './MultiSelectUnstyled'; + export { default as NoSsr } from './NoSsr'; +export { default as OptionGroupUnstyled } from './OptionGroupUnstyled'; +export * from './OptionGroupUnstyled'; + +export { default as OptionUnstyled } from './OptionUnstyled'; +export * from './OptionUnstyled'; + export { default as PopperUnstyled } from './PopperUnstyled'; export * from './PopperUnstyled'; export { default as Portal } from './Portal'; export * from './Portal'; +export { default as SelectUnstyled } from './SelectUnstyled'; +export * from './SelectUnstyled'; + export { default as SliderUnstyled } from './SliderUnstyled'; export * from './SliderUnstyled'; diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js index 72a019f48a48f7..0e9290a77487d7 100644 --- a/packages/mui-base/src/index.js +++ b/packages/mui-base/src/index.js @@ -26,15 +26,29 @@ export * from './FormControlUnstyled'; export { default as InputUnstyled } from './InputUnstyled'; export * from './InputUnstyled'; +export * from './ListboxUnstyled'; + export { default as ModalUnstyled } from './ModalUnstyled'; export * from './ModalUnstyled'; +export { default as MultiSelectUnstyled } from './MultiSelectUnstyled'; +export * from './MultiSelectUnstyled'; + export { default as NoSsr } from './NoSsr'; +export { default as OptionGroupUnstyled } from './OptionGroupUnstyled'; +export * from './OptionGroupUnstyled'; + +export { default as OptionUnstyled } from './OptionUnstyled'; +export * from './OptionUnstyled'; + export { default as PopperUnstyled } from './PopperUnstyled'; export { default as Portal } from './Portal'; +export { default as SelectUnstyled } from './SelectUnstyled'; +export * from './SelectUnstyled'; + export { default as SliderUnstyled } from './SliderUnstyled'; export * from './SliderUnstyled'; diff --git a/packages/mui-base/src/utils/areArraysEqual.ts b/packages/mui-base/src/utils/areArraysEqual.ts new file mode 100644 index 00000000000000..7dd92a8226db69 --- /dev/null +++ b/packages/mui-base/src/utils/areArraysEqual.ts @@ -0,0 +1,12 @@ +type ItemComparer = (a: T, b: T) => boolean; + +export default function areArraysEqual( + array1: T[], + array2: T[], + itemComparer: ItemComparer = (a, b) => a === b, +) { + return ( + array1.length === array2.length && + array1.every((value, index) => itemComparer(value, array2[index])) + ); +} diff --git a/packages/mui-base/src/utils/index.ts b/packages/mui-base/src/utils/index.ts index e17bd16213e9f0..5f8cded2807a39 100644 --- a/packages/mui-base/src/utils/index.ts +++ b/packages/mui-base/src/utils/index.ts @@ -1,3 +1,4 @@ export { default as appendOwnerState } from './appendOwnerState'; +export { default as areArraysEqual } from './areArraysEqual'; export { default as extractEventHandlers } from './extractEventHandlers'; export { default as isHostComponent } from './isHostComponent'; diff --git a/test/utils/describeConformanceUnstyled.tsx b/test/utils/describeConformanceUnstyled.tsx index 472bedbce53c74..5120548b5fe2cd 100644 --- a/test/utils/describeConformanceUnstyled.tsx +++ b/test/utils/describeConformanceUnstyled.tsx @@ -13,7 +13,7 @@ import { export interface SlotTestingOptions { testWithComponent?: React.ComponentType; - testWithElement?: keyof JSX.IntrinsicElements; + testWithElement?: keyof JSX.IntrinsicElements | null; expectedClassName: string; isOptional?: boolean; } @@ -140,17 +140,28 @@ function testComponentsProp( expect(renderedElement).to.have.class(slotOptions.expectedClassName); }); - it(`allows overriding the ${capitalize(slotName)} slot with an element`, () => { - const slotElement = slotOptions.testWithElement ?? 'i'; + if (slotOptions.testWithElement !== null) { + it(`allows overriding the ${capitalize(slotName)} slot with an element`, () => { + const slotElement = slotOptions.testWithElement ?? 'i'; - const components = { - [capitalize(slotName)]: slotElement, - }; + const components = { + [capitalize(slotName)]: slotElement, + }; - const { container } = render(React.cloneElement(element, { components })); - const renderedElement = container.querySelector(slotElement); - expect(renderedElement).to.have.class(slotOptions.expectedClassName); - }); + const componentsProps = { + [slotName]: { + 'data-testid': 'customized', + }, + }; + + const { getByTestId } = render( + React.cloneElement(element, { components, componentsProps }), + ); + const renderedElement = getByTestId('customized'); + expect(renderedElement.nodeName.toLowerCase()).to.equal(slotElement); + expect(renderedElement).to.have.class(slotOptions.expectedClassName); + }); + } if (slotOptions.isOptional) { it(`alows omitting the optional ${capitalize(slotName)} slot by providing null`, () => { diff --git a/test/utils/fireDiscreteEvent.js b/test/utils/fireDiscreteEvent.js index 0b5c32c6bc686a..ba2543b7da56fa 100644 --- a/test/utils/fireDiscreteEvent.js +++ b/test/utils/fireDiscreteEvent.js @@ -62,6 +62,17 @@ export function keyDown(element, options) { }); } +/** + * @param {Element} element + * @param {{}} [options] + * @returns {void} + */ +export function keyUp(element, options) { + return withMissingActWarningsIgnored(() => { + fireEvent.keyUp(element, options); + }); +} + /** * @param {Element} element * @param {{}} [options] diff --git a/test/utils/userEvent.ts b/test/utils/userEvent.ts index 0dc6ccbc7635e5..c8ff75ac438359 100644 --- a/test/utils/userEvent.ts +++ b/test/utils/userEvent.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import { click, mouseDown, mouseUp } from './fireDiscreteEvent'; +import { click, mouseDown, mouseUp, keyDown, keyUp } from './fireDiscreteEvent'; import { act, fireEvent } from './createRenderer'; export function touch(target: Element): void { @@ -19,3 +19,14 @@ export function mousePress(target: Element): void { act(() => {}); } } + +export function keyPress(target: Element, options: { key: string }): void { + if (React.version.startsWith('18')) { + fireEvent.keyDown(target, options); + fireEvent.keyUp(target, options); + } else { + keyDown(target, options); + keyUp(target, options); + act(() => {}); + } +}