@human-synthesis/norns-ui 0.0.1 → 0.0.3
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 +171 -25
- package/package.json +17 -15
- package/src/auto-import.js +34 -17
- package/src/components/Checkbox.n +33 -0
- package/src/components/Dialog.n +54 -0
- package/src/components/Dropdown.n +41 -0
- package/src/components/Field.n +56 -0
- package/src/components/FieldGroup.n +17 -0
- package/src/components/Form.n +45 -0
- package/src/components/Input.n +41 -0
- package/src/components/Popover.n +32 -0
- package/src/components/Radio.n +33 -0
- package/src/components/Select.n +34 -0
- package/src/components/Sheet.n +55 -0
- package/src/components/Switch.n +34 -0
- package/src/components/Tabs.n +28 -0
- package/src/components/Textarea.n +38 -0
- package/src/components/ToastProvider.n +16 -0
- package/src/components/Tooltip.n +34 -0
- package/src/index.js +24 -0
- package/src/lib/toast.svelte.js +42 -0
- package/src/styles/atoms.css +302 -0
- package/src/types/Checkbox.d.ts +11 -0
- package/src/types/Dialog.d.ts +17 -0
- package/src/types/Dropdown.d.ts +23 -0
- package/src/types/Field.d.ts +32 -0
- package/src/types/FieldGroup.d.ts +11 -0
- package/src/types/Form.d.ts +30 -0
- package/src/types/Input.d.ts +29 -0
- package/src/types/Popover.d.ts +18 -0
- package/src/types/Radio.d.ts +13 -0
- package/src/types/Select.d.ts +13 -0
- package/src/types/Sheet.d.ts +20 -0
- package/src/types/Switch.d.ts +11 -0
- package/src/types/Tabs.d.ts +17 -0
- package/src/types/Textarea.d.ts +12 -0
- package/src/types/ToastProvider.d.ts +15 -0
- package/src/types/Tooltip.d.ts +15 -0
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
UI library for the [Norns](https://github.com/human-synthesis/norns) ecosystem — Pug + Civet components on Tailwind v4.
|
|
4
4
|
|
|
5
|
-
**Status: Phase
|
|
5
|
+
**Status: Phase 3 (behavior tier).** Currently ships `Btn`, `Form`, `Field`, `FieldGroup`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, the in-house `ToastProvider` + `toast()` helper, and the Bits-UI-backed `Dialog`, `Sheet`, `Popover`, `Dropdown`, `Tooltip`, `Tabs`. Data tier (Combobox, Listbox, Pagination, Accordion) and polish tier (Avatar, Badge, Spinner, …) land in Phase 4–5.
|
|
6
|
+
|
|
7
|
+
> **Required: scope your preprocessors to project files.** Bits UI ships TypeScript-typed `<script lang="ts">` source, and the default Norns preprocess pipeline (svelte-preprocess + auto-import) corrupts those node_modules files. Wrap each preprocessor with a `node_modules` guard so they no-op on third-party `.svelte` files; vite-plugin-svelte handles them natively from there. See the **Setup** section below for the four-line `scopeToProject` helper.
|
|
6
8
|
|
|
7
9
|
## Stack
|
|
8
10
|
|
|
@@ -11,6 +13,7 @@ UI library for the [Norns](https://github.com/human-synthesis/norns) ecosystem
|
|
|
11
13
|
- [Civet](https://civet.dev) — `<script>` language
|
|
12
14
|
- [Tailwind CSS v4](https://tailwindcss.com) — styling, **hard peer dep**
|
|
13
15
|
- [tailwind-merge](https://github.com/dcastil/tailwind-merge) — class deduplication
|
|
16
|
+
- [Bits UI](https://bits-ui.com) — headless behavior backing `Dialog`/`Popover`/`Tabs`/etc.
|
|
14
17
|
- `@human-synthesis/norns` `^0.0.7` — peer (`nornsAutoImport` for the registration flow)
|
|
15
18
|
|
|
16
19
|
## Install
|
|
@@ -23,7 +26,7 @@ bun add -D @human-synthesis/norns-ui
|
|
|
23
26
|
|
|
24
27
|
### 1. Wire `presetUI()` into `nornsAutoImport`
|
|
25
28
|
|
|
26
|
-
So `<Btn>`, `<
|
|
29
|
+
So `<Btn>`, `<Field>`, `<Input>`, etc. resolve in markup without explicit imports.
|
|
27
30
|
|
|
28
31
|
```js
|
|
29
32
|
// vite.config.js
|
|
@@ -53,6 +56,27 @@ Same shape goes in `svelte.config.js`'s `preprocess` array.
|
|
|
53
56
|
|
|
54
57
|
`componentDirs` resolves first — your `src/lib/components/Btn.n` silently shadows the library's `Btn` whenever you want to override.
|
|
55
58
|
|
|
59
|
+
### 1b. Scope project preprocessors to skip `node_modules`
|
|
60
|
+
|
|
61
|
+
Wrap `nornsPreprocess()` outputs and `nornsAutoImport(...)` so their hooks no-op on third-party `.svelte` files. Bits UI's `lang="ts"` source compiles cleanly under vite-plugin-svelte's native TS handling, but breaks under our project-side svelte-preprocess + auto-import pipeline.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
// svelte.config.js
|
|
65
|
+
const scopeToProject = (p) => ({
|
|
66
|
+
name: p.name,
|
|
67
|
+
markup: p.markup ? (a) => (a.filename?.includes('/node_modules/') ? null : p.markup(a)) : undefined,
|
|
68
|
+
script: p.script ? (a) => (a.filename?.includes('/node_modules/') ? null : p.script(a)) : undefined,
|
|
69
|
+
style: p.style ? (a) => (a.filename?.includes('/node_modules/') ? null : p.style(a)) : undefined
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export default nornsConfig({
|
|
73
|
+
preprocess: [
|
|
74
|
+
...nornsPreprocess().map(scopeToProject),
|
|
75
|
+
scopeToProject(nornsAutoImport({ /* … */ }))
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
56
80
|
### 2. Import the styles
|
|
57
81
|
|
|
58
82
|
```css
|
|
@@ -69,28 +93,32 @@ Same shape goes in `svelte.config.js`'s `preprocess` array.
|
|
|
69
93
|
### 3. Use components in Pug
|
|
70
94
|
|
|
71
95
|
```pug
|
|
72
|
-
section.space-y-
|
|
73
|
-
h1.text-3xl
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
section.space-y-4
|
|
97
|
+
h1.text-3xl New note
|
|
98
|
+
|
|
99
|
+
Form(method="POST" action="?/create")
|
|
100
|
+
Field(label="Title" required error!="{form?.errors?.title}")
|
|
101
|
+
Input(name="title" required)
|
|
102
|
+
Field(label="Body" help="Optional — supports plain text only")
|
|
103
|
+
Textarea(name="body" rows="4")
|
|
76
104
|
Btn(type="submit" variant="primary") Create note
|
|
77
105
|
```
|
|
78
106
|
|
|
79
|
-
##
|
|
80
|
-
|
|
81
|
-
### Atoms (CSS-only — Tailwind `@layer components`)
|
|
82
|
-
|
|
83
|
-
Use directly via Pug class shorthand:
|
|
107
|
+
## Component catalog
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
- `.btn` + sizes: `.btn-sm`, `.btn-lg` (default md is built into `.btn`)
|
|
87
|
-
- `.ui-spinner` — small inline spinner
|
|
109
|
+
### Phase 1 — atoms
|
|
88
110
|
|
|
89
|
-
|
|
111
|
+
CSS classes via `@layer components`. Use directly via Pug class shorthand:
|
|
90
112
|
|
|
91
|
-
|
|
113
|
+
- `.btn` + variants (`primary`, `secondary`, `ghost`, `danger`, `link`) + sizes (`sm`, `lg`)
|
|
114
|
+
- `.input` / `.textarea` / `.select` + `-err` modifier
|
|
115
|
+
- `.checkbox` / `.radio` / `.switch` + `-err` modifier
|
|
116
|
+
- `.field` / `.field-label` / `.field-required` / `.field-help` / `.field-error`
|
|
117
|
+
- `.field-group` / `.field-group-legend`
|
|
118
|
+
- `.form`
|
|
119
|
+
- `.ui-spinner`
|
|
92
120
|
|
|
93
|
-
|
|
121
|
+
### Phase 1 — Btn
|
|
94
122
|
|
|
95
123
|
Wrapped `<button>` with class merging, variant/size props, loading state, and snippet-prop API for icons.
|
|
96
124
|
|
|
@@ -108,12 +136,129 @@ Props (see [`src/types/Btn.d.ts`](src/types/Btn.d.ts)):
|
|
|
108
136
|
- `variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link'` (default `'primary'`)
|
|
109
137
|
- `size?: 'sm' | 'md' | 'lg'` (default `'md'`)
|
|
110
138
|
- `loading?: boolean` — replaces leading icon with `.ui-spinner`, sets `disabled` and `aria-busy`
|
|
111
|
-
- `disabled?: boolean`
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
- `disabled?: boolean`, `type?`, `onclick?`, `class?`, `children`, `leading`, `trailing`
|
|
140
|
+
|
|
141
|
+
### Phase 2 — forms tier
|
|
142
|
+
|
|
143
|
+
#### `<Field>`
|
|
144
|
+
|
|
145
|
+
Wraps a control with label + optional help/error text. Provides `id` and `hasError` to descendant controls via Svelte context — child Inputs auto-pick up the right `id` for `<label for=>` and switch to error styling.
|
|
146
|
+
|
|
147
|
+
```pug
|
|
148
|
+
Field(label="Email" required error!="{form?.errors?.email}" help="We'll never share it")
|
|
149
|
+
Input(name="email" type="email" required)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Props: `label?`, `help?`, `error?`, `required?`, `id?`, `class?`, `children`.
|
|
153
|
+
|
|
154
|
+
#### `<Form>`, `<FieldGroup>`
|
|
155
|
+
|
|
156
|
+
Styled `<form>` (vertical stack via `.form`) and `<fieldset>` (`.field-group` with optional legend).
|
|
157
|
+
|
|
158
|
+
```pug
|
|
159
|
+
Form(action="?/save")
|
|
160
|
+
FieldGroup(legend="Profile")
|
|
161
|
+
Field(label="Name")
|
|
162
|
+
Input(name="name" required)
|
|
163
|
+
Field(label="Bio")
|
|
164
|
+
Textarea(name="bio" rows="3")
|
|
165
|
+
Btn(type="submit") Save
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### Form controls
|
|
169
|
+
|
|
170
|
+
All accept `class?` (merged via `cn`), pull `id` + `hasError` from a parent `<Field>` automatically, and surface `aria-invalid` + `aria-describedby` for accessibility:
|
|
171
|
+
|
|
172
|
+
| Component | Bind | Common props |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `<Input>` | `bind:value` | `type`, `size`, `name`, `placeholder`, `required`, `disabled`, `readonly`, `autocomplete` |
|
|
175
|
+
| `<Textarea>` | `bind:value` | `name`, `placeholder`, `rows` (default 4), `required`, `disabled`, `readonly` |
|
|
176
|
+
| `<Select>` | `bind:value` | `name`, `required`, `disabled`. Children: `<option>` elements |
|
|
177
|
+
| `<Checkbox>` | `bind:checked` | `name`, `value`, `required`, `disabled` |
|
|
178
|
+
| `<Radio>` | `bind:group` | `name`, `value`, `required`, `disabled` |
|
|
179
|
+
| `<Switch>` | `bind:checked` | `name`, `value`, `required`, `disabled`. Renders as a styled toggle (`role="switch"`) |
|
|
180
|
+
|
|
181
|
+
Each has a hand-rolled `.d.ts` shim under `src/types/`.
|
|
182
|
+
|
|
183
|
+
### Phase 3 — behavior tier (Bits UI)
|
|
184
|
+
|
|
185
|
+
Headless behavior + accessibility from [Bits UI](https://bits-ui.com), wrapped with default Tailwind styling and a snippet-prop API designed for Pug.
|
|
186
|
+
|
|
187
|
+
#### `<Dialog>`
|
|
188
|
+
|
|
189
|
+
```pug
|
|
190
|
+
Dialog(bind:open!="{deleteOpen}" title="Delete note?" description="This cannot be undone.")
|
|
191
|
+
+snippet('trigger')
|
|
192
|
+
Btn(variant="danger" size="sm") Delete
|
|
193
|
+
+snippet('actions')
|
|
194
|
+
Btn(variant="ghost" onclick!="{() => deleteOpen = false}") Cancel
|
|
195
|
+
Btn(variant="danger" onclick!="{confirm}") Delete
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Props: `open` (bindable), `title`, `description`, `hideClose`, `trigger`, `actions`, `children`, `class`, `overlayClass`, `triggerClass`.
|
|
199
|
+
|
|
200
|
+
#### `<Sheet>`
|
|
201
|
+
|
|
202
|
+
Same API as `Dialog` plus `side?: 'top' | 'right' | 'bottom' | 'left'` (default `'right'`). Slides in from the chosen edge instead of centering.
|
|
203
|
+
|
|
204
|
+
#### `<Popover>` and `<Dropdown>`
|
|
205
|
+
|
|
206
|
+
```pug
|
|
207
|
+
Popover
|
|
208
|
+
+snippet('trigger')
|
|
209
|
+
Btn(variant="ghost" size="sm") Filters
|
|
210
|
+
.space-y-2
|
|
211
|
+
Field(label="Status")
|
|
212
|
+
Select(name="status")
|
|
213
|
+
option(value="all") All
|
|
214
|
+
option(value="open") Open
|
|
215
|
+
|
|
216
|
+
Dropdown(items!="{[{label:'Edit', onSelect: edit}, {separator: true}, {label:'Delete', onSelect: del}]}")
|
|
217
|
+
+snippet('trigger')
|
|
218
|
+
Btn(variant="ghost") ⋯
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### `<Tooltip>`
|
|
222
|
+
|
|
223
|
+
```pug
|
|
224
|
+
Tooltip(content="Saved 2 minutes ago")
|
|
225
|
+
+snippet('trigger')
|
|
226
|
+
Btn(variant="ghost" size="sm") ⓘ
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
#### `<Tabs>`
|
|
230
|
+
|
|
231
|
+
```pug
|
|
232
|
+
+snippet('panelHuman')
|
|
233
|
+
p Play against another person on this device.
|
|
234
|
+
+snippet('panelCpu')
|
|
235
|
+
p Play against a simple CPU.
|
|
236
|
+
|
|
237
|
+
Tabs(bind:value!="{mode}" items!="{[
|
|
238
|
+
{ value: 'human', label: 'Human', panel: panelHuman },
|
|
239
|
+
{ value: 'cpu', label: 'CPU', panel: panelCpu }
|
|
240
|
+
]}")
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
#### `<ToastProvider>` + `toast()`
|
|
244
|
+
|
|
245
|
+
Mount once near the app root, then call `toast()` from anywhere:
|
|
246
|
+
|
|
247
|
+
```pug
|
|
248
|
+
// +layout.n
|
|
249
|
+
ToastProvider
|
|
250
|
+
| {@render children?.()}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```civet
|
|
254
|
+
import { toast } from '@human-synthesis/norns-ui/toast'
|
|
255
|
+
|
|
256
|
+
handleSave := =>
|
|
257
|
+
await save()
|
|
258
|
+
toast 'Saved!', { variant: 'success' }
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Variants: `info` (default), `success`, `warning`, `error`. Default duration 4000ms; pass `duration: 0` for sticky.
|
|
117
262
|
|
|
118
263
|
## Theming
|
|
119
264
|
|
|
@@ -126,14 +271,15 @@ Dark mode: toggle via `<html data-theme="dark">`. The library's tokens.css ships
|
|
|
126
271
|
Every component takes a `class` prop merged via `tailwind-merge` (the `cn` helper):
|
|
127
272
|
|
|
128
273
|
```svelte
|
|
129
|
-
<Btn variant="primary" class="w-full" />
|
|
274
|
+
<Btn variant="primary" class="w-full" />
|
|
275
|
+
<Input class="font-mono" />
|
|
130
276
|
```
|
|
131
277
|
|
|
132
278
|
`cn` is exported from `@human-synthesis/norns-ui/cn` if you want to swap in plain `clsx` for smaller bundle:
|
|
133
279
|
|
|
134
280
|
```js
|
|
135
281
|
import { cn } from '@human-synthesis/norns-ui/cn';
|
|
136
|
-
cn('btn', isActive && 'btn-active', extra)
|
|
282
|
+
cn('btn', isActive && 'btn-active', extra);
|
|
137
283
|
```
|
|
138
284
|
|
|
139
285
|
## Override a component
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "UI library for the Norns ecosystem — Pug + Civet components on Tailwind v4.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Teodoroiu (https://humansynthesis.ai)",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
".": "./src/index.js",
|
|
21
21
|
"./auto-import": "./src/auto-import.js",
|
|
22
22
|
"./cn": "./src/lib/cn.js",
|
|
23
|
+
"./toast": "./src/lib/toast.svelte.js",
|
|
23
24
|
"./styles": "./src/styles/index.css",
|
|
24
25
|
"./styles/tokens": "./src/styles/tokens.css",
|
|
25
26
|
"./styles/atoms": "./src/styles/atoms.css",
|
|
@@ -34,19 +35,20 @@
|
|
|
34
35
|
},
|
|
35
36
|
"peerDependenciesMeta": {
|
|
36
37
|
"@human-synthesis/norns": {
|
|
37
|
-
|
|
38
|
+
"optional": false
|
|
39
|
+
},
|
|
40
|
+
"@human-synthesis/norns-core": {
|
|
41
|
+
"optional": false
|
|
42
|
+
}
|
|
38
43
|
},
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
"publishConfig": {
|
|
50
|
-
"access": "public"
|
|
51
|
-
}
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"bits-ui": "^2.18.1",
|
|
46
|
+
"tailwind-merge": "^2.5.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
52
54
|
}
|
package/src/auto-import.js
CHANGED
|
@@ -9,39 +9,56 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const ui = presetUI();
|
|
11
11
|
*
|
|
12
|
-
* // vite.config.js
|
|
13
12
|
* plugins: [
|
|
14
13
|
* nornsCivetPlugin(),
|
|
15
14
|
* nornsAutoImport({
|
|
16
|
-
* componentDirs: ['src/lib/components'],
|
|
17
|
-
* components: ui.components,
|
|
18
|
-
* helpers: ui.helpers
|
|
15
|
+
* componentDirs: ['src/lib/components'],
|
|
16
|
+
* components: ui.components,
|
|
17
|
+
* helpers: [...DEFAULT_HELPERS, ...ui.helpers]
|
|
19
18
|
* }),
|
|
20
19
|
* sveltekit()
|
|
21
20
|
* ]
|
|
22
21
|
*
|
|
23
|
-
* Components are imported via bare specifiers so the consumer's
|
|
24
|
-
* `node_modules/@human-synthesis/norns-ui/...` is the resolution target.
|
|
25
|
-
* Helpers (functional APIs like `toast()`) are added incrementally as the
|
|
26
|
-
* library grows.
|
|
27
|
-
*
|
|
28
22
|
* @typedef {Object} UIPreset
|
|
29
|
-
* @property {Record<string, string>} components
|
|
30
|
-
* @property {Array<{ from: string, imports: string[] }>} helpers
|
|
23
|
+
* @property {Record<string, string>} components
|
|
24
|
+
* @property {Array<{ from: string, imports: string[] }>} helpers
|
|
31
25
|
*
|
|
32
26
|
* @returns {UIPreset}
|
|
33
27
|
*/
|
|
34
28
|
export function presetUI() {
|
|
35
29
|
return {
|
|
36
30
|
components: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
31
|
+
// Phase 1
|
|
32
|
+
Btn: '@human-synthesis/norns-ui/components/Btn.n',
|
|
33
|
+
|
|
34
|
+
// Phase 2 — forms tier
|
|
35
|
+
Field: '@human-synthesis/norns-ui/components/Field.n',
|
|
36
|
+
FieldGroup: '@human-synthesis/norns-ui/components/FieldGroup.n',
|
|
37
|
+
Form: '@human-synthesis/norns-ui/components/Form.n',
|
|
38
|
+
Input: '@human-synthesis/norns-ui/components/Input.n',
|
|
39
|
+
Textarea: '@human-synthesis/norns-ui/components/Textarea.n',
|
|
40
|
+
Select: '@human-synthesis/norns-ui/components/Select.n',
|
|
41
|
+
Checkbox: '@human-synthesis/norns-ui/components/Checkbox.n',
|
|
42
|
+
Radio: '@human-synthesis/norns-ui/components/Radio.n',
|
|
43
|
+
Switch: '@human-synthesis/norns-ui/components/Switch.n',
|
|
44
|
+
|
|
45
|
+
// Phase 3 — behavior tier (Bits UI)
|
|
46
|
+
Dialog: '@human-synthesis/norns-ui/components/Dialog.n',
|
|
47
|
+
Sheet: '@human-synthesis/norns-ui/components/Sheet.n',
|
|
48
|
+
Popover: '@human-synthesis/norns-ui/components/Popover.n',
|
|
49
|
+
Dropdown: '@human-synthesis/norns-ui/components/Dropdown.n',
|
|
50
|
+
Tooltip: '@human-synthesis/norns-ui/components/Tooltip.n',
|
|
51
|
+
Tabs: '@human-synthesis/norns-ui/components/Tabs.n',
|
|
52
|
+
ToastProvider: '@human-synthesis/norns-ui/components/ToastProvider.n'
|
|
53
|
+
|
|
54
|
+
// Phase 4+ adds: Accordion, Listbox, Combobox, Pagination,
|
|
55
|
+
// Avatar, Badge, Spinner, Progress, Skeleton, Icon, Card
|
|
42
56
|
},
|
|
43
57
|
helpers: [
|
|
44
|
-
|
|
58
|
+
{
|
|
59
|
+
from: '@human-synthesis/norns-ui/toast',
|
|
60
|
+
imports: ['toast', 'notify', 'dismiss']
|
|
61
|
+
}
|
|
45
62
|
]
|
|
46
63
|
};
|
|
47
64
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
input(
|
|
2
|
+
type="checkbox"
|
|
3
|
+
id!="{resolvedId}"
|
|
4
|
+
name!="{name}"
|
|
5
|
+
value!="{value}"
|
|
6
|
+
bind:checked
|
|
7
|
+
required!="{required}"
|
|
8
|
+
disabled!="{disabled}"
|
|
9
|
+
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
10
|
+
class!="{classes}"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
15
|
+
import { getContext } from 'svelte'
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
checked = $bindable(false)
|
|
19
|
+
value
|
|
20
|
+
name
|
|
21
|
+
id: providedId
|
|
22
|
+
required = false
|
|
23
|
+
disabled = false
|
|
24
|
+
error: explicitError = false
|
|
25
|
+
class: extra = ''
|
|
26
|
+
} .= $props()
|
|
27
|
+
|
|
28
|
+
field := getContext 'norns-ui:field'
|
|
29
|
+
resolvedId := providedId ?? field?.id
|
|
30
|
+
hasError := explicitError || field?.hasError || false
|
|
31
|
+
|
|
32
|
+
classes := cn 'checkbox', hasError && 'checkbox-err', extra
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
DialogRoot(bind:open)
|
|
2
|
+
+if('trigger')
|
|
3
|
+
DialogTrigger(class!="{triggerClass ?? undefined}")
|
|
4
|
+
| {@render trigger()}
|
|
5
|
+
DialogPortal
|
|
6
|
+
DialogOverlay(class!="{overlayClasses}")
|
|
7
|
+
DialogContent(class!="{contentClasses}")
|
|
8
|
+
+if('title')
|
|
9
|
+
DialogTitle.dialog-title
|
|
10
|
+
| {title}
|
|
11
|
+
+if('description')
|
|
12
|
+
DialogDescription.dialog-description
|
|
13
|
+
| {description}
|
|
14
|
+
+if('children')
|
|
15
|
+
.dialog-body
|
|
16
|
+
| {@render children()}
|
|
17
|
+
+if('actions')
|
|
18
|
+
.dialog-actions
|
|
19
|
+
| {@render actions()}
|
|
20
|
+
+if('!hideClose')
|
|
21
|
+
DialogClose.dialog-close(aria-label="Close")
|
|
22
|
+
| ✕
|
|
23
|
+
|
|
24
|
+
<script>
|
|
25
|
+
import { Dialog } from 'bits-ui'
|
|
26
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
Root: DialogRoot
|
|
30
|
+
Trigger: DialogTrigger
|
|
31
|
+
Portal: DialogPortal
|
|
32
|
+
Overlay: DialogOverlay
|
|
33
|
+
Content: DialogContent
|
|
34
|
+
Title: DialogTitle
|
|
35
|
+
Description: DialogDescription
|
|
36
|
+
Close: DialogClose
|
|
37
|
+
} := Dialog
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
open = $bindable(false)
|
|
41
|
+
title
|
|
42
|
+
description
|
|
43
|
+
hideClose = false
|
|
44
|
+
trigger
|
|
45
|
+
actions
|
|
46
|
+
children
|
|
47
|
+
triggerClass
|
|
48
|
+
overlayClass = ''
|
|
49
|
+
class: extra = ''
|
|
50
|
+
} .= $props()
|
|
51
|
+
|
|
52
|
+
overlayClasses := cn 'dialog-overlay', overlayClass
|
|
53
|
+
contentClasses := cn 'dialog-content', extra
|
|
54
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
MenuRoot(bind:open)
|
|
2
|
+
+if('trigger')
|
|
3
|
+
MenuTrigger(class!="{triggerClass ?? undefined}")
|
|
4
|
+
| {@render trigger()}
|
|
5
|
+
MenuPortal
|
|
6
|
+
MenuContent(class!="{contentClasses}" side!="{side}" align!="{align}" sideOffset!="{sideOffset}")
|
|
7
|
+
+each('items as item, i')
|
|
8
|
+
+if('item.separator')
|
|
9
|
+
MenuSeparator.dropdown-separator
|
|
10
|
+
+if('!item.separator')
|
|
11
|
+
MenuItem.dropdown-item(disabled!="{item.disabled}" onSelect!="{item.onSelect}")
|
|
12
|
+
| {item.label}
|
|
13
|
+
| {@render children?.()}
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import { DropdownMenu } from 'bits-ui'
|
|
17
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
Root: MenuRoot
|
|
21
|
+
Trigger: MenuTrigger
|
|
22
|
+
Portal: MenuPortal
|
|
23
|
+
Content: MenuContent
|
|
24
|
+
Item: MenuItem
|
|
25
|
+
Separator: MenuSeparator
|
|
26
|
+
} := DropdownMenu
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
open = $bindable(false)
|
|
30
|
+
side = 'bottom'
|
|
31
|
+
align = 'start'
|
|
32
|
+
sideOffset = 4
|
|
33
|
+
items = []
|
|
34
|
+
trigger
|
|
35
|
+
children
|
|
36
|
+
triggerClass
|
|
37
|
+
class: extra = ''
|
|
38
|
+
} .= $props()
|
|
39
|
+
|
|
40
|
+
contentClasses := cn 'dropdown-content', extra
|
|
41
|
+
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
.field(class!="{classes}")
|
|
2
|
+
+if('label')
|
|
3
|
+
label.field-label(for!="{resolvedId}")
|
|
4
|
+
| {label}
|
|
5
|
+
+if('required')
|
|
6
|
+
span.field-required *
|
|
7
|
+
| {@render children?.()}
|
|
8
|
+
+if('resolvedError')
|
|
9
|
+
p.field-error(role="alert" id!="{resolvedId ? `${resolvedId}-error` : undefined}")
|
|
10
|
+
| {resolvedError}
|
|
11
|
+
+if('!resolvedError && help')
|
|
12
|
+
p.field-help(id!="{resolvedId ? `${resolvedId}-help` : undefined}")
|
|
13
|
+
| {help}
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
17
|
+
import { setContext, getContext } from 'svelte'
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
label
|
|
21
|
+
help
|
|
22
|
+
error
|
|
23
|
+
name
|
|
24
|
+
required = false
|
|
25
|
+
id: providedId
|
|
26
|
+
children
|
|
27
|
+
class: extra = ''
|
|
28
|
+
} := $props()
|
|
29
|
+
|
|
30
|
+
// Error resolution order:
|
|
31
|
+
// 1. explicit `error` prop wins (caller has full control)
|
|
32
|
+
// 2. else look up `name` in the parent <Form>'s errors map (auto-wired)
|
|
33
|
+
// 3. else no error
|
|
34
|
+
formCtx := getContext 'norns-ui:form'
|
|
35
|
+
resolvedError := $derived error ?? (name && formCtx?.errors?.[name]) ?? undefined
|
|
36
|
+
|
|
37
|
+
// Deterministic id derivation. SSR-safe (no random fallback):
|
|
38
|
+
// 1. explicit `id` prop wins
|
|
39
|
+
// 2. else `name` (single source of truth for submission + id)
|
|
40
|
+
// 3. else slugify the label
|
|
41
|
+
// 4. else undefined — no label, no <label for=> association needed
|
|
42
|
+
slugify := (s) => s.toLowerCase().trim().replace(/[^\w]+/g, '-').replace(/^-+|-+$/g, '')
|
|
43
|
+
resolvedId := $derived providedId ?? name ?? (label && `field-${slugify(label)}`) ?? undefined
|
|
44
|
+
|
|
45
|
+
// Shared with descendants. Getters wrap $derived so context reads
|
|
46
|
+
// remain reactive in both SSR (synchronous) and client (post-mount)
|
|
47
|
+
// — `$effect` would only fire client-side, leaving SSR with stale
|
|
48
|
+
// initial values.
|
|
49
|
+
ctx := {
|
|
50
|
+
get id() { resolvedId }
|
|
51
|
+
get hasError() { !!resolvedError }
|
|
52
|
+
}
|
|
53
|
+
setContext 'norns-ui:field', ctx
|
|
54
|
+
|
|
55
|
+
classes := cn 'field', extra
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
fieldset(class!="{classes}")
|
|
2
|
+
+if('legend')
|
|
3
|
+
legend.field-group-legend
|
|
4
|
+
| {legend}
|
|
5
|
+
| {@render children?.()}
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
legend
|
|
12
|
+
children
|
|
13
|
+
class: extra = ''
|
|
14
|
+
} := $props()
|
|
15
|
+
|
|
16
|
+
classes := cn 'field-group', extra
|
|
17
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
form(
|
|
2
|
+
method!="{method}"
|
|
3
|
+
action!="{action}"
|
|
4
|
+
enctype!="{enctype}"
|
|
5
|
+
class!="{classes}"
|
|
6
|
+
onsubmit!="{onsubmit}"
|
|
7
|
+
)
|
|
8
|
+
| {@render children?.()}
|
|
9
|
+
|
|
10
|
+
<script>
|
|
11
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
12
|
+
import { setContext } from 'svelte'
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
method = 'POST'
|
|
16
|
+
action
|
|
17
|
+
enctype
|
|
18
|
+
onsubmit
|
|
19
|
+
form
|
|
20
|
+
children
|
|
21
|
+
class: extra = ''
|
|
22
|
+
} := $props()
|
|
23
|
+
|
|
24
|
+
// Errors-by-name map. Built reactively from the valibot issue list so
|
|
25
|
+
// descendant Fields can look up their own error by `name` without a
|
|
26
|
+
// per-page $derived helper.
|
|
27
|
+
//
|
|
28
|
+
// $derived (not $state + $effect) is critical for SSR — $effect runs
|
|
29
|
+
// only client-side, so an effect-based context would produce empty
|
|
30
|
+
// errors during initial server render.
|
|
31
|
+
errorsMap := $derived.by =>
|
|
32
|
+
out := {}
|
|
33
|
+
if form?.errors
|
|
34
|
+
for issue of form.errors
|
|
35
|
+
name := issue.path?.[0]?.key
|
|
36
|
+
if name && !out[name]
|
|
37
|
+
out[name] = issue.message
|
|
38
|
+
out
|
|
39
|
+
|
|
40
|
+
// Getter wraps the $derived so context consumers re-evaluate on read.
|
|
41
|
+
ctx := { get errors() { errorsMap } }
|
|
42
|
+
setContext 'norns-ui:form', ctx
|
|
43
|
+
|
|
44
|
+
classes := cn 'form', extra
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
input(
|
|
2
|
+
type!="{type}"
|
|
3
|
+
id!="{resolvedId}"
|
|
4
|
+
name!="{name}"
|
|
5
|
+
bind:value
|
|
6
|
+
placeholder!="{placeholder}"
|
|
7
|
+
required!="{required}"
|
|
8
|
+
disabled!="{disabled}"
|
|
9
|
+
readonly!="{readonly}"
|
|
10
|
+
autocomplete!="{autocomplete}"
|
|
11
|
+
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
12
|
+
aria-describedby!="{describedBy}"
|
|
13
|
+
class!="{classes}"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
18
|
+
import { getContext } from 'svelte'
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
value = $bindable('')
|
|
22
|
+
type = 'text'
|
|
23
|
+
size = 'md'
|
|
24
|
+
name
|
|
25
|
+
id: providedId
|
|
26
|
+
placeholder
|
|
27
|
+
required = false
|
|
28
|
+
disabled = false
|
|
29
|
+
readonly = false
|
|
30
|
+
autocomplete
|
|
31
|
+
error: explicitError = false
|
|
32
|
+
class: extra = ''
|
|
33
|
+
} .= $props()
|
|
34
|
+
|
|
35
|
+
field := getContext 'norns-ui:field'
|
|
36
|
+
resolvedId := providedId ?? field?.id
|
|
37
|
+
hasError := explicitError || field?.hasError || false
|
|
38
|
+
describedBy := resolvedId && hasError ? `${resolvedId}-error` : undefined
|
|
39
|
+
|
|
40
|
+
classes := cn 'input', size !== 'md' && `input-${size}`, hasError && 'input-err', extra
|
|
41
|
+
</script>
|