@rokkit/ui 1.0.0-next.121 → 1.0.0-next.123
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 +1 -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 +4 -4
- package/src/BreadCrumbs.svelte +14 -19
- package/src/Button.svelte +3 -7
- package/src/Card.svelte +6 -2
- package/src/GraphPaper.svelte +43 -0
- 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/PickOne.svelte +60 -0
- package/src/Pill.svelte +2 -2
- package/src/ProgressDots.svelte +1 -1
- package/src/Select.svelte +0 -3
- package/src/Stepper.svelte +2 -2
- package/src/Summary.svelte +1 -1
- package/src/Switch.svelte +45 -32
- package/src/Tabs.svelte +155 -75
- package/src/Toggle.svelte +2 -1
- package/src/ToggleThemeMode.svelte +7 -3
- package/src/index.js +1 -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,176 @@
|
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* @
|
|
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
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} TabProps
|
|
19
|
+
* @property {string} [class] - Additional CSS class names
|
|
20
|
+
* @property {string} [name] - Name for accessibility
|
|
21
|
+
* @property {any[]} [options] - Array of tab options 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} [tabItem] - Snippet for rendering tab headers
|
|
31
|
+
* @property {import('svelte').Snippet} [tabPanel] - 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
|
|
18
38
|
*/
|
|
19
39
|
|
|
20
|
-
/** @type {
|
|
40
|
+
/** @type {TabProps} */
|
|
21
41
|
let {
|
|
22
|
-
class:
|
|
42
|
+
class: classes = '',
|
|
43
|
+
name = 'tabs',
|
|
23
44
|
options = $bindable([]),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
tabItem,
|
|
53
|
+
tabPanel,
|
|
54
|
+
empty,
|
|
55
|
+
placeholder = 'Select a tab to view its content.',
|
|
56
|
+
icons,
|
|
57
|
+
onselect,
|
|
58
|
+
onchange,
|
|
59
|
+
onmove,
|
|
60
|
+
onadd,
|
|
61
|
+
onremove,
|
|
62
|
+
...restProps
|
|
35
63
|
} = $props()
|
|
36
64
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
/** @type {Proxy[]} */
|
|
66
|
+
let proxyItems = $derived(options.map((item) => new Proxy(item, fields)))
|
|
67
|
+
let tabItemSnippet = $derived(tabItem ?? defaultItem)
|
|
68
|
+
let tabPanelSnippet = $derived(tabPanel ?? defaultPanel)
|
|
69
|
+
let emptyMessage = $derived(empty ?? defaultEmpty)
|
|
70
|
+
|
|
71
|
+
function handleAction(event) {
|
|
72
|
+
const { name, data } = event.detail
|
|
43
73
|
|
|
44
|
-
|
|
74
|
+
if (has(name, emitter)) {
|
|
75
|
+
value = data.value
|
|
76
|
+
emitter[name](data)
|
|
77
|
+
}
|
|
45
78
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
onadd()
|
|
79
|
+
|
|
80
|
+
function handleAdd() {
|
|
81
|
+
onadd?.()
|
|
49
82
|
}
|
|
50
|
-
function handleNav(event) {
|
|
51
|
-
value = event.detail.node
|
|
52
|
-
cursor = event.detail.path
|
|
53
83
|
|
|
54
|
-
|
|
84
|
+
function handleRemove(item) {
|
|
85
|
+
onremove?.(item)
|
|
55
86
|
}
|
|
56
|
-
let
|
|
57
|
-
let
|
|
58
|
-
let wrapper =
|
|
59
|
-
|
|
87
|
+
let tabIcons = $derived({ ...pick(['add', 'close'], defaultStateIcons.action), ...icons })
|
|
88
|
+
let emitter = createEmitter({ onchange, onmove, onselect }, ['select', 'change', 'move'])
|
|
89
|
+
let wrapper = new ListController(options, value, fields)
|
|
90
|
+
|
|
91
|
+
$effect(() => wrapper.update(options))
|
|
60
92
|
</script>
|
|
61
93
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
{#snippet defaultItem(item)}
|
|
95
|
+
{item.get('text') || item.get('label') || item.get('name')}
|
|
96
|
+
{/snippet}
|
|
97
|
+
|
|
98
|
+
{#snippet defaultPanel(item)}
|
|
99
|
+
<div data-tabs-content>
|
|
100
|
+
{item.get('content')}
|
|
101
|
+
</div>
|
|
102
|
+
{/snippet}
|
|
103
|
+
|
|
104
|
+
{#snippet defaultEmpty()}
|
|
105
|
+
No tabs available.
|
|
106
|
+
{/snippet}
|
|
107
|
+
|
|
108
|
+
<div
|
|
109
|
+
{...restProps}
|
|
110
|
+
data-tabs-root
|
|
111
|
+
data-orientation={orientation}
|
|
112
|
+
data-position={position}
|
|
113
|
+
data-align={align}
|
|
114
|
+
class={classes}
|
|
115
|
+
role="tablist"
|
|
116
|
+
aria-label={name}
|
|
117
|
+
use:navigator={{ wrapper, orientation }}
|
|
118
|
+
{tabindex}
|
|
119
|
+
onaction={handleAction}
|
|
73
120
|
>
|
|
74
|
-
{#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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} />
|
|
121
|
+
{#if proxyItems.length === 0}
|
|
122
|
+
<div data-tabs-empty>
|
|
123
|
+
{@render emptyMessage()}
|
|
124
|
+
</div>
|
|
125
|
+
{:else if wrapper.focusedKey === null && value === null}
|
|
126
|
+
<div data-tabs-placeholder>
|
|
127
|
+
{placeholder}
|
|
128
|
+
</div>
|
|
95
129
|
{/if}
|
|
96
|
-
|
|
130
|
+
<div data-tabs-list>
|
|
131
|
+
{#each proxyItems as item, index (index)}
|
|
132
|
+
{@const key = getKeyFromPath([index])}
|
|
133
|
+
{@const isSelected = equals(item.value, value)}
|
|
134
|
+
{@const isFocused = wrapper.focusedKey === key}
|
|
135
|
+
<button
|
|
136
|
+
data-tabs-trigger
|
|
137
|
+
data-path={getKeyFromPath([index])}
|
|
138
|
+
role="tab"
|
|
139
|
+
aria-selected={isSelected}
|
|
140
|
+
aria-controls="tab-panel-{index}"
|
|
141
|
+
class:selected={isSelected}
|
|
142
|
+
class:focused={isFocused}
|
|
143
|
+
tabindex="0"
|
|
144
|
+
id={`tab-${index}`}
|
|
145
|
+
>
|
|
146
|
+
{@render tabItemSnippet(item)}
|
|
147
|
+
{#if editable}
|
|
148
|
+
<Icon
|
|
149
|
+
data-icon-remove
|
|
150
|
+
name={tabIcons.close}
|
|
151
|
+
role="button"
|
|
152
|
+
onclick={() => handleRemove(item.value)}
|
|
153
|
+
/>
|
|
154
|
+
{/if}
|
|
155
|
+
</button>
|
|
156
|
+
{/each}
|
|
157
|
+
{#if editable}
|
|
158
|
+
<Icon data-icon-add name={tabIcons.add} role="button" onclick={handleAdd} />
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- Tab Panels -->
|
|
163
|
+
{#each proxyItems as item, index (index)}
|
|
164
|
+
{@const isVisible = equals(item.value, value)}
|
|
165
|
+
|
|
166
|
+
<div
|
|
167
|
+
data-tabs-panel
|
|
168
|
+
role="tabpanel"
|
|
169
|
+
id="tab-panel-{index}"
|
|
170
|
+
aria-labelledby="tab-{index}"
|
|
171
|
+
data-panel-active={isVisible}
|
|
172
|
+
>
|
|
173
|
+
{@render tabPanelSnippet(item)}
|
|
174
|
+
</div>
|
|
175
|
+
{/each}
|
|
176
|
+
</div>
|
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,14 +39,10 @@ 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
|
|
|
50
45
|
export { default as Card } from './Card.svelte'
|
|
51
46
|
export { default as Shine } from './Shine.svelte'
|
|
52
47
|
export { default as Tilt } from './Tilt.svelte'
|
|
48
|
+
export { default as GraphPaper } from './GraphPaper.svelte'
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { defaultStateIcons, getKeyFromPath, getSnippet } from '@rokkit/core'
|
|
3
|
+
import Icon from '../Icon.svelte'
|
|
4
|
+
import Connector from '../Connector.svelte'
|
|
5
|
+
import Item from '../Item.svelte'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} Props
|
|
9
|
+
* @property {any} value
|
|
10
|
+
* @property {import('../types').FieldMapping} fields
|
|
11
|
+
* @property {any[]} types
|
|
12
|
+
* @property {import('../types').NodeStateIcons} stateIcons
|
|
13
|
+
* @property {number[]} path
|
|
14
|
+
* @property {boolean} focused
|
|
15
|
+
* @property {boolean} selected
|
|
16
|
+
* @property {boolean} expanded
|
|
17
|
+
* @property {Function} children
|
|
18
|
+
* @property {Function} stub
|
|
19
|
+
* @property {Object<string, Function>} snippets
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {Props} */
|
|
23
|
+
let {
|
|
24
|
+
value = $bindable(null),
|
|
25
|
+
fields,
|
|
26
|
+
types = [],
|
|
27
|
+
stateIcons = defaultStateIcons.node,
|
|
28
|
+
path = [],
|
|
29
|
+
focused = false,
|
|
30
|
+
selected = false,
|
|
31
|
+
expanded = false,
|
|
32
|
+
children,
|
|
33
|
+
stub = null,
|
|
34
|
+
snippets = {}
|
|
35
|
+
} = $props()
|
|
36
|
+
|
|
37
|
+
let stateName = $derived(expanded ? 'opened' : 'closed')
|
|
38
|
+
let icons = $derived({ ...defaultStateIcons.node, ...stateIcons })
|
|
39
|
+
let state = $derived(
|
|
40
|
+
expanded ? { icon: icons.opened, label: 'collapse' } : { icon: icons.closed, label: 'expand' }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const template = getSnippet(value[fields.snippet], snippets, stub)
|
|
44
|
+
const isLeaf = !value?.[fields.children] || value[fields.children]?.length === 0
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<div
|
|
48
|
+
data-tree-node
|
|
49
|
+
data-tree-leaf={isLeaf ? true : undefined}
|
|
50
|
+
data-tree-branch={!isLeaf ? true : undefined}
|
|
51
|
+
aria-current={focused}
|
|
52
|
+
aria-selected={selected}
|
|
53
|
+
aria-expanded={expanded}
|
|
54
|
+
role="treeitem"
|
|
55
|
+
data-path={getKeyFromPath(path)}
|
|
56
|
+
data-depth={path.length}
|
|
57
|
+
tabindex="-1"
|
|
58
|
+
class="flex flex-row items-center"
|
|
59
|
+
>
|
|
60
|
+
{#each types as type, index (index)}
|
|
61
|
+
{#if type === 'icon'}
|
|
62
|
+
<Icon name={state.icon} label={state.label} state={stateName} class="w-4" size="small" />
|
|
63
|
+
{:else}
|
|
64
|
+
<Connector {type} />
|
|
65
|
+
{/if}
|
|
66
|
+
{/each}
|
|
67
|
+
<div data-tree-content>
|
|
68
|
+
<svelte:boundary>
|
|
69
|
+
{#if template}
|
|
70
|
+
{@render template(value)}
|
|
71
|
+
{#snippet failed()}
|
|
72
|
+
<Item {value} {fields} />
|
|
73
|
+
{/snippet}
|
|
74
|
+
{:else}
|
|
75
|
+
<Item {value} {fields} />
|
|
76
|
+
{/if}
|
|
77
|
+
</svelte:boundary>
|
|
78
|
+
</div>
|
|
79
|
+
{@render children?.()}
|
|
80
|
+
</div>
|