@rokkit/ui 1.0.0-next.120 → 1.0.0-next.122
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 +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/tree/List.spec.svelte.d.ts +1 -0
- package/dist/tree/Node.spec.svelte.d.ts +1 -0
- package/dist/tree/Root.spec.svelte.d.ts +1 -0
- package/package.json +2 -2
- package/src/BreadCrumbs.svelte +14 -19
- package/src/Button.svelte +3 -7
- package/src/Icon.svelte +21 -16
- package/src/Item.svelte +6 -6
- package/src/List.svelte +8 -7
- package/src/ListBody.svelte +3 -2
- package/src/Pill.svelte +3 -3
- package/src/Select.svelte +0 -3
- package/src/Switch.svelte +45 -32
- package/src/Tabs.svelte +147 -77
- package/src/TabsOld.svelte +96 -0
- package/src/Toggle.svelte +2 -1
- package/src/ToggleThemeMode.svelte +7 -3
- package/src/index.js +0 -5
- package/src/tree/List.spec.svelte.js +84 -0
- package/src/tree/List.svelte +78 -0
- package/src/tree/Node.spec.svelte.js +104 -0
- package/src/tree/Node.svelte +80 -0
- package/src/tree/Root.spec.svelte.js +63 -0
- package/src/tree/Root.svelte +81 -0
- package/dist/input/types.d.ts +0 -9
- package/src/DataEditor.svelte +0 -31
- package/src/FieldLayout.svelte +0 -48
- package/src/Form.svelte +0 -17
- package/src/ListEditor.svelte +0 -44
- package/src/NestedEditor.svelte +0 -88
- package/src/input/Input.svelte +0 -17
- package/src/input/InputField.svelte +0 -69
- package/src/input/InputSelect.svelte +0 -23
- package/src/input/InputSwitch.svelte +0 -19
- package/src/input/types.js +0 -29
package/src/Tabs.svelte
CHANGED
|
@@ -1,96 +1,166 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
3
|
-
import { ListController } from '@rokkit/states'
|
|
2
|
+
import { createEmitter, getKeyFromPath, defaultStateIcons } from '@rokkit/core'
|
|
4
3
|
import { navigator } from '@rokkit/actions'
|
|
4
|
+
import { ListController } from '@rokkit/states'
|
|
5
|
+
import { Proxy } from '@rokkit/states'
|
|
6
|
+
import { has, equals, pick } from 'ramda'
|
|
5
7
|
import Icon from './Icon.svelte'
|
|
6
|
-
import Item from './Item.svelte'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* @typedef {Object}
|
|
10
|
-
* @property {string} [
|
|
11
|
-
* @property {
|
|
12
|
-
* @property {
|
|
13
|
-
* @property {
|
|
14
|
-
* @property {boolean} [below]
|
|
15
|
-
* @property {string} [align]
|
|
16
|
-
* @property {boolean} [editable]
|
|
17
|
-
* @property {any} [icons]
|
|
10
|
+
* @typedef {Object} FieldMapping
|
|
11
|
+
* @property {string} [id] - Field to use for item ID
|
|
12
|
+
* @property {string} [label] - Field to use for item label/text
|
|
13
|
+
* @property {string} [value] - Field to use for item value
|
|
14
|
+
* @property {string} [content] - Field to use for tab content
|
|
18
15
|
*/
|
|
19
16
|
|
|
20
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} TabProps
|
|
19
|
+
* @property {string} [class] - Additional CSS class names
|
|
20
|
+
* @property {string} [name] - Name for accessibility
|
|
21
|
+
* @property {any[]} [items] - Array of tab items to display
|
|
22
|
+
* @property {FieldMapping} [fields] - Field mappings for extracting data
|
|
23
|
+
* @property {'horizontal'|'vertical'} [orientation] - Orientation of the tab bar
|
|
24
|
+
* @property {'before' | 'after' } [position] - Position of the tab bar
|
|
25
|
+
* @property {'start'|'center'|'end'} [align] - Alignment of the tab bar
|
|
26
|
+
* @property {any} [value] - Selected tab value (bindable)
|
|
27
|
+
* @property {number} [tabindex] - Tab index for keyboard navigation
|
|
28
|
+
* @property {boolean} [editable] - Whether tabs can be added/removed
|
|
29
|
+
* @property {string} [placeholder] - Placeholder text for input field
|
|
30
|
+
* @property {import('svelte').Snippet} [child] - Snippet for rendering tab headers
|
|
31
|
+
* @property {import('svelte').Snippet} [children] - Snippet for rendering tab content
|
|
32
|
+
* @property {import('svelte').Snippet} [empty] - Snippet for rendering empty state
|
|
33
|
+
* @property {Function} [onselect] - Callback when tab is selected
|
|
34
|
+
* @property {Function} [onchange] - Callback when tab changes
|
|
35
|
+
* @property {Function} [onmove] - Callback when focus moves
|
|
36
|
+
* @property {Function} [onadd] - Callback when tab is added
|
|
37
|
+
* @property {Function} [onremove] - Callback when tab is removed
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** @type {TabProps} */
|
|
21
41
|
let {
|
|
22
|
-
class:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
align = '
|
|
42
|
+
class: classes = '',
|
|
43
|
+
name = 'tabs',
|
|
44
|
+
items = $bindable([]),
|
|
45
|
+
fields = {},
|
|
46
|
+
value = $bindable(),
|
|
47
|
+
orientation = 'horizontal',
|
|
48
|
+
align = 'start',
|
|
49
|
+
position = 'before',
|
|
50
|
+
tabindex = 0,
|
|
29
51
|
editable = false,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
child,
|
|
53
|
+
children,
|
|
54
|
+
empty,
|
|
55
|
+
placeholder = 'Select a tab to view its content.',
|
|
56
|
+
icons,
|
|
57
|
+
onselect,
|
|
58
|
+
onchange,
|
|
59
|
+
onmove,
|
|
60
|
+
onadd,
|
|
61
|
+
onremove,
|
|
62
|
+
...snippets
|
|
35
63
|
} = $props()
|
|
36
64
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
/** @type {Proxy[]} */
|
|
66
|
+
let proxyItems = $derived(items.map((item) => new Proxy(item, fields)))
|
|
67
|
+
let childSnippet = $derived(child ?? defaultChild)
|
|
68
|
+
let childrenSnippet = $derived(children ?? defaultChildren)
|
|
69
|
+
let emptyMessage = $derived(empty ?? defaultEmpty)
|
|
70
|
+
let activeItem = $derived(proxyItems.find((proxy) => equals(proxy.value, value)))
|
|
71
|
+
|
|
72
|
+
function handleAction(event) {
|
|
73
|
+
const { name, data } = event.detail
|
|
43
74
|
|
|
44
|
-
|
|
75
|
+
if (has(name, emitter)) {
|
|
76
|
+
value = data.value
|
|
77
|
+
emitter[name](data)
|
|
78
|
+
}
|
|
45
79
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
onadd()
|
|
80
|
+
|
|
81
|
+
function handleAdd() {
|
|
82
|
+
onadd?.()
|
|
49
83
|
}
|
|
50
|
-
function handleNav(event) {
|
|
51
|
-
value = event.detail.node
|
|
52
|
-
cursor = event.detail.path
|
|
53
84
|
|
|
54
|
-
|
|
85
|
+
function handleRemove(item) {
|
|
86
|
+
onremove?.(item)
|
|
55
87
|
}
|
|
56
|
-
let
|
|
57
|
-
let
|
|
58
|
-
let wrapper =
|
|
59
|
-
|
|
88
|
+
let tabIcons = $derived({ ...pick(['add', 'close'], defaultStateIcons.action), ...icons })
|
|
89
|
+
let emitter = createEmitter({ onchange, onmove, onselect }, ['select', 'change', 'move'])
|
|
90
|
+
let wrapper = new ListController(items, value, fields)
|
|
91
|
+
$effect(() => {
|
|
92
|
+
wrapper.update(items)
|
|
93
|
+
})
|
|
60
94
|
</script>
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
{#snippet defaultChild(item)}
|
|
97
|
+
{item.get('text') || item.get('label') || item.get('name')}
|
|
98
|
+
{/snippet}
|
|
99
|
+
|
|
100
|
+
{#snippet defaultChildren(item)}
|
|
101
|
+
<div data-tab-content-default>
|
|
102
|
+
{item.get('content')}
|
|
103
|
+
</div>
|
|
104
|
+
{/snippet}
|
|
105
|
+
|
|
106
|
+
{#snippet defaultEmpty()}
|
|
107
|
+
No tabs available.
|
|
108
|
+
{/snippet}
|
|
109
|
+
|
|
110
|
+
<div
|
|
111
|
+
data-tabs-root
|
|
112
|
+
data-orientation={orientation}
|
|
113
|
+
data-position={position}
|
|
114
|
+
data-align={align}
|
|
115
|
+
class={classes}
|
|
116
|
+
role="tablist"
|
|
117
|
+
aria-label={name}
|
|
118
|
+
use:navigator={{ wrapper, orientation }}
|
|
119
|
+
{tabindex}
|
|
120
|
+
onaction={handleAction}
|
|
73
121
|
>
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
{
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
<div data-tabs-list>
|
|
123
|
+
{#each proxyItems as item, index (index)}
|
|
124
|
+
{@const key = getKeyFromPath([index])}
|
|
125
|
+
{@const isSelected = equals(item.value, value)}
|
|
126
|
+
{@const isFocused = wrapper.focusedKey === key}
|
|
127
|
+
<div
|
|
128
|
+
data-tabs-trigger
|
|
129
|
+
data-path={getKeyFromPath([index])}
|
|
130
|
+
role="tab"
|
|
131
|
+
aria-selected={isSelected}
|
|
132
|
+
aria-controls="tab-panel-{index}"
|
|
133
|
+
class:selected={isSelected}
|
|
134
|
+
class:focused={isFocused}
|
|
135
|
+
>
|
|
136
|
+
{@render childSnippet(item)}
|
|
137
|
+
{#if editable}
|
|
138
|
+
<Icon
|
|
139
|
+
data-icon-remove
|
|
140
|
+
name={tabIcons.close}
|
|
141
|
+
role="button"
|
|
142
|
+
onclick={() => handleRemove(item.value)}
|
|
143
|
+
/>
|
|
144
|
+
{/if}
|
|
145
|
+
</div>
|
|
146
|
+
{/each}
|
|
147
|
+
{#if editable}
|
|
148
|
+
<Icon data-icon-add name={tabIcons.add} role="button" onclick={handleAdd} />
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<!-- Tab Content -->
|
|
153
|
+
<div data-tabs-content role="tabpanel">
|
|
154
|
+
{#if proxyItems.length === 0}
|
|
155
|
+
<div data-empty>
|
|
156
|
+
{@render emptyMessage()}
|
|
157
|
+
</div>
|
|
158
|
+
{:else if activeItem}
|
|
159
|
+
{@render childrenSnippet(activeItem)}
|
|
160
|
+
{:else}
|
|
161
|
+
<div data-placeholder>
|
|
162
|
+
{placeholder}
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { defaultFields, defaultStateIcons, noop, getSnippet, FieldMapper } from '@rokkit/core'
|
|
3
|
+
import { ListController } from '@rokkit/states'
|
|
4
|
+
import { navigator } from '@rokkit/actions'
|
|
5
|
+
import Icon from './Icon.svelte'
|
|
6
|
+
import Item from './Item.svelte'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} Props
|
|
10
|
+
* @property {string} [class]
|
|
11
|
+
* @property {any} [options]
|
|
12
|
+
* @property {import('@rokkit/core').FieldMapping} [fields]
|
|
13
|
+
* @property {any} [value]
|
|
14
|
+
* @property {boolean} [below]
|
|
15
|
+
* @property {string} [align]
|
|
16
|
+
* @property {boolean} [editable]
|
|
17
|
+
* @property {any} [icons]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {Props} */
|
|
21
|
+
let {
|
|
22
|
+
class: className = '',
|
|
23
|
+
options = $bindable([]),
|
|
24
|
+
value = $bindable(null),
|
|
25
|
+
icons = $bindable(defaultStateIcons.action),
|
|
26
|
+
fields = defaultFields,
|
|
27
|
+
below = false,
|
|
28
|
+
align = 'left',
|
|
29
|
+
editable = false,
|
|
30
|
+
onremove = noop,
|
|
31
|
+
onadd = noop,
|
|
32
|
+
onselect = noop,
|
|
33
|
+
stub,
|
|
34
|
+
...extra
|
|
35
|
+
} = $props()
|
|
36
|
+
|
|
37
|
+
function handleRemove(event) {
|
|
38
|
+
if (typeof event.detail === Object) {
|
|
39
|
+
event.detail[fields.isDeleted] = true
|
|
40
|
+
} else {
|
|
41
|
+
options = options.filter((i) => i !== event.detail)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onremove({ item: event.detail })
|
|
45
|
+
}
|
|
46
|
+
function handleAdd(event) {
|
|
47
|
+
event.stopPropagation()
|
|
48
|
+
onadd()
|
|
49
|
+
}
|
|
50
|
+
function handleNav(event) {
|
|
51
|
+
value = event.detail.node
|
|
52
|
+
cursor = event.detail.path
|
|
53
|
+
|
|
54
|
+
onselect({ item: value, indices: cursor })
|
|
55
|
+
}
|
|
56
|
+
let stateIcons = $derived({ ...defaultStateIcons.action, ...icons })
|
|
57
|
+
let filtered = $derived(options.filter((item) => !item[fields.deleted]))
|
|
58
|
+
let wrapper = $derived(new ListController(options, value, fields))
|
|
59
|
+
let mapper = new FieldMapper(fields)
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<rk-tabs
|
|
63
|
+
class="flex w-full {className}"
|
|
64
|
+
class:is-below={below}
|
|
65
|
+
class:justify-center={align === 'center'}
|
|
66
|
+
class:justify-end={align === 'right'}
|
|
67
|
+
tabindex="0"
|
|
68
|
+
role="listbox"
|
|
69
|
+
use:navigator={{ wrapper, horizontal: true }}
|
|
70
|
+
onaction={handleNav}
|
|
71
|
+
onremove={handleRemove}
|
|
72
|
+
onadd={handleAdd}
|
|
73
|
+
>
|
|
74
|
+
{#each filtered as item, index (index)}
|
|
75
|
+
{@const Template = getSnippet(extra, mapper.get('snippet', item), stub)}
|
|
76
|
+
<rk-tab>
|
|
77
|
+
{#if Template}
|
|
78
|
+
<Template value={item} {fields} />
|
|
79
|
+
{:else}
|
|
80
|
+
<Item value={item} {fields} />
|
|
81
|
+
{/if}
|
|
82
|
+
{#if editable}
|
|
83
|
+
<Icon
|
|
84
|
+
name="remove"
|
|
85
|
+
role="button"
|
|
86
|
+
label="Delete Tab"
|
|
87
|
+
size="small"
|
|
88
|
+
onclick={() => handleRemove(item)}
|
|
89
|
+
/>
|
|
90
|
+
{/if}
|
|
91
|
+
</rk-tab>
|
|
92
|
+
{/each}
|
|
93
|
+
{#if editable}
|
|
94
|
+
<Icon name="add" role="button" label="Add Tab" size="small" onclick={handleAdd} />
|
|
95
|
+
{/if}
|
|
96
|
+
</rk-tabs>
|
package/src/Toggle.svelte
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
options = [false, true],
|
|
19
19
|
fields,
|
|
20
20
|
disabled = false,
|
|
21
|
+
square = false,
|
|
21
22
|
label = 'toggle',
|
|
22
23
|
onchange
|
|
23
24
|
} = $props()
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
}
|
|
45
46
|
</script>
|
|
46
47
|
|
|
47
|
-
<div data-toggle-root data-disabled={disabled} class={classes}>
|
|
48
|
+
<div data-toggle-root data-disabled={disabled} data-square={square} class={classes}>
|
|
48
49
|
<button
|
|
49
50
|
use:keyboard={keyMappings}
|
|
50
51
|
onnext={() => toggle()}
|
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
import { vibe } from '@rokkit/states'
|
|
4
4
|
import Toggle from './Toggle.svelte'
|
|
5
5
|
|
|
6
|
-
let {
|
|
6
|
+
let {
|
|
7
|
+
icons = defaultStateIcons.mode,
|
|
8
|
+
labels = ['Light Mode', 'Dark Mode'],
|
|
9
|
+
square = false
|
|
10
|
+
} = $props()
|
|
7
11
|
|
|
8
12
|
const options = $state([
|
|
9
13
|
{ label: labels[0], value: 'light', icon: icons.light },
|
|
10
14
|
{ label: labels[1], value: 'dark', icon: icons.dark }
|
|
11
15
|
])
|
|
12
|
-
let value = $
|
|
16
|
+
let value = $derived(options.find((x) => x.value === vibe.mode))
|
|
13
17
|
|
|
14
18
|
function update(e) {
|
|
15
19
|
vibe.mode = e.value
|
|
16
20
|
}
|
|
17
21
|
</script>
|
|
18
22
|
|
|
19
|
-
<Toggle
|
|
23
|
+
<Toggle {value} {options} onchange={update} {square} />
|
package/src/index.js
CHANGED
|
@@ -39,11 +39,6 @@ export { default as Overlay } from './Overlay.svelte'
|
|
|
39
39
|
export { default as Message } from './Message.svelte'
|
|
40
40
|
export { default as SlidingColumns } from './SlidingColumns.svelte'
|
|
41
41
|
|
|
42
|
-
export { default as InputField } from './input/InputField.svelte'
|
|
43
|
-
export { default as Form } from './Form.svelte'
|
|
44
|
-
export { default as FieldLayout } from './FieldLayout.svelte'
|
|
45
|
-
export { default as DataEditor } from './DataEditor.svelte'
|
|
46
|
-
export { default as NestedEditor } from './NestedEditor.svelte'
|
|
47
42
|
export { default as Stepper } from './Stepper.svelte'
|
|
48
43
|
export { default as ProgressDots } from './ProgressDots.svelte'
|
|
49
44
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { cleanup, render, fireEvent } from '@testing-library/svelte'
|
|
3
|
+
import List from './List.svelte'
|
|
4
|
+
|
|
5
|
+
const fields = {
|
|
6
|
+
children: 'children',
|
|
7
|
+
expanded: 'expanded',
|
|
8
|
+
text: 'text'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeTreeData() {
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
text: 'Root 1',
|
|
15
|
+
expanded: true,
|
|
16
|
+
children: [
|
|
17
|
+
{ text: 'Child 1.1', expanded: false, children: [] },
|
|
18
|
+
{ text: 'Child 1.2', expanded: false, children: [] }
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
text: 'Root 2',
|
|
23
|
+
expanded: false,
|
|
24
|
+
children: [{ text: 'Child 2.1', expanded: false, children: [] }]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('List.svelte', () => {
|
|
30
|
+
beforeEach(() => cleanup())
|
|
31
|
+
|
|
32
|
+
it('renders all root nodes', () => {
|
|
33
|
+
const items = makeTreeData()
|
|
34
|
+
const { container, getByText } = render(List, { props: { items, fields } })
|
|
35
|
+
expect(getByText('Root 1')).toBeTruthy()
|
|
36
|
+
expect(getByText('Root 2')).toBeTruthy()
|
|
37
|
+
expect(container).toMatchSnapshot()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders children when expanded', () => {
|
|
41
|
+
const items = makeTreeData()
|
|
42
|
+
const { container, getByText } = render(List, { props: { items, fields } })
|
|
43
|
+
expect(getByText('Child 1.1')).toBeTruthy()
|
|
44
|
+
expect(getByText('Child 1.2')).toBeTruthy()
|
|
45
|
+
// Child 2.1 should not be rendered because Root 2 is not expanded
|
|
46
|
+
expect(() => getByText('Child 2.1')).toThrow()
|
|
47
|
+
expect(container).toMatchSnapshot()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders leaf and branch data attributes', () => {
|
|
51
|
+
const items = makeTreeData()
|
|
52
|
+
const { container } = render(List, { props: { items, fields } })
|
|
53
|
+
const nodes = container.querySelectorAll('[data-tree-node]')
|
|
54
|
+
expect(nodes.length).toBeGreaterThan(0)
|
|
55
|
+
// At least one branch and one leaf
|
|
56
|
+
const branch = container.querySelector('[data-tree-branch]')
|
|
57
|
+
const leaf = container.querySelector('[data-tree-leaf]')
|
|
58
|
+
expect(branch).toBeTruthy()
|
|
59
|
+
expect(leaf).toBeTruthy()
|
|
60
|
+
expect(container).toMatchSnapshot()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('passes focused and selected props', () => {
|
|
64
|
+
const items = makeTreeData()
|
|
65
|
+
const selectedKeys = new Set(['0'])
|
|
66
|
+
const { container } = render(List, {
|
|
67
|
+
props: { items, fields, focusedKey: '0', selectedKeys }
|
|
68
|
+
})
|
|
69
|
+
const focusedNode = container.querySelector('[data-tree-node][aria-current="true"]')
|
|
70
|
+
const selectedNode = container.querySelector('[data-tree-node][aria-selected="true"]')
|
|
71
|
+
expect(focusedNode).toBeTruthy()
|
|
72
|
+
expect(selectedNode).toBeTruthy()
|
|
73
|
+
expect(container).toMatchSnapshot()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders nested lists recursively', () => {
|
|
77
|
+
const items = makeTreeData()
|
|
78
|
+
const { container } = render(List, { props: { items, fields } })
|
|
79
|
+
// Should have nested data-tree-list elements
|
|
80
|
+
const lists = container.querySelectorAll('[data-tree-list]')
|
|
81
|
+
expect(lists.length).toBeGreaterThan(1)
|
|
82
|
+
expect(container).toMatchSnapshot()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import Node from './Node.svelte'
|
|
3
|
+
import List from './List.svelte'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} Props
|
|
7
|
+
* @property {Array<any>} items
|
|
8
|
+
* @property {any} value
|
|
9
|
+
* @property {Object} fields
|
|
10
|
+
* @property {Array<number>} [path]
|
|
11
|
+
* @property {Object} icons
|
|
12
|
+
* @property {Array<string>} [types]
|
|
13
|
+
* @property {string} [focusedKey]
|
|
14
|
+
* @property {Set<string>} [selectedKeys]
|
|
15
|
+
* @property {Function} [stub]
|
|
16
|
+
* @property {Object<string, Function>} [snippets]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {Props} */
|
|
20
|
+
let {
|
|
21
|
+
items = $bindable([]),
|
|
22
|
+
value = $bindable(null),
|
|
23
|
+
fields,
|
|
24
|
+
path = [],
|
|
25
|
+
icons = {},
|
|
26
|
+
types = [],
|
|
27
|
+
focusedKey,
|
|
28
|
+
selectedKeys = new Set(),
|
|
29
|
+
stub,
|
|
30
|
+
snippets
|
|
31
|
+
} = $props()
|
|
32
|
+
|
|
33
|
+
// Helper to check if a node has children
|
|
34
|
+
function hasChildren(node, fields) {
|
|
35
|
+
return Array.isArray(node?.[fields.children]) && node[fields.children].length > 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to get key from path
|
|
39
|
+
function getKeyFromPath(path) {
|
|
40
|
+
return path.join('-')
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div data-tree-list role="group">
|
|
45
|
+
{#each items as item, index (index)}
|
|
46
|
+
{@const nodePath = [...path, index]}
|
|
47
|
+
{@const key = getKeyFromPath(nodePath)}
|
|
48
|
+
{@const expanded = item[fields.expanded]}
|
|
49
|
+
{@const isBranch = hasChildren(item, fields)}
|
|
50
|
+
<Node
|
|
51
|
+
value={item}
|
|
52
|
+
{fields}
|
|
53
|
+
{icons}
|
|
54
|
+
{types}
|
|
55
|
+
focused={focusedKey === key}
|
|
56
|
+
selected={selectedKeys.has(key)}
|
|
57
|
+
{expanded}
|
|
58
|
+
path={nodePath}
|
|
59
|
+
{stub}
|
|
60
|
+
{snippets}
|
|
61
|
+
>
|
|
62
|
+
{#if isBranch && expanded}
|
|
63
|
+
<List
|
|
64
|
+
items={item[fields.children]}
|
|
65
|
+
{value}
|
|
66
|
+
{fields}
|
|
67
|
+
path={nodePath}
|
|
68
|
+
{icons}
|
|
69
|
+
{types}
|
|
70
|
+
{focusedKey}
|
|
71
|
+
{selectedKeys}
|
|
72
|
+
{stub}
|
|
73
|
+
{snippets}
|
|
74
|
+
/>
|
|
75
|
+
{/if}
|
|
76
|
+
</Node>
|
|
77
|
+
{/each}
|
|
78
|
+
</div>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { cleanup, render, fireEvent } from '@testing-library/svelte'
|
|
3
|
+
import Node from './Node.svelte'
|
|
4
|
+
|
|
5
|
+
describe('Node.svelte', () => {
|
|
6
|
+
const fields = {
|
|
7
|
+
text: 'label',
|
|
8
|
+
children: 'children',
|
|
9
|
+
expanded: 'expanded',
|
|
10
|
+
snippet: 'snippet'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const baseNode = {
|
|
14
|
+
label: 'Root Node',
|
|
15
|
+
expanded: false,
|
|
16
|
+
children: []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => cleanup())
|
|
20
|
+
|
|
21
|
+
it('renders node with label', () => {
|
|
22
|
+
const { container, getByText } = render(Node, {
|
|
23
|
+
props: {
|
|
24
|
+
value: baseNode,
|
|
25
|
+
fields,
|
|
26
|
+
path: [0],
|
|
27
|
+
focused: false,
|
|
28
|
+
selected: false,
|
|
29
|
+
expanded: false
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
const contentEl = container.querySelector('[data-tree-content]')
|
|
33
|
+
expect(contentEl).not.toBeNull()
|
|
34
|
+
expect(contentEl.textContent).toContain('Root Node')
|
|
35
|
+
expect(container).toMatchSnapshot()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('sets data-tree-leaf for leaf node', () => {
|
|
39
|
+
const { container } = render(Node, {
|
|
40
|
+
props: {
|
|
41
|
+
value: baseNode,
|
|
42
|
+
fields,
|
|
43
|
+
path: [0]
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
const nodeDiv = container.querySelector('[data-tree-node]')
|
|
47
|
+
expect(nodeDiv.getAttribute('data-tree-leaf')).toBe('true')
|
|
48
|
+
expect(nodeDiv.getAttribute('data-tree-branch')).toBeNull()
|
|
49
|
+
expect(container).toMatchSnapshot()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('sets data-tree-branch for branch node', () => {
|
|
53
|
+
const branchNode = {
|
|
54
|
+
label: 'Branch Node',
|
|
55
|
+
expanded: true,
|
|
56
|
+
children: [{ label: 'Child', expanded: false, children: [] }]
|
|
57
|
+
}
|
|
58
|
+
const { container } = render(Node, {
|
|
59
|
+
props: {
|
|
60
|
+
value: branchNode,
|
|
61
|
+
fields,
|
|
62
|
+
path: [0]
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
const nodeDiv = container.querySelector('[data-tree-node]')
|
|
66
|
+
expect(nodeDiv.getAttribute('data-tree-branch')).toBe('true')
|
|
67
|
+
expect(nodeDiv.getAttribute('data-tree-leaf')).toBeNull()
|
|
68
|
+
expect(container).toMatchSnapshot()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('sets aria attributes correctly', () => {
|
|
72
|
+
const { container } = render(Node, {
|
|
73
|
+
props: {
|
|
74
|
+
value: baseNode,
|
|
75
|
+
fields,
|
|
76
|
+
path: [0],
|
|
77
|
+
focused: true,
|
|
78
|
+
selected: true,
|
|
79
|
+
expanded: true
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
const nodeDiv = container.querySelector('[data-tree-node]')
|
|
83
|
+
expect(nodeDiv.getAttribute('aria-current')).toBe('true')
|
|
84
|
+
expect(nodeDiv.getAttribute('aria-selected')).toBe('true')
|
|
85
|
+
expect(nodeDiv.getAttribute('aria-expanded')).toBe('true')
|
|
86
|
+
expect(container).toMatchSnapshot()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// it('renders custom snippet if provided', () => {
|
|
90
|
+
// const snippet = (node) => `<span>Custom: ${node.label}</span>`
|
|
91
|
+
// const { container, getByText } = render(Node, {
|
|
92
|
+
// props: {
|
|
93
|
+
// value: baseNode,
|
|
94
|
+
// fields,
|
|
95
|
+
// path: [0],
|
|
96
|
+
// snippets: { [baseNode.snippet]: snippet }
|
|
97
|
+
// }
|
|
98
|
+
// })
|
|
99
|
+
// const contentEl = container.querySelector('[data-tree-content]')
|
|
100
|
+
// expect(contentEl).not.toBeNull()
|
|
101
|
+
// expect(contentEl.textContent).toContain('Custom: Root Node')
|
|
102
|
+
// expect(container).toMatchSnapshot()
|
|
103
|
+
// })
|
|
104
|
+
})
|