diff --git a/.changeset/few-bugs-promise.md b/.changeset/few-bugs-promise.md
new file mode 100644
index 0000000000..57bbff1794
--- /dev/null
+++ b/.changeset/few-bugs-promise.md
@@ -0,0 +1,5 @@
+---
+'@leafygreen-ui/select': major
+---
+
+Initial release of Select component
diff --git a/.changeset/wet-hotels-listen.md b/.changeset/wet-hotels-listen.md
new file mode 100644
index 0000000000..c1cb1b23c3
--- /dev/null
+++ b/.changeset/wet-hotels-listen.md
@@ -0,0 +1,7 @@
+---
+'@leafygreen-ui/button': minor
+---
+
+- The default outline shown by the browser is no longer shown when a button is focused. Instead, a custom interaction ring will be displayed that conforms to LeafyGreen-UI style guidelines whenever the button is hovered or focused.
+- Button now has a `darkMode` prop which for now only controls the interaction ring color.
+- Button now has a `focused` prop which can be used to force the button to display as focused or unfocused.
diff --git a/README.md b/README.md
index c41d3d2c3c..6f622e1e0b 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ A set of CSS styles and React components built with design in mind.
- [Portal](https://github.com/mongodb/leafygreen-ui/tree/master/packages/portal)
- [Radio Box Group](https://github.com/mongodb/leafygreen-ui/tree/master/packages/radio-box-group)
- [Radio Group](https://github.com/mongodb/leafygreen-ui/tree/master/packages/radio-group)
+- [Select](https://github.com/mongodb/leafygreen-ui/tree/master/packages/select)
- [Side Nav](https://github.com/mongodb/leafygreen-ui/tree/master/packages/side-nav)
- [Stepper](https://github.com/mongodb/leafygreen-ui/tree/master/packages/stepper)
- [Syntax](https://github.com/mongodb/leafygreen-ui/tree/master/packages/syntax)
diff --git a/babel.config.js b/babel.config.js
index f070543bf1..6970b6d894 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -21,6 +21,7 @@ module.exports = function (api) {
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
+ '@babel/plugin-proposal-logical-assignment-operators',
'emotion',
];
diff --git a/build.tsconfig.json b/build.tsconfig.json
index b0bdd2f18e..9693952e61 100644
--- a/build.tsconfig.json
+++ b/build.tsconfig.json
@@ -86,6 +86,9 @@
{
"path": "./packages/radio-group"
},
+ {
+ "path": "./packages/select"
+ },
{
"path": "./packages/side-nav"
},
diff --git a/package.json b/package.json
index d6933d124b..fc3505f159 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"@babel/plugin-proposal-class-properties": "7.8.3",
"@babel/plugin-proposal-export-default-from": "7.8.3",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.8.3",
+ "@babel/plugin-proposal-logical-assignment-operators": "7.12.1",
"@babel/plugin-proposal-object-rest-spread": "7.9.5",
"@babel/plugin-proposal-optional-chaining": "7.9.0",
"@babel/plugin-transform-react-jsx": "^7.9.4",
diff --git a/packages/select/README.md b/packages/select/README.md
new file mode 100644
index 0000000000..5f29ae3e7c
--- /dev/null
+++ b/packages/select/README.md
@@ -0,0 +1,133 @@
+# Select
+
+
+
+#### [View on Storybook](https://mongodb.github.io/leafygreen-ui/?path=/story/select--default)
+
+## Installation
+
+### Yarn
+
+```shell
+yarn add @leafygreen-ui/select
+```
+
+### NPM
+
+```shell
+npm install @leafygreen-ui/select
+```
+
+## Example
+
+```js
+import { Option, OptionGroup, Select, Size } from '@leafygreen-ui/select';
+
+;
+```
+
+**Output HTML**
+
+```html
+
+
+
Description
+
+
+```
+
+## Select Properties
+
+| Prop | Type | Description | Default |
+| ----------- | --------------------------------------------------- | ----------------------------------------------------------------- | ----------- |
+| `children` | `node` | `` and `` elements. | |
+| `className` | `string` | Adds a className to the outermost element. | |
+| `darkMode` | `boolean` | Determines whether or not the component will appear in dark mode. | `false` |
+| `size` | `'xsmall'` \| `'small'` \| `'default'` \| `'large'` | Sets the size of the component's elements. | `'default'` |
+| `id` | `string` | id associated with the Select component. | |
+| `name` | `string` | The name that will be used when submitted as part of a form. |
+
+| `label` | `string` | Text shown in bold above the input element. | |
+| `aria-labelledby` | `string` | Must be provided if and only if `label` is not provided. | |
+| `description` | `string` | Text that gives more detail about the requirements for the input. | |
+| `placeholder` | `string` | The placeholder text shown in the input element when an option is not selected. | `'Select'` |
+| `disabled` | `boolean` | Disables the component from being edited. | `false` |
+| `value` | `string` | Sets the `` that will appear selected and makes the component a controlled component. | `''` |
+| `defaultValue` | `string` | Sets the `` that will appear selected on page load when the component is uncontrolled. | `''` |
+| `onChange` | `function` | A function that gets called when the selected value changes. Receives the value string as the first argument. | `() => {}` |
+| `readOnly` | `boolean` | Disables the console warning when the component is controlled and no `onChange` prop is provided. | `false` |
+
+# Option
+
+| Prop | Type | Description | Default |
+| ----------- | -------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------- |
+| `children` | `node` | Content to appear inside of the component. | |
+| `className` | `string` | Adds a className to the outermost element. | |
+| `glyph` | `React.ReactElement` | Icon to display next to the option text. | |
+| `value` | `string` | Corresponds to the value passed into the `onChange` prop of `` when the option is selected. | text contents of `children` |
+| `disabled` | `boolean` | Prevents the option from being selectable. | `false` |
+
+# OptionGroup
+
+| Prop | Type | Description | Default |
+| ----------- | --------- | --------------------------------------------------------- | ------- |
+| `children` | `node` | `` elements | |
+| `className` | `string` | Adds a className to the outermost element. | |
+| `label` | `string` | Text shown above the group's options. | |
+| `disabled` | `boolean` | Prevents all the contained options from being selectable. | `false` |
diff --git a/packages/select/package.json b/packages/select/package.json
new file mode 100644
index 0000000000..31a9bc4c8b
--- /dev/null
+++ b/packages/select/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@leafygreen-ui/select",
+ "version": "0.9.0",
+ "description": "leafyGreen UI Kit Select",
+ "main": "./dist/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/index.d.ts",
+ "typesVersions": {
+ "<3.9": {
+ "*": [
+ "ts3.4/*"
+ ]
+ }
+ },
+ "scripts": {
+ "build": "../../node_modules/.bin/rollup --config ../../rollup.config.js"
+ },
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@leafygreen-ui/button": "^10.0.0",
+ "@leafygreen-ui/emotion": "^3.0.1",
+ "@leafygreen-ui/hooks": "^6.0.0",
+ "@leafygreen-ui/icon": "^7.0.2",
+ "@leafygreen-ui/lib": "^6.1.2",
+ "@leafygreen-ui/palette": "^3.1.0",
+ "@leafygreen-ui/popover": "^7.1.0",
+ "@leafygreen-ui/tokens": "0.5.0",
+ "@types/react-is": "^17.0.0",
+ "polished": "^4.0.3",
+ "react-is": "^17.0.1"
+ },
+ "peerDependencies": {
+ "@leafygreen-ui/leafygreen-provider": "^2.0.1"
+ }
+}
diff --git a/packages/select/src/ListMenu.tsx b/packages/select/src/ListMenu.tsx
new file mode 100644
index 0000000000..49a5648f80
--- /dev/null
+++ b/packages/select/src/ListMenu.tsx
@@ -0,0 +1,154 @@
+import React, { useCallback, useContext } from 'react';
+import { css, cx } from '@leafygreen-ui/emotion';
+import { useViewportSize } from '@leafygreen-ui/hooks';
+import { keyMap } from '@leafygreen-ui/lib';
+import Popover, { Align, Justify } from '@leafygreen-ui/popover';
+import { breakpoints } from '@leafygreen-ui/tokens';
+import SelectContext from './SelectContext';
+import { colorSets, mobileSizeSet, sizeSets } from './styleSets';
+import { useForwardedRef } from './utils';
+
+const menuStyle = css`
+ position: relative;
+ width: 100%;
+ border-radius: 3px;
+ line-height: 16px;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ overflow: auto;
+`;
+
+interface ListMenuProps {
+ children: React.ReactNode;
+ id: string;
+ referenceElement: React.MutableRefObject;
+ onClose: () => void;
+ onSelectFocusedOption: React.KeyboardEventHandler;
+ onFocusPreviousOption: () => void;
+ onFocusNextOption: () => void;
+ className?: string;
+}
+
+const ListMenu = React.forwardRef(
+ function ListMenu(
+ {
+ children,
+ id,
+ referenceElement,
+ onClose,
+ onFocusPreviousOption,
+ onFocusNextOption,
+ onSelectFocusedOption,
+ className,
+ }: ListMenuProps,
+ forwardedRef,
+ ) {
+ const { mode, size, disabled, open } = useContext(SelectContext);
+
+ const colorSet = colorSets[mode];
+ const sizeSet = sizeSets[size];
+
+ const ref = useForwardedRef(forwardedRef, null);
+
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ // No support for modifiers yet
+ /* istanbul ignore if */
+ if (event.ctrlKey || event.shiftKey || event.altKey) {
+ return;
+ }
+
+ let bubble = false;
+
+ switch (event.keyCode) {
+ case keyMap.Tab:
+ case keyMap.Enter:
+ onSelectFocusedOption(event);
+ break;
+ case keyMap.Escape:
+ onClose();
+ break;
+ case keyMap.ArrowUp:
+ onFocusPreviousOption();
+ break;
+ case keyMap.ArrowDown:
+ onFocusNextOption();
+ break;
+ /* istanbul ignore next */
+ default:
+ bubble = true;
+ }
+
+ /* istanbul ignore else */
+ if (!bubble) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ },
+ [
+ onClose,
+ onFocusNextOption,
+ onFocusPreviousOption,
+ onSelectFocusedOption,
+ ],
+ );
+
+ const viewportSize = useViewportSize();
+
+ const maxHeight =
+ viewportSize === null || ref.current === null
+ ? 0
+ : viewportSize.height - ref.current.getBoundingClientRect().top - 10;
+
+ const onClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (ref.current) {
+ ref.current.focus();
+ }
+ event.stopPropagation();
+ },
+ [ref],
+ );
+
+ return (
+
+
+
+ );
+ },
+);
+
+ListMenu.displayName = 'ListMenu';
+
+export default ListMenu;
diff --git a/packages/select/src/MenuButton.tsx b/packages/select/src/MenuButton.tsx
new file mode 100644
index 0000000000..31b8dd67d4
--- /dev/null
+++ b/packages/select/src/MenuButton.tsx
@@ -0,0 +1,206 @@
+import React, { useCallback, useContext, useMemo } from 'react';
+import Button, { Variant } from '@leafygreen-ui/button';
+import { css, cx } from '@leafygreen-ui/emotion';
+import CaretDownIcon from '@leafygreen-ui/icon/dist/CaretDown';
+import { keyMap } from '@leafygreen-ui/lib';
+import { breakpoints } from '@leafygreen-ui/tokens';
+import { colorSets, mobileSizeSet, Mode, sizeSets } from './styleSets';
+import SelectContext from './SelectContext';
+import { useForwardedRef } from './utils';
+
+const menuButtonStyle = css`
+ margin-top: 2px;
+
+ // reset default Button padding
+ > span {
+ padding: 0;
+ }
+`;
+
+const menuButtonContentsStyle = css`
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 100%;
+ width: 100%;
+`;
+
+const menuButtonTextStyle = css`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+`;
+
+const caretIconStyle = css`
+ min-width: 16px;
+`;
+
+type Props = {
+ children: React.ReactNode;
+ value: string;
+ text: React.ReactNode;
+ name?: string;
+ deselected: boolean;
+ readOnly?: boolean;
+ onFocusFirstOption: () => void;
+ onFocusLastOption: () => void;
+ onDeselect: React.KeyboardEventHandler;
+ onClose: () => void;
+ onOpen: () => void;
+} & Required<
+ Pick<
+ JSX.IntrinsicElements['div'],
+ 'aria-labelledby' | 'aria-controls' | 'aria-expanded' | 'aria-describedby'
+ >
+>;
+
+const MenuButton = React.forwardRef(function MenuButton(
+ {
+ children,
+ value,
+ text,
+ name,
+ deselected,
+ readOnly,
+ onDeselect,
+ onFocusFirstOption,
+ onFocusLastOption,
+ onClose,
+ onOpen,
+ ...ariaProps
+ }: Props,
+ forwardedRef,
+) {
+ const { mode, size, open, disabled } = useContext(SelectContext);
+
+ const ref = useForwardedRef(forwardedRef, null);
+
+ const colorSet = colorSets[mode];
+ const sizeSet = sizeSets[size];
+
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ /* istanbul ignore if */
+ if (event.ctrlKey || event.shiftKey || event.altKey) {
+ return;
+ }
+
+ let bubble = false;
+
+ switch (event.keyCode) {
+ case keyMap.Tab:
+ onClose();
+ bubble = true;
+ break;
+ case keyMap.Escape:
+ if (open) {
+ onClose();
+ } else {
+ onDeselect(event);
+ }
+ break;
+ case keyMap.ArrowUp:
+ onOpen();
+ onFocusLastOption();
+ break;
+ case keyMap.ArrowDown:
+ onOpen();
+ onFocusFirstOption();
+ break;
+ /* istanbul ignore next */
+ default:
+ bubble = true;
+ }
+
+ if (!bubble) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ },
+ [
+ disabled,
+ onClose,
+ onDeselect,
+ onFocusFirstOption,
+ onFocusLastOption,
+ onOpen,
+ open,
+ ],
+ );
+
+ const onClick = useCallback(() => {
+ if (open) {
+ onClose();
+ } else {
+ onOpen();
+ }
+ ref.current!.focus();
+ }, [onClose, onOpen, open, ref]);
+
+ const forceState = useMemo(() => {
+ if (open && !disabled) {
+ return { focused: true, active: true };
+ }
+ }, [open, disabled]);
+
+ return (
+
+ &:after {
+ opacity: 1;
+ }
+ `]: open && !disabled,
+ [css`
+ color: ${colorSet.text.disabled};
+ `]: disabled,
+ },
+ )}
+ >
+
+ {text}
+
+
+ {children}
+
+ );
+});
+
+MenuButton.displayName = 'MenuButton';
+
+export default MenuButton;
diff --git a/packages/select/src/Option.tsx b/packages/select/src/Option.tsx
new file mode 100644
index 0000000000..ac4b1a03ec
--- /dev/null
+++ b/packages/select/src/Option.tsx
@@ -0,0 +1,274 @@
+import React, { useCallback, useContext, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { css, cx } from '@leafygreen-ui/emotion';
+import { usePrevious } from '@leafygreen-ui/hooks';
+import CheckmarkIcon from '@leafygreen-ui/icon/dist/Checkmark';
+import { LGGlyph } from '@leafygreen-ui/icon/src/types';
+import { colorSets } from './styleSets';
+import SelectContext from './SelectContext';
+
+type GlyphElement = React.ReactElement & {
+ type?: { isGlyph?: boolean };
+};
+
+export type ReactEmpty = null | undefined | false | '';
+
+const optionStyle = css`
+ display: flex;
+ width: 100%;
+ padding: 10px 12px;
+ outline: none;
+ overflow-wrap: anywhere;
+ transition: background-color 150ms ease-in-out;
+`;
+
+const optionTextStyle = css`
+ display: flex;
+ align-items: center;
+`;
+
+const iconStyle = css`
+ min-width: 16px;
+ margin-right: 6px;
+`;
+
+export interface InternalProps {
+ children: React.ReactNode;
+ className: string | undefined;
+ glyph: GlyphElement | undefined;
+ selected: boolean;
+ focused: boolean;
+ disabled: boolean;
+ onClick: React.MouseEventHandler;
+ onFocus: React.FocusEventHandler;
+ isDeselection: boolean;
+ hasGlyphs: boolean;
+ triggerScrollIntoView: boolean;
+}
+
+export function InternalOption({
+ children,
+ className,
+ glyph,
+ selected,
+ focused,
+ disabled,
+ onClick,
+ onFocus,
+ isDeselection,
+ triggerScrollIntoView,
+ hasGlyphs,
+}: InternalProps) {
+ const { mode } = useContext(SelectContext);
+
+ const { option: colorSet } = colorSets[mode];
+
+ const ref = useRef(null);
+
+ const scrollIntoView = useCallback(() => {
+ const element = ref.current!;
+ const parent = element.offsetParent!;
+ // Can't use Element.scrollIntoView because it might
+ // cause scrolling outside the immediate parent.
+ parent.scrollTop =
+ element.offsetTop + (element.clientHeight - parent.clientHeight) / 2;
+ }, [ref]);
+
+ const alreadyScrolledIntoView = usePrevious(triggerScrollIntoView);
+ const shouldScrollIntoView =
+ triggerScrollIntoView && !alreadyScrolledIntoView;
+
+ useEffect(() => {
+ if (shouldScrollIntoView) {
+ scrollIntoView();
+ }
+ }, [scrollIntoView, shouldScrollIntoView]);
+
+ const wasFocused = usePrevious(focused);
+ const shouldFocus = focused && !wasFocused;
+
+ useEffect(() => {
+ if (shouldFocus) {
+ scrollIntoView();
+ ref.current!.focus();
+ }
+ }, [scrollIntoView, shouldFocus]);
+
+ const styledChildren: React.ReactNode = (
+
+ {children}
+
+ );
+
+ const iconPlaceholder = (
+
+ );
+
+ let styledGlyph = iconPlaceholder;
+
+ if (glyph) {
+ if (!glyph.type.isGlyph) {
+ console.error(
+ '`Option` instance did not render icon because it is not a known glyph element.',
+ );
+ } else {
+ styledGlyph = React.cloneElement(glyph, {
+ key: 'glyph',
+ className: cx(
+ iconStyle,
+ css`
+ color: ${colorSet.icon.base};
+ `,
+ {
+ [css`
+ color: ${colorSet.icon.disabled};
+ `]: disabled,
+ },
+ glyph.props.className,
+ ),
+ });
+ }
+ }
+
+ const checkmark =
+ selected && !isDeselection ? (
+
+ ) : (
+ iconPlaceholder
+ );
+
+ let renderedChildren: React.ReactNode;
+
+ if (hasGlyphs) {
+ renderedChildren = (
+
+
+ {styledGlyph}
+ {styledChildren}
+
+ {checkmark}
+
+ );
+ } else {
+ renderedChildren = (
+ <>
+ {checkmark}
+ {styledChildren}
+ >
+ );
+ }
+
+ return (
+
+ {renderedChildren}
+
+ );
+}
+
+InternalOption.displayName = 'Option';
+
+interface Props {
+ className?: string;
+ glyph?: GlyphElement;
+ disabled?: boolean;
+ value?: string;
+ children: React.ReactText | Array;
+}
+
+export default function Option(_: Props): JSX.Element {
+ throw Error('`Option` must be a child of a `Select` instance');
+}
+
+Option.displayName = 'Option';
+
+const textPropType = PropTypes.oneOfType([PropTypes.string, PropTypes.number]);
+
+Option.propTypes = {
+ children: PropTypes.oneOfType([
+ textPropType,
+ PropTypes.arrayOf(
+ PropTypes.oneOfType([
+ textPropType,
+ PropTypes.oneOf([false, null, undefined, '']),
+ ]),
+ ),
+ ]).isRequired,
+ className: PropTypes.string,
+ glyph: PropTypes.element,
+ value: PropTypes.string,
+ disabled: PropTypes.bool,
+};
+
+// React.ReactComponentElement messes up the original
+// typing of props, so it is fixed up by overriding it.
+export type OptionElement = Omit<
+ React.ReactComponentElement,
+ 'props'
+> & { props: Props };
diff --git a/packages/select/src/OptionGroup.tsx b/packages/select/src/OptionGroup.tsx
new file mode 100644
index 0000000000..6c29553516
--- /dev/null
+++ b/packages/select/src/OptionGroup.tsx
@@ -0,0 +1,111 @@
+import React, { useContext, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { css, cx } from '@leafygreen-ui/emotion';
+import { IdAllocator } from '@leafygreen-ui/lib';
+import Option from './Option';
+import SelectContext from './SelectContext';
+import { colorSets } from './styleSets';
+
+const optionGroupStyle = css`
+ padding-top: 8px;
+`;
+
+const optionGroupLabelStyle = css`
+ cursor: default;
+ width: 100%;
+ padding: 0 12px 2px;
+ outline: none;
+ overflow-wrap: anywhere;
+ font-size: 13px;
+ line-height: 16px;
+ font-weight: bold;
+`;
+
+export type ReactEmpty = null | undefined | false | '';
+
+export interface InternalProps {
+ className: string | undefined;
+ label: string;
+ children: React.ReactNode;
+}
+
+const idAllocator = IdAllocator.create('select-option-group');
+
+export function InternalOptionGroup({
+ className,
+ label,
+ children,
+}: InternalProps) {
+ const { mode } = useContext(SelectContext);
+ const colorSet = colorSets[mode].option;
+
+ const groupId = useMemo(() => idAllocator.generate(), []);
+
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+InternalOptionGroup.displayName = 'OptionGroup';
+
+interface Props {
+ className?: string;
+ label: string;
+ disabled?: boolean;
+ children:
+ | React.ReactFragment
+ | React.ReactComponentElement
+ | Array<
+ | React.ReactComponentElement
+ | React.ReactFragment
+ | ReactEmpty
+ >;
+}
+
+export default function OptionGroup(_: Props): JSX.Element {
+ throw Error('`OptionGroup` must be a child of a `Select` instance');
+}
+
+OptionGroup.displayName = 'OptionGroup';
+
+OptionGroup.propTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.element,
+ PropTypes.arrayOf(
+ PropTypes.oneOfType([
+ PropTypes.oneOf([false, null, undefined, '']),
+ PropTypes.element,
+ ]),
+ ),
+ ]).isRequired,
+ className: PropTypes.string,
+ label: PropTypes.string.isRequired,
+ disabled: PropTypes.bool,
+};
+
+export type OptionGroupElement = React.ReactComponentElement<
+ typeof OptionGroup
+>;
diff --git a/packages/select/src/Select.spec.tsx b/packages/select/src/Select.spec.tsx
new file mode 100644
index 0000000000..8b610e12af
--- /dev/null
+++ b/packages/select/src/Select.spec.tsx
@@ -0,0 +1,832 @@
+import React, { useState } from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ fireEvent,
+ getByText as getByTextFor,
+ render,
+ RenderResult,
+ waitForElementToBeRemoved,
+ waitFor,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { enforceExhaustive, keyMap } from '@leafygreen-ui/lib';
+import { Context, jest as Jest } from '@leafygreen-ui/testing-lib';
+import BeakerIcon from '@leafygreen-ui/icon/dist/Beaker';
+import { Option, OptionGroup, Select } from '.';
+
+const Color = {
+ Red: 'Explicit value: Red',
+ Blue: 'Blue',
+ Green: 'Green',
+ Yellow: 'Yellow',
+ Orange: 'Orange',
+ Indigo: 'Indigo',
+ Violet: 'Violet',
+} as const;
+
+const enabledOptions = ['Select', 'Red', 'Blue', 'Green', 'Yellow'] as const;
+
+const defaultProps = {
+ label: 'Label',
+ name: 'pet',
+ description: 'Description',
+ children: [
+ ,
+ ,
+ ,
+
+
+
+ ,
+
+
+ <>
+
+ >
+ ,
+ ],
+} as const;
+
+function Controller({
+ children,
+ initialValue,
+}: {
+ children: (
+ value: string,
+ setValue: (value: React.SetStateAction) => void,
+ ) => React.ReactNode;
+ initialValue: string;
+}) {
+ const [value, setValue] = useState(initialValue);
+ return <>{children(value, setValue)}>;
+}
+
+let offsetParentSpy: jest.SpyInstance;
+beforeAll(() => {
+ offsetParentSpy = jest.spyOn(HTMLElement.prototype, 'offsetParent', 'get');
+
+ // JSDOM doesn't implement `HTMLElement.prototype.offsetParent`, so this
+ // falls back to the parent element since it doesn't matter for these tests.
+ offsetParentSpy.mockImplementation(function (this: HTMLElement) {
+ return this.parentElement;
+ });
+});
+
+afterAll(() => {
+ if (offsetParentSpy.mock.calls.length === 0) {
+ // throw Error('`HTMLElement.prototype.offsetParent` was never called');
+ }
+ offsetParentSpy.mockRestore();
+});
+
+describe('packages/select', () => {
+ test('renders label and description', () => {
+ const { getByText } = render();
+ expect(getByText(defaultProps.label)).toBeVisible();
+ expect(getByText(defaultProps.description)).toBeVisible();
+ });
+
+ test('renders placeholder', async () => {
+ const { getByRole, rerender } = render();
+
+ let combobox = getByRole('combobox');
+ expect(combobox).toBeVisible();
+ expect(getByTextFor(combobox, 'Select')).toBeVisible();
+
+ rerender();
+
+ combobox = getByRole('combobox');
+ expect(getByTextFor(combobox, 'Explicit placeholder')).toBeVisible();
+
+ userEvent.click(combobox);
+
+ const listbox = await waitFor(() => {
+ const listbox = getByRole('listbox');
+ expect(listbox).toBeVisible();
+ return listbox;
+ });
+
+ expect(getByTextFor(listbox, 'Explicit placeholder')).toBeVisible();
+ });
+
+ test('combobox button has selected value', () => {
+ const { getByRole, rerender } = render();
+
+ const combobox = getByRole('combobox') as HTMLButtonElement;
+ expect(combobox).toBeInstanceOf(HTMLButtonElement);
+
+ expect(combobox.name).toEqual(defaultProps.name);
+ expect(combobox.disabled).toEqual(false);
+ expect(combobox).toHaveValue('');
+
+ rerender();
+ expect(combobox.name).toEqual('explicit_name');
+
+ rerender();
+ expect(combobox.disabled).toEqual(true);
+ });
+
+ test('must render options in