@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,15 +1,28 @@
1
- DialogRoot(bind:open)
2
- +if('trigger')
3
- DialogTrigger(class!="{triggerClass ?? undefined}")
4
- | {@render trigger()}
5
- DialogPortal
6
- DialogOverlay(class!="{overlayClasses}")
7
- DialogContent(class!="{contentClasses}")
1
+ +if('trigger')
2
+ span(class!="{triggerClass ?? ''}" onclick!="{() => open = true}")
3
+ | {@render trigger()}
4
+
5
+ +if('open')
6
+ div(use:portal use:scrollLock)
7
+ div(
8
+ data-state="open"
9
+ class!="{overlayClasses}"
10
+ onclick!="{onOverlayClick}"
11
+ )
12
+ div(
13
+ role="dialog"
14
+ aria-modal="true"
15
+ aria-label!="{title ?? ariaLabel}"
16
+ class!="{contentClasses}"
17
+ data-state="open"
18
+ use:focusTrap
19
+ use:escape!="{[onClose, open]}"
20
+ )
8
21
  +if('title')
9
- DialogTitle.dialog-title
22
+ h2.dialog-title
10
23
  | {title}
11
24
  +if('description')
12
- DialogDescription.dialog-description
25
+ p.dialog-description
13
26
  | {description}
14
27
  +if('children')
15
28
  .dialog-body
@@ -18,29 +31,23 @@ DialogRoot(bind:open)
18
31
  .dialog-actions
19
32
  | {@render actions()}
20
33
  +if('!hideClose')
21
- DialogClose.dialog-close(aria-label="Close")
34
+ button.dialog-close(type="button" aria-label="Close" onclick!="{onClose}")
22
35
  | ✕
23
36
 
24
37
  <script>
25
- import { Dialog } from 'bits-ui'
26
38
  import { cn } from '@human-synthesis/norns-ui/cn'
27
-
28
- {
29
- Root: DialogRoot
30
- Trigger: DialogTrigger
31
- Portal: DialogPortal
32
- Overlay: DialogOverlay
33
- Content: DialogContent
34
- Title: DialogTitle
35
- Description: DialogDescription
36
- Close: DialogClose
37
- } := Dialog
39
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
40
+ import { scrollLock } from '@human-synthesis/norns-ui/behaviors/scrollLock.svelte.js'
41
+ import { focusTrap } from '@human-synthesis/norns-ui/behaviors/focusTrap.svelte.js'
42
+ import { escape } from '@human-synthesis/norns-ui/behaviors/escape.svelte.js'
38
43
 
39
44
  {
40
45
  open = $bindable(false)
41
46
  title
42
47
  description
43
48
  hideClose = false
49
+ dismissable = true
50
+ ariaLabel
44
51
  trigger
45
52
  actions
46
53
  children
@@ -51,4 +58,10 @@ DialogRoot(bind:open)
51
58
 
52
59
  overlayClasses := cn 'dialog-overlay', overlayClass
53
60
  contentClasses := cn 'dialog-content', extra
61
+
62
+ onClose := => open = false
63
+
64
+ onOverlayClick := (e) =>
65
+ return unless dismissable
66
+ open = false if e.target === e.currentTarget
54
67
  </script>
@@ -1,29 +1,44 @@
1
- MenuRoot(bind:open)
2
- +if('trigger')
3
- MenuTrigger(class!="{triggerClass ?? undefined}")
4
- | {@render trigger()}
5
- MenuPortal
6
- MenuContent(class!="{contentClasses}" side!="{side}" align!="{align}" sideOffset!="{sideOffset}")
7
- +each('items as item, i')
8
- +if('item.separator')
9
- MenuSeparator.dropdown-separator
10
- +if('!item.separator')
11
- MenuItem.dropdown-item(disabled!="{item.disabled}" onSelect!="{item.onSelect}")
12
- | {item.label}
13
- | {@render children?.()}
1
+ +if('trigger')
2
+ span(use:refAction bind:this!="{triggerEl}" class!="{triggerClass ?? ''}" onclick!="{toggle}")
3
+ | {@render trigger()}
4
+
5
+ +if('open')
6
+ div(
7
+ use:portal
8
+ use:floatAction
9
+ use:clickOutside!="{[onClose, open, triggerEl]}"
10
+ use:escape!="{[onClose, open]}"
11
+ use:rovingTabindex!="{{ orientation: 'vertical', selector: '[data-roving]' }}"
12
+ class!="{contentClasses}"
13
+ data-state="open"
14
+ role="menu"
15
+ )
16
+ +each('items as item, i')
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
+ )
28
+ +if('item.icon')
29
+ Icon(name!="{item.icon}" size="size-3.5" class="text-fg-muted shrink-0")
30
+ span.flex-1 {item.label}
31
+ +if('children')
32
+ | {@render children()}
14
33
 
