diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx
index dc249967..194094ad 100644
--- a/src/components/input/SelectNext.tsx
+++ b/src/components/input/SelectNext.tsx
@@ -257,52 +257,57 @@ type MultiValueProps = {
onChange: (newValue: T[]) => void;
};
-export type SelectProps = CompositeProps &
- (SingleValueProps | MultiValueProps) & {
- buttonContent?: ComponentChildren;
- disabled?: boolean;
+type BaseSelectProps = CompositeProps & {
+ buttonContent?: ComponentChildren;
+ disabled?: boolean;
- /**
- * Whether this select should allow multi-selection or not.
- * When this is true, the listbox is kept open when an option is selected
- * and the value must be an array.
- * Defaults to false.
- */
- multiple?: boolean;
+ /**
+ * `id` attribute for the toggle button. This is useful to associate a label
+ * with the control.
+ */
+ buttonId?: string;
- /**
- * `id` attribute for the toggle button. This is useful to associate a label
- * with the control.
- */
- buttonId?: string;
+ /** Additional classes to pass to container */
+ containerClasses?: string | string[];
+ /** Additional classes to pass to toggle button */
+ buttonClasses?: string | string[];
+ /** Additional classes to pass to listbox */
+ listboxClasses?: string | string[];
- /** Additional classes to pass to container */
- containerClasses?: string | string[];
- /** Additional classes to pass to toggle button */
- buttonClasses?: string | string[];
- /** Additional classes to pass to listbox */
- listboxClasses?: string | string[];
+ /**
+ * Align the listbox to the right.
+ * Useful when the listbox is bigger than the toggle button and this component
+ * is rendered next to the right side of the page/container.
+ * Defaults to false.
+ */
+ right?: boolean;
- /**
- * Align the listbox to the right.
- * Useful when the listbox is bigger than the toggle button and this component
- * is rendered next to the right side of the page/container.
- * Defaults to false.
- */
- right?: boolean;
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
- 'aria-label'?: string;
- 'aria-labelledby'?: string;
+ /**
+ * Used to determine if the listbox should use the popover API.
+ * Defaults to true, as long as the browser supports it.
+ */
+ listboxAsPopover?: boolean;
- /**
- * Used to determine if the listbox should use the popover API.
- * Defaults to true, as long as the browser supports it.
- */
- listboxAsPopover?: boolean;
+ /** A callback passed to the listbox onScroll */
+ onListboxScroll?: JSX.HTMLAttributes['onScroll'];
+};
+
+export type SelectProps = BaseSelectProps & SingleValueProps;
- /** A callback passed to the listbox onScroll */
- onListboxScroll?: JSX.HTMLAttributes['onScroll'];
- };
+export type MultiSelectProps = BaseSelectProps & MultiValueProps;
+
+export type SelectNextProps = (SelectProps | MultiSelectProps) & {
+ /**
+ * Whether this select should allow multi-selection or not.
+ * When this is true, the listbox is kept open when an option is selected
+ * and the value must be an array.
+ * Defaults to false.
+ */
+ multiple?: boolean;
+};
function SelectMain({
buttonContent,
@@ -322,7 +327,7 @@ function SelectMain({
'aria-labelledby': ariaLabelledBy,
/* eslint-disable-next-line no-prototype-builtins */
listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'),
-}: SelectProps) {
+}: SelectNextProps) {
if (multiple && !Array.isArray(value)) {
throw new Error('When `multiple` is true, the value must be an array');
}
@@ -474,8 +479,27 @@ function SelectMain({
);
}
-SelectMain.displayName = 'SelectNext';
-
-const SelectNext = Object.assign(SelectMain, { Option: SelectOption });
-
-export default SelectNext;
+export const SelectNext = Object.assign(SelectMain, {
+ Option: SelectOption,
+ displayName: 'SelectNext',
+});
+
+export const Select = Object.assign(
+ function (props: SelectProps) {
+ // Calling the function directly instead of returning a JSX element, to
+ // avoid an unnecessary extra layer in the component tree
+ // eslint-disable-next-line new-cap
+ return SelectNext({ ...props, multiple: false });
+ },
+ { Option: SelectOption, displayName: 'Select' },
+);
+
+export const MultiSelect = Object.assign(
+ function (props: MultiSelectProps) {
+ // Calling the function directly instead of returning a JSX element, to
+ // avoid an unnecessary extra layer in the component tree
+ // eslint-disable-next-line new-cap
+ return SelectNext({ ...props, multiple: true });
+ },
+ { Option: SelectOption, displayName: 'MultiSelect' },
+);
diff --git a/src/components/input/index.ts b/src/components/input/index.ts
index fa62c4b5..9cd2e787 100644
--- a/src/components/input/index.ts
+++ b/src/components/input/index.ts
@@ -5,7 +5,7 @@ export { default as IconButton } from './IconButton';
export { default as Input } from './Input';
export { default as InputGroup } from './InputGroup';
export { default as OptionButton } from './OptionButton';
-export { default as SelectNext } from './SelectNext';
+export { SelectNext, Select, MultiSelect } from './SelectNext';
export { default as Textarea } from './Textarea';
export type { ButtonProps } from './Button';
@@ -15,5 +15,9 @@ export type { IconButtonProps } from './IconButton';
export type { InputProps } from './Input';
export type { InputGroupProps } from './InputGroup';
export type { OptionButtonProps } from './OptionButton';
-export type { SelectProps as SelectNextProps } from './SelectNext';
+export type {
+ MultiSelectProps,
+ SelectNextProps,
+ SelectProps,
+} from './SelectNext';
export type { TextareaProps } from './Textarea';
diff --git a/src/components/input/test/SelectNext-test.js b/src/components/input/test/SelectNext-test.js
index 0ce81666..772c3ab2 100644
--- a/src/components/input/test/SelectNext-test.js
+++ b/src/components/input/test/SelectNext-test.js
@@ -1,7 +1,7 @@
import { checkAccessibility, waitFor } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
-import SelectNext from '../SelectNext';
+import { MultiSelect, Select, SelectNext } from '../SelectNext';
describe('SelectNext', () => {
let wrappers;
@@ -21,20 +21,26 @@ describe('SelectNext', () => {
* Whether to renders SelectNext.Option children with callback notation.
* Used primarily to test and cover both branches.
* Defaults to true.
+ * @param {MultiSelect | Select | SelectNext} [options.Component] -
+ * The actual "select" component to use. Defaults to `SelectNext`.
*/
const createComponent = (props = {}, options = {}) => {
- const { paddingTop = 0, optionsChildrenAsCallback = true } = options;
+ const {
+ paddingTop = 0,
+ optionsChildrenAsCallback = true,
+ Component = SelectNext,
+ } = options;
const container = document.createElement('div');
container.style.paddingTop = `${paddingTop}px`;
document.body.append(container);
const wrapper = mount(
-
-
+
+ Reset
-
+
{items.map(item => (
- {
)
)}
-
+
))}
- ,
+ ,
{ attachTo: container },
);
@@ -93,18 +99,29 @@ describe('SelectNext', () => {
const clickOption = (wrapper, id) =>
wrapper.find(`[data-testid="option-${id}"]`).simulate('click');
- it('changes selected value when an option is clicked', () => {
- const onChange = sinon.stub();
- const wrapper = createComponent({ onChange });
+ [
+ {
+ title: 'changes selected value when an option is clicked',
+ },
+ {
+ title:
+ 'changes selected value when an option is clicked, via Select alias',
+ options: { Component: Select },
+ },
+ ].forEach(({ title, options = {} }) => {
+ it(title, () => {
+ const onChange = sinon.stub();
+ const wrapper = createComponent({ onChange }, options);
- clickOption(wrapper, 3);
- assert.calledWith(onChange.lastCall, items[2]);
+ clickOption(wrapper, 3);
+ assert.calledWith(onChange.lastCall, items[2]);
- clickOption(wrapper, 5);
- assert.calledWith(onChange.lastCall, items[4]);
+ clickOption(wrapper, 5);
+ assert.calledWith(onChange.lastCall, items[4]);
- clickOption(wrapper, 1);
- assert.calledWith(onChange.lastCall, items[0]);
+ clickOption(wrapper, 1);
+ assert.calledWith(onChange.lastCall, items[0]);
+ });
});
it('does not change selected value when a disabled option is clicked', () => {
@@ -409,19 +426,34 @@ describe('SelectNext', () => {
assert.isFalse(isListboxClosed(wrapper));
});
- it('allows multiple items to be selected when the value is an array', () => {
- const onChange = sinon.stub();
- const wrapper = createComponent({
- multiple: true,
- value: [items[0], items[2]],
- onChange,
- });
+ [
+ {
+ title:
+ 'allows multiple items to be selected when the value is an array',
+ extraProps: { multiple: true },
+ },
+ {
+ title: 'allows same behavior via MultiSelect alias',
+ options: { Component: MultiSelect },
+ },
+ ].forEach(({ title, extraProps = {}, options = {} }) => {
+ it(title, () => {
+ const onChange = sinon.stub();
+ const wrapper = createComponent(
+ {
+ value: [items[0], items[2]],
+ onChange,
+ ...extraProps,
+ },
+ options,
+ );
- toggleListbox(wrapper);
- clickOption(wrapper, 2);
+ toggleListbox(wrapper);
+ clickOption(wrapper, 2);
- // When a not-yet-selected item is clicked, it will be selected
- assert.calledWith(onChange, [items[0], items[2], items[1]]);
+ // When a not-yet-selected item is clicked, it will be selected
+ assert.calledWith(onChange, [items[0], items[2], items[1]]);
+ });
});
it('allows deselecting already selected options', () => {
diff --git a/src/index.ts b/src/index.ts
index ea1dd10d..889a00b8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -48,7 +48,9 @@ export {
IconButton,
Input,
InputGroup,
+ MultiSelect,
OptionButton,
+ Select,
SelectNext,
Textarea,
} from './components/input';
@@ -115,7 +117,9 @@ export type {
IconButtonProps,
InputProps,
InputGroupProps,
+ MultiSelectProps,
OptionButtonProps,
+ SelectProps,
SelectNextProps,
TextareaProps,
} from './components/input';
diff --git a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
index 773e1887..3950570c 100644
--- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
+++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
@@ -3,7 +3,7 @@ import { useId, useState } from 'preact/hooks';
import { Link } from '../../../..';
import type { SelectNextProps } from '../../../../components/input';
-import SelectNext from '../../../../components/input/SelectNext';
+import { SelectNext } from '../../../../components/input/SelectNext';
import SelectNextInInputGroup from '../../../examples/select-next-in-input-group';
import SelectNextWithManyOptions from '../../../examples/select-next-with-custom-options';
import Library from '../../Library';
@@ -99,14 +99,15 @@ export default function SelectNextPage() {
title="SelectNext"
intro={
- SelectNext is a composite component which behaves like
+ SelectNext (and its aliases Select and{' '}
+ MultiSelect) are composite components which behave like
the native {' element.
}
>
-
+ onChange will receive an array as
an argument.
+
+ This prop cannot be provided to the Select and{' '}
+ MultiSelect aliases, where it is implicitly{' '}
+ false and true respectively.
+