@human-synthesis/norns-ui 0.0.3 → 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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -225
  3. package/package.json +13 -3
  4. package/src/auto-import.js +81 -19
  5. package/src/components/Accordion.n +39 -0
  6. package/src/components/Audio.n +24 -0
  7. package/src/components/Autocomplete.n +134 -0
  8. package/src/components/Avatar.n +27 -0
  9. package/src/components/AvatarGroup.n +25 -0
  10. package/src/components/Badge.n +20 -0
  11. package/src/components/Banner.n +25 -0
  12. package/src/components/Breadcrumbs.n +22 -0
  13. package/src/components/Btn.n +29 -8
  14. package/src/components/ButtonGroup.n +14 -0
  15. package/src/components/Calendar.n +153 -0
  16. package/src/components/Card.n +34 -0
  17. package/src/components/Carousel.n +66 -0
  18. package/src/components/Chip.n +27 -0
  19. package/src/components/Collapsible.n +29 -0
  20. package/src/components/ColorPicker.n +62 -0
  21. package/src/components/ContextMenu.n +67 -0
  22. package/src/components/CopyButton.n +50 -0
  23. package/src/components/DataTable.n +61 -0
  24. package/src/components/DatePicker.n +56 -0
  25. package/src/components/DateRangePicker.n +47 -0
  26. package/src/components/Dialog.n +35 -22
  27. package/src/components/Dropdown.n +54 -23
  28. package/src/components/GradientText.n +21 -0
  29. package/src/components/Header.n +23 -0
  30. package/src/components/HeroBanner.n +37 -0
  31. package/src/components/HierarchicalMenu.n +38 -0
  32. package/src/components/Icon.n +16 -0
  33. package/src/components/Image.n +31 -0
  34. package/src/components/MegaMenu.n +36 -0
  35. package/src/components/MultiSelect.n +158 -0
  36. package/src/components/NumberInput.n +49 -0
  37. package/src/components/OtpField.n +67 -0
  38. package/src/components/Pagination.n +73 -0
  39. package/src/components/Popover.n +30 -15
  40. package/src/components/Progress.n +26 -0
  41. package/src/components/ProgressCircular.n +52 -0
  42. package/src/components/RippleButton.n +56 -0
  43. package/src/components/ScrollArea.n +20 -0
  44. package/src/components/Separator.n +12 -0
  45. package/src/components/Sheet.n +36 -22
  46. package/src/components/ShinyButton.n +36 -0
  47. package/src/components/Skeleton.n +27 -0
  48. package/src/components/Stepper.n +35 -0
  49. package/src/components/Surface.n +10 -0
  50. package/src/components/Tabs.n +39 -13
  51. package/src/components/TagsInput.n +66 -0
  52. package/src/components/ThemeToggler.n +43 -0
  53. package/src/components/TimePicker.n +153 -0
  54. package/src/components/Timeline.n +27 -0
  55. package/src/components/ToggleButton.n +35 -0
  56. package/src/components/ToggleButtonGroup.n +54 -0
  57. package/src/components/Toolbar.n +15 -0
  58. package/src/components/Tooltip.n +52 -19
  59. package/src/components/Tree.n +57 -0
  60. package/src/components/Uploader.n +88 -0
  61. package/src/components/Video.n +26 -0
  62. package/src/index.js +64 -1
  63. package/src/lib/behaviors/clickOutside.svelte.js +54 -0
  64. package/src/lib/behaviors/escape.svelte.js +36 -0
  65. package/src/lib/behaviors/floating.svelte.js +137 -0
  66. package/src/lib/behaviors/focusTrap.svelte.js +80 -0
  67. package/src/lib/behaviors/index.js +14 -0
  68. package/src/lib/behaviors/portal.svelte.js +32 -0
  69. package/src/lib/behaviors/rovingTabindex.svelte.js +76 -0
  70. package/src/lib/behaviors/scrollLock.svelte.js +56 -0
  71. package/src/motion/AnimatedNumber.n +55 -0
  72. package/src/motion/GradientBackground.n +25 -0
  73. package/src/motion/LiquidButton.n +48 -0
  74. package/src/motion/Reveal.n +37 -0
  75. package/src/motion/Sparkles.n +53 -0
  76. package/src/motion/index.js +18 -0
  77. package/src/styles/atoms.css +1481 -223
  78. package/src/styles/tokens.css +204 -40
  79. package/src/types/Accordion.d.ts +17 -0
  80. package/src/types/AnimatedNumber.d.ts +15 -0
  81. package/src/types/Audio.d.ts +18 -0
  82. package/src/types/Autocomplete.d.ts +17 -0
  83. package/src/types/Avatar.d.ts +14 -0
  84. package/src/types/AvatarGroup.d.ts +15 -0
  85. package/src/types/Badge.d.ts +14 -0
  86. package/src/types/Banner.d.ts +15 -0
  87. package/src/types/Breadcrumbs.d.ts +15 -0
  88. package/src/types/Btn.d.ts +10 -0
  89. package/src/types/ButtonGroup.d.ts +10 -0
  90. package/src/types/Calendar.d.ts +20 -0
  91. package/src/types/Card.d.ts +19 -0
  92. package/src/types/Carousel.d.ts +23 -0
  93. package/src/types/Chip.d.ts +12 -0
  94. package/src/types/Collapsible.d.ts +13 -0
  95. package/src/types/ColorPicker.d.ts +15 -0
  96. package/src/types/ContextMenu.d.ts +24 -0
  97. package/src/types/CopyButton.d.ts +16 -0
  98. package/src/types/DataTable.d.ts +27 -0
  99. package/src/types/DatePicker.d.ts +23 -0
  100. package/src/types/DateRangePicker.d.ts +17 -0
  101. package/src/types/Dialog.d.ts +4 -0
  102. package/src/types/Dropdown.d.ts +2 -1
  103. package/src/types/GradientBackground.d.ts +14 -0
  104. package/src/types/GradientText.d.ts +19 -0
  105. package/src/types/Header.d.ts +12 -0
  106. package/src/types/HeroBanner.d.ts +17 -0
  107. package/src/types/HierarchicalMenu.d.ts +20 -0
  108. package/src/types/Icon.d.ts +21 -0
  109. package/src/types/Image.d.ts +15 -0
  110. package/src/types/LiquidButton.d.ts +15 -0
  111. package/src/types/MegaMenu.d.ts +29 -0
  112. package/src/types/MultiSelect.d.ts +16 -0
  113. package/src/types/NumberInput.d.ts +17 -0
  114. package/src/types/OtpField.d.ts +13 -0
  115. package/src/types/Pagination.d.ts +13 -0
  116. package/src/types/Progress.d.ts +14 -0
  117. package/src/types/ProgressCircular.d.ts +14 -0
  118. package/src/types/Reveal.d.ts +20 -0
  119. package/src/types/RippleButton.d.ts +15 -0
  120. package/src/types/ScrollArea.d.ts +11 -0
  121. package/src/types/Separator.d.ts +9 -0
  122. package/src/types/Sheet.d.ts +3 -0
  123. package/src/types/ShinyButton.d.ts +15 -0
  124. package/src/types/Skeleton.d.ts +13 -0
  125. package/src/types/Sparkles.d.ts +13 -0
  126. package/src/types/Stepper.d.ts +19 -0
  127. package/src/types/Surface.d.ts +10 -0
  128. package/src/types/Tabs.d.ts +2 -0
  129. package/src/types/TagsInput.d.ts +17 -0
  130. package/src/types/ThemeToggler.d.ts +17 -0
  131. package/src/types/TimePicker.d.ts +20 -0
  132. package/src/types/Timeline.d.ts +17 -0
  133. package/src/types/ToggleButton.d.ts +16 -0
  134. package/src/types/ToggleButtonGroup.d.ts +21 -0
  135. package/src/types/Toolbar.d.ts +12 -0
  136. package/src/types/Tooltip.d.ts +3 -0
  137. package/src/types/Tree.d.ts +20 -0
  138. package/src/types/Uploader.d.ts +15 -0
  139. package/src/types/Video.d.ts +20 -0
