Skip to content

Commit 403557b

Browse files
authored
Fix Rollup failed to resolve import $app/environment (#308)
* replace `browser` from @app/env import with `typeof window` check in portal() action to support non-sveltekit apps - add `PortalParams` type for SSOT * more playwright tests for active+inactive portal * remove unused link-check-config.json since lychee migration * pnpm remove svelte-multiselect
1 parent e81ae54 commit 403557b

File tree

4 files changed

+154
-55
lines changed

4 files changed

+154
-55
lines changed

.github/workflows/link-check-config.json

Lines changed: 0 additions & 3 deletions
This file was deleted.

package.json

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@
3030
"@playwright/test": "^1.52.0",
3131
"@stylistic/eslint-plugin": "^4.2.0",
3232
"@sveltejs/adapter-static": "^3.0.8",
33-
"@sveltejs/kit": "^2.21.0",
33+
"@sveltejs/kit": "^2.21.1",
3434
"@sveltejs/package": "2.3.11",
3535
"@sveltejs/vite-plugin-svelte": "^5.0.3",
36-
"@types/node": "^22.15.18",
37-
"@vitest/coverage-v8": "^3.1.3",
38-
"eslint": "^9.26.0",
39-
"eslint-plugin-svelte": "^3.6.0",
36+
"@types/node": "^22.15.20",
37+
"@vitest/coverage-v8": "^3.1.4",
38+
"eslint": "^9.27.0",
39+
"eslint-plugin-svelte": "^3.8.2",
4040
"globals": "^16.1.0",
4141
"hastscript": "^9.0.1",
4242
"highlight.js": "^11.11.1",
@@ -47,17 +47,16 @@
4747
"prettier-plugin-svelte": "^3.4.0",
4848
"rehype-autolink-headings": "^7.1.0",
4949
"rehype-slug": "^6.0.0",
50-
"svelte": "^5.30.1",
50+
"svelte": "^5.32.1",
5151
"svelte-check": "^4.2.1",
52-
"svelte-multiselect": "11.0.0-rc.1",
5352
"svelte-preprocess": "^6.0.3",
54-
"svelte-toc": "^0.6.0",
53+
"svelte-toc": "^0.6.1",
5554
"svelte-zoo": "^0.4.18",
5655
"svelte2tsx": "^0.7.39",
5756
"typescript": "5.8.3",
5857
"typescript-eslint": "^8.32.1",
5958
"vite": "^6.3.5",
60-
"vitest": "^3.1.3"
59+
"vitest": "^3.1.4"
6160
},
6261
"keywords": [
6362
"svelte",

src/lib/MultiSelect.svelte

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import { browser } from '$app/environment'
32
import { tick } from 'svelte'
43
import { flip } from 'svelte/animate'
54
import type { FocusEventHandler, KeyboardEventHandler } from 'svelte/elements'
@@ -489,13 +488,11 @@
489488
form_input?.setCustomValidity(``)
490489
})
491490
492-
function portal(
493-
node: HTMLElement,
494-
params: { target_node: HTMLElement | null; active?: boolean },
495-
) {
491+
type PortalParams = { target_node: HTMLElement | null; active?: boolean }
492+
function portal(node: HTMLElement, params: PortalParams) {
496493
let { target_node, active } = params
497494
if (!active) return
498-
let render_in_place = !browser || !document.body.contains(node)
495+
let render_in_place = typeof window === `undefined` || !document.body.contains(node)
499496
500497
if (!render_in_place) {
501498
document.body.appendChild(node)
@@ -521,9 +518,9 @@
521518
})
522519
523520
return {
524-
update(params: { target_node: HTMLElement | null }) {
521+
update(params: PortalParams) {
525522
target_node = params.target_node
526-
render_in_place = !browser || !document.body.contains(node)
523+
render_in_place = typeof window === `undefined` || !document.body.contains(node)
527524
if (open && !render_in_place && target_node) tick().then(update_position)
528525
else if (!open || !target_node) node.hidden = true
529526
},

tests/MultiSelect.test.ts

Lines changed: 141 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { expect, test } from '@playwright/test'
2-
import { foods } from '../src/site/options'
2+
import {
3+
colors as demo_colors,
4+
languages as demo_languages,
5+
foods,
6+
} from '../src/site/options'
37

48
// to run tests in this file, use `npm run test:e2e`
59

@@ -618,65 +622,167 @@ test(`dragging selected options across each other changes their order`, async ({
618622
})
619623

620624
test.describe(`portal feature`, () => {
621-
test(`dropdown renders in document.body when portal is active in modal`, async ({
625+
test(`foods dropdown in modal 1 renders in body when portal is active`, async ({
622626
page,
623627
}) => {
624628
await page.goto(`/modal`, { waitUntil: `networkidle` })
625629

626-
// Open the first modal
627630
await page
628631
.getByRole(`button`, { name: `Open Modal 1 (Vertical Selects)` })
629632
.click()
630633

631-
// Locate the MultiSelect input for foods within the first modal
632-
const foods_multiselect_input = page.locator(
633-
`div.modal-content.modal-1 div.multiselect input[placeholder='Choose foods...']`,
634+
const modal_1_content = page.locator(`div.modal-content.modal-1`)
635+
const foods_input = modal_1_content.locator(
636+
`div.multiselect input[placeholder='Choose foods...']`,
634637
)
635-
// Wait for the modal to be fully visible and input to be available
636-
await foods_multiselect_input.waitFor({ state: `visible` })
638+
await foods_input.click() // Open dropdown
637639

638-
// Click the input to open the dropdown
639-
await foods_multiselect_input.click()
640+
// Options list should be portalled to body and visible
641+
const portalled_foods_options = page.locator(
642+
`body > ul.options[aria-expanded="true"]:has(li:has-text("${foods[0]}"))`,
643+
)
644+
await expect(portalled_foods_options).toBeVisible()
640645

641-
// Identify the specific portalled options list for foods
642-
// It should be in the body and contain the first food item
643-
const foods_options_list_in_body = page.locator(
644-
`body > ul.options:has(li:has-text("${foods[0]}"))`,
646+
// Options list should not be a direct child of the multiselect wrapper in the modal
647+
const foods_multiselect_wrapper = modal_1_content.locator(
648+
`div.multiselect:has(input[placeholder='Choose foods...'])`,
645649
)
646-
await foods_options_list_in_body.waitFor({ state: `visible` })
647-
await expect(foods_options_list_in_body).toHaveAttribute(
650+
await expect(
651+
foods_multiselect_wrapper.locator(`> ul.options`),
652+
).not.toBeAttached()
653+
654+
// Select an option
655+
await portalled_foods_options.locator(`li:has-text("${foods[0]}")`).click()
656+
await expect(portalled_foods_options).toBeHidden() // Dropdown should close
657+
658+
await expect(
659+
modal_1_content.getByRole(`button`, { name: `Remove ${foods[0]}` }),
660+
).toBeVisible()
661+
662+
await page.keyboard.press(`Escape`) // Close any remaining popups/dropdowns
663+
await page.getByRole(`button`, { name: `Close Modal 1` }).click()
664+
await expect(modal_1_content).toBeHidden()
665+
})
666+
667+
test(`dropdown renders within component when portal is inactive (/ui page)`, async ({
668+
page,
669+
}) => {
670+
await page.goto(`/ui`, { waitUntil: `networkidle` })
671+
672+
const foods_multiselect = page.locator(`#foods`)
673+
await foods_multiselect.locator(`input[autocomplete]`).click() // Open dropdown
674+
675+
// Options list should be a child of the multiselect wrapper and visible
676+
const foods_options_in_component = foods_multiselect.locator(`ul.options`)
677+
await expect(foods_options_in_component).toBeVisible()
678+
await expect(foods_options_in_component).toHaveAttribute(
648679
`aria-expanded`,
649680
`true`,
650-
) // Confirm it's open
681+
)
682+
683+
// Options list should NOT be portalled to the body
684+
const portalled_foods_options = page.locator(
685+
// More specific selector to avoid accidental matches if body > ul.options exists for other reasons
686+
`body > ul.options[aria-expanded="true"]:has(li:has-text("${foods[0]}"))`,
687+
)
688+
await expect(portalled_foods_options).not.toBeAttached()
689+
})
690+
691+
test(`colors dropdown in modal 1 renders in body when portal is active`, async ({
692+
page,
693+
}) => {
694+
await page.goto(`/modal`, { waitUntil: `networkidle` })
695+
696+
await page
697+
.getByRole(`button`, { name: `Open Modal 1 (Vertical Selects)` })
698+
.click()
699+
700+
const modal_1_content = page.locator(`div.modal-content.modal-1`)
701+
const colors_input = modal_1_content.locator(
702+
`div.multiselect input[placeholder='Choose colors...']`,
703+
)
704+
await colors_input.click() // Open dropdown
651705

652-
// Assert that the options list is NOT a direct child of the multiselect wrapper within the modal
653-
const multiselect_wrapper_in_modal = page.locator(
654-
`div.modal-content.modal-1 div.multiselect:has(input[placeholder='Choose foods...'])`,
706+
// Options list should be portalled to body and visible
707+
const portalled_colors_options = page.locator(
708+
`body > ul.options[aria-expanded="true"]:has(li:has-text("${demo_colors[0]}"))`,
709+
)
710+
await expect(portalled_colors_options).toBeVisible()
711+
712+
// Options list should not be a direct child of the multiselect wrapper in the modal
713+
const colors_multiselect_wrapper = modal_1_content.locator(
714+
`div.multiselect:has(input[placeholder='Choose colors...'])`,
655715
)
656716
await expect(
657-
multiselect_wrapper_in_modal.locator(`> ul.options`),
717+
colors_multiselect_wrapper.locator(`> ul.options`),
658718
).not.toBeAttached()
659719

660-
// Select an option from this specific portalled dropdown
661-
await foods_options_list_in_body
662-
.locator(`li:has-text("${foods[0]}")`)
720+
// Select an option
721+
await portalled_colors_options
722+
.locator(`li:has-text("${demo_colors[0]}")`)
663723
.click()
724+
await expect(portalled_colors_options).toBeHidden() // Dropdown should close
725+
726+
await expect(
727+
modal_1_content.getByRole(`button`, { name: `Remove ${demo_colors[0]}` }),
728+
).toBeVisible()
664729

665-
// Assert this specific dropdown is now hidden
666-
await expect(foods_options_list_in_body).toBeHidden({ timeout: 3000 })
730+
await page.keyboard.press(`Escape`) // Close any remaining popups/dropdowns
731+
await page.getByRole(`button`, { name: `Close Modal 1` }).click()
732+
await expect(modal_1_content).toBeHidden()
733+
})
667734

668-
// Explicitly wait for the selected item to appear before asserting visibility
735+
test(`languages dropdown in modal 2 renders in body when portal is active`, async ({
736+
page,
737+
}) => {
738+
await page.goto(`/modal`, { waitUntil: `networkidle` })
739+
740+
await page
741+
.getByRole(`button`, { name: `Open Modal 2 (Horizontal Selects)` })
742+
.click()
743+
744+
const modal_2_content = page.locator(`div.modal-content.modal-2`)
745+
const languages_input = modal_2_content.locator(
746+
`div.multiselect input[placeholder='Choose languages...']`,
747+
)
748+
await languages_input.click() // Open dropdown
749+
750+
// Options list should be portalled to body and visible
751+
const portalled_languages_options = page.locator(
752+
`body > ul.options[aria-expanded="true"]:has(li:has-text("${demo_languages[0]}"))`,
753+
)
754+
await expect(portalled_languages_options).toBeVisible()
755+
756+
// Options list should not be a direct child of the multiselect wrapper in the modal
757+
const languages_multiselect_wrapper = modal_2_content.locator(
758+
`div.multiselect:has(input[placeholder='Choose languages...'])`,
759+
)
760+
await expect(
761+
languages_multiselect_wrapper.locator(`> ul.options`),
762+
).not.toBeAttached()
763+
764+
// Select an option, ensuring exact match
765+
await portalled_languages_options
766+
.getByRole(`option`, { name: demo_languages[0], exact: true })
767+
.click()
768+
// Dropdown should remain visible on desktop by default
769+
await expect(portalled_languages_options).toBeVisible()
770+
// And the selected option should no longer be in the options list (if duplicates=false)
669771
await expect(
670-
page.getByRole(`button`, {
671-
name: `Remove 🍇 Grapes`,
772+
portalled_languages_options.getByRole(`option`, {
773+
name: demo_languages[0],
774+
exact: true,
672775
}),
673-
).toBeVisible()
776+
).not.toBeAttached()
674777

675-
// click escape to close the dropdown
676-
await page.keyboard.press(`Escape`)
778+
await expect(
779+
modal_2_content.getByRole(`button`, {
780+
name: `Remove ${demo_languages[0]}`,
781+
}),
782+
).toBeVisible()
677783

678-
// Close the modal
679-
await page.getByRole(`button`, { name: `Close Modal 1` }).click()
680-
await expect(page.locator(`div.modal-content.modal-1`)).toBeHidden()
784+
await page.keyboard.press(`Escape`) // Close any remaining popups/dropdowns
785+
await page.getByRole(`button`, { name: `Close Modal 2` }).click()
786+
await expect(modal_2_content).toBeHidden()
681787
})
682788
})

0 commit comments

Comments
 (0)