@rokkit/ui 1.0.0-next.136 → 1.0.0-next.138

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.
@@ -40,6 +40,7 @@
40
40
  // @ts-nocheck
41
41
  import type { ProxyItem } from '@rokkit/states'
42
42
  import { Wrapper, ProxyTree } from '@rokkit/states'
43
+ import { SvelteSet } from 'svelte/reactivity'
43
44
  import { Navigator, Trigger } from '@rokkit/actions'
44
45
  import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
45
46
  import ItemContent from './ItemContent.svelte'
@@ -105,27 +106,33 @@
105
106
  const textField = $derived(fields?.label || 'label')
106
107
  const childrenField = $derived(fields?.children || 'children')
107
108
 
109
+ function childMatchesQuery(child: unknown, query: string): boolean {
110
+ return String((child as Record<string, unknown>)[textField] ?? '').toLowerCase().includes(query)
111
+ }
112
+
113
+ function filterGroupChildren(
114
+ asRecord: Record<string, unknown>,
115
+ query: string
116
+ ): unknown | null {
117
+ const children = asRecord[childrenField] as unknown[]
118
+ const matching = children.filter((child: unknown) => childMatchesQuery(child, query))
119
+ return matching.length > 0 ? { ...asRecord, [childrenField]: matching } : null
120
+ }
121
+
122
+ function filterItem(item: unknown, query: string): unknown | null {
123
+ const asRecord = item as Record<string, unknown>
124
+ const children = asRecord[childrenField]
125
+ if (Array.isArray(children) && children.length > 0) {
126
+ return filterGroupChildren(asRecord, query)
127
+ }
128
+ const text = String(asRecord[textField] ?? '').toLowerCase()
129
+ return text.includes(query) ? item : null
130
+ }
131
+
108
132
  const filteredItems = $derived.by(() => {
109
133
  if (!filterable || !filterQuery) return items
110
134
  const query = filterQuery.toLowerCase()
111
- return items
112
- .map((item) => {
113
- const children = item[childrenField]
114
- if (Array.isArray(children) && children.length > 0) {
115
- const matching = children.filter((child) =>
116
- String(child[textField] ?? '')
117
- .toLowerCase()
118
- .includes(query)
119
- )
120
- return matching.length > 0 ? { ...item, [childrenField]: matching } : null
121
- }
122
- return String(item[textField] ?? '')
123
- .toLowerCase()
124
- .includes(query)
125
- ? item
126
- : null
127
- })
128
- .filter(Boolean)
135
+ return items.map((item) => filterItem(item, query)).filter(Boolean)
129
136
  })
130
137
 
131
138
  // Pre-process: force groups expanded + disabled (non-navigable labels)
@@ -176,13 +183,23 @@
176
183
 
177
184
  // ─── Selected proxy for trigger display ───────────────────────────────────
178
185
 
179
- const selectedProxy = $derived.by(() => {
180
- if (value === undefined || value === null) return null
186
+ function isValueUnset(): boolean {
187
+ return value === undefined || value === null
188
+ }
189
+
190
+ function isMatchingLeaf(proxy: ProxyItem): boolean {
191
+ return !proxy.hasChildren && proxy.value === value
192
+ }
193
+
194
+ function findSelectedProxy(): ProxyItem | null {
195
+ if (isValueUnset()) return null
181
196
  for (const [, proxy] of wrapper.lookup) {
182
- if (!proxy.hasChildren && proxy.value === value) return proxy
197
+ if (isMatchingLeaf(proxy)) return proxy
183
198
  }
184
199
  return null
185
- })
200
+ }
201
+
202
+ const selectedProxy = $derived.by(findSelectedProxy)
186
203
 
187
204
  // Sync selected raw item
188
205
  $effect(() => {
@@ -214,15 +231,18 @@
214
231
  return () => t.destroy()
215
232
  })
216
233
 
