fix(primitives): place the select panel when nothing is selected
Publish to NPM / Check version changes and publish (push) Has been cancelled
Publish to NPM / Check version changes and publish (push) Has been cancelled
Item-aligned placement centres the panel on the selected item, and `position()`
returned early unless both the item and its text node were known. The content
already adopts the first valid item as the anchor when the model matches no
option, but only the item is registered — the text node registers solely for the
selected value, so the pair was never complete and the early return fired. The
wrapper is `position: fixed`, so it stayed at the viewport origin: to a user the
dropdown simply does not open.
This is not an edge case. A stale id, a deleted record or a directory that has
not finished loading all leave the model unmatched, and the whole select then
looks broken rather than merely unlabelled.
Recover the text node from the item's own `aria-labelledby` instead of adding a
second registration — writing the anchor refs from inside the item's tracking
effect closes a reactive cycle ("Maximum recursive updates exceeded"). Reset the
text ref alongside the item ref per open cycle so a stale node cannot pair with
a fresh item. Finally, keep the guard from ever stranding the panel again: with
no anchors at all — an empty option list — fall back to a plain trigger-aligned
drop instead of returning with the wrapper unplaced.
Both paths are covered by browser tests that fail on the previous code.
This commit is contained in:
@@ -2,6 +2,6 @@
|
|||||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||||
"name": "@robonen/primitives",
|
"name": "@robonen/primitives",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"exports": "./src/index.ts"
|
"exports": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/primitives",
|
"name": "@robonen/primitives",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Collection of UI primitives",
|
"description": "Collection of UI primitives",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
|
|||||||
|
|
||||||
const firstValidItemFoundRef = ref(false);
|
const firstValidItemFoundRef = ref(false);
|
||||||
|
|
||||||
// Recompute the selected/first-valid item afresh for this open cycle.
|
// Recompute the selected/first-valid item afresh for this open cycle. The text
|
||||||
|
// node is reset alongside it: the item-aligned positioner reads the two as a
|
||||||
|
// pair, so a stale text node would pair with a fresh item and skew placement.
|
||||||
selectedItemRef.value = undefined;
|
selectedItemRef.value = undefined;
|
||||||
|
selectedItemTextRef.value = undefined;
|
||||||
|
|
||||||
// Resolve the actual listbox content element. The item-aligned strategy renders
|
// Resolve the actual listbox content element. The item-aligned strategy renders
|
||||||
// a positioning wrapper whose first child is the listbox; the popper strategy
|
// a positioning wrapper whose first child is the listbox; the popper strategy
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ const shouldExpandOnScrollRef = ref(false);
|
|||||||
const shouldRepositionRef = ref(true);
|
const shouldRepositionRef = ref(true);
|
||||||
const contentZIndex = ref('');
|
const contentZIndex = ref('');
|
||||||
|
|
||||||
|
// When nothing is selected the content adopts the first valid item as the
|
||||||
|
// alignment anchor, but only that item is registered — its text node registers
|
||||||
|
// solely for the *selected* value. Recover it from the item's own label
|
||||||
|
// association instead of demanding a second registration, which would mean
|
||||||
|
// writing to the anchor refs from inside the item's own tracking effect.
|
||||||
|
function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
|
||||||
|
const id = item?.getAttribute('aria-labelledby');
|
||||||
|
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function position() {
|
function position() {
|
||||||
const trigger = rootCtx.triggerElement.value;
|
const trigger = rootCtx.triggerElement.value;
|
||||||
const valueNode = rootCtx.valueElement.value;
|
const valueNode = rootCtx.valueElement.value;
|
||||||
@@ -54,9 +64,25 @@ function position() {
|
|||||||
const content = contentElement.value;
|
const content = contentElement.value;
|
||||||
const viewport = contentCtx.viewportRef.value;
|
const viewport = contentCtx.viewportRef.value;
|
||||||
const selectedItem = contentCtx.selectedItemRef.value;
|
const selectedItem = contentCtx.selectedItemRef.value;
|
||||||
const selectedItemText = contentCtx.selectedItemTextRef.value;
|
const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
|
||||||
|
|
||||||
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
|
if (!trigger || !wrapper || !content || !viewport) {
|
||||||
|
emit('placed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item-aligned placement centres the panel on the selected item, so without
|
||||||
|
// one there is nothing to align to — an empty option list, or items that have
|
||||||
|
// not registered yet. Drop the panel under the trigger instead of returning:
|
||||||
|
// the wrapper is `position: fixed`, so leaving it unplaced pins it to the
|
||||||
|
// viewport origin, where it reads as "the dropdown does not open".
|
||||||
|
if (!valueNode || !selectedItem || !selectedItemText) {
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
||||||
|
wrapper.style.minWidth = `${rect.width}px`;
|
||||||
|
wrapper.style.left = `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`;
|
||||||
|
wrapper.style.top = `${rect.bottom}px`;
|
||||||
|
wrapper.style.maxHeight = `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`;
|
||||||
emit('placed');
|
emit('placed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,3 +456,62 @@ describe('Select — attribute forwarding on the panel', () => {
|
|||||||
w.unmount();
|
w.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Select — panel placement without a selection', () => {
|
||||||
|
function mountUnmatched(options: Opt[]) {
|
||||||
|
return track(mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
// A model value that matches no option — a stale id, a deleted user,
|
||||||
|
// a directory that has not loaded yet.
|
||||||
|
return () => h(
|
||||||
|
SelectRoot,
|
||||||
|
{ defaultOpen: true, modelValue: 'gone' as never },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(SelectTrigger, null, { default: () => h(SelectValue, { placeholder: 'Pick one' }) }),
|
||||||
|
h(SelectPortal, null, {
|
||||||
|
default: () => h(SelectContent, null, {
|
||||||
|
default: () => h(SelectViewport, null, {
|
||||||
|
default: () => options.map(opt =>
|
||||||
|
h(SelectItem, { key: String(opt.value), value: opt.value as never }, {
|
||||||
|
default: () => h(SelectItemText, null, { default: () => opt.label }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ attachTo: document.body },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('aligns on the first valid item when the model matches nothing', async () => {
|
||||||
|
const w = mountUnmatched([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]);
|
||||||
|
await flush();
|
||||||
|
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
// Item-aligned placement sets all three; bailing out leaves them empty and
|
||||||
|
// the fixed wrapper pinned to the viewport origin.
|
||||||
|
expect(wrapper!.style.minWidth).not.toBe('');
|
||||||
|
expect(wrapper!.style.height).not.toBe('');
|
||||||
|
expect(wrapper!.style.left || wrapper!.style.right).not.toBe('');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the panel instead of leaving it pinned to the viewport origin', async () => {
|
||||||
|
const w = mountUnmatched([]);
|
||||||
|
await flush();
|
||||||
|
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
// With no items at all there is nothing to align to; the fallback still has
|
||||||
|
// to give the wrapper explicit coordinates.
|
||||||
|
expect(wrapper!.style.top).not.toBe('');
|
||||||
|
expect(wrapper!.style.left).not.toBe('');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user