@human-synthesis/norns-ui 0.0.5 → 0.0.7

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 +628 -920
  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
@@ -1,71 +1,59 @@
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 time'}"
7
- class!="{hasError ? 'input-err' : ''}"
8
- )
9
- Icon(name="lucide:clock" size="size-4" class="text-fg-muted shrink-0")
10
- span.flex-1.text-left.font-mono.tabular-nums
11
- | {value || 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-2 time-picker-popover"
16
- onOpenAutoFocus!="{(e) => e.preventDefault()}"
17
- onCloseAutoFocus!="{(e) => e.preventDefault()}"
1
+ Popover(bind:open class="p-2 time-picker-popover")
2
+ +snippet('trigger')
3
+ button.input.date-picker-trigger(
4
+ type="button"
5
+ disabled!="{disabled}"
6
+ id!="{resolvedId}"
7
+ aria-label!="{label ?? 'Pick a time'}"
8
+ class!="{hasError ? 'input-err' : ''}"
18
9
  )
19
- .time-picker-grid
20
- .time-picker-col(role="listbox" aria-label="Hour" bind:this!="{hourCol}")
21
- +each('hours as h')
22
- button.time-picker-cell(
23
- type="button"
24
- class!="{h === currentHour ? 'time-picker-cell-selected' : ''}"
25
- onclick!="{() => setHour(h)}"
26
- data-time-h!="{h}"
27
- )
28
- | {pad(h)}
29
- .time-picker-sep :
30
- .time-picker-col(role="listbox" aria-label="Minute" bind:this!="{minuteCol}")
31
- +each('minutes as m')
32
- button.time-picker-cell(
33
- type="button"
34
- class!="{m === currentMinute ? 'time-picker-cell-selected' : ''}"
35
- onclick!="{() => setMinute(m)}"
36
- data-time-m!="{m}"
37
- )
38
- | {pad(m)}
39
- +if('hourCycle === 12')
40
- .time-picker-col(role="listbox" aria-label="AM/PM")
41
- button.time-picker-cell(
42
- type="button"
43
- class!="{period === 'AM' ? 'time-picker-cell-selected' : ''}"
44
- onclick!="{() => setPeriod('AM')}"
45
- )
46
- | AM
47
- button.time-picker-cell(
48
- type="button"
49
- class!="{period === 'PM' ? 'time-picker-cell-selected' : ''}"
50
- onclick!="{() => setPeriod('PM')}"
51
- )
52
- | PM
53
- .flex.justify-between.gap-2.mt-2.pt-2(class="border-t border-border")
54
- button.btn.btn-ghost.btn-sm(type="button" onclick!="{clearValue}") Clear
55
- button.btn.btn-primary.btn-sm(type="button" onclick!="{() => open = false}") Done
10
+ Icon(name="lucide:clock" size="size-4" class="text-fg-muted shrink-0")
11
+ span.flex-1.text-left.font-mono.tabular-nums
12
+ | {value || placeholder}
13
+ Icon(name="lucide:chevron-down" size="size-3.5" class="text-fg-muted shrink-0")
14
+ .time-picker-grid
15
+ .time-picker-col(role="listbox" aria-label="Hour" bind:this!="{hourCol}")
16
+ +each('hours as h')
17
+ button.time-picker-cell(
18
+ type="button"
19
+ class!="{h === currentHour ? 'time-picker-cell-selected' : ''}"
20
+ onclick!="{() => setHour(h)}"
21
+ data-time-h!="{h}"
22
+ )
23
+ | {pad(h)}
24
+ .time-picker-sep :
25
+ .time-picker-col(role="listbox" aria-label="Minute" bind:this!="{minuteCol}")
26
+ +each('minutes as m')
27
+ button.time-picker-cell(
28
+ type="button"
29
+ class!="{m === currentMinute ? 'time-picker-cell-selected' : ''}"
30
+ onclick!="{() => setMinute(m)}"
31
+ data-time-m!="{m}"
32
+ )
33
+ | {pad(m)}
34
+ +if('hourCycle === 12')
35
+ .time-picker-col(role="listbox" aria-label="AM/PM")
36
+ button.time-picker-cell(
37
+ type="button"
38
+ class!="{period === 'AM' ? 'time-picker-cell-selected' : ''}"
39
+ onclick!="{() => setPeriod('AM')}"
40
+ )
41
+ | AM
42
+ button.time-picker-cell(
43
+ type="button"
44
+ class!="{period === 'PM' ? 'time-picker-cell-selected' : ''}"
45
+ onclick!="{() => setPeriod('PM')}"
46
+ )
47
+ | PM
48
+ .flex.justify-between.gap-2.mt-2.pt-2(class="border-t border-border")
49
+ button.btn.btn-ghost.btn-sm(type="button" onclick!="{clearValue}") Clear
50
+ button.btn.btn-primary.btn-sm(type="button" onclick!="{() => open = false}") Done
56
51
 
57
52
  <script>
58
- import { Popover } from 'bits-ui'
59
53
  import { getContext } from 'svelte'
54
+ import Popover from './Popover.n'
60
55
  import Icon from './Icon.n'
61
56
 
62
- {
63
- Root: PopoverRoot
64
- Trigger: PopoverTrigger
65
- Portal: PopoverPortal
66
- Content: PopoverContent
67
- } := Popover
68
-
69
57
  {
70
58
  value = $bindable('')
71
59
  open = $bindable(false)
@@ -151,16 +139,15 @@ PopoverRoot(bind:open)
151
139
  hourCol .= $state null
152
140
  minuteCol .= $state null
153
141
 
154
- // When the popover opens, scroll each column to its currently-selected cell.
155
142
  $effect =>
156
143
  return unless open && (hourCol || minuteCol)
157
144
  queueMicrotask =>
158
145
  scrollSelected hourCol, currentHour, 'data-time-h'
159
146
  scrollSelected minuteCol, currentMinute, 'data-time-m'
160
147
 
161
- scrollSelected := (col, value, attr) =>
148
+ scrollSelected := (col, val, attr) =>
162
149
  return unless col
163
- target := col.querySelector `[${attr}="${value}"]`
150
+ target := col.querySelector `[${attr}="${val}"]`
164
151
  return unless target
165
152
  col.scrollTop = target.offsetTop - col.clientHeight / 2 + target.clientHeight / 2
166
153
  </script>
@@ -1,29 +1,35 @@
1
- ToggleRoot(bind:pressed class!="{classes}" disabled!="{disabled}" onPressedChange!="{onpressedchange}")
1
+ button(
2
+ type="button"
3
+ class!="{classes}"
4
+ disabled!="{disabled}"
5
+ data-pressed!="{pressed ? 'true' : undefined}"
6
+ aria-pressed!="{pressed}"
7
+ onclick!="{toggle}"
8
+ )
2
9
  +if('icon')
3
- Icon(name!="{icon}" size="size-4")
4
- | {@render children?.()}
10
+ Icon(name!="{icon}" size!="{iconSize}")
11
+ +if('children')
12
+ | {@render children()}
5
13
 
6
14
  <script>
7
- import { Toggle } from 'bits-ui'
8
15
  import { cn } from '@human-synthesis/norns-ui/cn'
9
16
  import Icon from './Icon.n'
10
17
 
11
- { Root: ToggleRoot } := Toggle
12
-
13
18
  {
14
19
  pressed = $bindable(false)
15
20
  disabled = false
16
21
  icon
17
- variant = 'default'
22
+ variant = 'secondary'
18
23
  size = 'md'
19
24
  onpressedchange
20
25
  children
21
26
  class: extra = ''
22
27
  } .= $props()
23
28
 
24
- classes := cn
25
- 'toggle-btn'
26
- variant !== 'default' && `toggle-btn-${variant}`
27
- size !== 'md' && `toggle-btn-${size}`
28
- extra
29
+ classes := cn 'btn', `btn-${variant}`, size !== 'md' && `btn-${size}`, extra
30
+ iconSize := size === 'sm' ? 'size-3.5' : size === 'lg' ? 'size-5' : 'size-4'
31
+
32
+ toggle := =>
33
+ pressed = !pressed
34
+ onpressedchange?.(pressed)
29
35
  </script>
@@ -1,14 +1,19 @@
1
- ToggleGroupRoot(
2
- type!="{multiple ? 'multiple' : 'single'}"
3
- bind:value
1
+ .btn-group(
2
+ role="group"
3
+ aria-label!="{label}"
4
4
  class!="{classes}"
5
- disabled!="{disabled}"
5
+ use:rovingTabindex!="{{ orientation: 'horizontal' }}"
6
6
  )
7
7
  +each('items as item')
8
- ToggleGroupItem.toggle-btn(
9
- value!="{item.value}"
10
- disabled!="{item.disabled}"
8
+ button(
9
+ type="button"
10
+ class!="{itemClasses(item.value)}"
11
+ data-roving
12
+ data-pressed!="{isActive(item.value) ? 'true' : undefined}"
13
+ aria-pressed!="{isActive(item.value)}"
14
+ disabled!="{disabled || item.disabled}"
11
15
  aria-label!="{item.label}"
16
+ onclick!="{() => toggle(item.value)}"
12
17
  )
13
18
  +if('item.icon')
14
19
  Icon(name!="{item.icon}" size="size-4")
@@ -16,19 +21,34 @@ ToggleGroupRoot(
16
21
  span {item.label}
17
22
 
18
23
  <script>
19
- import { ToggleGroup } from 'bits-ui'
20
24
  import { cn } from '@human-synthesis/norns-ui/cn'
25
+ import { rovingTabindex } from '@human-synthesis/norns-ui/behaviors/rovingTabindex.svelte.js'
21
26
  import Icon from './Icon.n'
22
27
 
23
- { Root: ToggleGroupRoot, Item: ToggleGroupItem } := ToggleGroup
24
-
25
28
  {
26
29
  items = []
27
30
  value = $bindable(undefined)
28
31
  multiple = false
29
32
  disabled = false
33
+ label = 'Toggle group'
30
34
  class: extra = ''
31
35
  } .= $props()
32
36
 
33
- classes := cn 'btn-group toggle-group', extra
37
+ classes := cn extra
38
+
39
+ isActive := (v) =>
40
+ if multiple
41
+ Array.isArray(value) && value.includes v
42
+ else
43
+ value === v
44
+
45
+ itemClasses := (v) => cn 'btn btn-secondary'
46
+
47
+ toggle := (v) =>
48
+ return if disabled
49
+ if multiple
50
+ cur := Array.isArray(value) ? value : []
51
+ value = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
52
+ else
53
+ value = value === v ? undefined : v
34
54
  </script>
@@ -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>
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';
@@ -44,7 +44,6 @@ export { default as ProgressCircular } from './components/ProgressCircular.n';
44
44
 
45
45
  // 0.0.4 — composite layer
46
46
  export { default as Header } from './components/Header.n';
47
- export { default as Drawer } from './components/Drawer.n';
48
47
  export { default as HeroBanner } from './components/HeroBanner.n';
49
48
  export { default as Stepper } from './components/Stepper.n';
50
49
  export { default as Breadcrumbs } from './components/Breadcrumbs.n';
@@ -72,8 +71,6 @@ export { default as Timeline } from './components/Timeline.n';
72
71
  export { default as Autocomplete } from './components/Autocomplete.n';
73
72
  export { default as MultiSelect } from './components/MultiSelect.n';
74
73
  export { default as ContextMenu } from './components/ContextMenu.n';
75
- export { default as RichTooltip } from './components/RichTooltip.n';
76
- export { default as PreviewCard } from './components/PreviewCard.n';
77
74
  export { default as ScrollArea } from './components/ScrollArea.n';
78
75
  export { default as ColorPicker } from './components/ColorPicker.n';
79
76
  export { default as Uploader } from './components/Uploader.n';
@@ -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
+ }