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 && (
+
+ )}
+ {renderElement()}
+
+ );
+});
+
+namespace SelectPopup {
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ children?: React.ReactNode;
+ /**
+ * The id of the popup element.
+ */
+ id?: string;
+ }
+
+ export interface OwnerState {
+ side: Side | 'none';
+ alignment: 'start' | 'end' | 'center';
+ open: boolean;
+ transitionStatus: TransitionStatus;
+ }
+}
+
+SelectPopup.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 id of the popup element.
+ */
+ id: PropTypes.string,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { SelectPopup };
diff --git a/packages/mui-base/src/Select/Popup/useSelectPopup.ts b/packages/mui-base/src/Select/Popup/useSelectPopup.ts
new file mode 100644
index 0000000000..b46bf6eba7
--- /dev/null
+++ b/packages/mui-base/src/Select/Popup/useSelectPopup.ts
@@ -0,0 +1,329 @@
+import * as React from 'react';
+import type { GenericHTMLProps } from '../../utils/types';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useSelectRootContext } from '../Root/SelectRootContext';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { ownerDocument, ownerWindow } from '../../utils/owner';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { clearPositionerStyles } from './utils';
+
+/**
+ *
+ * API:
+ *
+ * - [useSelectPopup API](https://mui.com/base-ui/api/use-select-popup/)
+ */
+export function useSelectPopup(): useSelectPopup.ReturnValue {
+ const {
+ mounted,
+ id,
+ setOpen,
+ getRootPositionerProps,
+ alignOptionToTrigger,
+ triggerElement,
+ positionerElement,
+ valueRef,
+ selectedOptionTextRef,
+ popupRef,
+ scrollUpArrowVisible,
+ scrollDownArrowVisible,
+ setScrollUpArrowVisible,
+ setScrollDownArrowVisible,
+ setControlledAlignOptionToTrigger,
+ } = useSelectRootContext();
+
+ const initialHeightRef = React.useRef(0);
+ const reachedMaxHeightRef = React.useRef(false);
+ const maxHeightRef = React.useRef(0);
+ const initialPlacedRef = React.useRef(false);
+ const originalPositionerStylesRef = React.useRef({});
+
+ const handleScrollArrowVisibility = useEventCallback(() => {
+ if (!alignOptionToTrigger || !popupRef.current) {
+ return;
+ }
+
+ const isScrolledToTop = popupRef.current.scrollTop < 1;
+ const isScrolledToBottom =
+ popupRef.current.scrollTop + popupRef.current.clientHeight >=
+ popupRef.current.scrollHeight - 1;
+
+ if (scrollUpArrowVisible !== !isScrolledToTop) {
+ setScrollUpArrowVisible(!isScrolledToTop);
+ }
+ if (scrollDownArrowVisible !== !isScrolledToBottom) {
+ setScrollDownArrowVisible(!isScrolledToBottom);
+ }
+ });
+
+ useEnhancedEffect(() => {
+ if (
+ alignOptionToTrigger ||
+ !positionerElement ||
+ Object.keys(originalPositionerStylesRef.current).length
+ ) {
+ return;
+ }
+
+ originalPositionerStylesRef.current = {
+ top: positionerElement.style.top || '0',
+ left: positionerElement.style.left || '0',
+ right: positionerElement.style.right,
+ height: positionerElement.style.height,
+ bottom: positionerElement.style.bottom,
+ minHeight: positionerElement.style.minHeight,
+ maxHeight: positionerElement.style.maxHeight,
+ marginTop: positionerElement.style.marginTop,
+ marginBottom: positionerElement.style.marginBottom,
+ };
+ }, [alignOptionToTrigger, positionerElement]);
+
+ useEnhancedEffect(() => {
+ if (mounted || alignOptionToTrigger) {
+ return;
+ }
+
+ initialPlacedRef.current = false;
+ reachedMaxHeightRef.current = false;
+ initialHeightRef.current = 0;
+ maxHeightRef.current = 0;
+
+ if (positionerElement) {
+ clearPositionerStyles(positionerElement, originalPositionerStylesRef.current);
+ }
+ }, [mounted, alignOptionToTrigger, positionerElement]);
+
+ useEnhancedEffect(() => {
+ if (
+ !mounted ||
+ !alignOptionToTrigger ||
+ !triggerElement ||
+ !positionerElement ||
+ !popupRef.current
+ ) {
+ return;
+ }
+
+ const positionerStyles = getComputedStyle(positionerElement);
+ const popupStyles = getComputedStyle(popupRef.current);
+
+ const borderBottom = parseFloat(popupStyles.borderBottomWidth);
+ const marginTop = parseFloat(positionerStyles.marginTop) || 10;
+ const marginBottom = parseFloat(positionerStyles.marginBottom) || 10;
+ const minHeight = parseFloat(positionerStyles.minHeight) || 100;
+ const paddingLeft = 5;
+ const paddingRight = 5;
+
+ const doc = ownerDocument(triggerElement);
+ const triggerRect = triggerElement.getBoundingClientRect();
+ const positionerRect = positionerElement.getBoundingClientRect();
+ const triggerX = triggerRect.left;
+ const triggerHeight = triggerRect.height;
+ const viewportHeight = doc.documentElement.clientHeight - marginTop - marginBottom;
+ const viewportWidth = doc.documentElement.clientWidth;
+ const availableSpaceBeneathTrigger = viewportHeight - triggerRect.bottom + triggerHeight;
+
+ const optionTextElement = selectedOptionTextRef.current;
+ const valueElement = valueRef.current;
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (optionTextElement && valueElement) {
+ const valueRect = valueElement.getBoundingClientRect();
+ const textRect = optionTextElement.getBoundingClientRect();
+
+ const triggerXDiff = valueRect.left - triggerX;
+ const popupXDiff = textRect.left - positionerRect.left;
+
+ offsetX = triggerXDiff - popupXDiff;
+ offsetY = optionTextElement.offsetTop - (valueRect.top - triggerRect.top);
+ }
+
+ const idealHeight = availableSpaceBeneathTrigger + offsetY + marginBottom + borderBottom;
+ let height = Math.min(viewportHeight, idealHeight);
+ const maxHeight = viewportHeight - marginTop - marginBottom;
+ const scrollTop = idealHeight - height;
+
+ const left = Math.max(paddingLeft, triggerX + offsetX);
+ const maxRight = viewportWidth - paddingRight;
+ const rightOverflow = Math.max(0, left + positionerRect.width - maxRight);
+
+ positionerElement.style.left = `${left - rightOverflow}px`;
+ positionerElement.style.height = `${height}px`;
+ positionerElement.style.minHeight = `${minHeight}px`;
+ positionerElement.style.maxHeight = 'auto';
+ positionerElement.style.marginTop = `${marginTop}px`;
+ positionerElement.style.marginBottom = `${marginBottom}px`;
+
+ const maxScrollTop = popupRef.current.scrollHeight - popupRef.current.clientHeight;
+ const isTopPositioned = scrollTop >= maxScrollTop;
+
+ if (isTopPositioned) {
+ height = Math.min(viewportHeight, positionerRect.height) - (scrollTop - maxScrollTop);
+ }
+
+ // When the reference is too close to the top or bottom of the viewport, or the minHeight is
+ // reached, we fallback to aligning the popup to the trigger as the UX is poor otherwise.
+ const fallbackToAlignPopupToTrigger =
+ triggerRect.top < 20 || triggerRect.bottom > viewportHeight - 20 || height < minHeight;
+
+ if (fallbackToAlignPopupToTrigger) {
+ initialPlacedRef.current = true;
+ clearPositionerStyles(positionerElement, originalPositionerStylesRef.current);
+ setControlledAlignOptionToTrigger(false);
+ return;
+ }
+
+ if (isTopPositioned) {
+ const topOffset = Math.max(0, viewportHeight - idealHeight);
+ positionerElement.style.top = positionerRect.height >= maxHeight ? '0' : `${topOffset}px`;
+ positionerElement.style.height = `${height}px`;
+ popupRef.current.scrollTop = popupRef.current.scrollHeight - popupRef.current.clientHeight;
+ initialHeightRef.current = Math.max(minHeight, height);
+ } else {
+ positionerElement.style.bottom = '0';
+ initialHeightRef.current = Math.max(minHeight, height);
+ popupRef.current.scrollTop = scrollTop;
+ }
+
+ if (initialHeightRef.current === viewportHeight) {
+ reachedMaxHeightRef.current = true;
+ }
+
+ handleScrollArrowVisibility();
+
+ // Avoid the `onScroll` event logic from triggering before the popup is placed.
+ setTimeout(() => {
+ initialPlacedRef.current = true;
+ });
+ }, [
+ mounted,
+ alignOptionToTrigger,
+ positionerElement,
+ triggerElement,
+ valueRef,
+ selectedOptionTextRef,
+ popupRef,
+ setScrollUpArrowVisible,
+ setScrollDownArrowVisible,
+ handleScrollArrowVisibility,
+ setControlledAlignOptionToTrigger,
+ ]);
+
+ React.useEffect(() => {
+ if (!alignOptionToTrigger || !positionerElement || !mounted) {
+ return undefined;
+ }
+
+ const win = ownerWindow(positionerElement);
+
+ function handleResize() {
+ setOpen(false);
+ }
+
+ win.addEventListener('resize', handleResize);
+
+ return () => {
+ win.removeEventListener('resize', handleResize);
+ };
+ }, [setOpen, alignOptionToTrigger, positionerElement, mounted]);
+
+ const getPopupProps: useSelectPopup.ReturnValue['getPopupProps'] = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps<'div'>(getRootPositionerProps(externalProps), {
+ ['data-id' as string]: `${id}-popup`,
+ onScroll(event) {
+ if (
+ !alignOptionToTrigger ||
+ !positionerElement ||
+ !popupRef.current ||
+ !initialPlacedRef.current
+ ) {
+ return;
+ }
+
+ if (reachedMaxHeightRef.current || !alignOptionToTrigger) {
+ handleScrollArrowVisibility();
+ return;
+ }
+
+ const isTopPositioned = positionerElement.style.top === '0px';
+ const isBottomPositioned = positionerElement.style.bottom === '0px';
+ const currentHeight = positionerElement.getBoundingClientRect().height;
+ const doc = ownerDocument(positionerElement);
+ const positionerStyles = getComputedStyle(positionerElement);
+ const marginTop = parseFloat(positionerStyles.marginTop);
+ const marginBottom = parseFloat(positionerStyles.marginBottom);
+ const viewportHeight = doc.documentElement.clientHeight - marginTop - marginBottom;
+
+ if (isTopPositioned) {
+ const scrollTop = event.currentTarget.scrollTop;
+ const maxScrollTop =
+ event.currentTarget.scrollHeight - event.currentTarget.clientHeight;
+ const diff = maxScrollTop - scrollTop;
+ const nextHeight = Math.min(currentHeight + diff, viewportHeight);
+ positionerElement.style.height = `${Math.min(currentHeight + diff, viewportHeight)}px`;
+
+ if (nextHeight !== viewportHeight) {
+ event.currentTarget.scrollTop = maxScrollTop;
+ } else {
+ reachedMaxHeightRef.current = true;
+ }
+ } else if (isBottomPositioned) {
+ const scrollTop = event.currentTarget.scrollTop;
+ const minScrollTop = 0;
+ const diff = scrollTop - minScrollTop;
+ const nextHeight = Math.min(currentHeight + diff, viewportHeight);
+ const idealHeight = currentHeight + diff;
+ const overshoot = idealHeight - viewportHeight;
+ positionerElement.style.height = `${Math.min(idealHeight, viewportHeight)}px`;
+
+ if (nextHeight !== viewportHeight) {
+ event.currentTarget.scrollTop = 0;
+ } else {
+ reachedMaxHeightRef.current = true;
+ if (
+ event.currentTarget.scrollTop <
+ event.currentTarget.scrollHeight - event.currentTarget.clientHeight
+ ) {
+ event.currentTarget.scrollTop -= diff - overshoot;
+ }
+ }
+ }
+
+ handleScrollArrowVisibility();
+ },
+ style: {
+ ...(alignOptionToTrigger && {
+ position: 'relative',
+ maxHeight: '100%',
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ }),
+ outline: '0',
+ },
+ });
+ },
+ [
+ getRootPositionerProps,
+ id,
+ alignOptionToTrigger,
+ positionerElement,
+ popupRef,
+ handleScrollArrowVisibility,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getPopupProps,
+ }),
+ [getPopupProps],
+ );
+}
+
+namespace useSelectPopup {
+ export interface ReturnValue {
+ getPopupProps: (props?: GenericHTMLProps) => GenericHTMLProps;
+ }
+}
diff --git a/packages/mui-base/src/Select/Popup/utils.ts b/packages/mui-base/src/Select/Popup/utils.ts
new file mode 100644
index 0000000000..252fa9c5f0
--- /dev/null
+++ b/packages/mui-base/src/Select/Popup/utils.ts
@@ -0,0 +1,6 @@
+export function clearPositionerStyles(
+ positionerElement: HTMLElement,
+ originalPositionerStyles: React.CSSProperties,
+) {
+ Object.assign(positionerElement.style, originalPositionerStyles);
+}
diff --git a/packages/mui-base/src/Select/Positioner/SelectPositioner.test.tsx b/packages/mui-base/src/Select/Positioner/SelectPositioner.test.tsx
new file mode 100644
index 0000000000..362d51288d
--- /dev/null
+++ b/packages/mui-base/src/Select/Positioner/SelectPositioner.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/Positioner/SelectPositioner.tsx b/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx
new file mode 100644
index 0000000000..139ea48ba5
--- /dev/null
+++ b/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx
@@ -0,0 +1,295 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingFocusManager, FloatingPortal } from '@floating-ui/react';
+import { useForkRef } from '../../utils/useForkRef';
+import { useSelectRootContext } from '../Root/SelectRootContext';
+import { CompositeList } from '../../Composite/List/CompositeList';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { popupOpenStateMapping } from '../../utils/popupOpenStateMapping';
+import { useSelectPositioner } from './useSelectPositioner';
+import type { Alignment, Side } from '../../utils/useAnchorPositioning';
+import { SelectPositionerContext } from './SelectPositionerContext';
+
+/**
+ *
+ * Demos:
+ *
+ * - [Select](https://base-ui.netlify.app/components/react-select/)
+ *
+ * API:
+ *
+ * - [SelectPositioner API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectPositioner)
+ */
+const SelectPositioner = React.forwardRef(function SelectPositioner(
+ props: SelectPositioner.Props,
+ ref: React.ForwardedRef,
+) {
+ const {
+ anchor,
+ positionMethod = 'absolute',
+ className,
+ render,
+ side = 'bottom',
+ alignment = 'start',
+ sideOffset = 0,
+ alignmentOffset = 0,
+ collisionBoundary,
+ collisionPadding,
+ arrowPadding = 5,
+ hideWhenDetached = false,
+ sticky = false,
+ trackAnchor = true,
+ container,
+ ...otherProps
+ } = props;
+
+ const { open, mounted, setPositionerElement, listRef, labelsRef, floatingRootContext } =
+ useSelectRootContext();
+
+ const { getPositionerProps, positioner } = useSelectPositioner({
+ anchor,
+ floatingRootContext,
+ positionMethod,
+ container,
+ mounted,
+ side,
+ sideOffset,
+ alignment,
+ alignmentOffset,
+ arrowPadding,
+ collisionBoundary,
+ collisionPadding,
+ hideWhenDetached,
+ sticky,
+ trackAnchor,
+ allowAxisFlip: false,
+ });
+
+ const mergedRef = useForkRef(ref, setPositionerElement);
+
+ const ownerState: SelectPositioner.OwnerState = React.useMemo(
+ () => ({
+ open,
+ side: positioner.side,
+ alignment: positioner.alignment,
+ }),
+ [open, positioner.side, positioner.alignment],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getPositionerProps,
+ render: render ?? 'div',
+ ref: mergedRef,
+ className,
+ ownerState,
+ customStyleHookMapping: popupOpenStateMapping,
+ extraProps: otherProps,
+ });
+
+ return (
+
+
+
+
+ {renderElement()}
+
+
+
+
+ );
+});
+
+namespace SelectPositioner {
+ export interface OwnerState {
+ open: boolean;
+ side: Side | 'none';
+ alignment: Alignment;
+ }
+
+ export interface Props
+ extends useSelectPositioner.SharedParameters,
+ BaseUIComponentProps<'div', OwnerState> {}
+}
+
+SelectPositioner.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The alignment of the Select element to the anchor element along its cross axis.
+ * @default 'start'
+ */
+ alignment: PropTypes.oneOf(['center', 'end', 'start']),
+ /**
+ * The offset of the Select element along its alignment axis.
+ * @default 0
+ */
+ alignmentOffset: PropTypes.number,
+ /**
+ * The anchor element to which the Select popup will be placed at.
+ */
+ anchor: PropTypes.oneOfType([
+ (props, propName) => {
+ if (props[propName] == null) {
+ return new Error(`Prop '${propName}' is required but wasn't specified`);
+ }
+ if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
+ return new Error(`Expected prop '${propName}' to be of type Element`);
+ }
+ return null;
+ },
+ PropTypes.func,
+ PropTypes.shape({
+ contextElement: (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;
+ },
+ getBoundingClientRect: PropTypes.func.isRequired,
+ getClientRects: PropTypes.func,
+ }),
+ 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;
+ },
+ }),
+ ]),
+ /**
+ * Determines the padding between the arrow and the Select popup's edges. Useful when the popover
+ * popup has rounded corners via `border-radius`.
+ * @default 5
+ */
+ arrowPadding: PropTypes.number,
+ /**
+ * @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 boundary that the Select element should be constrained to.
+ * @default 'clippingAncestors'
+ */
+ collisionBoundary: PropTypes.oneOfType([
+ PropTypes.oneOf(['clippingAncestors']),
+ PropTypes.arrayOf((props, propName) => {
+ if (props[propName] == null) {
+ return new Error(`Prop '${propName}' is required but wasn't specified`);
+ }
+ if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
+ return new Error(`Expected prop '${propName}' to be of type Element`);
+ }
+ return null;
+ }),
+ (props, propName) => {
+ if (props[propName] == null) {
+ return new Error(`Prop '${propName}' is required but wasn't specified`);
+ }
+ if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
+ return new Error(`Expected prop '${propName}' to be of type Element`);
+ }
+ return null;
+ },
+ PropTypes.shape({
+ height: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ }),
+ ]),
+ /**
+ * The padding of the collision boundary.
+ * @default 5
+ */
+ collisionPadding: PropTypes.oneOfType([
+ PropTypes.number,
+ PropTypes.shape({
+ bottom: PropTypes.number,
+ left: PropTypes.number,
+ right: PropTypes.number,
+ top: PropTypes.number,
+ }),
+ ]),
+ /**
+ * The container element to which the Select popup will be appended to.
+ */
+ container: PropTypes.oneOfType([
+ (props, propName) => {
+ if (props[propName] == null) {
+ return new Error(`Prop '${propName}' is required but wasn't specified`);
+ }
+ if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
+ return new Error(`Expected prop '${propName}' to be of type Element`);
+ }
+ return null;
+ },
+ 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;
+ },
+ }),
+ ]),
+ /**
+ * If `true`, the Select will be hidden if it is detached from its anchor element due to
+ * differing clipping contexts.
+ * @default false
+ */
+ hideWhenDetached: PropTypes.bool,
+ /**
+ * The CSS position method for positioning the Select popup element.
+ * @default 'absolute'
+ */
+ positionMethod: PropTypes.oneOf(['absolute', 'fixed']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * The side of the anchor element that the Select element should align to.
+ * @default 'bottom'
+ */
+ side: PropTypes.oneOf(['bottom', 'left', 'right', 'top']),
+ /**
+ * The gap between the anchor element and the Select element.
+ * @default 0
+ */
+ sideOffset: PropTypes.number,
+ /**
+ * If `true`, allow the Select to remain in stuck view while the anchor element is scrolled out
+ * of view.
+ * @default false
+ */
+ sticky: PropTypes.bool,
+ /**
+ * Whether the select popup continuously tracks its anchor after the initial positioning upon mount.
+ * @default true
+ */
+ trackAnchor: PropTypes.bool,
+} as any;
+
+export { SelectPositioner };
diff --git a/packages/mui-base/src/Select/Positioner/SelectPositionerContext.ts b/packages/mui-base/src/Select/Positioner/SelectPositionerContext.ts
new file mode 100644
index 0000000000..6ce0e77de7
--- /dev/null
+++ b/packages/mui-base/src/Select/Positioner/SelectPositionerContext.ts
@@ -0,0 +1,16 @@
+import * as React from 'react';
+import { useSelectPositioner } from './useSelectPositioner';
+
+type SelectPositionerContext = ReturnType['positioner'];
+
+export const SelectPositionerContext = React.createContext(null);
+
+export function useSelectPositionerContext() {
+ const context = React.useContext(SelectPositionerContext);
+ if (context === null) {
+ throw new Error(
+ 'Base UI: SelectPositionerContext is missing. SelectPositioner parts must be placed within .',
+ );
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/Select/Positioner/useSelectPositioner.ts b/packages/mui-base/src/Select/Positioner/useSelectPositioner.ts
new file mode 100644
index 0000000000..0d9ad530f7
--- /dev/null
+++ b/packages/mui-base/src/Select/Positioner/useSelectPositioner.ts
@@ -0,0 +1,265 @@
+import * as React from 'react';
+import type {
+ VirtualElement,
+ Side,
+ Padding,
+ Boundary,
+ FloatingRootContext,
+ FloatingContext,
+ Middleware,
+} from '@floating-ui/react';
+import type { GenericHTMLProps } from '../../utils/types';
+import { useAnchorPositioning } from '../../utils/useAnchorPositioning';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useSelectRootContext } from '../Root/SelectRootContext';
+import { useScrollLock } from '../../utils/useScrollLock';
+
+/**
+ *
+ * API:
+ *
+ * - [useSelectPositioner API](https://mui.com/base-ui/api/use-select-positioner/)
+ */
+export function useSelectPositioner(
+ params: useSelectPositioner.Parameters,
+): useSelectPositioner.ReturnValue {
+ const { open, alignOptionToTrigger, mounted, triggerElement } = useSelectRootContext();
+
+ useScrollLock(alignOptionToTrigger && mounted, triggerElement);
+
+ const {
+ positionerStyles: enabledPositionerStyles,
+ arrowStyles,
+ hidden,
+ arrowRef,
+ arrowUncentered,
+ renderedSide,
+ renderedAlignment,
+ positionerContext,
+ isPositioned,
+ } = useAnchorPositioning({
+ ...params,
+ keepMounted: true,
+ enabled: mounted,
+ trackAnchor: params.trackAnchor ?? !alignOptionToTrigger,
+ });
+
+ const positionerStyles: React.CSSProperties = React.useMemo(
+ () => (alignOptionToTrigger ? { position: 'fixed' } : enabledPositionerStyles),
+ [alignOptionToTrigger, enabledPositionerStyles],
+ );
+
+ const getPositionerProps: useSelectPositioner.ReturnValue['getPositionerProps'] =
+ React.useCallback(
+ (externalProps = {}) => {
+ const hiddenStyles: React.CSSProperties = {};
+
+ if (!open || hidden) {
+ hiddenStyles.pointerEvents = 'none';
+ }
+
+ return mergeReactProps<'div'>(externalProps, {
+ role: 'presentation',
+ hidden: !mounted,
+ style: {
+ ...positionerStyles,
+ ...hiddenStyles,
+ },
+ });
+ },
+ [open, hidden, mounted, positionerStyles],
+ );
+
+ const positioner = React.useMemo(
+ () =>
+ ({
+ arrowRef,
+ arrowUncentered,
+ arrowStyles,
+ side: alignOptionToTrigger ? 'none' : renderedSide,
+ alignment: renderedAlignment,
+ positionerContext,
+ isPositioned,
+ }) as const,
+ [
+ alignOptionToTrigger,
+ arrowRef,
+ arrowStyles,
+ arrowUncentered,
+ isPositioned,
+ positionerContext,
+ renderedAlignment,
+ renderedSide,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getPositionerProps,
+ positioner,
+ }),
+ [getPositionerProps, positioner],
+ );
+}
+
+export namespace useSelectPositioner {
+ export interface SharedParameters {
+ /**
+ * The anchor element to which the Select popup will be placed at.
+ */
+ anchor?:
+ | Element
+ | null
+ | VirtualElement
+ | React.MutableRefObject
+ | (() => Element | VirtualElement | null);
+ /**
+ * The CSS position method for positioning the Select popup element.
+ * @default 'absolute'
+ */
+ positionMethod?: 'absolute' | 'fixed';
+ /**
+ * The container element to which the Select popup will be appended to.
+ */
+ container?: HTMLElement | null | React.MutableRefObject;
+ /**
+ * The side of the anchor element that the Select element should align to.
+ * @default 'bottom'
+ */
+ side?: Side;
+ /**
+ * The gap between the anchor element and the Select element.
+ * @default 0
+ */
+ sideOffset?: number;
+ /**
+ * The alignment of the Select element to the anchor element along its cross axis.
+ * @default 'start'
+ */
+ alignment?: 'start' | 'end' | 'center';
+ /**
+ * The offset of the Select element along its alignment axis.
+ * @default 0
+ */
+ alignmentOffset?: number;
+ /**
+ * The boundary that the Select element should be constrained to.
+ * @default 'clippingAncestors'
+ */
+ collisionBoundary?: Boundary;
+ /**
+ * The padding of the collision boundary.
+ * @default 5
+ */
+ collisionPadding?: Padding;
+ /**
+ * If `true`, the Select will be hidden if it is detached from its anchor element due to
+ * differing clipping contexts.
+ * @default false
+ */
+ hideWhenDetached?: boolean;
+ /**
+ * Whether the select popup remains mounted in the DOM while closed.
+ * @default true
+ */
+ keepMounted?: boolean;
+ /**
+ * If `true`, allow the Select to remain in stuck view while the anchor element is scrolled out
+ * of view.
+ * @default false
+ */
+ sticky?: boolean;
+ /**
+ * Determines the padding between the arrow and the Select popup's edges. Useful when the popover
+ * popup has rounded corners via `border-radius`.
+ * @default 5
+ */
+ arrowPadding?: number;
+ /**
+ * Whether the select popup continuously tracks its anchor after the initial positioning upon mount.
+ * @default true
+ */
+ trackAnchor?: boolean;
+ }
+
+ export interface Parameters extends SharedParameters {
+ /**
+ * If `true`, the Select is open.
+ */
+ open?: boolean;
+ /**
+ * If `true`, the Select is mounted.
+ * @default true
+ */
+ mounted?: boolean;
+ /**
+ * The Select root context.
+ */
+ floatingRootContext?: FloatingRootContext;
+ /**
+ * Floating node id.
+ */
+ nodeId?: string;
+ /**
+ * If specified, positions the popup relative to the selected option inside it.
+ */
+ inner?: Middleware;
+ /**
+ * Whether the floating element can flip to the perpendicular axis if it cannot fit in the
+ * viewport.
+ * @default true
+ */
+ allowAxisFlip?: boolean;
+ /**
+ * Whether to use fallback anchor postioning because anchoring to an inner item results in poor
+ * UX.
+ * @default false
+ */
+ innerFallback?: boolean;
+ /**
+ * Whether the user's current modality is touch.
+ * @default false
+ */
+ touchModality?: boolean;
+ }
+
+ export interface ReturnValue {
+ /**
+ * Props to spread on the Select positioner element.
+ */
+ getPositionerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
+ /**
+ * The Select positioner context.
+ */
+ positioner: {
+ /**
+ * The ref of the Select arrow element.
+ */
+ arrowRef: React.MutableRefObject;
+ /**
+ * Determines if the arrow cannot be centered.
+ */
+ arrowUncentered: boolean;
+ /**
+ * The rendered side of the Select element.
+ */
+ side: 'top' | 'right' | 'bottom' | 'left' | 'none';
+ /**
+ * The rendered alignment of the Select element.
+ */
+ alignment: 'start' | 'end' | 'center';
+ /**
+ * The styles to apply to the Select arrow element.
+ */
+ arrowStyles: React.CSSProperties;
+ /**
+ * The floating context.
+ */
+ positionerContext: FloatingContext;
+ /**
+ * Whether the Select popup has been positioned.
+ */
+ isPositioned: boolean;
+ };
+ }
+}
diff --git a/packages/mui-base/src/Select/Root/SelectIndexContext.ts b/packages/mui-base/src/Select/Root/SelectIndexContext.ts
new file mode 100644
index 0000000000..20dbfa6d78
--- /dev/null
+++ b/packages/mui-base/src/Select/Root/SelectIndexContext.ts
@@ -0,0 +1,20 @@
+import * as React from 'react';
+
+export interface SelectIndexContext {
+ activeIndex: number | null;
+ setActiveIndex: React.Dispatch>;
+ selectedIndex: number | null;
+ setSelectedIndex: React.Dispatch>;
+}
+
+export const SelectIndexContext = React.createContext(undefined);
+
+export function useSelectIndexContext() {
+ const context = React.useContext(SelectIndexContext);
+ if (context === undefined) {
+ throw new Error(
+ 'Base UI: SelectIndexContext is missing. Select parts must be placed within .',
+ );
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/Select/Root/SelectRoot.test.tsx b/packages/mui-base/src/Select/Root/SelectRoot.test.tsx
new file mode 100644
index 0000000000..44b1a3117f
--- /dev/null
+++ b/packages/mui-base/src/Select/Root/SelectRoot.test.tsx
@@ -0,0 +1,260 @@
+import * as React from 'react';
+import { Select } from '@base_ui/react/Select';
+import { fireEvent, flushMicrotasks, screen } from '@mui/internal-test-utils';
+import { createRenderer } from '#test-utils';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describe('prop: defaultValue', () => {
+ it('should select the option by default', async () => {
+ await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+
+ fireEvent.click(trigger);
+
+ await flushMicrotasks();
+
+ expect(screen.getByRole('option', { name: 'b', hidden: false })).to.have.attribute(
+ 'data-selected',
+ '',
+ );
+ });
+ });
+
+ describe('prop: value', () => {
+ it('should select the option specified by the value prop', async () => {
+ await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+
+ fireEvent.click(trigger);
+
+ await flushMicrotasks();
+
+ expect(screen.getByRole('option', { name: 'b', hidden: false })).to.have.attribute(
+ 'data-selected',
+ '',
+ );
+ });
+
+ it('should update the selected option when the value prop changes', async () => {
+ const { setProps } = await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+
+ fireEvent.click(trigger);
+
+ await flushMicrotasks();
+
+ expect(screen.getByRole('option', { name: 'a', hidden: false })).to.have.attribute(
+ 'data-selected',
+ '',
+ );
+
+ setProps({ value: 'b' });
+
+ await flushMicrotasks();
+
+ expect(screen.getByRole('option', { name: 'b', hidden: false })).to.have.attribute(
+ 'data-selected',
+ '',
+ );
+ });
+ });
+
+ describe('prop: onValueChange', () => {
+ it('should call onValueChange when an option is selected', async function test() {
+ const handleValueChange = spy();
+
+ function App() {
+ const [value, setValue] = React.useState('');
+
+ return (
+ {
+ setValue(newValue);
+ handleValueChange(newValue);
+ }}
+ animated={false}
+ >
+
+
+
+
+
+ a
+ b
+
+
+
+ );
+ }
+
+ const { user } = await render();
+
+ const trigger = screen.getByTestId('trigger');
+
+ await user.click(trigger);
+
+ await flushMicrotasks();
+
+ const option = await screen.findByRole('option', { name: 'b', hidden: false });
+
+ await user.click(option);
+
+ expect(handleValueChange.args[0][0]).to.equal('b');
+ });
+ });
+
+ describe('prop: defaultOpen', () => {
+ it('should open the select by default', async () => {
+ await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ expect(screen.getByRole('listbox', { hidden: false })).toBeVisible();
+ });
+ });
+
+ describe('prop: open', () => {
+ it('should control the open state of the select', async () => {
+ function ControlledSelect({ open }: { open: boolean }) {
+ return (
+
+
+
+
+
+
+ a
+ b
+
+
+
+ );
+ }
+
+ const { rerender } = await render();
+
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+
+ rerender();
+
+ await flushMicrotasks();
+
+ expect(screen.queryByRole('listbox')).not.to.equal(null);
+ });
+ });
+
+ describe('prop: onOpenChange', () => {
+ it('should call onOpenChange when the select is opened or closed', async () => {
+ const handleOpenChange = spy();
+
+ const { user } = await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+
+ await user.click(trigger);
+ expect(handleOpenChange.callCount).to.equal(1);
+ expect(handleOpenChange.args[0][0]).to.equal(true);
+
+ await user.click(trigger);
+ expect(handleOpenChange.callCount).to.equal(2);
+ expect(handleOpenChange.args[1][0]).to.equal(false);
+ });
+ });
+
+ it('should handle browser autofill', async () => {
+ const { container } = await render(
+
+
+
+
+
+
+ a
+ b
+
+
+ ,
+ );
+
+ const trigger = screen.getByTestId('trigger');
+
+ fireEvent.click(trigger);
+
+ await flushMicrotasks();
+
+ fireEvent.change(container.querySelector('[name="select"]')!, { target: { value: 'b' } });
+
+ await flushMicrotasks();
+
+ expect(screen.getByRole('option', { name: 'b', hidden: false })).to.have.attribute(
+ 'data-selected',
+ '',
+ );
+ });
+});
diff --git a/packages/mui-base/src/Select/Root/SelectRoot.tsx b/packages/mui-base/src/Select/Root/SelectRoot.tsx
new file mode 100644
index 0000000000..093556f119
--- /dev/null
+++ b/packages/mui-base/src/Select/Root/SelectRoot.tsx
@@ -0,0 +1,199 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useSelectRoot } from './useSelectRoot';
+import { SelectRootContext } from './SelectRootContext';
+import { SelectIndexContext } from './SelectIndexContext';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+import { visuallyHidden } from '../../utils/visuallyHidden';
+
+/**
+ *
+ * Demos:
+ *
+ * - [Select](https://base-ui.netlify.app/components/react-select/)
+ *
+ * API:
+ *
+ * - [SelectRoot API](https://base-ui.netlify.app/components/react-select/#api-reference-SelectRoot)
+ */
+const SelectRoot: SelectRoot = function SelectRoot(
+ props: SelectRoot.Props,
+): React.JSX.Element {
+ const {
+ value: valueProp,
+ defaultValue = null,
+ onValueChange,
+ open,
+ defaultOpen = false,
+ onOpenChange,
+ alignOptionToTrigger = true,
+ animated = true,
+ name,
+ disabled = false,
+ readOnly = false,
+ required = false,
+ } = props;
+
+ const selectRoot = useSelectRoot({
+ value: valueProp,
+ defaultValue,
+ onValueChange,
+ open,
+ defaultOpen,
+ onOpenChange,
+ alignOptionToTrigger,
+ animated,
+ name,
+ disabled,
+ readOnly,
+ required,
+ });
+
+ const { setDirty, validityData } = useFieldRootContext();
+
+ const { rootContext } = selectRoot;
+ const value = rootContext.value;
+
+ const serializedValue = React.useMemo(() => {
+ if (value == null) {
+ return ''; // avoid uncontrolled -> controlled error
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value);
+ }, [value]);
+
+ return (
+
+
+ {props.children}
+ to the trigger element.
+ rootContext.triggerElement?.focus();
+ },
+ // Handle browser autofill.
+ onChange(event: React.ChangeEvent) {
+ // Workaround for https://github.com/facebook/react/issues/9023
+ if (event.nativeEvent.defaultPrevented) {
+ return;
+ }
+
+ const nextValue = event.target.value;
+
+ const exactValue = rootContext.valuesRef.current.find(
+ (v) =>
+ v === nextValue ||
+ (typeof value === 'string' && nextValue.toLowerCase() === v.toLowerCase()),
+ );
+
+ if (exactValue != null) {
+ setDirty(exactValue !== validityData.initialValue);
+ rootContext.setValue?.(exactValue, event.nativeEvent);
+ }
+ },
+ id: rootContext.id,
+ name: rootContext.name,
+ disabled: rootContext.disabled,
+ required: rootContext.required,
+ readOnly: rootContext.readOnly,
+ value: serializedValue,
+ ref: rootContext.fieldControlValidation.inputRef,
+ style: visuallyHidden,
+ tabIndex: -1,
+ 'aria-hidden': true,
+ })}
+ />
+
+
+ );
+};
+
+SelectRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * Determines if the selected option inside the popup should align to the trigger element.
+ * @default true
+ */
+ alignOptionToTrigger: PropTypes.bool,
+ /**
+ * If `true`, the Select supports CSS-based animations and transitions.
+ * It is kept in the DOM until the animation completes.
+ *
+ * @default true
+ */
+ animated: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * If `true`, the Select is initially open.
+ *
+ * @default false
+ */
+ defaultOpen: PropTypes.bool,
+ /**
+ * The default value of the select.
+ * @default null
+ */
+ defaultValue: PropTypes.any,
+ /**
+ * If `true`, the Select is disabled.
+ *
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * The name of the Select in the owning form.
+ */
+ name: PropTypes.string,
+ /**
+ * Callback fired when the component requests to be opened or closed.
+ */
+ onOpenChange: PropTypes.func,
+ /**
+ * Callback fired when the value of the select changes. Use when controlled.
+ */
+ onValueChange: PropTypes.func,
+ /**
+ * Allows to control whether the dropdown is open.
+ * This is a controlled counterpart of `defaultOpen`.
+ */
+ open: PropTypes.bool,
+ /**
+ * If `true`, the Select is read-only.
+ * @default false
+ */
+ readOnly: PropTypes.bool,
+ /**
+ * If `true`, the Select is required.
+ * @default false
+ */
+ required: PropTypes.bool,
+ /**
+ * The value of the select.
+ */
+ value: PropTypes.any,
+} as any;
+
+export { SelectRoot };
+
+namespace SelectRoot {
+ export interface Props extends useSelectRoot.Parameters {
+ children?: React.ReactNode;
+ }
+
+ export interface OwnerState {}
+}
+
+interface SelectRoot {
+ (props: SelectRoot.Props): React.JSX.Element;
+ propTypes?: any;
+}
diff --git a/packages/mui-base/src/Select/Root/SelectRootContext.ts b/packages/mui-base/src/Select/Root/SelectRootContext.ts
new file mode 100644
index 0000000000..0c3dbd3047
--- /dev/null
+++ b/packages/mui-base/src/Select/Root/SelectRootContext.ts
@@ -0,0 +1,63 @@
+import * as React from 'react';
+import { useFloatingRootContext } from '@floating-ui/react';
+import type { TransitionStatus } from '../../utils/useTransitionStatus';
+import type { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation';
+import type { GenericHTMLProps } from '../../utils/types';
+
+export interface SelectRootContext {
+ name: string | undefined;
+ disabled: boolean;
+ readOnly: boolean;
+ required: boolean;
+ value: any;
+ setValue: (nextValue: any, event?: Event) => void;
+ open: boolean;
+ setOpen: (nextOpen: boolean, event?: Event) => void;
+ mounted: boolean;
+ setMounted: React.Dispatch>;
+ transitionStatus: TransitionStatus;
+ triggerElement: HTMLElement | null;
+ setTriggerElement: React.Dispatch>;
+ positionerElement: HTMLElement | null;
+ setPositionerElement: React.Dispatch>;
+ scrollUpArrowVisible: boolean;
+ setScrollUpArrowVisible: React.Dispatch>;
+ scrollDownArrowVisible: boolean;
+ setScrollDownArrowVisible: React.Dispatch>;
+ setControlledAlignOptionToTrigger: React.Dispatch>;
+ listRef: React.MutableRefObject>;
+ popupRef: React.MutableRefObject;
+ getRootTriggerProps: (props?: GenericHTMLProps) => GenericHTMLProps;
+ getRootPositionerProps: (props?: GenericHTMLProps) => GenericHTMLProps;
+ getItemProps: (
+ props?: GenericHTMLProps & { active?: boolean; selected?: boolean },
+ ) => Record;
+ floatingRootContext: ReturnType;
+ label: string;
+ setLabel: React.Dispatch>;
+ valuesRef: React.MutableRefObject>;
+ valueRef: React.MutableRefObject;
+ selectedOptionTextRef: React.MutableRefObject;
+ labelsRef: React.MutableRefObject>;
+ touchModality: boolean;
+ setTouchModality: React.Dispatch>;
+ alignOptionToTrigger: boolean;
+ typingRef: React.MutableRefObject;
+ selectionRef: React.MutableRefObject<{
+ allowUnselectedMouseUp: boolean;
+ allowSelectedMouseUp: boolean;
+ allowSelect: boolean;
+ }>;
+ id: string | undefined;
+ fieldControlValidation: ReturnType;
+}
+
+export const SelectRootContext = React.createContext(null);
+
+export function useSelectRootContext() {
+ const context = React.useContext(SelectRootContext);
+ if (context === null) {
+ throw new Error('useSelectRootContext must be used within a SelectRoot');
+ }
+ return context;
+}
diff --git a/packages/mui-base/src/Select/Root/useSelectRoot.ts b/packages/mui-base/src/Select/Root/useSelectRoot.ts
new file mode 100644
index 0000000000..711429ba14
--- /dev/null
+++ b/packages/mui-base/src/Select/Root/useSelectRoot.ts
@@ -0,0 +1,365 @@
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import {
+ useClick,
+ useDismiss,
+ useFloatingRootContext,
+ useInteractions,
+ useListNavigation,
+ useRole,
+ useTypeahead,
+} from '@floating-ui/react';
+import { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+import { useId } from '../../utils/useId';
+import { useControlled } from '../../utils/useControlled';
+import { type TransitionStatus, useTransitionStatus } from '../../utils';
+import { useAnimationsFinished } from '../../utils/useAnimationsFinished';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { warn } from '../../utils/warn';
+import type { SelectRootContext } from './SelectRootContext';
+import type { SelectIndexContext } from './SelectIndexContext';
+
+export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.ReturnValue {
+ const {
+ disabled = false,
+ readOnly = false,
+ required = false,
+ alignOptionToTrigger: alignOptionToTriggerParam = true,
+ } = params;
+
+ const id = useId();
+
+ const { setDirty, validityData, validationMode } = useFieldRootContext();
+ const fieldControlValidation = useFieldControlValidation();
+
+ const [value, setValueUnwrapped] = useControlled({
+ controlled: params.value,
+ default: params.defaultValue,
+ name: 'Select',
+ state: 'value',
+ });
+
+ const [open, setOpenUnwrapped] = useControlled({
+ controlled: params.open,
+ default: params.defaultOpen,
+ name: 'Select',
+ state: 'open',
+ });
+
+ const [controlledAlignOptionToTrigger, setControlledAlignOptionToTrigger] =
+ React.useState(alignOptionToTriggerParam);
+
+ const listRef = React.useRef>([]);
+ const labelsRef = React.useRef>([]);
+ const popupRef = React.useRef(null);
+ const valueRef = React.useRef(null);
+ const valuesRef = React.useRef>([]);
+ const typingRef = React.useRef(false);
+ const selectedOptionTextRef = React.useRef(null);
+ const selectionRef = React.useRef({
+ allowSelectedMouseUp: false,
+ allowUnselectedMouseUp: false,
+ allowSelect: false,
+ });
+
+ const [triggerElement, setTriggerElement] = React.useState(null);
+ const [positionerElement, setPositionerElement] = React.useState(null);
+ const [activeIndex, setActiveIndex] = React.useState(null);
+ const [selectedIndex, setSelectedIndex] = React.useState(null);
+ const [label, setLabel] = React.useState('');
+ const [touchModality, setTouchModality] = React.useState(false);
+ const [scrollUpArrowVisible, setScrollUpArrowVisible] = React.useState(false);
+ const [scrollDownArrowVisible, setScrollDownArrowVisible] = React.useState(false);
+
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, params.animated);
+
+ const runOnceAnimationsFinish = useAnimationsFinished(popupRef);
+
+ const alignOptionToTrigger = Boolean(mounted && controlledAlignOptionToTrigger && !touchModality);
+
+ if (!mounted && controlledAlignOptionToTrigger !== alignOptionToTriggerParam) {
+ setControlledAlignOptionToTrigger(alignOptionToTriggerParam);
+ }
+
+ if (!alignOptionToTriggerParam || !mounted) {
+ if (scrollUpArrowVisible) {
+ setScrollUpArrowVisible(false);
+ }
+ if (scrollDownArrowVisible) {
+ setScrollDownArrowVisible(false);
+ }
+ }
+
+ const setOpen = useEventCallback((nextOpen: boolean, event?: Event) => {
+ params.onOpenChange?.(nextOpen, event);
+ setOpenUnwrapped(nextOpen);
+
+ function handleUnmounted() {
+ ReactDOM.flushSync(() => {
+ setMounted(false);
+ });
+ }
+
+ if (!nextOpen) {
+ if (params.animated) {
+ runOnceAnimationsFinish(handleUnmounted);
+ } else {
+ handleUnmounted();
+ }
+ }
+ });
+
+ const setValue = useEventCallback((nextValue: any, event?: Event) => {
+ params.onValueChange?.(nextValue, event);
+ setValueUnwrapped(nextValue);
+
+ setDirty(nextValue !== validityData.initialValue);
+
+ if (validationMode === 'onChange') {
+ fieldControlValidation.commitValidation(nextValue);
+ }
+
+ const index = valuesRef.current.indexOf(nextValue);
+ setSelectedIndex(index);
+ setLabel(labelsRef.current[index] ?? '');
+ });
+
+ useEnhancedEffect(() => {
+ // Wait for the items to have registered their values in `valuesRef`.
+ queueMicrotask(() => {
+ const stringValue =
+ typeof value === 'string' || value === null ? value : JSON.stringify(value);
+ const index = valuesRef.current.indexOf(stringValue);
+ if (index !== -1) {
+ setSelectedIndex(index);
+ setLabel(labelsRef.current[index] ?? '');
+ } else if (value) {
+ warn(`The value \`${stringValue}\` is not present in the Select options.`);
+ }
+ });
+ }, [value]);
+
+ const floatingRootContext = useFloatingRootContext({
+ open,
+ onOpenChange: setOpen,
+ elements: {
+ reference: triggerElement,
+ floating: positionerElement,
+ },
+ });
+
+ const click = useClick(floatingRootContext, {
+ enabled: !readOnly,
+ event: 'mousedown',
+ });
+
+ const dismiss = useDismiss(floatingRootContext);
+
+ const role = useRole(floatingRootContext, {
+ role: 'select',
+ });
+
+ const listNavigation = useListNavigation(floatingRootContext, {
+ enabled: !readOnly,
+ listRef,
+ activeIndex,
+ selectedIndex,
+ onNavigate: setActiveIndex,
+ // Implement our own listeners since `onPointerLeave` on each option fires while scrolling with
+ // the `alignOptionToTrigger` prop enabled, causing a performance issue on Chrome.
+ focusItemOnHover: false,
+ });
+
+ const typehaead = useTypeahead(floatingRootContext, {
+ enabled: !readOnly,
+ listRef: labelsRef,
+ activeIndex,
+ selectedIndex,
+ onMatch(index) {
+ if (open) {
+ setActiveIndex(index);
+ } else {
+ setValue(valuesRef.current[index]);
+ }
+ },
+ onTypingChange(typing) {
+ // FIXME: Floating UI doesn't support allowing space to select an item while the popup is
+ // closed and the trigger isn't a native