@rokkit/states 1.0.0-next.124 → 1.0.0-next.127

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/states",
3
- "version": "1.0.0-next.124",
3
+ "version": "1.0.0-next.127",
4
4
  "description": "Contains generic data manipulation functions that can be used in various components.",
5
5
  "author": "Jerry Thomas <me@jerrythomas.name>",
6
6
  "license": "MIT",
@@ -30,11 +30,10 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@lukeed/uuid": "^2.0.1",
34
- "@rokkit/core": "1.0.0-next.124",
33
+ "@rokkit/core": "1.0.0-next.127",
34
+ "@rokkit/data": "1.0.0-next.127",
35
35
  "d3-array": "^3.2.4",
36
- "d3-collection": "^1.0.7",
37
- "ramda": "^0.31.3",
38
- "svelte": "^5.39.2"
36
+ "ramda": "^0.32.0",
37
+ "svelte": "^5.53.5"
39
38
  }
40
39
  }
package/src/constants.js CHANGED
@@ -9,6 +9,7 @@ export const DEFAULT_EVENTS = {
9
9
 
10
10
  export const VALID_DENSITIES = ['compact', 'comfortable', 'cozy']
11
11
  export const VALID_MODES = ['light', 'dark']
12
+ export const VALID_DIRECTIONS = ['ltr', 'rtl']
12
13
  /** @type {string[]} */
13
14
  export const DEFAULT_STYLES = ['rokkit', 'minimal', 'material']
14
15
 
@@ -5,23 +5,25 @@ import { Proxy } from './proxy.svelte'
5
5
  * @param {Array<*>} items
6
6
  * @param {import('@rokkit/core').FieldMapping} fields
7
7
  * @param {Array<number>} path
8
+ * @param {Set<string>|null} expandedKeys - When provided, expansion is determined by key membership; falls back to item field
8
9
  * @returns {Array<{ key: string, value: any }>}
9
10
  */
10
- export function flatVisibleNodes(items, fields = defaultFields, path = []) {
11
+ export function flatVisibleNodes(items, fields = defaultFields, path = [], expandedKeys = null) {
11
12
  const data = []
13
+ if (!items || !Array.isArray(items)) return data
14
+
12
15
  items.forEach((item, index) => {
13
16
  const itemPath = [...path, index]
14
17
  const key = getKeyFromPath(itemPath)
15
- const expanded =
16
- Array.isArray(item[fields.children]) &&
17
- item[fields.children].length > 0 &&
18
- item[fields.expanded]
18
+ const hasChildren =
19
+ Array.isArray(item[fields.children]) && item[fields.children].length > 0
20
+ const expanded = hasChildren && (expandedKeys ? expandedKeys.has(key) : item[fields.expanded])
19
21
 
20
22
  data.push({ key, value: item })
21
23
 
22
24
  if (expanded) {
23
25
  const childFields = getNestedFields(fields)
24
- data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath))
26
+ data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath, expandedKeys))
25
27
  }
26
28
  })
27
29
  return data
