@human-synthesis/norns-ui 0.0.1 → 0.0.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 +67 -25
- package/package.json +1 -1
- package/src/auto-import.js +18 -10
- package/src/components/Checkbox.n +33 -0
- package/src/components/Field.n +47 -0
- package/src/components/FieldGroup.n +17 -0
- package/src/components/Form.n +23 -0
- package/src/components/Input.n +41 -0
- package/src/components/Radio.n +33 -0
- package/src/components/Select.n +34 -0
- package/src/components/Switch.n +34 -0
- package/src/components/Textarea.n +38 -0
- package/src/index.js +14 -0
- package/src/styles/atoms.css +134 -0
- package/src/types/Checkbox.d.ts +11 -0
- package/src/types/Field.d.ts +21 -0
- package/src/types/FieldGroup.d.ts +11 -0
- package/src/types/Form.d.ts +13 -0
- package/src/types/Input.d.ts +29 -0
- package/src/types/Radio.d.ts +13 -0
- package/src/types/Select.d.ts +13 -0
- package/src/types/Switch.d.ts +11 -0
- package/src/types/Textarea.d.ts +12 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 2 (forms tier).** Currently ships `Btn`, `Form`, `Field`, `FieldGroup`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`. Bits-UI-backed behavior tier (Dialog, Popover, Tabs, Toast, …) lands in Phase 3.
|
|
6
6
|
|
|
7
7
|
## Stack
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ bun add -D @human-synthesis/norns-ui
|
|
|
23
23
|
|
|
24
24
|
### 1. Wire `presetUI()` into `nornsAutoImport`
|
|
25
25
|
|
|
26
|
-
So `<Btn>`, `<
|
|
26
|
+
So `<Btn>`, `<Field>`, `<Input>`, etc. resolve in markup without explicit imports.
|
|
27
27
|
|
|
28
28
|
```js
|
|
29
29
|
// vite.config.js
|
|
@@ -69,28 +69,32 @@ Same shape goes in `svelte.config.js`'s `preprocess` array.
|
|
|
69
69
|
### 3. Use components in Pug
|
|
70
70
|
|
|
71
71
|
```pug
|
|
72
|
-
section.space-y-
|
|
73
|
-
h1.text-3xl
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
section.space-y-4
|
|
73
|
+
h1.text-3xl New note
|
|
74
|
+
|
|
75
|
+
Form(method="POST" action="?/create")
|
|
76
|
+
Field(label="Title" required error!="{form?.errors?.title}")
|
|
77
|
+
Input(name="title" required)
|
|
78
|
+
Field(label="Body" help="Optional — supports plain text only")
|
|
79
|
+
Textarea(name="body" rows="4")
|
|
76
80
|
Btn(type="submit" variant="primary") Create note
|
|
77
81
|
```
|
|
78
82
|
|
|
79
|
-
##
|
|
83
|
+
## Component catalog
|
|
80
84
|
|
|
81
|
-
###
|
|
85
|
+
### Phase 1 — atoms
|
|
82
86
|
|
|
83
|
-
Use directly via Pug class shorthand:
|
|
87
|
+
CSS classes via `@layer components`. Use directly via Pug class shorthand:
|
|
84
88
|
|
|
85
|
-
- `.btn` + variants
|
|
86
|
-
- `.
|
|
87
|
-
- `.
|
|
89
|
+
- `.btn` + variants (`primary`, `secondary`, `ghost`, `danger`, `link`) + sizes (`sm`, `lg`)
|
|
90
|
+
- `.input` / `.textarea` / `.select` + `-err` modifier
|
|
91
|
+
- `.checkbox` / `.radio` / `.switch` + `-err` modifier
|
|
92
|
+
- `.field` / `.field-label` / `.field-required` / `.field-help` / `.field-error`
|
|
93
|
+
- `.field-group` / `.field-group-legend`
|
|
94
|
+
- `.form`
|
|
95
|
+
- `.ui-spinner`
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
### Components
|
|
92
|
-
|
|
93
|
-
#### `<Btn>` ([source](src/components/Btn.n))
|
|
97
|
+
### Phase 1 — Btn
|
|
94
98
|
|
|
95
99
|
Wrapped `<button>` with class merging, variant/size props, loading state, and snippet-prop API for icons.
|
|
96
100
|
|
|
@@ -108,12 +112,49 @@ Props (see [`src/types/Btn.d.ts`](src/types/Btn.d.ts)):
|
|
|
108
112
|
- `variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link'` (default `'primary'`)
|
|
109
113
|
- `size?: 'sm' | 'md' | 'lg'` (default `'md'`)
|
|
110
114
|
- `loading?: boolean` — replaces leading icon with `.ui-spinner`, sets `disabled` and `aria-busy`
|
|
111
|
-
- `disabled?: boolean`
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
- `disabled?: boolean`, `type?`, `onclick?`, `class?`, `children`, `leading`, `trailing`
|
|
116
|
+
|
|
117
|
+
### Phase 2 — forms tier
|
|
118
|
+
|
|
119
|
+
#### `<Field>`
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
```pug
|
|
124
|
+
Field(label="Email" required error!="{form?.errors?.email}" help="We'll never share it")
|
|
125
|
+
Input(name="email" type="email" required)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Props: `label?`, `help?`, `error?`, `required?`, `id?`, `class?`, `children`.
|
|
129
|
+
|
|
130
|
+
#### `<Form>`, `<FieldGroup>`
|
|
131
|
+
|
|
132
|
+
Styled `<form>` (vertical stack via `.form`) and `<fieldset>` (`.field-group` with optional legend).
|
|
133
|
+
|
|
134
|
+
```pug
|
|
135
|
+
Form(action="?/save")
|
|
136
|
+
FieldGroup(legend="Profile")
|
|
137
|
+
Field(label="Name")
|
|
138
|
+
Input(name="name" required)
|
|
139
|
+
Field(label="Bio")
|
|
140
|
+
Textarea(name="bio" rows="3")
|
|
141
|
+
Btn(type="submit") Save
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Form controls
|
|
145
|
+
|
|
146
|
+
All accept `class?` (merged via `cn`), pull `id` + `hasError` from a parent `<Field>` automatically, and surface `aria-invalid` + `aria-describedby` for accessibility:
|
|
147
|
+
|
|
148
|
+
| Component | Bind | Common props |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `<Input>` | `bind:value` | `type`, `size`, `name`, `placeholder`, `required`, `disabled`, `readonly`, `autocomplete` |
|
|
151
|
+
| `<Textarea>` | `bind:value` | `name`, `placeholder`, `rows` (default 4), `required`, `disabled`, `readonly` |
|
|
152
|
+
| `<Select>` | `bind:value` | `name`, `required`, `disabled`. Children: `<option>` elements |
|
|
153
|
+
| `<Checkbox>` | `bind:checked` | `name`, `value`, `required`, `disabled` |
|
|
154
|
+
| `<Radio>` | `bind:group` | `name`, `value`, `required`, `disabled` |
|
|
155
|
+
| `<Switch>` | `bind:checked` | `name`, `value`, `required`, `disabled`. Renders as a styled toggle (`role="switch"`) |
|
|
156
|
+
|
|
157
|
+
Each has a hand-rolled `.d.ts` shim under `src/types/`.
|
|
117
158
|
|
|
118
159
|
## Theming
|
|
119
160
|
|
|
@@ -126,14 +167,15 @@ Dark mode: toggle via `<html data-theme="dark">`. The library's tokens.css ships
|
|
|
126
167
|
Every component takes a `class` prop merged via `tailwind-merge` (the `cn` helper):
|
|
127
168
|
|
|
128
169
|
```svelte
|
|
129
|
-
<Btn variant="primary" class="w-full" />
|
|
170
|
+
<Btn variant="primary" class="w-full" />
|
|
171
|
+
<Input class="font-mono" />
|
|
130
172
|
```
|
|
131
173
|
|
|
132
174
|
`cn` is exported from `@human-synthesis/norns-ui/cn` if you want to swap in plain `clsx` for smaller bundle:
|
|
133
175
|
|
|
134
176
|
```js
|
|
135
177
|
import { cn } from '@human-synthesis/norns-ui/cn';
|
|
136
|
-
cn('btn', isActive && 'btn-active', extra)
|
|
178
|
+
cn('btn', isActive && 'btn-active', extra);
|
|
137
179
|
```
|
|
138
180
|
|
|
139
181
|
## Override a component
|
package/package.json
CHANGED
package/src/auto-import.js
CHANGED
|
@@ -9,21 +9,17 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const ui = presetUI();
|
|
11
11
|
*
|
|
12
|
-
* // vite.config.js
|
|
13
12
|
* plugins: [
|
|
14
13
|
* nornsCivetPlugin(),
|
|
15
14
|
* nornsAutoImport({
|
|
16
15
|
* componentDirs: ['src/lib/components'], // user folder wins
|
|
17
|
-
* components: ui.components
|
|
18
|
-
* helpers: ui.helpers
|
|
16
|
+
* components: ui.components // norns-ui as fallback
|
|
19
17
|
* }),
|
|
20
18
|
* sveltekit()
|
|
21
19
|
* ]
|
|
22
20
|
*
|
|
23
21
|
* Components are imported via bare specifiers so the consumer's
|
|
24
22
|
* `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
23
|
*
|
|
28
24
|
* @typedef {Object} UIPreset
|
|
29
25
|
* @property {Record<string, string>} components name → bare-specifier import path
|
|
@@ -34,11 +30,23 @@
|
|
|
34
30
|
export function presetUI() {
|
|
35
31
|
return {
|
|
36
32
|
components: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
33
|
+
// Phase 1
|
|
34
|
+
Btn: '@human-synthesis/norns-ui/components/Btn.n',
|
|
35
|
+
|
|
36
|
+
// Phase 2 — forms tier
|
|
37
|
+
Field: '@human-synthesis/norns-ui/components/Field.n',
|
|
38
|
+
FieldGroup: '@human-synthesis/norns-ui/components/FieldGroup.n',
|
|
39
|
+
Form: '@human-synthesis/norns-ui/components/Form.n',
|
|
40
|
+
Input: '@human-synthesis/norns-ui/components/Input.n',
|
|
41
|
+
Textarea: '@human-synthesis/norns-ui/components/Textarea.n',
|
|
42
|
+
Select: '@human-synthesis/norns-ui/components/Select.n',
|
|
43
|
+
Checkbox: '@human-synthesis/norns-ui/components/Checkbox.n',
|
|
44
|
+
Radio: '@human-synthesis/norns-ui/components/Radio.n',
|
|
45
|
+
Switch: '@human-synthesis/norns-ui/components/Switch.n'
|
|
46
|
+
|
|
47
|
+
// Phase 3+ adds: Card, Dialog, Sheet, Popover, Dropdown, Tooltip,
|
|
48
|
+
// Tabs, Accordion, Toast + ToastProvider, Listbox, Combobox,
|
|
49
|
+
// Pagination, Avatar, Badge, Spinner, Progress, Skeleton, Icon
|
|
42
50
|
},
|
|
43
51
|
helpers: [
|
|
44
52
|
// Phase 3+: { from: '@human-synthesis/norns-ui', imports: ['toast'] }
|
|
@@ -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,47 @@
|
|
|
1
|
+
.field(class!="{classes}")
|
|
2
|
+
+if('label')
|
|
3
|
+
label.field-label(for!="{ctx.id}")
|
|
4
|
+
| {label}
|
|
5
|
+
+if('required')
|
|
6
|
+
span.field-required *
|
|
7
|
+
| {@render children?.()}
|
|
8
|
+
+if('error')
|
|
9
|
+
p.field-error(role="alert" id!="{ctx.id ? `${ctx.id}-error` : undefined}")
|
|
10
|
+
| {error}
|
|
11
|
+
+if('!error && help')
|
|
12
|
+
p.field-help(id!="{ctx.id ? `${ctx.id}-help` : undefined}")
|
|
13
|
+
| {help}
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
17
|
+
import { setContext } from 'svelte'
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
label
|
|
21
|
+
help
|
|
22
|
+
error
|
|
23
|
+
required = false
|
|
24
|
+
id: providedId
|
|
25
|
+
children
|
|
26
|
+
class: extra = ''
|
|
27
|
+
} := $props()
|
|
28
|
+
|
|
29
|
+
// Deterministic id derivation:
|
|
30
|
+
// 1. explicit prop wins
|
|
31
|
+
// 2. otherwise slugify the label so SSR + hydrate produce the same id
|
|
32
|
+
// 3. otherwise undefined — without a label, no <label for=> association
|
|
33
|
+
// is needed anyway
|
|
34
|
+
slugify := (s) => s.toLowerCase().trim().replace(/[^\w]+/g, '-').replace(/^-+|-+$/g, '')
|
|
35
|
+
resolvedId := providedId ?? (label && `field-${slugify(label)}`) ?? undefined
|
|
36
|
+
|
|
37
|
+
// Shared with descendants (Input, Textarea, Select, etc.) so they pick up
|
|
38
|
+
// the id automatically and switch to error styling. $state proxy so
|
|
39
|
+
// children's reads are reactive when `error` changes.
|
|
40
|
+
ctx := $state({ id: resolvedId, hasError: !!error })
|
|
41
|
+
$effect ->
|
|
42
|
+
ctx.id = resolvedId
|
|
43
|
+
ctx.hasError = !!error
|
|
44
|
+
setContext 'norns-ui:field', ctx
|
|
45
|
+
|
|
46
|
+
classes := cn 'field', extra
|
|
47
|
+
</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,23 @@
|
|
|
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
|
+
|
|
13
|
+
{
|
|
14
|
+
method = 'POST'
|
|
15
|
+
action
|
|
16
|
+
enctype
|
|
17
|
+
onsubmit
|
|
18
|
+
children
|
|
19
|
+
class: extra = ''
|
|
20
|
+
} := $props()
|
|
21
|
+
|
|
22
|
+
classes := cn 'form', extra
|
|
23
|
+
</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>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
input(
|
|
2
|
+
type="radio"
|
|
3
|
+
id!="{resolvedId}"
|
|
4
|
+
name!="{name}"
|
|
5
|
+
value!="{value}"
|
|
6
|
+
bind:group
|
|
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
|
+
group = $bindable()
|
|
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 'radio', hasError && 'radio-err', extra
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
select(
|
|
2
|
+
id!="{resolvedId}"
|
|
3
|
+
name!="{name}"
|
|
4
|
+
bind:value
|
|
5
|
+
required!="{required}"
|
|
6
|
+
disabled!="{disabled}"
|
|
7
|
+
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
8
|
+
aria-describedby!="{describedBy}"
|
|
9
|
+
class!="{classes}"
|
|
10
|
+
)
|
|
11
|
+
| {@render children?.()}
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
15
|
+
import { getContext } from 'svelte'
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
value = $bindable()
|
|
19
|
+
name
|
|
20
|
+
id: providedId
|
|
21
|
+
required = false
|
|
22
|
+
disabled = false
|
|
23
|
+
error: explicitError = false
|
|
24
|
+
children
|
|
25
|
+
class: extra = ''
|
|
26
|
+
} .= $props()
|
|
27
|
+
|
|
28
|
+
field := getContext 'norns-ui:field'
|
|
29
|
+
resolvedId := providedId ?? field?.id
|
|
30
|
+
hasError := explicitError || field?.hasError || false
|
|
31
|
+
describedBy := resolvedId && hasError ? `${resolvedId}-error` : undefined
|
|
32
|
+
|
|
33
|
+
classes := cn 'select', hasError && 'select-err', extra
|
|
34
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
input(
|
|
2
|
+
type="checkbox"
|
|
3
|
+
role="switch"
|
|
4
|
+
id!="{resolvedId}"
|
|
5
|
+
name!="{name}"
|
|
6
|
+
value!="{value}"
|
|
7
|
+
bind:checked
|
|
8
|
+
required!="{required}"
|
|
9
|
+
disabled!="{disabled}"
|
|
10
|
+
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
11
|
+
class!="{classes}"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
<script>
|
|
15
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
16
|
+
import { getContext } from 'svelte'
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
checked = $bindable(false)
|
|
20
|
+
value
|
|
21
|
+
name
|
|
22
|
+
id: providedId
|
|
23
|
+
required = false
|
|
24
|
+
disabled = false
|
|
25
|
+
error: explicitError = false
|
|
26
|
+
class: extra = ''
|
|
27
|
+
} .= $props()
|
|
28
|
+
|
|
29
|
+
field := getContext 'norns-ui:field'
|
|
30
|
+
resolvedId := providedId ?? field?.id
|
|
31
|
+
hasError := explicitError || field?.hasError || false
|
|
32
|
+
|
|
33
|
+
classes := cn 'switch', extra
|
|
34
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
textarea(
|
|
2
|
+
id!="{resolvedId}"
|
|
3
|
+
name!="{name}"
|
|
4
|
+
bind:value
|
|
5
|
+
placeholder!="{placeholder}"
|
|
6
|
+
rows!="{rows}"
|
|
7
|
+
required!="{required}"
|
|
8
|
+
disabled!="{disabled}"
|
|
9
|
+
readonly!="{readonly}"
|
|
10
|
+
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
11
|
+
aria-describedby!="{describedBy}"
|
|
12
|
+
class!="{classes}"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
17
|
+
import { getContext } from 'svelte'
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
value = $bindable('')
|
|
21
|
+
name
|
|
22
|
+
id: providedId
|
|
23
|
+
placeholder
|
|
24
|
+
rows = 4
|
|
25
|
+
required = false
|
|
26
|
+
disabled = false
|
|
27
|
+
readonly = false
|
|
28
|
+
error: explicitError = false
|
|
29
|
+
class: extra = ''
|
|
30
|
+
} .= $props()
|
|
31
|
+
|
|
32
|
+
field := getContext 'norns-ui:field'
|
|
33
|
+
resolvedId := providedId ?? field?.id
|
|
34
|
+
hasError := explicitError || field?.hasError || false
|
|
35
|
+
describedBy := resolvedId && hasError ? `${resolvedId}-error` : undefined
|
|
36
|
+
|
|
37
|
+
classes := cn 'textarea', hasError && 'textarea-err', extra
|
|
38
|
+
</script>
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
* code that auto-import doesn't process (e.g. dynamic mount).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// Phase 1
|
|
10
11
|
export { default as Btn } from './components/Btn.n';
|
|
12
|
+
|
|
13
|
+
// Phase 2 — forms tier
|
|
14
|
+
export { default as Checkbox } from './components/Checkbox.n';
|
|
15
|
+
export { default as Field } from './components/Field.n';
|
|
16
|
+
export { default as FieldGroup } from './components/FieldGroup.n';
|
|
17
|
+
export { default as Form } from './components/Form.n';
|
|
18
|
+
export { default as Input } from './components/Input.n';
|
|
19
|
+
export { default as Radio } from './components/Radio.n';
|
|
20
|
+
export { default as Select } from './components/Select.n';
|
|
21
|
+
export { default as Switch } from './components/Switch.n';
|
|
22
|
+
export { default as Textarea } from './components/Textarea.n';
|
|
23
|
+
|
|
24
|
+
// Helpers
|
|
11
25
|
export { cn } from './lib/cn.js';
|
|
12
26
|
export { variantClasses } from './lib/variants.js';
|
|
13
27
|
export { presetUI } from './auto-import.js';
|
package/src/styles/atoms.css
CHANGED
|
@@ -59,4 +59,138 @@
|
|
|
59
59
|
.ui-spinner {
|
|
60
60
|
@apply inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent;
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/* --- Text inputs --- */
|
|
64
|
+
.input {
|
|
65
|
+
@apply block w-full rounded-md border border-neutral-300 bg-white text-sm text-neutral-900 shadow-sm;
|
|
66
|
+
@apply placeholder:text-neutral-400;
|
|
67
|
+
@apply transition-[border-color,box-shadow] duration-150 ease-out;
|
|
68
|
+
@apply focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30;
|
|
69
|
+
@apply disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:opacity-70;
|
|
70
|
+
height: var(--ui-h-md);
|
|
71
|
+
padding-inline: 0.75rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.input-sm {
|
|
75
|
+
@apply text-xs;
|
|
76
|
+
height: var(--ui-h-sm);
|
|
77
|
+
padding-inline: 0.625rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.input-lg {
|
|
81
|
+
@apply text-base;
|
|
82
|
+
height: var(--ui-h-lg);
|
|
83
|
+
padding-inline: 0.875rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.input-err {
|
|
87
|
+
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Textarea overrides .input's height */
|
|
91
|
+
.textarea {
|
|
92
|
+
@apply block w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm;
|
|
93
|
+
@apply placeholder:text-neutral-400;
|
|
94
|
+
@apply transition-[border-color,box-shadow] duration-150 ease-out;
|
|
95
|
+
@apply focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30;
|
|
96
|
+
@apply disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:opacity-70;
|
|
97
|
+
min-height: calc(var(--ui-h-md) * 2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.textarea-err {
|
|
101
|
+
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.select {
|
|
105
|
+
@apply block w-full rounded-md border border-neutral-300 bg-white text-sm text-neutral-900 shadow-sm;
|
|
106
|
+
@apply transition-[border-color,box-shadow] duration-150 ease-out;
|
|
107
|
+
@apply focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30;
|
|
108
|
+
@apply disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:opacity-70;
|
|
109
|
+
height: var(--ui-h-md);
|
|
110
|
+
padding-inline: 0.75rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.select-err {
|
|
114
|
+
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* --- Checkbox / Radio --- */
|
|
118
|
+
.checkbox {
|
|
119
|
+
@apply size-4 cursor-pointer rounded border-neutral-300 text-primary-600 shadow-sm;
|
|
120
|
+
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-1;
|
|
121
|
+
@apply disabled:cursor-not-allowed disabled:opacity-60;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.checkbox-err {
|
|
125
|
+
@apply border-red-500 focus:ring-red-500/30;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.radio {
|
|
129
|
+
@apply size-4 cursor-pointer border-neutral-300 text-primary-600 shadow-sm;
|
|
130
|
+
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:ring-offset-1;
|
|
131
|
+
@apply disabled:cursor-not-allowed disabled:opacity-60;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.radio-err {
|
|
135
|
+
@apply border-red-500 focus:ring-red-500/30;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* --- Switch (styled checkbox / toggle) ---
|
|
139
|
+
* Implemented as a checkbox the user wraps in a label; we provide the
|
|
140
|
+
* pill track via .switch and the moving knob via ::before.
|
|
141
|
+
*/
|
|
142
|
+
.switch {
|
|
143
|
+
@apply relative inline-flex h-5 w-9 shrink-0 cursor-pointer appearance-none rounded-full bg-neutral-200 shadow-inner;
|
|
144
|
+
@apply transition-colors duration-150 ease-out;
|
|
145
|
+
@apply checked:bg-primary-600;
|
|
146
|
+
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 focus-visible:ring-offset-1;
|
|
147
|
+
@apply disabled:cursor-not-allowed disabled:opacity-60;
|
|
148
|
+
}
|
|
149
|
+
.switch::before {
|
|
150
|
+
content: '';
|
|
151
|
+
@apply absolute left-0.5 top-0.5 size-4 rounded-full bg-white shadow;
|
|
152
|
+
@apply transition-transform duration-150 ease-out;
|
|
153
|
+
}
|
|
154
|
+
.switch:checked::before {
|
|
155
|
+
transform: translateX(1rem);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* --- Field (label + control + help/error) --- */
|
|
159
|
+
.field {
|
|
160
|
+
@apply flex flex-col;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.field-label {
|
|
164
|
+
@apply mb-1 block text-sm font-medium text-neutral-700;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.field-required {
|
|
168
|
+
@apply ml-0.5 text-red-600;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.field-help {
|
|
172
|
+
@apply mt-1 text-xs text-neutral-500;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.field-error {
|
|
176
|
+
@apply mt-1 text-xs text-red-600;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.field-row {
|
|
180
|
+
@apply flex items-center gap-2;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Field-group: border-grouped collection of fields */
|
|
184
|
+
.field-group {
|
|
185
|
+
@apply space-y-3 rounded-md border border-neutral-200 p-4;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.field-group-legend {
|
|
189
|
+
@apply -mx-1 -mt-1 px-1 text-sm font-medium text-neutral-700;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* --- Form (vertical stack of fields) --- */
|
|
193
|
+
.form {
|
|
194
|
+
@apply space-y-4;
|
|
195
|
+
}
|
|
62
196
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type CheckboxProps = Omit<HTMLInputAttributes, 'class' | 'type' | 'checked'> & {
|
|
5
|
+
checked?: boolean;
|
|
6
|
+
error?: boolean;
|
|
7
|
+
class?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
declare const Checkbox: Component<CheckboxProps>;
|
|
11
|
+
export default Checkbox;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
|
|
3
|
+
export type FieldProps = {
|
|
4
|
+
/** Label text rendered above the control. Used as id-derivation fallback if no `id` is given. */
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Helper text shown below the control when there's no error. */
|
|
7
|
+
help?: string;
|
|
8
|
+
/** Error message shown below the control. Switches the inner control to error styling via context. */
|
|
9
|
+
error?: string;
|
|
10
|
+
/** Adds a red asterisk after the label. Cosmetic; pair with `required` on the actual input. */
|
|
11
|
+
required?: boolean;
|
|
12
|
+
/** Explicit id for the control. Otherwise derived from `label`, otherwise undefined. */
|
|
13
|
+
id?: string;
|
|
14
|
+
/** Optional class merged into the field wrapper. */
|
|
15
|
+
class?: string;
|
|
16
|
+
/** Snippet that renders the actual control. Children should call `<Input />`, `<Textarea />`, etc. */
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
declare const Field: Component<FieldProps>;
|
|
21
|
+
export default Field;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
|
|
3
|
+
export type FieldGroupProps = {
|
|
4
|
+
/** Optional legend text rendered as the fieldset's `<legend>`. */
|
|
5
|
+
legend?: string;
|
|
6
|
+
class?: string;
|
|
7
|
+
children?: Snippet;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
declare const FieldGroup: Component<FieldGroupProps>;
|
|
11
|
+
export default FieldGroup;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type FormProps = Omit<HTMLFormAttributes, 'class' | 'children'> & {
|
|
5
|
+
method?: 'GET' | 'POST';
|
|
6
|
+
action?: string;
|
|
7
|
+
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
|
8
|
+
class?: string;
|
|
9
|
+
children?: Snippet;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
declare const Form: Component<FormProps>;
|
|
13
|
+
export default Form;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type InputSize = 'sm' | 'md' | 'lg';
|
|
5
|
+
export type InputType =
|
|
6
|
+
| 'text'
|
|
7
|
+
| 'email'
|
|
8
|
+
| 'password'
|
|
9
|
+
| 'number'
|
|
10
|
+
| 'tel'
|
|
11
|
+
| 'url'
|
|
12
|
+
| 'search'
|
|
13
|
+
| 'date'
|
|
14
|
+
| 'time'
|
|
15
|
+
| 'datetime-local'
|
|
16
|
+
| 'month'
|
|
17
|
+
| 'week';
|
|
18
|
+
|
|
19
|
+
export type InputProps = Omit<HTMLInputAttributes, 'class' | 'type' | 'size' | 'value'> & {
|
|
20
|
+
value?: string | number;
|
|
21
|
+
type?: InputType;
|
|
22
|
+
size?: InputSize;
|
|
23
|
+
/** Force the error styling regardless of the parent Field's error state. */
|
|
24
|
+
error?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
declare const Input: Component<InputProps>;
|
|
29
|
+
export default Input;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type RadioProps = Omit<HTMLInputAttributes, 'class' | 'type'> & {
|
|
5
|
+
/** Two-way bound shared group value — pass `bind:group={selected}` from parent. */
|
|
6
|
+
group?: string;
|
|
7
|
+
value?: string;
|
|
8
|
+
error?: boolean;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
declare const Radio: Component<RadioProps>;
|
|
13
|
+
export default Radio;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component, Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLSelectAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type SelectProps = Omit<HTMLSelectAttributes, 'class' | 'value' | 'children'> & {
|
|
5
|
+
value?: string | number | string[];
|
|
6
|
+
error?: boolean;
|
|
7
|
+
class?: string;
|
|
8
|
+
/** `<option>` children. Pass via Pug body. */
|
|
9
|
+
children?: Snippet;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
declare const Select: Component<SelectProps>;
|
|
13
|
+
export default Select;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type SwitchProps = Omit<HTMLInputAttributes, 'class' | 'type' | 'checked' | 'role'> & {
|
|
5
|
+
checked?: boolean;
|
|
6
|
+
error?: boolean;
|
|
7
|
+
class?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
declare const Switch: Component<SwitchProps>;
|
|
11
|
+
export default Switch;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
export type TextareaProps = Omit<HTMLTextareaAttributes, 'class' | 'value' | 'rows'> & {
|
|
5
|
+
value?: string;
|
|
6
|
+
rows?: number;
|
|
7
|
+
error?: boolean;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
declare const Textarea: Component<TextareaProps>;
|
|
12
|
+
export default Textarea;
|