@human-synthesis/norns-ui 0.0.2 → 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 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 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.
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
@@ -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
@@ -156,6 +180,86 @@ All accept `class?` (merged via `cn`), pull `id` + `hasError` from a parent `<Fi
156
180
 
157
181
  Each has a hand-rolled `.d.ts` shim under `src/types/`.
158
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.
262
+
159
263
  ## Theming
160
264
 
161
265
  Tokens live in [`src/styles/tokens.css`](src/styles/tokens.css) as a Tailwind v4 `@theme` block — colors, radii, sizes. Override by re-declaring `@theme { ... }` in your own `app.css` after the library import. Tailwind v4 merges layered theme blocks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.0.2",
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
- "optional": false
38
+ "optional": false
39
+ },
40
+ "@human-synthesis/norns-core": {
41
+ "optional": false
42
+ }
38
43
  },
39
- "@human-synthesis/norns-core": {
40
- "optional": false
41
- }
42
- },
43
- "dependencies": {
44
- "tailwind-merge": "^2.5.0"
45
- },
46
- "engines": {
47
- "node": ">=18"
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
  }
@@ -12,18 +12,16 @@
12
12
  * plugins: [
13
13
  * nornsCivetPlugin(),
14
14
  * nornsAutoImport({
15
- * componentDirs: ['src/lib/components'], // user folder wins
16
- * components: ui.components // norns-ui as fallback
15
+ * componentDirs: ['src/lib/components'],
16
+ * components: ui.components,
17
+ * helpers: [...DEFAULT_HELPERS, ...ui.helpers]
17
18
  * }),
18
19
  * sveltekit()
19
20
  * ]
20
21
  *
21
- * Components are imported via bare specifiers so the consumer's
22
- * `node_modules/@human-synthesis/norns-ui/...` is the resolution target.
23
- *
24
22
  * @typedef {Object} UIPreset
25
- * @property {Record<string, string>} components name → bare-specifier import path
26
- * @property {Array<{ from: string, imports: string[] }>} helpers name groups for nornsAutoImport
23
+ * @property {Record<string, string>} components
24
+ * @property {Array<{ from: string, imports: string[] }>} helpers
27
25
  *
28
26
  * @returns {UIPreset}
29
27
  */
@@ -42,14 +40,25 @@ export function presetUI() {
42
40
  Select: '@human-synthesis/norns-ui/components/Select.n',
43
41
  Checkbox: '@human-synthesis/norns-ui/components/Checkbox.n',
44
42
  Radio: '@human-synthesis/norns-ui/components/Radio.n',
45
- Switch: '@human-synthesis/norns-ui/components/Switch.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'
46
53
 
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
54
+ // Phase 4+ adds: Accordion, Listbox, Combobox, Pagination,
55
+ // Avatar, Badge, Spinner, Progress, Skeleton, Icon, Card
50
56
  },
51
57
  helpers: [
52
- // Phase 3+: { from: '@human-synthesis/norns-ui', imports: ['toast'] }
58
+ {
59
+ from: '@human-synthesis/norns-ui/toast',
60
+ imports: ['toast', 'notify', 'dismiss']
61
+ }
53
62
  ]
54
63
  };
55
64
  }
@@ -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>
@@ -1,46 +1,55 @@
1
1
  .field(class!="{classes}")
2
2
  +if('label')
3
- label.field-label(for!="{ctx.id}")
3
+ label.field-label(for!="{resolvedId}")
4
4
  | {label}
5
5
  +if('required')
6
6
  span.field-required *
7
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}")
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
13
  | {help}
14
14
 
15
15
  <script>
16
16
  import { cn } from '@human-synthesis/norns-ui/cn'
17
- import { setContext } from 'svelte'
17
+ import { setContext, getContext } from 'svelte'
18
18
 
19
19
  {
20
20
  label
21
21
  help
22
22
  error
23
+ name
23
24
  required = false
24
25
  id: providedId
25
26
  children
26
27
  class: extra = ''
27
28
  } := $props()
28
29
 
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
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
34
42
  slugify := (s) => s.toLowerCase().trim().replace(/[^\w]+/g, '-').replace(/^-+|-+$/g, '')
35
- resolvedId := providedId ?? (label && `field-${slugify(label)}`) ?? undefined
43
+ resolvedId := $derived providedId ?? name ?? (label && `field-${slugify(label)}`) ?? undefined
36
44
 
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
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
+ }
44
53
  setContext 'norns-ui:field', ctx
45
54
 
46
55
  classes := cn 'field', extra
@@ -9,15 +9,37 @@ form(
9
9
 
10
10
  <script>
11
11
  import { cn } from '@human-synthesis/norns-ui/cn'
12
+ import { setContext } from 'svelte'
12
13
 
13
14
  {
14
15
  method = 'POST'
15
16
  action
16
17
  enctype
17
18
  onsubmit
19
+ form
18
20
  children
19
21
  class: extra = ''
20
22
  } := $props()
21
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
+
22
44
  classes := cn 'form', extra
23
45
  </script>
@@ -0,0 +1,32 @@
1
+ PopoverRoot(bind:open)
2
+ +if('trigger')
3
+ PopoverTrigger(class!="{triggerClass ?? undefined}")
4
+ | {@render trigger()}
5
+ PopoverPortal
6
+ PopoverContent(class!="{contentClasses}" side!="{side}" align!="{align}" sideOffset!="{sideOffset}")
7
+ | {@render children?.()}
8
+
9
+ <script>
10
+ import { Popover } from 'bits-ui'
11
+ import { cn } from '@human-synthesis/norns-ui/cn'
12
+
13
+ {
14
+ Root: PopoverRoot
15
+ Trigger: PopoverTrigger
16
+ Portal: PopoverPortal
17
+ Content: PopoverContent
18
+ } := Popover
19
+
20
+ {
21
+ open = $bindable(false)
22
+ side = 'bottom'
23
+ align = 'center'
24
+ sideOffset = 8
25
+ trigger
26
+ children
27
+ triggerClass
28
+ class: extra = ''
29
+ } .= $props()
30
+
31
+ contentClasses := cn 'popover-content', extra
32
+ </script>
@@ -0,0 +1,55 @@
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}" data-side!="{side}")
8
+ +if('title')
9
+ DialogTitle.dialog-title
10
+ | {title}
11
+ +if('description')
12
+ DialogDescription.dialog-description
13
+ | {description}
14
+ +if('children')
15
+ .sheet-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
+ side = 'right'
42
+ title
43
+ description
44
+ hideClose = false
45
+ trigger
46
+ actions
47
+ children
48
+ triggerClass
49
+ overlayClass = ''
50
+ class: extra = ''
51
+ } .= $props()
52
+
53
+ overlayClasses := cn 'dialog-overlay', overlayClass
54
+ contentClasses := cn 'sheet-content', `sheet-${side}`, extra
55
+ </script>
@@ -0,0 +1,28 @@
1
+ TabsRoot(bind:value class!="{rootClasses}")
2
+ TabsList.tabs-list
3
+ +each('items as item (item.value)')
4
+ TabsTrigger.tabs-trigger(value!="{item.value}" disabled!="{item.disabled}")
5
+ | {item.label}
6
+ +each('items as item (item.value)')
7
+ TabsContent.tabs-content(value!="{item.value}")
8
+ | {@render item.panel?.()}
9
+
10
+ <script>
11
+ import { Tabs } from 'bits-ui'
12
+ import { cn } from '@human-synthesis/norns-ui/cn'
13
+
14
+ {
15
+ Root: TabsRoot
16
+ List: TabsList
17
+ Trigger: TabsTrigger
18
+ Content: TabsContent
19
+ } := Tabs
20
+
21
+ {
22
+ value = $bindable()
23
+ items = []
24
+ class: extra = ''
25
+ } .= $props()
26
+
27
+ rootClasses := cn 'tabs-root', extra
28
+ </script>
@@ -0,0 +1,16 @@
1
+ div(role="region" aria-label="Notifications" class!="{regionClasses}")
2
+ +each('toastStore.items as item (item.id)')
3
+ div(class!="{`toast toast-${item.variant}`}" role="status" data-id!="{item.id}")
4
+ .toast-message
5
+ | {item.message}
6
+ button.toast-close(type="button" aria-label="Dismiss" onclick!="{() => dismiss(item.id)}")
7
+ | ✕
8
+
9
+ <script>
10
+ import { cn } from '@human-synthesis/norns-ui/cn'
11
+ import { toastStore, dismiss } from '@human-synthesis/norns-ui/toast'
12
+
13
+ { class: extra = '' } := $props()
14
+
15
+ regionClasses := cn 'toast-region', extra
16
+ </script>
@@ -0,0 +1,34 @@
1
+ TooltipProvider(delayDuration!="{delay}")
2
+ TooltipRoot
3
+ TooltipTrigger(class!="{triggerClass ?? undefined}")
4
+ | {@render trigger?.()}
5
+ TooltipPortal
6
+ TooltipContent(class!="{contentClasses}" side!="{side}" sideOffset!="{sideOffset}")
7
+ | {content ?? ''}
8
+ | {@render children?.()}
9
+
10
+ <script>
11
+ import { Tooltip } from 'bits-ui'
12
+ import { cn } from '@human-synthesis/norns-ui/cn'
13
+
14
+ {
15
+ Provider: TooltipProvider
16
+ Root: TooltipRoot
17
+ Trigger: TooltipTrigger
18
+ Portal: TooltipPortal
19
+ Content: TooltipContent
20
+ } := Tooltip
21
+
22
+ {
23
+ content
24
+ side = 'top'
25
+ sideOffset = 6
26
+ delay = 300
27
+ trigger
28
+ children
29
+ triggerClass
30
+ class: extra = ''
31
+ } .= $props()
32
+
33
+ contentClasses := cn 'tooltip-content', extra
34
+ </script>
package/src/index.js CHANGED
@@ -21,7 +21,17 @@ export { default as Select } from './components/Select.n';
21
21
  export { default as Switch } from './components/Switch.n';
22
22
  export { default as Textarea } from './components/Textarea.n';
23
23
 
24
+ // Phase 3 — behavior tier (Bits UI)
25
+ export { default as Dialog } from './components/Dialog.n';
26
+ export { default as Sheet } from './components/Sheet.n';
27
+ export { default as Popover } from './components/Popover.n';
28
+ export { default as Dropdown } from './components/Dropdown.n';
29
+ export { default as Tooltip } from './components/Tooltip.n';
30
+ export { default as Tabs } from './components/Tabs.n';
31
+ export { default as ToastProvider } from './components/ToastProvider.n';
32
+
24
33
  // Helpers
25
34
  export { cn } from './lib/cn.js';
26
35
  export { variantClasses } from './lib/variants.js';
27
36
  export { presetUI } from './auto-import.js';
37
+ export { toast, notify, dismiss, clear } from './lib/toast.svelte.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Toast store + helpers. Single in-process queue, mounted once via
3
+ * <ToastProvider>. `toast(msg)` pushes from anywhere — server actions,
4
+ * client effects, button handlers.
5
+ */
6
+
7
+ let nextId = 0;
8
+
9
+ export const toastStore = $state({ items: [] });
10
+
11
+ /**
12
+ * Push a toast. Returns the toast id so the caller can dismiss early.
13
+ *
14
+ * @param {string} message
15
+ * @param {{ variant?: 'info' | 'success' | 'warning' | 'error', duration?: number }} [opts]
16
+ * `duration` in ms, default 4000. Set to 0 for sticky.
17
+ */
18
+ export function toast(message, opts = {}) {
19
+ const id = ++nextId;
20
+ const item = {
21
+ id,
22
+ message,
23
+ variant: opts.variant ?? 'info',
24
+ duration: opts.duration ?? 4000
25
+ };
26
+ toastStore.items.push(item);
27
+ if (item.duration > 0) {
28
+ setTimeout(() => dismiss(id), item.duration);
29
+ }
30
+ return id;
31
+ }
32
+
33
+ export const notify = toast;
34
+
35
+ export function dismiss(id) {
36
+ const i = toastStore.items.findIndex((t) => t.id === id);
37
+ if (i >= 0) toastStore.items.splice(i, 1);
38
+ }
39
+
40
+ export function clear() {
41
+ toastStore.items.length = 0;
42
+ }
@@ -193,4 +193,172 @@
193
193
  .form {
194
194
  @apply space-y-4;
195
195
  }
196
+
197
+ /* --- Dialog (modal) ---
198
+ * Bits UI toggles `data-state="open|closed"`. We use plain CSS transitions
199
+ * targeting that attribute for fade + scale; no `tailwindcss-animate`
200
+ * dependency, no v4 plugin.
201
+ */
202
+ .dialog-overlay {
203
+ @apply fixed inset-0 z-50 bg-black/50 backdrop-blur-sm opacity-0 transition-opacity duration-150;
204
+ }
205
+ .dialog-overlay[data-state='open'] {
206
+ @apply opacity-100;
207
+ }
208
+
209
+ .dialog-content {
210
+ @apply fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2;
211
+ @apply rounded-lg border border-neutral-200 bg-white p-6 shadow-xl;
212
+ @apply opacity-0 scale-95 transition duration-150;
213
+ }
214
+ .dialog-content[data-state='open'] {
215
+ @apply opacity-100 scale-100;
216
+ }
217
+
218
+ .dialog-title {
219
+ @apply text-lg font-semibold leading-tight tracking-tight text-neutral-900;
220
+ }
221
+
222
+ .dialog-description {
223
+ @apply mt-1 text-sm text-neutral-600;
224
+ }
225
+
226
+ .dialog-body {
227
+ @apply mt-4;
228
+ }
229
+
230
+ .dialog-actions {
231
+ @apply mt-6 flex items-center justify-end gap-2;
232
+ }
233
+
234
+ .dialog-close {
235
+ @apply absolute right-3 top-3 inline-flex size-7 items-center justify-center rounded-md text-neutral-500;
236
+ @apply hover:bg-neutral-100 hover:text-neutral-900;
237
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500;
238
+ }
239
+
240
+ /* --- Sheet (side panel) --- */
241
+ .sheet-content {
242
+ @apply fixed z-50 border border-neutral-200 bg-white shadow-xl transition-transform duration-200 ease-out;
243
+ }
244
+ .sheet-right {
245
+ @apply right-0 top-0 h-full w-full max-w-md translate-x-full p-6;
246
+ }
247
+ .sheet-right[data-state='open'] {
248
+ @apply translate-x-0;
249
+ }
250
+ .sheet-left {
251
+ @apply left-0 top-0 h-full w-full max-w-md -translate-x-full p-6;
252
+ }
253
+ .sheet-left[data-state='open'] {
254
+ @apply translate-x-0;
255
+ }
256
+ .sheet-top {
257
+ @apply left-0 top-0 w-full -translate-y-full p-6;
258
+ }
259
+ .sheet-top[data-state='open'] {
260
+ @apply translate-y-0;
261
+ }
262
+ .sheet-bottom {
263
+ @apply bottom-0 left-0 w-full translate-y-full p-6;
264
+ }
265
+ .sheet-bottom[data-state='open'] {
266
+ @apply translate-y-0;
267
+ }
268
+
269
+ .sheet-body {
270
+ @apply mt-4;
271
+ }
272
+
273
+ /* --- Popover --- */
274
+ .popover-content {
275
+ @apply z-50 rounded-md border border-neutral-200 bg-white p-3 text-sm shadow-md;
276
+ @apply opacity-0 scale-95 transition duration-150;
277
+ }
278
+ .popover-content[data-state='open'] {
279
+ @apply opacity-100 scale-100;
280
+ }
281
+
282
+ /* --- Dropdown menu --- */
283
+ .dropdown-content {
284
+ @apply z-50 min-w-[8rem] rounded-md border border-neutral-200 bg-white p-1 text-sm shadow-md;
285
+ @apply opacity-0 scale-95 transition duration-150;
286
+ }
287
+ .dropdown-content[data-state='open'] {
288
+ @apply opacity-100 scale-100;
289
+ }
290
+ .dropdown-item {
291
+ @apply flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm text-neutral-800 outline-none;
292
+ @apply data-[highlighted]:bg-neutral-100 data-[highlighted]:text-neutral-900;
293
+ @apply data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50;
294
+ }
295
+ .dropdown-separator {
296
+ @apply -mx-1 my-1 h-px bg-neutral-200;
297
+ }
298
+
299
+ /* --- Tooltip --- */
300
+ .tooltip-content {
301
+ @apply z-50 overflow-hidden rounded-md bg-neutral-900 px-2 py-1 text-xs text-white shadow-md;
302
+ @apply opacity-0 scale-95 transition duration-150;
303
+ }
304
+ .tooltip-content[data-state='delayed-open'],
305
+ .tooltip-content[data-state='instant-open'] {
306
+ @apply opacity-100 scale-100;
307
+ }
308
+
309
+ /* --- Tabs --- */
310
+ .tabs-root {
311
+ @apply space-y-3;
312
+ }
313
+ .tabs-list {
314
+ @apply inline-flex h-9 items-center justify-center rounded-md bg-neutral-100 p-1 text-neutral-600;
315
+ }
316
+ .tabs-trigger {
317
+ @apply inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium;
318
+ @apply transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500;
319
+ @apply data-[state=active]:bg-white data-[state=active]:text-neutral-900 data-[state=active]:shadow-sm;
320
+ @apply disabled:cursor-not-allowed disabled:opacity-50;
321
+ }
322
+ .tabs-content {
323
+ @apply focus-visible:outline-none;
324
+ }
325
+
326
+ /* --- Toast --- */
327
+ .toast-region {
328
+ @apply pointer-events-none fixed bottom-4 right-4 z-[100] flex w-full max-w-sm flex-col gap-2;
329
+ }
330
+ .toast {
331
+ @apply pointer-events-auto flex items-start gap-3 rounded-md border border-neutral-200 bg-white p-3 shadow-md;
332
+ animation: norns-toast-in 200ms ease-out;
333
+ }
334
+ @keyframes norns-toast-in {
335
+ from {
336
+ opacity: 0;
337
+ transform: translateX(1rem);
338
+ }
339
+ to {
340
+ opacity: 1;
341
+ transform: translateX(0);
342
+ }
343
+ }
344
+ .toast-info {
345
+ @apply border-neutral-200;
346
+ }
347
+ .toast-success {
348
+ @apply border-green-200 bg-green-50 text-green-900;
349
+ }
350
+ .toast-warning {
351
+ @apply border-amber-200 bg-amber-50 text-amber-900;
352
+ }
353
+ .toast-error {
354
+ @apply border-red-200 bg-red-50 text-red-900;
355
+ }
356
+ .toast-message {
357
+ @apply flex-1 text-sm leading-snug;
358
+ }
359
+ .toast-close {
360
+ @apply -mr-1 -mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded text-neutral-500;
361
+ @apply hover:bg-neutral-100 hover:text-neutral-900;
362
+ @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500;
363
+ }
196
364
  }
@@ -0,0 +1,17 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type DialogProps = {
4
+ open?: boolean;
5
+ title?: string;
6
+ description?: string;
7
+ hideClose?: boolean;
8
+ trigger?: Snippet;
9
+ actions?: Snippet;
10
+ children?: Snippet;
11
+ triggerClass?: string;
12
+ overlayClass?: string;
13
+ class?: string;
14
+ };
15
+
16
+ declare const Dialog: Component<DialogProps>;
17
+ export default Dialog;
@@ -0,0 +1,23 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type DropdownItem = {
4
+ label?: string;
5
+ separator?: boolean;
6
+ disabled?: boolean;
7
+ onSelect?: (event: Event) => void;
8
+ };
9
+
10
+ export type DropdownProps = {
11
+ open?: boolean;
12
+ side?: 'top' | 'right' | 'bottom' | 'left';
13
+ align?: 'start' | 'center' | 'end';
14
+ sideOffset?: number;
15
+ items?: DropdownItem[];
16
+ trigger?: Snippet;
17
+ children?: Snippet;
18
+ triggerClass?: string;
19
+ class?: string;
20
+ };
21
+
22
+ declare const Dropdown: Component<DropdownProps>;
23
+ export default Dropdown;
@@ -1,15 +1,26 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
2
 
3
3
  export type FieldProps = {
4
- /** Label text rendered above the control. Used as id-derivation fallback if no `id` is given. */
4
+ /** Label text rendered above the control. Used as id-derivation fallback if no `id`/`name` is given. */
5
5
  label?: string;
6
6
  /** Helper text shown below the control when there's no error. */
7
7
  help?: string;
8
- /** Error message shown below the control. Switches the inner control to error styling via context. */
8
+ /**
9
+ * Explicit error message. Overrides any auto-resolved error from the
10
+ * parent `<Form form={...}>` context. Switches the inner control to
11
+ * error styling.
12
+ */
9
13
  error?: string;
14
+ /**
15
+ * Field name — used to look up an auto-error from the parent `<Form>`
16
+ * context (matching `form.errors[*].path[0].key`) and as the default
17
+ * id for the inner control. When set, an inner `<Input>` doesn't need
18
+ * an explicit `name=` either if it inherits from this Field.
19
+ */
20
+ name?: string;
10
21
  /** Adds a red asterisk after the label. Cosmetic; pair with `required` on the actual input. */
11
22
  required?: boolean;
12
- /** Explicit id for the control. Otherwise derived from `label`, otherwise undefined. */
23
+ /** Explicit id for the control. Otherwise: `name`, then slugified `label`, then undefined. */
13
24
  id?: string;
14
25
  /** Optional class merged into the field wrapper. */
15
26
  class?: string;
@@ -1,10 +1,27 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
2
  import type { HTMLFormAttributes } from 'svelte/elements';
3
3
 
4
+ /**
5
+ * The shape returned by `fail(400, { errors, values })` from a SvelteKit
6
+ * action — what `+page.server.c`'s `page.actions` produces on validation
7
+ * failure (or a manually-constructed equivalent). `errors` is a valibot
8
+ * issue list; `values` is the raw form data echoed back for re-rendering.
9
+ */
10
+ export type FormActionResult = {
11
+ errors?: Array<{ path?: Array<{ key?: string }>; message: string }>;
12
+ values?: Record<string, string>;
13
+ } | null | undefined;
14
+
4
15
  export type FormProps = Omit<HTMLFormAttributes, 'class' | 'children'> & {
5
16
  method?: 'GET' | 'POST';
6
17
  action?: string;
7
18
  enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
19
+ /**
20
+ * Pass the page's `form` prop here. Form derives a `name → message` errors
21
+ * map and exposes it via context so descendant `<Field name="...">` looks
22
+ * up its own error automatically.
23
+ */
24
+ form?: FormActionResult;
8
25
  class?: string;
9
26
  children?: Snippet;
10
27
  };
@@ -0,0 +1,18 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type PopoverSide = 'top' | 'right' | 'bottom' | 'left';
4
+ export type PopoverAlign = 'start' | 'center' | 'end';
5
+
6
+ export type PopoverProps = {
7
+ open?: boolean;
8
+ side?: PopoverSide;
9
+ align?: PopoverAlign;
10
+ sideOffset?: number;
11
+ trigger?: Snippet;
12
+ children?: Snippet;
13
+ triggerClass?: string;
14
+ class?: string;
15
+ };
16
+
17
+ declare const Popover: Component<PopoverProps>;
18
+ export default Popover;
@@ -0,0 +1,20 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type SheetSide = 'top' | 'right' | 'bottom' | 'left';
4
+
5
+ export type SheetProps = {
6
+ open?: boolean;
7
+ side?: SheetSide;
8
+ title?: string;
9
+ description?: string;
10
+ hideClose?: boolean;
11
+ trigger?: Snippet;
12
+ actions?: Snippet;
13
+ children?: Snippet;
14
+ triggerClass?: string;
15
+ overlayClass?: string;
16
+ class?: string;
17
+ };
18
+
19
+ declare const Sheet: Component<SheetProps>;
20
+ export default Sheet;
@@ -0,0 +1,17 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type TabsItem = {
4
+ value: string;
5
+ label: string;
6
+ panel?: Snippet;
7
+ disabled?: boolean;
8
+ };
9
+
10
+ export type TabsProps = {
11
+ value?: string;
12
+ items?: TabsItem[];
13
+ class?: string;
14
+ };
15
+
16
+ declare const Tabs: Component<TabsProps>;
17
+ export default Tabs;
@@ -0,0 +1,15 @@
1
+ import type { Component } from 'svelte';
2
+
3
+ export type ToastVariant = 'info' | 'success' | 'warning' | 'error';
4
+
5
+ export type ToastProviderProps = {
6
+ class?: string;
7
+ };
8
+
9
+ export type ToastOpts = {
10
+ variant?: ToastVariant;
11
+ duration?: number;
12
+ };
13
+
14
+ declare const ToastProvider: Component<ToastProviderProps>;
15
+ export default ToastProvider;
@@ -0,0 +1,15 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ export type TooltipProps = {
4
+ content?: string;
5
+ side?: 'top' | 'right' | 'bottom' | 'left';
6
+ sideOffset?: number;
7
+ delay?: number;
8
+ trigger?: Snippet;
9
+ children?: Snippet;
10
+ triggerClass?: string;
11
+ class?: string;
12
+ };
13
+
14
+ declare const Tooltip: Component<TooltipProps>;
15
+ export default Tooltip;