15
34
  <script>
16
- import { DropdownMenu } from 'bits-ui'
17
35
  import { cn } from '@human-synthesis/norns-ui/cn'
18
-
19
- {
20
- Root: MenuRoot
21
- Trigger: MenuTrigger
22
- Portal: MenuPortal
23
- Content: MenuContent
24
- Item: MenuItem
25
- Separator: MenuSeparator
26
- } := DropdownMenu
36
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
37
+ import { clickOutside } from '@human-synthesis/norns-ui/behaviors/clickOutside.svelte.js'
38
+ import { escape } from '@human-synthesis/norns-ui/behaviors/escape.svelte.js'
39
+ import { rovingTabindex } from '@human-synthesis/norns-ui/behaviors/rovingTabindex.svelte.js'
40
+ import { useFloating } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
41
+ import Icon from './Icon.n'
27
42
 
28
43
  {
29
44
  open = $bindable(false)
@@ -37,5 +52,21 @@ MenuRoot(bind:open)
37
52
  class: extra = ''
38
53
  } .= $props()
39
54
 
55
+ placement := align === 'center' ? side : `${side}-${align}`
56
+
57
+ { reference: refAction, floating: floatAction } := useFloating
58
+ placement: placement
59
+ offset: sideOffset
60
+
40
61
  contentClasses := cn 'dropdown-content', extra
62
+
63
+ triggerEl .= $state null
64
+
65
+ toggle := => open = !open
66
+ onClose := => open = false
67
+
68
+ onItem := (item) =>
69
+ return if item.disabled
70
+ item.onSelect?.()
71
+ open = false
41
72
  </script>
