@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/src/Tabs.svelte CHANGED
@@ -1,96 +1,166 @@
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
18
15
  */
19
16
 
20
- /** @type {Props} */
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: className = '',
23
- options = $bindable([]),
24
- value = $bindable(null),
25
- icons = $bindable(defaultStateIcons.action),
26
- fields = defaultFields,
27
- below = false,
28
- align = 'left',
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
- onremove = noop,
31
- onadd = noop,
32
- onselect = noop,
33
- stub,
34
- ...extra
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
- 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(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
- onremove({ item: event.detail })
75
+ if (has(name, emitter)) {
76
+ value = data.value
77
+ emitter[name](data)
78
+ }
45
79
  }
46
- function handleAdd(event) {
47
- event.stopPropagation()
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
- onselect({ item: value, indices: cursor })
85
+ function handleRemove(item) {
86
+ onremove?.(item)
55
87
  }
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)
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
- <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}
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
- {#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>
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 { 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,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
+ })