@dfosco/storyboard-react 1.1.0
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 +29 -0
- package/src/StoryboardContext.js +3 -0
- package/src/__mocks__/virtual-storyboard-data-index.js +3 -0
- package/src/context/FormContext.js +13 -0
- package/src/context/FormContext.test.js +48 -0
- package/src/context.jsx +78 -0
- package/src/context.test.jsx +102 -0
- package/src/hashPreserver.js +73 -0
- package/src/hashPreserver.test.js +107 -0
- package/src/hooks/useHideMode.js +31 -0
- package/src/hooks/useHideMode.test.js +43 -0
- package/src/hooks/useLocalStorage.js +57 -0
- package/src/hooks/useLocalStorage.test.js +76 -0
- package/src/hooks/useOverride.js +80 -0
- package/src/hooks/useOverride.test.js +66 -0
- package/src/hooks/useRecord.js +130 -0
- package/src/hooks/useRecord.test.js +81 -0
- package/src/hooks/useRecordOverride.js +22 -0
- package/src/hooks/useRecordOverride.test.js +52 -0
- package/src/hooks/useScene.js +28 -0
- package/src/hooks/useScene.test.js +39 -0
- package/src/hooks/useSceneData.js +97 -0
- package/src/hooks/useSceneData.test.js +108 -0
- package/src/hooks/useSession.js +4 -0
- package/src/hooks/useSession.test.js +8 -0
- package/src/hooks/useUndoRedo.js +28 -0
- package/src/hooks/useUndoRedo.test.js +64 -0
- package/src/index.js +27 -0
- package/src/test-utils.js +42 -0
- package/src/vite/data-plugin.js +151 -0
- package/src/vite/data-plugin.test.js +127 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dfosco/storyboard-react",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@dfosco/storyboard-core": "*",
|
|
7
|
+
"glob": "^11.0.0",
|
|
8
|
+
"jsonc-parser": "^3.3.1"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/dfosco/storyboard.git",
|
|
14
|
+
"directory": "packages/react"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18",
|
|
21
|
+
"react-router-dom": ">=6",
|
|
22
|
+
"vite": ">=5"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.js",
|
|
26
|
+
"./vite": "./src/vite/data-plugin.js",
|
|
27
|
+
"./hash-preserver": "./src/hashPreserver.js"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provides the form context from <StoryboardForm> to child inputs.
|
|
5
|
+
*
|
|
6
|
+
* Value shape:
|
|
7
|
+
* {
|
|
8
|
+
* prefix: string, // data path prefix (e.g. "checkout")
|
|
9
|
+
* getDraft: (name) => any, // read local draft value for a field
|
|
10
|
+
* setDraft: (name, value) => void, // write local draft value
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export const FormContext = createContext(null)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { FormContext } from './FormContext.js'
|
|
4
|
+
|
|
5
|
+
describe('FormContext', () => {
|
|
6
|
+
it('is a React context object', () => {
|
|
7
|
+
expect(FormContext).toBeDefined()
|
|
8
|
+
expect(FormContext.Provider).toBeDefined()
|
|
9
|
+
expect(FormContext.Consumer).toBeDefined()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('has a default value of null', () => {
|
|
13
|
+
function Reader() {
|
|
14
|
+
return createElement(FormContext.Consumer, null, (value) =>
|
|
15
|
+
createElement('span', { 'data-testid': 'val' }, String(value)),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
render(createElement(Reader))
|
|
19
|
+
expect(screen.getByTestId('val')).toHaveTextContent('null')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('can provide and consume a value with prefix, getDraft, setDraft', () => {
|
|
23
|
+
const getDraft = vi.fn((name) => `draft-${name}`)
|
|
24
|
+
const setDraft = vi.fn()
|
|
25
|
+
const contextValue = { prefix: 'checkout', getDraft, setDraft }
|
|
26
|
+
|
|
27
|
+
function Reader() {
|
|
28
|
+
return createElement(FormContext.Consumer, null, (value) =>
|
|
29
|
+
createElement(
|
|
30
|
+
'span',
|
|
31
|
+
{ 'data-testid': 'val' },
|
|
32
|
+
`${value.prefix}:${value.getDraft('email')}`,
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
render(
|
|
38
|
+
createElement(
|
|
39
|
+
FormContext.Provider,
|
|
40
|
+
{ value: contextValue },
|
|
41
|
+
createElement(Reader),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(screen.getByTestId('val')).toHaveTextContent('checkout:draft-email')
|
|
46
|
+
expect(getDraft).toHaveBeenCalledWith('email')
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/context.jsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { useParams } from 'react-router-dom'
|
|
4
|
+
// Side-effect import: seeds the core data index via init()
|
|
5
|
+
import 'virtual:storyboard-data-index'
|
|
6
|
+
import { loadScene, sceneExists, findRecord, deepMerge } from '@dfosco/storyboard-core'
|
|
7
|
+
import { StoryboardContext } from './StoryboardContext.js'
|
|
8
|
+
|
|
9
|
+
export { StoryboardContext }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the ?scene= param directly from window.location.
|
|
13
|
+
* Avoids useSearchParams() which re-renders on every router
|
|
14
|
+
* navigation event (including hash changes), causing a flash.
|
|
15
|
+
*/
|
|
16
|
+
function getSceneParam() {
|
|
17
|
+
return new URLSearchParams(window.location.search).get('scene')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derives a scene name from the current page pathname.
|
|
22
|
+
* "/Overview" → "Overview", "/" → "index", "/nested/Page" → "Page"
|
|
23
|
+
*/
|
|
24
|
+
function getPageSceneName() {
|
|
25
|
+
const path = window.location.pathname.replace(/\/+$/, '') || '/'
|
|
26
|
+
if (path === '/') return 'index'
|
|
27
|
+
const last = path.split('/').pop()
|
|
28
|
+
return last || 'index'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Provides loaded scene data to the component tree.
|
|
33
|
+
* Reads the scene name from the ?scene= URL param, the sceneName prop,
|
|
34
|
+
* a matching scene file for the current page, or defaults to "default".
|
|
35
|
+
*
|
|
36
|
+
* Optionally merges record data when `recordName` and `recordParam` are provided.
|
|
37
|
+
* The matched record entry is injected under the "record" key in scene data.
|
|
38
|
+
*/
|
|
39
|
+
export default function StoryboardProvider({ sceneName, recordName, recordParam, children }) {
|
|
40
|
+
const pageScene = getPageSceneName()
|
|
41
|
+
const activeSceneName = getSceneParam() || sceneName || (sceneExists(pageScene) ? pageScene : 'default')
|
|
42
|
+
const params = useParams()
|
|
43
|
+
|
|
44
|
+
const { data, error } = useMemo(() => {
|
|
45
|
+
try {
|
|
46
|
+
let sceneData = loadScene(activeSceneName)
|
|
47
|
+
|
|
48
|
+
// Merge record data if configured
|
|
49
|
+
if (recordName && recordParam && params[recordParam]) {
|
|
50
|
+
const entry = findRecord(recordName, params[recordParam])
|
|
51
|
+
if (entry) {
|
|
52
|
+
sceneData = deepMerge(sceneData, { record: entry })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { data: sceneData, error: null }
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { data: null, error: err.message }
|
|
59
|
+
}
|
|
60
|
+
}, [activeSceneName, recordName, recordParam, params])
|
|
61
|
+
|
|
62
|
+
const value = {
|
|
63
|
+
data,
|
|
64
|
+
error,
|
|
65
|
+
loading: false,
|
|
66
|
+
sceneName: activeSceneName,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error) {
|
|
70
|
+
return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading scene: {error}</span>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<StoryboardContext.Provider value={value}>
|
|
75
|
+
{children}
|
|
76
|
+
</StoryboardContext.Provider>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { useContext } from 'react'
|
|
3
|
+
import { init } from '@dfosco/storyboard-core'
|
|
4
|
+
import StoryboardProvider, { StoryboardContext } from './context.jsx'
|
|
5
|
+
|
|
6
|
+
vi.mock('react-router-dom', async () => {
|
|
7
|
+
const actual = await vi.importActual('react-router-dom')
|
|
8
|
+
return { ...actual, useParams: vi.fn(() => ({})) }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
init({
|
|
13
|
+
scenes: {
|
|
14
|
+
default: { title: 'Default Scene' },
|
|
15
|
+
other: { title: 'Other Scene' },
|
|
16
|
+
},
|
|
17
|
+
objects: {},
|
|
18
|
+
records: {},
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** Helper component that reads context and renders it. */
|
|
23
|
+
function ContextReader({ path }) {
|
|
24
|
+
const ctx = useContext(StoryboardContext)
|
|
25
|
+
if (!ctx) return <span>no context</span>
|
|
26
|
+
if (ctx.error) return <span>error: {ctx.error}</span>
|
|
27
|
+
const value = path ? ctx.data?.[path] : JSON.stringify(ctx)
|
|
28
|
+
return <span data-testid="ctx">{String(value)}</span>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('StoryboardProvider', () => {
|
|
32
|
+
it('renders children when scene loads successfully', () => {
|
|
33
|
+
render(
|
|
34
|
+
<StoryboardProvider>
|
|
35
|
+
<span>child content</span>
|
|
36
|
+
</StoryboardProvider>,
|
|
37
|
+
)
|
|
38
|
+
expect(screen.getByText('child content')).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('provides scene data via context', () => {
|
|
42
|
+
render(
|
|
43
|
+
<StoryboardProvider>
|
|
44
|
+
<ContextReader path="title" />
|
|
45
|
+
</StoryboardProvider>,
|
|
46
|
+
)
|
|
47
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('uses sceneName prop when provided', () => {
|
|
51
|
+
render(
|
|
52
|
+
<StoryboardProvider sceneName="other">
|
|
53
|
+
<ContextReader path="title" />
|
|
54
|
+
</StoryboardProvider>,
|
|
55
|
+
)
|
|
56
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("falls back to 'default' scene when no ?scene= param", () => {
|
|
60
|
+
render(
|
|
61
|
+
<StoryboardProvider>
|
|
62
|
+
<ContextReader path="title" />
|
|
63
|
+
</StoryboardProvider>,
|
|
64
|
+
)
|
|
65
|
+
expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('shows error message when scene fails to load', () => {
|
|
69
|
+
render(
|
|
70
|
+
<StoryboardProvider sceneName="nonexistent">
|
|
71
|
+
<ContextReader />
|
|
72
|
+
</StoryboardProvider>,
|
|
73
|
+
)
|
|
74
|
+
expect(screen.getByText(/Error loading scene/)).toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('provides sceneName in context value', () => {
|
|
78
|
+
function SceneNameReader() {
|
|
79
|
+
const ctx = useContext(StoryboardContext)
|
|
80
|
+
return <span data-testid="name">{ctx?.sceneName}</span>
|
|
81
|
+
}
|
|
82
|
+
render(
|
|
83
|
+
<StoryboardProvider sceneName="other">
|
|
84
|
+
<SceneNameReader />
|
|
85
|
+
</StoryboardProvider>,
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByTestId('name')).toHaveTextContent('other')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('provides loading: false in context value', () => {
|
|
91
|
+
function LoadingReader() {
|
|
92
|
+
const ctx = useContext(StoryboardContext)
|
|
93
|
+
return <span data-testid="loading">{String(ctx?.loading)}</span>
|
|
94
|
+
}
|
|
95
|
+
render(
|
|
96
|
+
<StoryboardProvider>
|
|
97
|
+
<LoadingReader />
|
|
98
|
+
</StoryboardProvider>,
|
|
99
|
+
)
|
|
100
|
+
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { interceptHideParams } from '@dfosco/storyboard-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preserve URL hash params across all navigations — both <a> clicks
|
|
5
|
+
* and programmatic router.navigate() calls.
|
|
6
|
+
*
|
|
7
|
+
* Also intercepts ?hide and ?show params on every navigation.
|
|
8
|
+
*
|
|
9
|
+
* Hash is NOT preserved when:
|
|
10
|
+
* - The target path already has its own hash fragment
|
|
11
|
+
* - The link points to an external origin (click handler only)
|
|
12
|
+
* - The current URL has no hash
|
|
13
|
+
*
|
|
14
|
+
* @param {import('react-router-dom').Router} router - React Router instance
|
|
15
|
+
* @param {string} basename - Router basename (e.g. "/storyboard")
|
|
16
|
+
*/
|
|
17
|
+
export function installHashPreserver(router, basename = '') {
|
|
18
|
+
// Normalize basename: ensure no trailing slash
|
|
19
|
+
const base = basename.replace(/\/+$/, '')
|
|
20
|
+
|
|
21
|
+
// --- 1. Intercept <a> clicks ---
|
|
22
|
+
document.addEventListener('click', (e) => {
|
|
23
|
+
// Skip if modifier keys are held (open in new tab, etc.)
|
|
24
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
25
|
+
|
|
26
|
+
const anchor = e.target.closest('a[href]')
|
|
27
|
+
if (!anchor) return
|
|
28
|
+
if (anchor.target === '_blank') return
|
|
29
|
+
|
|
30
|
+
const targetUrl = new URL(anchor.href, window.location.origin)
|
|
31
|
+
|
|
32
|
+
// Skip external links
|
|
33
|
+
if (targetUrl.origin !== window.location.origin) return
|
|
34
|
+
|
|
35
|
+
// Determine the hash to carry forward
|
|
36
|
+
const currentHash = window.location.hash
|
|
37
|
+
const hasCurrentHash = currentHash && currentHash !== '#'
|
|
38
|
+
const targetHasOwnHash = targetUrl.hash && targetUrl.hash !== '#'
|
|
39
|
+
|
|
40
|
+
// Use the target's own hash if it has one, otherwise carry current hash
|
|
41
|
+
const hash = targetHasOwnHash ? targetUrl.hash : (hasCurrentHash ? currentHash : '')
|
|
42
|
+
|
|
43
|
+
// Strip basename to get the route path
|
|
44
|
+
let pathname = targetUrl.pathname
|
|
45
|
+
if (base && pathname.startsWith(base)) {
|
|
46
|
+
pathname = pathname.slice(base.length) || '/'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Prevent full page reload — navigate client-side
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
router.navigate(pathname + targetUrl.search + hash)
|
|
52
|
+
|
|
53
|
+
// Check for ?hide/?show after client-side navigation
|
|
54
|
+
setTimeout(interceptHideParams, 0)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// --- 2. Intercept programmatic router.navigate() ---
|
|
58
|
+
const originalNavigate = router.navigate.bind(router)
|
|
59
|
+
router.navigate = (to, opts) => {
|
|
60
|
+
const currentHash = window.location.hash
|
|
61
|
+
const hasCurrentHash = currentHash && currentHash !== '#'
|
|
62
|
+
|
|
63
|
+
if (hasCurrentHash && typeof to === 'string' && !to.includes('#')) {
|
|
64
|
+
to = to + currentHash
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return originalNavigate(to, opts).then((result) => {
|
|
68
|
+
// Check for ?hide/?show after programmatic navigation
|
|
69
|
+
interceptHideParams()
|
|
70
|
+
return result
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { installHashPreserver } from './hashPreserver.js'
|
|
2
|
+
|
|
3
|
+
vi.mock('@dfosco/storyboard-core', async () => {
|
|
4
|
+
const actual = await vi.importActual('@dfosco/storyboard-core')
|
|
5
|
+
return { ...actual, interceptHideParams: vi.fn() }
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
function createMockRouter() {
|
|
9
|
+
const originalNavigate = vi.fn(() => Promise.resolve())
|
|
10
|
+
return { navigate: originalNavigate, _original: originalNavigate }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('installHashPreserver', () => {
|
|
14
|
+
describe('programmatic navigate()', () => {
|
|
15
|
+
it('preserves hash when navigate() is called without hash', () => {
|
|
16
|
+
const router = createMockRouter()
|
|
17
|
+
window.location.hash = '#foo=bar'
|
|
18
|
+
installHashPreserver(router)
|
|
19
|
+
|
|
20
|
+
router.navigate('/page')
|
|
21
|
+
expect(router._original).toHaveBeenCalledWith('/page#foo=bar', undefined)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does NOT append current hash when navigate() target has its own hash', () => {
|
|
25
|
+
const router = createMockRouter()
|
|
26
|
+
window.location.hash = '#foo=bar'
|
|
27
|
+
installHashPreserver(router)
|
|
28
|
+
|
|
29
|
+
router.navigate('/page#other')
|
|
30
|
+
expect(router._original).toHaveBeenCalledWith('/page#other', undefined)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('calls navigate normally when no current hash', () => {
|
|
34
|
+
const router = createMockRouter()
|
|
35
|
+
installHashPreserver(router)
|
|
36
|
+
|
|
37
|
+
router.navigate('/page')
|
|
38
|
+
expect(router._original).toHaveBeenCalledWith('/page', undefined)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes options through to original navigate', async () => {
|
|
42
|
+
const router = createMockRouter()
|
|
43
|
+
installHashPreserver(router)
|
|
44
|
+
|
|
45
|
+
await router.navigate('/page', { replace: true })
|
|
46
|
+
expect(router._original).toHaveBeenCalledWith('/page', { replace: true })
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('click handler', () => {
|
|
51
|
+
it('intercepts internal link clicks and navigates client-side', () => {
|
|
52
|
+
const router = createMockRouter()
|
|
53
|
+
window.location.hash = '#state=1'
|
|
54
|
+
installHashPreserver(router)
|
|
55
|
+
|
|
56
|
+
const a = document.createElement('a')
|
|
57
|
+
a.href = '/other-page'
|
|
58
|
+
document.body.appendChild(a)
|
|
59
|
+
|
|
60
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
61
|
+
a.dispatchEvent(event)
|
|
62
|
+
|
|
63
|
+
expect(event.defaultPrevented).toBe(true)
|
|
64
|
+
const callArg = router._original.mock.calls[0][0]
|
|
65
|
+
expect(callArg).toContain('/other-page')
|
|
66
|
+
|
|
67
|
+
document.body.removeChild(a)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not intercept clicks with modifier keys', () => {
|
|
71
|
+
const router = createMockRouter()
|
|
72
|
+
installHashPreserver(router)
|
|
73
|
+
|
|
74
|
+
const a = document.createElement('a')
|
|
75
|
+
a.href = '/page'
|
|
76
|
+
document.body.appendChild(a)
|
|
77
|
+
|
|
78
|
+
const event = new MouseEvent('click', {
|
|
79
|
+
bubbles: true,
|
|
80
|
+
cancelable: true,
|
|
81
|
+
metaKey: true,
|
|
82
|
+
})
|
|
83
|
+
a.dispatchEvent(event)
|
|
84
|
+
|
|
85
|
+
expect(event.defaultPrevented).toBe(false)
|
|
86
|
+
|
|
87
|
+
document.body.removeChild(a)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not intercept clicks on links with target="_blank"', () => {
|
|
91
|
+
const router = createMockRouter()
|
|
92
|
+
installHashPreserver(router)
|
|
93
|
+
|
|
94
|
+
const a = document.createElement('a')
|
|
95
|
+
a.href = '/page'
|
|
96
|
+
a.target = '_blank'
|
|
97
|
+
document.body.appendChild(a)
|
|
98
|
+
|
|
99
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
100
|
+
a.dispatchEvent(event)
|
|
101
|
+
|
|
102
|
+
expect(event.defaultPrevented).toBe(false)
|
|
103
|
+
|
|
104
|
+
document.body.removeChild(a)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
2
|
+
import { isHideMode, activateHideMode, deactivateHideMode } from '@dfosco/storyboard-core'
|
|
3
|
+
import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read/control hide mode.
|
|
7
|
+
*
|
|
8
|
+
* Hide mode moves all URL hash overrides into localStorage so the URL
|
|
9
|
+
* stays clean — useful when sharing storyboards with customers.
|
|
10
|
+
*
|
|
11
|
+
* @returns {{ isHidden: boolean, hide: function, show: function }}
|
|
12
|
+
* - isHidden – true when hide mode is active
|
|
13
|
+
* - hide() – activate hide mode (copies hash → localStorage, cleans URL)
|
|
14
|
+
* - show() – deactivate hide mode (restores localStorage → hash)
|
|
15
|
+
*/
|
|
16
|
+
export function useHideMode() {
|
|
17
|
+
// Re-render when localStorage changes (hide flag lives there)
|
|
18
|
+
useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
19
|
+
|
|
20
|
+
const isHidden = isHideMode()
|
|
21
|
+
|
|
22
|
+
const hide = useCallback(() => {
|
|
23
|
+
activateHideMode()
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
const show = useCallback(() => {
|
|
27
|
+
deactivateHideMode()
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
return { isHidden, hide, show }
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { seedTestData } from '../../test-utils.js'
|
|
3
|
+
import { useHideMode } from './useHideMode.js'
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
seedTestData()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('useHideMode', () => {
|
|
10
|
+
it('returns { isHidden, hide, show }', () => {
|
|
11
|
+
const { result } = renderHook(() => useHideMode())
|
|
12
|
+
expect(result.current).toHaveProperty('isHidden')
|
|
13
|
+
expect(typeof result.current.hide).toBe('function')
|
|
14
|
+
expect(typeof result.current.show).toBe('function')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('isHidden is false initially', () => {
|
|
18
|
+
const { result } = renderHook(() => useHideMode())
|
|
19
|
+
expect(result.current.isHidden).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('after calling hide(), isHidden becomes true', () => {
|
|
23
|
+
const { result } = renderHook(() => useHideMode())
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.hide()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(result.current.isHidden).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('after calling show(), isHidden becomes false', () => {
|
|
33
|
+
// Activate hide mode directly via localStorage to set known state
|
|
34
|
+
localStorage.setItem('storyboard:__hide__', '1')
|
|
35
|
+
const { result } = renderHook(() => useHideMode())
|
|
36
|
+
expect(result.current.isHidden).toBe(true)
|
|
37
|
+
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current.show()
|
|
40
|
+
})
|
|
41
|
+
expect(result.current.isHidden).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useContext, useSyncExternalStore } from 'react'
|
|
2
|
+
import { StoryboardContext } from '../StoryboardContext.js'
|
|
3
|
+
import { getByPath } from '@dfosco/storyboard-core'
|
|
4
|
+
import { getParam } from '@dfosco/storyboard-core'
|
|
5
|
+
import { getLocal, setLocal, removeLocal, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
6
|
+
import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Persistent localStorage override on top of scene data.
|
|
10
|
+
*
|
|
11
|
+
* Read priority: URL hash param → localStorage → Scene JSON value → undefined
|
|
12
|
+
* Write target: localStorage (not the URL hash)
|
|
13
|
+
*
|
|
14
|
+
* Use this hook for values that should survive page refreshes (e.g. theme).
|
|
15
|
+
* For ephemeral URL-based overrides, use `useOverride()`.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} path - Dot-notation key (e.g. 'settings.theme')
|
|
18
|
+
* @returns {[any, function, function]}
|
|
19
|
+
* [0] current value (hash ?? localStorage ?? scene default)
|
|
20
|
+
* [1] setValue(newValue) – write to localStorage
|
|
21
|
+
* [2] clearValue() – remove from localStorage
|
|
22
|
+
*/
|
|
23
|
+
export function useLocalStorage(path) {
|
|
24
|
+
const context = useContext(StoryboardContext)
|
|
25
|
+
if (context === null) {
|
|
26
|
+
throw new Error('useLocalStorage must be used within a <StoryboardProvider>')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { data } = context
|
|
30
|
+
|
|
31
|
+
// Scene default for this path
|
|
32
|
+
const sceneDefault = data != null ? getByPath(data, path) : undefined
|
|
33
|
+
|
|
34
|
+
// Subscribe to both hash and localStorage changes for reactivity
|
|
35
|
+
useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
36
|
+
useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
37
|
+
|
|
38
|
+
// Read priority: hash → localStorage → scene default
|
|
39
|
+
const hashValue = getParam(path)
|
|
40
|
+
const localValue = getLocal(path)
|
|
41
|
+
const value = hashValue !== null ? hashValue : (localValue !== null ? localValue : sceneDefault)
|
|
42
|
+
|
|
43
|
+
/** Write a value to localStorage */
|
|
44
|
+
const setValue = useCallback(
|
|
45
|
+
(newValue) => {
|
|
46
|
+
setLocal(path, newValue)
|
|
47
|
+
},
|
|
48
|
+
[path],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
/** Remove the localStorage value, reverting to scene default */
|
|
52
|
+
const clearValue = useCallback(() => {
|
|
53
|
+
removeLocal(path)
|
|
54
|
+
}, [path])
|
|
55
|
+
|
|
56
|
+
return [value, setValue, clearValue]
|
|
57
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { seedTestData, createWrapper, TEST_SCENES } from '../../test-utils.js'
|
|
4
|
+
import { useLocalStorage } from './useLocalStorage.js'
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
seedTestData()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const wrapper = createWrapper(TEST_SCENES.default)
|
|
11
|
+
|
|
12
|
+
describe('useLocalStorage', () => {
|
|
13
|
+
it('returns [value, setValue, clearValue]', () => {
|
|
14
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
15
|
+
wrapper,
|
|
16
|
+
})
|
|
17
|
+
expect(result.current).toHaveLength(3)
|
|
18
|
+
expect(typeof result.current[1]).toBe('function')
|
|
19
|
+
expect(typeof result.current[2]).toBe('function')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('falls back to scene default when no override exists', () => {
|
|
23
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
24
|
+
wrapper,
|
|
25
|
+
})
|
|
26
|
+
expect(result.current[0]).toBe('dark_dimmed')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reads from localStorage when present', () => {
|
|
30
|
+
localStorage.setItem('storyboard:settings.theme', 'light')
|
|
31
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
32
|
+
wrapper,
|
|
33
|
+
})
|
|
34
|
+
expect(result.current[0]).toBe('light')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('hash override takes priority over localStorage', () => {
|
|
38
|
+
localStorage.setItem('storyboard:settings.theme', 'light')
|
|
39
|
+
window.location.hash = 'settings.theme=high-contrast'
|
|
40
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
41
|
+
wrapper,
|
|
42
|
+
})
|
|
43
|
+
expect(result.current[0]).toBe('high-contrast')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('setValue writes to localStorage', () => {
|
|
47
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
48
|
+
wrapper,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
result.current[1]('light')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(localStorage.getItem('storyboard:settings.theme')).toBe('light')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clearValue removes from localStorage', () => {
|
|
59
|
+
localStorage.setItem('storyboard:settings.theme', 'light')
|
|
60
|
+
const { result } = renderHook(() => useLocalStorage('settings.theme'), {
|
|
61
|
+
wrapper,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current[2]()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(localStorage.getItem('storyboard:settings.theme')).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('throws when used outside StoryboardProvider', () => {
|
|
72
|
+
expect(() => {
|
|
73
|
+
renderHook(() => useLocalStorage('settings.theme'))
|
|
74
|
+
}).toThrow('useLocalStorage must be used within a <StoryboardProvider>')
|
|
75
|
+
})
|
|
76
|
+
})
|