217
- function focusSelectedOrFirst() {
218
- if (value !== undefined && value !== null) {
219
- for (const node of wrapper.flatView) {
220
- if (!node.proxy.disabled && node.proxy.value === value) {
221
- wrapper.moveTo(node.key)
222
- return
223
- }
234
+ function moveToSelectedValue(): boolean {
235
+ for (const node of wrapper.flatView) {
236
+ if (!node.proxy.disabled && node.proxy.value === value) {
237
+ wrapper.moveTo(node.key)
238
+ return true
224
239
  }
225
240
  }
241
+ return false
242
+ }
243
+
244
+ function focusSelectedOrFirst() {
245
+ if (value !== undefined && value !== null && moveToSelectedValue()) return
226
246
  wrapper.first(null)
227
247
  }
228
248
 
@@ -250,36 +270,38 @@
250
270
 
251
271
  // ─── Filter keyboard (native listener, fires before Navigator) ───────────
252
272
 
273
+ function selectFocused() {
274
+ if (wrapper.focusedKey) wrapper.select(null)
275
+ }
276
+
277
+ function handleFilterKeyDown(event: KeyboardEvent) {
278
+ if (event.key === 'ArrowDown') {
279
+ event.preventDefault()
280
+ event.stopPropagation()
281
+ wrapper.first(null)
282
+ } else if (event.key === 'Escape' && filterQuery) {
283
+ event.preventDefault()
284
+ event.stopPropagation()
285
+ filterQuery = ''
286
+ } else if (event.key === 'Enter') {
287
+ event.preventDefault()
288
+ event.stopPropagation()
289
+ selectFocused()
290
+ }
291
+ }
292
+
253
293
  $effect(() => {
254
294
  if (!isOpen || !filterable || !filterInputRef) return
255
295
  const el = filterInputRef
256
- const handler = (event: KeyboardEvent) => {
257
- if (event.key === 'ArrowDown') {
258
- event.preventDefault()
259
- event.stopPropagation()
260
- wrapper.first(null)
261
- } else if (event.key === 'Escape') {
262
- if (filterQuery) {
263
- event.preventDefault()
264
- event.stopPropagation()
265
- filterQuery = ''
266
- }
267
- // Empty filter: let event bubble to Navigator/Trigger for close
268
- } else if (event.key === 'Enter') {
269
- event.preventDefault()
270
- event.stopPropagation()
271
- if (wrapper.focusedKey) wrapper.select(null)
272
- }
273
- }
274
- el.addEventListener('keydown', handler)
275
- return () => el.removeEventListener('keydown', handler)
296
+ el.addEventListener('keydown', handleFilterKeyDown)
297
+ return () => el.removeEventListener('keydown', handleFilterKeyDown)
276
298
  })
277
299
 
278
300
  // ─── Helpers ──────────────────────────────────────────────────────────────
279
301
 
280
302
  /** Set of group keys that need a divider before them (not the first group) */
281
303
  const groupDividers = $derived.by(() => {
282
- const set = new Set<string>()
304
+ const set = new SvelteSet<string>()
283
305
  let foundFirst = false
284
306
  for (const node of wrapper.flatView) {
285
307
  if (node.hasChildren) {
@@ -334,11 +356,9 @@
334
356
  </button>
335
357
 
336
358
  {#if isOpen}
337
- <!-- svelte-ignore a11y_no_static_element_interactions -->
338
359
  <div bind:this={dropdownRef} data-select-dropdown role="listbox" aria-orientation="vertical">
339
360
  {#if filterable}
340
361
  <div data-select-filter>
341
- <!-- svelte-ignore a11y_autofocus -->
342
362
  <input
343
363
  bind:this={filterInputRef}
344
364
  type="text"
@@ -28,22 +28,18 @@
28
28
  onchange?.(nextValue, next.original as SwitchItem)
29
29
  }
30
30
 
31
+ function shouldToggle(key: string): boolean {
32
+ if (key === ' ' || key === 'Enter') return true
33
+ if (key === 'ArrowRight') return !isChecked
34
+ if (key === 'ArrowLeft') return isChecked
35
+ return false
36
+ }
37
+
31
38
  function handleKeyDown(event: KeyboardEvent) {
32
39
  if (disabled) return
33
- switch (event.key) {
34
- case ' ':
35
- case 'Enter':
36
- event.preventDefault()
37
- toggle()
38
- break
39
- case 'ArrowRight':
40
- event.preventDefault()
41
- if (!isChecked) toggle()
42
- break
43
- case 'ArrowLeft':
44
- event.preventDefault()
45
- if (isChecked) toggle()
46
- break
40
+ if (shouldToggle(event.key)) {
41
+ event.preventDefault()
42
+ toggle()
47
43
  }
48
44
  }
49
45
  </script>
@@ -56,27 +56,25 @@
56
56
 
57
57
  // ─── Focus management ───────────────────────────────────────────
58
58
 
59
+ function focusByKey(el: HTMLElement, key: string) {
60
+ const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
61
+ if (target && target !== document.activeElement) {
62
+ target.focus()
63
+ target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
64
+ }
65
+ }
66
+
59
67
  $effect(() => {
60
68
  if (!tableRef) return
61
69
  const el = tableRef
62
70
 
63
71
  function onAction(event: Event) {
64
72
  const detail = (event as CustomEvent).detail
65
-
66
73
  if (detail.name === 'move') {
67
74
  const key = controller.focusedKey
68
- if (key) {
69
- const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
70
- if (target && target !== document.activeElement) {
71
- target.focus()
72
- target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
73
- }
74
- }
75
- }
76
-
77
- if (detail.name === 'select') {
78
- handleSelectAction()
75
+ if (key) focusByKey(el, key)
79
76
  }
77
+ if (detail.name === 'select') handleSelectAction()
80
78
  }
81
79
 
82
80
  el.addEventListener('action', onAction)
@@ -134,7 +132,6 @@
134
132
  }
135
133
  </script>
136
134
 
137
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
138
135
  <div
139
136
  bind:this={tableRef}
140
137
  data-table
@@ -92,7 +92,6 @@
92
92
  {/if}
93
93
  <span data-tabs-label>{proxy.label}</span>
94
94
  {#if editable}
95
- <!-- svelte-ignore a11y_no_static_element_interactions -->
96
95
  <span
97
96
  data-tabs-remove
98
97
  role="button"
@@ -125,7 +124,6 @@
125
124
  No tabs available.
126
125
  {/snippet}
127
126
 
128
- <!-- svelte-ignore a11y_no_static_element_interactions -->
129
127
  <div
130
128
  bind:this={containerRef}
131
129
  data-tabs
@@ -8,6 +8,7 @@
8
8
  import { getSnippet } from '../types/menu.js'
9
9
  import { ProxyItem, messages } from '@rokkit/states'
10
10
  import { ListController } from '@rokkit/states'
11
+ import { SvelteMap } from 'svelte/reactivity'
11
12
  import { navigator } from '@rokkit/actions'
12
13
  import { untrack } from 'svelte'
13
14
 
@@ -51,7 +52,7 @@
51
52
 
52
53
  /** Map from item → its data-path key (index within interactive items) */
53
54
  const itemPathMap = $derived.by(() => {
54
- const map = new Map<unknown, string>()
55
+ const map = new SvelteMap<unknown, string>()
55
56
  let idx = 0
56
57
  for (const item of items) {
57
58
  if (isInteractive(item)) {
@@ -94,6 +95,11 @@
94
95
  return () => el.removeEventListener('focusin', onFocusIn)
95
96
  })
96
97
 
98
+ function focusKeyedItem(el: HTMLElement, key: string) {
99
+ const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
100
+ if (target && target !== document.activeElement) target.focus()
101
+ }
102
+
97
103
  // Focus the item matching controller.focusedKey on navigator action events
98
104
  $effect(() => {
99
105
  if (!containerRef) return
@@ -101,20 +107,11 @@
101
107
 
102
108
  function onAction(event: Event) {
103
109
  const detail = (event as CustomEvent).detail
104
-
105
110
  if (detail.name === 'move') {
106
111
  const key = controller.focusedKey
107
- if (key) {
108
- const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
109
- if (target && target !== document.activeElement) {
110
- target.focus()
111
- }
112
- }
113
- }
114
-
115
- if (detail.name === 'select') {
116
- handleSelectAction()
112
+ if (key) focusKeyedItem(el, key)
117
113
  }
114
+ if (detail.name === 'select') handleSelectAction()
118
115
  }
119
116
 
120
117
  el.addEventListener('action', onAction)
@@ -250,7 +247,6 @@
250
247
  {/if}
251
248
  {/snippet}
252
249
 
253
- <!-- svelte-ignore a11y_no_static_element_interactions -->
254
250
  <div
255
251
  bind:this={containerRef}
256
252
  data-toolbar
@@ -64,7 +64,6 @@
64
64
  })
65
65
  </script>
66
66
 
67
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
68
67
  <div
69
68
  bind:this={treeRef}
70
69
  data-tree
@@ -38,22 +38,21 @@
38
38
  let inputRef = $state<HTMLInputElement | null>(null)
39
39
  let dragging = $state(false)
40
40
 
41
- function handleFiles(fileList: FileList | File[]) {
42
- const files = Array.from(fileList)
43
- const valid: File[] = []
41
+ function validateFile(file: File): 'type' | 'size' | null {
42
+ if (accept && !matchesAccept(file, accept)) return 'type'
43
+ if (file.size > maxSize) return 'size'
44
+ return null
45
+ }
44
46
 
45
- for (const file of files) {
46
- if (accept && !matchesAccept(file, accept)) {
47
- onerror?.({ file, reason: 'type' })
48
- continue
49
- }
50
- if (file.size > maxSize) {
51
- onerror?.({ file, reason: 'size' })
52
- continue
53
- }
54
- valid.push(file)
55
- }
47
+ function processFile(file: File, valid: File[]) {
48
+ const reason = validateFile(file)
49
+ if (reason) onerror?.({ file, reason })
50
+ else valid.push(file)
51
+ }
56
52
 
53
+ function handleFiles(fileList: FileList | File[]) {
54
+ const valid: File[] = []
55
+ for (const file of Array.from(fileList)) processFile(file, valid)
57
56
  if (valid.length > 0) onfiles?.(valid)
58
57
  }
59
58
 
@@ -86,7 +85,6 @@
86
85
  }
87
86
  </script>
88
87
 
89
- <!-- svelte-ignore a11y_no_static_element_interactions -->
90
88
  <div
91
89
  data-upload-target
92
90
  data-disabled={disabled || undefined}
@@ -83,38 +83,45 @@ export function rgbToHex(rgb: RGB): string {
83
83
  /**
84
84
  * Convert RGB to HSL
85
85
  */
86
+ interface NormalizedRGB { r: number; g: number; b: number }
87
+
88
+ function computeHue(norm: NormalizedRGB, max: number, d: number): number {
89
+ const { r, g, b } = norm
90
+ if (max === r) return ((g - b) / d + (g < b ? 6 : 0)) / 6
91
+ if (max === g) return ((b - r) / d + 2) / 6
92
+ return ((r - g) / d + 4) / 6
93
+ }
94
+
86
95
  export function rgbToHsl(rgb: RGB): HSL {
87
- const r = rgb.r / 255
88
- const g = rgb.g / 255
89
- const b = rgb.b / 255
96
+ const norm: NormalizedRGB = { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255 }
90
97
 
91
- const max = Math.max(r, g, b)
92
- const min = Math.min(r, g, b)
98
+ const max = Math.max(norm.r, norm.g, norm.b)
99
+ const min = Math.min(norm.r, norm.g, norm.b)
93
100
  const l = (max + min) / 2
94
101
 
95
- if (max === min) {
96
- return { h: 0, s: 0, l: l * 100 }
97
- }
102
+ if (max === min) return { h: 0, s: 0, l: l * 100 }
98
103
 
99
104
  const d = max - min
100
105
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
101
-
102
- let h = 0
103
- switch (max) {
104
- case r:
105
- h = ((g - b) / d + (g < b ? 6 : 0)) / 6
106
- break
107
- case g:
108
- h = ((b - r) / d + 2) / 6
109
- break
110
- case b:
111
- h = ((r - g) / d + 4) / 6
112
- break
113
- }
106
+ const h = computeHue(norm, max, d)
114
107
 
115
108
  return { h: h * 360, s: s * 100, l: l * 100 }
116
109
  }
117
110
 
111
+ function normalizeT(t: number): number {
112
+ if (t < 0) return t + 1
113
+ if (t > 1) return t - 1
114
+ return t
115
+ }
116
+
117
+ function hue2rgb(p: number, q: number, t: number): number {
118
+ const tt = normalizeT(t)
119
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt
120
+ if (tt < 1 / 2) return q
121
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6
122
+ return p
123
+ }
124
+
118
125
  /**
119
126
  * Convert HSL to RGB
120
127
  */
@@ -128,15 +135,6 @@ export function hslToRgb(hsl: HSL): RGB {
128
135
  return { r: val, g: val, b: val }
129
136
  }
130
137
 
131
- const hue2rgb = (p: number, q: number, t: number) => {
132
- if (t < 0) t += 1
133
- if (t > 1) t -= 1
134
- if (t < 1 / 6) return p + (q - p) * 6 * t
135
- if (t < 1 / 2) return q
136
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
137
- return p
138
- }
139
-
140
138
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s
141
139
  const p = 2 * l - q
142
140
 
@@ -546,39 +544,24 @@ export function applyPalette(
546
544
  }
547
545
  }
548
546
 
547
+ const DEFAULT_PALETTE_ROLES: ColorRole[] = [
548
+ 'primary', 'secondary', 'accent', 'surface', 'success', 'warning', 'danger', 'info'
549
+ ]
550
+
551
+ const ALL_SHADE_KEYS: ShadeKey[] = [
552
+ '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'
553
+ ]
554
+
549
555
  /**
550
556
  * Remove custom palette CSS variables (reset to theme defaults)
551
557
  */
552
558
  export function resetPalette(
553
- roles: ColorRole[] = [
554
- 'primary',
555
- 'secondary',
556
- 'accent',
557
- 'surface',
558
- 'success',
559
- 'warning',
560
- 'danger',
561
- 'info'
562
- ],
559
+ roles: ColorRole[] = DEFAULT_PALETTE_ROLES,
563
560
  element: HTMLElement = document.documentElement
564
561
  ): void {
565
- const shadeKeys: ShadeKey[] = [
566
- '50',
567
- '100',
568
- '200',
569
- '300',
570
- '400',
571
- '500',
572
- '600',
573
- '700',
574
- '800',
575
- '900',
576
- '950'
577
- ]
578
-
579
562
  for (const role of roles) {
580
563
  element.style.removeProperty(`--color-${role}`)
581
- for (const shade of shadeKeys) {
564
+ for (const shade of ALL_SHADE_KEYS) {
582
565
  element.style.removeProperty(`--color-${role}-${shade}`)
583
566
  }
584
567
  }
@@ -70,36 +70,28 @@ export interface HighlightOptions {
70
70
  * @param options - Highlighting options
71
71
  * @returns The highlighted code as HTML
72
72
  */
73
+ function resolveTheme(themeOption: string | undefined): BundledTheme {
74
+ if (themeOption === 'light') return 'github-light'
75
+ if (themeOption === 'dark' || !themeOption) return 'github-dark'
76
+ return themeOption as BundledTheme
77
+ }
78
+
79
+ function isValidCode(code: unknown): code is string {
80
+ return Boolean(code) && typeof code === 'string'
81
+ }
82
+
73
83
  export async function highlightCode(code: string, options: HighlightOptions = {}): Promise<string> {
74
- if (!code || typeof code !== 'string') {
75
- throw new Error('Invalid code provided for highlighting')
76
- }
84
+ if (!isValidCode(code)) throw new Error('Invalid code provided for highlighting')
77
85
 
78
86
  const hl = await initializeHighlighter()
79
- const lang = (options.lang || 'text') as BundledLanguage
80
- let theme: BundledTheme = 'github-dark'
81
-
82
- if (options.theme === 'light') {
83
- theme = 'github-light'
84
- } else if (options.theme === 'dark') {
85
- theme = 'github-dark'
86
- } else if (options.theme) {
87
- theme = options.theme
88
- }
89
-
90
- // Check if language is supported, fallback to text
87
+ const lang = (options.lang ?? 'text') as BundledLanguage
88
+ const theme = resolveTheme(options.theme)
91
89
  const loadedLangs = hl.getLoadedLanguages()
92
90
  const effectiveLang = loadedLangs.includes(lang) ? lang : 'text'
93
91
 
94
- return (
95
- hl
96
- .codeToHtml(code, {
97
- lang: effectiveLang,
98
- theme
99
- })
100
- // Remove inline styles from pre tag to allow CSS theming
101
- .replace(/(<pre[^>]+) style="[^"]*"/, '$1')
102
- )
92
+ return hl
93
+ .codeToHtml(code, { lang: effectiveLang, theme })
94
+ .replace(/(<pre[^>]+) style="[^"]*"/, '$1')
103
95
  }
104
96
 
105
97
  /**