@human-synthesis/norns-ui 0.0.5 → 0.0.6

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.
Files changed (50) hide show
  1. package/README.md +32 -20
  2. package/package.json +7 -3
  3. package/src/auto-import.js +1 -4
  4. package/src/components/Accordion.n +24 -23
  5. package/src/components/Autocomplete.n +108 -33
  6. package/src/components/Collapsible.n +8 -11
  7. package/src/components/ContextMenu.n +55 -36
  8. package/src/components/DatePicker.n +19 -31
  9. package/src/components/DateRangePicker.n +17 -29
  10. package/src/components/Dialog.n +35 -22
  11. package/src/components/Dropdown.n +54 -23
  12. package/src/components/MultiSelect.n +138 -36
  13. package/src/components/Popover.n +30 -15
  14. package/src/components/ScrollArea.n +9 -20
  15. package/src/components/Sheet.n +36 -22
  16. package/src/components/Tabs.n +39 -13
  17. package/src/components/TimePicker.n +52 -65
  18. package/src/components/ToggleButton.n +18 -12
  19. package/src/components/ToggleButtonGroup.n +31 -11
  20. package/src/components/Tooltip.n +52 -19
  21. package/src/index.js +1 -4
  22. package/src/lib/behaviors/clickOutside.svelte.js +54 -0
  23. package/src/lib/behaviors/escape.svelte.js +36 -0
  24. package/src/lib/behaviors/floating.svelte.js +137 -0
  25. package/src/lib/behaviors/focusTrap.svelte.js +80 -0
  26. package/src/lib/behaviors/index.js +14 -0
  27. package/src/lib/behaviors/portal.svelte.js +32 -0
  28. package/src/lib/behaviors/rovingTabindex.svelte.js +76 -0
  29. package/src/lib/behaviors/scrollLock.svelte.js +56 -0
  30. package/src/styles/atoms.css +622 -919
  31. package/src/styles/tokens.css +33 -0
  32. package/src/types/Collapsible.d.ts +1 -0
  33. package/src/types/ContextMenu.d.ts +6 -1
  34. package/src/types/DatePicker.d.ts +7 -2
  35. package/src/types/DateRangePicker.d.ts +3 -3
  36. package/src/types/Dialog.d.ts +4 -0
  37. package/src/types/Dropdown.d.ts +2 -1
  38. package/src/types/MultiSelect.d.ts +3 -0
  39. package/src/types/ScrollArea.d.ts +1 -1
  40. package/src/types/Sheet.d.ts +3 -0
  41. package/src/types/Tabs.d.ts +2 -0
  42. package/src/types/ToggleButton.d.ts +2 -1
  43. package/src/types/ToggleButtonGroup.d.ts +2 -1
  44. package/src/types/Tooltip.d.ts +3 -0
  45. package/src/components/Drawer.n +0 -49
  46. package/src/components/PreviewCard.n +0 -30
  47. package/src/components/RichTooltip.n +0 -33
  48. package/src/types/Drawer.d.ts +0 -18
  49. package/src/types/PreviewCard.d.ts +0 -16
  50. package/src/types/RichTooltip.d.ts +0 -16
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@human-synthesis/norns-ui`
2
2
 
3
- **UI library for the Norns ecosystem.** Pug + Civet components on Tailwind v4, with Bits UI behind the behavior tier and Iconify behind the icon system.
3
+ **UI library for the Norns ecosystem.** Pug + Civet components on Tailwind v4, with custom headless behaviors built on `@floating-ui/dom` and Iconify behind the icon system.
4
4
 
5
5
  ## Stack
6
6
 
@@ -8,7 +8,8 @@
8
8
  - [Pug](https://pugjs.org) — templates (in `.n` files)
9
9
  - [Civet](https://civet.dev) — `<script>` language
10
10
  - [Tailwind CSS v4](https://tailwindcss.com) — styling, **hard peer dep**
11
- - [Bits UI](https://bits-ui.com) — headless behavior (Dialog, Popover, Tabs, Accordion, )
11
+ - [@floating-ui/dom](https://floating-ui.com) — overlay positioning (Popover, Dropdown, Tooltip, ContextMenu, pickers)
12
+ - Custom in-tree headless behaviors (focus-trap, scroll-lock, click-outside, escape, portal, roving-tabindex) — exported at `@human-synthesis/norns-ui/behaviors`
12
13
  - [Iconify](https://iconify.design) — icons; ships with [Lucide](https://lucide.dev) preset
13
14
  - [tailwind-merge](https://github.com/dcastil/tailwind-merge) — class deduplication
14
15
  - [`@human-synthesis/norns`](https://github.com/human-synthesis/norns) — peer (`nornsAutoImport`)
@@ -49,22 +50,6 @@ export default {
49
50
  @import '@human-synthesis/norns-ui/styles';
50
51
  ```
