@rokkit/states 1.0.0-next.106

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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@rokkit/states",
3
+ "version": "1.0.0-next.106",
4
+ "description": "Contains generic data manipulation functions that can be used in various components.",
5
+ "author": "Jerry Thomas <me@jerrythomas.name>",
6
+ "license": "MIT",
7
+ "main": "index.js",
8
+ "module": "src/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "type": "module",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "scripts": {
15
+ "prepublishOnly": "tsc --project tsconfig.build.json",
16
+ "clean": "rm -rf dist",
17
+ "build": "bun clean && bun prepublishOnly"
18
+ },
19
+ "files": [
20
+ "src/**/*.js",
21
+ "src/**/*.svelte"
22
+ ],
23
+ "exports": {
24
+ "./src": "./src",
25
+ "./package.json": "./package.json",
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./src/index.js",
29
+ "svelte": "./src/index.js"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@lukeed/uuid": "^2.0.1",
34
+ "@rokkit/core": "1.0.0-next.105",
35
+ "d3-array": "^3.2.4",
36
+ "d3-collection": "^1.0.7",
37
+ "ramda": "^0.30.1",
38
+ "svelte": "^5.10.0"
39
+ }
40
+ }
@@ -0,0 +1,31 @@
1
+ import { noop } from '@rokkit/core'
2
+
3
+ export const DEFAULT_EVENTS = {
4
+ move: noop,
5
+ select: noop,
6
+ expand: noop,
7
+ collapse: noop
8
+ }
9
+
10
+ export const VALID_DENSITIES = ['compact', 'comfortable', 'cozy']
11
+ export const VALID_MODES = ['light', 'dark']
12
+ /** @type {string[]} */
13
+ export const DEFAULT_STYLES = ['rokkit', 'minimal', 'material']
14
+
15
+ export const DEFAULT_VIBE_OPTIONS = {
16
+ allowed: DEFAULT_STYLES,
17
+ style: 'rokkit',
18
+ mode: 'dark',
19
+ density: 'comfortable'
20
+ }
21
+
22
+ export const DEFAULT_VIBE_PALETTE = {
23
+ primary: '#007bff',
24
+ secondary: '#6c757d',
25
+ success: '#28a745',
26
+ info: '#17a2b8',
27
+ warning: '#ffc107',
28
+ danger: '#dc3545',
29
+ light: '#f8f9fa',
30
+ dark: '#343a40'
31
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @typedef {Object} HierarchyItem
3
+ * @property {number} depth - The depth of the item in the hierarchy.
4
+ * @property {Array<number>} path - The path of the item in the hierarchy.
5
+ * @property {any} item - The item itself.
6
+ * @property {HierarchyItem} parent - The reference to the parent item.
7
+ */
8
+
9
+ /**
10
+ * Converts a hierarchy of items into a flat array of objects.
11
+
12
+ * @param {Array<any>} items - The array of items to convert.
13
+ * @param {import('@rokkit/core').FieldMapper} mapping - The field mapper to use for mapping.
14
+ * @param {HierarchyItem} parent - The current path of the item.
15
+ * @returns {Array<Object>} - The flat array of objects.
16
+ */
17
+ export function flatHierarchy(items, mapping, parent = null) {
18
+ const data = []
19
+ const path = parent?.path ?? []
20
+
21
+ items.forEach((item, index) => {
22
+ const props = {
23
+ depth: path.length,
24
+ path: [...path, index],
25
+ item,
26
+ parent
27
+ }
28
+
29
+ const children = flatHierarchy(mapping.getChildren(item), mapping, props)
30
+ data.push(props, ...children)
31
+ })
32
+
33
+ return data
34
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { DataWrapper } from './nested.svelte'
2
+ export { TableWrapper } from './tabular.svelte'
3
+ export { vibe } from './vibe.svelte'
@@ -0,0 +1,267 @@
1
+ import { has, equals, pick, omit } from 'ramda'
2
+ import { SvelteMap } from 'svelte/reactivity'
3
+ import { DEFAULT_EVENTS } from './constants'
4
+ import { FieldMapper, getKeyFromPath } from '@rokkit/core'
5
+
6
+ export class DataWrapper {
7
+ /* @type number[] */
8
+ #path = []
9
+ #events = {}
10
+ #init = false
11
+ #keys = null
12
+ #options = { multiselect: false, autoCloseSiblings: false }
13
+
14
+ items = null
15
+ data = $state(null)
16
+ value = $state(null)
17
+ mapping = new FieldMapper()
18
+ currentNode = $state(null)
19
+ selected = new SvelteMap()
20
+
21
+ constructor(items, mapper, value, options = {}) {
22
+ this.items = items
23
+ this.data = items
24
+ if (mapper) this.mapping = mapper
25
+
26
+ this.#events = { ...DEFAULT_EVENTS, ...options.events }
27
+ this.#options = { ...options, ...pick(['multiselect', 'autoCloseSiblings'], options) }
28
+ this.#keys = options.keys || null
29
+ this.moveToValue(value)
30
+ }
31
+
32
+ moveToValue(value) {
33
+ this.#init = true
34
+ this.value = value
35
+ this.moveTo(this.findPathToItem(value))
36
+ this.#expandPath(this.#path)
37
+
38
+ this.#init = false
39
+ }
40
+
41
+ #expandPath(path) {
42
+ if (!path.length) return
43
+ for (let i = 0; i < path.length; i++) {
44
+ const item = this.mapping.getItemByPath(this.data, path.slice(0, i + 1))
45
+ if (!this.mapping.isExpanded(item)) {
46
+ this.mapping.toggleExpansion(item)
47
+ }
48
+ }
49
+ }
50
+ #matchItems(a, b) {
51
+ if (this.#keys) {
52
+ return equals(pick(this.#keys, a), pick(this.#keys, b))
53
+ } else {
54
+ return equals(a, b)
55
+ }
56
+ }
57
+ /**
58
+ * Finds an item in a tree structure and returns the path as array of indices
59
+ * @param {*} value - The value to find
60
+ * @param {number[]} parent - The current path being explored
61
+ * @returns {number[]|null} - Array of indices representing path to item, or null if not found
62
+ */
63
+ findPathToItem(value, parent = []) {
64
+ const children = this.mapping.getChildrenByPath(this.data, parent)
65
+ // Direct child check
66
+ const directIndex = children.findIndex((item) => this.#matchItems(item, value))
67
+ if (directIndex !== -1) {
68
+ return [...parent, directIndex]
69
+ }
70
+
71
+ // Recursive search in children
72
+ return children.reduce((path, _, index) => {
73
+ if (path.length > 0) return path
74
+ if (!this.mapping.hasChildren(children[index])) return []
75
+
76
+ return this.findPathToItem(value, [...parent, index])
77
+ }, [])
78
+ }
79
+
80
+ #getLastVisibleDescendant(node, nodePath) {
81
+ if (!this.mapping.hasChildren(node) || !this.mapping.isExpanded(node)) {
82
+ return { node, path: nodePath }
83
+ }
84
+
85
+ const children = this.mapping.getChildren(node)
86
+ if (children.length === 0) {
87
+ return { node, path: nodePath }
88
+ }
89
+
90
+ const lastChildIndex = children.length - 1
91
+ const lastChild = children[lastChildIndex]
92
+ return this.#getLastVisibleDescendant(lastChild, [...nodePath, lastChildIndex])
93
+ }
94
+
95
+ #getPreviousSiblingPath() {
96
+ const currentIndex = this.#path[this.#path.length - 1]
97
+ const prevSiblingPath = [...this.#path.slice(0, -1), currentIndex - 1]
98
+ const prevSibling = this.mapping.getItemByPath(this.data, prevSiblingPath)
99
+
100
+ if (this.mapping.isExpanded(prevSibling)) {
101
+ const { path } = this.#getLastVisibleDescendant(prevSibling, prevSiblingPath)
102
+ return path
103
+ } else {
104
+ return prevSiblingPath
105
+ }
106
+ }
107
+
108
+ #getNextSiblingPath(inputPath) {
109
+ const parentPath = inputPath.slice(0, -1)
110
+ const currentIndex = Number(inputPath[inputPath.length - 1])
111
+
112
+ const siblings = this.mapping.getChildrenByPath(this.data, parentPath)
113
+ if (currentIndex < siblings.length - 1) {
114
+ return [...parentPath, currentIndex + 1]
115
+ } else if (parentPath.length > 0) {
116
+ return this.#getNextSiblingPath(parentPath)
117
+ }
118
+ return null
119
+ }
120
+
121
+ emit(type, data) {
122
+ if (!this.#init && has(type, this.#events)) this.#events[type](data)
123
+ }
124
+
125
+ moveTo(path) {
126
+ if (!path) return
127
+ const currentPath = Array.isArray(path) ? path : [path]
128
+
129
+ if (equals(currentPath, this.#path)) return
130
+
131
+ this.#path = currentPath
132
+ if (currentPath.length === 0) {
133
+ this.currentNode = null
134
+ } else {
135
+ this.currentNode = this.mapping.getItemByPath(this.data, currentPath)
136
+ this.emit('move', { path: this.#path, node: this.currentNode })
137
+ }
138
+ }
139
+ movePrev() {
140
+ let currentPath = [0]
141
+ if (this.#path.length === 0) this.moveTo([0])
142
+
143
+ // Return false if at root level first item
144
+ if (this.#path.length === 1 && this.#path[0] === 0) {
145
+ return
146
+ }
147
+
148
+ // Get previous sibling index
149
+ const currentIndex = this.#path[this.#path.length - 1]
150
+ if (currentIndex > 0) {
151
+ // Has previous sibling
152
+ currentPath = this.#getPreviousSiblingPath()
153
+ } else {
154
+ // Move to parent
155
+ currentPath = this.#path.slice(0, -1)
156
+ }
157
+ this.moveTo(currentPath)
158
+ }
159
+
160
+ moveNext() {
161
+ if (this.#path.length === 0) {
162
+ this.moveTo([0])
163
+ return
164
+ }
165
+
166
+ const currentNode = this.currentNode
167
+
168
+ // If current node is expanded and has children, move to first child
169
+ if (this.mapping.isExpanded(currentNode) && this.mapping.hasChildren(currentNode)) {
170
+ this.moveTo([...this.#path, 0])
171
+ return
172
+ }
173
+
174
+ // Try to move to next sibling
175
+ const nextSiblingPath = this.#getNextSiblingPath(this.#path)
176
+ if (nextSiblingPath) {
177
+ this.moveTo(nextSiblingPath)
178
+ return
179
+ }
180
+ }
181
+
182
+ #collapseParent() {
183
+ this.moveTo(this.#path.slice(0, -1))
184
+ if (this.mapping.isExpanded(this.currentNode)) {
185
+ this.toggleExpansion()
186
+ }
187
+ }
188
+
189
+ collapse() {
190
+ // if not expanded child move to parent?
191
+ if (this.mapping.isExpanded(this.currentNode)) {
192
+ this.toggleExpansion()
193
+ } else if (this.#path.length > 1) {
194
+ this.#collapseParent()
195
+ }
196
+ }
197
+
198
+ expand() {
199
+ if (this.mapping.isExpanded(this.currentNode)) return
200
+ this.toggleExpansion()
201
+ }
202
+
203
+ collapseSiblings() {
204
+ if (!this.#options.autoCloseSiblings || !this.mapping.isExpanded(this.currentNode)) return
205
+
206
+ const parentPath = this.#path.slice(0, -1)
207
+ const siblings = this.mapping.getChildrenByPath(this.data, parentPath)
208
+ const currentIndex = this.#path[this.#path.length - 1]
209
+
210
+ siblings.forEach((sibling, index) => {
211
+ if (currentIndex !== index && this.mapping.isExpanded(sibling)) {
212
+ this.mapping.toggleExpansion(sibling)
213
+ }
214
+ })
215
+ }
216
+
217
+ toggleExpansion() {
218
+ if (!this.currentNode || !this.mapping.hasChildren(this.currentNode)) return
219
+
220
+ const eventType = this.mapping.isExpanded(this.currentNode) ? 'collapse' : 'expand'
221
+ this.mapping.toggleExpansion(this.currentNode)
222
+ this.collapseSiblings()
223
+ this.emit(eventType, { path: this.#path, node: this.currentNode })
224
+ }
225
+
226
+ select(path = null) {
227
+ this.moveTo(path)
228
+
229
+ if (this.currentNode) {
230
+ this.value = this.mapping.getItemByPath(this.data, this.#path)
231
+ this.selected.clear()
232
+ this.selected.set(getKeyFromPath(this.#path), this.currentNode)
233
+ this.emit('select', {
234
+ path: this.#path,
235
+ node: this.currentNode,
236
+ selected: this.selected
237
+ })
238
+ }
239
+ }
240
+
241
+ #toggleSelection() {
242
+ if (!this.currentNode) return
243
+
244
+ const isSelected = this.selected.has(getKeyFromPath(this.#path))
245
+
246
+ if (isSelected) {
247
+ this.selected.delete(getKeyFromPath(this.#path))
248
+ } else {
249
+ this.selected.set(getKeyFromPath(this.#path), this.currentNode)
250
+ }
251
+
252
+ this.emit('select', {
253
+ path: this.#path,
254
+ node: this.currentNode,
255
+ selected: this.selected
256
+ })
257
+ }
258
+
259
+ extendSelection(path = null) {
260
+ this.moveTo(path)
261
+ if (this.#options.multiselect) {
262
+ this.#toggleSelection()
263
+ } else {
264
+ this.select()
265
+ }
266
+ }
267
+ }
package/src/node.js ADDED
@@ -0,0 +1,64 @@
1
+ export class Node {
2
+ #original
3
+ #state
4
+ #mapper
5
+ #children
6
+
7
+ constructor(data, mapper) {
8
+ this.#original = data
9
+ this.#state = {}
10
+ this.#mapper = mapper
11
+
12
+ // Initialize children if they exist
13
+ if (this.#mapper.hasChildren(data)) {
14
+ this.#children = this.#mapper.getChildren(data).map((child) => new Node(child, mapper))
15
+ }
16
+
17
+ // Create getters/setters for all properties except children
18
+ Object.keys(data).forEach((key) => {
19
+ if (key !== this.#mapper.fields.children) {
20
+ Object.defineProperty(this, key, {
21
+ get: () => this.#state[key] ?? this.#original[key],
22
+ set: (value) => {
23
+ this.#original[key] = value
24
+ this.#state[key] = value
25
+ },
26
+ enumerable: true
27
+ })
28
+ }
29
+ })
30
+ }
31
+
32
+ get children() {
33
+ return this.#children ?? []
34
+ }
35
+
36
+ get original() {
37
+ return this.#original
38
+ }
39
+
40
+ addChild(data) {
41
+ if (!this.#mapper.hasChildren(this.#original)) {
42
+ this.#original[this.#mapper.fields.children] = []
43
+ this.#children = []
44
+ }
45
+
46
+ const newNode = new Node(data, this.#mapper)
47
+ this.#original[this.#mapper.fields.children].push(data)
48
+ this.#children.push(newNode)
49
+
50
+ return newNode
51
+ }
52
+
53
+ removeChild(node) {
54
+ const originalChildren = this.#original[this.#mapper.fields.children]
55
+ const index = originalChildren.findIndex((child) => child === node.original)
56
+
57
+ if (index !== -1) {
58
+ originalChildren.splice(index, 1)
59
+ this.#children.splice(index, 1)
60
+ return true
61
+ }
62
+ return false
63
+ }
64
+ }
@@ -0,0 +1,8 @@
1
+ export class TableWrapper {
2
+ data = null
3
+ headers = $state([])
4
+
5
+ constructor(items) {
6
+ this.data = items
7
+ }
8
+ }
@@ -0,0 +1,33 @@
1
+ import { Node } from './node'
2
+
3
+ /**
4
+ * Filters a tree structure based on a predicate
5
+ * @param {Array} items - The tree data
6
+ * @param {Function} predicate - Filter function that returns boolean
7
+ * @param {FieldMapper} mapper - FieldMapper instance
8
+ * @returns {Array} Filtered tree
9
+ */
10
+ export function filterTree(nodes, predicate, mapper) {
11
+ const filterNode = (node) => {
12
+ // If node matches predicate, include it and all its children
13
+ if (predicate(node)) {
14
+ return new Node(node.original, mapper)
15
+ }
16
+
17
+ // If node has children, check them
18
+ if (node.children.length > 0) {
19
+ const filteredChildren = node.children.map((child) => filterNode(child)).filter(Boolean)
20
+
21
+ if (filteredChildren.length > 0) {
22
+ // Create new node with only matching children
23
+ const filteredData = { ...node.original }
24
+ filteredData[mapper.fields.children] = filteredChildren.map((n) => n.original)
25
+ return new Node(filteredData, mapper)
26
+ }
27
+ }
28
+
29
+ return null
30
+ }
31
+
32
+ return nodes.map((node) => filterNode(node)).filter(Boolean)
33
+ }
@@ -0,0 +1,159 @@
1
+ /** @typedef {'light' | 'dark'} ThemeMode */
2
+ /** @typedef {'cozy' | 'compact' | 'comfortable'} Density */
3
+
4
+ import { defaultColors, defaultThemeMapping, themeRules } from '@rokkit/core'
5
+ import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES } from './constants'
6
+ import { has } from 'ramda'
7
+
8
+ /**
9
+ * Transforms theme rules array into an object grouped by mode
10
+ * @param {Array<[string, Object.<string, string>]>} themeRules
11
+ * @returns {Object.<string, Object.<string, string>>}
12
+ */
13
+ function groupThemeRulesByMode(themeRules) {
14
+ return themeRules.reduce((acc, [name, variables]) => {
15
+ const mode = name.includes('light') ? 'light' : 'dark'
16
+ return { ...acc, [mode]: variables }
17
+ }, {})
18
+ }
19
+
20
+ /**
21
+ * Checks if a value is allowed based on a list of allowed values and the current value
22
+ * @param {string} value
23
+ * @param {string[]} allowed
24
+ * @param {string} current
25
+ * @returns
26
+ */
27
+ function isAllowedValue(value, allowed, current) {
28
+ return allowed.includes(value) && value !== current
29
+ }
30
+
31
+ /**
32
+ * Vibe - Theme management
33
+ */
34
+ class Vibe {
35
+ #allowedStyles = $state(DEFAULT_STYLES)
36
+ #mode = $state('dark')
37
+ #style = $state('rokkit')
38
+ #colors = $state(defaultColors)
39
+ #density = $state('comfortable')
40
+ #colorMap = $state(defaultThemeMapping)
41
+ #palette = $derived.by(() => {
42
+ const grouped = groupThemeRulesByMode(themeRules(this.#style, this.#colorMap, this.#colors))
43
+ return grouped[this.#mode]
44
+ })
45
+ #hasChanged = false
46
+
47
+ /**
48
+ * Private constructor to enforce singleton pattern
49
+ * @param {VibeOptions} [options={}]
50
+ */
51
+ constructor(options = {}) {
52
+ this.mode = options.mode
53
+ this.style = options.style
54
+ this.density = options.density
55
+ this.colorMap = options.colorMap
56
+ this.colors = options.colors
57
+ }
58
+
59
+ set allowedStyles(input) {
60
+ const styles = (Array.isArray(input) ? input : [input]).filter(Boolean)
61
+ if (styles.length > 0) {
62
+ this.#allowedStyles = styles
63
+ }
64
+ }
65
+
66
+ set colorMap(value) {
67
+ if (value) {
68
+ const missing = Object.values(value).filter((key) => !has(key, this.#colors))
69
+ if (missing.length > 0) {
70
+ throw new Error(`Did you forget to define "${missing.join(', ')}"?`)
71
+ }
72
+ this.#colorMap = { ...defaultThemeMapping, ...value }
73
+ }
74
+ }
75
+
76
+ set colors(value) {
77
+ if (value) {
78
+ this.#colors = { ...defaultColors, ...value }
79
+ }
80
+ }
81
+
82
+ get style() {
83
+ return this.#style
84
+ }
85
+
86
+ set style(value) {
87
+ if (isAllowedValue(value, this.#allowedStyles, this.#style)) {
88
+ this.#style = value
89
+ }
90
+ }
91
+
92
+ get mode() {
93
+ return this.#mode
94
+ }
95
+
96
+ set mode(value) {
97
+ if (isAllowedValue(value, VALID_MODES, this.#mode)) {
98
+ this.#mode = value
99
+ }
100
+ }
101
+
102
+ get density() {
103
+ return this.#density
104
+ }
105
+
106
+ set density(value) {
107
+ if (isAllowedValue(value, VALID_DENSITIES, this.#density)) {
108
+ this.#density = value
109
+ }
110
+ }
111
+
112
+ get palette() {
113
+ return this.#palette
114
+ }
115
+
116
+ /**
117
+ * Load theme from storage
118
+ * @param {string} key
119
+ */
120
+ load(key) {
121
+ try {
122
+ const stored = localStorage.getItem(key)
123
+ if (stored) {
124
+ this.update(JSON.parse(stored))
125
+ }
126
+ } catch (e) {
127
+ // eslint-disable-next-line no-console
128
+ console.warn(`Failed to load theme from storage for key "${key}"`, e.message)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Save current theme to storage
134
+ * @param {string} key
135
+ */
136
+ save(key) {
137
+ if (!key) throw new Error('Key is required')
138
+
139
+ try {
140
+ const config = { style: this.#style, mode: this.#mode, density: this.#density }
141
+ localStorage.setItem(key, JSON.stringify(config))
142
+ } catch (e) {
143
+ // eslint-disable-next-line no-console
144
+ console.warn(`Failed to save theme to storage for key "${key}"`, e.message)
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Update theme with new values
150
+ * @param {Partial<Theme>} value
151
+ */
152
+ update(value) {
153
+ this.style = value.style
154
+ this.mode = value.mode
155
+ this.density = value.density
156
+ }
157
+ }
158
+
159
+ export const vibe = new Vibe()