@@ -1,34 +1,67 @@
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?.()}
1
+ span.tooltip-trigger-wrap(
2
+ use:refAction
3
+ class!="{triggerClass ?? ''}"
4
+ onmouseenter!="{onEnter}"
5
+ onmouseleave!="{onLeave}"
6
+ onfocusin!="{onEnter}"
7
+ onfocusout!="{onLeave}"
8
+ )
9
+ | {@render trigger?.()}
10
+
11
+ +if('open')
12
+ div(
13
+ use:portal
14
+ use:floatAction
15
+ role="tooltip"
16
+ class!="{contentClasses}"
17
+ data-state="open"
18
+ )
19
+ +if('content')
20
+ | {content}
21
+ +if('children')
22
+ | {@render children()}
9
23
 
10
24
  <script>
11
- import { Tooltip } from 'bits-ui'
12
25
  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
26
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
27
+ import { useFloating } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
21
28
 
22
29
  {
23
30
  content
24
31
  side = 'top'
32
+ align = 'center'
25
33
  sideOffset = 6
26
34
  delay = 300
35
+ rich = false
27
36
  trigger
28
37
  children
29
38
  triggerClass
30
39
  class: extra = ''
31
- } .= $props()
40
+ } := $props()
41
+
42
+ open .= $state false
43
+ openTimer .= null
44
+ closeTimer .= null
45
+
46
+ placement := align === 'center' ? side : `${side}-${align}`
47
+
48
+ { reference: refAction, floating: floatAction } := useFloating
49
+ placement: placement
50
+ offset: sideOffset
51
+
52
+ contentClasses := cn rich ? 'tooltip-rich' : 'tooltip-content', extra
53
+
54
+ clear := =>
55
+ clearTimeout openTimer if openTimer
56
+ clearTimeout closeTimer if closeTimer
57
+ openTimer = null
58
+ closeTimer = null
59
+
60
+ onEnter := =>
61
+ clear()
62
+ openTimer = setTimeout (=> open = true), delay
32
63
 
