Skip to content

Commit 674eea6

Browse files
committed
Render select popup in a portal
1 parent 62a52be commit 674eea6

2 files changed

Lines changed: 114 additions & 7 deletions

File tree

src/v2/components/forms/select/Select.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
22
import { Select } from './Select'
33
import './index'
44

5+
function getPortalRoot () {
6+
const portalHost = document.querySelector('[data-solid-ui-select-portal]') as HTMLDivElement | null
7+
return portalHost?.shadowRoot ?? null
8+
}
9+
510
describe('SolidUISelect', () => {
611
beforeEach(() => {
712
document.body.innerHTML = ''
@@ -67,8 +72,9 @@ describe('SolidUISelect', () => {
6772
trigger.click()
6873
await select.updateComplete
6974

70-
const listbox = select.shadowRoot?.querySelector('[role="listbox"]') as HTMLElement
71-
const options = select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>
75+
const portalRoot = getPortalRoot()
76+
const listbox = portalRoot?.querySelector('[role="listbox"]') as HTMLElement
77+
const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>
7278

7379
expect(listbox).not.toBeNull()
7480
expect(options).toHaveLength(2)
@@ -98,7 +104,7 @@ describe('SolidUISelect', () => {
98104
trigger.click()
99105
await select.updateComplete
100106

101-
const options = Array.from(select.shadowRoot?.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>)
107+
const options = Array.from(getPortalRoot()?.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>)
102108

103109
expect(options).toHaveLength(3)
104110
expect(options[0].textContent).toContain('French')
@@ -170,6 +176,7 @@ describe('SolidUISelect', () => {
170176
await select.updateComplete
171177

172178
expect(trigger.getAttribute('aria-expanded')).toBe('true')
179+
expect(getPortalRoot()).not.toBeNull()
173180

174181
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
175182
await select.updateComplete

src/v2/components/forms/select/Select.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { css, html, LitElement } from 'lit'
2+
import { render as renderPortal } from 'lit/html.js'
23
import { downArrowIcon } from '../shared/downArrow'
34
import { renderListbox } from '../shared/listboxTemplate'
45
import { SelectOption } from '../shared/optionTypes'
@@ -16,6 +17,9 @@ import {
1617
/* Prompt: can you wire up the keyboard interactions and aria attributes for Select */
1718
export class Select extends LitElement {
1819
private static _nextId = 0
20+
private _popupPortalHost: HTMLDivElement | null = null
21+
private _popupPortalRoot: ShadowRoot | null = null
22+
private _popupPortalContainer: Element | null = null
1923
private readonly _handleDocumentPointerDown = (event: Event) => {
2024
const eventTarget = event.target
2125

@@ -34,11 +38,23 @@ export class Select extends LitElement {
3438
return
3539
}
3640

41+
if (
42+
(this._popupPortalHost && eventPath.includes(this._popupPortalHost)) ||
43+
(this._popupPortalRoot && eventPath.includes(this._popupPortalRoot))
44+
) {
45+
return
46+
}
47+
3748
if (!this.contains(eventTarget)) {
3849
this._closePopup()
3950
}
4051
}
4152

53+
private readonly _handleViewportChange = () => {
54+
if (!this._popupOpen) return
55+
this._updatePopupPosition()
56+
}
57+
4258
static properties = {
4359
label: { type: String, reflect: true },
4460
theme: { type: String, reflect: true },
@@ -268,20 +284,105 @@ export class Select extends LitElement {
268284
connectedCallback () {
269285
super.connectedCallback()
270286
document.addEventListener('pointerdown', this._handleDocumentPointerDown)
287+
window.addEventListener('resize', this._handleViewportChange)
288+
window.addEventListener('scroll', this._handleViewportChange, true)
271289
}
272290

273291
disconnectedCallback () {
292+
this._detachPopupPortal()
274293
document.removeEventListener('pointerdown', this._handleDocumentPointerDown)
294+
window.removeEventListener('resize', this._handleViewportChange)
295+
window.removeEventListener('scroll', this._handleViewportChange, true)
275296
super.disconnectedCallback()
276297
}
277298

278299
protected updated () {
279300
this.toggleAttribute('popup-open', this._popupOpen)
301+
302+
if (this._popupOpen) {
303+
this._updatePopupPosition()
304+
if (this._popupPortalRoot) {
305+
renderPortal(this._renderPopup(), this._popupPortalRoot)
306+
}
307+
} else if (this._popupPortalRoot) {
308+
renderPortal(null, this._popupPortalRoot)
309+
}
310+
}
311+
312+
private _getPopupPortalContainer () {
313+
return this.closest('dialog[open]') || document.body
314+
}
315+
316+
private _ensurePopupPortal () {
317+
const nextContainer = this._getPopupPortalContainer()
318+
319+
if (
320+
this._popupPortalHost &&
321+
this._popupPortalRoot &&
322+
this._popupPortalContainer === nextContainer
323+
) {
324+
return
325+
}
326+
327+
this._detachPopupPortal()
328+
329+
this._popupPortalHost = document.createElement('div')
330+
this._popupPortalHost.setAttribute('data-solid-ui-select-portal', '')
331+
this._popupPortalHost.style.position = 'fixed'
332+
this._popupPortalHost.style.inset = '0 auto auto 0'
333+
this._popupPortalHost.style.zIndex = '2147483647'
334+
this._popupPortalHost.style.pointerEvents = 'none'
335+
this._popupPortalHost.style.boxSizing = 'border-box'
336+
337+
this._popupPortalRoot = this._popupPortalHost.attachShadow({ mode: 'open' })
338+
const styleSheets = (Array.isArray(Select.styles) ? Select.styles : [Select.styles])
339+
.map((style) => style?.styleSheet)
340+
.filter((styleSheet): styleSheet is CSSStyleSheet => Boolean(styleSheet))
341+
342+
if (styleSheets.length > 0) {
343+
this._popupPortalRoot.adoptedStyleSheets = styleSheets
344+
}
345+
346+
nextContainer.appendChild(this._popupPortalHost)
347+
this._popupPortalContainer = nextContainer
348+
}
349+
350+
private _detachPopupPortal () {
351+
if (this._popupPortalRoot) {
352+
renderPortal(null, this._popupPortalRoot)
353+
}
354+
355+
if (this._popupPortalHost?.parentNode) {
356+
this._popupPortalHost.parentNode.removeChild(this._popupPortalHost)
357+
}
358+
359+
this._popupPortalHost = null
360+
this._popupPortalRoot = null
361+
this._popupPortalContainer = null
362+
}
363+
364+
private _updatePopupPosition () {
365+
this._ensurePopupPortal()
366+
367+
const rect = this.getBoundingClientRect()
368+
const maxHeight = Math.min(288, Math.max(120, window.innerHeight - rect.bottom - 12))
369+
370+
if (this._popupPortalHost) {
371+
this._popupPortalHost.style.top = `${Math.round(rect.bottom + 2)}px`
372+
this._popupPortalHost.style.left = `${Math.round(rect.left)}px`
373+
this._popupPortalHost.style.width = `${Math.round(rect.width)}px`
374+
this._popupPortalHost.style.maxHeight = `${Math.round(maxHeight)}px`
375+
this._popupPortalHost.style.height = '0px'
376+
}
280377
}
281378

282379
private _closePopup () {
283380
this._popupOpen = false
284381
this._activeIndex = -1
382+
383+
if (this._popupPortalRoot) {
384+
renderPortal(null, this._popupPortalRoot)
385+
}
285386
}
286387

287388
private _getSelectedIndex () {
@@ -345,6 +446,7 @@ export class Select extends LitElement {
345446
const popupOptions = this._getDisplayedOptions()
346447

347448
this._popupOpen = true
449+
this._updatePopupPosition()
348450
this._activeIndex = findOptionIndexByValue(popupOptions, this.value)
349451

350452
if (this._activeIndex < 0) {
@@ -422,7 +524,7 @@ export class Select extends LitElement {
422524
const activeOption = this._getActiveOption()
423525

424526
return html`
425-
<div class="popup-box" part="popup-box">
527+
<div class="popup-box" part="popup-box" style="pointer-events: auto; max-height: inherit; overflow: auto;">
426528
<div class="select-options-section">
427529
${renderListbox({
428530
selectedOption,
@@ -475,9 +577,7 @@ export class Select extends LitElement {
475577
@click="${(e: MouseEvent) => {
476578
if (e.target === e.currentTarget) this._closePopup()
477579
}}"
478-
>
479-
${this._popupOpen ? this._renderPopup() : ''}
480-
</div>
580+
></div>
481581
`
482582
}
483583
}

0 commit comments

Comments
 (0)