Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Documentation/Dropdown/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ description: Single, filtered, and multiple selection with documented names, rol

When option objects contain `label` and `value`, those fields are used automatically. Use `optionLabel` and `optionValue` for another shape.

Choosing an option — with Enter on the keyboard-active option, or with a click — reports it through `onChange` once, closes the list, and shows that option. The Dropdown completes the selection itself, so a `value` that is not fed straight back, or that resolves to none of the options, never leaves the list hanging open over stale filter text. A `value` supplied afterwards still decides what is selected.

## Label the control

An external native label associates with the primary button, filter input, or multiple-select control through `htmlFor` and the Dropdown's `id`:
Expand Down
43 changes: 43 additions & 0 deletions Source/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,49 @@ export const StateMatrix: Story = {
),
};

// Deliberately hands the Dropdown no `value`: the keyboard has to finish its own commit - close the
// overlay and settle the filter text on the chosen option - without an application feeding the
// emitted value back in.
const UnboundFilteredDropdown = () => {
const [committed, setCommitted] = useState<string | null>(null);
return (
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '18rem' }}>
<Dropdown<string | null>
aria-label='Role'
options={roles}
optionLabel='label'
optionValue='value'
placeholder='Select a role'
filter
filterPlaceholder='Find a role'
onChange={setCommitted}
/>
<span>Committed: {committed ?? 'none'}</span>
</div>
);
};

export const FilteredKeyboardCommit: Story = {
render: () => <UnboundFilteredDropdown />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const filter = canvas.getByRole('combobox', { name: 'Role' });
await userEvent.click(filter);
await userEvent.keyboard('dev');
const listbox = await within(document.body).findByRole('listbox');
await expect(within(listbox).getAllByRole('option')).toHaveLength(1);
await userEvent.keyboard('{ArrowDown}');
await expect(filter.getAttribute('aria-activedescendant')).toBe(
within(listbox).getByRole('option').id,
);
await userEvent.keyboard('{Enter}');
await expect(canvas.getByText('Committed: developer')).toBeVisible();
await expect(filter).toHaveValue('Developer');
await expect(filter).toHaveAttribute('aria-expanded', 'false');
await expect(within(document.body).queryByRole('listbox')).toBeNull();
},
};

