|
10 | 10 |
|
11 | 11 | const SELECT_CONTEXT_NAME = "select-context"; |
12 | 12 |
|
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); |
15 | 15 | if (context === undefined) { |
16 | 16 | throw new Error( |
17 | 17 | `<${component} /> is missing a parent <Select /> component.` |
|
30 | 30 | IconCheckmark, |
31 | 31 | } from "@stackoverflow/stacks-icons/icons"; |
32 | 32 | 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(); |
91 | 116 |
|
92 | 117 | const getClasses = (size: Size, placement: LabelPlacement) => { |
93 | 118 | const base = "s-select"; |
|
104 | 129 | return classes; |
105 | 130 | }; |
106 | 131 |
|
107 | | - const selectState = writable<SelectState>({ |
| 132 | + let classes = $derived(getClasses(size, labelPlacement)); |
| 133 | +
|
| 134 | + let internalState = $state({ |
108 | 135 | selected, |
109 | 136 | }); |
110 | 137 |
|
111 | | - setContext(SELECT_CONTEXT_NAME, selectState); |
| 138 | + $effect(() => { |
| 139 | + internalState.selected = selected; |
| 140 | + }); |
| 141 | +
|
| 142 | + setContext(SELECT_CONTEXT_NAME, internalState); |
112 | 143 |
|
113 | | - const onChangeHandler = (event: Event) => { |
| 144 | + const onChangeHandler = ( |
| 145 | + event: Event & { currentTarget: EventTarget & HTMLSelectElement } |
| 146 | + ) => { |
114 | 147 | 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); |
116 | 151 | }; |
117 | 152 | </script> |
118 | 153 |
|
119 | 154 | <div |
120 | 155 | 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"} |
124 | 159 | > |
125 | 160 | <Label {id} class={hideLabel ? "v-visible-sr" : ""} {size}> |
126 | 161 | {label} |
127 | 162 | </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"} |
130 | 164 | <p class="s-description mb0 mtn2" id={`${id}-description`}> |
131 | | - <slot name="description" /> |
| 165 | + {@render description()} |
132 | 166 | </p> |
133 | 167 | {/if} |
134 | 168 | <div class={classes}> |
135 | 169 | <select |
136 | 170 | {id} |
137 | 171 | {name} |
138 | 172 | {disabled} |
139 | | - aria-describedby={$$slots.message |
| 173 | + aria-describedby={message |
140 | 174 | ? `${id}-message` |
141 | | - : $$slots.description |
| 175 | + : description |
142 | 176 | ? `${id}-description` |
143 | 177 | : 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} |
149 | 181 | > |
150 | | - <!-- Renders the options (SelectItem). --> |
151 | | - <slot /> |
| 182 | + {@render children?.()} |
152 | 183 | </select> |
153 | | - {#if state} |
| 184 | + {#if vState} |
154 | 185 | <div class="s-input-icon"> |
155 | | - {#if state === "error"} |
| 186 | + {#if vState === "error"} |
156 | 187 | <Icon src={IconAlertCircle} /> |
157 | | - {:else if state === "success"} |
| 188 | + {:else if vState === "success"} |
158 | 189 | <Icon src={IconCheckmark} /> |
159 | 190 | {:else} |
160 | 191 | <Icon src={IconAlert} /> |
|
163 | 194 | {/if} |
164 | 195 | </div> |
165 | 196 |
|
166 | | - {#if $$slots.message} |
167 | | - <!-- Renders a message after the select element. --> |
| 197 | + {#if message} |
168 | 198 | <p class="s-input-message" id={`${id}-message`}> |
169 | | - <slot name="message" /> |
| 199 | + {@render message()} |
170 | 200 | </p> |
171 | 201 | {/if} |
172 | 202 | </div> |
0 commit comments