From 6da5ecaa83a6d30b9adfa77193a87922f96b881b Mon Sep 17 00:00:00 2001 From: robonen Date: Mon, 10 Aug 2026 04:30:24 +0700 Subject: [PATCH] fix(primitives): place the select panel when nothing is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- vue/primitives/jsr.json | 2 +- vue/primitives/package.json | 2 +- .../selection/select/SelectContentImpl.vue | 5 +- .../select/SelectItemAlignedPosition.vue | 30 +++++++++- .../selection/select/__test__/Select.test.ts | 59 +++++++++++++++++++ 5 files changed, 93 insertions(+), 5 deletions(-) diff --git a/vue/primitives/jsr.json b/vue/primitives/jsr.json index 9a289c1..7ea071c 100644 --- a/vue/primitives/jsr.json +++ b/vue/primitives/jsr.json @@ -2,6 +2,6 @@ "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@robonen/primitives", "license": "Apache-2.0", - "version": "0.0.3", + "version": "0.0.4", "exports": "./src/index.ts" } diff --git a/vue/primitives/package.json b/vue/primitives/package.json index 6d85264..08b1fdc 100644 --- a/vue/primitives/package.json +++ b/vue/primitives/package.json @@ -1,6 +1,6 @@ { "name": "@robonen/primitives", - "version": "0.0.3", + "version": "0.0.4", "license": "Apache-2.0", "description": "Collection of UI primitives", "keywords": [ diff --git a/vue/primitives/src/selection/select/SelectContentImpl.vue b/vue/primitives/src/selection/select/SelectContentImpl.vue index b0f62b8..cab1463 100644 --- a/vue/primitives/src/selection/select/SelectContentImpl.vue +++ b/vue/primitives/src/selection/select/SelectContentImpl.vue @@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef; 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; +selectedItemTextRef.value = undefined; // Resolve the actual listbox content element. The item-aligned strategy renders // a positioning wrapper whose first child is the listbox; the popper strategy diff --git a/vue/primitives/src/selection/select/SelectItemAlignedPosition.vue b/vue/primitives/src/selection/select/SelectItemAlignedPosition.vue index cf573bb..9a42698 100644 --- a/vue/primitives/src/selection/select/SelectItemAlignedPosition.vue +++ b/vue/primitives/src/selection/select/SelectItemAlignedPosition.vue @@ -47,6 +47,16 @@ const shouldExpandOnScrollRef = ref(false); const shouldRepositionRef = ref(true); 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() { const trigger = rootCtx.triggerElement.value; const valueNode = rootCtx.valueElement.value; @@ -54,9 +64,25 @@ function position() { const content = contentElement.value; const viewport = contentCtx.viewportRef.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'); return; } diff --git a/vue/primitives/src/selection/select/__test__/Select.test.ts b/vue/primitives/src/selection/select/__test__/Select.test.ts index 2726d66..f443f14 100644 --- a/vue/primitives/src/selection/select/__test__/Select.test.ts +++ b/vue/primitives/src/selection/select/__test__/Select.test.ts @@ -456,3 +456,62 @@ describe('Select — attribute forwarding on the panel', () => { 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(); + }); +});