Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Reveal the current value when the dropdown opens
A controlled dropdown opened at the top of its list regardless of the current
value. On open, mark the item whose data-ukt-value matches props.value
aria-selected, make it the active item so keyboard navigation starts there,
and scroll it into view — so a long list opens showing, and scrolled to, the
current selection.

setActiveItem's event becomes optional (it only builds the onActiveItem
payload, which is skipped when there's no event) so an item can be activated
programmatically on open. Adds a themeable --uktdd-body-bg-color-selected tint
for the persistent selection marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • Loading branch information
acusti and claude committed Jul 16, 2026
commit 126c785cb6eeb7d13d5ebd3e993cf5285ba57728
12 changes: 12 additions & 0 deletions .changeset/dropdown-reveal-value-on-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@acusti/dropdown': minor
---

Reveal the current value when the dropdown opens

A controlled dropdown now opens with the item whose `data-ukt-value`
matches `props.value` marked `aria-selected`, made the active item (so
keyboard navigation starts from it), and scrolled into view — so a long
list opens showing, and scrolled to, the current selection instead of the
top. The persistent selection tint is themeable via the new
`--uktdd-body-bg-color-selected` custom property.
Comment thread
acusti marked this conversation as resolved.
34 changes: 34 additions & 0 deletions packages/docs/stories/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,40 @@ export const LabelDifferentFromValue: Story = {
},
};

export const OpensToCurrentValue: Story = {
parameters: {
docs: {
description: {
story: 'A controlled dropdown opens with the item matching `props.value` marked `aria-selected`, made the active item (so keyboard navigation starts there), and scrolled into view. Here the value sits far down a long list, so opening jumps straight to it rather than the top. Theme the persistent selection tint with the `--uktdd-body-bg-color-selected` custom property.',
},
},
},
render() {
const items = Array.from({ length: 40 }, (_, index) => ({
label: `Item ${index + 1}`,
value: String(index + 1),
}));
const [value, setValue] = React.useState('31');
return (
<Dropdown
isSearchable
label="Item"
onSubmitItem={({ value: submitted }) => setValue(submitted)}
placeholder="Search items…"
value={value}
>
<ul>
{items.map(({ label, value: itemValue }) => (
<li data-ukt-value={itemValue} key={itemValue}>
{label}
</li>
))}
</ul>
</Dropdown>
);
},
};

export const CSSValueInputTrigger: Story = {
args: {
allowCreate: true,
Expand Down
7 changes: 7 additions & 0 deletions packages/dropdown/src/Dropdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
--uktdd-body-bg-color: #fff;
--uktdd-body-bg-color-hover: rgb(105, 162, 249);
--uktdd-body-color-hover: #fff;
/* the persistent tint on the item matching props.value (the current
selection), shown even after the active/hover highlight moves off it */
--uktdd-body-bg-color-selected: rgba(0, 0, 0, 0.06);
--uktdd-body-buffer: 10px;
--uktdd-body-max-height: calc(100dvh - var(--uktdd-body-buffer));
--uktdd-body-max-width: calc(100dvw - var(--uktdd-body-buffer));
Expand Down Expand Up @@ -94,6 +97,10 @@
user-select: none;
}

[aria-selected="true"] {
background-color: var(--uktdd-body-bg-color-selected);
}

[data-ukt-active] {
background-color: var(--uktdd-body-bg-color-hover);
color: var(--uktdd-body-color-hover);
Expand Down
46 changes: 46 additions & 0 deletions packages/dropdown/src/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,52 @@ describe('@acusti/dropdown', () => {
});
});

describe('revealing the current value on open', () => {
it('marks the item matching value active and aria-selected when opened', async () => {
const user = userEvent.setup();
render(
<Dropdown value="bold">
Voice
<ul>
<li data-testid="warm" data-ukt-value="warm">
Warm
</li>
<li data-testid="bold" data-ukt-value="bold">
Bold
</li>
</ul>
</Dropdown>,
);

await user.click(screen.getByRole('button', { name: 'Voice' }));

const bold = screen.getByTestId('bold');
expect(bold.hasAttribute('data-ukt-active')).toBe(true);
expect(bold.getAttribute('aria-selected')).toBe('true');
expect(screen.getByTestId('warm').hasAttribute('data-ukt-active')).toBe(
false,
);
});

it('activates no item when the value matches none', async () => {
const user = userEvent.setup();
const { container } = render(
<Dropdown value="unknown">
Voice
<ul>
<li data-ukt-value="warm">Warm</li>
<li data-ukt-value="bold">Bold</li>
</ul>
</Dropdown>,
);

await user.click(screen.getByRole('button', { name: 'Voice' }));

expect(container.querySelector('[data-ukt-active]')).toBeNull();
expect(container.querySelector('[aria-selected="true"]')).toBeNull();
});
});

describe('submitting with no active item', () => {
it('does not call onSubmitItem when a non-searchable dropdown is submitted with nothing selected', async () => {
const handleSubmitItem = vi.fn();
Expand Down
13 changes: 13 additions & 0 deletions packages/dropdown/src/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,19 @@ function RootDropdown({
// detection; the body is mounted only while open, so this runs once
// per open and won’t throw on an already-open body.
ref.showPopover();
// Reveal the current value on open: mark the item whose data-ukt-value
// matches it aria-selected, make it the active item (so keyboard
// navigation starts there), and scroll it into view — so a controlled
// dropdown opens showing (and at) its current selection.
if (valueIdentity != null && dropdownElement) {
const selectedItem = getItemElements(dropdownElement)?.find(
(item) => item.dataset.uktValue === valueIdentity,
);
Comment thread
acusti marked this conversation as resolved.
if (selectedItem) {
selectedItem.setAttribute('aria-selected', 'true');
setActiveItem({ dropdownElement, element: selectedItem });
}
Comment thread
acusti marked this conversation as resolved.
}
};

if (!isValidElement(trigger)) {
Expand Down
26 changes: 16 additions & 10 deletions packages/dropdown/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ const setActiveChain = (dropdownElement: HTMLElement, element: HTMLElement) => {
type BaseSetActiveItemPayload = {
dropdownElement: HTMLElement;
element?: null;
event: Event | SyntheticEvent<HTMLElement>;
// Optional so an item can be activated programmatically (e.g. revealing the
// current value on open); it’s only used to build the onActiveItem payload,
// which is skipped when there’s no event to report.
event?: Event | SyntheticEvent<HTMLElement>;
index?: null;
indexAddend?: null;
isExactMatch?: null;
Expand Down Expand Up @@ -406,15 +409,18 @@ export const setActiveItem = ({
}

setActiveChain(dropdownElement, nextActiveItem);
const label = getItemLabel(nextActiveItem);
const value = nextActiveItem.dataset.uktValue ?? label;
onActiveItem?.({
element: nextActiveItem,
event,
label,
path: getItemPath(nextActiveItem),
value,
});
// A programmatic activation (no event) still moves the highlight and scrolls
// the item into view, but has no event to report to onActiveItem.
if (event) {
const label = getItemLabel(nextActiveItem);
onActiveItem?.({
element: nextActiveItem,
event,
label,
path: getItemPath(nextActiveItem),
value: nextActiveItem.dataset.uktValue ?? label,
});
}
// Find closest scrollable parent and ensure that next active item is visible
let { parentElement } = nextActiveItem;
let scrollableParent = null;
Expand Down