@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 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,3 @@
1
+ import { createContext } from 'react'
2
+
3
+ export const StoryboardContext = createContext(null)
@@ -0,0 +1,3 @@
1
+ // Stub for the Vite virtual module used by context.jsx
2
+ // The actual init() seeding is done in each test's beforeEach
3
+ export default {}
@@ -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
+ })
@@ -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
+ })