@@ -1,56 +1,158 @@
1
- ComboboxRoot(bind:value bind:open type="multiple")
2
- .combobox-input-wrap
3
- ComboboxInput.input.combobox-input(
4
- placeholder!="{placeholder}"
5
- disabled!="{disabled}"
6
- oninput!="{(e) => term = e.currentTarget.value}"
7
- )
8
- ComboboxTrigger.combobox-trigger(aria-label="Toggle options")
9
- Icon(name="lucide:chevrons-up-down" size="size-4")
10
- +if('value.length > 0')
11
- .flex.flex-wrap.gap-1.mt-2
12
- +each('value as v')
13
- span.chip
14
- | {labelOf(v)}
15
- ComboboxPortal
16
- ComboboxContent.dropdown-content
17
- ComboboxViewport
18
- +each('filtered as item')
19
- ComboboxItem.dropdown-item(value!="{item.value}" label!="{item.label}")
20
- Icon(name="lucide:check" size="size-3.5" class="opacity-0 data-[selected]:opacity-100")
21
- span {item.label}
22
- +if('filtered.length === 0')
23
- .dropdown-item(class="text-fg-muted")
24
- | No matches
1
+ .tags-input.multi-select(
2
+ use:refAction
3
+ bind:this!="{wrapEl}"
4
+ class!="{hasError ? 'tags-input-err' : ''}"
5
+ onclick!="{focusInput}"
6
+ )
7
+ +each('value as v')
8
+ span.chip
9
+ | {labelOf(v)}
10
+ button.chip-remove(
11
+ type="button"
12
+ aria-label="Remove"
13
+ onclick!="{(e) => { e.stopPropagation(); unpick(v) }}"
14
+ onpointerdown!="{(e) => e.stopPropagation()}"
15
+ )
16
+ Icon(name="lucide:x" size="size-3")
17
+ input.tags-input-field(
18
+ bind:this!="{inputEl}"
19
+ type="text"
20
+ bind:value!="{term}"
21
+ placeholder!="{value.length === 0 ? placeholder : ''}"
22
+ disabled!="{disabled}"
23
+ role="combobox"
24
+ aria-expanded!="{open}"
25
+ aria-controls!="{listboxId}"
26
+ aria-autocomplete="list"
27
+ id!="{resolvedId}"
28
+ aria-invalid!="{hasError ? 'true' : undefined}"
29
+ oninput!="{onInput}"
30
+ onkeydown!="{onKeyDown}"
31
+ onfocus!="{() => open = true}"
32
+ onblur!="{onBlur}"
33
+ )
34
+ button.multi-select-toggle.shrink-0(
35
+ type="button"
36
+ tabindex="-1"
37
+ aria-label="Toggle options"
38
+ onclick!="{(e) => { e.stopPropagation(); toggle() }}"
39
+ )
40
+ Icon(name="lucide:chevrons-up-down" size="size-4")
41
+
42
+ +if('open && filtered.length > 0')
43
+ ul(
44
+ use:portal
45
+ use:floatAction
46
+ use:clickOutside!="{[onClose, open, wrapEl]}"
47
+ class="dropdown-content combobox-listbox"
48
+ data-state="open"
49
+ id!="{listboxId}"
50
+ role="listbox"
51
+ aria-multiselectable="true"
52
+ )
53
+ +each('filtered as item, i (item.value)')
54
+ li.dropdown-item(
55
+ role="option"
56
+ aria-selected!="{value.includes(item.value)}"
57
+ data-active!="{i === active ? 'true' : undefined}"
58
+ onpointerdown!="{(e) => { e.preventDefault(); pick(item) }}"
59
+ )
60
+ Icon(name="lucide:check" size="size-3.5" class!="{value.includes(item.value) ? '' : 'opacity-0'}")
61
+ span {item.label}
62
+ +if('open && filtered.length === 0')
63
+ div(use:portal use:floatAction use:clickOutside!="{[onClose, open, wrapEl]}" class="dropdown-content combobox-listbox" data-state="open")
64
+ .combobox-empty No matches
25
65
 
26
66
  <script>
27
- import { Combobox } from 'bits-ui'
67
+ import { getContext } from 'svelte'
28
68
  import Icon from './Icon.n'
29
-
30
- {
31
- Root: ComboboxRoot
32
- Input: ComboboxInput
33
- Trigger: ComboboxTrigger
34
- Portal: ComboboxPortal
35
- Content: ComboboxContent
36
- Viewport: ComboboxViewport
37
- Item: ComboboxItem
38
- } := Combobox
69
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
70
+ import { clickOutside } from '@human-synthesis/norns-ui/behaviors/clickOutside.svelte.js'
71
+ import { useFloating } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
39
72
 
40
73
  {
41
74
  items = []
42
75
  value = $bindable([])
43
76
  open = $bindable(false)
44
- placeholder = 'Search…'
77
+ placeholder = 'Pick options…'
45
78
  disabled = false
79
+ name
80
+ id: providedId
81
+ error: explicitError = false
46
82
  } .= $props()
47
83
 
84
+ field := getContext 'norns-ui:field'
85
+ resolvedId := providedId ?? field?.id
86
+ hasError := explicitError || field?.hasError || false
87
+
88
+ uid := Math.random().toString(36).slice(2, 8)
89
+ listboxId := `listbox-${uid}`
90
+
91
+ { reference: refAction, floating: floatAction } := useFloating
92
+ placement: 'bottom-start'
93
+ offset: 4
94
+ matchWidth: true
95
+
48
96
  term .= $state ''
97
+ active .= $state 0
98
+ inputEl .= $state null
99
+ wrapEl .= $state null
49
100
 
50
101
  filtered := $derived.by =>
