@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.
- package/README.md +32 -20
- package/package.json +7 -3
- package/src/auto-import.js +1 -4
- package/src/components/Accordion.n +24 -23
- package/src/components/Autocomplete.n +108 -33
- package/src/components/Collapsible.n +8 -11
- package/src/components/ContextMenu.n +55 -36
- package/src/components/DatePicker.n +19 -31
- package/src/components/DateRangePicker.n +17 -29
- package/src/components/Dialog.n +35 -22
- package/src/components/Dropdown.n +54 -23
- package/src/components/MultiSelect.n +138 -36
- package/src/components/Popover.n +30 -15
- package/src/components/ScrollArea.n +9 -20
- package/src/components/Sheet.n +36 -22
- package/src/components/Tabs.n +39 -13
- package/src/components/TimePicker.n +52 -65
- package/src/components/ToggleButton.n +18 -12
- package/src/components/ToggleButtonGroup.n +31 -11
- package/src/components/Tooltip.n +52 -19
- package/src/index.js +1 -4
- package/src/lib/behaviors/clickOutside.svelte.js +54 -0
- package/src/lib/behaviors/escape.svelte.js +36 -0
- package/src/lib/behaviors/floating.svelte.js +137 -0
- package/src/lib/behaviors/focusTrap.svelte.js +80 -0
- package/src/lib/behaviors/index.js +14 -0
- package/src/lib/behaviors/portal.svelte.js +32 -0
- package/src/lib/behaviors/rovingTabindex.svelte.js +76 -0
- package/src/lib/behaviors/scrollLock.svelte.js +56 -0
- package/src/styles/atoms.css +628 -920
- package/src/styles/tokens.css +33 -0
- package/src/types/Collapsible.d.ts +1 -0
- package/src/types/ContextMenu.d.ts +6 -1
- package/src/types/DatePicker.d.ts +7 -2
- package/src/types/DateRangePicker.d.ts +3 -3
- package/src/types/Dialog.d.ts +4 -0
- package/src/types/Dropdown.d.ts +2 -1
- package/src/types/MultiSelect.d.ts +3 -0
- package/src/types/ScrollArea.d.ts +1 -1
- package/src/types/Sheet.d.ts +3 -0
- package/src/types/Tabs.d.ts +2 -0
- package/src/types/ToggleButton.d.ts +2 -1
- package/src/types/ToggleButtonGroup.d.ts +2 -1
- package/src/types/Tooltip.d.ts +3 -0
- package/src/components/Drawer.n +0 -49
- package/src/components/PreviewCard.n +0 -30
- package/src/components/RichTooltip.n +0 -33
- package/src/types/Drawer.d.ts +0 -18
- package/src/types/PreviewCard.d.ts +0 -16
- package/src/types/RichTooltip.d.ts +0 -16
|
@@ -1,71 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,
|
|
148
|
+
scrollSelected := (col, val, attr) =>
|
|
162
149
|
return unless col
|
|
163
|
-
target := col.querySelector `[${attr}="${
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
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 = '
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
.btn-group(
|
|
2
|
+
role="group"
|
|
3
|
+
aria-label!="{label}"
|
|
4
4
|
class!="{classes}"
|
|
5
|
-
|
|
5
|
+
use:rovingTabindex!="{{ orientation: 'horizontal' }}"
|
|
6
6
|
)
|
|
7
7
|
+each('items as item')
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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>
|
package/src/components/Tooltip.n
CHANGED
|
@@ -1,34 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|