export const FilteredAndOpen: Story = {
render: () => <ControlledDropdown filter />,
play: async ({ canvasElement }) => {
Expand Down
78 changes: 61 additions & 17 deletions Source/Dropdown/DropdownImplementation.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { useCallback, useState } from 'react';
import type { JSX, Key } from 'react';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import type { JSX, Key, MouseEvent, RefObject } from 'react';
import {
Button as AriaButton,
ListBox,
Expand All @@ -14,6 +14,7 @@ import {
import {
Button as ComboBoxButton,
ComboBox,
ComboBoxStateContext,
ComboBoxValue,
Input,
ListBox as ComboBoxListBox,
Expand Down Expand Up @@ -91,6 +92,30 @@ const resolveOptions = (
const classNames = (...values: Array<string | undefined>) =>
values.filter(Boolean).join(' ');

/** The part of the ComboBox lifecycle a commit has to reach, from outside the ComboBox subtree. */
interface CommittableComboBox {
close: () => void;
}

/**
* Publishes the ComboBox state to the commit handler, which is declared outside the ComboBox and so
* cannot read the context itself. Renders nothing.
*/
const ComboBoxCommitBridge = ({
handle,
}: {
handle: RefObject<CommittableComboBox | null>;
}) => {
const state = useContext(ComboBoxStateContext);
useEffect(() => {
handle.current = state;
return () => {
handle.current = null;
};
});
return null;
};

const renderTriggerWithOpenState = (props: JSX.IntrinsicElements['button']) => (
<button
{...props}
Expand Down Expand Up @@ -134,6 +159,7 @@ export const DropdownImplementation = <T = unknown,>({
pt,
}: DropdownProps<T>) => {
const [isOpen, setIsOpen] = useState(false);
const comboBox = useRef<CommittableComboBox | null>(null);
const { ref: attachExternalLabel, labelledBy: externalLabelledBy } = useExternalLabel();
const overlayEnvironment = unstable_useOverlayEnvironment();
const nearestDialogZIndex = useNearestDialogZIndex();
Expand All @@ -153,9 +179,26 @@ export const DropdownImplementation = <T = unknown,>({
dropdownMessages?.clearSelection ??
'Clear selection';
const resolvedOptions = resolveOptions(options, optionLabel, optionValue);
const selectedOption = resolvedOptions.find((option) =>
Object.is(option.value, value),
);
// React Aria decides that a selection happened - and only then closes the overlay and syncs the
// filter text - by watching the key handed to it change. Deriving that key from `value` alone
// means a consumer that does not feed the emitted value straight back gets a committed selection
// the Dropdown never acts on: the popup stays open over an unchanged filter. Remembering what the
// user just committed keeps the commit whole on its own, while an incoming `value` still wins and
// still drops the memory the moment the consumer answers.
const [committed, setCommitted] = useState<{
key: string | null;
observedValue: unknown;
} | null>(null);
const remembered =
committed !== null && Object.is(committed.observedValue, value)
? committed
: null;
const remember = (key: string | null) => setCommitted({ key, observedValue: value });
const selectedOption =
resolvedOptions.find((option) => Object.is(option.value, value)) ??
(remembered?.key == null
? undefined
: resolvedOptions.find((option) => option.key === remembered.key));
const selectedKey = selectedOption?.key ?? null;
const controlPart = filter ? pt?.filter : multiple ? pt?.multiple : pt?.trigger;
const effectiveAriaLabel =
Expand Down Expand Up @@ -194,7 +237,17 @@ export const DropdownImplementation = <T = unknown,>({

const selectOption = (key: Key | null) => {
const option = resolvedOptions.find((candidate) => candidate.key === String(key));
remember(option?.key ?? null);
onChange?.((option?.value ?? null) as T, { source: 'user' });
// React Aria hands the closing of a filtered overlay to whoever owns the value, and stands
// down when that value does not come back as one of the options. Nothing about a committed
// option is the consumer's to close, so close it here and let the filter text follow the
// selection the Dropdown ends up showing.
comboBox.current?.close();
};
const clearSelection = (event: MouseEvent<HTMLButtonElement>) => {
remember(null);
onChange?.(null as T, { source: 'user', nativeEvent: event.nativeEvent });
};
const selectOptions = (keys: readonly Key[]) => {
const selectedKeys = new Set(keys.map(String));
Expand Down Expand Up @@ -494,6 +547,7 @@ export const DropdownImplementation = <T = unknown,>({
allowsEmptyCollection
className='cratis-dropdown__combobox'
>
<ComboBoxCommitBridge handle={comboBox} />
<Input
{...pt?.filter}
ref={attachExternalLabel}
Expand Down Expand Up @@ -539,12 +593,7 @@ export const DropdownImplementation = <T = unknown,>({
data-cratis-part='clear'
data-disabled={disabled || undefined}
aria-label={clearSelectionLabel}
onClick={(event) =>
onChange?.(null as T, {
source: 'user',
nativeEvent: event.nativeEvent,
})
}
onClick={clearSelection}
>
<span aria-hidden='true'>×</span>
</button>
Expand Down Expand Up @@ -686,12 +735,7 @@ export const DropdownImplementation = <T = unknown,>({
data-cratis-part='clear'
data-disabled={disabled || undefined}
aria-label={clearSelectionLabel}
onClick={(event) =>
onChange?.(null as T, {
source: 'user',
nativeEvent: event.nativeEvent,
})
}
onClick={clearSelection}
>
<span aria-hidden='true'>×</span>
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// @vitest-environment jsdom

import { expect } from 'chai';
import { afterEach, describe, it } from 'vitest';
import { unmountPrimitive } from '../../../Common/for_Primitives/given/a_primitive_dom';
import {
filterText,
focusDropdown,
isExpanded,
listbox,
mountDropdown,
options,
pressKey,
selectedState,
typeIntoFilter,
type DropdownBinding,
type MountedDropdown,
} from './given/a_dropdown';

// The keyboard owes the same finished commit whether or not the consuming application feeds the
// emitted value straight back, so every arrow-key case runs against both bindings.
const bindings: DropdownBinding[] = ['accepts', 'never answers'];

describe('when committing an option with the keyboard and a filter narrows the options', () => {
let mounted: MountedDropdown | undefined;

afterEach(async () => {
if (mounted) await unmountPrimitive(mounted);
mounted = undefined;
});

for (const binding of bindings) {
it(`should commit the option ArrowDown made active when the consumer ${binding} the value`, async () => {
mounted = await mountDropdown({ binding });
await focusDropdown(mounted);
await typeIntoFilter(mounted, 'front');

expect(options().map((option) => option.textContent)).to.deep.equal([
'Frontend developer',
]);

await pressKey('ArrowDown', mounted.control);
expect(mounted.control.getAttribute('aria-activedescendant')).to.equal(
options()[0].id,
);

await pressKey('Enter', mounted.control);

expect(mounted.changes).to.deep.equal(['frontend']);
expect(filterText(mounted)).to.equal('Frontend developer');
expect(isExpanded(mounted)).to.equal('false');
expect(listbox()).to.equal(null);
expect(selectedState(mounted)).to.equal('true');
});

it(`should commit the option ArrowUp made active when the consumer ${binding} the value`, async () => {
mounted = await mountDropdown({ binding });
await focusDropdown(mounted);
await typeIntoFilter(mounted, 'developer');

expect(options().map((option) => option.textContent)).to.deep.equal([
'Backend developer',
'Frontend developer',
]);

await pressKey('ArrowUp', mounted.control);
expect(mounted.control.getAttribute('aria-activedescendant')).to.equal(
options()[1].id,
);

await pressKey('Enter', mounted.control);

expect(mounted.changes).to.deep.equal(['frontend']);
expect(filterText(mounted)).to.equal('Frontend developer');
expect(isExpanded(mounted)).to.equal('false');
expect(listbox()).to.equal(null);
expect(selectedState(mounted)).to.equal('true');
});
}

it('should report the committed value exactly once', async () => {
mounted = await mountDropdown();
await focusDropdown(mounted);
await typeIntoFilter(mounted, 'design');
await pressKey('ArrowDown', mounted.control);
await pressKey('Enter', mounted.control);

expect(mounted.changes).to.deep.equal(['designer']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// @vitest-environment jsdom

import { expect } from 'chai';
import { afterEach, describe, it } from 'vitest';
import { unmountPrimitive } from '../../../Common/for_Primitives/given/a_primitive_dom';
import {
isExpanded,
listbox,
mountDropdown,
options,
pressKey,
selectedState,
type DropdownBinding,
type MountedDropdown,
} from './given/a_dropdown';

const bindings: DropdownBinding[] = ['accepts', 'never answers'];

describe('when committing an option with the keyboard and no filter is in play', () => {
let mounted: MountedDropdown | undefined;

afterEach(async () => {
if (mounted) await unmountPrimitive(mounted);
mounted = undefined;
});

for (const binding of bindings) {
it(`should open on ArrowDown and commit the active option with Enter when the consumer ${binding} the value`, async () => {
mounted = await mountDropdown({ filter: false, binding });
expect(mounted.control.textContent).to.contain('Select a role');

await pressKey('ArrowDown', mounted.control);
expect(options().map((option) => option.textContent)).to.deep.equal([
'Backend developer',
'Frontend developer',
'Designer',
]);

await pressKey('ArrowDown');
await pressKey('Enter');

expect(mounted.changes).to.deep.equal(['frontend']);
expect(mounted.control.textContent).to.contain('Frontend developer');
expect(isExpanded(mounted)).to.equal('false');
expect(listbox()).to.equal(null);
expect(selectedState(mounted)).to.equal('true');
});
}

it('should leave the selection alone when Escape dismisses the list', async () => {
mounted = await mountDropdown({ filter: false });
await pressKey('ArrowDown', mounted.control);
await pressKey('Escape');

expect(mounted.changes).to.deep.equal([]);
expect(mounted.control.textContent).to.contain('Select a role');
expect(isExpanded(mounted)).to.equal('false');
expect(listbox()).to.equal(null);
expect(selectedState(mounted)).to.equal(null);
});
});
Loading
Loading