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.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..6752bcbdb2 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 component.` @@ -30,64 +30,89 @@ IconCheckmark, } from "@stackoverflow/stacks-icons/icons"; import { setContext } from "svelte"; - import type { Writable } from "svelte/store"; - import { writable } from "svelte/store"; - - /** - * `id` attribute of the select element - * @type {string} - */ - export let id: string; - - /** - * The label associated with the select element - * @type {string} - */ - export let label: string; - - /** - * Specify the initial selected item value - * @type {string | number} - */ - export let selected: string | number | undefined = undefined; - - /** - * Sets the disabled state - * @type {boolean} - */ - export let disabled: boolean = false; - - /** - * The visiblity of the label element - * @type {boolean} - */ - export let hideLabel: boolean = false; - - /** - * Name attribute of the select element - * @type {string | undefined} - */ - export let name: string | undefined = undefined; - - /** - * The size of the select - * @type {"" | "sm" | "md" | "lg" | "xl"} Size - */ - export let size: Size = ""; - - /** - * The state of the select - * @type {"" | "error" | "success" | "warning"} State - */ - export let state: State = ""; - - /** - * The placement of the label relative to the select - * @type {"top" | "left"} - */ - export let labelPlacement: LabelPlacement = "top"; - - $: classes = getClasses(size, labelPlacement); + import type { Snippet } from "svelte"; + import type { HTMLSelectAttributes } from "svelte/elements"; + + // @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 + */ + id: string; + + /** + * The label associated with the select element + */ + label: string; + + /** + * Specify the initial selected item value + */ + selected?: string | number | undefined; + + /** + * Sets the disabled state + */ + disabled?: boolean; + + /** + * The visiblity of the label element + */ + hideLabel?: boolean; + + /** + * Name attribute of the select element + */ + name?: string | undefined; + + /** + * The size of the select + */ + size?: Size; + + /** + * The validation state of the select + */ + state?: State; + + /** + * The placement of the label relative to the select + */ + labelPlacement?: LabelPlacement; + + /** + * Snippet to render options as SelectItem components + */ + children?: Snippet; + + /** + * Snippet to render a description between the label and the select (only when label is visible and placed on top) + */ + description?: Snippet; + + /** + * Snippet to render a message after the select element + */ + message?: Snippet; + } + + let { + id, + label, + selected = $bindable(undefined), + disabled = false, + hideLabel = false, + name = undefined, + size = "", + state: vState = "", + labelPlacement = "top", + children, + description, + message, + ...restProps + }: Props = $props(); const getClasses = (size: Size, placement: LabelPlacement) => { const base = "s-select"; @@ -104,31 +129,40 @@ return classes; }; - const selectState = writable({ + let classes = $derived(getClasses(size, labelPlacement)); + + let internalState = $state({ selected, }); - setContext(SELECT_CONTEXT_NAME, selectState); + $effect(() => { + internalState.selected = selected; + }); + + setContext(SELECT_CONTEXT_NAME, internalState); - const onChangeHandler = (event: Event) => { + const onChangeHandler = ( + event: Event & { currentTarget: EventTarget & HTMLSelectElement } + ) => { const target = event.target as HTMLSelectElement; - selectState.set({ selected: target.value }); + internalState.selected = target.value; + selected = target.value; + restProps.onchange?.(event); }; {label} - {#if $$slots.description && !hideLabel && labelPlacement === "top"} - + {#if description && !hideLabel && labelPlacement === "top"} - + {@render description()} {/if} @@ -136,25 +170,22 @@ {id} {name} {disabled} - aria-describedby={$$slots.message + aria-describedby={message ? `${id}-message` - : $$slots.description + : description ? `${id}-description` : undefined} - aria-invalid={state === "error"} - on:change={onChangeHandler} - on:change - on:focus - on:blur + aria-invalid={vState === "error"} + onchange={onChangeHandler} + {...restProps} > - - + {@render children?.()} - {#if state} + {#if vState} - {#if state === "error"} + {#if vState === "error"} - {:else if state === "success"} + {:else if vState === "success"} {:else} @@ -163,10 +194,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 @@ - + {text || value}
- + {@render description()}
- + {@render message()}