@dfosco/storyboard-core 1.11.1 → 1.11.3
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 +1 -1
- package/src/bodyClasses.js +117 -0
- package/src/bodyClasses.test.js +150 -0
- package/src/index.js +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Body class sync for overrides and scenes.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors active overrides as `sb-{key}--{value}` classes on <body>
|
|
5
|
+
* and the active scene as `sb-scene--{name}`.
|
|
6
|
+
*
|
|
7
|
+
* Works in both normal mode (URL hash) and hide mode (localStorage shadows).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getAllParams } from './session.js'
|
|
11
|
+
import { isHideMode, getAllShadows } from './hideMode.js'
|
|
12
|
+
import { subscribeToHash } from './hashSubscribe.js'
|
|
13
|
+
import { subscribeToStorage } from './localStorage.js'
|
|
14
|
+
|
|
15
|
+
const PREFIX = 'sb-'
|
|
16
|
+
const SCENE_PREFIX = 'sb-scene--'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize a string for use in a CSS class name.
|
|
20
|
+
* Dots and spaces become dashes, lowercased, non-alphanumeric stripped.
|
|
21
|
+
* @param {string} str
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function sanitize(str) {
|
|
25
|
+
return String(str)
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[\.\s]+/g, '-')
|
|
28
|
+
.replace(/[^a-z0-9-]/g, '')
|
|
29
|
+
.replace(/-+/g, '-')
|
|
30
|
+
.replace(/^-|-$/g, '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the class name for an override key/value pair.
|
|
35
|
+
* @param {string} key
|
|
36
|
+
* @param {string} value
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function overrideClass(key, value) {
|
|
40
|
+
return `${PREFIX}${sanitize(key)}--${sanitize(value)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get all current sb- classes on body (excluding scene classes).
|
|
45
|
+
* @returns {Set<string>}
|
|
46
|
+
*/
|
|
47
|
+
function getCurrentOverrideClasses() {
|
|
48
|
+
const classes = new Set()
|
|
49
|
+
for (const cls of document.body.classList) {
|
|
50
|
+
if (cls.startsWith(PREFIX) && !cls.startsWith(SCENE_PREFIX)) {
|
|
51
|
+
classes.add(cls)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return classes
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sync override classes on <body> with current hash/shadow state.
|
|
59
|
+
* Diffs against existing classes to minimize DOM mutations.
|
|
60
|
+
*/
|
|
61
|
+
export function syncOverrideClasses() {
|
|
62
|
+
const overrides = isHideMode() ? getAllShadows() : getAllParams()
|
|
63
|
+
const desired = new Set()
|
|
64
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
65
|
+
if (key && value != null && value !== '') {
|
|
66
|
+
desired.add(overrideClass(key, value))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const current = getCurrentOverrideClasses()
|
|
71
|
+
|
|
72
|
+
// Remove stale classes
|
|
73
|
+
for (const cls of current) {
|
|
74
|
+
if (!desired.has(cls)) {
|
|
75
|
+
document.body.classList.remove(cls)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Add missing classes
|
|
79
|
+
for (const cls of desired) {
|
|
80
|
+
if (!current.has(cls)) {
|
|
81
|
+
document.body.classList.add(cls)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Set the scene class on <body>. Removes any previous scene class.
|
|
88
|
+
* @param {string} name - Scene name (e.g. "Dashboard")
|
|
89
|
+
*/
|
|
90
|
+
export function setSceneClass(name) {
|
|
91
|
+
// Remove any existing scene classes
|
|
92
|
+
for (const cls of [...document.body.classList]) {
|
|
93
|
+
if (cls.startsWith(SCENE_PREFIX)) {
|
|
94
|
+
document.body.classList.remove(cls)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (name) {
|
|
98
|
+
document.body.classList.add(`${SCENE_PREFIX}${sanitize(name)}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Install listeners that keep body classes in sync with overrides.
|
|
104
|
+
* Subscribes to both hashchange (normal mode) and storage (hide mode).
|
|
105
|
+
* Runs an initial sync immediately.
|
|
106
|
+
*
|
|
107
|
+
* @returns {function} unsubscribe — removes all listeners
|
|
108
|
+
*/
|
|
109
|
+
export function installBodyClassSync() {
|
|
110
|
+
syncOverrideClasses()
|
|
111
|
+
const unsubHash = subscribeToHash(syncOverrideClasses)
|
|
112
|
+
const unsubStorage = subscribeToStorage(syncOverrideClasses)
|
|
113
|
+
return () => {
|
|
114
|
+
unsubHash()
|
|
115
|
+
unsubStorage()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
syncOverrideClasses,
|
|
3
|
+
setSceneClass,
|
|
4
|
+
installBodyClassSync,
|
|
5
|
+
} from './bodyClasses.js'
|
|
6
|
+
import { activateHideMode, deactivateHideMode, setShadow } from './hideMode.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collect all sb- prefixed classes currently on document.body.
|
|
10
|
+
* @returns {string[]}
|
|
11
|
+
*/
|
|
12
|
+
function getSbClasses() {
|
|
13
|
+
return [...document.body.classList].filter((c) => c.startsWith('sb-'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Clear all sb- classes and hash between tests
|
|
18
|
+
for (const cls of getSbClasses()) {
|
|
19
|
+
document.body.classList.remove(cls)
|
|
20
|
+
}
|
|
21
|
+
window.location.hash = ''
|
|
22
|
+
// Ensure hide mode is off
|
|
23
|
+
try {
|
|
24
|
+
deactivateHideMode()
|
|
25
|
+
} catch {
|
|
26
|
+
// ignore if not active
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// ── Override Classes ──
|
|
31
|
+
|
|
32
|
+
describe('Override body classes', () => {
|
|
33
|
+
it('adds sb- classes for hash overrides', () => {
|
|
34
|
+
window.location.hash = '#theme=dark&sidebar=collapsed'
|
|
35
|
+
syncOverrideClasses()
|
|
36
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
37
|
+
expect(getSbClasses()).toContain('sb-sidebar--collapsed')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('removes stale classes when overrides are cleared', () => {
|
|
41
|
+
window.location.hash = '#theme=dark&sidebar=collapsed'
|
|
42
|
+
syncOverrideClasses()
|
|
43
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
44
|
+
|
|
45
|
+
window.location.hash = '#sidebar=collapsed'
|
|
46
|
+
syncOverrideClasses()
|
|
47
|
+
expect(getSbClasses()).not.toContain('sb-theme--dark')
|
|
48
|
+
expect(getSbClasses()).toContain('sb-sidebar--collapsed')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('removes all override classes when hash is empty', () => {
|
|
52
|
+
window.location.hash = '#theme=dark'
|
|
53
|
+
syncOverrideClasses()
|
|
54
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
55
|
+
|
|
56
|
+
window.location.hash = ''
|
|
57
|
+
syncOverrideClasses()
|
|
58
|
+
const overrideClasses = getSbClasses().filter((c) => !c.startsWith('sb-scene--'))
|
|
59
|
+
expect(overrideClasses).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('sanitizes dot-notation keys (dots become dashes)', () => {
|
|
63
|
+
window.location.hash = '#settings.theme=dark'
|
|
64
|
+
syncOverrideClasses()
|
|
65
|
+
expect(getSbClasses()).toContain('sb-settings-theme--dark')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('sanitizes values with special characters', () => {
|
|
69
|
+
window.location.hash = '#mode=dark.dimmed'
|
|
70
|
+
syncOverrideClasses()
|
|
71
|
+
expect(getSbClasses()).toContain('sb-mode--dark-dimmed')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('skips overrides with empty values', () => {
|
|
75
|
+
window.location.hash = '#theme='
|
|
76
|
+
syncOverrideClasses()
|
|
77
|
+
const overrideClasses = getSbClasses().filter((c) => !c.startsWith('sb-scene--'))
|
|
78
|
+
expect(overrideClasses).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('updates classes when override value changes', () => {
|
|
82
|
+
window.location.hash = '#theme=dark'
|
|
83
|
+
syncOverrideClasses()
|
|
84
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
85
|
+
|
|
86
|
+
window.location.hash = '#theme=light'
|
|
87
|
+
syncOverrideClasses()
|
|
88
|
+
expect(getSbClasses()).not.toContain('sb-theme--dark')
|
|
89
|
+
expect(getSbClasses()).toContain('sb-theme--light')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ── Scene Classes ──
|
|
94
|
+
|
|
95
|
+
describe('Scene body classes', () => {
|
|
96
|
+
it('sets sb-scene-- class', () => {
|
|
97
|
+
setSceneClass('Dashboard')
|
|
98
|
+
expect(getSbClasses()).toContain('sb-scene--dashboard')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('replaces previous scene class', () => {
|
|
102
|
+
setSceneClass('Dashboard')
|
|
103
|
+
setSceneClass('Settings')
|
|
104
|
+
expect(getSbClasses()).not.toContain('sb-scene--dashboard')
|
|
105
|
+
expect(getSbClasses()).toContain('sb-scene--settings')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('removes scene class when called with empty string', () => {
|
|
109
|
+
setSceneClass('Dashboard')
|
|
110
|
+
setSceneClass('')
|
|
111
|
+
const sceneClasses = getSbClasses().filter((c) => c.startsWith('sb-scene--'))
|
|
112
|
+
expect(sceneClasses).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not interfere with override classes', () => {
|
|
116
|
+
window.location.hash = '#theme=dark'
|
|
117
|
+
syncOverrideClasses()
|
|
118
|
+
setSceneClass('Dashboard')
|
|
119
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
120
|
+
expect(getSbClasses()).toContain('sb-scene--dashboard')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ── Hide Mode ──
|
|
125
|
+
|
|
126
|
+
describe('Hide mode body classes', () => {
|
|
127
|
+
it('reflects shadow overrides as body classes', () => {
|
|
128
|
+
activateHideMode()
|
|
129
|
+
setShadow('theme', 'dark')
|
|
130
|
+
syncOverrideClasses()
|
|
131
|
+
expect(getSbClasses()).toContain('sb-theme--dark')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ── installBodyClassSync ──
|
|
136
|
+
|
|
137
|
+
describe('installBodyClassSync', () => {
|
|
138
|
+
it('runs initial sync on install', () => {
|
|
139
|
+
window.location.hash = '#layout=compact'
|
|
140
|
+
const unsub = installBodyClassSync()
|
|
141
|
+
expect(getSbClasses()).toContain('sb-layout--compact')
|
|
142
|
+
unsub()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns an unsubscribe function', () => {
|
|
146
|
+
const unsub = installBodyClassSync()
|
|
147
|
+
expect(typeof unsub).toBe('function')
|
|
148
|
+
unsub()
|
|
149
|
+
})
|
|
150
|
+
})
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,9 @@ export { interceptHideParams, installHideParamListener } from './interceptHidePa
|
|
|
27
27
|
// Hash change subscription (for reactive frameworks)
|
|
28
28
|
export { subscribeToHash, getHashSnapshot } from './hashSubscribe.js'
|
|
29
29
|
|
|
30
|
+
// Body class sync (overrides + scene → <body> classes)
|
|
31
|
+
export { installBodyClassSync, setSceneClass, syncOverrideClasses } from './bodyClasses.js'
|
|
32
|
+
|
|
30
33
|
// Dev tools (vanilla JS, framework-agnostic)
|
|
31
34
|
export { mountDevTools } from './devtools.js'
|
|
32
35
|
export { mountSceneDebug } from './sceneDebug.js'
|