@rokkit/states 1.0.0-next.125 → 1.0.0-next.128

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.
@@ -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, DEFAULT_THEME_MAPPING, 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,7 +26,8 @@ class Vibe {
25
26
  #style = $state('rokkit')
26
27
  #colors = $state(defaultColors)
27
28
  #density = $state('comfortable')
28
- #colorMap = $state(defaultThemeMapping)
29
+ #direction = $state(detectDirection())
30
+ #colorMap = $state(DEFAULT_THEME_MAPPING)
29
31
  #palette = $derived.by(() => themeRules(this.#colorMap, this.#colors))
30
32
 
31
33
  /**
@@ -61,7 +63,7 @@ class Vibe {
61
63
  if (missing.length > 0) {
62
64
  throw new Error(`Did you forget to define "${missing.join(', ')}"?`)
63
65
  }
64
- this.#colorMap = { ...defaultThemeMapping, ...value }
66
+ this.#colorMap = { ...DEFAULT_THEME_MAPPING, ...value }
65
67
  }
66
68
  }
67
69
 
@@ -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
 
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Wrapper
3
+ *
4
+ * Navigation controller for persistent list/tree/sidebar components.
5
+ * Accepts a ProxyTree instance for reactive data (flatView, lookup),
6
+ * and provides full navigation, expansion, selection, and typeahead logic.
7
+ *
8
+ * ProxyTree owns the data layer: items -> proxies -> flatView + lookup.
9
+ * Wrapper owns the navigation layer: focusedKey, movement, selection callbacks.
10
+ *
11
+ * Designed for any persistent (always-visible) component:
12
+ * - Sidebar navigation (links, collapsible groups)
13
+ * - List / Tree components
14
+ * - Any option list rendered inline
15
+ *
16
+ * Dropdown variants (Select, Menu) extend this class and override cancel() / blur()
17
+ * to close the dropdown and return focus to the trigger.
18
+ */
19
+
20
+ export class Wrapper {
21
+ // ─── Data ──────────────────────────────────────────────────────────────────
22
+
23
+ #proxyTree
24
+
25
+ // flatView: re-derives from proxyTree's flatView, which itself re-derives
26
+ // when any proxy.expanded or proxy.children changes.
27
+ flatView = $derived(this.#proxyTree.flatView)
28
+
29
+ // Navigable items: exclude separators, spacers, and disabled items.
30
+ // This is the subset that keyboard navigation moves through.
31
+ #navigable = $derived(
32
+ this.flatView.filter(
33
+ (n) => n.type !== 'separator' && n.type !== 'spacer' && !n.proxy.disabled
34
+ )
35
+ )
36
+
37
+ // ─── State ──────────────────────────────────────────────────────────────────
38
+
39
+ #focusedKey = $state(null)
40
+
41
+ // ─── Callbacks ──────────────────────────────────────────────────────────────
42
+
43
+ #onselect
44
+ #onchange
45
+ #selectedValue = $state(undefined)
46
+
47
+ /**
48
+ * @param {import('./proxy-tree.svelte.js').ProxyTree} proxyTree
49
+ * @param {{ onselect?: Function, onchange?: Function }} [options]
50
+ */
51
+ constructor(proxyTree, options = {}) {
52
+ this.#proxyTree = proxyTree
53
+ this.#onselect = options.onselect
54
+ this.#onchange = options.onchange
55
+ }
56
+
57
+ // ─── IWrapper: state read by Navigator ─────────────────────────────────────
58
+
59
+ get focusedKey() { return this.#focusedKey }
60
+
61
+ // ─── IWrapper: movement (path passed through but ignored) ──────────────────
62
+
63
+ /** Move focus to the next navigable item; clamp at end. */
64
+ next(_path) {
65
+ const nav = this.#navigable
66
+ if (!nav.length) return
67
+ const idx = nav.findIndex((n) => n.key === this.#focusedKey)
68
+ if (idx < nav.length - 1) this.#focusedKey = nav[idx + 1].key
69
+ }
70
+
71
+ /** Move focus to the previous navigable item; clamp at start. */
72
+ prev(_path) {
73
+ const nav = this.#navigable
74
+ if (!nav.length) return
75
+ const idx = nav.findIndex((n) => n.key === this.#focusedKey)
76
+ if (idx > 0) this.#focusedKey = nav[idx - 1].key
77
+ }
78
+
79
+ /** Move focus to the first navigable item. */
80
+ first(_path) {
81
+ const nav = this.#navigable
82
+ if (nav.length) this.#focusedKey = nav[0].key
83
+ }
84
+
85
+ /** Move focus to the last navigable item. */
86
+ last(_path) {
87
+ const nav = this.#navigable
88
+ if (nav.length) this.#focusedKey = nav[nav.length - 1].key
89
+ }
90
+
91
+ /**
92
+ * Expand focused group, or move focus into it if already open.
93
+ * No-op on leaf items.
94
+ */
95
+ expand(_path) {
96
+ if (!this.#focusedKey) return
97
+ const node = this.flatView.find((n) => n.key === this.#focusedKey)
98
+ if (!node || !node.hasChildren) return
99
+ if (!node.proxy.expanded) {
100
+ node.proxy.expanded = true
101
+ } else {
102
+ // Already open — advance focus to first visible child
103
+ this.next(null)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Collapse focused group, or move focus to parent if already collapsed / leaf.
109
+ * At root level with no parent: no-op.
110
+ */
111
+ collapse(_path) {
112
+ if (!this.#focusedKey) return
113
+ const node = this.flatView.find((n) => n.key === this.#focusedKey)
114
+ if (!node) return
115
+ if (node.hasChildren && node.proxy.expanded) {
116
+ node.proxy.expanded = false
117
+ } else {
118
+ // Move to parent: strip the last segment from the key
119
+ const parts = this.#focusedKey.split('-')
120
+ if (parts.length > 1) {
121
+ parts.pop()
122
+ this.#focusedKey = parts.join('-')
123
+ }
124
+ // At root level (no '-'): no-op — already at root
125
+ }
126
+ }
127
+
128
+ // ─── IWrapper: selection actions ───────────────────────────────────────────
129
+
130
+ /**
131
+ * Select item at path (or focusedKey when path is null).
132
+ * Groups toggle expanded. Leaves fire onchange (value differs) and onselect callbacks.
133
+ */
134
+ select(path) {
135
+ const key = path ?? this.#focusedKey
136
+ if (!key) return
137
+ this.#focusedKey = key
138
+ const proxy = this.#proxyTree.lookup.get(key)
139
+ if (!proxy) return
140
+ if (proxy.hasChildren) {
141
+ proxy.expanded = !proxy.expanded
142
+ } else {
143
+ if (proxy.value !== this.#selectedValue) {
144
+ this.#selectedValue = proxy.value
145
+ this.#onchange?.(proxy.value, proxy)
146
+ }
147
+ this.#onselect?.(proxy.value, proxy)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Toggle expansion of group at path — called by Navigator for accordion-trigger clicks.
153
+ * Unlike select(), this only applies to groups and never fires onselect.
154
+ */
155
+ toggle(path) {
156
+ const key = path ?? this.#focusedKey
157
+ if (!key) return
158
+ const proxy = this.#proxyTree.lookup.get(key)
159
+ if (proxy?.hasChildren) proxy.expanded = !proxy.expanded
160
+ }
161
+
162
+ /**
163
+ * Sync focused state to path — called by Navigator on focusin and typeahead match.
164
+ */
165
+ moveTo(path) {
166
+ if (path !== null) this.#focusedKey = path
167
+ }
168
+
169
+ /**
170
+ * Sync focused key to the item matching this semantic value.
171
+ * Used by controlled components (Toggle, Select) to keep navigation
172
+ * in sync when the bound value changes externally.
173
+ *
174
+ * @param {unknown} v
175
+ */
176
+ moveToValue(v) {
177
+ if (v === undefined || v === null) return
178
+ for (const [key, proxy] of this.#proxyTree.lookup) {
179
+ if (proxy.value === v) {
180
+ this.#focusedKey = key
181
+ this.#selectedValue = v
182
+ return
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Persistent list: no dropdown to close. Override in dropdown wrappers. */
188
+ cancel(_path) {}
189
+
190
+ /** Persistent list: no-op. Override in dropdown wrappers to close + restore trigger focus. */
191
+ blur() {}
192
+
193
+ /** Multiselect toggle — not yet implemented. */
194
+ extend(_path) {}
195
+
196
+ /** Multiselect range — not yet implemented. */
197
+ range(_path) {}
198
+
199
+ // ─── IWrapper: typeahead ───────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Return the key of the first navigable item whose text starts with query
203
+ * (case-insensitive). Wraps around. startAfterKey enables cycling.
204
+ * Returns null if no match.
205
+ *
206
+ * @param {string} query
207
+ * @param {string|null} [startAfterKey]
208
+ * @returns {string|null}
209
+ */
210
+ findByText(query, startAfterKey = null) {
211
+ const nav = this.#navigable
212
+ if (!nav.length) return null
213
+ const q = query.toLowerCase()
214
+ const startIdx = startAfterKey
215
+ ? nav.findIndex((n) => n.key === startAfterKey) + 1
216
+ : 0
217
+ for (let i = 0; i < nav.length; i++) {
218
+ const node = nav[(startIdx + i) % nav.length]
219
+ if (node.proxy.label.toLowerCase().startsWith(q)) return node.key
220
+ }
221
+ return null
222
+ }
223
+
224
+ // ─── Helpers for the component ─────────────────────────────────────────────
225
+
226
+ /** @returns {Map<string, import('./proxy-item.svelte.js').ProxyItem>} */
227
+ get lookup() { return this.#proxyTree.lookup }
228
+
229
+ /** @returns {import('./proxy-tree.svelte.js').ProxyTree} */
230
+ get proxyTree() { return this.#proxyTree }
231
+ }
@@ -1,71 +0,0 @@
1
- import { getKeyFromPath, getPathFromKey } from '@rokkit/core'
2
- import { equals } from 'ramda'
3
- import { ListController } from './list-controller.svelte'
4
-
5
- export class NestedController extends ListController {
6
- /**
7
- * @protected
8
- * @param {Object} [value]
9
- */
10
- init(value) {
11
- if (value) {
12
- this.ensureVisible(value)
13
- this.moveToValue(value)
14
- }
15
- }
16
-
17
- /**
18
- * Mark parents as expanded so that item is visible
19
- * @param {*} value
20
- * @returns
21
- */
22
- ensureVisible(value) {
23
- const result = this.lookup.entries().find((entry) => equals(entry[1].value, value))
24
- const path = getPathFromKey(result[0])
25
-
26
- for (let i = 1; i < path.length; i++) {
27
- const nodeKey = getKeyFromPath(path.slice(0, i))
28
- this.expand(nodeKey)
29
- }
30
- return true
31
- }
32
-
33
- /**
34
- * Toggle expansion of item
35
- * @param {*} value
36
- * @returns
37
- */
38
- toggleExpansion(key) {
39
- if (!this.lookup.has(key)) return false
40
- const proxy = this.lookup.get(key)
41
- proxy.expanded = !proxy.expanded
42
- return true
43
- }
44
-
45
- /**
46
- * Expand item
47
- * @param {*} value
48
- * @returns
49
- */
50
- expand(key) {
51
- const actualKey = key ?? this.focusedKey
52
- if (!this.lookup.has(actualKey)) return false
53
- const proxy = this.lookup.get(actualKey)
54
- proxy.expanded = true
55
-
56
- return true
57
- }
58
-
59
- /**
60
- * Collapse item
61
- * @param {*} value
62
- * @returns
63
- */
64
- collapse(key) {
65
- const actualKey = key ?? this.focusedKey
66
- if (!this.lookup.has(actualKey)) return false
67
- const proxy = this.lookup.get(actualKey)
68
- proxy.expanded = false
69
- return true
70
- }
71
- }
@@ -1,126 +0,0 @@
1
- import { defaultFields, id, toString, getNestedFields } from '@rokkit/core'
2
- import { isNil, has } from 'ramda'
3
-
4
- export class Proxy {
5
- #original = null
6
- #value = $state(null)
7
- #fields = defaultFields
8
- #id = null
9
-
10
- #children = $derived(this.#processChildren())
11
-
12
- constructor(value, fields) {
13
- this.fields = fields
14
- this.#original = value
15
- this.#value = typeof value === 'object' ? value : { [this.fields.text]: value }
16
- this.id = typeof value === 'object' ? (this.get('id') ?? id()) : value
17
- }
18
-
19
- #processChildren() {
20
- if (isNil(this.#value)) return []
21
-
22
- const children = this.#value[this.fields.children] ?? []
23
- if (Array.isArray(children)) {
24
- const fields = getNestedFields(this.fields)
25
- return children.map((child) => new Proxy(child, fields))
26
- }
27
- return []
28
- }
29
-
30
- get id() {
31
- return this.#id
32
- }
33
- set id(new_id) {
34
- this.#id = typeof new_id === 'string' ? new_id : toString(new_id)
35
- }
36
- get children() {
37
- return this.#children
38
- }
39
- get fields() {
40
- return this.#fields
41
- }
42
- set fields(value) {
43
- this.#fields = { ...defaultFields, ...value }
44
- }
45
-
46
- get value() {
47
- return typeof this.#original === 'object' ? this.#value : this.#original
48
- }
49
-
50
- set value(value) {
51
- if (typeof value === 'object') {
52
- const removedKeys = Object.keys(this.#value).filter(
53
- (key) => !Object.keys(value).includes(key)
54
- )
55
- Object.entries(value).forEach(([k, v]) => {
56
- this.#value[k] = v
57
- })
58
- removedKeys.forEach((key) => {
59
- delete this.#value[key]
60
- })
61
- } else {
62
- this.#value = { [this.fields.text]: value }
63
- this.#original = value
64
- }
65
- }
66
-
67
- /**
68
- * Gets a mapped attribute from the original item
69
- *
70
- * @param {string} fieldName - Name of the field to get
71
- * @param {any} [defaultValue] - Default value to return if not found
72
- * @returns {any|undefined} - The attribute value or null if not found
73
- */
74
- get(fieldName, defaultValue) {
75
- return this.has(fieldName) ? this.#value[this.fields[fieldName]] : defaultValue
76
- }
77
-
78
- /**
79
- * Checks if a mapped attribute exists in the original item
80
- * @param {string} fieldName - Name of the field to check
81
- * @returns boolean
82
- */
83
- has(fieldName) {
84
- const mappedField = this.fields[fieldName]
85
- return !isNil(mappedField) && has(mappedField, this.#value)
86
- }
87
-
88
- /**
89
- * Gets the appropriate snippet for rendering this item:
90
- * - Uses the 'snippet' field from the current item to find the snippet key
91
- * - Finds a matching snippet in the provided collection using this key
92
- * - Falls back to the defaultSnippet if:
93
- * - No snippet key is configured for this item
94
- * - The configured snippet key doesn't exist in the snippets collection
95
- * @param {Object} snippets
96
- * @param {import('svelte').Snippet|undefined} [defaultSnippet]
97
- * @returns {import('svelte').Snippet|undefined}
98
- */
99
- getSnippet(snippets, defaultSnippet) {
100
- const snippetKey = this.get('snippet')
101
- const snippet = has(snippetKey, snippets) ? snippets[snippetKey] : undefined
102
- return snippet ?? defaultSnippet
103
- }
104
-
105
- /**
106
- * Identifies if the item has children
107
- */
108
- get hasChildren() {
109
- return (
110
- typeof this.#original === 'object' &&
111
- has(this.fields.children, this.#value) &&
112
- Array.isArray(this.#value[this.fields.children]) &&
113
- this.#value[this.fields.children].length > 0
114
- )
115
- }
116
-
117
- get expanded() {
118
- return this.has('expanded') ? this.#value[this.fields.expanded] : false
119
- }
120
-
121
- set expanded(value) {
122
- if (typeof this.#original === 'object') {
123
- this.#value[this.fields.expanded] = Boolean(value)
124
- }
125
- }
126
- }