diff --git a/docs/data/api/select-arrow.json b/docs/data/api/select-arrow.json new file mode 100644 index 0000000000..24eed4806a --- /dev/null +++ b/docs/data/api/select-arrow.json @@ -0,0 +1,18 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "hideWhenUncentered": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectArrow", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectArrow = Select.Arrow;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectArrow", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Arrow/SelectArrow.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-backdrop.json b/docs/data/api/select-backdrop.json new file mode 100644 index 0000000000..fd014aaa72 --- /dev/null +++ b/docs/data/api/select-backdrop.json @@ -0,0 +1,24 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "container": { + "type": { "name": "union", "description": "HTML element
| func" }, + "default": "false" + }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectBackdrop", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectBackdrop = Select.Backdrop;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectBackdrop", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Backdrop/SelectBackdrop.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-group-label.json b/docs/data/api/select-group-label.json new file mode 100644 index 0000000000..2ad62bed89 --- /dev/null +++ b/docs/data/api/select-group-label.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectGroupLabel", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectGroupLabel = Select.GroupLabel;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectGroupLabel", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-group.json b/docs/data/api/select-group.json new file mode 100644 index 0000000000..b820a03935 --- /dev/null +++ b/docs/data/api/select-group.json @@ -0,0 +1,17 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectGroup", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectGroup = Select.Group;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectGroup", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Group/SelectGroup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-icon.json b/docs/data/api/select-icon.json new file mode 100644 index 0000000000..e57e713369 --- /dev/null +++ b/docs/data/api/select-icon.json @@ -0,0 +1,17 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectIcon", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectIcon = Select.Icon;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectIcon", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/src/Select/Icon/SelectIcon.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-option-group-label.json b/docs/data/api/select-option-group-label.json new file mode 100644 index 0000000000..94298762e9 --- /dev/null +++ b/docs/data/api/select-option-group-label.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectOptionGroupLabel", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectOptionGroupLabel = Select.OptionGroupLabel;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectOptionGroupLabel", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/OptionGroupLabel/SelectOptionGroupLabel.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-option-group.json b/docs/data/api/select-option-group.json new file mode 100644 index 0000000000..222bd099f7 --- /dev/null +++ b/docs/data/api/select-option-group.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectOptionGroup", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectOptionGroup = Select.OptionGroup;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectOptionGroup", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/OptionGroup/SelectOptionGroup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-option-indicator.json b/docs/data/api/select-option-indicator.json new file mode 100644 index 0000000000..1e29f6db3f --- /dev/null +++ b/docs/data/api/select-option-indicator.json @@ -0,0 +1,20 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectOptionIndicator", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectOptionIndicator = Select.OptionIndicator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectOptionIndicator", + "forwardsRefTo": "HTMLSpanElement", + "filename": "/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-option-text.json b/docs/data/api/select-option-text.json new file mode 100644 index 0000000000..b663d04cd4 --- /dev/null +++ b/docs/data/api/select-option-text.json @@ -0,0 +1,16 @@ +{ + "props": {}, + "name": "SelectOptionText", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectOptionText = Select.OptionText;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectOptionText", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/OptionText/SelectOptionText.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-option.json b/docs/data/api/select-option.json new file mode 100644 index 0000000000..23675d2c09 --- /dev/null +++ b/docs/data/api/select-option.json @@ -0,0 +1,20 @@ +{ + "props": { + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "value": { "type": { "name": "any" }, "default": "null" } + }, + "name": "SelectOption", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectOption = Select.Option;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectOption", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Option/SelectOption.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-popup.json b/docs/data/api/select-popup.json new file mode 100644 index 0000000000..3f593a177a --- /dev/null +++ b/docs/data/api/select-popup.json @@ -0,0 +1,18 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "id": { "type": { "name": "string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectPopup", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectPopup = Select.Popup;"], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectPopup", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Popup/SelectPopup.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-positioner.json b/docs/data/api/select-positioner.json new file mode 100644 index 0000000000..ee0a334cdb --- /dev/null +++ b/docs/data/api/select-positioner.json @@ -0,0 +1,69 @@ +{ + "props": { + "alignment": { + "type": { + "name": "enum", + "description": "'center'
| 'end'
| 'start'" + }, + "default": "'start'" + }, + "alignmentOffset": { "type": { "name": "number" }, "default": "0" }, + "anchor": { + "type": { + "name": "union", + "description": "(props, propName) => {\n if (props[propName] == null) {\n return new Error(`Prop '${propName}' is required but wasn't specified`);\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}
| func
| { contextElement?: (props, propName) => {\n if (props[propName] == null) {\n return null;\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}, getBoundingClientRect: func, getClientRects?: func }
| { current?: (props, propName) => {\n if (props[propName] == null) {\n return null;\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n} }" + } + }, + "arrowPadding": { "type": { "name": "number" }, "default": "5" }, + "className": { "type": { "name": "union", "description": "func
| string" } }, + "collisionBoundary": { + "type": { + "name": "union", + "description": "'clippingAncestors'
| Array<(props, propName) => {\n if (props[propName] == null) {\n return new Error(`Prop '${propName}' is required but wasn't specified`);\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}>
| (props, propName) => {\n if (props[propName] == null) {\n return new Error(`Prop '${propName}' is required but wasn't specified`);\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}
| { height: number, width: number, x: number, y: number }" + }, + "default": "'clippingAncestors'" + }, + "collisionPadding": { + "type": { + "name": "union", + "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" + }, + "default": "5" + }, + "container": { + "type": { + "name": "union", + "description": "(props, propName) => {\n if (props[propName] == null) {\n return new Error(`Prop '${propName}' is required but wasn't specified`);\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n}
| { current?: (props, propName) => {\n if (props[propName] == null) {\n return null;\n }\n if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {\n return new Error(`Expected prop '${propName}' to be of type Element`);\n }\n return null;\n} }" + } + }, + "hideWhenDetached": { "type": { "name": "bool" }, "default": "false" }, + "positionMethod": { + "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, + "default": "'absolute'" + }, + "render": { "type": { "name": "union", "description": "element
| func" } }, + "side": { + "type": { + "name": "enum", + "description": "'bottom'
| 'left'
| 'right'
| 'top'" + }, + "default": "'bottom'" + }, + "sideOffset": { "type": { "name": "number" }, "default": "0" }, + "sticky": { "type": { "name": "bool" }, "default": "false" }, + "trackAnchor": { "type": { "name": "bool" }, "default": "true" } + }, + "name": "SelectPositioner", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectPositioner = Select.Positioner;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectPositioner", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-root.json b/docs/data/api/select-root.json new file mode 100644 index 0000000000..699b3b6b27 --- /dev/null +++ b/docs/data/api/select-root.json @@ -0,0 +1,26 @@ +{ + "props": { + "alignOptionToTrigger": { "type": { "name": "bool" }, "default": "true" }, + "animated": { "type": { "name": "bool" }, "default": "true" }, + "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, + "defaultValue": { "type": { "name": "any" }, "default": "null" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "name": { "type": { "name": "string" } }, + "onOpenChange": { "type": { "name": "func" } }, + "onValueChange": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } }, + "readOnly": { "type": { "name": "bool" }, "default": "false" }, + "required": { "type": { "name": "bool" }, "default": "false" }, + "value": { "type": { "name": "any" } } + }, + "name": "SelectRoot", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectRoot = Select.Root;"], + "classes": [], + "spread": true, + "themeDefaultProps": null, + "muiName": "SelectRoot", + "filename": "/packages/mui-base/src/Select/Root/SelectRoot.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-scroll-down-arrow.json b/docs/data/api/select-scroll-down-arrow.json new file mode 100644 index 0000000000..af08fc9a4c --- /dev/null +++ b/docs/data/api/select-scroll-down-arrow.json @@ -0,0 +1,16 @@ +{ + "props": { "keepMounted": { "type": { "name": "bool" }, "default": "false" } }, + "name": "SelectScrollDownArrow", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectScrollDownArrow = Select.ScrollDownArrow;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectScrollDownArrow", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/ScrollDownArrow/SelectScrollDownArrow.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-scroll-up-arrow.json b/docs/data/api/select-scroll-up-arrow.json new file mode 100644 index 0000000000..140b5c8dff --- /dev/null +++ b/docs/data/api/select-scroll-up-arrow.json @@ -0,0 +1,16 @@ +{ + "props": { "keepMounted": { "type": { "name": "bool" }, "default": "false" } }, + "name": "SelectScrollUpArrow", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectScrollUpArrow = Select.ScrollUpArrow;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectScrollUpArrow", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/ScrollUpArrow/SelectScrollUpArrow.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-separator.json b/docs/data/api/select-separator.json new file mode 100644 index 0000000000..0c20851282 --- /dev/null +++ b/docs/data/api/select-separator.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectSeparator", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectSeparator = Select.Separator;" + ], + "classes": [], + "spread": true, + "themeDefaultProps": true, + "muiName": "SelectSeparator", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Select/Separator/SelectSeparator.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-trigger.json b/docs/data/api/select-trigger.json new file mode 100644 index 0000000000..92239a7615 --- /dev/null +++ b/docs/data/api/select-trigger.json @@ -0,0 +1,19 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "focusableWhenDisabled": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectTrigger", + "imports": [ + "import { Select } from '@base_ui/react/Select';\nconst SelectTrigger = Select.Trigger;" + ], + "classes": [], + "muiName": "SelectTrigger", + "filename": "/packages/mui-base/src/Select/Trigger/SelectTrigger.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/select-value.json b/docs/data/api/select-value.json new file mode 100644 index 0000000000..74db484c08 --- /dev/null +++ b/docs/data/api/select-value.json @@ -0,0 +1,15 @@ +{ + "props": { + "className": { "type": { "name": "union", "description": "func
| string" } }, + "placeholder": { "type": { "name": "string" } }, + "render": { "type": { "name": "union", "description": "element
| func" } } + }, + "name": "SelectValue", + "imports": ["import { Select } from '@base_ui/react/Select';\nconst SelectValue = Select.Value;"], + "classes": [], + "muiName": "SelectValue", + "filename": "/packages/mui-base/src/Select/Value/SelectValue.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/data/api/use-select-popup.json b/docs/data/api/use-select-popup.json new file mode 100644 index 0000000000..e93f3c3c6e --- /dev/null +++ b/docs/data/api/use-select-popup.json @@ -0,0 +1,8 @@ +{ + "parameters": {}, + "returnValue": {}, + "name": "useSelectPopup", + "filename": "/packages/mui-base/src/Select/Popup/useSelectPopup.ts", + "imports": ["import { useSelectPopup } from '@base_ui/react/Select';"], + "demos": "" +} diff --git a/docs/data/components/select/SelectAlign.js b/docs/data/components/select/SelectAlign.js new file mode 100644 index 0000000000..51e94317e0 --- /dev/null +++ b/docs/data/components/select/SelectAlign.js @@ -0,0 +1,245 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +function AlignOptionToTriggerTrue() { + return ( + + + + + + + + + + } /> + Align option to trigger + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +function AlignOptionToTriggerFalse() { + return ( + + + + + + + + + + } /> + Align popup to trigger + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +export default function SelectAlign() { + return ( +
+ + +
+ ); +} + +const CheckIcon = styled(function CheckIcon(props) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/SelectAlign.tsx b/docs/data/components/select/SelectAlign.tsx new file mode 100644 index 0000000000..526e3e22ab --- /dev/null +++ b/docs/data/components/select/SelectAlign.tsx @@ -0,0 +1,245 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +function AlignOptionToTriggerTrue() { + return ( + + + + + + + + + + } /> + Align option to trigger + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +function AlignOptionToTriggerFalse() { + return ( + + + + + + + + + + } /> + Align popup to trigger + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +export default function SelectAlign() { + return ( +
+ + +
+ ); +} + +const CheckIcon = styled(function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/SelectAlign.tsx.preview b/docs/data/components/select/SelectAlign.tsx.preview new file mode 100644 index 0000000000..c84013a363 --- /dev/null +++ b/docs/data/components/select/SelectAlign.tsx.preview @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/docs/data/components/select/SelectEmpty.js b/docs/data/components/select/SelectEmpty.js new file mode 100644 index 0000000000..f7cd3a0ca9 --- /dev/null +++ b/docs/data/components/select/SelectEmpty.js @@ -0,0 +1,196 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +export default function SelectEmpty() { + return ( + + + + + + + + + + } /> + Select font... + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/SelectEmpty.tsx b/docs/data/components/select/SelectEmpty.tsx new file mode 100644 index 0000000000..12e3670711 --- /dev/null +++ b/docs/data/components/select/SelectEmpty.tsx @@ -0,0 +1,196 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +export default function SelectEmpty() { + return ( + + + + + + + + + + } /> + Select font... + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/SelectGroup.js b/docs/data/components/select/SelectGroup.js new file mode 100644 index 0000000000..f8c936d43e --- /dev/null +++ b/docs/data/components/select/SelectGroup.js @@ -0,0 +1,241 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +function createOptions(items) { + return items.map((item) => ({ + value: item, + label: item[0].toUpperCase() + item.slice(1), + })); +} + +const data = { + Fruits: createOptions(['apple', 'banana', 'orange', 'pear', 'grape', 'pineapple']), + Vegetables: createOptions([ + 'carrot', + 'lettuce', + 'broccoli', + 'cauliflower', + 'asparagus', + 'zucchini', + ]), +}; + +const entries = Object.entries(data); + +export default function SelectGroup() { + return ( + + + + + + + + + + } /> + Select food... + + {entries.map(([group, items]) => ( + + + + {group} + {items.map((item) => ( + + } /> + {item.label} + + ))} + + + ))} + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const gray = { + 300: '#e5e7eb', +}; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; + +const SelectGroupLabel = styled(Select.GroupLabel)` + font-weight: bold; + padding: var(--padding) + calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + cursor: default; + user-select: none; +`; + +const SelectSeparator = styled(Select.Separator)` + height: 1px; + background-color: ${gray[300]}; + margin: 5px 0; +`; diff --git a/docs/data/components/select/SelectGroup.tsx b/docs/data/components/select/SelectGroup.tsx new file mode 100644 index 0000000000..905e28e517 --- /dev/null +++ b/docs/data/components/select/SelectGroup.tsx @@ -0,0 +1,241 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +function createOptions(items: string[]) { + return items.map((item) => ({ + value: item, + label: item[0].toUpperCase() + item.slice(1), + })); +} + +const data = { + Fruits: createOptions(['apple', 'banana', 'orange', 'pear', 'grape', 'pineapple']), + Vegetables: createOptions([ + 'carrot', + 'lettuce', + 'broccoli', + 'cauliflower', + 'asparagus', + 'zucchini', + ]), +}; + +const entries = Object.entries(data); + +export default function SelectGroup() { + return ( + + + + + + + + + + } /> + Select food... + + {entries.map(([group, items]) => ( + + + + {group} + {items.map((item) => ( + + } /> + {item.label} + + ))} + + + ))} + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const gray = { + 300: '#e5e7eb', +}; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; + +const SelectGroupLabel = styled(Select.GroupLabel)` + font-weight: bold; + padding: var(--padding) + calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + cursor: default; + user-select: none; +`; + +const SelectSeparator = styled(Select.Separator)` + height: 1px; + background-color: ${gray[300]}; + margin: 5px 0; +`; diff --git a/docs/data/components/select/SelectIntroduction/system/index.js b/docs/data/components/select/SelectIntroduction/system/index.js new file mode 100644 index 0000000000..3874e0299f --- /dev/null +++ b/docs/data/components/select/SelectIntroduction/system/index.js @@ -0,0 +1,192 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +export default function SelectIntroduction() { + return ( + + + + + + + + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/SelectIntroduction/system/index.tsx b/docs/data/components/select/SelectIntroduction/system/index.tsx new file mode 100644 index 0000000000..14b1f1698b --- /dev/null +++ b/docs/data/components/select/SelectIntroduction/system/index.tsx @@ -0,0 +1,192 @@ +'use client'; +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { css, styled } from '@mui/system'; + +export default function SelectIntroduction() { + return ( + + + + + + + + + + } /> + System font + + + } /> + Arial + + + } /> + Roboto + + + + + + ); +} + +const CheckIcon = styled(function CheckIcon(props: React.SVGProps) { + return ( + + + + ); +})` + width: 100%; + height: 100%; +`; + +const triggerPaddingX = 6; +const popupPadding = 4; + +const SelectTrigger = styled(Select.Trigger)` + font-family: 'IBM Plex Sans', sans-serif; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${triggerPaddingX}px 12px; + border-radius: 5px; + background-color: black; + color: white; + border: none; + font-size: 100%; + line-height: 1.5; + user-select: none; + cursor: default; + + &:focus-visible { + outline: 2px solid black; + outline-offset: 2px; + } +`; + +const SelectDropdownArrow = styled(Select.Icon)` + margin-left: 6px; + font-size: 10px; + line-height: 1; + height: 6px; +`; + +const SelectPositioner = styled(Select.Positioner)` + &[data-side='none'] { + z-index: 1; + } +`; + +const SelectPopup = styled(Select.Popup)` + overflow-y: auto; + background-color: white; + padding: ${popupPadding}px; + border-radius: 5px; + box-shadow: + 0 2px 4px rgb(0 0 0 / 0.1), + 0 0 0 1px rgb(0 0 0 / 0.1); + max-height: var(--available-height); + min-width: min( + calc(var(--available-width) - ${popupPadding * 2}px), + calc(var(--anchor-width) + ${triggerPaddingX * 2 + popupPadding * 2}px) + ); + scroll-padding: ${popupPadding}px; + + &[data-side='none'] { + scroll-padding: 15px; + } + + --padding: 6px; + --icon-size: 16px; + --icon-margin: 4px; +`; + +const SelectOption = styled(Select.Option)` + outline: 0; + cursor: default; + border-radius: 4px; + user-select: none; + display: flex; + align-items: center; + line-height: 1.5; + padding-block: var(--padding); + padding-inline: calc(var(--padding) + var(--icon-margin) + var(--icon-size)); + + &[data-selected] { + padding-left: var(--padding); + } + + &[data-disabled] { + opacity: 0.5; + } + + &[data-highlighted] { + background-color: black; + color: white; + } +`; + +const SelectOptionIndicator = styled(Select.OptionIndicator)` + margin-right: var(--icon-margin); + visibility: hidden; + width: var(--icon-size); + height: var(--icon-size); + + &[data-selected] { + visibility: visible; + } +`; + +const scrollArrowStyles = css` + position: relative; + width: 100%; + height: 15px; + font-size: 10px; + cursor: default; + background: white; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + z-index: 1; + + &[data-side='none'] { + &::before { + content: ''; + display: block; + position: absolute; + width: 100%; + height: calc(100% + 10px); + } + } +`; + +const SelectScrollUpArrow = styled(Select.ScrollUpArrow)` + ${scrollArrowStyles} + + &::before { + top: -10px; + } +`; + +const SelectScrollDownArrow = styled(Select.ScrollDownArrow)` + ${scrollArrowStyles} + bottom: 0; + + &::before { + top: 0; + } +`; diff --git a/docs/data/components/select/select.mdx b/docs/data/components/select/select.mdx new file mode 100644 index 0000000000..78ff14e62d --- /dev/null +++ b/docs/data/components/select/select.mdx @@ -0,0 +1,344 @@ +--- +productId: base-ui +title: React Select component +description: Select provides users with a floating element containing a list of options to choose from. +components: SelectRoot, SelectTrigger, SelectValue, SelectIcon, SelectBackdrop, SelectPositioner, SelectPopup, SelectOption, SelectOptionText, SelectOptionIndicator, SelectGroup, SelectGroupLabel, SelectScrollUpArrow, SelectScrollDownArrow, SelectArrow +githubLabel: 'component: select' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ +--- + +# Select + + + + + +## Introduction + + + +## Installation + + + +## Anatomy + +Selects are implemented using a collection of related components: + +- `` is a top-level component that wraps the other components. +- `` renders the trigger element that opens the select popup on click. +- `` renders the value of the select. +- `` renders a caret icon. +- `` renders a backdrop element behind the popup. +- `` renders the select popup's positioning element. +- `` renders the select popup itself. +- `` renders an option, placed inside the popup. +- `` renders the text of an option. +- `` renders an option indicator inside an option to indicate it's selected (e.g. a check icon). +- `` renders a group for a set of options, wrapping `` components. +- `` renders a label for a group of options. +- `` renders a scrolling arrow for the `alignOptionToTrigger` anchoring mode. +- `` renders a scrolling arrow for the `alignOptionToTrigger` anchoring mode. +- `` renders a separator between option groups. +- `` renders the select popup's arrow when disabling `alignOptionToTrigger`. + +```jsx + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Default value + +To set an initial value when uncontrolled, use `defaultValue`: + +```jsx + +``` + +## Empty value + +The select's value is empty (`null`) by default, which enables an empty `Option` to be initially selected when it has no `value` prop: + + + +## Controlled + +To control the value with external state, specify the `value` and `onValueChange` props: + +```jsx +const [value, setValue] = React.useState('system'); + +return ( + + {/* subcomponents */} + +); +``` + +## Option indicator + +The `Select.OptionIndicator` subcomponent renders an indicator inside an option to indicate it's selected. By default, it renders a check icon, but this can be customized: + +```jsx + + + +``` + +## Grouped options + +`Select.Group` can be used to group options together with a label. The `Select.GroupLabel` subcomponent renders the label: + +```jsx + + Label + Option 1 + Option 2 + +``` + + + +## Align method + +By default, the selected option inside the popup is aligned to the trigger element. This can be disabled with the `alignOptionToTrigger` prop: + +```jsx + +``` + +- **`alignOptionToTrigger={true}`**: aligns the popup such that the selected option inside of it appears centered over the trigger. If there's not enough space, it falls back to standard anchoring. This method is useful as it allows the user to select an option in a single click or "pointer cycle" (pointer down, pointer move, pointer up). This is the native behavior on macOS; the scroll arrow components must be used to ensure a single pointer cycle can be used. The `[data-side]` attribute value is `none` on both `Select.Positioner` and `Select.Popup` when in this mode, allowing it to be styled differently. +- **`alignOptionToTrigger={false}`**: aligns the popup to the trigger itself on its top or bottom side, which is the standard form of anchor positioning used in Tooltip, Popover, Menu, etc. + + + `alignOptionToTrigger` is always `false` on touch devices or touch input. + + + + Scrolling is locked when `alignOptionToTrigger` is `true` to prevent unwanted scrolling of the + background when expanding the popup, ensuring positioning remains correct. + + + + +### Scrollable popup + +The select's height needs to be manually limited by its available space using CSS. + +This can be achieved by using the `--available-height` CSS variable: + +```jsx + +``` + +```css +.SelectPopup { + max-height: var(--available-height); +} +``` + +## Value component + +The `Select.Value` subcomponent renders the selected value. This is the text content or `label` of `Select.Option` by default. + +The `placeholder` prop can be used when the value is empty. During SSR, if a default value is specified as the selected option, the value isn't available until hydration: + +```jsx + + + +``` + +A function can be specified as a child to customize the rendering of the value: + +```jsx +{(value) => value.toLowerCase()} +``` + +## Arrow + +To add an arrow (caret or triangle) inside the select popup that points toward the center of the anchor element, use the `Select.Arrow` component: + +This is not supported when `alignOptionToTrigger` is `true`. + +```jsx + + + + +``` + +It automatically positions a wrapper element that can be styled or contain a custom SVG shape. + +## Animations + +The select can animate when opening or closing with either: + +- CSS transitions +- CSS animations +- JavaScript animations + +### CSS transitions + +Here is an example of how to apply a symmetric scale and fade transition with the default conditionally-rendered behavior: + +```jsx + + Option 1 + +``` + +```css +.SelectPopup { + transform-origin: var(--transform-origin); + transition-property: opacity, transform; + transition-duration: 0.2s; + /* Represents the final styles once exited */ + opacity: 0; + transform: scale(0.9); +} + +/* Represents the final styles once entered */ +.SelectPopup[data-open] { + opacity: 1; + transform: scale(1); +} + +/* Represents the initial styles when entering */ +.SelectPopup[data-entering] { + opacity: 0; + transform: scale(0.9); +} +``` + +Styles need to be applied in three states: + +- The exiting styles, placed on the base element class +- The open styles, placed on the base element class with `[data-state="open"]` +- The entering styles, placed on the base element class with `[data-entering]` + +In newer browsers, there is a feature called `@starting-style` which allows transitions to occur on open for conditionally-mounted components: + +```css +/* Base UI API - Polyfill */ +.SelectPopup[data-entering] { + opacity: 0; + transform: scale(0.9); +} + +/* Official Browser API - no Firefox support as of May 2024 */ +@starting-style { + .SelectPopup[data-open] { + opacity: 0; + transform: scale(0.9); + } +} +``` + +### CSS animations + +CSS animations can also be used, requiring only two separate declarations: + +```css +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.9); + } +} + +@keyframes scale-out { + to { + opacity: 0; + transform: scale(0.9); + } +} + +.SelectPopup { + animation: scale-in 0.2s forwards; +} + +.SelectPopup[data-exiting] { + animation: scale-out 0.2s forwards; +} +``` + +### JavaScript animations + +The `keepMounted` prop lets an external library control the mounting, for example `framer-motion`'s `AnimatePresence` component. + +```js +function App() { + const [open, setOpen] = useState(false); + return ( + + Trigger + + {open && ( + + + } + > + Option 1 + Option 2 + + + )} + + + ); +} +``` + +### Animation states + +Four states are available as data attributes to animate the popup, which enables full control depending on whether the popup is being animated with CSS transitions or animations, JavaScript, or is using the `keepMounted` prop. + +- `[data-open]` - `open` state is `true`. +- `[data-entering]` - the popup was just inserted to the DOM. The attribute is removed 1 animation frame later. Enables "starting styles" upon insertion for conditional rendering. +- `[data-exiting]` - the popup is in the process of being removed from the DOM, but is still mounted. + +## Overriding default components + +Use the `render` prop to override the rendered elements with your own components. + +```jsx +// Element shorthand +} /> +``` + +```jsx +// Function + } /> +``` diff --git a/docs/data/pages.ts b/docs/data/pages.ts index bb5d899a9d..b0efde5018 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -39,6 +39,7 @@ const pages: readonly RouteMetadata[] = [ { pathname: '/components/react-radio-group', title: 'Radio Group' }, { pathname: '/components/react-scroll-area', title: 'Scroll Area' }, { pathname: '/components/react-separator', title: 'Separator' }, + { pathname: '/components/react-select', title: 'Select' }, { pathname: '/components/react-slider', title: 'Slider' }, { pathname: '/components/react-switch', title: 'Switch' }, { pathname: '/components/react-tabs', title: 'Tabs' }, diff --git a/docs/data/translations/api-docs/select-arrow/select-arrow.json b/docs/data/translations/api-docs/select-arrow/select-arrow.json new file mode 100644 index 0000000000..3dfaaeafbf --- /dev/null +++ b/docs/data/translations/api-docs/select-arrow/select-arrow.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "hideWhenUncentered": { + "description": "If true, the arrow is hidden when it can't point to the center of the anchor element." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-backdrop/select-backdrop.json b/docs/data/translations/api-docs/select-backdrop/select-backdrop.json new file mode 100644 index 0000000000..17b35bc881 --- /dev/null +++ b/docs/data/translations/api-docs/select-backdrop/select-backdrop.json @@ -0,0 +1,14 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "container": { "description": "The container element to which the Backdrop is appended to." }, + "keepMounted": { + "description": "If true, the Backdrop remains mounted when the Select popup is closed." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-group-label/select-group-label.json b/docs/data/translations/api-docs/select-group-label/select-group-label.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/select-group-label/select-group-label.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-group/select-group.json b/docs/data/translations/api-docs/select-group/select-group.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/select-group/select-group.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-icon/select-icon.json b/docs/data/translations/api-docs/select-icon/select-icon.json new file mode 100644 index 0000000000..4bc12cf1e0 --- /dev/null +++ b/docs/data/translations/api-docs/select-icon/select-icon.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-option-indicator/select-option-indicator.json b/docs/data/translations/api-docs/select-option-indicator/select-option-indicator.json new file mode 100644 index 0000000000..9c4340d034 --- /dev/null +++ b/docs/data/translations/api-docs/select-option-indicator/select-option-indicator.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "keepMounted": { + "description": "If true, the item indicator remains mounted when the item is not selected." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-option-text/select-option-text.json b/docs/data/translations/api-docs/select-option-text/select-option-text.json new file mode 100644 index 0000000000..f93d4cbd8c --- /dev/null +++ b/docs/data/translations/api-docs/select-option-text/select-option-text.json @@ -0,0 +1 @@ +{ "componentDescription": "", "propDescriptions": {}, "classDescriptions": {} } diff --git a/docs/data/translations/api-docs/select-option/select-option.json b/docs/data/translations/api-docs/select-option/select-option.json new file mode 100644 index 0000000000..722b9e0ff6 --- /dev/null +++ b/docs/data/translations/api-docs/select-option/select-option.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "disabled": { "description": "If true, the select option will be disabled." }, + "label": { + "description": "A text representation of the select option's content. Used for keyboard text navigation matching." + }, + "value": { "description": "The value of the select option." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-popup/select-popup.json b/docs/data/translations/api-docs/select-popup/select-popup.json new file mode 100644 index 0000000000..4a1c0a2068 --- /dev/null +++ b/docs/data/translations/api-docs/select-popup/select-popup.json @@ -0,0 +1,11 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "id": { "description": "The id of the popup element." }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-positioner/select-positioner.json b/docs/data/translations/api-docs/select-positioner/select-positioner.json new file mode 100644 index 0000000000..bb34207487 --- /dev/null +++ b/docs/data/translations/api-docs/select-positioner/select-positioner.json @@ -0,0 +1,43 @@ +{ + "componentDescription": "", + "propDescriptions": { + "alignment": { + "description": "The alignment of the Select element to the anchor element along its cross axis." + }, + "alignmentOffset": { + "description": "The offset of the Select element along its alignment axis." + }, + "anchor": { "description": "The anchor element to which the Select popup will be placed at." }, + "arrowPadding": { + "description": "Determines the padding between the arrow and the Select popup's edges. Useful when the popover popup has rounded corners via border-radius." + }, + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "collisionBoundary": { + "description": "The boundary that the Select element should be constrained to." + }, + "collisionPadding": { "description": "The padding of the collision boundary." }, + "container": { + "description": "The container element to which the Select popup will be appended to." + }, + "hideWhenDetached": { + "description": "If true, the Select will be hidden if it is detached from its anchor element due to differing clipping contexts." + }, + "positionMethod": { + "description": "The CSS position method for positioning the Select popup element." + }, + "render": { "description": "A function to customize rendering of the component." }, + "side": { + "description": "The side of the anchor element that the Select element should align to." + }, + "sideOffset": { "description": "The gap between the anchor element and the Select element." }, + "sticky": { + "description": "If true, allow the Select to remain in stuck view while the anchor element is scrolled out of view." + }, + "trackAnchor": { + "description": "Whether the select popup continuously tracks its anchor after the initial positioning upon mount." + } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-root/select-root.json b/docs/data/translations/api-docs/select-root/select-root.json new file mode 100644 index 0000000000..04261f8702 --- /dev/null +++ b/docs/data/translations/api-docs/select-root/select-root.json @@ -0,0 +1,28 @@ +{ + "componentDescription": "", + "propDescriptions": { + "alignOptionToTrigger": { + "description": "Determines if the selected option inside the popup should align to the trigger element." + }, + "animated": { + "description": "If true, the Select supports CSS-based animations and transitions. It is kept in the DOM until the animation completes." + }, + "defaultOpen": { "description": "If true, the Select is initially open." }, + "defaultValue": { "description": "The default value of the select." }, + "disabled": { "description": "If true, the Select is disabled." }, + "name": { "description": "The name of the Select in the owning form." }, + "onOpenChange": { + "description": "Callback fired when the component requests to be opened or closed." + }, + "onValueChange": { + "description": "Callback fired when the value of the select changes. Use when controlled." + }, + "open": { + "description": "Allows to control whether the dropdown is open. This is a controlled counterpart of defaultOpen." + }, + "readOnly": { "description": "If true, the Select is read-only." }, + "required": { "description": "If true, the Select is required." }, + "value": { "description": "The value of the select." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-scroll-down-arrow/select-scroll-down-arrow.json b/docs/data/translations/api-docs/select-scroll-down-arrow/select-scroll-down-arrow.json new file mode 100644 index 0000000000..47e423952c --- /dev/null +++ b/docs/data/translations/api-docs/select-scroll-down-arrow/select-scroll-down-arrow.json @@ -0,0 +1,9 @@ +{ + "componentDescription": "", + "propDescriptions": { + "keepMounted": { + "description": "Whether the component should be kept mounted when it is not rendered." + } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-scroll-up-arrow/select-scroll-up-arrow.json b/docs/data/translations/api-docs/select-scroll-up-arrow/select-scroll-up-arrow.json new file mode 100644 index 0000000000..47e423952c --- /dev/null +++ b/docs/data/translations/api-docs/select-scroll-up-arrow/select-scroll-up-arrow.json @@ -0,0 +1,9 @@ +{ + "componentDescription": "", + "propDescriptions": { + "keepMounted": { + "description": "Whether the component should be kept mounted when it is not rendered." + } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-trigger/select-trigger.json b/docs/data/translations/api-docs/select-trigger/select-trigger.json new file mode 100644 index 0000000000..814aa3e491 --- /dev/null +++ b/docs/data/translations/api-docs/select-trigger/select-trigger.json @@ -0,0 +1,15 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "disabled": { "description": "If true, the component is disabled." }, + "focusableWhenDisabled": { + "description": "If true, allows a disabled button to receive focus." + }, + "label": { "description": "Label of the button" }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/data/translations/api-docs/select-value/select-value.json b/docs/data/translations/api-docs/select-value/select-value.json new file mode 100644 index 0000000000..900c8e0486 --- /dev/null +++ b/docs/data/translations/api-docs/select-value/select-value.json @@ -0,0 +1,13 @@ +{ + "componentDescription": "", + "propDescriptions": { + "className": { + "description": "Class names applied to the element or a function that returns them based on the component's state." + }, + "placeholder": { + "description": "The placeholder value to display when the value is empty (such as during SSR)." + }, + "render": { "description": "A function to customize rendering of the component." } + }, + "classDescriptions": {} +} diff --git a/docs/src/app/experiments/select-perf.tsx b/docs/src/app/experiments/select-perf.tsx new file mode 100644 index 0000000000..045d9fcf06 --- /dev/null +++ b/docs/src/app/experiments/select-perf.tsx @@ -0,0 +1,76 @@ +'use client'; +import * as React from 'react'; +import { Select as BaseSelect } from '@base_ui/react/Select'; + +const options = [...Array(1000)].map((_, i) => `Item ${i + 1}`); + +const arrowStyles: React.CSSProperties = { + width: 'calc(100% - 2px)', + margin: '0 auto', + textAlign: 'center', + background: 'white', + fontSize: 12, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginInline: 1, +}; + +function BaseSelectExample() { + return ( + + + + + + + + {options.map((item) => ( + + {item} + + ))} + + + + + ); +} + +export default function SelectPerf() { + return ( + +

Base UI Select

+ +
+ ); +} diff --git a/docs/src/styles/reset.css b/docs/src/styles/reset.css index b578b16a7c..85fa6ec163 100644 --- a/docs/src/styles/reset.css +++ b/docs/src/styles/reset.css @@ -2,6 +2,13 @@ body { margin: 0; /* background-color: var(--gray-surface-2); */ padding-top: 49px; + font-family: var(--inter); +} + +*, +*::before, +*::after { + box-sizing: border-box; } *, diff --git a/docs/src/styles/theme.css b/docs/src/styles/theme.css index fd6b22b6d5..2bf3f62142 100644 --- a/docs/src/styles/theme.css +++ b/docs/src/styles/theme.css @@ -44,6 +44,7 @@ --br-circle: 50%; --br-pill: 9999px; + --inter: 'Inter', sans-serif; --ff-sans: graphik, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; --ff-code: Söhne mono, ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', diff --git a/docs/translations/translations.json b/docs/translations/translations.json new file mode 100644 index 0000000000..b38a689c72 --- /dev/null +++ b/docs/translations/translations.json @@ -0,0 +1,243 @@ +{ + "adblock": "If you don't mind tech-related ads (no tracking or remarketing), and want to keep us running, please whitelist us in your blocker.", + "api-docs": { + "componentName": "Component name", + "componentsApi": "Components API", + "themeDefaultProps": "Theme default props", + "themeDefaultPropsDescription": "You can use {{muiName}} to change the default props of this component with the theme.", + "classes": "CSS classes", + "classesDescription": "These class names are useful for styling with CSS. They are applied to the component's slots when specific states are triggered.", + "className": "Class name", + "cssDescription": "The following class names are useful for styling with CSS (the state classes are marked).
To learn more, visit the component customization page.", + "css": "CSS", + "cssComponent": "As a CSS utility, the {{name}} component also supports all system properties. You can use them as props directly on the component.", + "default": "Default", + "defaultComponent": "Default component", + "defaultValue": "Default value", + "defaultHTMLTag": "Default HTML tag", + "demos": "Component demos", + "deprecated": "Deprecated", + "description": "Description", + "globalClass": "Global class", + "defaultClass": "Default class", + "hookName": "Hook name", + "hooksApi": "Hooks API", + "hooksNoParameters": "This hook does not accept any input parameters.", + "hooksPageDescription": "API reference docs for the {{name}} hook. Learn about the input parameters and other APIs of this exported module.", + "import": "Import", + "importDifference": "Learn about the difference by reading this guide on minimizing bundle size.", + "inheritance": "Inheritance", + "inheritanceDescription": "While not explicitly documented above, the props of the {{component}} component{{suffix}} are also available in {{name}}. You can take advantage of this to target nested components.", + "inheritanceSuffixTransition": " from react-transition-group", + "name": "Name", + "nativeElement": "native", + "overrideStyles": "You can override the style of the component using one of these customization options:\n", + "overrideStylesStyledComponent": "", + "pageDescription": "API reference docs for the React {{name}} component. Learn about the props, CSS, and other APIs of this exported module.", + "props": "Props", + "parameters": "Parameters", + "requires-ref": "This needs to be able to hold a ref.", + "returns": "Returns: ", + "returnValue": "Return value", + "refNotHeld": "The component cannot hold a ref.", + "refRootElement": "The ref is forwarded to the root element.", + "ruleName": "Rule name", + "signature": "Signature", + "slots": "Slots", + "spreadHint": "Props of the {{spreadHintElement}} component are also available.", + "state": "STATE", + "styleOverrides": "The name {{componentStyles.name}} can be used when providing default props or style overrides in the theme.", + "slotDescription": "To learn how to customize the slot, check out the Overriding component structure guide.", + "slotName": "Slot name", + "type": "Type", + "required": "Required", + "optional": "Optional", + "additional-info": { + "cssApi": "See CSS API below for more details.", + "sx": "See the `sx` page for more details.", + "slotsApi": "See Slots API below for more details.", + "joy-size": "To learn how to add custom sizes to the component, check out Themed components—Extend sizes.", + "joy-color": "To learn how to add your own colors, check out Themed components—Extend colors.", + "joy-variant": "To learn how to add your own variants, check out Themed components—Extend variants." + } + }, + "landingPageDescr": "A responsive landing page layout with many common sections.", + "landingPageTitle": "Landing page", + "searchButton": "Search…", + "algoliaSearch": "What are you looking for?", + "appFrame": { + "changeLanguage": "Change language", + "github": "GitHub repository", + "helpToTranslate": "Help to translate", + "openDrawer": "Open main navigation", + "skipToContent": "Skip to content", + "toggleSettings": "Toggle settings drawer" + }, + "backToTop": "Scroll back to top", + "blogDescr": "A sophisticated blog page layout. Markdown support is courtesy of markdown-to-jsx.", + "blogTitle": "Blog", + "bundleSize": "Bundle size", + "bundleSizeTooltip": "Scroll down to 'Exports Analysis' for a more detailed report.", + "cancel": "Cancel", + "cdn": "or use a CDN.", + "checkoutDescr": "A step-by-step checkout page layout. Adapt the number of steps to suit your needs, or make steps optional.", + "checkoutTitle": "Checkout", + "clickToCopy": "Click to copy", + "close": "Close", + "codesandbox": "Edit in CodeSandbox", + "copied": "Copied", + "copiedSource": "The source code has been copied to your clipboard.", + "copiedSourceLink": "Link to the source code has been copied to your clipboard.", + "copySource": "Copy the source", + "copySourceLinkJS": "Copy link to JavaScript source", + "copySourceLinkTS": "Copy link to TypeScript source", + "dashboardDescr": "Contains a taskbar and a mini variant drawer. The chart is courtesy of Recharts.", + "dashboardTitle": "Dashboard", + "decreaseSpacing": "decrease spacing", + "demoToolbarLabel": "demo source", + "demoStylingSelectSystem": "MUI System", + "demoStylingSelectTailwind": "Tailwind CSS", + "demoStylingSelectCSS": "Plain CSS", + "diamondSponsors": "Diamond sponsors", + "becomeADiamondSponsor": "Become a Diamond sponsor", + "diamondSponsorVacancies": "One spot left!", + "editorHint": "Press Enter to start editing", + "editPage": "Edit this page", + "emojiLove": "Love", + "emojiWarning": "Warning", + "expandAll": "Expand all", + "feedbackCommentLabel": "Comment", + "feedbackFailed": "Couldn't submit feedback. Please try again later.", + "feedbackMessage": "Was this page helpful?", + "feedbackMessageDown": "How can we improve this page? (optional)", + "feedbackMessageUp": "What did you like about this page? (optional)", + "feedbackSectionSpecific": "How can we improve the {{sectionName}} section? (optional)", + "feedbackMessageToGitHub": { + "usecases": "If something is broken or if you need a reply to a problem you've encountered, please", + "reasonWhy": "Otherwise, the team won't be able to answer back or ask for more information.", + "callToAction": { + "link": "open an issue instead." + } + }, + "feedbackNo": "No", + "feedbackSubmitted": "Feedback submitted", + "feedbackYes": "Yes", + "footerCompany": "Company", + "goToHome": "go to homepage", + "getProfessionalSupport": "Get Professional Support", + "getStarted": "Get Started", + "githubLabel": "Feedback", + "headTitle": "MUI: A popular React UI framework", + "hideFullSource": "Collapse code", + "hideSource": "Hide code", + "homeQuickWord": "A quick word from our sponsors:", + "increaseSpacing": "increase spacing", + "initialFocusLabel": "A generic container that is programmatically focused to test keyboard navigation of our components.", + "installation": "Installation", + "installButton": "Read installation docs", + "installDescr": "Install MUI's source files via npm. We take care of injecting the CSS needed.", + "joinThese": "Join these and other great organizations!", + "JS": "JavaScript", + "letUsKnow": "Let us know!", + "likeMui": "Help us keep running", + "loadFont": "Load the default Roboto font.", + "mainNavigation": "documentation", + "newest": "Newest", + "openDrawer": "Open documentation navigation", + "or": "or", + "pageTOC": "Page table of contents", + "praise": "Praise for MUI", + "praiseDescr": "Here's what some of our users are saying.", + "pricingDescr": "Quickly build an effective pricing table for your potential customers.", + "pricingTitle": "Pricing", + "resetDemo": "Reset demo", + "resetDensity": "Reset density", + "resetFocus": "Reset focus to test keyboard navigation", + "searchIcons": { + "learnMore": "Learn more about the import" + }, + "seeMore": "See more", + "settings": { + "color": "Color", + "dark": "Dark", + "direction": "Direction", + "editWebsiteColors": "Edit website colors", + "light": "Light", + "ltr": "Left to right", + "mode": "Mode", + "rtl": "Right to left", + "settings": "Settings", + "system": "System", + "language": "Language" + }, + "showFullSource": "Expand code", + "showJSSource": "Show JavaScript source", + "showSource": "Show code", + "showTSSource": "Show TypeScript source", + "signInDescr": "A simple sign-in page using text fields, buttons, checkboxes, links, and more.", + "signInSideDescr": "A simple sign-in page with a two-column layout using text fields, buttons, and more.", + "signInSideTitle": "Sign-in side", + "signInTitle": "Sign-in", + "signUpDescr": "A simple sign-up page using text fields, buttons, checkboxes, links, and more.", + "signUpTitle": "Sign-up", + "sourceCode": "Source code", + "spacingUnit": "Spacing unit", + "stackblitz": "Edit in StackBlitz", + "stars": "GitHub stars", + "stickyFooterDescr": "Attach a footer to the bottom of the viewport when page content is short.", + "stickyFooterTitle": "Sticky footer", + "strapline": "MUI provides a simple, customizable, and accessible library of React components. Follow your own design system, or start with Material Design.", + "submit": "Submit", + "tableOfContents": "Contents", + "thanks": "Thank you!", + "themes": "Premium themes", + "themesButton": "Browse themes", + "themesDescr": "Take your project to the next level with premium themes from our store – all built on MUI.", + "toggleNotifications": "Toggle notifications panel", + "toggleRTL": "Toggle right-to-left/left-to-right", + "traffic": "Traffic", + "TS": "TypeScript", + "v5IsOut": "🎉 v5 release candidate is out! Head to the", + "v5docsLink": "v5 documentation", + "v5startAdoption": "to get started.", + "unreadNotifications": "unread notifications", + "usage": "Usage", + "usageButton": "Explore the docs", + "usageDescr": "MUI components work without any additional setup, and don't pollute the global scope.", + "useDarkTheme": "Use dark theme", + "useHighDensity": "Apply higher density via props", + "usingMui": "Are you using MUI?", + "viewGitHub": "View the source on GitHub", + "visit": "Visit the website", + "whosUsing": "Who's using MUI?", + "pages": { + "/base-ui/getting-started-group": "Getting started", + "/base-ui/getting-started": "Overview", + "/base-ui/getting-started/quickstart": "Quickstart", + "/base-ui/getting-started/usage": "Usage", + "/base-ui/getting-started/support": "Support", + "/base-ui/react-": "Components", + "/base-ui/all-components": "All components", + "inputs": "Inputs", + "/base-ui/react-checkbox": "Checkbox", + "/base-ui/react-number-field": "Number Field", + "/base-ui/react-select": "Select", + "/base-ui/react-slider": "Slider", + "/base-ui/react-switch": "Switch", + "data-display": "Data display", + "/base-ui/react-popover": "Popover", + "/base-ui/react-preview-card": "Preview Card", + "/base-ui/react-tooltip": "Tooltip", + "/base-ui/react-field": "Field", + "/base-ui/react-fieldset": "Fieldset", + "feedback": "Feedback", + "/base-ui/react-alert-dialog": "Alert Dialog", + "/base-ui/react-dialog": "Dialog", + "/base-ui/react-progress": "Progress", + "navigation": "Navigation", + "/base-ui/react-menu": "Menu", + "/base-ui/react-tabs": "Tabs", + "/base-ui/guides": "How-to guides", + "/base-ui/guides/next-js-app-router": "Next.js App Router" + } +} diff --git a/packages/mui-base/package.json b/packages/mui-base/package.json index a38b7a44e6..0243673a25 100644 --- a/packages/mui-base/package.json +++ b/packages/mui-base/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@babel/runtime": "^7.26.0", - "@floating-ui/react": "^0.26.26", + "@floating-ui/react": "^0.26.27", "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.8", "@mui/types": "^7.2.19", diff --git a/packages/mui-base/src/Field/Control/useFieldControlValidation.ts b/packages/mui-base/src/Field/Control/useFieldControlValidation.ts index 477c6cb058..9c6b45a174 100644 --- a/packages/mui-base/src/Field/Control/useFieldControlValidation.ts +++ b/packages/mui-base/src/Field/Control/useFieldControlValidation.ts @@ -6,6 +6,7 @@ import { mergeReactProps } from '../../utils/mergeReactProps'; import { DEFAULT_VALIDITY_STATE } from '../utils/constants'; import { useFormRootContext } from '../../Form/Root/FormRootContext'; import { getCombinedFieldValidityData } from '../utils/getCombinedFieldValidityData'; +import type { GenericHTMLProps } from '../../utils/types'; const validityKeys = Object.keys(DEFAULT_VALIDITY_STATE) as Array; @@ -161,3 +162,12 @@ export function useFieldControlValidation() { [getValidationProps, getInputValidationProps, commitValidation], ); } + +export namespace useFieldControlValidation { + export interface ReturnValue { + getValidationProps: (props?: GenericHTMLProps) => GenericHTMLProps; + getInputValidationProps: (props?: GenericHTMLProps) => GenericHTMLProps; + inputRef: React.MutableRefObject; + commitValidation: (value: unknown) => void; + } +} diff --git a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx index 43b71b0859..429fcf540b 100644 --- a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx +++ b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx @@ -6,17 +6,13 @@ import { NumberField } from '@base_ui/react/NumberField'; import { Slider } from '@base_ui/react/Slider'; import { RadioGroup } from '@base_ui/react/RadioGroup'; import { Radio } from '@base_ui/react/Radio'; +import { Select } from '@base_ui/react/Select'; import userEvent from '@testing-library/user-event'; -import { - act, - createRenderer, - fireEvent, - flushMicrotasks, - screen, - waitFor, -} from '@mui/internal-test-utils'; +import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; import { expect } from 'chai'; -import { describeConformance } from '../../../test/describeConformance'; +import { createRenderer, describeConformance } from '#test-utils'; + +const user = userEvent.setup(); describe('', () => { const { render } = createRenderer(); @@ -27,10 +23,10 @@ describe('', () => { })); describe('prop: disabled', () => { - it('should add data-disabled style hook to all components', () => { - render( + it('should add data-disabled style hook to all components', async () => { + await render( - + , @@ -49,8 +45,8 @@ describe('', () => { }); describe('prop: validate', () => { - it('should validate the field on blur', () => { - render( + it('should validate the field on blur', async () => { + await render( 'error'}> @@ -69,7 +65,7 @@ describe('', () => { }); it('supports async validation', async () => { - render( + await render( Promise.resolve('error')}> @@ -91,8 +87,8 @@ describe('', () => { }); }); - it('should apply [data-valid] and [data-invalid] style hooks to field components', () => { - render( + it('should apply [data-field] style hooks to field components', async () => { + await render( Label Description @@ -137,8 +133,8 @@ describe('', () => { expect(error).to.equal(null); }); - it('should apply aria-invalid prop to control once validated', () => { - render( + it('should apply aria-invalid prop to control once validated', async () => { + await render( 'error'}> @@ -156,8 +152,8 @@ describe('', () => { }); describe('component integration', () => { - it('supports Checkbox', () => { - render( + it('supports Checkbox', async () => { + await render( 'error'}> @@ -174,8 +170,8 @@ describe('', () => { expect(button).to.have.attribute('aria-invalid', 'true'); }); - it('supports Switch', () => { - render( + it('supports Switch', async () => { + await render( 'error'}> @@ -192,8 +188,8 @@ describe('', () => { expect(button).to.have.attribute('aria-invalid', 'true'); }); - it('supports NumberField', () => { - render( + it('supports NumberField', async () => { + await render( 'error'}> @@ -212,8 +208,8 @@ describe('', () => { expect(input).to.have.attribute('aria-invalid', 'true'); }); - it('supports Slider', () => { - const { container } = render( + it('supports Slider', async () => { + const { container } = await render( 'error'}> @@ -236,8 +232,8 @@ describe('', () => { expect(input).to.have.attribute('aria-invalid', 'true'); }); - it('supports RadioGroup', () => { - render( + it('supports RadioGroup', async () => { + await render( 'error'}> One @@ -256,12 +252,34 @@ describe('', () => { expect(group).to.have.attribute('aria-invalid', 'true'); }); + + it('supports Select', async () => { + await render( + 'error'}> + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).not.to.have.attribute('aria-invalid'); + + fireEvent.focus(trigger); + fireEvent.blur(trigger); + + await flushMicrotasks(); + + expect(trigger).to.have.attribute('aria-invalid', 'true'); + }); }); }); describe('prop: validationMode', () => { it('should validate the field on change', async () => { - render( + await render( { @@ -292,7 +310,7 @@ describe('', () => { clock.withFakeTimers(); it('should debounce validation', async () => { - renderFakeTimers( + await renderFakeTimers( ', () => { describe('style hooks', () => { describe('touched', () => { - it('should apply [data-touched] style hook to all components when touched', () => { - render( + it('should apply [data-touched] style hook to all components when touched', async () => { + await render( @@ -364,8 +382,8 @@ describe('', () => { expect(error).to.equal(null); }); - it('supports Checkbox', () => { - render( + it('supports Checkbox', async () => { + await render( , @@ -379,8 +397,8 @@ describe('', () => { expect(button).to.have.attribute('data-touched', ''); }); - it('supports Switch', () => { - render( + it('supports Switch', async () => { + await render( , @@ -394,8 +412,8 @@ describe('', () => { expect(button).to.have.attribute('data-touched', ''); }); - it('supports NumberField', () => { - render( + it('supports NumberField', async () => { + await render( @@ -411,8 +429,8 @@ describe('', () => { expect(input).to.have.attribute('data-touched', ''); }); - it('supports Slider', () => { - render( + it('supports Slider', async () => { + await render( @@ -431,8 +449,35 @@ describe('', () => { expect(root).to.have.attribute('data-touched', ''); }); - it('supports RadioGroup (click)', () => { - render( + it('supports Select', async () => { + await render( + + + + + + Select + Option 1 + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).not.to.have.attribute('data-dirty'); + + fireEvent.focus(trigger); + fireEvent.blur(trigger); + + await flushMicrotasks(); + + expect(trigger).to.have.attribute('data-touched', ''); + }); + + it('supports RadioGroup (click)', async () => { + await render( @@ -453,7 +498,7 @@ describe('', () => { }); it('supports RadioGroup (blur)', async () => { - render( + await render( @@ -477,8 +522,8 @@ describe('', () => { }); describe('dirty', () => { - it('should apply [data-dirty] style hook to all components when dirty', () => { - render( + it('should apply [data-dirty] style hook to all components when dirty', async () => { + await render( @@ -512,8 +557,8 @@ describe('', () => { expect(description).not.to.have.attribute('data-dirty'); }); - it('supports Checkbox', () => { - render( + it('supports Checkbox', async () => { + await render( , @@ -528,8 +573,8 @@ describe('', () => { expect(button).to.have.attribute('data-dirty', ''); }); - it('supports Switch', () => { - render( + it('supports Switch', async () => { + await render( , @@ -544,8 +589,8 @@ describe('', () => { expect(button).to.have.attribute('data-dirty', ''); }); - it('supports NumberField', () => { - render( + it('supports NumberField', async () => { + await render( @@ -562,8 +607,8 @@ describe('', () => { expect(input).to.have.attribute('data-dirty', ''); }); - it('supports Slider', () => { - const { container } = render( + it('supports Slider', async () => { + const { container } = await render( @@ -584,8 +629,8 @@ describe('', () => { expect(root).to.have.attribute('data-dirty', ''); }); - it('supports RadioGroup', () => { - render( + it('supports RadioGroup', async () => { + await render( One @@ -602,6 +647,41 @@ describe('', () => { expect(group).to.have.attribute('data-dirty', ''); }); + + it('supports Select', async () => { + await render( + + + + + + Select + Option 1 + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + expect(trigger).not.to.have.attribute('data-dirty'); + + await userEvent.click(trigger); + + await flushMicrotasks(); + + const option = screen.getByRole('option', { name: 'Option 1' }); + + // Arrow Down to focus the Option 1 + await user.keyboard('{ArrowDown}'); + + await userEvent.click(option); + + await flushMicrotasks(); + + expect(trigger).to.have.attribute('data-dirty', ''); + }); }); }); }); diff --git a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts index 818bb235fe..2e95b8b269 100644 --- a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts +++ b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts @@ -41,7 +41,6 @@ export function useMenuPositioner( style: { ...positionerStyles, ...hiddenStyles, - zIndex: 2147483647, // max z-index }, 'aria-hidden': !open || undefined, inert: getInertValue(!open), diff --git a/packages/mui-base/src/Menu/Root/useMenuRoot.ts b/packages/mui-base/src/Menu/Root/useMenuRoot.ts index ff3b472abb..9ccdcb6df9 100644 --- a/packages/mui-base/src/Menu/Root/useMenuRoot.ts +++ b/packages/mui-base/src/Menu/Root/useMenuRoot.ts @@ -18,6 +18,7 @@ import { useTransitionStatus } from '../../utils/useTransitionStatus'; import { useEventCallback } from '../../utils/useEventCallback'; import { useAnimationsFinished } from '../../utils/useAnimationsFinished'; import { useControlled } from '../../utils/useControlled'; +import { TYPEAHEAD_RESET_MS } from '../../utils/constants'; const EMPTY_ARRAY: never[] = []; @@ -115,7 +116,7 @@ export function useMenuRoot(parameters: useMenuRoot.Parameters): useMenuRoot.Ret const typeahead = useTypeahead(floatingRootContext, { listRef: itemLabels, activeIndex, - resetMs: 350, + resetMs: TYPEAHEAD_RESET_MS, onMatch: (index) => { if (open && index !== activeIndex) { setActiveIndex(index); diff --git a/packages/mui-base/src/NumberField/Root/useScrub.ts b/packages/mui-base/src/NumberField/Root/useScrub.ts index 85764f5bb9..8142b8fd7b 100644 --- a/packages/mui-base/src/NumberField/Root/useScrub.ts +++ b/packages/mui-base/src/NumberField/Root/useScrub.ts @@ -173,7 +173,6 @@ export function useScrub(params: ScrubParams) { top: 0, left: 0, pointerEvents: 'none', - zIndex: 2147483647, // max z-index }, }, ), diff --git a/packages/mui-base/src/Popover/Backdrop/usePopoverBackdrop.ts b/packages/mui-base/src/Popover/Backdrop/usePopoverBackdrop.ts index 08e5eadb97..e8cbfebd1b 100644 --- a/packages/mui-base/src/Popover/Backdrop/usePopoverBackdrop.ts +++ b/packages/mui-base/src/Popover/Backdrop/usePopoverBackdrop.ts @@ -7,7 +7,6 @@ export function usePopoverBackdrop(): usePopoverBackdrop.ReturnValue { return mergeReactProps<'div'>(externalProps, { role: 'presentation', style: { - zIndex: 2147483647, // max z-index overflow: 'auto', position: 'fixed', inset: 0, diff --git a/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx b/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx index 278b607ac5..1fcfa6e81a 100644 --- a/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx +++ b/packages/mui-base/src/Popover/Positioner/usePopoverPositioner.tsx @@ -70,7 +70,6 @@ export function usePopoverPositioner( style: { ...positionerStyles, ...hiddenStyles, - zIndex: 2147483647, // max z-index }, }); }, diff --git a/packages/mui-base/src/PreviewCard/Backdrop/usePreviewCardBackdrop.ts b/packages/mui-base/src/PreviewCard/Backdrop/usePreviewCardBackdrop.ts index a6d90254af..c779055338 100644 --- a/packages/mui-base/src/PreviewCard/Backdrop/usePreviewCardBackdrop.ts +++ b/packages/mui-base/src/PreviewCard/Backdrop/usePreviewCardBackdrop.ts @@ -6,7 +6,6 @@ export function usePreviewCardBackdrop(): usePreviewCardBackdrop.ReturnValue { const getBackdropProps = React.useCallback((externalProps = {}) => { return mergeReactProps<'div'>(externalProps, { style: { - zIndex: 2147483647, // max z-index overflow: 'auto', position: 'fixed', inset: 0, diff --git a/packages/mui-base/src/PreviewCard/Positioner/usePreviewCardPositioner.ts b/packages/mui-base/src/PreviewCard/Positioner/usePreviewCardPositioner.ts index 3a9991d890..348d39b2de 100644 --- a/packages/mui-base/src/PreviewCard/Positioner/usePreviewCardPositioner.ts +++ b/packages/mui-base/src/PreviewCard/Positioner/usePreviewCardPositioner.ts @@ -39,7 +39,6 @@ export function usePreviewCardPositioner( style: { ...positionerStyles, ...hiddenStyles, - zIndex: 2147483647, // max z-index }, }); }, diff --git a/packages/mui-base/src/Select/Arrow/SelectArrow.test.tsx b/packages/mui-base/src/Select/Arrow/SelectArrow.test.tsx new file mode 100644 index 0000000000..e1da705726 --- /dev/null +++ b/packages/mui-base/src/Select/Arrow/SelectArrow.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/Arrow/SelectArrow.tsx b/packages/mui-base/src/Select/Arrow/SelectArrow.tsx new file mode 100644 index 0000000000..abd0ea352e --- /dev/null +++ b/packages/mui-base/src/Select/Arrow/SelectArrow.tsx @@ -0,0 +1,111 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useSelectPositionerContext } from '../Positioner/SelectPositionerContext'; +import { useSelectRootContext } from '../Root/SelectRootContext'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useForkRef } from '../../utils/useForkRef'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { popupOpenStateMapping } from '../../utils/popupOpenStateMapping'; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectArrow API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectArrow) + */ +const SelectArrow = React.forwardRef(function SelectArrow( + props: SelectArrow.Props, + forwardedRef: React.ForwardedRef, +) { + const { className, render, hideWhenUncentered = false, ...otherProps } = props; + + const { open, alignOptionToTrigger } = useSelectRootContext(); + const { arrowRef, side, alignment, arrowUncentered, arrowStyles } = useSelectPositionerContext(); + + const getArrowProps = React.useCallback( + (externalProps = {}) => + mergeReactProps<'div'>(externalProps, { + style: { + ...arrowStyles, + ...(hideWhenUncentered && arrowUncentered ? { visibility: 'hidden' } : {}), + }, + }), + [arrowStyles, hideWhenUncentered, arrowUncentered], + ); + + const ownerState: SelectArrow.OwnerState = React.useMemo( + () => ({ + open, + side, + alignment, + arrowUncentered, + }), + [open, side, alignment, arrowUncentered], + ); + + const mergedRef = useForkRef(arrowRef, forwardedRef); + + const { renderElement } = useComponentRenderer({ + propGetter: getArrowProps, + render: render ?? 'div', + className, + ownerState, + ref: mergedRef, + extraProps: otherProps, + customStyleHookMapping: popupOpenStateMapping, + }); + + if (alignOptionToTrigger) { + return null; + } + + return renderElement(); +}); + +namespace SelectArrow { + export interface OwnerState { + open: boolean; + side: 'top' | 'bottom' | 'left' | 'right' | 'none'; + alignment: 'start' | 'center' | 'end'; + arrowUncentered: boolean; + } + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * If `true`, the arrow is hidden when it can't point to the center of the anchor element. + * @default false + */ + hideWhenUncentered?: boolean; + } +} + +SelectArrow.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * If `true`, the arrow is hidden when it can't point to the center of the anchor element. + * @default false + */ + hideWhenUncentered: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectArrow }; diff --git a/packages/mui-base/src/Select/Backdrop/SelectBackdrop.test.tsx b/packages/mui-base/src/Select/Backdrop/SelectBackdrop.test.tsx new file mode 100644 index 0000000000..3d8251c77b --- /dev/null +++ b/packages/mui-base/src/Select/Backdrop/SelectBackdrop.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/Backdrop/SelectBackdrop.tsx b/packages/mui-base/src/Select/Backdrop/SelectBackdrop.tsx new file mode 100644 index 0000000000..049583aae3 --- /dev/null +++ b/packages/mui-base/src/Select/Backdrop/SelectBackdrop.tsx @@ -0,0 +1,124 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { FloatingPortal } from '@floating-ui/react'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { HTMLElementType } from '../../utils/proptypes'; +import { useSelectRootContext } from '../Root/SelectRootContext'; +import { useSelectBackdrop } from './useSelectBackdrop'; +import { popupOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import type { TransitionStatus } from '../../utils/useTransitionStatus'; + +const customStyleHookMapping: CustomStyleHookMapping = { + ...popupOpenStateMapping, + transitionStatus(value): Record | null { + if (value === 'entering') { + return { 'data-entering': '' }; + } + + if (value === 'exiting') { + return { 'data-exiting': '' }; + } + + return null; + }, +}; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectBackdrop API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectBackdrop) + */ +const SelectBackdrop = React.forwardRef(function SelectBackdrop( + props: SelectBackdrop.Props, + forwardedRef: React.ForwardedRef, +) { + const { className, render, keepMounted = false, container, ...otherProps } = props; + + const { open, mounted, transitionStatus } = useSelectRootContext(); + + const { getBackdropProps } = useSelectBackdrop(); + + const ownerState: SelectBackdrop.OwnerState = React.useMemo( + () => ({ open, transitionStatus }), + [open, transitionStatus], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getBackdropProps, + render: render ?? 'div', + className, + ownerState, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping, + }); + + const shouldRender = keepMounted || mounted; + if (!shouldRender) { + return null; + } + + return {renderElement()}; +}); + +namespace SelectBackdrop { + export interface Props extends BaseUIComponentProps<'div', OwnerState> { + /** + * If `true`, the Backdrop remains mounted when the Select popup is closed. + * @default false + */ + keepMounted?: boolean; + /** + * The container element to which the Backdrop is appended to. + * @default false + */ + container?: HTMLElement | null | React.MutableRefObject; + } + + export interface OwnerState { + open: boolean; + transitionStatus: TransitionStatus; + } +} + +SelectBackdrop.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * The container element to which the Backdrop is appended to. + * @default false + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.func, + ]), + /** + * If `true`, the Backdrop remains mounted when the Select popup is closed. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectBackdrop }; diff --git a/packages/mui-base/src/Select/Backdrop/useSelectBackdrop.ts b/packages/mui-base/src/Select/Backdrop/useSelectBackdrop.ts new file mode 100644 index 0000000000..a4dcb26993 --- /dev/null +++ b/packages/mui-base/src/Select/Backdrop/useSelectBackdrop.ts @@ -0,0 +1,30 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +/** + * + * API: + * + * - [useSelectBackdrop API](https://mui.com/base-ui/api/use-select-backdrop/) + */ +export function useSelectBackdrop() { + const getBackdropProps = React.useCallback((externalProps = {}) => { + return mergeReactProps<'div'>(externalProps, { + role: 'presentation', + style: { + overflow: 'auto', + position: 'fixed', + inset: 0, + pointerEvents: 'none', + }, + }); + }, []); + + return React.useMemo( + () => ({ + getBackdropProps, + }), + [getBackdropProps], + ); +} diff --git a/packages/mui-base/src/Select/Group/SelectGroup.test.tsx b/packages/mui-base/src/Select/Group/SelectGroup.test.tsx new file mode 100644 index 0000000000..a9a6738705 --- /dev/null +++ b/packages/mui-base/src/Select/Group/SelectGroup.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; +import { screen } from '@mui/internal-test-utils'; +import { expect } from 'chai'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); + + it('should render option group with label', async () => { + await render( + + + + Fruits + Apple + Banana + + + , + ); + + expect(screen.getByRole('group')).to.have.attribute('aria-labelledby'); + expect(screen.getByText('Fruits')).toBeVisible(); + }); + + it('should associate label with option group', async () => { + await render( + + + + Vegetables + Carrot + Lettuce + + + , + ); + + const Group = screen.getByRole('group'); + const label = screen.getByText('Vegetables'); + expect(Group).to.have.attribute('aria-labelledby', label.id); + }); +}); diff --git a/packages/mui-base/src/Select/Group/SelectGroup.tsx b/packages/mui-base/src/Select/Group/SelectGroup.tsx new file mode 100644 index 0000000000..a7fc2a46dc --- /dev/null +++ b/packages/mui-base/src/Select/Group/SelectGroup.tsx @@ -0,0 +1,87 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { SelectGroupContext } from './SelectGroupContext'; + +const ownerState = {}; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectGroup API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectGroup) + */ +const SelectGroup = React.forwardRef(function SelectGroup( + props: SelectGroup.Props, + forwardedRef: React.ForwardedRef, +) { + const { className, render, ...otherProps } = props; + + const [labelId, setLabelId] = React.useState(); + + const getSelectOptionGroupProps = React.useCallback( + (externalProps = {}) => + mergeReactProps(externalProps, { + role: 'group', + 'aria-labelledby': labelId, + }), + [labelId], + ); + + const contextValue: SelectGroupContext = React.useMemo( + () => ({ + labelId, + setLabelId, + }), + [labelId, setLabelId], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getSelectOptionGroupProps, + render: render ?? 'div', + ref: forwardedRef, + ownerState, + className, + extraProps: otherProps, + }); + + return ( + + {renderElement()} + + ); +}); + +namespace SelectGroup { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +SelectGroup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectGroup }; diff --git a/packages/mui-base/src/Select/Group/SelectGroupContext.ts b/packages/mui-base/src/Select/Group/SelectGroupContext.ts new file mode 100644 index 0000000000..6acc043a7d --- /dev/null +++ b/packages/mui-base/src/Select/Group/SelectGroupContext.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; + +export interface SelectGroupContext { + labelId: string | undefined; + setLabelId: React.Dispatch>; +} + +export const SelectGroupContext = React.createContext(undefined); + +if (process.env.NODE_ENV !== 'production') { + SelectGroupContext.displayName = 'SelectGroupContext'; +} + +export function useSelectGroupContext() { + const context = React.useContext(SelectGroupContext); + if (context === undefined) { + throw new Error( + 'Base UI: SelectGroupContext is missing. SelectGroup parts must be placed within .', + ); + } + return context; +} diff --git a/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.test.tsx b/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.test.tsx new file mode 100644 index 0000000000..85f8387865 --- /dev/null +++ b/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.tsx b/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.tsx new file mode 100644 index 0000000000..848b14d752 --- /dev/null +++ b/packages/mui-base/src/Select/GroupLabel/SelectGroupLabel.tsx @@ -0,0 +1,86 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import { useId } from '../../utils/useId'; +import { useSelectGroupContext } from '../Group/SelectGroupContext'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; + +const ownerState = {}; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectGroupLabel API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectGroupLabel) + */ +const SelectGroupLabel = React.forwardRef(function SelectGroupLabel( + props: SelectGroupLabel.Props, + forwardedRef: React.ForwardedRef, +) { + const { className, render, id: idProp, ...otherProps } = props; + + const { setLabelId } = useSelectGroupContext(); + + const id = useId(idProp); + + useEnhancedEffect(() => { + setLabelId(id); + }, [id, setLabelId]); + + const getSelectOptionGroupLabelProps = React.useCallback( + (externalProps = {}) => + mergeReactProps(externalProps, { + id, + }), + [id], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getSelectOptionGroupLabelProps, + render: render ?? 'div', + ref: forwardedRef, + ownerState, + className, + extraProps: otherProps, + }); + + return renderElement(); +}); + +namespace SelectGroupLabel { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} +} + +SelectGroupLabel.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + id: PropTypes.string, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectGroupLabel }; diff --git a/packages/mui-base/src/Select/Icon/SelectIcon.test.tsx b/packages/mui-base/src/Select/Icon/SelectIcon.test.tsx new file mode 100644 index 0000000000..1ab6b6f44b --- /dev/null +++ b/packages/mui-base/src/Select/Icon/SelectIcon.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLSpanElement, + render(node) { + return render( + + {node} + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/Icon/SelectIcon.tsx b/packages/mui-base/src/Select/Icon/SelectIcon.tsx new file mode 100644 index 0000000000..8eaec9b4e6 --- /dev/null +++ b/packages/mui-base/src/Select/Icon/SelectIcon.tsx @@ -0,0 +1,70 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectIcon API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectIcon) + */ +const SelectIcon = React.forwardRef(function SelectIcon( + props: SelectIcon.Props, + forwardedRef: React.ForwardedRef, +) { + const { className, render, ...otherProps } = props; + + const ownerState: SelectIcon.OwnerState = React.useMemo(() => ({}), []); + + const getIconProps = React.useCallback((externalProps: React.ComponentProps<'span'>) => { + return mergeReactProps(externalProps, { + 'aria-hidden': true, + children: '▼', + }); + }, []); + + const { renderElement } = useComponentRenderer({ + propGetter: getIconProps, + render: render ?? 'span', + ref: forwardedRef, + className, + ownerState, + extraProps: otherProps, + }); + + return renderElement(); +}); + +namespace SelectIcon { + export interface OwnerState {} + + export interface Props extends BaseUIComponentProps<'span', OwnerState> {} +} + +SelectIcon.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectIcon }; diff --git a/packages/mui-base/src/Select/Option/SelectOption.test.tsx b/packages/mui-base/src/Select/Option/SelectOption.test.tsx new file mode 100644 index 0000000000..1bcf609215 --- /dev/null +++ b/packages/mui-base/src/Select/Option/SelectOption.test.tsx @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils'; +import { createRenderer, describeConformance } from '#test-utils'; +import { expect } from 'chai'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); + + it('should select the option and close popup when clicked', async () => { + await render( + + + + + + one + + , + ); + + const value = screen.getByTestId('value'); + const trigger = screen.getByTestId('trigger'); + const positioner = screen.getByTestId('positioner'); + + expect(value.textContent).to.equal('null'); + + fireEvent.click(trigger); + + await flushMicrotasks(); + + fireEvent.click(screen.getByText('one')); + + await flushMicrotasks(); + + expect(value.textContent).to.equal('one'); + + expect(positioner).not.toBeVisible(); + }); + + it('navigating with keyboard should highlight option', async () => { + const { user } = await render( + + + + + + + one + two + three + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + fireEvent.click(trigger); + + await flushMicrotasks(); + + await waitFor(() => { + expect(screen.getByText('one')).toHaveFocus(); + }); + + await user.keyboard('{ArrowDown}'); + + await waitFor(() => { + expect(screen.getByText('two')).toHaveFocus(); + }); + }); + + it('should select option when Enter key is pressed', async function test(t = {}) { + if (!/jsdom/.test(window.navigator.userAgent)) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const { user } = await render( + + + + + + + one + two + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const value = screen.getByTestId('value'); + + fireEvent.click(trigger); + + await flushMicrotasks(); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(value.textContent).to.equal('two'); + }); + }); + + it('should not select disabled option', async () => { + await render( + + + + + + + one + + two + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const value = screen.getByTestId('value'); + + fireEvent.click(trigger); + + await flushMicrotasks(); + + fireEvent.click(screen.getByText('two')); + + expect(value.textContent).to.equal(''); + }); + + it('should focus the selected option upon opening the popup', async () => { + const { user } = await render( + + + + + + + one + two + three + + + , + ); + + const trigger = screen.getByTestId('trigger'); + + await user.click(trigger); + + await flushMicrotasks(); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + + await user.click(screen.getByRole('option', { name: 'three' })); + await user.click(trigger); + + await flushMicrotasks(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'three', hidden: false })).toHaveFocus(); + }); + }); + + describe('style hooks', () => { + it('should apply data-highlighted attribute when option is highlighted', async function test(t = {}) { + if (!/jsdom/.test(window.navigator.userAgent)) { + // @ts-expect-error to support mocha and vitest + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this?.skip?.() || t?.skip(); + } + + const { user } = await render( + + + + + a + b + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const attr = 'data-highlighted'; + + fireEvent.click(trigger); + + await flushMicrotasks(); + + expect(screen.getByRole('option', { name: 'a' })).to.have.attribute(attr, ''); + expect(screen.getByRole('option', { name: 'b' })).not.to.have.attribute(attr); + + await user.keyboard('{ArrowDown}'); + await flushMicrotasks(); + + expect(screen.getByRole('option', { name: 'a' })).not.to.have.attribute(attr); + expect(screen.getByRole('option', { name: 'b' })).to.have.attribute(attr, ''); + }); + + it('should apply data-selected attribute when option is selected', async () => { + await render( + + + + + a + b + + + , + ); + + const trigger = screen.getByTestId('trigger'); + const attr = 'data-selected'; + + fireEvent.click(trigger); + + await flushMicrotasks(); + + expect(screen.getByRole('option', { name: 'a' })).not.to.have.attribute(attr); + expect(screen.getByRole('option', { name: 'b' })).not.to.have.attribute(attr); + + fireEvent.click(screen.getByRole('option', { name: 'a' })); + + await flushMicrotasks(); + + fireEvent.click(trigger); + + await flushMicrotasks(); + + expect(screen.getByRole('option', { name: 'a' })).to.have.attribute(attr, ''); + expect(screen.getByRole('option', { name: 'b' })).not.to.have.attribute(attr); + }); + }); +}); diff --git a/packages/mui-base/src/Select/Option/SelectOption.tsx b/packages/mui-base/src/Select/Option/SelectOption.tsx new file mode 100644 index 0000000000..6f42998eda --- /dev/null +++ b/packages/mui-base/src/Select/Option/SelectOption.tsx @@ -0,0 +1,337 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { UseInteractionsReturn } from '@floating-ui/react'; +import { SelectRootContext, useSelectRootContext } from '../Root/SelectRootContext'; +import { SelectIndexContext, useSelectIndexContext } from '../Root/SelectIndexContext'; +import { useCompositeListItem } from '../../Composite/List/useCompositeListItem'; +import { useForkRef } from '../../utils/useForkRef'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useSelectOption } from './useSelectOption'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useLatestRef } from '../../utils/useLatestRef'; +import { SelectOptionContext } from './SelectOptionContext'; + +interface InnerSelectOptionProps extends Omit { + highlighted: boolean; + selected: boolean; + getRootItemProps: UseInteractionsReturn['getItemProps']; + setOpen: SelectRootContext['setOpen']; + typingRef: React.MutableRefObject; + selectionRef: React.MutableRefObject<{ + allowUnselectedMouseUp: boolean; + allowSelectedMouseUp: boolean; + allowSelect: boolean; + }>; + open: boolean; + value: any; + setValue: SelectRootContext['setValue']; + selectedIndexRef: React.RefObject; + indexRef: React.RefObject; + setActiveIndex: SelectIndexContext['setActiveIndex']; + popupRef: React.RefObject; +} + +const InnerSelectOption = React.forwardRef(function InnerSelectOption( + props: InnerSelectOptionProps, + forwardedRef: React.ForwardedRef, +) { + const { + className, + disabled = false, + highlighted, + selected, + getRootItemProps, + render, + setOpen, + typingRef, + selectionRef, + open, + value, + setValue, + selectedIndexRef, + indexRef, + setActiveIndex, + popupRef, + ...otherProps + } = props; + + const ownerState: SelectOption.OwnerState = React.useMemo( + () => ({ + disabled, + open, + highlighted, + selected, + }), + [disabled, open, highlighted, selected], + ); + + const { getItemProps, rootRef } = useSelectOption({ + open, + setOpen, + disabled, + highlighted, + selected, + ref: forwardedRef, + typingRef, + handleSelect: () => setValue(value), + selectionRef, + selectedIndexRef, + indexRef, + setActiveIndex, + popupRef, + }); + + const mergedRef = useForkRef(rootRef, forwardedRef); + + const { renderElement } = useComponentRenderer({ + propGetter(externalProps = {}) { + const rootProps = getRootItemProps({ + ...externalProps, + active: highlighted, + selected, + }); + // With our custom `focusItemOnHover` implementation, this interferes with the logic and can + // cause the index state to be stuck when leaving the select popup. + delete rootProps.onFocus; + return getItemProps(rootProps); + }, + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + extraProps: otherProps, + }); + + const contextValue = React.useMemo( + () => ({ + selected, + indexRef, + }), + [selected, indexRef], + ); + + return ( + + {renderElement()} + + ); +}); + +InnerSelectOption.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * If `true`, the select option will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * @ignore + */ + getRootItemProps: PropTypes.func.isRequired, + /** + * @ignore + */ + highlighted: PropTypes.bool.isRequired, + /** + * @ignore + */ + indexRef: PropTypes.shape({ + current: PropTypes.number.isRequired, + }).isRequired, + /** + * A text representation of the select option's content. + * Used for keyboard text navigation matching. + */ + label: PropTypes.string, + /** + * @ignore + */ + open: PropTypes.bool.isRequired, + /** + * @ignore + */ + popupRef: PropTypes.shape({ + current: PropTypes.object, + }).isRequired, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + selected: PropTypes.bool.isRequired, + /** + * @ignore + */ + selectedIndexRef: PropTypes.shape({ + current: PropTypes.number, + }).isRequired, + /** + * @ignore + */ + selectionRef: PropTypes.shape({ + current: PropTypes.shape({ + allowSelect: PropTypes.bool.isRequired, + allowSelectedMouseUp: PropTypes.bool.isRequired, + allowUnselectedMouseUp: PropTypes.bool.isRequired, + }).isRequired, + }).isRequired, + /** + * @ignore + */ + setActiveIndex: PropTypes.func.isRequired, + /** + * @ignore + */ + setOpen: PropTypes.func.isRequired, + /** + * @ignore + */ + setValue: PropTypes.func.isRequired, + /** + * @ignore + */ + typingRef: PropTypes.shape({ + current: PropTypes.bool.isRequired, + }).isRequired, + /** + * @ignore + */ + value: PropTypes.any.isRequired, +} as any; + +const MemoizedInnerSelectOption = React.memo(InnerSelectOption); + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectOption API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectOption) + */ +const SelectOption = React.forwardRef(function SelectOption( + props: SelectOption.Props, + forwardedRef: React.ForwardedRef, +) { + const { value: valueProp = null, label, ...otherProps } = props; + + const listItem = useCompositeListItem({ label }); + + const { activeIndex, selectedIndex, setActiveIndex } = useSelectIndexContext(); + const { getItemProps, setOpen, setValue, open, selectionRef, typingRef, valuesRef, popupRef } = + useSelectRootContext(); + + const selectedIndexRef = useLatestRef(selectedIndex); + const indexRef = useLatestRef(listItem.index); + + const mergedRef = useForkRef(listItem.ref, forwardedRef); + + useEnhancedEffect(() => { + if (listItem.index === -1) { + return undefined; + } + + const values = valuesRef.current; + values[listItem.index] = valueProp; + + return () => { + delete values[listItem.index]; + }; + }, [listItem.index, valueProp, valuesRef]); + + const highlighted = activeIndex === listItem.index; + const selected = selectedIndex === listItem.index; + + return ( + + ); +}); + +namespace SelectOption { + export interface OwnerState { + disabled: boolean; + highlighted: boolean; + selected: boolean; + open: boolean; + } + + export interface Props extends Omit, 'id'> { + children?: React.ReactNode; + /** + * The value of the select option. + * @default null + */ + value?: any; + /** + * If `true`, the select option will be disabled. + * @default false + */ + disabled?: boolean; + /** + * A text representation of the select option's content. + * Used for keyboard text navigation matching. + */ + label?: string; + } +} + +SelectOption.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * If `true`, the select option will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * A text representation of the select option's content. + * Used for keyboard text navigation matching. + */ + label: PropTypes.string, + /** + * The value of the select option. + * @default null + */ + value: PropTypes.any, +} as any; + +export { SelectOption }; diff --git a/packages/mui-base/src/Select/Option/SelectOptionContext.ts b/packages/mui-base/src/Select/Option/SelectOptionContext.ts new file mode 100644 index 0000000000..6895894b4c --- /dev/null +++ b/packages/mui-base/src/Select/Option/SelectOptionContext.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; + +interface SelectOptionContext { + selected: boolean; + indexRef: React.RefObject; +} + +export const SelectOptionContext = React.createContext(undefined); + +export function useSelectOptionContext() { + const context = React.useContext(SelectOptionContext); + if (context === undefined) { + throw new Error( + 'Base UI: SelectOptionContext is missing. SelectOption parts must be placed within .', + ); + } + return context; +} diff --git a/packages/mui-base/src/Select/Option/useSelectOption.ts b/packages/mui-base/src/Select/Option/useSelectOption.ts new file mode 100644 index 0000000000..e7bab7828d --- /dev/null +++ b/packages/mui-base/src/Select/Option/useSelectOption.ts @@ -0,0 +1,235 @@ +import * as React from 'react'; +import type { GenericHTMLProps } from '../../utils/types'; +import { useButton } from '../../useButton'; +import { mergeReactProps } from '../../utils/mergeReactProps'; +import type { SelectRootContext } from '../Root/SelectRootContext'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { SelectIndexContext } from '../Root/SelectIndexContext'; +import { useForkRef } from '../../utils/useForkRef'; + +/** + * + * API: + * + * - [useSelectOption API](https://mui.com/base-ui/api/use-select-option/) + */ +export function useSelectOption(params: useSelectOption.Parameters): useSelectOption.ReturnValue { + const { + open, + disabled = false, + highlighted, + selected, + ref: externalRef, + setOpen, + typingRef, + handleSelect, + selectionRef, + indexRef, + setActiveIndex, + popupRef, + } = params; + + const ref = React.useRef(null); + + const mergedRef = useForkRef(externalRef, ref); + + // Manually set the tabindex. + // Workaround `enableFocusInside` in Floating UI setting `tabindex=0` of a non-highlighted + // option upon close when tabbing out: https://github.com/floating-ui/floating-ui/pull/3004/files#diff-962a7439cdeb09ea98d4b622a45d517bce07ad8c3f866e089bda05f4b0bbd875R194-R199 + React.useEffect(() => { + if (!ref.current) { + return; + } + + ref.current.setAttribute('tabindex', highlighted || !open ? '0' : '-1'); + }, [open, highlighted]); + + const { getButtonProps, buttonRef } = useButton({ + disabled, + focusableWhenDisabled: true, + buttonRef: mergedRef, + }); + + const commitSelection = useEventCallback((event: Event) => { + handleSelect(); + setOpen(false, event); + }); + + const lastKeyRef = React.useRef(null); + const pointerTypeRef = React.useRef<'mouse' | 'touch' | 'pen'>('mouse'); + + const prevPopupHeightRef = React.useRef(0); + const allowFocusSyncRef = React.useRef(true); + + const getItemProps = React.useCallback( + (externalProps?: GenericHTMLProps): GenericHTMLProps => { + return getButtonProps( + mergeReactProps<'div'>(externalProps, { + 'aria-disabled': disabled || undefined, + style: { + pointerEvents: disabled ? 'none' : undefined, + }, + onFocus() { + if (allowFocusSyncRef.current) { + setActiveIndex(indexRef.current); + } + }, + onMouseMove() { + setActiveIndex(indexRef.current); + if (popupRef.current) { + prevPopupHeightRef.current = popupRef.current.offsetHeight; + } + }, + onMouseLeave() { + const popup = popupRef.current; + if (!popup) { + return; + } + + // With `alignOptionToTrigger`, avoid re-rendering the root due to `onMouseLeave` + // firing and causing a performance issue when expanding the popup. + if (popup.offsetHeight === prevPopupHeightRef.current) { + // Prevent `onFocus` from causing the highlight to be stuck when quickly moving + // the mouse out of the popup. + allowFocusSyncRef.current = false; + setActiveIndex(null); + requestAnimationFrame(() => { + popup.focus({ preventScroll: true }); + allowFocusSyncRef.current = true; + }); + } + }, + onTouchStart() { + selectionRef.current = { + allowSelectedMouseUp: false, + allowUnselectedMouseUp: false, + allowSelect: true, + }; + }, + onKeyDown(event) { + selectionRef.current.allowSelect = true; + lastKeyRef.current = event.key; + }, + onClick(event) { + if ( + (lastKeyRef.current === ' ' && typingRef.current) || + (pointerTypeRef.current !== 'touch' && !highlighted) + ) { + return; + } + + if (selectionRef.current.allowSelect) { + lastKeyRef.current = null; + commitSelection(event.nativeEvent); + } + }, + onPointerEnter(event) { + pointerTypeRef.current = event.pointerType; + }, + onPointerDown(event) { + pointerTypeRef.current = event.pointerType; + }, + onMouseUp(event) { + const disallowSelectedMouseUp = !selectionRef.current.allowSelectedMouseUp && selected; + const disallowUnselectedMouseUp = + !selectionRef.current.allowUnselectedMouseUp && !selected; + + if ( + disallowSelectedMouseUp || + disallowUnselectedMouseUp || + (pointerTypeRef.current !== 'touch' && !highlighted) + ) { + return; + } + + if (selectionRef.current.allowSelect || !selected) { + commitSelection(event.nativeEvent); + } + + selectionRef.current.allowSelect = true; + }, + }), + ); + }, + [ + commitSelection, + disabled, + getButtonProps, + highlighted, + indexRef, + popupRef, + selected, + selectionRef, + setActiveIndex, + typingRef, + ], + ); + + return React.useMemo( + () => ({ + getItemProps, + rootRef: buttonRef, + }), + [getItemProps, buttonRef], + ); +} + +export namespace useSelectOption { + export interface Parameters { + /** + * If `true`, the select item will be disabled. + */ + disabled: boolean; + /** + * Determines if the select item is highlighted. + */ + highlighted: boolean; + /** + * Determines if the select item is selected. + */ + selected: boolean; + /** + * The ref of the trigger element. + */ + ref?: React.Ref; + /** + * The open state of the select. + */ + open: boolean; + /** + * The function to set the open state of the select. + */ + setOpen: SelectRootContext['setOpen']; + /** + * Determines if the user is currently typing for typeahead matching. + */ + typingRef: React.MutableRefObject; + /** + * The function to handle the selection of the option. + */ + handleSelect: () => void; + /** + * The ref to the selection state of the option. + */ + selectionRef: React.MutableRefObject<{ + allowSelectedMouseUp: boolean; + allowUnselectedMouseUp: boolean; + allowSelect: boolean; + }>; + /** + * A ref to the index of the selected item. + */ + selectedIndexRef: React.RefObject; + /** + * A ref to the index of the item. + */ + indexRef: React.RefObject; + setActiveIndex: SelectIndexContext['setActiveIndex']; + popupRef: React.RefObject; + } + + export interface ReturnValue { + getItemProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; + rootRef: React.RefCallback | null; + } +} diff --git a/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.test.tsx b/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.test.tsx new file mode 100644 index 0000000000..4b478ccf27 --- /dev/null +++ b/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.test.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLSpanElement, + render(node) { + return render( + + + + + + {node} + + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.tsx b/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.tsx new file mode 100644 index 0000000000..ecd6bce1df --- /dev/null +++ b/packages/mui-base/src/Select/OptionIndicator/SelectOptionIndicator.tsx @@ -0,0 +1,101 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useSelectOptionContext } from '../Option/SelectOptionContext'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectOptionIndicator API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectOptionIndicator) + */ +const SelectOptionIndicator = React.forwardRef(function SelectOptionIndicator( + props: SelectOptionIndicator.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, keepMounted = false, ...otherProps } = props; + + const { selected } = useSelectOptionContext(); + + const getOptionProps = React.useCallback( + (externalProps = {}) => + mergeReactProps(externalProps, { + 'aria-hidden': true, + children: '✔️', + }), + [], + ); + + const ownerState: SelectOptionIndicator.OwnerState = React.useMemo( + () => ({ + selected, + }), + [selected], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getOptionProps, + render: render ?? 'span', + ref: forwardedRef, + className, + ownerState, + extraProps: otherProps, + }); + + const shouldRender = selected || keepMounted; + if (!shouldRender) { + return null; + } + + return renderElement(); +}); + +namespace SelectOptionIndicator { + export interface Props extends BaseUIComponentProps<'span', OwnerState> { + children?: React.ReactNode; + /** + * If `true`, the item indicator remains mounted when the item is not + * selected. + * @default false + */ + keepMounted?: boolean; + } + + export interface OwnerState { + selected: boolean; + } +} + +SelectOptionIndicator.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * If `true`, the item indicator remains mounted when the item is not + * selected. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export { SelectOptionIndicator }; diff --git a/packages/mui-base/src/Select/OptionText/SelectOptionText.test.tsx b/packages/mui-base/src/Select/OptionText/SelectOptionText.test.tsx new file mode 100644 index 0000000000..34d5e12be2 --- /dev/null +++ b/packages/mui-base/src/Select/OptionText/SelectOptionText.test.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + + {node} + + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/OptionText/SelectOptionText.tsx b/packages/mui-base/src/Select/OptionText/SelectOptionText.tsx new file mode 100644 index 0000000000..2a3d1bcadc --- /dev/null +++ b/packages/mui-base/src/Select/OptionText/SelectOptionText.tsx @@ -0,0 +1,143 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useForkRef } from '../../utils/useForkRef'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useSelectRootContext } from '../Root/SelectRootContext'; +import { useSelectOptionContext } from '../Option/SelectOptionContext'; + +interface InnerSelectOptionTextProps extends SelectOptionText.Props { + selected: boolean; + selectedOptionTextRef: React.RefObject; + indexRef: React.RefObject; +} + +const InnerSelectOptionText = React.forwardRef(function InnerSelectOptionText( + props: InnerSelectOptionTextProps, + forwardedRef: React.ForwardedRef, +) { + const { className, render, selected, selectedOptionTextRef, indexRef, ...otherProps } = props; + + const mergedRef = useForkRef(forwardedRef); + + const ownerState: SelectOptionText.OwnerState = React.useMemo(() => ({}), []); + + const ref = React.useCallback( + (node: HTMLElement | null) => { + if (mergedRef) { + mergedRef(node); + } + + // Wait for the DOM indices to be set. + queueMicrotask(() => { + if (selected || (selectedOptionTextRef.current === null && indexRef.current === 0)) { + selectedOptionTextRef.current = node; + } + }); + }, + [mergedRef, selected, selectedOptionTextRef, indexRef], + ); + + const { renderElement } = useComponentRenderer({ + ref, + render: render ?? 'div', + className, + ownerState, + extraProps: otherProps, + }); + + return renderElement(); +}); + +InnerSelectOptionText.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Class names applied to the element or a function that returns them based on the component's state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + indexRef: PropTypes.shape({ + current: PropTypes.number.isRequired, + }).isRequired, + /** + * A function to customize rendering of the component. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + selected: PropTypes.bool.isRequired, + /** + * @ignore + */ + selectedOptionTextRef: PropTypes.shape({ + current: (props, propName) => { + if (props[propName] == null) { + return null; + } + if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) { + return new Error(`Expected prop '${propName}' to be of type Element`); + } + return null; + }, + }).isRequired, +} as any; + +const MemoizedInnerSelectOptionText = React.memo(InnerSelectOptionText); +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectOptionText API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectOptionText) + */ +const SelectOptionText = React.forwardRef(function SelectOptionText( + props: SelectOptionText.Props, + forwardedRef: React.ForwardedRef, +) { + const { selected, indexRef } = useSelectOptionContext(); + const { selectedOptionTextRef } = useSelectRootContext(); + const mergedRef = useForkRef(forwardedRef); + + return ( + + ); +}); + +namespace SelectOptionText { + export interface Props extends BaseUIComponentProps<'div', OwnerState> {} + + export interface OwnerState {} +} + +SelectOptionText.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, +} as any; + +export { SelectOptionText }; diff --git a/packages/mui-base/src/Select/Popup/SelectPopup.test.tsx b/packages/mui-base/src/Select/Popup/SelectPopup.test.tsx new file mode 100644 index 0000000000..c3a0002c37 --- /dev/null +++ b/packages/mui-base/src/Select/Popup/SelectPopup.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Select } from '@base_ui/react/Select'; +import { createRenderer, describeConformance } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + refInstanceof: window.HTMLDivElement, + render(node) { + return render( + + {node} + , + ); + }, + })); +}); diff --git a/packages/mui-base/src/Select/Popup/SelectPopup.tsx b/packages/mui-base/src/Select/Popup/SelectPopup.tsx new file mode 100644 index 0000000000..aba4a850f7 --- /dev/null +++ b/packages/mui-base/src/Select/Popup/SelectPopup.tsx @@ -0,0 +1,135 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import type { Side } from '@floating-ui/react'; +import type { BaseUIComponentProps } from '../../utils/types'; +import { useSelectRootContext } from '../Root/SelectRootContext'; +import { popupOpenStateMapping } from '../../utils/popupOpenStateMapping'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useForkRef } from '../../utils/useForkRef'; +import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps'; +import { useSelectPopup } from './useSelectPopup'; +import type { TransitionStatus } from '../../utils/useTransitionStatus'; +import { useSelectPositionerContext } from '../Positioner/SelectPositionerContext'; + +const customStyleHookMapping: CustomStyleHookMapping = { + ...popupOpenStateMapping, + transitionStatus(value): Record | null { + if (value === 'entering') { + return { 'data-entering': '' }; + } + + if (value === 'exiting') { + return { 'data-exiting': '' }; + } + + return null; + }, +}; + +/** + * + * Demos: + * + * - [Select](https://base-ui.netlify.app/components/react-select/) + * + * API: + * + * - [SelectPopup API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectPopup) + */ +const SelectPopup = React.forwardRef(function SelectPopup( + props: SelectPopup.Props, + forwardedRef: React.ForwardedRef, +) { + const { render, className, ...otherProps } = props; + + const { id, open, popupRef, transitionStatus, alignOptionToTrigger } = useSelectRootContext(); + const positioner = useSelectPositionerContext(); + + const { getPopupProps } = useSelectPopup(); + + const mergedRef = useForkRef(forwardedRef, popupRef); + + const ownerState: SelectPopup.OwnerState = React.useMemo( + () => ({ + open, + transitionStatus, + side: positioner.side, + alignment: positioner.alignment, + }), + [open, transitionStatus, positioner], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getPopupProps, + render: render ?? 'div', + ref: mergedRef, + className, + ownerState, + customStyleHookMapping, + extraProps: otherProps, + }); + + const popupSelector = `[data-id="${id}-popup"]`; + + const html = React.useMemo( + () => ({ + __html: `${popupSelector}{scrollbar-width:none}${popupSelector}::-webkit-scrollbar{display:none}`, + }), + [popupSelector], + ); + + return ( + + {id && alignOptionToTrigger && ( +