33
- contentClasses := cn 'tooltip-content', extra
64
+ onLeave := =>
65
+ clear()
66
+ closeTimer = setTimeout (=> open = false), 100
34
67
  </script>
@@ -0,0 +1,57 @@
1
+ ul.tree(class!="{classes}" role="tree")
2
+ +each('items as node')
3
+ | {@render renderNode(node, 0)}
4
+
5
+ +snippet('renderNode', node, depth)
6
+ li.tree-node(
7
+ role="treeitem"
8
+ aria-expanded!="{hasChildren(node) ? isExpanded(node.id) : undefined}"
9
+ aria-selected!="{node.id === selected}"
10
+ )
11
+ button.tree-row(
12
+ type="button"
13
+ class!="{node.id === selected ? 'tree-row-selected' : ''}"
14
+ style!="{`padding-left: ${0.5 + depth * 1.25}rem`}"
15
+ onclick!="{() => onRowClick(node)}"
16
+ )
17
+ +if('hasChildren(node)')
18
+ span.tree-chevron(class!="{isExpanded(node.id) ? 'tree-chevron-open' : ''}")
19
+ Icon(name="lucide:chevron-right" size="size-3.5")
20
+ +if('!hasChildren(node)')
21
+ span.tree-chevron-spacer
22
+ +if('node.icon')
23
+ Icon(name!="{node.icon}" size="size-4" class="text-fg-muted shrink-0")
24
+ span.tree-label
25
+ | {node.label}
26
+ +if('hasChildren(node) && isExpanded(node.id)')
27
+ ul(role="group")
28
+ +each('node.children as child')
29
+ | {@render renderNode(child, depth + 1)}
30
+
31
+ <script>
32
+ import { cn } from '@human-synthesis/norns-ui/cn'
33
+ import Icon from './Icon.n'
34
+
35
+ {
36
+ items = []
37
+ expanded = $bindable([])
38
+ selected = $bindable(undefined)
39
+ class: extra = ''
40
+ } .= $props()
41
+
42
+ classes := cn 'tree', extra
43
+
44
+ hasChildren := (node) => Array.isArray(node.children) && node.children.length > 0
45
+ isExpanded := (id) => expanded.includes(id)
46
+
47
+ toggle := (id) =>
48
+ if isExpanded(id)
49
+ expanded = expanded.filter (x) => x !== id
50
+ else
51
+ expanded = [...expanded, id]
52
+
53
+ onRowClick := (node) =>
54
+ if hasChildren(node)
55
+ toggle node.id
56
+ selected = node.id
57
+ </script>
@@ -0,0 +1,88 @@
1
+ .uploader(
2
+ class!="{classes}"
3
+ ondragover!="{onDragOver}"
4
+ ondragleave!="{onDragLeave}"
5
+ ondrop!="{onDrop}"
6
+ role="button"
7
+ tabindex="0"
8
+ onclick!="{() => inputEl?.click()}"
9
+ onkeydown!="{(e) => { if (e.key === 'Enter' || e.key === ' ') inputEl?.click() }}"
10
+ )
11
+ input(
12
+ type="file"
13
+ bind:this!="{inputEl}"
14
+ multiple!="{multiple}"
15
+ accept!="{accept}"
16
+ disabled!="{disabled}"
17
+ class="sr-only"
18
+ onchange!="{(e) => onPick(e.currentTarget.files)}"
19
+ )
20
+ Icon(name="lucide:upload-cloud" size="size-8" class="text-fg-muted")
21
+ .uploader-text
22
+ span.uploader-primary
23
+ | {placeholder}
24
+ +if('helper')
25
+ span.uploader-helper
26
+ | {helper}
27
+
28
+ +if('files.length > 0')
29
+ ul.uploader-list
30
+ +each('files as file, i')
31
+ li.uploader-item
32
+ Icon(name="lucide:file" size="size-4" class="text-fg-muted shrink-0")
33
+ .flex-1.min-w-0
34
+ .text-sm.truncate {file.name}
35
+ .text-xs(class="text-fg-muted") {humanSize(file.size)}
36
+ button.btn.btn-ghost.btn-sm(type="button" aria-label="Remove" onclick!="{() => remove(i)}")
37
+ Icon(name="lucide:x" size="size-3.5")
38
+
39
+ <script>
40
+ import { cn } from '@human-synthesis/norns-ui/cn'
41
+ import Icon from './Icon.n'
42
+
43
+ {
44
+ files = $bindable([])
45
+ multiple = true
46
+ accept
47
+ disabled = false
48
+ placeholder = 'Drop files here or click to browse'
49
+ helper
50
+ onchange
51
+ class: extra = ''
52
+ } .= $props()
53
+
54
+ inputEl .= $state null
55
+ dragging .= $state false
56
+
57
+ classes := cn 'uploader-root', dragging && 'uploader-dragging', extra
58
+
59
+ humanSize := (n) =>
60
+ return '' unless n
61
+ if n < 1024
62
+ `${n} B`
63
+ else if n < 1024 * 1024
64
+ `${(n / 1024).toFixed(1)} KB`
65
+ else
66
+ `${(n / 1024 / 1024).toFixed(1)} MB`
67
+
68
+ onPick := (list) =>
69
+ return unless list
70
+ next := Array.from list
71
+ files = multiple ? [...files, ...next] : next.slice(0, 1)
72
+ onchange?.(files)
73
+
74
+ remove := (i) =>
75
+ files = files.filter (_, idx) => idx !== i
76
+ onchange?.(files)
77
+
78
+ onDragOver := (e) =>
79
+ e.preventDefault()
80
+ dragging = true
81
+
82
+ onDragLeave := => dragging = false
83
+
84
+ onDrop := (e) =>
85
+ e.preventDefault()
86
+ dragging = false
87
+ onPick e.dataTransfer?.files
88
+ </script>
@@ -0,0 +1,26 @@
1
+ video(src!="{src}" controls!="{controls}" autoplay!="{autoplay}" loop!="{looping}" muted!="{muted}" playsinline!="{playsinline}" preload!="{preload}" poster!="{poster}" class!="{classes}")
2
+ +each('sources as source')
3
+ source(src!="{source.src}" type!="{source.type}")
4
+ | {fallback}
5
+
6
+ <script>
7
+ import { cn } from '@human-synthesis/norns-ui/cn'
8
+
9
+ // Note: HTML attribute `loop` becomes `looping` here because `loop` is a
10
+ // Civet reserved word.
11
+ {
12
+ src
13
+ sources = []
14
+ controls = true
15
+ autoplay = false
16
+ loop: looping = false
17
+ muted = false
18
+ playsinline = true
19
+ preload = 'metadata'
20
+ poster
21
+ fallback = 'Your browser does not support the video element.'
22
+ class: extra = ''
23
+ } := $props()
24
+
25
+ classes := cn 'video-player block w-full rounded-md', extra
26
+ </script>
package/src/index.js CHANGED
@@ -21,7 +21,7 @@ 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)
24
+ // Phase 3 — behavior tier (custom on @floating-ui/dom + behaviors module)
25
25
  export { default as Dialog } from './components/Dialog.n';
