@joyautomation/salt 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +332 -29
- package/dist/components/forms/Form.svelte +25 -25
- package/dist/components/forms/Input.svelte +6 -6
- package/dist/components/forms/SearchableSelect.svelte +58 -58
- package/dist/components/forms/Select.svelte +6 -6
- package/dist/components/icons/outline/ArrowTurnDownLeft.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnDownRight.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnLeftDown.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnLeftUp.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnRightDown.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnRightUp.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnUpLeft.svelte +1 -1
- package/dist/components/icons/outline/ArrowTurnUpRight.svelte +1 -1
- package/dist/components/icons/outline/Bold.svelte +1 -1
- package/dist/components/icons/outline/CalendarDateRange.svelte +1 -1
- package/dist/components/icons/outline/Divide.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyBangladeshi.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyDollar.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyEuro.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyPound.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyRupee.svelte +1 -1
- package/dist/components/icons/outline/DocumentCurrencyYen.svelte +1 -1
- package/dist/components/icons/outline/Equals.svelte +1 -1
- package/dist/components/icons/outline/H1.svelte +1 -1
- package/dist/components/icons/outline/H2.svelte +1 -1
- package/dist/components/icons/outline/H3.svelte +1 -1
- package/dist/components/icons/outline/Italic.svelte +1 -1
- package/dist/components/icons/outline/LinkSlash.svelte +1 -1
- package/dist/components/icons/outline/NumberedList.svelte +1 -1
- package/dist/components/icons/outline/PercentBadge.svelte +1 -1
- package/dist/components/icons/outline/Slash.svelte +1 -1
- package/dist/components/icons/outline/Strikethrough.svelte +1 -1
- package/dist/components/icons/outline/Underline.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnDownLeft.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnDownRight.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnLeftDown.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnLeftUp.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnRightDown.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnRightUp.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnUpLeft.svelte +1 -1
- package/dist/components/icons/solid/ArrowTurnUpRight.svelte +1 -1
- package/dist/components/icons/solid/Bold.svelte +1 -1
- package/dist/components/icons/solid/CalendarDateRange.svelte +10 -10
- package/dist/components/icons/solid/Divide.svelte +1 -1
- package/dist/components/icons/solid/DocumentCurrencyBangladeshi.svelte +1 -1
- package/dist/components/icons/solid/DocumentCurrencyDollar.svelte +1 -1
- package/dist/components/icons/solid/DocumentCurrencyEuro.svelte +2 -2
- package/dist/components/icons/solid/DocumentCurrencyPound.svelte +1 -1
- package/dist/components/icons/solid/DocumentCurrencyRupee.svelte +1 -1
- package/dist/components/icons/solid/DocumentCurrencyYen.svelte +1 -1
- package/dist/components/icons/solid/Equals.svelte +2 -2
- package/dist/components/icons/solid/H1.svelte +1 -1
- package/dist/components/icons/solid/H2.svelte +1 -1
- package/dist/components/icons/solid/H3.svelte +2 -2
- package/dist/components/icons/solid/Italic.svelte +1 -1
- package/dist/components/icons/solid/LinkSlash.svelte +1 -1
- package/dist/components/icons/solid/NumberedList.svelte +6 -6
- package/dist/components/icons/solid/PercentBadge.svelte +1 -1
- package/dist/components/icons/solid/Slash.svelte +1 -1
- package/dist/components/icons/solid/Strikethrough.svelte +1 -1
- package/dist/components/icons/solid/Underline.svelte +1 -1
- package/dist/components/icons/solid/VideoCameraSlash.svelte +4 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,58 +1,361 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @joyautomation/salt
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Svelte 5 component library with theming, forms, icons, and notifications.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @joyautomation/salt
|
|
9
|
+
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Import the styles in your root layout or app entry point:
|
|
10
12
|
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
```svelte
|
|
14
|
+
<script>
|
|
15
|
+
import '@joyautomation/salt/styles.scss'
|
|
16
|
+
</script>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Theming
|
|
20
|
+
|
|
21
|
+
Salt includes a light/dark theme system using CSS custom variables. Apply the default theme class to `<body>` in your `app.html`:
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
```html
|
|
24
|
+
<body class="themeLight" data-sveltekit-preload-data="hover">
|
|
25
|
+
%sveltekit.body%
|
|
26
|
+
</body>
|
|
17
27
|
```
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
### ThemeButton
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
A toggle button that switches between light and dark mode via a SvelteKit form action.
|
|
22
32
|
|
|
23
|
-
```
|
|
24
|
-
|
|
33
|
+
```svelte
|
|
34
|
+
<script>
|
|
35
|
+
import { ThemeButton } from '@joyautomation/salt'
|
|
36
|
+
</script>
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
npm run dev -- --open
|
|
38
|
+
<ThemeButton theme="themeLight" />
|
|
28
39
|
```
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
Wire up the server action in your `+layout.server.ts`:
|
|
31
42
|
|
|
32
|
-
|
|
43
|
+
```ts
|
|
44
|
+
import { actions as saltActions } from '@joyautomation/salt'
|
|
45
|
+
import type { Actions } from '@sveltejs/kit'
|
|
33
46
|
|
|
34
|
-
|
|
47
|
+
export const actions: Actions = {
|
|
48
|
+
setTheme: saltActions.setTheme
|
|
49
|
+
}
|
|
50
|
+
```
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
### ThemeSwitch
|
|
53
|
+
|
|
54
|
+
A three-option selector (System / Light / Dark):
|
|
55
|
+
|
|
56
|
+
```svelte
|
|
57
|
+
<script>
|
|
58
|
+
import { ThemeSwitch } from '@joyautomation/salt'
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<ThemeSwitch />
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Theme State
|
|
65
|
+
|
|
66
|
+
Programmatic access to the current theme:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { themeState, getEffectiveTheme } from '@joyautomation/salt'
|
|
70
|
+
|
|
71
|
+
// Read current theme
|
|
72
|
+
themeState.value // 'themeSystem' | 'themeLight' | 'themeDark'
|
|
73
|
+
|
|
74
|
+
// Get resolved theme (resolves 'themeSystem' to actual preference)
|
|
75
|
+
getEffectiveTheme() // 'themeLight' | 'themeDark'
|
|
76
|
+
|
|
77
|
+
// Set theme programmatically
|
|
78
|
+
themeState.set('themeDark')
|
|
79
|
+
|
|
80
|
+
// Initialize from server cookie
|
|
81
|
+
themeState.initialize(serverTheme)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Toast Notifications
|
|
85
|
+
|
|
86
|
+
Drop-in toast notification system that auto-displays messages returned from SvelteKit form actions.
|
|
87
|
+
|
|
88
|
+
### Setup
|
|
89
|
+
|
|
90
|
+
Add the `Toast` component to your root layout:
|
|
91
|
+
|
|
92
|
+
```svelte
|
|
93
|
+
<script>
|
|
94
|
+
import { Toast } from '@joyautomation/salt'
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<slot />
|
|
98
|
+
<Toast />
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Triggering Toasts from Form Actions
|
|
102
|
+
|
|
103
|
+
Return an object with `message` and `type` from any form action:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// +page.server.ts
|
|
107
|
+
export const actions = {
|
|
108
|
+
save: async () => {
|
|
109
|
+
return {
|
|
110
|
+
message: 'Changes saved successfully',
|
|
111
|
+
type: 'success' // 'success' | 'error' | 'warning'
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Triggering Toasts Programmatically
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { state } from '@joyautomation/salt'
|
|
121
|
+
|
|
122
|
+
state.addNotification({
|
|
123
|
+
message: 'Something happened',
|
|
124
|
+
type: 'warning'
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Notifications auto-dismiss after 5 seconds.
|
|
129
|
+
|
|
130
|
+
## Forms
|
|
131
|
+
|
|
132
|
+
A JSON-driven form system using a 2D array layout where each inner array represents a row of fields.
|
|
133
|
+
|
|
134
|
+
### Basic Usage
|
|
135
|
+
|
|
136
|
+
```svelte
|
|
137
|
+
<script>
|
|
138
|
+
import { forms } from '@joyautomation/salt'
|
|
139
|
+
import type { FormInputs } from '@joyautomation/salt'
|
|
140
|
+
|
|
141
|
+
const inputs: FormInputs = $state([
|
|
142
|
+
// Row 1: two fields side by side
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
id: 'name',
|
|
146
|
+
name: 'name',
|
|
147
|
+
label: 'Name',
|
|
148
|
+
type: 'text',
|
|
149
|
+
value: '',
|
|
150
|
+
validations: [[(v) => v.trim().length === 0, 'Name is required']]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'email',
|
|
154
|
+
name: 'email',
|
|
155
|
+
label: 'Email',
|
|
156
|
+
type: 'email',
|
|
157
|
+
value: '',
|
|
158
|
+
validations: [
|
|
159
|
+
[(v) => v.trim().length === 0, 'Email is required'],
|
|
160
|
+
[(v) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), 'Enter a valid email']
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
// Row 2: select dropdown
|
|
165
|
+
[
|
|
166
|
+
{
|
|
167
|
+
id: 'role',
|
|
168
|
+
name: 'role',
|
|
169
|
+
label: 'Role',
|
|
170
|
+
type: 'select',
|
|
171
|
+
value: 'user',
|
|
172
|
+
validations: [],
|
|
173
|
+
options: [
|
|
174
|
+
{ value: 'user', label: 'User' },
|
|
175
|
+
{ value: 'admin', label: 'Admin' }
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
],
|
|
179
|
+
// Row 3: textarea
|
|
180
|
+
[
|
|
181
|
+
{
|
|
182
|
+
id: 'notes',
|
|
183
|
+
name: 'notes',
|
|
184
|
+
label: 'Notes',
|
|
185
|
+
type: 'textarea',
|
|
186
|
+
value: '',
|
|
187
|
+
validations: []
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
])
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<forms.Form inputs={inputs} action="?/save" buttonText="Save" />
|
|
38
194
|
```
|
|
39
195
|
|
|
40
|
-
|
|
196
|
+
### Field Types
|
|
197
|
+
|
|
198
|
+
Set `type` on an `InputProps` entry:
|
|
199
|
+
|
|
200
|
+
- **Text inputs**: `text`, `password`, `email`, `number`, `tel`, `datetime-local`, etc.
|
|
201
|
+
- **Textarea**: `textarea` — renders a resizable text area
|
|
202
|
+
- **Select**: `select` — renders a dropdown, requires `options: { value: string, label: string }[]`
|
|
203
|
+
|
|
204
|
+
### Validation
|
|
205
|
+
|
|
206
|
+
Each field has a `validations` array of `[testFn, errorMessage]` tuples. The test function returns `true` when the value is **invalid**:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
validations: [
|
|
210
|
+
[(value) => value.length === 0, 'Required'],
|
|
211
|
+
[(value) => value.length < 3, 'Must be at least 3 characters'],
|
|
212
|
+
// Cross-field validation — second param gives access to all form inputs
|
|
213
|
+
[(value, inputs) => {
|
|
214
|
+
const other = inputs.flat().find(i => i.name === 'password')
|
|
215
|
+
return value !== other?.value
|
|
216
|
+
}, 'Passwords must match']
|
|
217
|
+
]
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The submit button is disabled until all validations pass.
|
|
221
|
+
|
|
222
|
+
### Form Component
|
|
223
|
+
|
|
224
|
+
```svelte
|
|
225
|
+
<forms.Form
|
|
226
|
+
inputs={formInputs}
|
|
227
|
+
action="?/submit"
|
|
228
|
+
buttonText="Submit"
|
|
229
|
+
onsubmitstart={() => console.log('submitting...')}
|
|
230
|
+
onsubmitend={() => console.log('done')}
|
|
231
|
+
/>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Props:
|
|
235
|
+
- `inputs` — `FormInputs` (2D array of `InputProps`)
|
|
236
|
+
- `action` — SvelteKit form action path
|
|
237
|
+
- `buttonText` — submit button label (default: `'Submit'`)
|
|
238
|
+
- `onsubmitstart` / `onsubmitend` — optional callbacks
|
|
239
|
+
|
|
240
|
+
### Individual Components
|
|
241
|
+
|
|
242
|
+
You can also use form components standalone:
|
|
243
|
+
|
|
244
|
+
```svelte
|
|
245
|
+
<forms.Input
|
|
246
|
+
id="name"
|
|
247
|
+
name="name"
|
|
248
|
+
label="Name"
|
|
249
|
+
type="text"
|
|
250
|
+
bind:value={name}
|
|
251
|
+
validations={[[(v) => v.length === 0, 'Required']]}
|
|
252
|
+
touched={true}
|
|
253
|
+
/>
|
|
254
|
+
|
|
255
|
+
<forms.Select
|
|
256
|
+
id="color"
|
|
257
|
+
name="color"
|
|
258
|
+
label="Color"
|
|
259
|
+
bind:value={color}
|
|
260
|
+
validations={[]}
|
|
261
|
+
options={[{ value: 'red', label: 'Red' }, { value: 'blue', label: 'Blue' }]}
|
|
262
|
+
/>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### SearchableSelect
|
|
266
|
+
|
|
267
|
+
A filterable dropdown with keyboard navigation, independent of the form system:
|
|
268
|
+
|
|
269
|
+
```svelte
|
|
270
|
+
<script>
|
|
271
|
+
import { forms } from '@joyautomation/salt'
|
|
272
|
+
|
|
273
|
+
let selected = $state('')
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<forms.SearchableSelect
|
|
277
|
+
options={[
|
|
278
|
+
{ value: 'svelte', label: 'Svelte', sublabel: 'Cybernetically enhanced' },
|
|
279
|
+
{ value: 'react', label: 'React', sublabel: 'A JavaScript library' }
|
|
280
|
+
]}
|
|
281
|
+
placeholder="Choose a framework..."
|
|
282
|
+
onSelect={(option) => (selected = option.value)}
|
|
283
|
+
disabled={false}
|
|
284
|
+
/>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Types
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
import type { InputProps, FormInputs, FormInputsPartial } from '@joyautomation/salt'
|
|
291
|
+
|
|
292
|
+
// InputProps — single field configuration
|
|
293
|
+
// FormInputs — InputProps[][] (2D array, each inner array is a row)
|
|
294
|
+
// FormInputsPartial — partial version for merging/patching field configs
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Icons
|
|
298
|
+
|
|
299
|
+
All 648 [Heroicons](https://heroicons.com) (324 outline + 324 solid) are available as tree-shakeable Svelte components.
|
|
300
|
+
|
|
301
|
+
### Usage
|
|
302
|
+
|
|
303
|
+
```svelte
|
|
304
|
+
<script>
|
|
305
|
+
// Outline icons (default export)
|
|
306
|
+
import { AcademicCap, ArrowPath, Trash } from '@joyautomation/salt/icons'
|
|
307
|
+
|
|
308
|
+
// Style-specific imports
|
|
309
|
+
import { AcademicCap } from '@joyautomation/salt/icons/outline'
|
|
310
|
+
import { AcademicCap } from '@joyautomation/salt/icons/solid'
|
|
311
|
+
</script>
|
|
312
|
+
|
|
313
|
+
<!-- Default size is 1.5rem -->
|
|
314
|
+
<AcademicCap />
|
|
315
|
+
|
|
316
|
+
<!-- Custom size -->
|
|
317
|
+
<ArrowPath size="2rem" />
|
|
318
|
+
<Trash size="1rem" />
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Icons inherit `currentColor` so they match the surrounding text color.
|
|
322
|
+
|
|
323
|
+
### Regenerating Icons
|
|
324
|
+
|
|
325
|
+
If you update the SVGs in `src/lib/components/icons/svg/`, regenerate with:
|
|
41
326
|
|
|
42
327
|
```bash
|
|
43
|
-
npm run
|
|
328
|
+
npm run generate:icons
|
|
44
329
|
```
|
|
45
330
|
|
|
46
|
-
|
|
331
|
+
## Toggle
|
|
47
332
|
|
|
48
|
-
|
|
333
|
+
A toggle switch component with hidden form input:
|
|
49
334
|
|
|
50
|
-
|
|
335
|
+
```svelte
|
|
336
|
+
<script>
|
|
337
|
+
import { Toggle } from '@joyautomation/salt'
|
|
338
|
+
</script>
|
|
51
339
|
|
|
52
|
-
|
|
340
|
+
<Toggle id="notifications" name="notifications" checked={true} />
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Props:
|
|
344
|
+
- `id` — input ID
|
|
345
|
+
- `name` — form field name
|
|
346
|
+
- `checked` — boolean state
|
|
347
|
+
- `buttonType` — `'button'` (default) or `'submit'`
|
|
348
|
+
- `selector` / `selectorName` — optional additional hidden input
|
|
53
349
|
|
|
54
|
-
|
|
350
|
+
## Development
|
|
55
351
|
|
|
56
352
|
```bash
|
|
57
|
-
|
|
353
|
+
pnpm install
|
|
354
|
+
pnpm dev # starts dev server on port 3014
|
|
355
|
+
pnpm run check # type check
|
|
356
|
+
pnpm run package # build the library
|
|
58
357
|
```
|
|
358
|
+
|
|
359
|
+
## Publishing
|
|
360
|
+
|
|
361
|
+
Create a [GitHub release](https://github.com/joyautomation/salt/releases) with a tag like `v0.2.0`. The CI workflow will automatically set the version, build, and publish to npm.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { enhance } from '$app/forms'
|
|
3
|
-
import Input from './Input.svelte'
|
|
4
|
-
import Select from './Select.svelte'
|
|
5
|
-
import type { FormInputs } from './types.js'
|
|
6
|
-
import { slide } from 'svelte/transition'
|
|
2
|
+
import { enhance } from '$app/forms'
|
|
3
|
+
import Input from './Input.svelte'
|
|
4
|
+
import Select from './Select.svelte'
|
|
5
|
+
import type { FormInputs } from './types.js'
|
|
6
|
+
import { slide } from 'svelte/transition'
|
|
7
7
|
|
|
8
8
|
const {
|
|
9
9
|
inputs: propInputs,
|
|
@@ -12,29 +12,29 @@
|
|
|
12
12
|
onsubmitstart,
|
|
13
13
|
onsubmitend
|
|
14
14
|
}: {
|
|
15
|
-
inputs: FormInputs
|
|
16
|
-
action: string
|
|
17
|
-
buttonText?: string
|
|
18
|
-
onsubmitstart?: () => void
|
|
19
|
-
onsubmitend?: () => void
|
|
20
|
-
} = $props()
|
|
15
|
+
inputs: FormInputs
|
|
16
|
+
action: string
|
|
17
|
+
buttonText?: string
|
|
18
|
+
onsubmitstart?: () => void
|
|
19
|
+
onsubmitend?: () => void
|
|
20
|
+
} = $props()
|
|
21
21
|
|
|
22
|
-
let inputs = $state(propInputs)
|
|
23
|
-
let submitting = $state(false)
|
|
22
|
+
let inputs = $state(propInputs)
|
|
23
|
+
let submitting = $state(false)
|
|
24
24
|
|
|
25
25
|
$effect(() => {
|
|
26
|
-
inputs = propInputs
|
|
27
|
-
})
|
|
26
|
+
inputs = propInputs
|
|
27
|
+
})
|
|
28
28
|
|
|
29
29
|
const valid = $derived(
|
|
30
30
|
inputs.every((row) =>
|
|
31
31
|
row.every((input) => {
|
|
32
|
-
return input.validations.every(([validation
|
|
33
|
-
return !validation(input.value, inputs)
|
|
34
|
-
})
|
|
32
|
+
return input.validations.every(([validation]) => {
|
|
33
|
+
return !validation(input.value, inputs)
|
|
34
|
+
})
|
|
35
35
|
})
|
|
36
36
|
)
|
|
37
|
-
)
|
|
37
|
+
)
|
|
38
38
|
</script>
|
|
39
39
|
|
|
40
40
|
<form
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
method="post"
|
|
43
43
|
{action}
|
|
44
44
|
use:enhance={() => {
|
|
45
|
-
submitting = true
|
|
46
|
-
onsubmitstart?.()
|
|
45
|
+
submitting = true
|
|
46
|
+
onsubmitstart?.()
|
|
47
47
|
return async ({ update }) => {
|
|
48
|
-
submitting = false
|
|
49
|
-
onsubmitend?.()
|
|
50
|
-
update({ reset: false })
|
|
51
|
-
}
|
|
48
|
+
submitting = false
|
|
49
|
+
onsubmitend?.()
|
|
50
|
+
update({ reset: false })
|
|
51
|
+
}
|
|
52
52
|
}}
|
|
53
53
|
>
|
|
54
54
|
{#each inputs as row}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { slide } from 'svelte/transition'
|
|
3
|
-
import type { FormInputs, InputProps } from './types.js'
|
|
2
|
+
import { slide } from 'svelte/transition'
|
|
3
|
+
import type { FormInputs, InputProps } from './types.js'
|
|
4
4
|
|
|
5
5
|
let {
|
|
6
6
|
id,
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
inputs,
|
|
14
14
|
touched = false,
|
|
15
15
|
onblur = () => {}
|
|
16
|
-
}: InputProps & { inputs?: FormInputs; touched?: boolean; onblur?: () => void } = $props()
|
|
16
|
+
}: InputProps & { inputs?: FormInputs; touched?: boolean; onblur?: () => void } = $props()
|
|
17
17
|
const validationResult = $derived(
|
|
18
|
-
validations.find(([validation
|
|
19
|
-
return validation(value, inputs ?? [])
|
|
18
|
+
validations.find(([validation]) => {
|
|
19
|
+
return validation(value, inputs ?? [])
|
|
20
20
|
})?.[1] || null
|
|
21
|
-
)
|
|
21
|
+
)
|
|
22
22
|
</script>
|
|
23
23
|
|
|
24
24
|
<div class="input" class:input--invalid={touched && validationResult != null}>
|
|
@@ -1,121 +1,121 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import ChevronDown from '../icons/outline/ChevronDown.svelte'
|
|
2
|
+
import ChevronDown from '../icons/outline/ChevronDown.svelte'
|
|
3
3
|
|
|
4
4
|
type Option = {
|
|
5
|
-
value: string
|
|
6
|
-
label: string
|
|
7
|
-
sublabel?: string
|
|
8
|
-
}
|
|
5
|
+
value: string
|
|
6
|
+
label: string
|
|
7
|
+
sublabel?: string
|
|
8
|
+
}
|
|
9
9
|
|
|
10
10
|
type Props = {
|
|
11
|
-
options: Option[]
|
|
12
|
-
placeholder?: string
|
|
13
|
-
onSelect: (option: Option) => void
|
|
14
|
-
disabled?: boolean
|
|
15
|
-
}
|
|
11
|
+
options: Option[]
|
|
12
|
+
placeholder?: string
|
|
13
|
+
onSelect: (option: Option) => void
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}
|
|
16
16
|
|
|
17
|
-
let { options, placeholder = 'Select...', onSelect, disabled = false }: Props = $props()
|
|
17
|
+
let { options, placeholder = 'Select...', onSelect, disabled = false }: Props = $props()
|
|
18
18
|
|
|
19
|
-
let isOpen = $state(false)
|
|
20
|
-
let searchQuery = $state('')
|
|
21
|
-
let highlightedIndex = $state(-1)
|
|
22
|
-
let inputRef: HTMLInputElement | undefined = $state()
|
|
23
|
-
let dropdownRef: HTMLDivElement | undefined = $state()
|
|
19
|
+
let isOpen = $state(false)
|
|
20
|
+
let searchQuery = $state('')
|
|
21
|
+
let highlightedIndex = $state(-1)
|
|
22
|
+
let inputRef: HTMLInputElement | undefined = $state()
|
|
23
|
+
let dropdownRef: HTMLDivElement | undefined = $state()
|
|
24
24
|
|
|
25
25
|
let filteredOptions = $derived(
|
|
26
26
|
searchQuery.trim() === ''
|
|
27
27
|
? options
|
|
28
28
|
: options.filter((opt) => {
|
|
29
|
-
const query = searchQuery.toLowerCase()
|
|
29
|
+
const query = searchQuery.toLowerCase()
|
|
30
30
|
return (
|
|
31
31
|
opt.label.toLowerCase().includes(query) ||
|
|
32
32
|
(opt.sublabel && opt.sublabel.toLowerCase().includes(query))
|
|
33
|
-
)
|
|
33
|
+
)
|
|
34
34
|
})
|
|
35
|
-
)
|
|
35
|
+
)
|
|
36
36
|
|
|
37
37
|
function openDropdown() {
|
|
38
|
-
if (disabled) return
|
|
39
|
-
isOpen = true
|
|
40
|
-
highlightedIndex = -1
|
|
41
|
-
setTimeout(() => inputRef?.focus(), 0)
|
|
38
|
+
if (disabled) return
|
|
39
|
+
isOpen = true
|
|
40
|
+
highlightedIndex = -1
|
|
41
|
+
setTimeout(() => inputRef?.focus(), 0)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function closeDropdown() {
|
|
45
|
-
isOpen = false
|
|
46
|
-
searchQuery = ''
|
|
47
|
-
highlightedIndex = -1
|
|
45
|
+
isOpen = false
|
|
46
|
+
searchQuery = ''
|
|
47
|
+
highlightedIndex = -1
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function selectOption(option: Option) {
|
|
51
|
-
onSelect(option)
|
|
52
|
-
closeDropdown()
|
|
51
|
+
onSelect(option)
|
|
52
|
+
closeDropdown()
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function handleKeydown(e: KeyboardEvent) {
|
|
56
56
|
if (!isOpen) {
|
|
57
57
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
58
|
-
e.preventDefault()
|
|
59
|
-
openDropdown()
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
openDropdown()
|
|
60
60
|
}
|
|
61
|
-
return
|
|
61
|
+
return
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
switch (e.key) {
|
|
65
65
|
case 'ArrowDown':
|
|
66
|
-
e.preventDefault()
|
|
67
|
-
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1)
|
|
68
|
-
scrollToHighlighted()
|
|
69
|
-
break
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1)
|
|
68
|
+
scrollToHighlighted()
|
|
69
|
+
break
|
|
70
70
|
case 'ArrowUp':
|
|
71
|
-
e.preventDefault()
|
|
72
|
-
highlightedIndex = Math.max(highlightedIndex - 1, 0)
|
|
73
|
-
scrollToHighlighted()
|
|
74
|
-
break
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
highlightedIndex = Math.max(highlightedIndex - 1, 0)
|
|
73
|
+
scrollToHighlighted()
|
|
74
|
+
break
|
|
75
75
|
case 'Enter':
|
|
76
|
-
e.preventDefault()
|
|
76
|
+
e.preventDefault()
|
|
77
77
|
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
|
78
|
-
selectOption(filteredOptions[highlightedIndex])
|
|
78
|
+
selectOption(filteredOptions[highlightedIndex])
|
|
79
79
|
}
|
|
80
|
-
break
|
|
80
|
+
break
|
|
81
81
|
case 'Escape':
|
|
82
|
-
e.preventDefault()
|
|
83
|
-
closeDropdown()
|
|
84
|
-
break
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
closeDropdown()
|
|
84
|
+
break
|
|
85
85
|
case 'Tab':
|
|
86
|
-
closeDropdown()
|
|
87
|
-
break
|
|
86
|
+
closeDropdown()
|
|
87
|
+
break
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function scrollToHighlighted() {
|
|
92
|
-
if (!dropdownRef || highlightedIndex < 0) return
|
|
93
|
-
const items = dropdownRef.querySelectorAll('.searchable-select__option')
|
|
94
|
-
const item = items[highlightedIndex] as HTMLElement
|
|
92
|
+
if (!dropdownRef || highlightedIndex < 0) return
|
|
93
|
+
const items = dropdownRef.querySelectorAll('.searchable-select__option')
|
|
94
|
+
const item = items[highlightedIndex] as HTMLElement
|
|
95
95
|
if (item) {
|
|
96
|
-
item.scrollIntoView({ block: 'nearest' })
|
|
96
|
+
item.scrollIntoView({ block: 'nearest' })
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function handleClickOutside(e: MouseEvent) {
|
|
101
|
-
const target = e.target as Node
|
|
101
|
+
const target = e.target as Node
|
|
102
102
|
if (dropdownRef && !dropdownRef.contains(target)) {
|
|
103
|
-
closeDropdown()
|
|
103
|
+
closeDropdown()
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
$effect(() => {
|
|
108
108
|
if (isOpen) {
|
|
109
|
-
document.addEventListener('click', handleClickOutside, true)
|
|
110
|
-
return () => document.removeEventListener('click', handleClickOutside, true)
|
|
109
|
+
document.addEventListener('click', handleClickOutside, true)
|
|
110
|
+
return () => document.removeEventListener('click', handleClickOutside, true)
|
|
111
111
|
}
|
|
112
|
-
})
|
|
112
|
+
})
|
|
113
113
|
|
|
114
114
|
$effect(() => {
|
|
115
115
|
if (searchQuery) {
|
|
116
|
-
highlightedIndex = filteredOptions.length > 0 ? 0 : -1
|
|
116
|
+
highlightedIndex = filteredOptions.length > 0 ? 0 : -1
|
|
117
117
|
}
|
|
118
|
-
})
|
|
118
|
+
})
|
|
119
119
|
</script>
|
|
120
120
|
|
|
121
121
|
<div class="searchable-select" class:searchable-select--disabled={disabled}>
|