51
52
 
52
- `svelte.config.js` — wrap our preprocessors so they no-op on `node_modules`. Without this, svelte-preprocess corrupts Bits UI's `<script lang="ts">` source:
53
-
54
- ```js
55
- const scopeToProject = (p) => ({
56
- name: p.name,
57
- markup: p.markup ? (a) => (a.filename?.includes('/node_modules/') ? null : p.markup(a)) : undefined,
58
- script: p.script ? (a) => (a.filename?.includes('/node_modules/') ? null : p.script(a)) : undefined,
59
- style: p.style ? (a) => (a.filename?.includes('/node_modules/') ? null : p.style(a)) : undefined
60
- });
61
-
62
- preprocess: [
63
- ...nornsPreprocess().map(scopeToProject),
64
- scopeToProject(nornsAutoImport({ /* … */ }))
65
- ]
66
- ```
67
-
68
53
  ## Dark mode
69
54
 
70
55
  Toggle via `<html data-theme="dark">` — every component swaps. Persist the choice with a small inline script in `app.html` to avoid FOUC:
@@ -115,15 +100,38 @@ Form(action="?/save" form!="{form}")
115
100
 
116
101
  ## What's in here
117
102
 
118
- - **Atoms** (CSS-only `@layer components`): `.btn`, `.input`, `.field`, `.form`, `.checkbox`, `.radio`, `.switch`, `.card`, `.banner`, `.badge`, `.chip`, `.avatar`, `.skeleton`, `.progress`, `.norns-header`, `.hero`, `.stepper`, `.breadcrumbs`, `.pagination`, `.accordion`, `.carousel`, plus variants/sizes.
103
+ - **Atoms** (CSS-only `@layer components`): `.btn`, `.input`, `.field`, `.form`, `.checkbox`, `.radio`, `.switch`, `.card`, `.surface-elevated`, `.btn-icon`, `.banner`, `.badge`, `.chip`, `.avatar`, `.skeleton`, `.progress`, `.norns-header`, `.hero`, `.stepper`, `.breadcrumbs`, `.pagination`, `.accordion`, `.carousel`, plus variants/sizes.
119
104
  - **Forms**: `Btn`, `Form`, `Field`, `FieldGroup`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`.
120
- - **Behavior** (Bits UI): `Dialog`, `Sheet`, `Popover`, `Dropdown`, `Tooltip`, `Tabs`, `Accordion`, `Drawer`.
105
+ - **Behavior** (custom on `@floating-ui/dom`): `Dialog`, `Sheet`, `Popover`, `Dropdown`, `Tooltip`, `Tabs`, `Accordion`, `ContextMenu`, `Collapsible`.
121
106
  - **Display**: `Card`, `Surface`, `Banner`, `Badge`, `Chip`, `Avatar`, `Skeleton`, `Progress`, `ProgressCircular`, `Icon`.
122
107
  - **Composite**: `Header`, `HeroBanner`, `Stepper`, `Breadcrumbs`, `Pagination`, `Carousel`.
123
108
  - **Toast**: `ToastProvider` + `toast()` / `notify()` / `dismiss()` from `@human-synthesis/norns-ui/toast`.
124
109
 
125
110
  Hand-rolled `.d.ts` shims live under `src/types/`. Override any component by dropping `src/lib/components/<Name>.n` in your project — `nornsAutoImport`'s first-match-wins shadows the library silently.
126
111
 
112
+ ## Behaviors
113
+
114
+ A small set of headless behaviors is exposed at `@human-synthesis/norns-ui/behaviors` for use in your own components:
115
+
116
+ ```js
117
+ import {
118
+ portal, clickOutside, escape, focusTrap,
119
+ scrollLock, useFloating, rovingTabindex
120
+ } from '@human-synthesis/norns-ui/behaviors';
121
+ ```
122
+
123
+ These are Svelte 5 actions (`use:portal`, `use:clickOutside={handler}`, etc.) and a small `useFloating()` factory that wraps `@floating-ui/dom`. They're what every overlay component in this library is built on, so reaching for them in your own code keeps interactions consistent.
124
+
125
+ ## Migrating from 0.0.5 → 0.0.6
126
+
127
+ bits-ui has been removed; overlays are now built on `@floating-ui/dom` plus the in-tree behaviors module. Most APIs are unchanged. The breaking changes:
128
+
129
+ - `<Drawer>` is gone. Use `<Sheet side="left">` (it now defaults to `'right'`; pass the side you want).
130
+ - `<RichTooltip>` is gone. Use `<Tooltip rich>`.
131
+ - `<PreviewCard>` is gone. Use `<Tooltip>` with the trigger as a link, e.g. `<Tooltip rich content="...">{#snippet trigger()}<a href=...>{/snippet}</Tooltip>`.
132
+ - `<ContextMenu>` no longer renders nested submenus. The `children` field on items is accepted but ignored.
133
+ - The `svelte.config.js` `scopeToProject` workaround is no longer needed (it existed only to keep svelte-preprocess from corrupting bits-ui's TypeScript source). Remove it from your config.
134
+
127
135
  ## Theming
128
136
 
129
137
  Tokens are in [`src/styles/tokens.css`](src/styles/tokens.css) as a Tailwind v4 `@theme` block. The library exposes:
@@ -132,6 +140,10 @@ Tokens are in [`src/styles/tokens.css`](src/styles/tokens.css) as a Tailwind v4
132
140
  - **Role tokens** that atoms reference (and that you should reach for in your own components): `--color-fg`, `--color-fg-muted`, `--color-fg-subtle`, `--color-bg`, `--color-bg-muted`, `--color-bg-subtle`, `--color-bg-elevated`, `--color-border`, `--color-border-strong`, `--color-ring`. These swap automatically on dark mode.
133
141
  - **Feedback tokens**: `--color-{success,warning,danger,info}-{bg,fg,border}` for tinted surfaces.
134
142
  - **Typography tokens**: `--font-sans`, `--font-mono`.
143
+ - **Sizing**: `--ui-radius-{sm,,lg,full}`, `--ui-h-{sm,md,lg}` (full button heights), `--ui-icon-btn-{sm,md,lg}` (square icon-button sizes).
144
+ - **Motion**: `--ui-motion-{fast,med,slow}` durations + `--ui-motion-ease`. All component transitions reference these.
145
+ - **Elevation**: `--ui-elev-{1,2,3}` (subtle / floating / modal). Dark mode swaps to deeper shadows.
146
+ - **Focus**: `--ui-focus-ring` — the single canonical focus indicator. Apply as `box-shadow: var(--ui-focus-ring)` on `:focus-visible`.
135
147
 
136
148
  Override by re-declaring `@theme { … }` in your `app.css` after the library import.
137
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
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)",
@@ -14,12 +14,16 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "bun test"
17
+ "test": "bun test",
18
+ "check:shims": "bun scripts/check-shims.mjs",
19
+ "check:shims:strict": "bun scripts/check-shims.mjs --strict"
18
20
  },