@@ -38,6 +40,7 @@ export function flatVisibleNodes(items, fields = defaultFields, path = []) {
38
40
  */
39
41
  export function deriveLookupWithProxy(items, fields = defaultFields, path = []) {
40
42
  const lookup = new Map()
43
+ if (!items || !Array.isArray(items)) return lookup
41
44
 
42
45
  items.forEach((item, index) => {
43
46
  const itemPath = [...path, index]
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
- export { TableWrapper } from './tabular.svelte.js'
1
+ export { TableController } from './table-controller.svelte.js'
2
2
  export { Proxy } from './proxy.svelte.js'
3
3
  export { vibe } from './vibe.svelte.js'
4
4
  export { ListController } from './list-controller.svelte.js'
5
5
  export { NestedController } from './nested-controller.svelte.js'
6
+ export { messages } from './messages.svelte.js'
@@ -1,4 +1,4 @@
1
- import { FieldMapper, defaultFields } from '@rokkit/core'
1
+ import { FieldMapper, defaultFields, getKeyFromPath, getNestedFields } from '@rokkit/core'
2
2
  import { equals } from 'ramda'
3
3
  import { SvelteSet } from 'svelte/reactivity'
4
4
  import { deriveLookupWithProxy, flatVisibleNodes } from './derive.svelte'
@@ -10,12 +10,14 @@ export class ListController {
10
10
  #options = $state({})
11
11
  // lookup = new Map()
12
12
  selectedKeys = new SvelteSet()
13
+ expandedKeys = new SvelteSet()
13
14
  focusedKey = $state(null)
14
15
  #currentIndex = -1
16
+ #anchorKey = null
15
17
 
16
18
  selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key).value))
17
19
  focused = $derived(this.lookup.get(this.focusedKey)?.value)
18
- data = $derived(flatVisibleNodes(this.items, this.fields))
20
+ data = $derived(flatVisibleNodes(this.items, this.fields, [], this.expandedKeys))
19
21
  lookup = $derived(deriveLookupWithProxy(this.items, this.fields))
20
22
 
21
23
  constructor(items, value, fields, options) {
@@ -23,9 +25,29 @@ export class ListController {
23
25
  this.fields = { ...defaultFields, ...fields }
24
26
  this.mappers.push(new FieldMapper(fields))
25
27
  this.#options = { multiselect: false, ...options }
28
+ this.#initExpandedKeys(items, this.fields)
26
29
  this.init(value)
27
30
  }
28
31
 
32
+ /**
33
+ * Scan items for pre-existing expanded flags and populate expandedKeys
34
+ * @private
35
+ */
36
+ #initExpandedKeys(items, fields, path = []) {
37
+ if (!items || !Array.isArray(items)) return
38
+ items.forEach((item, index) => {
39
+ if (item === null || item === undefined || typeof item !== 'object') return
40
+ const itemPath = [...path, index]
41
+ const children = item[fields.children]
42
+ if (Array.isArray(children) && children.length > 0) {
43
+ if (item[fields.expanded]) {
44
+ this.expandedKeys.add(getKeyFromPath(itemPath))
45
+ }
46
+ this.#initExpandedKeys(children, getNestedFields(fields), itemPath)
47
+ }
48
+ })
49
+ }
50
+
29
51
  /**
30
52
  * @private
31
53
  * @param {Array<*>} items
@@ -54,7 +76,17 @@ export class ListController {
54
76
  * @returns
55
77
  */
56
78
  findByValue(value) {
57
- const index = this.data.findIndex((row) => equals(row.value, value))
79
+ // Try exact match first (full object comparison)
80
+ let index = this.data.findIndex((row) => equals(row.value, value))
81
+
82
+ // Fallback: match by extracted value field (e.g. primitive 'a' against { text: 'A', value: 'a' })
83
+ if (index < 0) {
84
+ const valueField = this.fields.value
85
+ index = this.data.findIndex(
86
+ (row) => typeof row.value === 'object' && row.value !== null && equals(row.value[valueField], value)
87
+ )
88
+ }
89
+
58
90
  return index < 0 ? { index } : { index, ...this.data[index] }
59
91
  }
60
92
 
@@ -79,12 +111,13 @@ export class ListController {
79
111
 
80
112
  /**
81
113
  *
82
- * @param {string} path
114
+ * @param {string|number} path - path key string (e.g. "0", "1-0", "2-1-3")
83
115
  * @returns
84
116
  */
85
117
  moveTo(path) {
86
- const index = Number(path)
87
- return this.moveToIndex(index)
118
+ const key = String(path)
119
+ const index = this.data.findIndex((row) => row.key === key)
120
+ return index >= 0 ? this.moveToIndex(index) : false
88
121
  }
89
122
 
90
123
  /**
@@ -100,28 +133,44 @@ export class ListController {
100
133
  return false
101
134
  }
102
135
 
136
+ /**
137
+ * @private
138
+ * @param {number} index
139
+ * @returns {boolean}
140
+ */
141
+ #isDisabled(index) {
142
+ const item = this.data[index]?.value
143
+ if (item === null || item === undefined || typeof item !== 'object') return false
144
+ return item[this.fields.disabled] === true
145
+ }
146
+
103
147
  movePrev() {
104
- if (this.#currentIndex > 0) {
105
- return this.moveToIndex(this.#currentIndex - 1)
106
- } else if (this.#currentIndex < 0) {
107
- return this.moveLast()
148
+ if (this.#currentIndex < 0) return this.moveLast()
149
+ for (let i = this.#currentIndex - 1; i >= 0; i--) {
150
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
108
151
  }
109
152
  return false
110
153
  }
111
154
 
112
155
  moveNext() {
113
- if (this.#currentIndex < this.data.length - 1) {
114
- return this.moveToIndex(this.#currentIndex + 1)
156
+ for (let i = this.#currentIndex + 1; i < this.data.length; i++) {
157
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
115
158
  }
116
159
  return false
117
160
  }
118
161
 
119
162
  moveFirst() {
120
- return this.moveToIndex(0)
163
+ for (let i = 0; i < this.data.length; i++) {
164
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
165
+ }
166
+ return false
121
167
  }
122
168
 
123
169
  moveLast() {
124
- return this.moveToIndex(this.data.length - 1)
170
+ for (let i = this.data.length - 1; i >= 0; i--) {
171
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
172
+ }
173
+ return false
125
174
  }
126
175
 
127
176
  /**
@@ -159,6 +208,7 @@ export class ListController {
159
208
  this.selectedKeys.add(key)
160
209
  }
161
210
 
211
+ this.#anchorKey = key
162
212
  return true
163
213
  }
164
214
 
@@ -173,12 +223,74 @@ export class ListController {
173
223
  if (!this.lookup.has(key)) return false
174
224
 
175
225
  if (this.#options.multiselect) {
226
+ this.#anchorKey = key
176
227
  return this.toggleSelection(key)
177
228
  } else {
178
229
  return this.select(key)
179
230
  }
180
231
  }
181
232
 
233
+ /**
234
+ * Select all non-disabled items between the anchor and the given key (inclusive).
235
+ * Used for Shift+click range selection in multiselect mode.
236
+ * @param {string} selectedKey
237
+ * @returns {boolean}
238
+ */
239
+ selectRange(selectedKey) {
240
+ const key = selectedKey ?? this.focusedKey
241
+ if (!this.lookup.has(key)) return false
242
+
243
+ if (!this.#options.multiselect) return this.select(key)
244
+
245
+ const anchorKey = this.#anchorKey ?? this.focusedKey
246
+ if (!anchorKey) return this.select(key)
247
+
248
+ const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
249
+ const targetIndex = this.data.findIndex((row) => row.key === key)
250
+ if (anchorIndex < 0 || targetIndex < 0) return false
251
+
252
+ const start = Math.min(anchorIndex, targetIndex)
253
+ const end = Math.max(anchorIndex, targetIndex)
254
+
255
+ this.selectedKeys.clear()
256
+ for (let i = start; i <= end; i++) {
257
+ if (!this.#isDisabled(i)) {
258
+ this.selectedKeys.add(this.data[i].key)
259
+ }
260
+ }
261
+
262
+ // Move focus but don't change anchor (anchor stays for subsequent Shift+clicks)
263
+ this.moveToIndex(targetIndex)
264
+ return true
265
+ }
266
+
267
+ /**
268
+ * Find the first visible, non-disabled item whose text starts with `query`.
269
+ * Search wraps around and starts after `startAfterKey` for cycling.
270
+ *
271
+ * @param {string} query - Prefix to match (case-insensitive)
272
+ * @param {string|null} [startAfterKey] - Key to start searching after (for cycling)
273
+ * @returns {string|null} The matching item's key, or null
274
+ */
275
+ findByText(query, startAfterKey = null) {
276
+ const q = query.toLowerCase()
277
+ let startIndex = 0
278
+ if (startAfterKey !== null) {
279
+ const idx = this.data.findIndex((row) => row.key === startAfterKey)
280
+ if (idx >= 0) startIndex = idx + 1
281
+ }
282
+ for (let i = 0; i < this.data.length; i++) {
283
+ const idx = (startIndex + i) % this.data.length
284
+ if (this.#isDisabled(idx)) continue
285
+ const proxy = this.lookup.get(this.data[idx].key)
286
+ const text = proxy?.get('text') ?? ''
287
+ if (String(text).toLowerCase().startsWith(q)) {
288
+ return this.data[idx].key
289
+ }
290
+ }
291
+ return null
292
+ }
293
+
182
294
  update(items) {
183
295
  this.items = items
184
296
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Default messages for UI components
3
+ * @type {import('./types').Messages}
4
+ */
5
+ const defaultMessages = {
6
+ emptyList: 'No items found',
7
+ emptyTree: 'No data available',
8
+ loading: 'Loading...',
9
+ noResults: 'No results found',
10
+ select: 'Select an option',
11
+ search: 'Search...'
12
+ }
13
+
14
+ /**
15
+ * Messages store for localized UI strings
16
+ */
17
+ class MessagesStore {
18
+ /** @type {import('./types').Messages} */
19
+ #messages = $state({ ...defaultMessages })
20
+
21
+ /**
22
+ * Get the current messages
23
+ * @returns {import('./types').Messages}
24
+ */
25
+ get current() {
26
+ return this.#messages
27
+ }
28
+
29
+ /**
30
+ * Set custom messages (merges with defaults)
31
+ * @param {Partial<import('./types').Messages>} custom
32
+ */
33
+ set(custom) {
34
+ this.#messages = { ...defaultMessages, ...custom }
35
+ }
36
+
37
+ /**
38
+ * Reset to default messages
39
+ */
40
+ reset() {
41
+ this.#messages = { ...defaultMessages }
42
+ }
43
+ }
44
+
45
+ export const messages = new MessagesStore()
@@ -21,51 +21,74 @@ export class NestedController extends ListController {
21
21
  */
22
22
  ensureVisible(value) {
23
23
  const result = this.lookup.entries().find((entry) => equals(entry[1].value, value))
24
+ if (!result) return false
24
25
  const path = getPathFromKey(result[0])
25
26
 
26
27
  for (let i = 1; i < path.length; i++) {
27
28
  const nodeKey = getKeyFromPath(path.slice(0, i))
28
- this.expand(nodeKey)
29
+ this.expandedKeys.add(nodeKey)
29
30
  }
30
31
  return true
31
32
  }
32
33
 
33
34
  /**
34
35
  * Toggle expansion of item
35
- * @param {*} value
36
- * @returns
36
+ * @param {string} key
37
+ * @returns {boolean}
37
38
  */
38
39
  toggleExpansion(key) {
39
40
  if (!this.lookup.has(key)) return false
40
- const proxy = this.lookup.get(key)
41
- proxy.expanded = !proxy.expanded
41
+ if (this.expandedKeys.has(key)) {
42
+ this.expandedKeys.delete(key)
43
+ } else {
44
+ this.expandedKeys.add(key)
45
+ }
42
46
  return true
43
47
  }
44
48
 
45
49
  /**
46
- * Expand item
47
- * @param {*} value
48
- * @returns
50
+ * Expand item. If already expanded, move focus to first child.
51
+ * @param {string} [key]
52
+ * @returns {boolean}
49
53
  */
50
54
  expand(key) {
51
55
  const actualKey = key ?? this.focusedKey
52
56
  if (!this.lookup.has(actualKey)) return false
53
- const proxy = this.lookup.get(actualKey)
54
- proxy.expanded = true
55
57
 
58
+ const firstChildKey = `${actualKey}-0`
59
+ const hasChildren = this.lookup.has(firstChildKey)
60
+
61
+ if (!hasChildren) return false
62
+
63
+ if (this.expandedKeys.has(actualKey)) {
64
+ // Already expanded → move to first child
65
+ return this.moveTo(firstChildKey)
66
+ }
67
+
68
+ this.expandedKeys.add(actualKey)
56
69
  return true
57
70
  }
58
71
 
59
72
  /**
60
- * Collapse item
61
- * @param {*} value
62
- * @returns
73
+ * Collapse item. If not expandable (leaf or already collapsed), move focus to parent.
74
+ * @param {string} [key]
75
+ * @returns {boolean}
63
76
  */
64
77
  collapse(key) {
65
78
  const actualKey = key ?? this.focusedKey
66
79
  if (!this.lookup.has(actualKey)) return false
67
- const proxy = this.lookup.get(actualKey)
68
- proxy.expanded = false
69
- return true
80
+
81
+ if (this.expandedKeys.has(actualKey)) {
82
+ this.expandedKeys.delete(actualKey)
83
+ return true
84
+ }
85
+
86
+ // Leaf or collapsed group → move to parent
87
+ const path = getPathFromKey(actualKey)
88
+ if (path.length > 1) {
89
+ const parentKey = getKeyFromPath(path.slice(0, -1))
90
+ return this.lookup.has(parentKey) ? this.moveTo(parentKey) : false
91
+ }
92
+ return false
70
93
  }
71
94
  }
@@ -117,10 +117,4 @@ export class Proxy {
117
117
  get expanded() {
118
118
  return this.has('expanded') ? this.#value[this.fields.expanded] : false
119
119
  }
120
-
121
- set expanded(value) {
122
- if (typeof this.#original === 'object') {
123
- this.#value[this.fields.expanded] = Boolean(value)
124
- }
125
- }
126
120
  }
@@ -0,0 +1,199 @@
1
+ import { ascending, descending } from 'd3-array'
2
+ import { deriveColumns } from '@rokkit/data'
3
+ import { ListController } from './list-controller.svelte.js'
4
+
5
+ /**
6
+ * TableController — manages table state via composition over ListController.
7
+ *
8
+ * Handles column metadata, sorting (single and multi-column), and delegates
9
+ * row focus/selection/navigation to an internal ListController.
10
+ */
11
+ export class TableController {
12
+ columns = $state([])
13
+ sortState = $state([])
14
+
15
+ #list
16
+ #rawData
17
+ #fields
18
+
19
+ /**
20
+ * @param {Array<Record<string, unknown>>} data - Row data
21
+ * @param {Object} [options]
22
+ * @param {Array} [options.columns] - Column definitions (auto-derived if empty)
23
+ * @param {Object} [options.fields] - Row-level field mapping
24
+ * @param {*} [options.value] - Initial selected value
25
+ * @param {boolean} [options.multiselect] - Enable multi-row selection
26
+ */
27
+ constructor(data = [], options = {}) {
28
+ const { columns, fields, value, multiselect } = options
29
+ this.#rawData = data
30
+ this.#fields = fields
31
+ this.columns = columns?.length
32
+ ? columns.map((c) => ({ sortable: true, sorted: 'none', ...c }))
33
+ : deriveColumns(data)
34
+ this.#list = new ListController(data, value, fields, { multiselect })
35
+ }
36
+
37
+ // =========================================================================
38
+ // Sort
39
+ // =========================================================================
40
+
41
+ /**
42
+ * Toggle sort on a column. Cycles: none → ascending → descending → none.
43
+ * @param {string} columnName - Column to sort by
44
+ * @param {boolean} [extend=false] - If true (Shift+click), add to sort stack
45
+ */
46
+ sortBy(columnName, extend = false) {
47
+ const col = this.columns.find((c) => c.name === columnName)
48
+ if (!col || col.sortable === false) return
49
+
50
+ // Determine next direction
51
+ const cycle = { none: 'ascending', ascending: 'descending', descending: 'none' }
52
+ const nextDirection = cycle[col.sorted ?? 'none']
53
+
54
+ if (extend) {
55
+ // Multi-column sort: add/update/remove this column in the sort stack
56
+ const existing = this.sortState.findIndex((s) => s.column === columnName)
57
+ if (nextDirection === 'none') {
58
+ // Remove from stack
59
+ this.sortState = this.sortState.filter((s) => s.column !== columnName)
60
+ } else if (existing >= 0) {
61
+ // Update direction in place
62
+ this.sortState = this.sortState.map((s) =>
63
+ s.column === columnName ? { ...s, direction: nextDirection } : s
64
+ )
65
+ } else {
66
+ // Add to stack
67
+ this.sortState = [...this.sortState, { column: columnName, direction: nextDirection }]
68
+ }
69
+ } else {
70
+ // Single column sort: replace entire sort state
71
+ this.sortState = nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
72
+ }
73
+
74
+ // Update column sorted flags
75
+ this.columns = this.columns.map((c) => {
76
+ const sort = this.sortState.find((s) => s.column === c.name)
77
+ return { ...c, sorted: sort ? sort.direction : 'none' }
78
+ })
79
+
80
+ // Apply sort and update list
81
+ this.#applySortAndUpdate()
82
+ }
83
+
84
+ /**
85
+ * Clear all sort state and restore original data order.
86
+ */
87
+ clearSort() {
88
+ this.sortState = []
89
+ this.columns = this.columns.map((c) => ({ ...c, sorted: 'none' }))
90
+ this.#list.update(this.#rawData)
91
+ }
92
+
93
+ /**
94
+ * Apply current sortState to rawData and feed sorted data to list controller.
95
+ * @private
96
+ */
97
+ #applySortAndUpdate() {
98
+ if (this.sortState.length === 0) {
99
+ this.#list.update(this.#rawData)
100
+ return
101
+ }
102
+
103
+ const sorted = [...this.#rawData].sort((a, b) => {
104
+ for (const { column, direction } of this.sortState) {
105
+ const comparator = direction === 'ascending' ? ascending : descending
106
+ const result = comparator(a[column], b[column])
107
+ if (result !== 0) return result
108
+ }
109
+ return 0
110
+ })
111
+
112
+ this.#list.update(sorted)
113
+ }
114
+
115
+ // =========================================================================
116
+ // Data access (delegated to ListController)
117
+ // =========================================================================
118
+
119
+ get data() {
120
+ return this.#list.data
121
+ }
122
+
123
+ get lookup() {
124
+ return this.#list.lookup
125
+ }
126
+
127
+ get focusedKey() {
128
+ return this.#list.focusedKey
129
+ }
130
+
131
+ set focusedKey(v) {
132
+ this.#list.focusedKey = v
133
+ }
134
+
135
+ get focused() {
136
+ return this.#list.focused
137
+ }
138
+
139
+ get selected() {
140
+ return this.#list.selected
141
+ }
142
+
143
+ get selectedKeys() {
144
+ return this.#list.selectedKeys
145
+ }
146
+
147
+ // =========================================================================
148
+ // Navigation (delegated to ListController)
149
+ // =========================================================================
150
+
151
+ moveFirst() {
152
+ return this.#list.moveFirst()
153
+ }
154
+
155
+ moveLast() {
156
+ return this.#list.moveLast()
157
+ }
158
+
159
+ moveNext() {
160
+ return this.#list.moveNext()
161
+ }
162
+
163
+ movePrev() {
164
+ return this.#list.movePrev()
165
+ }
166
+
167
+ moveTo(path) {
168
+ return this.#list.moveTo(path)
169
+ }
170
+
171
+ moveToIndex(index) {
172
+ return this.#list.moveToIndex(index)
173
+ }
174
+
175
+ // =========================================================================
176
+ // Selection (delegated to ListController)
177
+ // =========================================================================
178
+
179
+ select(key) {
180
+ return this.#list.select(key)
181
+ }
182
+
183
+ extendSelection(key) {
184
+ return this.#list.extendSelection(key)
185
+ }
186
+
187
+ // =========================================================================
188
+ // Update
189
+ // =========================================================================
190
+
191
+ /**
192
+ * Update the data source. Re-applies current sort if active.
193
+ * @param {Array<Record<string, unknown>>} data
194
+ */
195
+ update(data) {
196
+ this.#rawData = data
197
+ this.#applySortAndUpdate()
198
+ }
199
+ }
@@ -1,8 +1,9 @@
1
1
  /** @typedef {'light' | 'dark'} ThemeMode */
2
2
  /** @typedef {'cozy' | 'compact' | 'comfortable'} Density */
3
+ /** @typedef {'ltr' | 'rtl'} Direction */
3
4
 
4
- import { defaultColors, defaultThemeMapping, themeRules } from '@rokkit/core'
5
- import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES } from './constants'
5
+ import { defaultColors, defaultThemeMapping, themeRules, detectDirection } from '@rokkit/core'
6
+ import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES, VALID_DIRECTIONS } from './constants'
6
7
  import { has } from 'ramda'
7
8
 
8
9
  /**
@@ -25,6 +26,7 @@ class Vibe {
25
26
  #style = $state('rokkit')
26
27
  #colors = $state(defaultColors)
27
28
  #density = $state('comfortable')
29
+ #direction = $state(detectDirection())
28
30
  #colorMap = $state(defaultThemeMapping)
29
31
  #palette = $derived.by(() => themeRules(this.#colorMap, this.#colors))
30
32
 
@@ -105,6 +107,21 @@ class Vibe {
105
107
  }
106
108
  }
107
109
 
110
+ get direction() {
111
+ return this.#direction
112
+ }
113
+
114
+ set direction(value) {
115
+ if (isAllowedValue(value, VALID_DIRECTIONS, this.#direction)) {
116
+ this.#direction = value
117
+ }
118
+ }
119
+
120
+ /** @returns {boolean} */
121
+ get isRTL() {
122
+ return this.#direction === 'rtl'
123
+ }
124
+
108
125
  get palette() {
109
126
  return this.#palette
110
127
  }
@@ -133,7 +150,12 @@ class Vibe {
133
150
  if (!key) throw new Error('Key is required')
134
151
 
135
152
  try {
136
- const config = { style: this.#style, mode: this.#mode, density: this.#density }
153
+ const config = {
154
+ style: this.#style,
155
+ mode: this.#mode,
156
+ density: this.#density,
157
+ direction: this.#direction
158
+ }
137
159
  localStorage.setItem(key, JSON.stringify(config))
138
160
  } catch (e) {
139
161
  // eslint-disable-next-line no-console
@@ -149,6 +171,15 @@ class Vibe {
149
171
  this.style = value.style
150
172
  this.mode = value.mode
151
173
  this.density = value.density
174
+ this.direction = value.direction
175
+ }
176
+
177
+ /**
178
+ * Re-detect direction from document
179
+ * Useful when lang attribute changes dynamically
180
+ */
181
+ detectDirection() {
182
+ this.#direction = detectDirection()
152
183
  }
153
184
  }
154
185