51
102
  return items unless term
52
103
  t := term.toLowerCase()
53
104
  items.filter (it) => it.label.toLowerCase().includes(t)
54
105
 
106
+ $effect =>
107
+ active = 0 if active >= filtered.length
108
+
55
109
  labelOf := (v) => items.find((it) => it.value === v)?.label ?? v
110
+
111
+ focusInput := => inputEl?.focus()
112
+
113
+ onInput := =>
114
+ open = true
115
+ active = 0
116
+
117
+ toggle := =>
118
+ open = !open
119
+ inputEl?.focus() if open
120
+
121
+ onClose := => open = false
122
+
123
+ onBlur := =>
124
+ setTimeout (=> open = false), 100
125
+
126
+ pick := (item) =>
127
+ if value.includes(item.value)
128
+ value = value.filter (v) => v !== item.value
129
+ else
130
+ value = [...value, item.value]
131
+ term = ''
132
+
133
+ unpick := (v) =>
134
+ value = value.filter (x) => x !== v
135
+ inputEl?.focus()
136
+
137
+ onKeyDown := (e) =>
138
+ switch e.key
139
+ when 'ArrowDown'
140
+ e.preventDefault()
141
+ open = true
142
+ active = Math.min(filtered.length - 1, active + 1)
143
+ when 'ArrowUp'
144
+ e.preventDefault()
145
+ open = true
146
+ active = Math.max(0, active - 1)
147
+ when 'Enter'
148
+ if open && filtered[active]
149
+ e.preventDefault()
150
+ pick filtered[active]
151
+ when 'Backspace'
152
+ if !term && value.length > 0
153
+ value = value.slice(0, -1)
154
+ when 'Escape'
155
+ open = false
156
+ when 'Tab'
157
+ open = false
56
158
  </script>
@@ -1,21 +1,25 @@
1
- PopoverRoot(bind:open)
2
- +if('trigger')
3
- PopoverTrigger(class!="{triggerClass ?? undefined}")
4
- | {@render trigger()}
5
- PopoverPortal
6
- PopoverContent(class!="{contentClasses}" side!="{side}" align!="{align}" sideOffset!="{sideOffset}")
7
- | {@render children?.()}
1
+ +if('trigger')
2
+ span(use:refAction bind:this!="{triggerEl}" class!="{triggerClass ?? ''}" onclick!="{toggle}")
3
+ | {@render trigger()}
4
+
5
+ +if('open')
6
+ div(
7
+ use:portal
8
+ use:floatAction
9
+ use:clickOutside!="{[onClose, open, triggerEl]}"
10
+ use:escape!="{[onClose, open]}"
11
+ class!="{contentClasses}"
12
+ data-state="open"
13
+ role="dialog"
14
+ )
15
+ | {@render children?.()}
8
16
 
9
17
  <script>
10
- import { Popover } from 'bits-ui'
11
18
  import { cn } from '@human-synthesis/norns-ui/cn'
12
-
13
- {
14
- Root: PopoverRoot
15
- Trigger: PopoverTrigger
16
- Portal: PopoverPortal
17
- Content: PopoverContent
18
- } := Popover
19
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
20
+ import { clickOutside } from '@human-synthesis/norns-ui/behaviors/clickOutside.svelte.js'
21
+ import { escape } from '@human-synthesis/norns-ui/behaviors/escape.svelte.js'
22
+ import { useFloating } from '@human-synthesis/norns-ui/behaviors/floating.svelte.js'
19
23
 
20
24
  {
21
25
  open = $bindable(false)
@@ -28,5 +32,16 @@ PopoverRoot(bind:open)
28
32
  class: extra = ''
29
33
  } .= $props()
30
34
 
35
+ placement := align === 'center' ? side : `${side}-${align}`
36
+
37
+ { reference: refAction, floating: floatAction } := useFloating
38
+ placement: placement
39
+ offset: sideOffset
40
+
31
41
  contentClasses := cn 'popover-content', extra
42
+
43
+ triggerEl .= $state null
44
+
45
+ toggle := => open = !open
46
+ onClose := => open = false
32
47
  </script>
