@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 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
+ }
@@ -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 &quot;{nextScene}&quot;
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
+ }
@@ -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'