@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 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 1 (foundation).** Currently ships `Btn` and the atom CSS layer. Forms tier (Input, Field, Form, …) lands in Phase 2; Bits-UI-backed behavior tier (Dialog, Popover, Tabs, Toast, …) in Phase 3.
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>`, `<Card>`, `<Field>` resolve in markup without explicit imports.
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-3
73
- h1.text-3xl Notes
74
- form(method="POST" action="?/create")
75
- input.input(name="title" required)
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
- ## What ships
83
+ ## Component catalog
80
84
 
81
- ### Atoms (CSS-onlyTailwind `@layer components`)
85
+ ### Phase 1atoms
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: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-danger`, `.btn-link`
86
- - `.btn` + sizes: `.btn-sm`, `.btn-lg` (default md is built into `.btn`)
87
- - `.ui-spinner` small inline spinner
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
- More atoms (`.input`, `.card`, `.badge`, `.field-*`) land in Phase 2.
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
- - `type?: 'button' | 'submit' | 'reset'` (default `'button'`)
113
- - `onclick?: (event) => void`
114
- - `class?: string` — merged via [`tailwind-merge`](https://github.com/dcastil/tailwind-merge)
115
- - `children` — default snippet (the button label)
116
- - `leading`, `trailing` — snippet slots for icons
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" /> <!-- both classes survive; later wins on conflicts -->
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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)",
@@ -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, // norns-ui as fallback
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
- Btn: '@human-synthesis/norns-ui/components/Btn.n'
38
- // Phase 2+ adds: Input, Textarea, Select, Checkbox, Radio, Switch,
39
- // Field, Form, FieldGroup, Card, Dialog, Sheet, Popover, Dropdown,
40
- // Tooltip, Tabs, Accordion, Toast, Listbox, Combobox, Pagination,
41
- // Avatar, Badge, Spinner, Progress, Skeleton, Icon
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';
@@ -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;