@@ -1,31 +1,20 @@
1
- ScrollAreaRoot.scroll-area(class!="{classes}" type!="{type}")
2
- ScrollAreaViewport.scroll-area-viewport
3
- | {@render children?.()}
4
- ScrollAreaScrollbar.scroll-area-scrollbar(orientation="vertical")
5
- ScrollAreaThumb.scroll-area-thumb
6
- +if('horizontal')
7
- ScrollAreaScrollbar.scroll-area-scrollbar.scroll-area-scrollbar-h(orientation="horizontal")
8
- ScrollAreaThumb.scroll-area-thumb
9
- ScrollAreaCorner
1
+ div(class!="{classes}")
2
+ | {@render children?.()}
10
3
 
11
4
  <script>
12
- import { ScrollArea } from 'bits-ui'
13
5
  import { cn } from '@human-synthesis/norns-ui/cn'
14
6
 
15
7
  {
16
- Root: ScrollAreaRoot
17
- Viewport: ScrollAreaViewport
18
- Scrollbar: ScrollAreaScrollbar
19
- Thumb: ScrollAreaThumb
20
- Corner: ScrollAreaCorner
21
- } := ScrollArea
22
-
23
- {
24
- type = 'hover'
25
8
  horizontal = false
26
9
  children
27
10
  class: extra = ''
28
11
  } := $props()
29
12
 
30
- classes := cn 'relative overflow-hidden', extra
13
+ // Atoms defined in atoms.css — see `.norns-scroll-area*` rules. The
14
+ // utility-class approach (`overflow-y-auto overflow-x-hidden`) cannot
15
+ // be used here because Tailwind's content scanner doesn't see strings
16
+ // inside library-side `cn()` calls.
17
+ classes := cn
18
+ horizontal ? 'norns-scroll-area-horizontal' : 'norns-scroll-area'
19
+ extra
31
20
  </script>
@@ -1,15 +1,29 @@
1
- DialogRoot(bind:open)
2
- +if('trigger')
3
- DialogTrigger(class!="{triggerClass ?? undefined}")
4
- | {@render trigger()}
5
- DialogPortal
6
- DialogOverlay(class!="{overlayClasses}")
7
- DialogContent(class!="{contentClasses}" data-side!="{side}")
1
+ +if('trigger')
2
+ span(class!="{triggerClass ?? ''}" onclick!="{() => open = true}")
3
+ | {@render trigger()}
4
+
5
+ +if('open')
6
+ div(use:portal use:scrollLock)
7
+ div(
8
+ data-state="open"
9
+ class!="{overlayClasses}"
10
+ onclick!="{onOverlayClick}"
11
+ )
12
+ div(
13
+ role="dialog"
14
+ aria-modal="true"
15
+ aria-label!="{title ?? ariaLabel}"
16
+ class!="{contentClasses}"
17
+ data-state="open"
18
+ data-side!="{side}"
19
+ use:focusTrap
20
+ use:escape!="{[onClose, open]}"
21
+ )
8
22
  +if('title')
9
- DialogTitle.dialog-title
23
+ h2.dialog-title
10
24
  | {title}
11
25
  +if('description')
12
- DialogDescription.dialog-description
26
+ p.dialog-description
13
27
  | {description}
14
28
  +if('children')
15
29
  .sheet-body
@@ -18,23 +32,15 @@ DialogRoot(bind:open)
18
32
  .dialog-actions
19
33
  | {@render actions()}
20
34
  +if('!hideClose')
21
- DialogClose.dialog-close(aria-label="Close")
35
+ button.dialog-close(type="button" aria-label="Close" onclick!="{onClose}")
22
36
  | ✕
23
37
 
24
38
  <script>
25
- import { Dialog } from 'bits-ui'
26
39
  import { cn } from '@human-synthesis/norns-ui/cn'
