@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +1 -5
  3. package/dist/tree/List.spec.svelte.d.ts +1 -0
  4. package/dist/tree/Node.spec.svelte.d.ts +1 -0
  5. package/dist/tree/Root.spec.svelte.d.ts +1 -0
  6. package/package.json +4 -4
  7. package/src/BreadCrumbs.svelte +14 -19
  8. package/src/Button.svelte +3 -7
  9. package/src/Card.svelte +6 -2
  10. package/src/GraphPaper.svelte +43 -0
  11. package/src/Icon.svelte +21 -16
  12. package/src/Item.svelte +6 -6
  13. package/src/List.svelte +8 -7
  14. package/src/ListBody.svelte +3 -2
  15. package/src/PickOne.svelte +60 -0
  16. package/src/Pill.svelte +2 -2
  17. package/src/ProgressDots.svelte +1 -1
  18. package/src/Select.svelte +0 -3
  19. package/src/Stepper.svelte +2 -2
  20. package/src/Summary.svelte +1 -1
  21. package/src/Switch.svelte +45 -32
  22. package/src/Tabs.svelte +155 -75
  23. package/src/Toggle.svelte +2 -1
  24. package/src/ToggleThemeMode.svelte +7 -3
  25. package/src/index.js +1 -5
  26. package/src/tree/List.spec.svelte.js +84 -0
  27. package/src/tree/List.svelte +78 -0
  28. package/src/tree/Node.spec.svelte.js +104 -0
  29. package/src/tree/Node.svelte +80 -0
  30. package/src/tree/Root.spec.svelte.js +63 -0
  31. package/src/tree/Root.svelte +81 -0
  32. package/dist/input/types.d.ts +0 -9
  33. package/src/DataEditor.svelte +0 -31
  34. package/src/FieldLayout.svelte +0 -48
  35. package/src/Form.svelte +0 -17
  36. package/src/ListEditor.svelte +0 -44
  37. package/src/NestedEditor.svelte +0 -88
  38. package/src/input/Input.svelte +0 -17
  39. package/src/input/InputField.svelte +0 -69
  40. package/src/input/InputSelect.svelte +0 -23
  41. package/src/input/InputSwitch.svelte +0 -19
  42. package/src/input/types.js +0 -29
package/src/Tabs.svelte CHANGED
@@ -1,96 +1,176 @@
1
1
  <script>
2
- import { defaultFields, defaultStateIcons, noop, getSnippet, FieldMapper } from '@rokkit/core'
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} 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]
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 {Props} */
40
+ /** @type {TabProps} */
21
41
  let {
22
- class: className = '',
42
+ class: classes = '',
43
+ name = 'tabs',
23
44
  options = $bindable([]),
24
- value = $bindable(null),
25
- icons = $bindable(defaultStateIcons.action),
26
- fields = defaultFields,
27
- below = false,
28
- align = 'left',
45
+ fields = {},
46
+ value = $bindable(),
47
+ orientation = 'horizontal',
48
+ align = 'start',
49
+ position = 'before',
50
+ tabindex = 0,
29
51
  editable = false,
30
- onremove = noop,
31
- onadd = noop,
32
- onselect = noop,
33
- stub,
34
- ...extra
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
- 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
- }
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
- onremove({ item: event.detail })
74
+ if (has(name, emitter)) {
75
+ value = data.value
76
+ emitter[name](data)
77
+ }
45
78
  }
46
- function handleAdd(event) {
47
- event.stopPropagation()
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
- onselect({ item: value, indices: cursor })
84
+ function handleRemove(item) {
85
+ onremove?.(item)
55
86
  }
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)
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
- <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}
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
- {#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} />
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
- </rk-tabs>
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 { icons = defaultStateIcons.mode, labels = ['Light Mode', 'Dark Mode'] } = $props()
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 = $state(options.find((x) => x.value === vibe.mode))
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 bind:value {options} onchange={update} />
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>