26
26
  export { default as Sheet } from './components/Sheet.n';
27
27
  export { default as Popover } from './components/Popover.n';
@@ -30,6 +30,69 @@ export { default as Tooltip } from './components/Tooltip.n';
30
30
  export { default as Tabs } from './components/Tabs.n';
31
31
  export { default as ToastProvider } from './components/ToastProvider.n';
32
32
 
33
+ // 0.0.4 — display layer
34
+ export { default as Icon } from './components/Icon.n';
35
+ export { default as Card } from './components/Card.n';
36
+ export { default as Surface } from './components/Surface.n';
37
+ export { default as Banner } from './components/Banner.n';
38
+ export { default as Badge } from './components/Badge.n';
39
+ export { default as Chip } from './components/Chip.n';
40
+ export { default as Avatar } from './components/Avatar.n';
41
+ export { default as Skeleton } from './components/Skeleton.n';
42
+ export { default as Progress } from './components/Progress.n';
43
+ export { default as ProgressCircular } from './components/ProgressCircular.n';
44
+
45
+ // 0.0.4 — composite layer
46
+ export { default as Header } from './components/Header.n';
47
+ export { default as HeroBanner } from './components/HeroBanner.n';
48
+ export { default as Stepper } from './components/Stepper.n';
49
+ export { default as Breadcrumbs } from './components/Breadcrumbs.n';
50
+ export { default as Pagination } from './components/Pagination.n';
51
+ export { default as Accordion } from './components/Accordion.n';
52
+ export { default as Carousel } from './components/Carousel.n';
53
+ export { default as Tree } from './components/Tree.n';
54
+
55
+ // 0.0.4 — wave A
56
+ export { default as Separator } from './components/Separator.n';
57
+ export { default as Toolbar } from './components/Toolbar.n';
58
+ export { default as ButtonGroup } from './components/ButtonGroup.n';
59
+ export { default as ToggleButton } from './components/ToggleButton.n';
60
+ export { default as ToggleButtonGroup } from './components/ToggleButtonGroup.n';
61
+ export { default as Collapsible } from './components/Collapsible.n';
62
+ export { default as NumberInput } from './components/NumberInput.n';
63
+ export { default as OtpField } from './components/OtpField.n';
64
+ export { default as TagsInput } from './components/TagsInput.n';
65
+ export { default as Image } from './components/Image.n';
66
+ export { default as Audio } from './components/Audio.n';
67
+ export { default as Video } from './components/Video.n';
68
+ export { default as Timeline } from './components/Timeline.n';
69
+
70
+ // 0.0.4 — wave B
71
+ export { default as Autocomplete } from './components/Autocomplete.n';
72
+ export { default as MultiSelect } from './components/MultiSelect.n';
73
+ export { default as ContextMenu } from './components/ContextMenu.n';
74
+ export { default as ScrollArea } from './components/ScrollArea.n';
75
+ export { default as ColorPicker } from './components/ColorPicker.n';
76
+ export { default as Uploader } from './components/Uploader.n';
77
+ export { default as HierarchicalMenu } from './components/HierarchicalMenu.n';
78
+ export { default as MegaMenu } from './components/MegaMenu.n';
79
+
80
+ // 0.0.4 — wave C
81
+ export { default as DatePicker } from './components/DatePicker.n';
82
+ export { default as DateRangePicker } from './components/DateRangePicker.n';
83
+ export { default as TimePicker } from './components/TimePicker.n';
84
+ export { default as Calendar } from './components/Calendar.n';
85
+ export { default as DataTable } from './components/DataTable.n';
86
+
87
+ // 0.0.5 — CSS-only motion components
88
+ export { default as AvatarGroup } from './components/AvatarGroup.n';
89
+ export { default as GradientText } from './components/GradientText.n';
90
+ export { default as CopyButton } from './components/CopyButton.n';
91
+ export { default as ThemeToggler } from './components/ThemeToggler.n';
92
+ export { default as ShinyButton } from './components/ShinyButton.n';
93
+ export { default as RippleButton } from './components/RippleButton.n';
94
+ // Motion-driven: `import … from '@human-synthesis/norns-ui/motion'`.
95
+
33
96
  // Helpers