19
21
  "exports": {
20
22
  ".": "./src/index.js",
21
23
  "./auto-import": "./src/auto-import.js",
22
24
  "./cn": "./src/lib/cn.js",
25
+ "./behaviors": "./src/lib/behaviors/index.js",
26
+ "./behaviors/*": "./src/lib/behaviors/*",
23
27
  "./toast": "./src/lib/toast.svelte.js",
24
28
  "./styles": "./src/styles/index.css",
25
29
  "./styles/tokens": "./src/styles/tokens.css",
@@ -44,10 +48,10 @@
44
48
  }
45
49
  },
46
50
  "dependencies": {
51
+ "@floating-ui/dom": "^1.7.6",
47
52
  "@fontsource-variable/outfit": "^5.2.8",
48
53
  "@iconify-json/lucide": "^1.2.105",
49
54
  "@iconify/svelte": "^5.2.1",
50
- "bits-ui": "^2.18.1",
51
55
  "motion": "^12.38.0",
52
56
  "tailwind-merge": "^2.5.0"
53
57
  },
@@ -66,7 +66,6 @@ export function presetUI() {
66
66
 
67
67
  // 0.0.4 — composite layer
68
68
  Header: c('Header'),
69
- Drawer: c('Drawer'),
70
69
  HeroBanner: c('HeroBanner'),
71
70
  Stepper: c('Stepper'),
72
71
  Breadcrumbs: c('Breadcrumbs'),
@@ -90,12 +89,10 @@ export function presetUI() {
90
89
  Video: c('Video'),
91
90
  Timeline: c('Timeline'),
92
91
 
93
- // 0.0.4 — wave B: medium / Bits-UI-backed
92
+ // 0.0.4 — wave B: medium-complexity (custom; ex Bits-UI-backed)
94
93
  Autocomplete: c('Autocomplete'),
95
94
  MultiSelect: c('MultiSelect'),
96
95
  ContextMenu: c('ContextMenu'),
97
- RichTooltip: c('RichTooltip'),
98
- PreviewCard: c('PreviewCard'),
99
96
  ScrollArea: c('ScrollArea'),
100
97
  ColorPicker: c('ColorPicker'),
101
98
  Uploader: c('Uploader'),
@@ -1,32 +1,20 @@
1
- AccordionRoot(
2
- type!="{multiple ? 'multiple' : 'single'}"
3
- bind:value
4
- class!="{classes}"
5
- )
6
- +each('items as item')
7
- AccordionItem.accordion-item(value!="{item.value}")
8
- AccordionHeader
9
- AccordionTrigger.accordion-trigger
10
- span
11
- | {item.title}
12
- .accordion-chevron
13
- Icon(name="lucide:chevron-down" size="size-4")
14
- AccordionContent.accordion-content
1
+ .accordion(class!="{classes}")
2
+ +each('items as item (item.value)')
3
+ details.accordion-item(
4
+ open!="{isOpen(item.value)}"
5
+ ontoggle!="{(e) => onToggle(item.value, e.target.open)}"
6
+ )
7
+ summary.accordion-trigger
8
+ span {item.title}
9
+ .accordion-chevron
10
+ Icon(name="lucide:chevron-down" size="size-4")
11
+ .accordion-content
15
12
  | {@render item.content?.()}
16
13
 
17
14
  <script>
18
- import { Accordion } from 'bits-ui'
19
15
  import { cn } from '@human-synthesis/norns-ui/cn'
20
16
  import Icon from './Icon.n'
21
17
 
22
- {
23
- Root: AccordionRoot
24
- Item: AccordionItem
25
- Header: AccordionHeader
26
- Trigger: AccordionTrigger
27
- Content: AccordionContent
28
- } := Accordion
29
-
30
18
  {
31
19
  items = []
32
20
  multiple = false
@@ -35,4 +23,17 @@ AccordionRoot(
35
23
  } .= $props()
36
24
 
37
25
  classes := cn 'accordion', extra
26
+
27
+ isOpen := (v) =>
28
+ if multiple
29
+ Array.isArray(value) && value.includes v
30
+ else
31
+ value === v
32
+
33
+ onToggle := (v, isNowOpen) =>
34
+ if multiple
35
+ cur := Array.isArray(value) ? value : []
36
+ value = isNowOpen ? [...new Set([...cur, v])] : cur.filter((x) => x !== v)
37
+ else
38
+ value = isNowOpen ? v : (value === v ? undefined : value)
38
39
  </script>
@@ -1,39 +1,55 @@
1
- ComboboxRoot(bind:value bind:open type="single")
2
- .combobox-input-wrap
3
- ComboboxInput.input.combobox-input(
4
- placeholder!="{placeholder}"
5
- disabled!="{disabled}"
6
- oninput!="{(e) => term = e.currentTarget.value}"
7
- id!="{resolvedId}"
8
- aria-invalid!="{hasError ? 'true' : undefined}"
9
- )
10
- ComboboxTrigger.combobox-trigger(aria-label="Toggle options")
11
- Icon(name="lucide:chevrons-up-down" size="size-4")
12
- ComboboxPortal
13
- ComboboxContent.dropdown-content
14
- ComboboxViewport
15
- +each('filtered as item')
16
- ComboboxItem.dropdown-item(value!="{item.value}" label!="{item.label}")
17
- Icon(name="lucide:check" size="size-3.5" class="opacity-0 data-[selected]:opacity-100")
18
- span {item.label}
19
- +if('filtered.length === 0')
20
- .dropdown-item(class="text-fg-muted")
21
- | No matches
1
+ .combobox-input-wrap(use:refAction bind:this!="{wrapEl}")
2
+ input.input.combobox-input(
3
+ bind:this!="{inputEl}"
4
+ type="text"
5
+ bind:value!="{term}"
6
+ placeholder!="{placeholder}"
7
+ disabled!="{disabled}"
8
+ role="combobox"
9
+ aria-expanded!="{open}"
10
+ aria-controls!="{listboxId}"
11
+ aria-autocomplete="list"
12
+ aria-activedescendant!="{open && filtered[active] ? optionId(active) : undefined}"
13
+ id!="{resolvedId}"
14
+ aria-invalid!="{hasError ? 'true' : undefined}"
15
+ oninput!="{onInput}"
16
+ onkeydown!="{onKeyDown}"
17
+ onfocus!="{() => open = true}"
18
+ onblur!="{onBlur}"
19
+ )
20
+ button.combobox-trigger(type="button" tabindex="-1" aria-label="Toggle options" onclick!="{toggle}")
21
+ Icon(name="lucide:chevrons-up-down" size="size-4")
22
+
23
+ +if('open && filtered.length > 0')
24
+ ul(
25
+ use:portal
26
+ use:floatAction
27
+ use:clickOutside!="{[onClose, open, wrapEl]}"
28
+ class="dropdown-content combobox-listbox"
29
+ data-state="open"
30
+ id!="{listboxId}"
31
+ role="listbox"
32
+ )
33
+ +each('filtered as item, i (item.value)')
34
+ li.dropdown-item(
35
+ role="option"
36
+ id!="{optionId(i)}"
37
+ aria-selected!="{value === item.value}"
38
+ data-active!="{i === active ? 'true' : undefined}"
39
+ onpointerdown!="{(e) => { e.preventDefault(); pick(item) }}"
40
+ )
41
+ Icon(name="lucide:check" size="size-3.5" class!="{value === item.value ? '' : 'opacity-0'}")
42
+ span {item.label}
43
+ +if('open && filtered.length === 0')
44
+ div(use:portal use:floatAction use:clickOutside!="{[onClose, open, wrapEl]}" class="dropdown-content combobox-listbox" data-state="open")
45
+ .combobox-empty No matches
22
46
 
23
47
  <script>
24
- import { Combobox } from 'bits-ui'
25
- import Icon from './Icon.n'
26
48
  import { getContext } from 'svelte'
27
-
28
- {
29
- Root: ComboboxRoot
30
- Input: ComboboxInput
31
- Trigger: ComboboxTrigger
32
- Portal: ComboboxPortal
33
- Content: ComboboxContent
34
- Viewport: ComboboxViewport
35
- Item: ComboboxItem
36
- } := Combobox
49
+ import Icon from './Icon.n'
50
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
51
+ import { clickOutside } from '@human-synthesis/norns-ui/behaviors/clickOutside.svelte.js'
52
+ import { useFloating } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
37
53
 
38
54
  {
39
55
  items = []
@@ -50,10 +66,69 @@ ComboboxRoot(bind:value bind:open type="single")
50
66
  resolvedId := providedId ?? field?.id
51
67
  hasError := explicitError || field?.hasError || false
52
68
 
69
+ uid := Math.random().toString(36).slice(2, 8)
70
+ listboxId := `listbox-${uid}`
71
+ optionId := (i) => `option-${uid}-${i}`
72
+
73
+ { reference: refAction, floating: floatAction } := useFloating
74
+ placement: 'bottom-start'
75
+ offset: 4
76
+ matchWidth: true
77
+
53
78
  term .= $state ''
79
+ active .= $state 0
80
+ inputEl .= $state null
81
+ wrapEl .= $state null
82
+
83
+ // Reset term to selected label when value changes externally.
84
+ $effect =>
85
+ if value !== undefined
86
+ match := items.find (it) => it.value === value
87
+ term = match?.label ?? '' if match && term !== match.label
54
88
 
55
89
  filtered := $derived.by =>
56
90
  return items unless term
57
91
  t := term.toLowerCase()
58
92
  items.filter (it) => it.label.toLowerCase().includes(t)
93
+
94
+ $effect =>
95
+ active = 0 if active >= filtered.length
96
+
97
+ onInput := =>
98
+ open = true
99
+ active = 0
100
+
101
+ toggle := =>
102
+ open = !open
103
+ inputEl?.focus() if open
104
+
105
+ onClose := => open = false
106
+
107
+ onBlur := =>
108
+ // Close after click handlers complete; pointerdown picks before this fires.
109
+ setTimeout (=> open = false), 100
110
+
111
+ pick := (item) =>
112
+ value = item.value
113
+ term = item.label
114
+ open = false
115
+
116
+ onKeyDown := (e) =>
117
+ switch e.key
118
+ when 'ArrowDown'
119
+ e.preventDefault()
120
+ open = true
121
+ active = Math.min(filtered.length - 1, active + 1)
122
+ when 'ArrowUp'
123
+ e.preventDefault()
124
+ open = true
125
+ active = Math.max(0, active - 1)
126
+ when 'Enter'
127
+ if open && filtered[active]
128
+ e.preventDefault()
129
+ pick filtered[active]
130
+ when 'Escape'
131
+ open = false
132
+ when 'Tab'
133
+ open = false
59
134
  </script>
@@ -1,32 +1,29 @@
1
- CollapsibleRoot(bind:open class!="{classes}")
2
- CollapsibleTrigger.collapsible-trigger
3
- .collapsible-chevron(class!="{open ? 'collapsible-chevron-open' : ''}")
1
+ details.collapsible(class!="{classes}" bind:open ontoggle!="{onToggle}")
2
+ summary.collapsible-trigger
3
+ .collapsible-chevron
4
4
  Icon(name="lucide:chevron-right" size="size-4")
5
5
  +if('title')
6
6
  span.collapsible-title {title}
7
7
  +if('!title && trigger')
8
8
  | {@render trigger()}
9
- CollapsibleContent.collapsible-content
9
+ .collapsible-content
10
10
  | {@render children?.()}
11
11
 
12
12
  <script>
13
- import { Collapsible } from 'bits-ui'
14
13
  import { cn } from '@human-synthesis/norns-ui/cn'
15
14
  import Icon from './Icon.n'
16
15
 
17
- {
18
- Root: CollapsibleRoot
19
- Trigger: CollapsibleTrigger
20
- Content: CollapsibleContent
21
- } := Collapsible
22
-
23
16
  {
24
17
  open = $bindable(false)
25
18
  title
26
19
  trigger
27
20
  children
21
+ onopenchange
28
22
  class: extra = ''
29
23
  } .= $props()
30
24
 
31
25
  classes := cn 'collapsible', extra
26
+
27
+ onToggle := (e) =>
28
+ onopenchange?.(e.target.open)
32
29
  </script>
@@ -1,48 +1,67 @@
1
- ContextMenuRoot
2
- ContextMenuTrigger(class!="{triggerClass ?? ''}")
3
- | {@render trigger?.()}
4
- ContextMenuPortal
5
- ContextMenuContent.dropdown-content
6
- | {@render renderItems(items)}
7
-
8
- +snippet('renderItems', list)
9
- +each('list as item')
10
- +if('item.separator')
11
- ContextMenuSeparator.dropdown-separator
12
- +if('!item.separator && !item.children')
13
- ContextMenuItem.dropdown-item(disabled!="{item.disabled}" onSelect!="{item.onSelect}")
14
- +if('item.icon')
15
- Icon(name!="{item.icon}" size="size-3.5")
16
- span {item.label}
17
- +if('!item.separator && item.children')
18
- ContextMenuSubRoot
19
- ContextMenuSubTrigger.dropdown-item(disabled!="{item.disabled}")
1
+ span(class!="{triggerClass ?? ''}" oncontextmenu!="{onContext}")
2
+ | {@render trigger?.()}
3
+
4
+ +if('open')
5
+ div(
6
+ use:portal
7
+ use:clickOutside!="{[onClose, open]}"
8
+ use:escape!="{[onClose, open]}"
9
+ use:rovingTabindex!="{{ orientation: 'vertical', selector: '[data-roving]' }}"
10
+ bind:this!="{menuEl}"
11
+ class="dropdown-content"
12
+ data-state="open"
13
+ role="menu"
14
+ style="position: absolute"
15
+ )
16
+ +each('items as item')
17
+ +if('item.separator')
18
+ .dropdown-separator(role="separator")
19
+ +if('!item.separator')
20
+ button.dropdown-item(
21
+ type="button"
22
+ role="menuitem"
23
+ data-roving
24
+ data-disabled!="{item.disabled ? '' : undefined}"
25
+ disabled!="{item.disabled}"
26
+ onclick!="{() => onItem(item)}"
27
+ )
20
28
  +if('item.icon')
21
- Icon(name!="{item.icon}" size="size-3.5")
29
+ Icon(name!="{item.icon}" size="size-3.5" class="text-fg-muted shrink-0")
22
30
  span.flex-1 {item.label}
23
- Icon(name="lucide:chevron-right" size="size-3.5")
24
- ContextMenuSubContent.dropdown-content
25
- | {@render renderItems(item.children)}
26
31
 
27
32
  <script>
28
- import { ContextMenu } from 'bits-ui'
33
+ import { positionAt } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
34
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
35
+ import { clickOutside } from '@human-synthesis/norns-ui/behaviors/clickOutside.svelte.js'
36
+ import { escape } from '@human-synthesis/norns-ui/behaviors/escape.svelte.js'
37
+ import { rovingTabindex } from '@human-synthesis/norns-ui/behaviors/rovingTabindex.svelte.js'
29
38
  import Icon from './Icon.n'
30
39
 
31
- {
32
- Root: ContextMenuRoot
33
- Trigger: ContextMenuTrigger
34
- Portal: ContextMenuPortal
35
- Content: ContextMenuContent
36
- Item: ContextMenuItem
37
- Separator: ContextMenuSeparator
38
- Sub: ContextMenuSubRoot
39
- SubTrigger: ContextMenuSubTrigger
40
- SubContent: ContextMenuSubContent
41
- } := ContextMenu
42
-
43
40
  {
44
41
  items = []
45
42
  trigger
46
43
  triggerClass
47
44
  } := $props()
45
+
46
+ open .= $state false
47
+ menuEl .= $state null
48
+ clickX .= 0
49
+ clickY .= 0
50
+
51
+ onContext := (e) =>
52
+ e.preventDefault()
53
+ clickX = e.clientX
54
+ clickY = e.clientY
55
+ open = true
56
+
57
+ $effect =>
58
+ return unless open && menuEl
59
+ positionAt menuEl, clickX, clickY, { placement: 'right-start' }
60
+
61
+ onClose := => open = false
62
+
63
+ onItem := (item) =>
64
+ return if item.disabled
65
+ item.onSelect?.()
66
+ open = false
48
67
  </script>
@@ -1,41 +1,29 @@
1
- PopoverRoot(bind:open)
2
- PopoverTrigger.input.date-picker-trigger(
3
- type="button"
4
- disabled!="{disabled}"
5
- id!="{resolvedId}"
6
- aria-label!="{label ?? 'Pick a date'}"
7
- class!="{hasError ? 'input-err' : ''}"
8
- )
9
- Icon(name="lucide:calendar" size="size-4" class="text-fg-muted shrink-0")
10
- span.flex-1.text-left.truncate
11
- | {value ? formatted : placeholder}
12
- Icon(name="lucide:chevron-down" size="size-3.5" class="text-fg-muted shrink-0")
13
- PopoverPortal
14
- PopoverContent.popover-content(
15
- class="p-3 w-[20rem]"
16
- onOpenAutoFocus!="{(e) => e.preventDefault()}"
17
- onCloseAutoFocus!="{(e) => e.preventDefault()}"
1
+ Popover(bind:open class="p-3 w-[20rem]")
2
+ +snippet('trigger')
3
+ button.input.date-picker-trigger(
4
+ type="button"
5
+ disabled!="{disabled}"
6
+ id!="{resolvedId}"
7
+ aria-label!="{label ?? 'Pick a date'}"
8
+ class!="{hasError ? 'input-err' : ''}"
18
9
  )
19
- Calendar(
20
- bind:value
21
- min!="{min}"
22
- max!="{max}"
23
- onchange!="{handleChange}"
24
- )
10
+ Icon(name="lucide:calendar" size="size-4" class="text-fg-muted shrink-0")
11
+ span.flex-1.text-left.truncate
12
+ | {value ? formatted : placeholder}
13
+ Icon(name="lucide:chevron-down" size="size-3.5" class="text-fg-muted shrink-0")
14
+ Calendar(
15
+ bind:value
16
+ min!="{min}"
17
+ max!="{max}"
18
+ onchange!="{handleChange}"
19
+ )
25
20
 
26
21
  <script>
27
- import { Popover } from 'bits-ui'
28
22
  import { getContext } from 'svelte'
23
+ import Popover from './Popover.n'
29
24
  import Icon from './Icon.n'
30
25
  import Calendar from './Calendar.n'
31
26
 
32
- {
33
- Root: PopoverRoot
34
- Trigger: PopoverTrigger
35
- Portal: PopoverPortal
36
- Content: PopoverContent
37
- } := Popover
38
-
39
27
  {
40
28
  value = $bindable('')
41
29
  open = $bindable(false)
@@ -1,37 +1,25 @@
1
- PopoverRoot(bind:open)
2
- PopoverTrigger.input.date-picker-trigger(type="button" disabled!="{disabled}" aria-label="Pick a date range")
3
- Icon(name="lucide:calendar-range" size="size-4" class="text-fg-muted shrink-0")
4
- span.flex-1.text-left.truncate
5
- | {label}
6
- Icon(name="lucide:chevron-down" size="size-3.5" class="text-fg-muted shrink-0")
7
- PopoverPortal
8
- PopoverContent.popover-content(
9
- class="p-3 w-[20rem]"
10
- onOpenAutoFocus!="{(e) => e.preventDefault()}"
11
- onCloseAutoFocus!="{(e) => e.preventDefault()}"
12
- )
13
- Calendar(
14
- bind:rangeValue!="{value}"
15
- range!="{true}"
16
- min!="{min}"
17
- max!="{max}"
18
- )
19
- .flex.justify-end.gap-2.mt-3
20
- button.btn.btn-ghost.btn-sm(type="button" onclick!="{clear}") Clear
21
- button.btn.btn-primary.btn-sm(type="button" onclick!="{() => open = false}") Done
1
+ Popover(bind:open class="p-3 w-[20rem]")
2
+ +snippet('trigger')
3
+ button.input.date-picker-trigger(type="button" disabled!="{disabled}" aria-label="Pick a date range")
4
+ Icon(name="lucide:calendar-range" size="size-4" class="text-fg-muted shrink-0")
5
+ span.flex-1.text-left.truncate
6
+ | {label}
7
+ Icon(name="lucide:chevron-down" size="size-3.5" class="text-fg-muted shrink-0")
8
+ Calendar(
9
+ bind:rangeValue!="{value}"
10
+ range!="{true}"
11
+ min!="{min}"
12
+ max!="{max}"
13
+ )
14
+ .flex.justify-end.gap-2.mt-3
15
+ button.btn.btn-ghost.btn-sm(type="button" onclick!="{clear}") Clear
16
+ button.btn.btn-primary.btn-sm(type="button" onclick!="{() => open = false}") Done
22
17
 
23
18
  <script>
24
- import { Popover } from 'bits-ui'
19
+ import Popover from './Popover.n'
25
20
  import Icon from './Icon.n'
26
21
  import Calendar from './Calendar.n'
27
22
 
28
- {
29
- Root: PopoverRoot
30
- Trigger: PopoverTrigger
31
- Portal: PopoverPortal
32
- Content: PopoverContent
33
- } := Popover
34
-
35
23
  {
36
24
  value = $bindable({ start: '', end: '' })
37
25
  open = $bindable(false)