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(); + }); +});