34
97
  export { cn } from './lib/cn.js';
35
98
  export { variantClasses } from './lib/variants.js';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Svelte action: invoke `handler` when a pointerdown occurs outside `node`.
3
+ *
4
+ * div(use:clickOutside={onClose})
5
+ * div(use:clickOutside={[onClose, open]})
6
+ * div(use:clickOutside={[onClose, open, triggerEl]})
7
+ *
8
+ * Three parameter shapes:
9
+ * - `handler` alone
10
+ * - `[handler, enabled]` — pass the open flag so the action no-ops while closed
11
+ * - `[handler, enabled, ignore]` — `ignore` is an element (or array of elements)
12
+ * whose subtree also counts as "inside." Use this to exclude the trigger
13
+ * button so clicking it doesn't close-then-reopen the overlay.
14
+ *
15
+ * Listens on `pointerdown` (capture) so it fires before inner handlers get
16
+ * a chance to stop propagation.
17
+ */
18
+ export function clickOutside(node, paramOrHandler) {
19
+ let handler;
20
+ let enabled = true;
21
+ let ignoreEls = [];
22
+ const set = (value) => {
23
+ if (Array.isArray(value)) {
24
+ handler = value[0];
25
+ enabled = value[1] !== false;
26
+ const ig = value[2];
27
+ ignoreEls = ig == null ? [] : Array.isArray(ig) ? ig : [ig];
28
+ } else {
29
+ handler = value;
30
+ enabled = true;
31
+ ignoreEls = [];
32
+ }
33
+ };
34
+ set(paramOrHandler);
35
+
36
+ const onPointerDown = (e) => {
37
+ if (!enabled || !handler) return;
38
+ if (node.contains(e.target)) return;
39
+ for (const el of ignoreEls) {
40
+ if (el && typeof el.contains === 'function' && el.contains(e.target)) return;
41
+ }
42
+ handler(e);
43
+ };
44
+ document.addEventListener('pointerdown', onPointerDown, true);
45
+
46
+ return {
47
+ update(value) {
48
+ set(value);
49
+ },
50
+ destroy() {
51
+ document.removeEventListener('pointerdown', onPointerDown, true);
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Svelte action: invoke `handler` on Escape keydown while the action is
3
+ * mounted. Honors disabled state via the second-tuple parameter form:
4
+ *
5
+ * div(use:escape={[onClose, open]})
6
+ */
7
+ export function escape(node, paramOrHandler) {
8
+ let handler;
9
+ let enabled = true;
10
+ const set = (value) => {
11
+ if (Array.isArray(value)) {
12
+ handler = value[0];
13
+ enabled = value[1] !== false;
14
+ } else {
15
+ handler = value;
16
+ enabled = true;
17
+ }
18
+ };
19
+ set(paramOrHandler);
20
+
21
+ const onKeyDown = (e) => {
22
+ if (!enabled || !handler) return;
23
+ if (e.key !== 'Escape') return;
24
+ handler(e);
25
+ };
26
+ document.addEventListener('keydown', onKeyDown);
27
+
28
+ return {
29
+ update(value) {
30
+ set(value);
31
+ },
32
+ destroy() {
33
+ document.removeEventListener('keydown', onKeyDown);
34
+ }
35
+ };
36
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Floating-element positioning, built on `@floating-ui/dom`.
3
+ *
4
+ * import { useFloating } from '$lib/behaviors/floating.svelte.js'
5
+ *
6
+ * floating := useFloating
7
+ * placement: 'bottom-start'
8
+ * offset: 8
9
+ *
10
+ * ...
11
+ * div(use:floating.reference)
12
+ * div(use:floating.floating)
13
+ *
14
+ * The action sets `position: absolute` + `left`/`top` on the floating
15
+ * element, plus a `data-placement` attribute reflecting the actually-used
16
+ * placement after collision flips.
17
+ *
18
+ * Imperative `update()` is exposed too — call it after content size changes.
19
+ */
20
+ import { computePosition, flip, shift, offset as offsetMiddleware, autoUpdate, size } from '@floating-ui/dom';
21
+
22
+ /**
23
+ * @param {object} [opts]
24
+ * @param {import('@floating-ui/dom').Placement} [opts.placement] — e.g. 'bottom-start'
25
+ * @param {number} [opts.offset] — gap from reference, in px
26
+ * @param {number} [opts.padding] — viewport padding for collision
27
+ * @param {boolean} [opts.matchWidth] — match floating's min-width to reference width
28
+ * @param {boolean} [opts.flip] — enable collision flip (default true)
29
+ */
30
+ export function useFloating(opts = {}) {
31
+ const {
32
+ placement = 'bottom',
33
+ offset: gap = 8,
34
+ padding = 8,
35
+ matchWidth = false,
36
+ flip: flipOnCollision = true
37
+ } = opts;
38
+
39
+ let referenceEl = null;
40
+ let floatingEl = null;
41
+ let cleanupAutoUpdate = null;
42
+
43
+ const middleware = [offsetMiddleware(gap)];
44
+ if (flipOnCollision) middleware.push(flip({ padding }));
45
+ middleware.push(shift({ padding }));
46
+ if (matchWidth) {
47
+ middleware.push(
48
+ size({
49
+ apply({ rects, elements }) {
50
+ Object.assign(elements.floating.style, {
51
+ minWidth: `${rects.reference.width}px`
52
+ });
53
+ }
54
+ })
55
+ );
56
+ }
57
+
58
+ async function update() {
59
+ if (!referenceEl || !floatingEl) return;
60
+ const { x, y, placement: actualPlacement } = await computePosition(referenceEl, floatingEl, {
61
+ placement,
62
+ middleware,
63
+ strategy: 'absolute'
64
+ });
65
+ Object.assign(floatingEl.style, {
66
+ position: 'absolute',
67
+ left: `${x}px`,
68
+ top: `${y}px`
69
+ });
70
+ floatingEl.setAttribute('data-placement', actualPlacement);
71
+ }
72
+
73
+ function bind() {
74
+ if (cleanupAutoUpdate) cleanupAutoUpdate();
75
+ if (referenceEl && floatingEl) {
76
+ cleanupAutoUpdate = autoUpdate(referenceEl, floatingEl, update);
77
+ }
78
+ }
79
+
80
+ const reference = (node) => {
81
+ referenceEl = node;
82
+ bind();
83
+ return {
84
+ destroy() {
85
+ if (referenceEl === node) referenceEl = null;
86
+ if (cleanupAutoUpdate) cleanupAutoUpdate();
87
+ cleanupAutoUpdate = null;
88
+ }
89
+ };
90
+ };
91
+
92
+ const floating = (node) => {
93
+ floatingEl = node;
94
+ bind();
95
+ return {
96
+ destroy() {
97
+ if (floatingEl === node) floatingEl = null;
98
+ if (cleanupAutoUpdate) cleanupAutoUpdate();
99
+ cleanupAutoUpdate = null;
100
+ }
101
+ };
102
+ };
103
+
104
+ return { reference, floating, update };
105
+ }
106
+
107
+ /**
108
+ * Position a floating element relative to an arbitrary `{ x, y }` virtual
109
+ * reference (e.g. a right-click position for a context menu).
110
+ */
111
+ export function positionAt(floatingEl, x, y, opts = {}) {
112
+ const virtual = {
113
+ getBoundingClientRect: () => ({
114
+ x,
115
+ y,
116
+ width: 0,
117
+ height: 0,
118
+ left: x,
119
+ top: y,
120
+ right: x,
121
+ bottom: y
122
+ })
123
+ };
124
+ const { placement = 'right-start', offset: gap = 0, padding = 8 } = opts;
125
+ return computePosition(virtual, floatingEl, {
126
+ placement,
127
+ middleware: [offsetMiddleware(gap), flip({ padding }), shift({ padding })],
128
+ strategy: 'absolute'
129
+ }).then(({ x: fx, y: fy, placement: p }) => {
130
+ Object.assign(floatingEl.style, {
131
+ position: 'absolute',
132
+ left: `${fx}px`,
133
+ top: `${fy}px`
134
+ });
135
+ floatingEl.setAttribute('data-placement', p);
136
+ });
137
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Svelte action: trap Tab/Shift+Tab focus inside `node`.
3
+ *
4
+ * div(use:focusTrap)
5
+ *
6
+ * On mount, focuses the first focusable child (or the node itself if it's
7
+ * tabbable). On destroy, returns focus to the previously focused element
8
+ * outside the trap.
9
+ *
10
+ * Disable via tuple form:
11
+ * div(use:focusTrap={open})
12
+ */
13
+ const FOCUSABLE = [
14
+ 'a[href]',
15
+ 'button:not([disabled])',
16
+ 'input:not([disabled]):not([type="hidden"])',
17
+ 'select:not([disabled])',
18
+ 'textarea:not([disabled])',
19
+ '[tabindex]:not([tabindex="-1"])',
20
+ 'audio[controls]',
21
+ 'video[controls]',
22
+ '[contenteditable]:not([contenteditable="false"])'
23
+ ].join(',');
24
+
25
+ function focusable(node) {
26
+ return Array.from(node.querySelectorAll(FOCUSABLE)).filter(
27
+ (el) => !el.hasAttribute('disabled') && el.offsetParent !== null
28
+ );
29
+ }
30
+
31
+ export function focusTrap(node, enabled = true) {
32
+ let active = enabled !== false;
33
+ const previouslyFocused = document.activeElement;
34
+
35
+ const focusFirst = () => {
36
+ const items = focusable(node);
37
+ if (items.length > 0) {
38
+ items[0].focus();
39
+ } else {
40
+ node.setAttribute('tabindex', '-1');
41
+ node.focus();
42
+ }
43
+ };
44
+
45
+ const onKeyDown = (e) => {
46
+ if (!active || e.key !== 'Tab') return;
47
+ const items = focusable(node);
48
+ if (items.length === 0) {
49
+ e.preventDefault();
50
+ return;
51
+ }
52
+ const first = items[0];
53
+ const last = items[items.length - 1];
54
+ if (e.shiftKey && document.activeElement === first) {
55
+ e.preventDefault();
56
+ last.focus();
57
+ } else if (!e.shiftKey && document.activeElement === last) {
58
+ e.preventDefault();
59
+ first.focus();
60
+ }
61
+ };
62
+
63
+ if (active) {
64
+ // Defer one frame so the node is actually in the DOM after portal moves.
65
+ queueMicrotask(focusFirst);
66
+ }
67
+ node.addEventListener('keydown', onKeyDown);
68
+
69
+ return {
70
+ update(value) {
71
+ active = value !== false;
72
+ },
73
+ destroy() {
74
+ node.removeEventListener('keydown', onKeyDown);
75
+ if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
76
+ previouslyFocused.focus();
77
+ }
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public re-exports of the behavior actions used by the library's components.
3
+ * Consumers can use these directly in their own components for consistency
4
+ * with the library's interaction model.
5
+ *
6
+ * import { portal, clickOutside, escape, focusTrap, useFloating } from '@human-synthesis/norns-ui/behaviors'
7
+ */
8
+ export { portal } from './portal.svelte.js';
9
+ export { clickOutside } from './clickOutside.svelte.js';
10
+ export { escape } from './escape.svelte.js';
11
+ export { focusTrap } from './focusTrap.svelte.js';
12
+ export { lock as scrollLockAcquire, unlock as scrollLockRelease, scrollLock } from './scrollLock.svelte.js';
13
+ export { useFloating, positionAt } from './floating.svelte.js';
14
+ export { rovingTabindex } from './rovingTabindex.svelte.js';