sv5ui 1.5.0 → 1.6.0
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 +60 -183
- package/dist/Checkbox/Checkbox.svelte +8 -2
- package/dist/CheckboxGroup/CheckboxGroup.svelte +15 -2
- package/dist/Form/Form.svelte +203 -0
- package/dist/Form/Form.svelte.d.ts +26 -0
- package/dist/Form/form.context.svelte.d.ts +64 -0
- package/dist/Form/form.context.svelte.js +478 -0
- package/dist/Form/form.types.d.ts +164 -0
- package/dist/Form/form.types.js +12 -0
- package/dist/Form/form.variants.d.ts +39 -0
- package/dist/Form/form.variants.js +17 -0
- package/dist/Form/index.d.ts +4 -0
- package/dist/Form/index.js +6 -0
- package/dist/Form/validate-schema.d.ts +13 -0
- package/dist/Form/validate-schema.js +113 -0
- package/dist/FormField/FormField.svelte +71 -8
- package/dist/FormField/form-field.types.d.ts +15 -0
- package/dist/Input/Input.svelte +31 -5
- package/dist/Input/Input.svelte.d.ts +25 -4
- package/dist/Input/input.types.d.ts +24 -3
- package/dist/PinInput/PinInput.svelte +9 -2
- package/dist/RadioGroup/RadioGroup.svelte +17 -3
- package/dist/Select/Select.svelte +14 -3
- package/dist/SelectMenu/SelectMenu.svelte +9 -2
- package/dist/Slider/Slider.svelte +4 -1
- package/dist/Switch/Switch.svelte +8 -2
- package/dist/Table/Table.svelte +11 -0
- package/dist/Table/table.types.d.ts +3 -0
- package/dist/Table/table.variants.js +5 -5
- package/dist/Textarea/Textarea.svelte +27 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/useFormField.svelte.d.ts +64 -0
- package/dist/hooks/useFormField.svelte.js +70 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +26 -3
package/README.md
CHANGED
|
@@ -1,240 +1,117 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://img.shields.io/badge/
|
|
2
|
+
<img src="https://img.shields.io/badge/SV5UI-ff3e00?style=for-the-badge&logo=svelte&logoColor=white" alt="SV5UI" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">SV5UI</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
<strong>Modern UI component library for Svelte 5</strong><br/>
|
|
9
|
+
Tailwind CSS 4 · OKLCH Color Tokens · Fully Typed · 50+ Components · 7 Hooks
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
|
-
<a href="https://
|
|
13
|
+
<a href="https://www.npmjs.com/package/sv5ui"><img src="https://img.shields.io/npm/v/sv5ui?style=flat-square&colorA=18181b&colorB=ff3e00" alt="npm" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/sv5ui"><img src="https://img.shields.io/npm/dm/sv5ui?style=flat-square&colorA=18181b&colorB=ff3e00" alt="downloads" /></a>
|
|
15
|
+
<a href="https://github.com/ndlabdev/sv5ui/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/sv5ui?style=flat-square&colorA=18181b&colorB=ff3e00" alt="license" /></a>
|
|
14
16
|
</p>
|
|
15
17
|
|
|
16
18
|
<p align="center">
|
|
17
|
-
<a href="https://
|
|
18
|
-
<a href="https://
|
|
19
|
-
<a href="https://
|
|
20
|
-
<a href="https://svelte.dev"><img src="https://img.shields.io/badge/svelte-5-ff3e00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" /></a>
|
|
21
|
-
<a href="https://www.typescriptlang.org"><img src="https://img.shields.io/badge/types-TypeScript-blue?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
|
19
|
+
<a href="https://sv5ui.vercel.app"><strong>Live Demo</strong></a> ·
|
|
20
|
+
<a href="https://sv5ui.vercel.app/getting-started"><strong>Getting Started</strong></a> ·
|
|
21
|
+
<a href="https://github.com/ndlabdev/sv5ui/blob/main/CHANGELOG.md"><strong>Changelog</strong></a>
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
##
|
|
27
|
-
|
|
28
|
-
- **Svelte 5** — Built with runes, snippets, and the latest reactivity model
|
|
29
|
-
- **Tailwind CSS 4** — Utility-first styling with [Tailwind Variants](https://www.tailwind-variants.org/) for type-safe, composable variants
|
|
30
|
-
- **Fully Typed** — Strict TypeScript with exported prop types for every component
|
|
31
|
-
- **Accessible** — Built on [Bits UI](https://bits-ui.com) and [Vaul Svelte](https://vaul-svelte.com) headless primitives
|
|
32
|
-
- **200,000+ Icons** — First-class [Iconify](https://iconify.design) integration
|
|
33
|
-
- **Customizable** — Global config system + per-instance slot overrides
|
|
34
|
-
|
|
35
|
-
## Installation
|
|
26
|
+
## Install
|
|
36
27
|
|
|
37
28
|
```bash
|
|
38
29
|
npm install sv5ui
|
|
39
|
-
#
|
|
40
|
-
|
|
30
|
+
# pnpm add sv5ui
|
|
31
|
+
# yarn add sv5ui
|
|
32
|
+
# bun add sv5ui
|
|
41
33
|
```
|
|
42
34
|
|
|
43
|
-
**Peer dependencies:** `svelte >= 5.0.0`, `tailwindcss >= 4.0.0`
|
|
44
|
-
|
|
45
|
-
## Quick Start
|
|
46
|
-
|
|
47
|
-
**1. Import the theme**
|
|
48
|
-
|
|
49
35
|
```css
|
|
50
36
|
/* layout.css */
|
|
51
37
|
@import 'sv5ui/theme.css';
|
|
52
38
|
```
|
|
53
39
|
|
|
54
|
-
**2. Use components**
|
|
55
|
-
|
|
56
40
|
```svelte
|
|
57
41
|
<script>
|
|
58
|
-
import { Button, Avatar,
|
|
42
|
+
import { Button, Avatar, toast } from 'sv5ui'
|
|
59
43
|
</script>
|
|
60
44
|
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
</Tooltip>
|
|
64
|
-
|
|
65
|
-
<Avatar src="/photo.jpg" alt="Jane Doe" size="lg" />
|
|
66
|
-
<Badge label="Online" color="success" variant="soft" />
|
|
45
|
+
<Button variant="soft" color="primary" leadingIcon="lucide:edit">Edit</Button>
|
|
46
|
+
<Avatar src="/photo.jpg" alt="Jane" size="lg" />
|
|
67
47
|
```
|
|
68
48
|
|
|
69
|
-
##
|
|
70
|
-
|
|
71
|
-
### General
|
|
72
|
-
|
|
73
|
-
| Component | Description |
|
|
74
|
-
| :--------------------------------------------- | :--------------------------------------------------------------------------------- |
|
|
75
|
-
| [**Button**](src/lib/Button) | Versatile button with 6 variants, loading state, icons, and avatar support |
|
|
76
|
-
| [**Link**](src/lib/Link) | Smart anchor with automatic active-state detection based on current route |
|
|
77
|
-
| [**Icon**](src/lib/Icon) | Iconify wrapper — render any of 200,000+ icons by name |
|
|
78
|
-
| [**Kbd**](src/lib/Kbd) | Keyboard shortcut display with OS-aware symbol mapping |
|
|
79
|
-
| [**ThemeModeButton**](src/lib/ThemeModeButton) | Light/dark mode toggle button with customizable icons and mode-watcher integration |
|
|
80
|
-
|
|
81
|
-
### Layout
|
|
82
|
-
|
|
83
|
-
| Component | Description |
|
|
84
|
-
| :--------------------------------- | :--------------------------------------------------------------- |
|
|
85
|
-
| [**Card**](src/lib/Card) | Content container with header, body, and footer slots |
|
|
86
|
-
| [**Container**](src/lib/Container) | Responsive max-width wrapper for page content |
|
|
87
|
-
| [**Separator**](src/lib/Separator) | Horizontal/vertical divider with optional label, icon, or avatar |
|
|
88
|
-
|
|
89
|
-
### Data Display
|
|
90
|
-
|
|
91
|
-
| Component | Description |
|
|
92
|
-
| :------------------------------------- | :--------------------------------------------------------------------- |
|
|
93
|
-
| [**Avatar**](src/lib/Avatar) | Profile image with auto-generated initials fallback |
|
|
94
|
-
| [**AvatarGroup**](src/lib/AvatarGroup) | Stacked avatars with overflow count |
|
|
95
|
-
| [**Badge**](src/lib/Badge) | Status indicators and tags in 4 variants and 8 colors |
|
|
96
|
-
| [**Chip**](src/lib/Chip) | Notification dot indicator with configurable positioning |
|
|
97
|
-
| [**User**](src/lib/User) | User profile display combining avatar, name, and description |
|
|
98
|
-
| [**Timeline**](src/lib/Timeline) | Step/sequence visualization with completed, active, and pending states |
|
|
99
|
-
| [**Skeleton**](src/lib/Skeleton) | Animated loading placeholder |
|
|
100
|
-
| [**Empty**](src/lib/Empty) | Empty state with icon, description, and action slots |
|
|
101
|
-
|
|
102
|
-
### Feedback
|
|
103
|
-
|
|
104
|
-
| Component | Description |
|
|
105
|
-
| :------------------------------- | :------------------------------------------------------------------- |
|
|
106
|
-
| [**Alert**](src/lib/Alert) | Notification banner with icon, actions, and dismissible support |
|
|
107
|
-
| [**Progress**](src/lib/Progress) | Determinate/indeterminate progress bar with step mode and animations |
|
|
108
|
-
|
|
109
|
-
### Navigation
|
|
110
|
-
|
|
111
|
-
| Component | Description |
|
|
112
|
-
| :----------------------------------- | :----------------------------------------------------------------------------- |
|
|
113
|
-
| [**Breadcrumb**](src/lib/Breadcrumb) | Hierarchical navigation trail with icons, custom separators, and snippet slots |
|
|
114
|
-
| [**Tabs**](src/lib/Tabs) | Tabbed interface with content panels and configurable orientation |
|
|
115
|
-
| [**Pagination**](src/lib/Pagination) | Page navigation with first/prev/next/last controls and ellipsis |
|
|
116
|
-
|
|
117
|
-
### Overlay
|
|
118
|
-
|
|
119
|
-
| Component | Description |
|
|
120
|
-
| :--------------------------------------- | :------------------------------------------------------------------------ |
|
|
121
|
-
| [**Modal**](src/lib/Modal) | Accessible dialog with overlay, focus trap, and scroll lock |
|
|
122
|
-
| [**Slideover**](src/lib/Slideover) | Side panel sliding from any edge with inset mode |
|
|
123
|
-
| [**Drawer**](src/lib/Drawer) | Draggable bottom/side sheet with snap points |
|
|
124
|
-
| [**Tooltip**](src/lib/Tooltip) | Hover tooltip with arrow, keyboard shortcut display, and portal rendering |
|
|
125
|
-
| [**Popover**](src/lib/Popover) | Floating interactive content panel with focus management |
|
|
126
|
-
| [**Accordion**](src/lib/Accordion) | Expandable sections with single or multiple open modes |
|
|
127
|
-
| [**DropdownMenu**](src/lib/DropdownMenu) | Triggered floating menu with items, groups, separators, and sub-menus |
|
|
128
|
-
| [**ContextMenu**](src/lib/ContextMenu) | Right-click context menu with items, colors, and keyboard navigation |
|
|
129
|
-
|
|
130
|
-
### Form
|
|
131
|
-
|
|
132
|
-
| Component | Description |
|
|
133
|
-
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------ |
|
|
134
|
-
| [**Input**](src/lib/Input) | Text input with 5 variants, icons, avatar, loading state, and FormField integration |
|
|
135
|
-
| [**Textarea**](src/lib/Textarea) | Multi-line text input with 5 variants, icons, autoresize with maxrows, and FormField integration |
|
|
136
|
-
| [**Select**](src/lib/Select) | Dropdown select with 5 variants, icons, avatars, groups, descriptions, and FormField support |
|
|
137
|
-
| [**SelectMenu**](src/lib/SelectMenu) | Searchable multi-select menu with chips, groups, and FormField integration |
|
|
138
|
-
| [**Switch**](src/lib/Switch) | Toggle switch with 8 colors, 5 sizes, checked/unchecked icons, loading state, and FormField integration |
|
|
139
|
-
| [**Checkbox**](src/lib/Checkbox) | Checkbox with 8 colors, 5 sizes, indeterminate state, custom icons, and FormField integration |
|
|
140
|
-
| [**CheckboxGroup**](src/lib/CheckboxGroup) | Grouped checkboxes with single/multiple selection, per-item disabled, and FormField integration |
|
|
141
|
-
| [**RadioGroup**](src/lib/RadioGroup) | Radio group for single-selection with items API, legend, orientation, and FormField integration |
|
|
142
|
-
| [**Slider**](src/lib/Slider) | Range slider with single/range values, step, orientation, tooltip labels, and FormField integration |
|
|
143
|
-
| [**PinInput**](src/lib/PinInput) | PIN/OTP input with masking, numeric filtering, OTP autocomplete, and FormField integration |
|
|
144
|
-
| [**FileUpload**](src/lib/FileUpload) | Drag-and-drop file upload with preview list, image thumbnails, accept filter, and multiple files |
|
|
145
|
-
| [**FormField**](src/lib/FormField) | Form control wrapper providing label, description, hint, help, and error handling |
|
|
146
|
-
| [**FieldGroup**](src/lib/FieldGroup) | Groups buttons and inputs with seamless borders and shared size/orientation context |
|
|
147
|
-
| [**Calendar**](src/lib/Calendar) | Date picker calendar with single, multiple, and range selection modes |
|
|
148
|
-
|
|
149
|
-
## Theming
|
|
150
|
-
|
|
151
|
-
SV5UI uses **OKLCH color space** with semantic tokens. Light and dark modes work out of the box.
|
|
152
|
-
|
|
153
|
-
### Color Tokens
|
|
154
|
-
|
|
155
|
-
Each color provides a set of related tokens for surfaces, text, and containers:
|
|
49
|
+
## Features
|
|
156
50
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
51
|
+
- **Svelte 5** — Runes, snippets, latest reactivity
|
|
52
|
+
- **Tailwind CSS 4** — Utility-first with [Tailwind Variants](https://www.tailwind-variants.org/)
|
|
53
|
+
- **Fully Typed** — Strict TypeScript, exported prop types
|
|
54
|
+
- **Accessible** — Built on [Bits UI](https://bits-ui.com) & [Vaul Svelte](https://vaul-svelte.com)
|
|
55
|
+
- **200,000+ Icons** — [Iconify](https://iconify.design) integration
|
|
56
|
+
- **Themeable** — OKLCH color tokens, light/dark mode, global config
|
|
57
|
+
- **Hooks** — 7 reactive hooks for common UI patterns
|
|
163
58
|
|
|
164
|
-
|
|
59
|
+
## Hooks
|
|
165
60
|
|
|
166
|
-
|
|
61
|
+
Reactive hooks built on Svelte 5 runes and actions.
|
|
167
62
|
|
|
168
|
-
|
|
63
|
+
```svelte
|
|
64
|
+
<script>
|
|
65
|
+
import { useMediaQuery, useClipboard, useDebounce } from 'sv5ui'
|
|
169
66
|
|
|
170
|
-
|
|
67
|
+
const isMobile = useMediaQuery('(max-width: 640px)')
|
|
68
|
+
const clipboard = useClipboard()
|
|
69
|
+
const debounce = useDebounce({ delay: 500 })
|
|
70
|
+
</script>
|
|
171
71
|
|
|
172
|
-
|
|
72
|
+
{#if isMobile.matches}
|
|
73
|
+
<MobileLayout />
|
|
74
|
+
{/if}
|
|
173
75
|
|
|
174
|
-
|
|
175
|
-
:
|
|
176
|
-
|
|
177
|
-
--color-secondary: oklch(0.45 0.15 240);
|
|
178
|
-
}
|
|
76
|
+
<Button onclick={() => clipboard.copy('Hello!')}>
|
|
77
|
+
{clipboard.copied ? 'Copied!' : 'Copy'}
|
|
78
|
+
</Button>
|
|
179
79
|
```
|
|
180
80
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
81
|
+
| Hook | Type | Description |
|
|
82
|
+
| ------------------- | -------------- | ---------------------------------------------- |
|
|
83
|
+
| `useMediaQuery` | Runes | Reactive CSS media query tracking |
|
|
84
|
+
| `useClipboard` | Runes | Copy text with auto-reset state |
|
|
85
|
+
| `useFormField` | Context | Access FormField context from child components |
|
|
86
|
+
| `useDebounce` | Runes | Debounce with pending, cancel, flush |
|
|
87
|
+
| `useClickOutside` | Action | Detect clicks outside an element |
|
|
88
|
+
| `useInfiniteScroll` | Runes + Action | Auto-load on scroll with loading state |
|
|
89
|
+
| `useEscapeKeydown` | Action | Listen for Escape key |
|
|
184
90
|
|
|
185
|
-
|
|
91
|
+
## Customization
|
|
186
92
|
|
|
187
93
|
```svelte
|
|
188
|
-
|
|
94
|
+
<!-- Per-instance -->
|
|
95
|
+
<Button ui={{ base: 'rounded-full shadow-lg' }}>Custom</Button>
|
|
189
96
|
```
|
|
190
97
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
Set library-wide defaults for variants, slots, and icons:
|
|
194
|
-
|
|
195
|
-
```typescript
|
|
98
|
+
```ts
|
|
99
|
+
// Global defaults
|
|
196
100
|
import { defineConfig } from 'sv5ui'
|
|
197
101
|
|
|
198
102
|
defineConfig({
|
|
199
|
-
button: {
|
|
200
|
-
|
|
201
|
-
slots: { base: 'shadow-md' }
|
|
202
|
-
},
|
|
203
|
-
avatar: {
|
|
204
|
-
defaultVariants: { size: 'lg' },
|
|
205
|
-
slots: { root: 'ring-2 ring-primary' }
|
|
206
|
-
},
|
|
207
|
-
icons: {
|
|
208
|
-
loading: 'lucide:loader',
|
|
209
|
-
close: 'lucide:x'
|
|
210
|
-
}
|
|
103
|
+
button: { defaultVariants: { variant: 'outline' } },
|
|
104
|
+
icons: { loading: 'lucide:loader' }
|
|
211
105
|
})
|
|
212
106
|
```
|
|
213
107
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
pnpm test # Run unit tests
|
|
220
|
-
pnpm check # TypeScript check
|
|
221
|
-
pnpm prepack # Build library package
|
|
222
|
-
pnpm lint # Lint
|
|
223
|
-
pnpm format # Format code
|
|
108
|
+
```css
|
|
109
|
+
/* Custom colors */
|
|
110
|
+
:root {
|
|
111
|
+
--color-primary: oklch(0.55 0.25 270);
|
|
112
|
+
}
|
|
224
113
|
```
|
|
225
114
|
|
|
226
|
-
> Run `pnpm dev` and open [localhost:5173](http://localhost:5173) to browse the interactive component demos.
|
|
227
|
-
|
|
228
|
-
## Tech Stack
|
|
229
|
-
|
|
230
|
-
| Layer | Technology |
|
|
231
|
-
| :--------- | :-------------------------------------------------------------------------------------------------- |
|
|
232
|
-
| Framework | [Svelte 5](https://svelte.dev) + [SvelteKit](https://svelte.dev/docs/kit) |
|
|
233
|
-
| Styling | [Tailwind CSS 4](https://tailwindcss.com) + [Tailwind Variants](https://www.tailwind-variants.org/) |
|
|
234
|
-
| Primitives | [Bits UI](https://bits-ui.com) · [Vaul Svelte](https://vaul-svelte.com) |
|
|
235
|
-
| Icons | [Iconify](https://iconify.design) (200,000+ icons) |
|
|
236
|
-
| Testing | [Vitest](https://vitest.dev) + [Playwright](https://playwright.dev) |
|
|
237
|
-
|
|
238
115
|
## License
|
|
239
116
|
|
|
240
117
|
[MIT](LICENSE) © [ndlabdev](https://github.com/ndlabdev)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { checkboxVariants, checkboxDefaults } from './checkbox.variants.js'
|
|
10
10
|
import { getComponentConfig, iconsDefaults } from '../config.js'
|
|
11
11
|
import Icon from '../Icon/Icon.svelte'
|
|
12
|
-
import { useFormField } from '../hooks/useFormField.svelte.js'
|
|
12
|
+
import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
|
|
13
13
|
|
|
14
14
|
const config = getComponentConfig('checkbox', checkboxDefaults)
|
|
15
15
|
const icons = getComponentConfig('icons', iconsDefaults)
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
}: Props = $props()
|
|
44
44
|
|
|
45
45
|
const formFieldContext = useFormField()
|
|
46
|
+
const emit = useFormFieldEmit()
|
|
46
47
|
|
|
47
48
|
const hasError = $derived(
|
|
48
49
|
formFieldContext?.error !== undefined && formFieldContext?.error !== false
|
|
@@ -107,9 +108,14 @@
|
|
|
107
108
|
<div bind:this={containerRef} class={classes.container}>
|
|
108
109
|
<Checkbox.Root
|
|
109
110
|
bind:checked
|
|
110
|
-
{
|
|
111
|
+
onCheckedChange={(val) => {
|
|
112
|
+
emit.onChange()
|
|
113
|
+
onCheckedChange?.(val)
|
|
114
|
+
}}
|
|
111
115
|
bind:indeterminate
|
|
112
116
|
{onIndeterminateChange}
|
|
117
|
+
onblur={() => emit.onBlur()}
|
|
118
|
+
onfocus={() => emit.onFocus()}
|
|
113
119
|
id={resolvedId}
|
|
114
120
|
name={resolvedName}
|
|
115
121
|
{value}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { checkboxGroupVariants, checkboxGroupDefaults } from './checkbox-group.variants.js'
|
|
10
10
|
import { getComponentConfig, iconsDefaults } from '../config.js'
|
|
11
11
|
import Icon from '../Icon/Icon.svelte'
|
|
12
|
-
import { useFormField } from '../hooks/useFormField.svelte.js'
|
|
12
|
+
import { useFormField, useFormFieldEmit } from '../hooks/useFormField.svelte.js'
|
|
13
13
|
import type { CheckboxGroupItem } from './checkbox-group.types.js'
|
|
14
14
|
|
|
15
15
|
const config = getComponentConfig('checkboxGroup', checkboxGroupDefaults)
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
}: Props = $props()
|
|
43
43
|
|
|
44
44
|
const formFieldContext = useFormField()
|
|
45
|
+
const emit = useFormFieldEmit()
|
|
45
46
|
|
|
46
47
|
const hasError = $derived(
|
|
47
48
|
formFieldContext?.error !== undefined && formFieldContext?.error !== false
|
|
@@ -97,6 +98,7 @@
|
|
|
97
98
|
} else {
|
|
98
99
|
value = value.filter((v) => v !== itemValue)
|
|
99
100
|
}
|
|
101
|
+
emit.onChange()
|
|
100
102
|
onValueChange?.(value)
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -169,7 +171,18 @@
|
|
|
169
171
|
{/snippet}
|
|
170
172
|
|
|
171
173
|
<div {...restProps} bind:this={ref} class={layoutClasses.root}>
|
|
172
|
-
<fieldset
|
|
174
|
+
<fieldset
|
|
175
|
+
class={layoutClasses.fieldset}
|
|
176
|
+
aria-describedby={ariaDescribedBy}
|
|
177
|
+
onfocusin={() => emit.onFocus()}
|
|
178
|
+
onfocusout={(e) => {
|
|
179
|
+
// Only emit blur when focus leaves the fieldset entirely, not when
|
|
180
|
+
// moving between checkboxes within the group.
|
|
181
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
182
|
+
emit.onBlur()
|
|
183
|
+
}
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
173
186
|
{#if legend || legendSlot}
|
|
174
187
|
{#if legendSlot}
|
|
175
188
|
{@render legendSlot({ legend })}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { FormProps, FormSchema } from './form.types.js'
|
|
3
|
+
|
|
4
|
+
export type Props<
|
|
5
|
+
S extends FormSchema | undefined = FormSchema | undefined,
|
|
6
|
+
T extends boolean = true,
|
|
7
|
+
N extends boolean = false
|
|
8
|
+
> = FormProps<S, T, N>
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script
|
|
12
|
+
lang="ts"
|
|
13
|
+
generics="S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false"
|
|
14
|
+
>
|
|
15
|
+
import { setContext, onMount, onDestroy, untrack } from 'svelte'
|
|
16
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
17
|
+
import { formVariants, formDefaults } from './form.variants.js'
|
|
18
|
+
import { FormContext, FORM_CONTEXT_KEY, getFormContext } from './form.context.svelte.js'
|
|
19
|
+
import { getComponentConfig } from '../config.js'
|
|
20
|
+
import type { FormApi, FormErrorWithId } from './form.types.js'
|
|
21
|
+
|
|
22
|
+
const config = getComponentConfig('form', formDefaults)
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
ref = $bindable(null),
|
|
26
|
+
api = $bindable<FormApi<unknown> | undefined>(),
|
|
27
|
+
id,
|
|
28
|
+
schema,
|
|
29
|
+
state = $bindable({} as never),
|
|
30
|
+
validate: customValidate,
|
|
31
|
+
validateOn = ['input', 'blur', 'change'],
|
|
32
|
+
validateOnInputDelay = 300,
|
|
33
|
+
disabled = false,
|
|
34
|
+
loadingAuto = true,
|
|
35
|
+
transform = true as T,
|
|
36
|
+
nested = false as N,
|
|
37
|
+
name,
|
|
38
|
+
onsubmit,
|
|
39
|
+
onerror,
|
|
40
|
+
class: className,
|
|
41
|
+
ui,
|
|
42
|
+
children,
|
|
43
|
+
...restProps
|
|
44
|
+
}: Props<S, T, N> = $props()
|
|
45
|
+
|
|
46
|
+
// Generate a stable form id. Both `id` and `nested` are effectively init-only
|
|
47
|
+
// (they should not change over a form instance's lifetime), so `untrack`
|
|
48
|
+
// silences Svelte's state_referenced_locally warning.
|
|
49
|
+
const formId: string | number = untrack(
|
|
50
|
+
() =>
|
|
51
|
+
(id as string | number | undefined) ??
|
|
52
|
+
(typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
53
|
+
? crypto.randomUUID()
|
|
54
|
+
: `sv5ui-form-${Math.random().toString(36).slice(2)}`)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// Parent context captured once at init — `nested` is effectively immutable
|
|
58
|
+
// over a form instance's lifetime. We warn (dev-only) if it's mutated later.
|
|
59
|
+
const parentCtx = untrack(() => (nested ? getFormContext() : undefined))
|
|
60
|
+
const initialNested = untrack(() => nested)
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (nested !== initialNested) {
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.warn(
|
|
65
|
+
'[sv5ui Form] The `nested` prop was changed after mount. This is ' +
|
|
66
|
+
'not supported — nested/standalone status is fixed at mount time. ' +
|
|
67
|
+
'Unmount and remount the Form to toggle.'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Create the reactive form context. All config is passed via getter closures
|
|
73
|
+
// so that reactive prop changes are visible inside the class.
|
|
74
|
+
const ctx = new FormContext(
|
|
75
|
+
{
|
|
76
|
+
getState: () => state as unknown,
|
|
77
|
+
getSchema: () => schema as FormSchema | undefined,
|
|
78
|
+
getCustomValidate: () =>
|
|
79
|
+
customValidate as
|
|
80
|
+
| ((s: unknown) => ReturnType<NonNullable<typeof customValidate>>)
|
|
81
|
+
| undefined,
|
|
82
|
+
getValidateOn: () => validateOn,
|
|
83
|
+
getValidateOnInputDelay: () => validateOnInputDelay,
|
|
84
|
+
getDisabled: () => disabled,
|
|
85
|
+
getLoadingAuto: () => loadingAuto,
|
|
86
|
+
getTransform: () => transform,
|
|
87
|
+
getName: () => name as string | undefined,
|
|
88
|
+
getOnSubmit: () =>
|
|
89
|
+
onsubmit as
|
|
90
|
+
| ((
|
|
91
|
+
e: import('./form.types.js').FormSubmitEvent<unknown>
|
|
92
|
+
) => void | Promise<void>)
|
|
93
|
+
| undefined,
|
|
94
|
+
getOnError: () => onerror
|
|
95
|
+
},
|
|
96
|
+
formId,
|
|
97
|
+
parentCtx
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
setContext(FORM_CONTEXT_KEY, ctx)
|
|
101
|
+
|
|
102
|
+
// Build the bindable API object. Getters ensure reactive state reads stay live.
|
|
103
|
+
const apiObject: FormApi<unknown> = {
|
|
104
|
+
validate: (opts) => ctx.validate(opts) as Promise<unknown | false>,
|
|
105
|
+
submit: () => ctx.submit(),
|
|
106
|
+
clear: (name) => ctx.clear(name),
|
|
107
|
+
getErrors: (name) => ctx.getErrors(name),
|
|
108
|
+
setErrors: (errs, name) => ctx.setErrors(errs, name),
|
|
109
|
+
reset: () => ctx.reset(),
|
|
110
|
+
get errors() {
|
|
111
|
+
return ctx.errors
|
|
112
|
+
},
|
|
113
|
+
get loading() {
|
|
114
|
+
return ctx.loading
|
|
115
|
+
},
|
|
116
|
+
get disabled() {
|
|
117
|
+
return ctx.disabled
|
|
118
|
+
},
|
|
119
|
+
get dirty() {
|
|
120
|
+
return ctx.dirty
|
|
121
|
+
},
|
|
122
|
+
get dirtyFields() {
|
|
123
|
+
return ctx.dirtyFields as ReadonlySet<string>
|
|
124
|
+
},
|
|
125
|
+
get touchedFields() {
|
|
126
|
+
return ctx.touchedFields as ReadonlySet<string>
|
|
127
|
+
},
|
|
128
|
+
get blurredFields() {
|
|
129
|
+
return ctx.blurredFields as ReadonlySet<string>
|
|
130
|
+
},
|
|
131
|
+
get submitCount() {
|
|
132
|
+
return ctx.submitCount
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Sync the api object to the bindable prop once at setup time. `apiObject`
|
|
137
|
+
// is a stable reference built from getters, so there's nothing reactive to
|
|
138
|
+
// track — direct assignment is sufficient.
|
|
139
|
+
api = apiObject as typeof api
|
|
140
|
+
|
|
141
|
+
// Nested form attach/detach lifecycle.
|
|
142
|
+
onMount(() => {
|
|
143
|
+
if (parentCtx && nested) {
|
|
144
|
+
parentCtx.attachChild(formId, {
|
|
145
|
+
formId,
|
|
146
|
+
name: name as string | undefined,
|
|
147
|
+
validate: (opts) => ctx.validate(opts) as Promise<unknown | false>,
|
|
148
|
+
clear: (n) => ctx.clear(n),
|
|
149
|
+
reset: () => ctx.reset(),
|
|
150
|
+
setErrors: (errs, n) => ctx.setErrors(errs, n)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
onDestroy(() => {
|
|
156
|
+
if (parentCtx && nested) parentCtx.detachChild(formId)
|
|
157
|
+
ctx.dispose()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
161
|
+
event.preventDefault()
|
|
162
|
+
// Forward the real SubmitEvent so user handlers can read `submitter`,
|
|
163
|
+
// `target`, etc. When `api.submit()` is called programmatically, ctx.submit
|
|
164
|
+
// synthesizes a fresh event instead.
|
|
165
|
+
await ctx.submit(event)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `formVariants` has no variants (only a single `root` slot), so its
|
|
169
|
+
// output is stable — compute once at setup rather than via $derived.
|
|
170
|
+
const variantSlots = formVariants()
|
|
171
|
+
const classes = $derived({
|
|
172
|
+
root: variantSlots.root({
|
|
173
|
+
class: [config.slots.root, className, ui?.root]
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Slot props for children
|
|
178
|
+
const slotProps = $derived({
|
|
179
|
+
errors: ctx.errors as FormErrorWithId[],
|
|
180
|
+
loading: ctx.loading
|
|
181
|
+
})
|
|
182
|
+
</script>
|
|
183
|
+
|
|
184
|
+
{#if nested}
|
|
185
|
+
<div
|
|
186
|
+
{...restProps as unknown as HTMLAttributes<HTMLDivElement>}
|
|
187
|
+
bind:this={ref}
|
|
188
|
+
id={typeof id === 'number' ? String(id) : id}
|
|
189
|
+
class={classes.root}
|
|
190
|
+
>
|
|
191
|
+
{@render children?.(slotProps)}
|
|
192
|
+
</div>
|
|
193
|
+
{:else}
|
|
194
|
+
<form
|
|
195
|
+
{...restProps}
|
|
196
|
+
bind:this={ref}
|
|
197
|
+
id={typeof id === 'number' ? String(id) : id}
|
|
198
|
+
class={classes.root}
|
|
199
|
+
onsubmit={handleSubmit}
|
|
200
|
+
>
|
|
201
|
+
{@render children?.(slotProps)}
|
|
202
|
+
</form>
|
|
203
|
+
{/if}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FormProps, FormSchema } from './form.types.js';
|
|
2
|
+
export type Props<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> = FormProps<S, T, N>;
|
|
3
|
+
declare function $$render<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(): {
|
|
4
|
+
props: Props<S, T, N>;
|
|
5
|
+
exports: {};
|
|
6
|
+
bindings: "ref" | "api" | "state";
|
|
7
|
+
slots: {};
|
|
8
|
+
events: {};
|
|
9
|
+
};
|
|
10
|
+
declare class __sveltets_Render<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> {
|
|
11
|
+
props(): ReturnType<typeof $$render<S, T, N>>['props'];
|
|
12
|
+
events(): ReturnType<typeof $$render<S, T, N>>['events'];
|
|
13
|
+
slots(): ReturnType<typeof $$render<S, T, N>>['slots'];
|
|
14
|
+
bindings(): "ref" | "api" | "state";
|
|
15
|
+
exports(): {};
|
|
16
|
+
}
|
|
17
|
+
interface $$IsomorphicComponent {
|
|
18
|
+
new <S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<S, T, N>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<S, T, N>['props']>, ReturnType<__sveltets_Render<S, T, N>['events']>, ReturnType<__sveltets_Render<S, T, N>['slots']>> & {
|
|
19
|
+
$$bindings?: ReturnType<__sveltets_Render<S, T, N>['bindings']>;
|
|
20
|
+
} & ReturnType<__sveltets_Render<S, T, N>['exports']>;
|
|
21
|
+
<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<S, T, N>['props']> & {}): ReturnType<__sveltets_Render<S, T, N>['exports']>;
|
|
22
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any, any>['bindings']>;
|
|
23
|
+
}
|
|
24
|
+
declare const Form: $$IsomorphicComponent;
|
|
25
|
+
type Form<S extends FormSchema | undefined = FormSchema | undefined, T extends boolean = true, N extends boolean = false> = InstanceType<typeof Form<S, T, N>>;
|
|
26
|
+
export default Form;
|