@dfosco/storyboard-react-primer 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 +24 -0
- package/src/Checkbox.jsx +57 -0
- package/src/DevTools/DevTools.jsx +140 -0
- package/src/DevTools/DevTools.module.css +153 -0
- package/src/SceneDataDemo.jsx +90 -0
- package/src/SceneDebug.jsx +47 -0
- package/src/SceneDebug.module.css +43 -0
- package/src/Select.jsx +60 -0
- package/src/StoryboardForm.jsx +63 -0
- package/src/TextInput.jsx +59 -0
- package/src/Textarea.jsx +55 -0
- package/src/index.js +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dfosco/storyboard-react-primer",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/dfosco/storyboard.git",
|
|
9
|
+
"directory": "packages/react-primer"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@dfosco/storyboard-react": "*"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@primer/react": ">=37",
|
|
19
|
+
"react": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.js"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/Checkbox.jsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useContext, useState, useEffect } from 'react'
|
|
3
|
+
import { Checkbox as PrimerCheckbox } from '@primer/react'
|
|
4
|
+
import { FormContext } from '@dfosco/storyboard-react'
|
|
5
|
+
import { useOverride } from '@dfosco/storyboard-react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped Primer Checkbox that integrates with StoryboardForm.
|
|
9
|
+
*
|
|
10
|
+
* Inside a <StoryboardForm>, values are buffered locally and only
|
|
11
|
+
* written to session on form submit.
|
|
12
|
+
*
|
|
13
|
+
* Stores "true" / "false" as string values in the URL hash.
|
|
14
|
+
*/
|
|
15
|
+
export default function Checkbox({ name, onChange, checked: controlledChecked, ...props }) {
|
|
16
|
+
const form = useContext(FormContext)
|
|
17
|
+
const path = form?.prefix && name ? `${form.prefix}.${name}` : name
|
|
18
|
+
const [sessionValue] = useOverride(path || '')
|
|
19
|
+
|
|
20
|
+
const initialChecked = sessionValue === 'true' || sessionValue === true
|
|
21
|
+
const [draft, setDraftState] = useState(initialChecked)
|
|
22
|
+
|
|
23
|
+
const isConnected = !!form && !!name
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!isConnected) return
|
|
27
|
+
return form.subscribe(name, (val) => setDraftState(val === 'true' || val === true))
|
|
28
|
+
}, [isConnected, form, name])
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isConnected && sessionValue != null) {
|
|
32
|
+
const val = sessionValue === 'true' || sessionValue === true
|
|
33
|
+
setDraftState(val)
|
|
34
|
+
form.setDraft(name, val ? 'true' : 'false')
|
|
35
|
+
}
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
const handleChange = (e) => {
|
|
40
|
+
if (isConnected) {
|
|
41
|
+
setDraftState(e.target.checked)
|
|
42
|
+
form.setDraft(name, e.target.checked ? 'true' : 'false')
|
|
43
|
+
}
|
|
44
|
+
if (onChange) onChange(e)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const resolvedChecked = isConnected ? draft : controlledChecked
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<PrimerCheckbox
|
|
51
|
+
name={name}
|
|
52
|
+
checked={resolvedChecked}
|
|
53
|
+
onChange={handleChange}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { ActionMenu, ActionList } from '@primer/react'
|
|
3
|
+
import { loadScene } from '@dfosco/storyboard-core'
|
|
4
|
+
import { BeakerIcon, InfoIcon, SyncIcon, XIcon, ScreenFullIcon } from '@primer/octicons-react'
|
|
5
|
+
import styles from './DevTools.module.css'
|
|
6
|
+
|
|
7
|
+
function getSceneName() {
|
|
8
|
+
return new URLSearchParams(window.location.search).get('scene') || 'default'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Storyboard DevTools — a floating toolbar for development.
|
|
13
|
+
*
|
|
14
|
+
* Features:
|
|
15
|
+
* - Floating button (bottom-center) that opens a menu
|
|
16
|
+
* - "Show scene info" — translucent overlay panel with resolved scene JSON
|
|
17
|
+
* - "Reset all params" — clears all URL hash session params
|
|
18
|
+
* - Cmd+. (Mac) / Ctrl+. (other) toggles the toolbar visibility
|
|
19
|
+
*/
|
|
20
|
+
export default function DevTools() {
|
|
21
|
+
const [visible, setVisible] = useState(true)
|
|
22
|
+
const [panelOpen, setPanelOpen] = useState(false)
|
|
23
|
+
const [sceneData, setSceneData] = useState(null)
|
|
24
|
+
const [sceneError, setSceneError] = useState(null)
|
|
25
|
+
|
|
26
|
+
// Cmd+. keyboard shortcut to toggle toolbar
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function handleKeyDown(e) {
|
|
29
|
+
if (e.key === '.' && (e.metaKey || e.ctrlKey)) {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
setVisible((v) => !v)
|
|
32
|
+
if (visible) {
|
|
33
|
+
setPanelOpen(false)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
38
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
39
|
+
}, [visible])
|
|
40
|
+
|
|
41
|
+
const handleShowSceneInfo = useCallback(() => {
|
|
42
|
+
const sceneName = getSceneName()
|
|
43
|
+
setPanelOpen(true)
|
|
44
|
+
setSceneError(null)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
setSceneData(loadScene(sceneName))
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setSceneError(err.message)
|
|
50
|
+
}
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const handleResetParams = useCallback(() => {
|
|
54
|
+
window.location.hash = ''
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const handleViewfinder = useCallback(() => {
|
|
58
|
+
window.location.href = (document.querySelector('base')?.href || '/') + 'viewfinder'
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
if (!visible) return null
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{/* Scene info overlay panel */}
|
|
66
|
+
{panelOpen && (
|
|
67
|
+
<div className={styles.overlay}>
|
|
68
|
+
<div
|
|
69
|
+
className={styles.overlayBackdrop}
|
|
70
|
+
onClick={() => setPanelOpen(false)}
|
|
71
|
+
/>
|
|
72
|
+
<div className={styles.panel}>
|
|
73
|
+
<div className={styles.panelHeader}>
|
|
74
|
+
<span className={styles.panelTitle}>
|
|
75
|
+
Scene: {getSceneName()}
|
|
76
|
+
</span>
|
|
77
|
+
<button
|
|
78
|
+
className={styles.panelClose}
|
|
79
|
+
onClick={() => setPanelOpen(false)}
|
|
80
|
+
aria-label="Close panel"
|
|
81
|
+
>
|
|
82
|
+
<XIcon size={16} />
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div className={styles.panelBody}>
|
|
86
|
+
{sceneError && (
|
|
87
|
+
<span className={styles.error}>{sceneError}</span>
|
|
88
|
+
)}
|
|
89
|
+
{!sceneError && sceneData && (
|
|
90
|
+
<pre className={styles.codeBlock}>
|
|
91
|
+
{JSON.stringify(sceneData, null, 2)}
|
|
92
|
+
</pre>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{/* Floating toolbar */}
|
|
100
|
+
<div className={styles.wrapper}>
|
|
101
|
+
<ActionMenu>
|
|
102
|
+
<ActionMenu.Anchor>
|
|
103
|
+
<button
|
|
104
|
+
className={styles.trigger}
|
|
105
|
+
aria-label="Storyboard DevTools"
|
|
106
|
+
>
|
|
107
|
+
<BeakerIcon className={styles.triggerIcon} size={16} />
|
|
108
|
+
</button>
|
|
109
|
+
</ActionMenu.Anchor>
|
|
110
|
+
<ActionMenu.Overlay align="center" side="outside-top" sideOffset={16}>
|
|
111
|
+
<ActionList>
|
|
112
|
+
<ActionList.Item onSelect={handleViewfinder}>
|
|
113
|
+
<ActionList.LeadingVisual>
|
|
114
|
+
<ScreenFullIcon size={16} />
|
|
115
|
+
</ActionList.LeadingVisual>
|
|
116
|
+
See viewfinder
|
|
117
|
+
</ActionList.Item>
|
|
118
|
+
<ActionList.Item onSelect={handleShowSceneInfo}>
|
|
119
|
+
<ActionList.LeadingVisual>
|
|
120
|
+
<InfoIcon size={16} />
|
|
121
|
+
</ActionList.LeadingVisual>
|
|
122
|
+
Show scene info
|
|
123
|
+
</ActionList.Item>
|
|
124
|
+
<ActionList.Item onSelect={handleResetParams}>
|
|
125
|
+
<ActionList.LeadingVisual>
|
|
126
|
+
<SyncIcon size={16} />
|
|
127
|
+
</ActionList.LeadingVisual>
|
|
128
|
+
Reset all params
|
|
129
|
+
</ActionList.Item>
|
|
130
|
+
<div className={styles.shortcutHint}>
|
|
131
|
+
Press <code>⌘ + .</code> to hide
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
</ActionList>
|
|
135
|
+
</ActionMenu.Overlay>
|
|
136
|
+
</ActionMenu>
|
|
137
|
+
</div>
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
.wrapper {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: var(--base-size-24);
|
|
4
|
+
right: var(--base-size-24);
|
|
5
|
+
z-index: 9999;
|
|
6
|
+
font-family: var(--fontStack-system);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
code {
|
|
10
|
+
font-family: var(--fontStack-monospace);
|
|
11
|
+
background-color: var(--bgColor-muted);
|
|
12
|
+
padding: 2px 4px;
|
|
13
|
+
border-radius: var(--borderRadius-small);
|
|
14
|
+
|
|
15
|
+
&:hover {
|
|
16
|
+
background-color: var(--bgColor-muted);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Floating trigger button */
|
|
21
|
+
.trigger {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: var(--base-size-8);
|
|
25
|
+
padding: var(--base-size-12);
|
|
26
|
+
background-color: var(--bgColor-subtle);
|
|
27
|
+
color: var(--fgColor-muted);
|
|
28
|
+
border: 1px solid var(--borderColor-default);
|
|
29
|
+
border-radius: var(--borderRadius-full);
|
|
30
|
+
font-size: var(--text-body-size-small);
|
|
31
|
+
font-weight: var(--base-text-weight-semibold);
|
|
32
|
+
font-family: var(--fontStack-system);
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
box-shadow: var(--shadow-floating-large);
|
|
35
|
+
transition: opacity 150ms ease, transform 150ms ease;
|
|
36
|
+
user-select: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.trigger:hover {
|
|
40
|
+
transform: scale(1.05);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.trigger:active {
|
|
44
|
+
transform: scale(0.97);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.triggerIcon {
|
|
48
|
+
width: var(--base-size-16);
|
|
49
|
+
height: var(--base-size-16);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.shortcutHint {
|
|
53
|
+
padding: var(--base-size-8) var(--base-size-16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Scene info overlay panel */
|
|
57
|
+
.overlay {
|
|
58
|
+
position: fixed;
|
|
59
|
+
inset: 0;
|
|
60
|
+
z-index: 9998;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: flex-end;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
padding: var(--base-size-16);
|
|
65
|
+
padding-bottom: calc(var(--base-size-64) + var(--base-size-16));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.overlayBackdrop {
|
|
69
|
+
position: fixed;
|
|
70
|
+
inset: 0;
|
|
71
|
+
background: transparent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.panel {
|
|
75
|
+
position: relative;
|
|
76
|
+
width: 100%;
|
|
77
|
+
max-width: 640px;
|
|
78
|
+
max-height: 60vh;
|
|
79
|
+
background-color: var(--overlay-bgColor);
|
|
80
|
+
backdrop-filter: blur(16px);
|
|
81
|
+
border: 1px solid var(--borderColor-default);
|
|
82
|
+
border-radius: var(--borderRadius-large);
|
|
83
|
+
box-shadow: var(--shadow-floating-large);
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
display: flex;
|
|
86
|
+
flex-direction: column;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.panelHeader {
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: space-between;
|
|
93
|
+
padding: var(--base-size-12) var(--base-size-16);
|
|
94
|
+
border-bottom: 1px solid var(--borderColor-muted);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.panelTitle {
|
|
98
|
+
font-size: var(--text-body-size-medium);
|
|
99
|
+
font-weight: var(--base-text-weight-semibold);
|
|
100
|
+
color: var(--fgColor-default);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.panelClose {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: center;
|
|
107
|
+
width: var(--base-size-28);
|
|
108
|
+
height: var(--base-size-28);
|
|
109
|
+
background: none;
|
|
110
|
+
border: none;
|
|
111
|
+
border-radius: var(--borderRadius-small);
|
|
112
|
+
color: var(--fgColor-muted);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.panelClose:hover {
|
|
117
|
+
background-color: var(--control-transparent-bgColor-hover);
|
|
118
|
+
color: var(--fgColor-default);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.panelBody {
|
|
122
|
+
overflow: auto;
|
|
123
|
+
padding: var(--base-size-16);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.codeBlock {
|
|
127
|
+
padding: 0;
|
|
128
|
+
margin: 0;
|
|
129
|
+
background: none;
|
|
130
|
+
font-size: var(--text-body-size-small);
|
|
131
|
+
font-family: var(--fontStack-monospace);
|
|
132
|
+
line-height: var(--text-body-lineHeight-medium);
|
|
133
|
+
color: var(--fgColor-default);
|
|
134
|
+
white-space: pre-wrap;
|
|
135
|
+
word-break: break-word;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.error {
|
|
139
|
+
color: var(--fgColor-danger);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.loading {
|
|
143
|
+
color: var(--fgColor-muted);
|
|
144
|
+
font-size: var(--text-body-size-small);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Keyboard shortcut hint */
|
|
148
|
+
.shortcutHint {
|
|
149
|
+
font-size: var(--text-caption-size);
|
|
150
|
+
color: var(--fgColor-muted);
|
|
151
|
+
margin-left: auto;
|
|
152
|
+
font-family: var(--fontStack-monospace);
|
|
153
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Text, Button, ButtonGroup, FormControl } from '@primer/react'
|
|
2
|
+
import { useOverride } from '@dfosco/storyboard-react'
|
|
3
|
+
import { useScene } from '@dfosco/storyboard-react'
|
|
4
|
+
import StoryboardForm from './StoryboardForm.jsx'
|
|
5
|
+
import TextInput from './TextInput.jsx'
|
|
6
|
+
import Textarea from './Textarea.jsx'
|
|
7
|
+
import styles from './SceneDebug.module.css'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Demo component that renders scene data via useOverride().
|
|
11
|
+
* Every value can be overridden by adding a URL param, e.g.:
|
|
12
|
+
* #user.name=Alice&user.profile.bio=Hello
|
|
13
|
+
* Refresh the page — overrides persist. Remove the param — scene default returns.
|
|
14
|
+
*/
|
|
15
|
+
export default function SceneDataDemo() {
|
|
16
|
+
const [name, setName, clearName] = useOverride('user.name')
|
|
17
|
+
const [username, setUsername, clearUsername] = useOverride('user.username')
|
|
18
|
+
const [bio, , clearBio] = useOverride('user.profile.bio')
|
|
19
|
+
const [location, , clearLocation] = useOverride('user.profile.location')
|
|
20
|
+
const { sceneName, switchScene } = useScene()
|
|
21
|
+
|
|
22
|
+
const nextScene = (sceneName === 'default') ? 'other-scene' : 'default'
|
|
23
|
+
|
|
24
|
+
const resetUser = () => {
|
|
25
|
+
clearName()
|
|
26
|
+
clearUsername()
|
|
27
|
+
clearBio()
|
|
28
|
+
clearLocation()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={styles.container}>
|
|
33
|
+
<h2 className={styles.title}>useOverride Demo</h2>
|
|
34
|
+
<p>Add <code>#user.name=Alice</code> to the URL hash to override any value.</p>
|
|
35
|
+
|
|
36
|
+
<section>
|
|
37
|
+
<Text as="h3" fontWeight="bold">Scene</Text>
|
|
38
|
+
<pre className={styles.codeBlock}>current: {sceneName}</pre>
|
|
39
|
+
|
|
40
|
+
<Button size="small" onClick={() => switchScene(nextScene)}>
|
|
41
|
+
Switch to "{nextScene}"
|
|
42
|
+
</Button>
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<section>
|
|
46
|
+
<Text as="h3" fontWeight="bold">User</Text>
|
|
47
|
+
<pre className={styles.codeBlock}>
|
|
48
|
+
{name} ({username})
|
|
49
|
+
</pre>
|
|
50
|
+
<pre className={styles.codeBlock}>
|
|
51
|
+
{bio} · {location}
|
|
52
|
+
</pre>
|
|
53
|
+
|
|
54
|
+
<Text as="h4" fontWeight="semibold" fontSize={1}>Switch User</Text>
|
|
55
|
+
<ButtonGroup>
|
|
56
|
+
<Button size="small" onClick={() => setName('Alice Chen')}>Update name</Button>
|
|
57
|
+
<Button size="small" onClick={() => setUsername('alice123')}>Update username</Button>
|
|
58
|
+
</ButtonGroup>
|
|
59
|
+
<Button size="small" variant="danger" onClick={resetUser} style={{ marginLeft: '8px' }}>
|
|
60
|
+
Reset
|
|
61
|
+
</Button>
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<a href="/storyboard/Overview">hello</a>
|
|
65
|
+
|
|
66
|
+
<section>
|
|
67
|
+
<Text as="h3" fontWeight="bold">Edit User</Text>
|
|
68
|
+
<StoryboardForm data="user" className={styles.form}>
|
|
69
|
+
<FormControl>
|
|
70
|
+
<FormControl.Label>Name</FormControl.Label>
|
|
71
|
+
<TextInput name="name" placeholder="Name" size="small" />
|
|
72
|
+
</FormControl>
|
|
73
|
+
<FormControl>
|
|
74
|
+
<FormControl.Label>Username</FormControl.Label>
|
|
75
|
+
<TextInput name="username" placeholder="Username" size="small" />
|
|
76
|
+
</FormControl>
|
|
77
|
+
<FormControl>
|
|
78
|
+
<FormControl.Label>Bio</FormControl.Label>
|
|
79
|
+
<Textarea name="profile.bio" placeholder="Bio" />
|
|
80
|
+
</FormControl>
|
|
81
|
+
<FormControl>
|
|
82
|
+
<FormControl.Label>Location</FormControl.Label>
|
|
83
|
+
<TextInput name="profile.location" placeholder="Location" size="small" />
|
|
84
|
+
</FormControl>
|
|
85
|
+
<Button type="submit" size="small">Save</Button>
|
|
86
|
+
</StoryboardForm>
|
|
87
|
+
</section>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import PropTypes from 'prop-types'
|
|
3
|
+
import { useSearchParams } from 'react-router-dom'
|
|
4
|
+
import { Text } from '@primer/react'
|
|
5
|
+
import { loadScene } from '@dfosco/storyboard-core'
|
|
6
|
+
import styles from './SceneDebug.module.css'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Debug component that displays loaded scene data as formatted JSON.
|
|
10
|
+
* Used to verify the loader is working correctly.
|
|
11
|
+
* Reads scene name from URL param (?scene=name) or uses prop/default.
|
|
12
|
+
*/
|
|
13
|
+
export default function SceneDebug({ sceneName } = {}) {
|
|
14
|
+
const [searchParams] = useSearchParams()
|
|
15
|
+
const sceneFromUrl = searchParams.get('scene')
|
|
16
|
+
const activeSceneName = sceneName || sceneFromUrl || 'default'
|
|
17
|
+
|
|
18
|
+
const { data, error } = useMemo(() => {
|
|
19
|
+
try {
|
|
20
|
+
return { data: loadScene(activeSceneName), error: null }
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { data: null, error: err.message }
|
|
23
|
+
}
|
|
24
|
+
}, [activeSceneName])
|
|
25
|
+
|
|
26
|
+
if (error) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={styles.error}>
|
|
29
|
+
<Text className={styles.errorTitle}>Error loading scene</Text>
|
|
30
|
+
<p className={styles.errorMessage}>{error}</p>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className={styles.container}>
|
|
37
|
+
<h2 className={styles.title}>Scene: {activeSceneName}</h2>
|
|
38
|
+
<pre className={styles.codeBlock}>
|
|
39
|
+
{JSON.stringify(data, null, 2)}
|
|
40
|
+
</pre>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
SceneDebug.propTypes = {
|
|
46
|
+
sceneName: PropTypes.string,
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
padding: var(--stack-padding-normal);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.title {
|
|
6
|
+
font-size: var(--text-title-size-small);
|
|
7
|
+
font-weight: var(--base-text-weight-semibold);
|
|
8
|
+
margin-bottom: var(--base-size-8);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.codeBlock {
|
|
12
|
+
padding: var(--stack-padding-normal);
|
|
13
|
+
background-color: var(--bgColor-muted);
|
|
14
|
+
border-radius: var(--borderRadius-medium);
|
|
15
|
+
overflow: auto;
|
|
16
|
+
font-size: var(--text-body-size-small);
|
|
17
|
+
font-family: var(--fontStack-monospace);
|
|
18
|
+
line-height: var(--text-body-lineHeight-medium);
|
|
19
|
+
max-height: 70vh;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.error {
|
|
23
|
+
padding: var(--stack-padding-normal);
|
|
24
|
+
background-color: var(--bgColor-danger-muted, var(--bgColor-muted));
|
|
25
|
+
border-radius: var(--borderRadius-medium);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.errorTitle {
|
|
29
|
+
color: var(--fgColor-danger, var(--fgColor-default));
|
|
30
|
+
font-weight: var(--base-text-weight-semibold);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.errorMessage {
|
|
34
|
+
color: var(--fgColor-danger, var(--fgColor-default));
|
|
35
|
+
margin-top: var(--base-size-4);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.form {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: var(--stack-gap-condensed);
|
|
42
|
+
max-width: 320px;
|
|
43
|
+
}
|
package/src/Select.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useContext, useState, useEffect } from 'react'
|
|
3
|
+
import { Select as PrimerSelect } from '@primer/react'
|
|
4
|
+
import { FormContext } from '@dfosco/storyboard-react'
|
|
5
|
+
import { useOverride } from '@dfosco/storyboard-react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped Primer Select that integrates with StoryboardForm.
|
|
9
|
+
*
|
|
10
|
+
* Inside a <StoryboardForm>, values are buffered locally and only
|
|
11
|
+
* written to session on form submit.
|
|
12
|
+
*
|
|
13
|
+
* Outside a form, behaves as a normal controlled Primer Select.
|
|
14
|
+
*/
|
|
15
|
+
export default function Select({ name, onChange, value: controlledValue, children, ...props }) {
|
|
16
|
+
const form = useContext(FormContext)
|
|
17
|
+
const path = form?.prefix && name ? `${form.prefix}.${name}` : name
|
|
18
|
+
const [sessionValue] = useOverride(path || '')
|
|
19
|
+
|
|
20
|
+
const [draft, setDraftState] = useState(sessionValue ?? '')
|
|
21
|
+
|
|
22
|
+
const isConnected = !!form && !!name
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isConnected) return
|
|
26
|
+
return form.subscribe(name, (val) => setDraftState(val))
|
|
27
|
+
}, [isConnected, form, name])
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (isConnected && sessionValue != null) {
|
|
31
|
+
setDraftState(sessionValue)
|
|
32
|
+
form.setDraft(name, sessionValue)
|
|
33
|
+
}
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const handleChange = (e) => {
|
|
38
|
+
if (isConnected) {
|
|
39
|
+
setDraftState(e.target.value)
|
|
40
|
+
form.setDraft(name, e.target.value)
|
|
41
|
+
}
|
|
42
|
+
if (onChange) onChange(e)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resolvedValue = isConnected ? draft : controlledValue
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<PrimerSelect
|
|
49
|
+
name={name}
|
|
50
|
+
value={resolvedValue}
|
|
51
|
+
onChange={handleChange}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</PrimerSelect>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Forward Primer's static sub-components (e.g. Select.Option)
|
|
60
|
+
Select.Option = PrimerSelect.Option
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useRef, useCallback } from 'react'
|
|
3
|
+
import { FormContext } from '@dfosco/storyboard-react'
|
|
4
|
+
import { setParam } from '@dfosco/storyboard-core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A form wrapper that buffers input values locally and only
|
|
8
|
+
* persists them to session (URL hash) on submit.
|
|
9
|
+
*
|
|
10
|
+
* The `data` prop sets the root path — child inputs with a `name` prop
|
|
11
|
+
* will read/write to local draft state while typing, then flush to
|
|
12
|
+
* `data.name` in the URL hash on form submission.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <StoryboardForm data="checkout">
|
|
16
|
+
* <FormControl>
|
|
17
|
+
* <FormControl.Label>Email</FormControl.Label>
|
|
18
|
+
* <TextInput name="email" />
|
|
19
|
+
* </FormControl>
|
|
20
|
+
* <Button type="submit">Save</Button>
|
|
21
|
+
* </StoryboardForm>
|
|
22
|
+
* // On submit: writes #checkout.email=... to URL hash
|
|
23
|
+
*/
|
|
24
|
+
export default function StoryboardForm({ data, onSubmit, children, ...props }) {
|
|
25
|
+
const prefix = data || null
|
|
26
|
+
const draftsRef = useRef({})
|
|
27
|
+
const listenersRef = useRef({})
|
|
28
|
+
|
|
29
|
+
const getDraft = useCallback((name) => draftsRef.current[name], [])
|
|
30
|
+
|
|
31
|
+
const setDraft = useCallback((name, value) => {
|
|
32
|
+
draftsRef.current[name] = value
|
|
33
|
+
// Notify the field so it re-renders with the new draft value
|
|
34
|
+
const listener = listenersRef.current[name]
|
|
35
|
+
if (listener) listener(value)
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const subscribe = useCallback((name, listener) => {
|
|
39
|
+
listenersRef.current[name] = listener
|
|
40
|
+
return () => { delete listenersRef.current[name] }
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const handleSubmit = (e) => {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
// Flush all draft values to session hash params
|
|
46
|
+
if (prefix) {
|
|
47
|
+
for (const [name, value] of Object.entries(draftsRef.current)) {
|
|
48
|
+
setParam(`${prefix}.${name}`, value)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (onSubmit) onSubmit(e)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const contextValue = { prefix, getDraft, setDraft, subscribe }
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<FormContext.Provider value={contextValue}>
|
|
58
|
+
<form {...props} onSubmit={handleSubmit}>
|
|
59
|
+
{children}
|
|
60
|
+
</form>
|
|
61
|
+
</FormContext.Provider>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useContext, useState, useEffect } from 'react'
|
|
3
|
+
import { TextInput as PrimerTextInput } from '@primer/react'
|
|
4
|
+
import { FormContext } from '@dfosco/storyboard-react'
|
|
5
|
+
import { useOverride } from '@dfosco/storyboard-react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped Primer TextInput that integrates with StoryboardForm.
|
|
9
|
+
*
|
|
10
|
+
* Inside a <StoryboardForm>, values are buffered locally and only
|
|
11
|
+
* written to session on form submit. Session values are used as
|
|
12
|
+
* initial defaults.
|
|
13
|
+
*
|
|
14
|
+
* Outside a form, behaves as a normal controlled Primer TextInput.
|
|
15
|
+
*/
|
|
16
|
+
export default function TextInput({ name, onChange, value: controlledValue, ...props }) {
|
|
17
|
+
const form = useContext(FormContext)
|
|
18
|
+
const path = form?.prefix && name ? `${form.prefix}.${name}` : name
|
|
19
|
+
const [sessionValue] = useOverride(path || '')
|
|
20
|
+
|
|
21
|
+
// Local draft state, initialised from session/scene default
|
|
22
|
+
const [draft, setDraftState] = useState(sessionValue ?? '')
|
|
23
|
+
|
|
24
|
+
const isConnected = !!form && !!name
|
|
25
|
+
|
|
26
|
+
// Subscribe to form context draft updates (e.g. external resets)
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!isConnected) return
|
|
29
|
+
return form.subscribe(name, (val) => setDraftState(val))
|
|
30
|
+
}, [isConnected, form, name])
|
|
31
|
+
|
|
32
|
+
// Sync initial session value into draft on mount
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (isConnected && sessionValue != null) {
|
|
35
|
+
setDraftState(sessionValue)
|
|
36
|
+
form.setDraft(name, sessionValue)
|
|
37
|
+
}
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const handleChange = (e) => {
|
|
42
|
+
if (isConnected) {
|
|
43
|
+
setDraftState(e.target.value)
|
|
44
|
+
form.setDraft(name, e.target.value)
|
|
45
|
+
}
|
|
46
|
+
if (onChange) onChange(e)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resolvedValue = isConnected ? draft : controlledValue
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<PrimerTextInput
|
|
53
|
+
name={name}
|
|
54
|
+
value={resolvedValue}
|
|
55
|
+
onChange={handleChange}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
}
|
package/src/Textarea.jsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useContext, useState, useEffect } from 'react'
|
|
3
|
+
import { Textarea as PrimerTextarea } from '@primer/react'
|
|
4
|
+
import { FormContext } from '@dfosco/storyboard-react'
|
|
5
|
+
import { useOverride } from '@dfosco/storyboard-react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wrapped Primer Textarea that integrates with StoryboardForm.
|
|
9
|
+
*
|
|
10
|
+
* Inside a <StoryboardForm>, values are buffered locally and only
|
|
11
|
+
* written to session on form submit.
|
|
12
|
+
*
|
|
13
|
+
* Outside a form, behaves as a normal controlled Primer Textarea.
|
|
14
|
+
*/
|
|
15
|
+
export default function Textarea({ name, onChange, value: controlledValue, ...props }) {
|
|
16
|
+
const form = useContext(FormContext)
|
|
17
|
+
const path = form?.prefix && name ? `${form.prefix}.${name}` : name
|
|
18
|
+
const [sessionValue] = useOverride(path || '')
|
|
19
|
+
|
|
20
|
+
const [draft, setDraftState] = useState(sessionValue ?? '')
|
|
21
|
+
|
|
22
|
+
const isConnected = !!form && !!name
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isConnected) return
|
|
26
|
+
return form.subscribe(name, (val) => setDraftState(val))
|
|
27
|
+
}, [isConnected, form, name])
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (isConnected && sessionValue != null) {
|
|
31
|
+
setDraftState(sessionValue)
|
|
32
|
+
form.setDraft(name, sessionValue)
|
|
33
|
+
}
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const handleChange = (e) => {
|
|
38
|
+
if (isConnected) {
|
|
39
|
+
setDraftState(e.target.value)
|
|
40
|
+
form.setDraft(name, e.target.value)
|
|
41
|
+
}
|
|
42
|
+
if (onChange) onChange(e)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resolvedValue = isConnected ? draft : controlledValue
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<PrimerTextarea
|
|
49
|
+
name={name}
|
|
50
|
+
value={resolvedValue}
|
|
51
|
+
onChange={handleChange}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dfosco/storyboard-react-primer — Primer React design system package for Storyboard.
|
|
3
|
+
*
|
|
4
|
+
* Provides storyboard-aware form components backed by Primer React,
|
|
5
|
+
* plus dev tools styled with Primer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Storyboard form wrappers (Primer-backed)
|
|
9
|
+
export { default as TextInput } from './TextInput.jsx'
|
|
10
|
+
export { default as Select } from './Select.jsx'
|
|
11
|
+
export { default as Checkbox } from './Checkbox.jsx'
|
|
12
|
+
export { default as Textarea } from './Textarea.jsx'
|
|
13
|
+
export { default as StoryboardForm } from './StoryboardForm.jsx'
|
|
14
|
+
|
|
15
|
+
// Scene data demo
|
|
16
|
+
export { default as SceneDataDemo } from './SceneDataDemo.jsx'
|