@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 +40 -0
- package/src/constants.js +31 -0
- package/src/hierarchy.js +34 -0
- package/src/index.js +3 -0
- package/src/nested.svelte.js +267 -0
- package/src/node.js +64 -0
- package/src/tabular.svelte.js +8 -0
- package/src/tree-filter.svelte.js +33 -0
- package/src/vibe.svelte.js +159 -0
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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|
package/src/hierarchy.js
ADDED
|
@@ -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,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,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()
|