From 4bc43fb0d8013c9927fc60cb16157435d6035bea Mon Sep 17 00:00:00 2001 From: Giamir Buoncristiani Date: Mon, 13 Oct 2025 17:02:14 +0200 Subject: [PATCH 1/3] chore(select): update to svelte 5 runes apis --- .../components/Select/Select.stories.svelte | 8 +- .../src/components/Select/Select.svelte | 218 +++++++++++------- .../src/components/Select/Select.test.ts | 94 ++++---- .../src/components/Select/SelectItem.svelte | 39 ++-- 4 files changed, 196 insertions(+), 163 deletions(-) diff --git a/packages/stacks-svelte/src/components/Select/Select.stories.svelte b/packages/stacks-svelte/src/components/Select/Select.stories.svelte index 5ee9f29a75..fcfdcc2480 100644 --- a/packages/stacks-svelte/src/components/Select/Select.stories.svelte +++ b/packages/stacks-svelte/src/components/Select/Select.stories.svelte @@ -105,17 +105,17 @@ id="select-with-description-and-message" label="With Description and Message" > - + {#snippet description()} Select the sorting order - + {/snippet} - + {#snippet message()} The available sorting orders are Relevance, Newest, Active, and Score - + {/snippet} diff --git a/packages/stacks-svelte/src/components/Select/Select.svelte b/packages/stacks-svelte/src/components/Select/Select.svelte index da0f1594ea..03bdf78ea2 100644 --- a/packages/stacks-svelte/src/components/Select/Select.svelte +++ b/packages/stacks-svelte/src/components/Select/Select.svelte @@ -10,8 +10,8 @@ const SELECT_CONTEXT_NAME = "select-context"; - export function useSelectContext(component: string): Writable { - const context = getContext>(SELECT_CONTEXT_NAME); + export function useSelectContext(component: string): SelectState { + const context = getContext(SELECT_CONTEXT_NAME); if (context === undefined) { throw new Error( `<${component} /> is missing a parent - {#if state} + {#if vState}
- {#if state === "error"} + {#if vState === "error"} - {:else if state === "success"} + {:else if vState === "success"} {:else} @@ -163,10 +214,9 @@ {/if}
- {#if $$slots.message} - + {#if message}

- + {@render message()}

{/if} diff --git a/packages/stacks-svelte/src/components/Select/Select.test.ts b/packages/stacks-svelte/src/components/Select/Select.test.ts index d37913105f..0ef0712775 100644 --- a/packages/stacks-svelte/src/components/Select/Select.test.ts +++ b/packages/stacks-svelte/src/components/Select/Select.test.ts @@ -1,5 +1,5 @@ import { expect } from "@open-wc/testing"; -import { render, screen } from "@testing-library/svelte"; +import { render, screen, waitFor } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { createRawSnippet, mount, unmount } from "svelte"; import sinon from "sinon"; @@ -38,10 +38,7 @@ describe("Select", () => { render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - default: selectItemsSnippet, - }, + children: selectItemsSnippet, }); expect(screen.getByRole("combobox")).to.have.id("example-select"); expect(screen.getByText("example label")).to.exist; @@ -57,19 +54,33 @@ describe("Select", () => { expect(screen.getByText("example label")).to.have.class("v-visible-sr"); }); - it.skip("should select the option matching the value passed in the selected prop", () => { + it("should select the option matching the value passed in the selected prop", () => { render(Select, { id: "example-select", label: "example label", selected: "2", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - default: selectItemsSnippet, - }, + children: selectItemsSnippet, }); expect(screen.getByRole("combobox")).to.have.value("2"); }); + it("should sync internal state when selected prop changes externally", async () => { + const { rerender } = render(Select, { + id: "example-select", + label: "example label", + selected: "1", + children: selectItemsSnippet, + }); + + const selectElement = screen.getByRole("combobox") as HTMLSelectElement; + expect(selectElement).to.have.value("1"); + + rerender({ selected: "2" }); + await waitFor(() => { + expect(selectElement).to.have.value("2"); + }); + }); + it("should render the appropriate size class", () => { render(Select, { id: "example-select", @@ -109,8 +120,8 @@ describe("Select", () => { expect(screen.getByRole("combobox")).to.have.attribute("disabled"); }); - // slots - it("should render the description slot when the label is not hidden and placed on top", () => { + // snippets + it("should render the description snippet when the label is not hidden and placed on top", () => { const descriptionSnippet = createRawSnippet(() => ({ render: () => "example description", })); @@ -118,10 +129,7 @@ describe("Select", () => { render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - description: descriptionSnippet, - }, + description: descriptionSnippet, }); expect( @@ -129,7 +137,7 @@ describe("Select", () => { ).to.have.class("s-description"); }); - it("should not render the description slot when the label is hidden", () => { + it("should not render the description snippet when the label is hidden", () => { const descriptionSnippet = createRawSnippet(() => ({ render: () => "example description", })); @@ -138,16 +146,13 @@ describe("Select", () => { id: "example-select", label: "example label", hideLabel: true, - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - description: descriptionSnippet, - }, + description: descriptionSnippet, }); expect(screen.queryByText("example description")).not.to.exist; }); - it("should not render the description slot when the label placement is left", () => { + it("should not render the description snippet when the label placement is left", () => { const descriptionSnippet = createRawSnippet(() => ({ render: () => "example description", })); @@ -156,16 +161,13 @@ describe("Select", () => { id: "example-select", label: "example label", labelPlacement: "left", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - description: descriptionSnippet, - }, + description: descriptionSnippet, }); expect(screen.queryByText("example description")).not.to.exist; }); - it("should render the message slot", () => { + it("should render the message snippet", () => { const messageSnippet = createRawSnippet(() => ({ render: () => "example message", })); @@ -173,10 +175,7 @@ describe("Select", () => { render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - message: messageSnippet, - }, + message: messageSnippet, }); expect(screen.getByText("example message").parentElement).to.have.class( @@ -185,18 +184,13 @@ describe("Select", () => { }); // events - it.skip("should call the on:change callback when the user changes the value", async () => { + it("should call the onchange callback when the user changes the value", async () => { const onChangeSpy = sinon.spy(); render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - default: selectItemsSnippet, - }, - $$events: { - change: onChangeSpy, - }, + children: selectItemsSnippet, + onchange: onChangeSpy, }); const selectElement = screen.getByRole("combobox"); await user.selectOptions(selectElement, "2"); @@ -206,35 +200,25 @@ describe("Select", () => { }); }); - it("should call the on:focus callback when the user focus on the select", async () => { + it("should call the onfocus callback when the user focus on the select", async () => { const onFocusSpy = sinon.spy(); render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - default: selectItemsSnippet, - }, - $$events: { - focus: onFocusSpy, - }, + children: selectItemsSnippet, + onfocus: onFocusSpy, }); await user.click(screen.getByRole("combobox")); expect(onFocusSpy).to.have.been.calledOnce; }); - it("should call the on:blur callback when the user focus away from the select", async () => { + it("should call the onblur callback when the user focus away from the select", async () => { const onBlurSpy = sinon.spy(); render(Select, { id: "example-select", label: "example label", - // @ts-expect-error $$slots is used to pass children while component is still using Svelte 4 syntax - $$slots: { - default: selectItemsSnippet, - }, - $$events: { - blur: onBlurSpy, - }, + children: selectItemsSnippet, + onblur: onBlurSpy, }); await user.click(screen.getByRole("combobox")); await user.tab(); diff --git a/packages/stacks-svelte/src/components/Select/SelectItem.svelte b/packages/stacks-svelte/src/components/Select/SelectItem.svelte index 4df42baf68..c6a921d441 100644 --- a/packages/stacks-svelte/src/components/Select/SelectItem.svelte +++ b/packages/stacks-svelte/src/components/Select/SelectItem.svelte @@ -1,31 +1,30 @@ - From b458658f41d6654b8c5400c7839505bf499e2ec1 Mon Sep 17 00:00:00 2001 From: Giamir Buoncristiani Date: Mon, 13 Oct 2025 17:23:42 +0200 Subject: [PATCH 2/3] add changeset and forward all rest props on select element --- .changeset/rude-moments-yell.md | 9 +++++ .../src/components/Select/Select.svelte | 39 ++++--------------- 2 files changed, 17 insertions(+), 31 deletions(-) create mode 100644 .changeset/rude-moments-yell.md diff --git a/.changeset/rude-moments-yell.md b/.changeset/rude-moments-yell.md new file mode 100644 index 0000000000..dae123c50c --- /dev/null +++ b/.changeset/rude-moments-yell.md @@ -0,0 +1,9 @@ +--- +"@stackoverflow/stacks-svelte": patch +--- + +Migrate `Select` and `SelectItem` components to use Svelte 5 runes API + +BREAKING CHANGES: +- `message` and `description` slotted content are not available anymore. `message` and `description` snippets should be used instead. +- `on:change` `on:focus` and `on:blur` are not available anymore. The new callback props should be used instead: `onchange`, `onfocus`, `onblur`. diff --git a/packages/stacks-svelte/src/components/Select/Select.svelte b/packages/stacks-svelte/src/components/Select/Select.svelte index 03bdf78ea2..e2de8dc444 100644 --- a/packages/stacks-svelte/src/components/Select/Select.svelte +++ b/packages/stacks-svelte/src/components/Select/Select.svelte @@ -31,8 +31,9 @@ } from "@stackoverflow/stacks-icons/icons"; import { setContext } from "svelte"; import type { Snippet } from "svelte"; + import type { HTMLSelectAttributes } from "svelte/elements"; - interface Props { + interface Props extends Omit { /** * `id` attribute of the select element */ @@ -78,21 +79,6 @@ */ labelPlacement?: LabelPlacement; - /** - * Callback fired when the select value changes - */ - onchange?: (event: Event) => void; - - /** - * Callback fired when the select receives focus - */ - onfocus?: (event: FocusEvent) => void; - - /** - * Callback fired when the select loses focus - */ - onblur?: (event: FocusEvent) => void; - /** * Snippet to render options as SelectItem components */ @@ -119,12 +105,10 @@ size = "", state: vState = "", labelPlacement = "top", - onchange, - onfocus, - onblur, children, description, message, + ...restProps }: Props = $props(); const getClasses = (size: Size, placement: LabelPlacement) => { @@ -154,19 +138,13 @@ setContext(SELECT_CONTEXT_NAME, internalState); - const onChangeHandler = (event: Event) => { + const onChangeHandler = ( + event: Event & { currentTarget: EventTarget & HTMLSelectElement } + ) => { const target = event.target as HTMLSelectElement; internalState.selected = target.value; selected = target.value; - onchange?.(event); - }; - - const onFocusHandler = (event: FocusEvent) => { - onfocus?.(event); - }; - - const onBlurHandler = (event: FocusEvent) => { - onblur?.(event); + restProps.onchange?.(event); }; @@ -196,8 +174,7 @@ : undefined} aria-invalid={vState === "error"} onchange={onChangeHandler} - onfocus={onFocusHandler} - onblur={onBlurHandler} + {...restProps} > {@render children?.()} From f15f58c7e507e6a44e2b0a3364ae4c2a2c823090 Mon Sep 17 00:00:00 2001 From: Giamir Buoncristiani Date: Tue, 14 Oct 2025 12:39:09 +0200 Subject: [PATCH 3/3] fix storybook overdocumenting the select props --- packages/stacks-svelte/src/components/Select/Select.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/stacks-svelte/src/components/Select/Select.svelte b/packages/stacks-svelte/src/components/Select/Select.svelte index e2de8dc444..6752bcbdb2 100644 --- a/packages/stacks-svelte/src/components/Select/Select.svelte +++ b/packages/stacks-svelte/src/components/Select/Select.svelte @@ -33,7 +33,10 @@ import type { Snippet } from "svelte"; import type { HTMLSelectAttributes } from "svelte/elements"; - interface Props extends Omit { + // @ts-expect-error - HTMLSelectAttributes size is not compatible with our custom Size type. + // Ideally we could use Omit but doing that + // causes Storybook autodocs to document all the select attributes. + interface Props extends HTMLSelectAttributes { /** * `id` attribute of the select element */