Skip to content

Commit b5ca551

Browse files
authored
chore(select): update to svelte 5 runes apis (#2005)
1 parent bf4eb81 commit b5ca551

File tree

5 files changed

+186
-164
lines changed

5 files changed

+186
-164
lines changed

.changeset/rude-moments-yell.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@stackoverflow/stacks-svelte": patch
3+
---
4+
5+
Migrate `Select` and `SelectItem` components to use Svelte 5 runes API
6+
7+
BREAKING CHANGES:
8+
- `message` and `description` slotted content are not available anymore. `message` and `description` snippets should be used instead.
9+
- `on:change` `on:focus` and `on:blur` are not available anymore. The new callback props should be used instead: `onchange`, `onfocus`, `onblur`.

packages/stacks-svelte/src/components/Select/Select.stories.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,17 @@
105105
id="select-with-description-and-message"
106106
label="With Description and Message"
107107
>
108-
<svelte:fragment slot="description">
108+
{#snippet description()}
109109
Select the sorting order
110-
</svelte:fragment>
110+
{/snippet}
111111
<SelectItem value="relevance" text="Relevance" />
112112
<SelectItem value="newest" text="Newest" />
113113
<SelectItem value="active" text="Active" />
114114
<SelectItem value="score" text="Score" />
115-
<svelte:fragment slot="message">
115+
{#snippet message()}
116116
The available sorting orders are Relevance, Newest, Active, and
117117
Score
118-
</svelte:fragment>
118+
{/snippet}
119119
</Select>
120120
</Story>
121121

packages/stacks-svelte/src/components/Select/Select.svelte

Lines changed: 115 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
1111
const SELECT_CONTEXT_NAME = "select-context";
1212
13-
export function useSelectContext(component: string): Writable<SelectState> {
14-
const context = getContext<Writable<SelectState>>(SELECT_CONTEXT_NAME);
13+
export function useSelectContext(component: string): SelectState {
14+
const context = getContext<SelectState>(SELECT_CONTEXT_NAME);
1515
if (context === undefined) {
1616
throw new Error(
1717
`<${component} /> is missing a parent <Select /> component.`
@@ -30,64 +30,89 @@
3030
IconCheckmark,
3131
} from "@stackoverflow/stacks-icons/icons";
3232
import { setContext } from "svelte";
33-
import type { Writable } from "svelte/store";
34-
import { writable } from "svelte/store";
35-
36-
/**
37-
* `id` attribute of the select element
38-
* @type {string}
39-
*/
40-
export let id: string;
41-
42-
/**
43-
* The label associated with the select element
44-
* @type {string}
45-
*/
46-
export let label: string;
47-
48-
/**
49-
* Specify the initial selected item value
50-
* @type {string | number}
51-
*/
52-
export let selected: string | number | undefined = undefined;
53-
54-
/**
55-
* Sets the disabled state
56-
* @type {boolean}
57-
*/
58-
export let disabled: boolean = false;
59-
60-
/**
61-
* The visiblity of the label element
62-
* @type {boolean}
63-
*/
64-
export let hideLabel: boolean = false;
65-
66-
/**
67-
* Name attribute of the select element
68-
* @type {string | undefined}
69-
*/
70-
export let name: string | undefined = undefined;
71-
72-
/**
73-
* The size of the select
74-
* @type {"" | "sm" | "md" | "lg" | "xl"} Size
75-
*/
76-
export let size: Size = "";
77-
78-
/**
79-
* The state of the select
80-
* @type {"" | "error" | "success" | "warning"} State
81-
*/
82-
export let state: State = "";
83-
84-
/**
85-
* The placement of the label relative to the select
86-
* @type {"top" | "left"}
87-
*/
88-
export let labelPlacement: LabelPlacement = "top";
89-
90-
$: classes = getClasses(size, labelPlacement);
33+
import type { Snippet } from "svelte";
34+
import type { HTMLSelectAttributes } from "svelte/elements";
35+
36+
// @ts-expect-error - HTMLSelectAttributes size is not compatible with our custom Size type.
37+
// Ideally we could use Omit<HTMLSelectAttributes, "size"> but doing that
38+
// causes Storybook autodocs to document all the select attributes.
39+
interface Props extends HTMLSelectAttributes {
40+
/**
41+
* `id` attribute of the select element
42+
*/
43+
id: string;
44+
45+
/**
46+
* The label associated with the select element
47+
*/
48+
label: string;
49+
50+
/**
51+
* Specify the initial selected item value
52+
*/
53+
selected?: string | number | undefined;
54+
55+
/**
56+
* Sets the disabled state
57+
*/
58+
disabled?: boolean;
59+
60+
/**
61+
* The visiblity of the label element
62+
*/
63+
hideLabel?: boolean;
64+
65+
/**
66+
* Name attribute of the select element
67+
*/
68+
name?: string | undefined;
69+
70+
/**
71+
* The size of the select
72+
*/
73+
size?: Size;
74+
75+
/**
76+
* The validation state of the select
77+
*/
78+
state?: State;
79+
80+
/**
81+
* The placement of the label relative to the select
82+
*/
83+
labelPlacement?: LabelPlacement;
84+
85+
/**
86+
* Snippet to render options as SelectItem components
87+
*/
88+
children?: Snippet;
89+
90+
/**
91+
* Snippet to render a description between the label and the select (only when label is visible and placed on top)
92+
*/
93+
description?: Snippet;
94+
95+
/**
96+
* Snippet to render a message after the select element
97+
*/
98+
message?: Snippet;
99+
}
100+
101+
let {
102+
id,
103+
label,
104+
selected = $bindable(undefined),
105+
disabled = false,
106+
hideLabel = false,
107+
name = undefined,
108+
size = "",
109+
state: vState = "",
110+
labelPlacement = "top",
111+
children,
112+
description,
113+
message,
114+
...restProps
115+
}: Props = $props();
91116
92117
const getClasses = (size: Size, placement: LabelPlacement) => {
93118
const base = "s-select";
@@ -104,57 +129,63 @@
104129
return classes;
105130
};
106131
107-
const selectState = writable<SelectState>({
132+
let classes = $derived(getClasses(size, labelPlacement));
133+
134+
let internalState = $state({
108135
selected,
109136
});
110137
111-
setContext(SELECT_CONTEXT_NAME, selectState);
138+
$effect(() => {
139+
internalState.selected = selected;
140+
});
141+
142+
setContext(SELECT_CONTEXT_NAME, internalState);
112143
113-
const onChangeHandler = (event: Event) => {
144+
const onChangeHandler = (
145+
event: Event & { currentTarget: EventTarget & HTMLSelectElement }
146+
) => {
114147
const target = event.target as HTMLSelectElement;
115-
selectState.set({ selected: target.value });
148+
internalState.selected = target.value;
149+
selected = target.value;
150+
restProps.onchange?.(event);
116151
};
117152
</script>
118153

119154
<div
120155
class={`d-flex ${labelPlacement === "top" ? " fd-column gy4" : "ai-center"}`}
121-
class:has-error={state === "error"}
122-
class:has-success={state === "success"}
123-
class:has-warning={state === "warning"}
156+
class:has-error={vState === "error"}
157+
class:has-success={vState === "success"}
158+
class:has-warning={vState === "warning"}
124159
>
125160
<Label {id} class={hideLabel ? "v-visible-sr" : ""} {size}>
126161
{label}
127162
</Label>
128-
{#if $$slots.description && !hideLabel && labelPlacement === "top"}
129-
<!-- Renders a description between the label and the select (only when label is visible and placed on top). -->
163+
{#if description && !hideLabel && labelPlacement === "top"}
130164
<p class="s-description mb0 mtn2" id={`${id}-description`}>
131-
<slot name="description" />
165+
{@render description()}
132166
</p>
133167
{/if}
134168
<div class={classes}>
135169
<select
136170
{id}
137171
{name}
138172
{disabled}
139-
aria-describedby={$$slots.message
173+
aria-describedby={message
140174
? `${id}-message`
141-
: $$slots.description
175+
: description
142176
? `${id}-description`
143177
: undefined}
144-
aria-invalid={state === "error"}
145-
on:change={onChangeHandler}
146-
on:change
147-
on:focus
148-
on:blur
178+
aria-invalid={vState === "error"}
179+
onchange={onChangeHandler}
180+
{...restProps}
149181
>
150-
<!-- Renders the options (SelectItem). -->
151-
<slot />
182+
{@render children?.()}
152183
</select>
153-
{#if state}
184+
{#if vState}
154185
<div class="s-input-icon">
155-
{#if state === "error"}
186+
{#if vState === "error"}
156187
<Icon src={IconAlertCircle} />
157-
{:else if state === "success"}
188+
{:else if vState === "success"}
158189
<Icon src={IconCheckmark} />
159190
{:else}
160191
<Icon src={IconAlert} />
@@ -163,10 +194,9 @@
163194
{/if}
164195
</div>
165196

166-
{#if $$slots.message}
167-
<!-- Renders a message after the select element. -->
197+
{#if message}
168198
<p class="s-input-message" id={`${id}-message`}>
169-
<slot name="message" />
199+
{@render message()}
170200
</p>
171201
{/if}
172202
</div>

0 commit comments

Comments
 (0)