leaflet-theme-control 0.0.1

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.
@@ -0,0 +1,621 @@
1
+ import { DomEvent, DomUtil } from 'leaflet'
2
+ import { DEFAULT_THEMES } from './leaflet-theme-control-themes.js'
3
+
4
+ /**
5
+ * ThemeEditor - UI for selecting and editing themes
6
+ *
7
+ * Provides a theme selector with individual theme editors.
8
+ * Allows users to customize CSS filters for each theme with sliders
9
+ * and save preferences to localStorage.
10
+ */
11
+ export class ThemeEditor {
12
+ constructor(themeControl) {
13
+ this.themeControl = themeControl
14
+ this.panel = null
15
+ this.isOpen = false
16
+ this.currentView = 'selector' // 'selector' or 'editor'
17
+ this.editingTheme = null
18
+
19
+ // Storage key for custom filters
20
+ this.storageKey = `${themeControl.options.storageKey}-custom-filters`
21
+
22
+ // Load custom filters from localStorage
23
+ this.customFilters = this._loadCustomFilters()
24
+
25
+ // Apply custom filters to themes
26
+ this._applyCustomFilters()
27
+ }
28
+
29
+ /**
30
+ * Create DOM element with children (like React's createElement)
31
+ * @param {string} tag - HTML tag name
32
+ * @param {object} attrs - Attributes (class, id, data-*, aria-*, etc)
33
+ * @param {...(Node|string)} children - Child nodes or text
34
+ * @returns {HTMLElement} Created element
35
+ */
36
+ _el(tag, attrs = {}, ...children) {
37
+ const element = document.createElement(tag)
38
+
39
+ // Set attributes
40
+ Object.entries(attrs).forEach(([key, value]) => {
41
+ if (key === 'className') {
42
+ element.className = value
43
+ }
44
+ else if (key.startsWith('data-')) {
45
+ element.setAttribute(key, value)
46
+ }
47
+ else if (key.startsWith('aria-')) {
48
+ element.setAttribute(key, value)
49
+ }
50
+ else {
51
+ element[key] = value
52
+ }
53
+ })
54
+
55
+ // Append children
56
+ children.forEach((child) => {
57
+ if (typeof child === 'string') {
58
+ element.appendChild(document.createTextNode(child))
59
+ }
60
+ else if (child instanceof Node) {
61
+ element.appendChild(child)
62
+ }
63
+ })
64
+
65
+ return element
66
+ }
67
+
68
+ _loadCustomFilters() {
69
+ try {
70
+ const stored = localStorage.getItem(this.storageKey)
71
+ return stored ? JSON.parse(stored) : {}
72
+ }
73
+ catch (e) {
74
+ console.error('Failed to load custom filters:', e)
75
+ return {}
76
+ }
77
+ }
78
+
79
+ _saveCustomFilters() {
80
+ try {
81
+ localStorage.setItem(this.storageKey, JSON.stringify(this.customFilters))
82
+ }
83
+ catch (e) {
84
+ console.error('Failed to save custom filters:', e)
85
+ }
86
+ }
87
+
88
+ _applyCustomFilters() {
89
+ Object.keys(this.customFilters).forEach((themeKey) => {
90
+ if (this.themeControl.options.themes[themeKey]) {
91
+ const custom = this.customFilters[themeKey]
92
+ this.themeControl.options.themes[themeKey].filter = this._buildFilterString(custom)
93
+
94
+ // Apply control style if saved
95
+ if (custom.controlStyle) {
96
+ this.themeControl.options.themes[themeKey].controlStyle = custom.controlStyle
97
+ }
98
+ }
99
+ })
100
+ }
101
+
102
+ createPanel() {
103
+ const panel = DomUtil.create('div', 'leaflet-theme-panel')
104
+ panel.style.display = 'none'
105
+ panel.setAttribute('role', 'dialog')
106
+ panel.setAttribute('aria-modal', 'true')
107
+ panel.setAttribute('aria-labelledby', 'theme-panel-title')
108
+
109
+ // Prevent map interactions when using the panel
110
+ DomEvent.disableClickPropagation(panel)
111
+ DomEvent.disableScrollPropagation(panel)
112
+
113
+ this.panel = panel
114
+
115
+ // Close on ESC key
116
+ this._onKeyDown = (e) => {
117
+ if (e.key === 'Escape' && this.isOpen) {
118
+ this.close()
119
+ }
120
+ }
121
+
122
+ return panel
123
+ }
124
+
125
+ _getLabel(key) {
126
+ // Use custom label function if provided
127
+ if (this.themeControl.options.getEditorLabels) {
128
+ return this.themeControl.options.getEditorLabels(key)
129
+ }
130
+
131
+ // Default labels
132
+ const labels = {
133
+ selectTheme: 'Select Theme',
134
+ customize: 'Customize',
135
+ close: 'Close',
136
+ back: 'Back',
137
+ resetToDefault: 'Reset to Default',
138
+ customizeTheme: 'Customize this theme',
139
+ customBadge: 'Custom',
140
+ controlStyle: 'Control Style',
141
+ lightControls: 'Light',
142
+ darkControls: 'Dark',
143
+ invert: 'Invert',
144
+ hueRotate: 'Hue Rotate',
145
+ saturate: 'Saturate',
146
+ brightness: 'Brightness',
147
+ contrast: 'Contrast',
148
+ sepia: 'Sepia',
149
+ grayscaleFilter: 'Grayscale',
150
+ }
151
+
152
+ return labels[key] || key
153
+ }
154
+
155
+ openThemeSelector() {
156
+ if (!this.panel) return
157
+
158
+ this.isOpen = true
159
+ this.currentView = 'selector'
160
+ this.panel.style.display = 'block'
161
+ this._renderThemeSelector()
162
+
163
+ // Add keyboard listener
164
+ document.addEventListener('keydown', this._onKeyDown)
165
+
166
+ // Focus first interactive element
167
+ setTimeout(() => {
168
+ const firstBtn = this.panel.querySelector('.theme-select-btn')
169
+ if (firstBtn) firstBtn.focus()
170
+ }, 50)
171
+ }
172
+
173
+ openThemeEditor(themeKey) {
174
+ if (!this.panel) return
175
+
176
+ this.currentView = 'editor'
177
+ this.editingTheme = themeKey
178
+ this._renderThemeEditor(themeKey)
179
+
180
+ // Focus first slider
181
+ setTimeout(() => {
182
+ const firstSlider = this.panel.querySelector('input[type="range"]')
183
+ if (firstSlider) firstSlider.focus()
184
+ }, 50)
185
+ }
186
+
187
+ // Alias for easier API usage
188
+ open() {
189
+ this.openThemeSelector()
190
+ }
191
+
192
+ close() {
193
+ if (!this.panel) return
194
+
195
+ this.isOpen = false
196
+ this.panel.style.display = 'none'
197
+ this.currentView = 'selector'
198
+ this.editingTheme = null
199
+
200
+ // Remove keyboard listener
201
+ document.removeEventListener('keydown', this._onKeyDown)
202
+ }
203
+
204
+ cleanup() {
205
+ // Close panel if open
206
+ if (this.isOpen) {
207
+ this.close()
208
+ }
209
+
210
+ // Remove keyboard listener (in case close() wasn't called)
211
+ if (this._onKeyDown) {
212
+ document.removeEventListener('keydown', this._onKeyDown)
213
+ this._onKeyDown = null
214
+ }
215
+
216
+ // Remove panel from DOM
217
+ if (this.panel && this.panel.parentNode) {
218
+ this.panel.parentNode.removeChild(this.panel)
219
+ }
220
+
221
+ // Clear all references
222
+ this.panel = null
223
+ this.themeControl = null
224
+ this.customFilters = null
225
+ }
226
+
227
+ _renderThemeSelector() {
228
+ const currentTheme = this.themeControl.getCurrentTheme()
229
+ const themes = this.themeControl.options.themes
230
+
231
+ // Build panel header
232
+ const header = this._createPanelHeader(this._getLabel('selectTheme'))
233
+
234
+ // Build theme rows
235
+ const body = this._el('div', {
236
+ 'className': 'theme-panel-body',
237
+ 'role': 'radiogroup',
238
+ 'aria-label': this._getLabel('selectTheme'),
239
+ })
240
+ Object.keys(themes).forEach((themeKey) => {
241
+ const row = this._createThemeRow(themeKey, themes[themeKey], themeKey === currentTheme)
242
+ body.appendChild(row)
243
+ })
244
+
245
+ // Replace panel content
246
+ this.panel.replaceChildren(header, body)
247
+
248
+ // Attach event listeners
249
+ this._attachSelectorListeners()
250
+ }
251
+
252
+ _createPanelHeader(title, showBackButton = false) {
253
+ const header = this._el('div', { className: 'theme-panel-header' })
254
+
255
+ if (showBackButton) {
256
+ const backBtn = this._el('button', {
257
+ 'className': 'theme-panel-back',
258
+ 'aria-label': this._getLabel('back'),
259
+ }, '←')
260
+ header.appendChild(backBtn)
261
+ }
262
+
263
+ const h3 = this._el('h3', { id: 'theme-panel-title' }, title)
264
+ header.appendChild(h3)
265
+
266
+ const closeBtn = this._el('button', {
267
+ 'className': 'theme-panel-close',
268
+ 'aria-label': this._getLabel('close'),
269
+ }, '×')
270
+ header.appendChild(closeBtn)
271
+
272
+ return header
273
+ }
274
+
275
+ _createThemeRow(themeKey, theme, isActive) {
276
+ const hasCustom = !!this.customFilters[themeKey]
277
+ const themeLabel = this.themeControl._getThemeLabel(themeKey)
278
+
279
+ // Create select button with icon, label and badges
280
+ const selectBtn = this._el('button', {
281
+ 'className': 'theme-select-btn',
282
+ 'data-theme': themeKey,
283
+ 'role': 'radio',
284
+ 'aria-checked': isActive ? 'true' : 'false',
285
+ 'aria-label': themeLabel,
286
+ })
287
+
288
+ selectBtn.appendChild(this._el('span', { 'className': 'theme-icon', 'aria-hidden': 'true' }, theme.icon || '🎨'))
289
+ selectBtn.appendChild(this._el('span', { className: 'theme-name' }, themeLabel))
290
+
291
+ if (hasCustom) {
292
+ selectBtn.appendChild(this._el('span', { className: 'theme-custom-badge' }, this._getLabel('customBadge')))
293
+ }
294
+
295
+ if (isActive) {
296
+ selectBtn.appendChild(this._el('span', { 'className': 'theme-active-badge', 'aria-hidden': 'true' }, '✓'))
297
+ }
298
+
299
+ // Create edit button
300
+ const editBtn = this._el('button', {
301
+ 'className': 'theme-edit-btn',
302
+ 'data-theme': themeKey,
303
+ 'aria-label': `${this._getLabel('customizeTheme')}: ${themeLabel}`,
304
+ }, '⚙️')
305
+
306
+ // Create row container
307
+ const row = this._el('div', {
308
+ 'className': `theme-row ${isActive ? 'active' : ''}`,
309
+ 'data-theme': themeKey,
310
+ })
311
+ row.appendChild(selectBtn)
312
+ row.appendChild(editBtn)
313
+
314
+ return row
315
+ }
316
+
317
+ _attachSelectorListeners() {
318
+ // Close button
319
+ const closeBtn = this.panel.querySelector('.theme-panel-close')
320
+ DomEvent.on(closeBtn, 'click', () => this.close())
321
+
322
+ // Theme select buttons
323
+ const selectBtns = this.panel.querySelectorAll('.theme-select-btn')
324
+ selectBtns.forEach((btn) => {
325
+ DomEvent.on(btn, 'click', (e) => {
326
+ const themeKey = e.currentTarget.dataset.theme
327
+
328
+ // Update aria-checked states
329
+ selectBtns.forEach(b => b.setAttribute('aria-checked', 'false'))
330
+ e.currentTarget.setAttribute('aria-checked', 'true')
331
+
332
+ this.themeControl.setTheme(themeKey)
333
+ this.close()
334
+ })
335
+ })
336
+
337
+ // Theme edit buttons
338
+ const editBtns = this.panel.querySelectorAll('.theme-edit-btn')
339
+ editBtns.forEach((btn) => {
340
+ DomEvent.on(btn, 'click', (e) => {
341
+ DomEvent.stop(e)
342
+ const themeKey = e.currentTarget.dataset.theme
343
+ this.openThemeEditor(themeKey)
344
+ })
345
+ })
346
+ }
347
+
348
+ _renderThemeEditor(themeKey) {
349
+ const theme = this.themeControl.options.themes[themeKey]
350
+ const themeLabel = this.themeControl._getThemeLabel(themeKey)
351
+
352
+ // Get current filter values or defaults
353
+ const filterValues = this.customFilters[themeKey] || this._parseFilterString(theme.filter)
354
+
355
+ // Get control style preference
356
+ let controlStyle = 'light'
357
+ if (this.customFilters[themeKey]?.controlStyle) {
358
+ controlStyle = this.customFilters[themeKey].controlStyle
359
+ }
360
+ else if (theme.controlStyle) {
361
+ controlStyle = theme.controlStyle
362
+ }
363
+
364
+ // Build panel structure
365
+ const header = this._createPanelHeader(`${this._getLabel('customize')}: ${themeLabel}`, true)
366
+ const body = this._createEditorBody(filterValues, controlStyle)
367
+ const footer = this._createEditorFooter()
368
+
369
+ // Replace panel content
370
+ this.panel.replaceChildren(header, body, footer)
371
+
372
+ // Attach event listeners
373
+ this._attachEditorListeners(themeKey, filterValues, controlStyle)
374
+ }
375
+
376
+ _createControlStyleSelector(controlStyle) {
377
+ const selector = this._el('div', { className: 'control-style-selector' })
378
+
379
+ const label = this._el('label', {}, this._getLabel('controlStyle'))
380
+ selector.appendChild(label)
381
+
382
+ const buttonsContainer = this._el('div', { className: 'control-style-buttons' })
383
+
384
+ const lightBtn = this._el('button', {
385
+ 'className': `control-style-btn ${controlStyle === 'light' ? 'active' : ''}`,
386
+ 'data-style': 'light',
387
+ }, `☀️ ${this._getLabel('lightControls')}`)
388
+
389
+ const darkBtn = this._el('button', {
390
+ 'className': `control-style-btn ${controlStyle === 'dark' ? 'active' : ''}`,
391
+ 'data-style': 'dark',
392
+ }, `🌙 ${this._getLabel('darkControls')}`)
393
+
394
+ buttonsContainer.appendChild(lightBtn)
395
+ buttonsContainer.appendChild(darkBtn)
396
+ selector.appendChild(buttonsContainer)
397
+
398
+ return selector
399
+ }
400
+
401
+ _createEditorBody(filterValues, controlStyle) {
402
+ const body = this._el('div', { className: 'theme-panel-body theme-editor-sliders' })
403
+
404
+ // Control style selector
405
+ body.appendChild(this._createControlStyleSelector(controlStyle))
406
+
407
+ // Filter sliders
408
+ body.appendChild(this._createSlider('invert', filterValues.invert || 0, 0, 1, 0.1))
409
+ body.appendChild(this._createSlider('hueRotate', filterValues.hueRotate || 0, 0, 360, 1, '°'))
410
+ body.appendChild(this._createSlider('saturate', filterValues.saturate || 1, 0, 2, 0.1))
411
+ body.appendChild(this._createSlider('brightness', filterValues.brightness || 1, 0, 2, 0.1))
412
+ body.appendChild(this._createSlider('contrast', filterValues.contrast || 1, 0, 2, 0.1))
413
+ body.appendChild(this._createSlider('sepia', filterValues.sepia || 0, 0, 1, 0.1))
414
+ body.appendChild(this._createSlider('grayscale', filterValues.grayscale || 0, 0, 1, 0.1))
415
+
416
+ return body
417
+ }
418
+
419
+ _createEditorFooter() {
420
+ const footer = this._el('div', { className: 'theme-panel-footer' })
421
+ const resetBtn = this._el('button', { className: 'theme-editor-reset' }, this._getLabel('resetToDefault'))
422
+ footer.appendChild(resetBtn)
423
+ return footer
424
+ }
425
+
426
+ _createSlider(key, value, min, max, step, unit = '') {
427
+ const label = this._getLabel(key)
428
+
429
+ return this._el('div', { className: 'theme-editor-slider' },
430
+ this._el('label', { htmlFor: `slider-${key}` },
431
+ this._el('span', { className: 'slider-label' }, label),
432
+ this._el('span', { 'className': 'slider-value', 'data-key': key }, `${value}${unit}`),
433
+ ),
434
+ this._el('input', {
435
+ 'type': 'range',
436
+ 'id': `slider-${key}`,
437
+ 'data-key': key,
438
+ 'min': String(min),
439
+ 'max': String(max),
440
+ 'step': String(step),
441
+ 'value': String(value),
442
+ 'aria-label': `${label}: ${value}${unit}`,
443
+ 'aria-valuemin': String(min),
444
+ 'aria-valuemax': String(max),
445
+ 'aria-valuenow': String(value),
446
+ }),
447
+ )
448
+ }
449
+
450
+ _attachEditorListeners(themeKey, initialValues, initialControlStyle) {
451
+ // Back button
452
+ const backBtn = this.panel.querySelector('.theme-panel-back')
453
+ DomEvent.on(backBtn, 'click', () => this.openThemeSelector())
454
+
455
+ // Close button
456
+ const closeBtn = this.panel.querySelector('.theme-panel-close')
457
+ DomEvent.on(closeBtn, 'click', () => this.close())
458
+
459
+ // Control style buttons
460
+ let currentControlStyle = initialControlStyle
461
+ const controlStyleBtns = this.panel.querySelectorAll('.control-style-btn')
462
+ controlStyleBtns.forEach((btn) => {
463
+ DomEvent.on(btn, 'click', (e) => {
464
+ const style = e.currentTarget.dataset.style
465
+ currentControlStyle = style
466
+
467
+ // Update active state
468
+ controlStyleBtns.forEach(b => b.classList.remove('active'))
469
+ e.currentTarget.classList.add('active')
470
+
471
+ // Live preview and save
472
+ this.themeControl.root.setAttribute('data-control-style', style)
473
+ this._saveTheme(themeKey, currentValues, currentControlStyle)
474
+ })
475
+ })
476
+
477
+ // Sliders
478
+ const sliders = this.panel.querySelectorAll('input[type="range"]')
479
+ const currentValues = { ...initialValues }
480
+
481
+ sliders.forEach((slider) => {
482
+ DomEvent.on(slider, 'input', (e) => {
483
+ const key = e.target.dataset.key
484
+ const value = parseFloat(e.target.value)
485
+ currentValues[key] = value
486
+
487
+ // Update display value
488
+ const valueDisplay = this.panel.querySelector(`.slider-value[data-key="${key}"]`)
489
+ const unit = key === 'hueRotate' ? '°' : ''
490
+ valueDisplay.textContent = value + unit
491
+
492
+ // Update aria-valuenow and aria-label
493
+ e.target.setAttribute('aria-valuenow', value)
494
+ e.target.setAttribute('aria-label', `${this._getLabel(key)}: ${value}${unit}`)
495
+
496
+ // Live preview and save
497
+ this._previewFilter(themeKey, currentValues)
498
+ this._saveTheme(themeKey, currentValues, currentControlStyle)
499
+ })
500
+ })
501
+
502
+ // Reset button
503
+ const resetBtn = this.panel.querySelector('.theme-editor-reset')
504
+ DomEvent.on(resetBtn, 'click', () => {
505
+ this._resetTheme(themeKey)
506
+ })
507
+ }
508
+
509
+ _previewFilter(themeKey, values) {
510
+ const filterString = this._buildFilterString(values)
511
+
512
+ // Temporarily update the filter
513
+ const elements = document.querySelectorAll(this.themeControl.options.cssSelector)
514
+ elements.forEach((el) => {
515
+ el.style.filter = filterString
516
+ })
517
+ }
518
+
519
+ _saveTheme(themeKey, values, controlStyle) {
520
+ this.customFilters[themeKey] = {
521
+ ...values,
522
+ controlStyle: controlStyle,
523
+ }
524
+ this._saveCustomFilters()
525
+
526
+ // Update theme
527
+ const filterString = this._buildFilterString(values)
528
+ this.themeControl.options.themes[themeKey].filter = filterString
529
+ this.themeControl.options.themes[themeKey].controlStyle = controlStyle
530
+
531
+ // Reapply current theme if it's the one being edited
532
+ if (this.themeControl.getCurrentTheme() === themeKey) {
533
+ this.themeControl.setTheme(themeKey)
534
+ }
535
+ }
536
+
537
+ _resetTheme(themeKey) {
538
+ // Remove custom filter and controlStyle
539
+ delete this.customFilters[themeKey]
540
+ this._saveCustomFilters()
541
+
542
+ // Restore default values from DEFAULT_THEMES
543
+ const defaultTheme = DEFAULT_THEMES[themeKey]
544
+
545
+ if (defaultTheme) {
546
+ // Copy all properties from default theme
547
+ this.themeControl.options.themes[themeKey] = { ...defaultTheme }
548
+ }
549
+
550
+ // Reapply theme (including controlStyle) BEFORE re-rendering editor
551
+ // This ensures the DOM attribute is set correctly first
552
+ if (this.themeControl.getCurrentTheme() === themeKey) {
553
+ this.themeControl._applyTheme(themeKey, false)
554
+ }
555
+
556
+ // Re-render editor panel with default values
557
+ this._renderThemeEditor(themeKey)
558
+ }
559
+
560
+ _buildFilterString(values) {
561
+ const parts = []
562
+
563
+ if (values.invert > 0) parts.push(`invert(${values.invert})`)
564
+ if (values.hueRotate !== 0) parts.push(`hue-rotate(${values.hueRotate}deg)`)
565
+ if (values.saturate !== 1) parts.push(`saturate(${values.saturate})`)
566
+ if (values.brightness !== 1) parts.push(`brightness(${values.brightness})`)
567
+ if (values.contrast !== 1) parts.push(`contrast(${values.contrast})`)
568
+ if (values.sepia > 0) parts.push(`sepia(${values.sepia})`)
569
+ if (values.grayscale > 0) parts.push(`grayscale(${values.grayscale})`)
570
+
571
+ return parts.join(' ')
572
+ }
573
+
574
+ _parseFilterString(filterString) {
575
+ if (!filterString) {
576
+ return {
577
+ invert: 0,
578
+ hueRotate: 0,
579
+ saturate: 1,
580
+ brightness: 1,
581
+ contrast: 1,
582
+ sepia: 0,
583
+ grayscale: 0,
584
+ }
585
+ }
586
+
587
+ const values = {
588
+ invert: 0,
589
+ hueRotate: 0,
590
+ saturate: 1,
591
+ brightness: 1,
592
+ contrast: 1,
593
+ sepia: 0,
594
+ grayscale: 0,
595
+ }
596
+
597
+ // Parse each filter function
598
+ const invertMatch = filterString.match(/invert\(([\d.]+)\)/)
599
+ if (invertMatch) values.invert = parseFloat(invertMatch[1])
600
+
601
+ const hueMatch = filterString.match(/hue-rotate\(([\d.]+)deg\)/)
602
+ if (hueMatch) values.hueRotate = parseFloat(hueMatch[1])
603
+
604
+ const saturateMatch = filterString.match(/saturate\(([\d.]+)\)/)
605
+ if (saturateMatch) values.saturate = parseFloat(saturateMatch[1])
606
+
607
+ const brightnessMatch = filterString.match(/brightness\(([\d.]+)\)/)
608
+ if (brightnessMatch) values.brightness = parseFloat(brightnessMatch[1])
609
+
610
+ const contrastMatch = filterString.match(/contrast\(([\d.]+)\)/)
611
+ if (contrastMatch) values.contrast = parseFloat(contrastMatch[1])
612
+
613
+ const sepiaMatch = filterString.match(/sepia\(([\d.]+)\)/)
614
+ if (sepiaMatch) values.sepia = parseFloat(sepiaMatch[1])
615
+
616
+ const grayscaleMatch = filterString.match(/grayscale\(([\d.]+)\)/)
617
+ if (grayscaleMatch) values.grayscale = parseFloat(grayscaleMatch[1])
618
+
619
+ return values
620
+ }
621
+ }