27
-
28
- {
29
- Root: DialogRoot
30
- Trigger: DialogTrigger
31
- Portal: DialogPortal
32
- Overlay: DialogOverlay
33
- Content: DialogContent
34
- Title: DialogTitle
35
- Description: DialogDescription
36
- Close: DialogClose
37
- } := Dialog
40
+ import { portal } from '@human-synthesis/norns-ui/behaviors/portal.svelte.js'
41
+ import { scrollLock } from '@human-synthesis/norns-ui/behaviors/scrollLock.svelte.js'
42
+ import { focusTrap } from '@human-synthesis/norns-ui/behaviors/focusTrap.svelte.js'
43
+ import { escape } from '@human-synthesis/norns-ui/behaviors/escape.svelte.js'
38
44
 
39
45
  {
40
46
  open = $bindable(false)
@@ -42,6 +48,8 @@ DialogRoot(bind:open)
42
48
  title
43
49
  description
44
50
  hideClose = false
51
+ dismissable = true
52
+ ariaLabel
45
53
  trigger
46
54
  actions
47
55
  children
@@ -52,4 +60,10 @@ DialogRoot(bind:open)
52
60
 
53
61
  overlayClasses := cn 'dialog-overlay', overlayClass
54
62
  contentClasses := cn 'sheet-content', `sheet-${side}`, extra
63
+
64
+ onClose := => open = false
65
+
66
+ onOverlayClick := (e) =>
67
+ return unless dismissable
68
+ open = false if e.target === e.currentTarget
55
69
  </script>
@@ -1,28 +1,54 @@
1
- TabsRoot(bind:value class!="{rootClasses}")
2
- TabsList.tabs-list
1
+ .tabs-root(class!="{rootClasses}")
2
+ .tabs-list(
3
+ role="tablist"
4
+ aria-label!="{label}"
5
+ use:rovingTabindex!="{{ orientation: 'horizontal' }}"
6
+ )
3
7
  +each('items as item (item.value)')
4
- TabsTrigger.tabs-trigger(value!="{item.value}" disabled!="{item.disabled}")
8
+ button.tabs-trigger(
9
+ type="button"
10
+ role="tab"
11
+ id!="{tabId(item.value)}"
12
+ aria-selected!="{value === item.value}"
13
+ aria-controls!="{panelId(item.value)}"
14
+ data-state!="{value === item.value ? 'active' : 'inactive'}"
15
+ data-roving
16
+ disabled!="{item.disabled}"
17
+ onclick!="{() => select(item.value)}"
18
+ )
5
19
  | {item.label}
6
20
  +each('items as item (item.value)')
7
- TabsContent.tabs-content(value!="{item.value}")
8
- | {@render item.panel?.()}
21
+ .tabs-content(
22
+ role="tabpanel"
23
+ id!="{panelId(item.value)}"
24
+ aria-labelledby!="{tabId(item.value)}"
25
+ tabindex="0"
26
+ hidden!="{value !== item.value ? 'until-found' : undefined}"
27
+ )
28
+ +if('value === item.value')
29
+ | {@render item.panel?.()}
9
30
 
10
31
  <script>
11
- import { Tabs } from 'bits-ui'
12
32
  import { cn } from '@human-synthesis/norns-ui/cn'
13
-
14
- {
15
- Root: TabsRoot
16
- List: TabsList
17
- Trigger: TabsTrigger
18
- Content: TabsContent
19
- } := Tabs
33
+ import { rovingTabindex } from '@human-synthesis/norns-ui/behaviors/rovingTabindex.svelte.js'
20
34
 
21
35
  {
22
36
  value = $bindable()
23
37
  items = []
38
+ label = 'Tabs'
24
39
  class: extra = ''
25
40
  } .= $props()
26
41
 
27
42
  rootClasses := cn 'tabs-root', extra
43
+
44
+ // Lazy default to first tab if no value bound.
45
+ $effect =>
46
+ if value === undefined && items.length > 0 && !items[0].disabled
47
+ value = items[0].value
48
+
49
+ uid := Math.random().toString(36).slice(2, 8)
50
+ tabId := (v) => `tab-${uid}-${v}`
51
+ panelId := (v) => `tabpanel-${uid}-${v}`
52
+
53
+ select := (v) => value = v
28
54
  </script>