@dfosco/storyboard 0.11.3 → 0.11.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +16 -5
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +16 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.test.jsx +62 -0
- package/src/internals/Viewfinder.jsx +7 -2
- package/src/internals/Viewfinder.module.css +0 -20
- package/src/internals/canvas/widgets/Widget.jsx +270 -0
- package/src/internals/canvas/widgets/Widget.module.css +132 -0
- package/src/internals/canvas/widgets/Widget.test.jsx +186 -0
- package/src/internals/index.js +4 -0
package/package.json
CHANGED
|
@@ -19,26 +19,37 @@ function branchLabel(entry, homeTitle, homeFolder) {
|
|
|
19
19
|
return pieces.join(' ')
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export default function BranchesDropdown({ branches, branchBasePath, currentBranch, homeTitle = '', homeFolder = null }) {
|
|
22
|
+
export default function BranchesDropdown({ branches, branchBasePath, currentBranch, homeTitle = '', homeFolder = null, isBranchOnly = false }) {
|
|
23
23
|
if (!currentBranch || isLocalDev() || !Array.isArray(branches)) return null
|
|
24
24
|
|
|
25
25
|
const visibleBranches = branches.filter(entry => entry?.branch && entry.branch !== currentBranch)
|
|
26
26
|
if (visibleBranches.length === 0) return null
|
|
27
27
|
|
|
28
|
+
// When the prototype is branch-only (i.e. the current branch doesn't
|
|
29
|
+
// have it), give the button a subtle accent so users can spot the
|
|
30
|
+
// affordance without painting the whole card. Tooltip text shifts to
|
|
31
|
+
// make the meaning explicit.
|
|
32
|
+
const btnClass = isBranchOnly ? `${css.iconBtn} ${css.iconBtnAccent}` : css.iconBtn
|
|
33
|
+
const label = isBranchOnly
|
|
34
|
+
? 'Open on another branch'
|
|
35
|
+
: 'See branches'
|
|
36
|
+
|
|
28
37
|
return (
|
|
29
38
|
<Menu.Root>
|
|
30
39
|
<Menu.Trigger
|
|
31
|
-
className={
|
|
40
|
+
className={btnClass}
|
|
32
41
|
onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
|
|
33
|
-
aria-label=
|
|
34
|
-
title=
|
|
42
|
+
aria-label={label}
|
|
43
|
+
title={label}
|
|
35
44
|
>
|
|
36
45
|
<GitBranchIcon size={16} />
|
|
37
46
|
</Menu.Trigger>
|
|
38
47
|
<Menu.Portal>
|
|
39
48
|
<Menu.Positioner className={css.branchesPositioner} side="bottom" align="end" sideOffset={4}>
|
|
40
49
|
<Menu.Popup className={css.branchesPopup}>
|
|
41
|
-
<div className={css.branchesTitle}>
|
|
50
|
+
<div className={css.branchesTitle}>
|
|
51
|
+
{isBranchOnly ? 'Available on' : 'Branches'}
|
|
52
|
+
</div>
|
|
42
53
|
{visibleBranches.map(entry => {
|
|
43
54
|
const href = entry.isExternal && entry.externalUrl
|
|
44
55
|
? entry.externalUrl
|
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
color: var(--color-foreground-muted, #555);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/* Subtle accent applied when the current branch doesn't have the
|
|
21
|
+
* prototype (the card is "branch-only" elsewhere). Replaces the
|
|
22
|
+
* previous loud card-level blue background — now the only marker
|
|
23
|
+
* that a prototype lives on another branch is this single tinted
|
|
24
|
+
* button, plus an accent ring on hover/open. */
|
|
25
|
+
.iconBtnAccent {
|
|
26
|
+
color: var(--color-foreground-accent, #0969da);
|
|
27
|
+
background: var(--color-background-accent-muted, #ddf4ff);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.iconBtnAccent:hover {
|
|
31
|
+
background: var(--color-background-accent-muted, #ddf4ff);
|
|
32
|
+
color: var(--color-foreground-accent, #0969da);
|
|
33
|
+
box-shadow: inset 0 0 0 1px var(--color-border-accent-muted, #54aeff66);
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
.branchesPositioner {
|
|
21
37
|
z-index: 200;
|
|
22
38
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
|
+
import BranchesDropdown from './BranchesDropdown.jsx'
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
cleanup()
|
|
7
|
+
// BranchesDropdown bails out when window.__SB_LOCAL_DEV__ === true.
|
|
8
|
+
if (typeof window !== 'undefined') {
|
|
9
|
+
delete window.__SB_LOCAL_DEV__
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const branches = [
|
|
14
|
+
{ branch: 'main', folder: 'demos', route: '/Signup' },
|
|
15
|
+
{ branch: 'feature-x', folder: 'demos', route: '/Signup' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
describe('BranchesDropdown', () => {
|
|
19
|
+
it('renders the trigger when other branches have the prototype', () => {
|
|
20
|
+
const { container } = render(
|
|
21
|
+
<BranchesDropdown branches={branches} branchBasePath="/" currentBranch="main" />,
|
|
22
|
+
)
|
|
23
|
+
const trigger = container.querySelector('button')
|
|
24
|
+
expect(trigger).toBeTruthy()
|
|
25
|
+
expect(trigger.getAttribute('aria-label')).toBe('See branches')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies the accent class and re-labels when isBranchOnly is true', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<BranchesDropdown
|
|
31
|
+
branches={branches}
|
|
32
|
+
branchBasePath="/"
|
|
33
|
+
currentBranch="main"
|
|
34
|
+
isBranchOnly
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
const trigger = container.querySelector('button')
|
|
38
|
+
expect(trigger).toBeTruthy()
|
|
39
|
+
// CSS Modules hash class names — assert by suffix match.
|
|
40
|
+
expect(trigger.className).toMatch(/iconBtnAccent/)
|
|
41
|
+
expect(trigger.getAttribute('aria-label')).toBe('Open on another branch')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does NOT apply the accent class when isBranchOnly is false', () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<BranchesDropdown branches={branches} branchBasePath="/" currentBranch="main" />,
|
|
47
|
+
)
|
|
48
|
+
const trigger = container.querySelector('button')
|
|
49
|
+
expect(trigger.className).not.toMatch(/iconBtnAccent/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null when only the current branch has the prototype', () => {
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<BranchesDropdown
|
|
55
|
+
branches={[{ branch: 'main', folder: 'demos', route: '/Signup' }]}
|
|
56
|
+
branchBasePath="/"
|
|
57
|
+
currentBranch="main"
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
expect(container.querySelector('button')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -530,7 +530,12 @@ function ArtifactCard({ item, basePath, branchBasePath, currentBranch, starred,
|
|
|
530
530
|
const dirName = item.id.split(':').slice(1).join(':')
|
|
531
531
|
const typeLabel = item.type === 'canvas' ? 'Canvas' : item.type === 'prototype' ? 'Prototype' : 'Component'
|
|
532
532
|
const canEditDelete = !isBranchOnly && (item.type === 'canvas' || item.type === 'prototype')
|
|
533
|
-
|
|
533
|
+
// Cards intentionally look identical regardless of `isBranchOnly`. The
|
|
534
|
+
// only marker that a prototype lives on another branch is now the
|
|
535
|
+
// subtle blue tint on the BranchesDropdown button (see below). The
|
|
536
|
+
// previous full-card .branchOnlyCard background drew the eye too
|
|
537
|
+
// strongly for what is effectively a "hint, not a blocker" state.
|
|
538
|
+
const cardClass = `${css.card}${item.isPrivate ? ' ' + css.cardPrivate : ''}`
|
|
534
539
|
|
|
535
540
|
return (
|
|
536
541
|
<>
|
|
@@ -538,7 +543,6 @@ function ArtifactCard({ item, basePath, branchBasePath, currentBranch, starred,
|
|
|
538
543
|
<div className={css.cardHeader}>
|
|
539
544
|
<div className={css.cardBadgeGroup}>
|
|
540
545
|
<span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
|
|
541
|
-
{isBranchOnly && <span className={css.branchOnlyBadge}>Branch-only</span>}
|
|
542
546
|
{item.isPrivate && !isBranchOnly && (
|
|
543
547
|
<Tooltip text={`${typeLabel} not published, only visible to you`} direction="n">
|
|
544
548
|
<button
|
|
@@ -562,6 +566,7 @@ function ArtifactCard({ item, basePath, branchBasePath, currentBranch, starred,
|
|
|
562
566
|
currentBranch={currentBranch}
|
|
563
567
|
homeTitle={item.name}
|
|
564
568
|
homeFolder={item.folder}
|
|
569
|
+
isBranchOnly={isBranchOnly}
|
|
565
570
|
/>
|
|
566
571
|
{canEditDelete && (
|
|
567
572
|
<CardActionsMenu
|
|
@@ -650,15 +650,6 @@
|
|
|
650
650
|
opacity: 1;
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
-
.branchOnlyCard {
|
|
654
|
-
background: var(--color-background-muted, #f6f8fa);
|
|
655
|
-
border-color: var(--color-border-muted, #d8dee4);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
.branchOnlyCard:hover {
|
|
659
|
-
border-color: var(--color-border-accent-muted, #54aeff66);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
653
|
.cardThumb {
|
|
663
654
|
height: 140px;
|
|
664
655
|
background: var(--color-background-muted, #f5f5f5);
|
|
@@ -680,17 +671,6 @@
|
|
|
680
671
|
color: var(--color-foreground-muted, #555);
|
|
681
672
|
}
|
|
682
673
|
|
|
683
|
-
.branchOnlyBadge {
|
|
684
|
-
padding: 3px 8px;
|
|
685
|
-
border-radius: 999px;
|
|
686
|
-
font-size: 11px;
|
|
687
|
-
font-weight: 700;
|
|
688
|
-
text-transform: uppercase;
|
|
689
|
-
letter-spacing: 0.4px;
|
|
690
|
-
background: var(--color-background-accent-muted, #ddf4ff);
|
|
691
|
-
color: var(--color-foreground-accent, #0969da);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
674
|
.cardPrivateBadge {
|
|
695
675
|
display: inline-flex;
|
|
696
676
|
align-items: center;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import { getWidgetComponent } from './index.js'
|
|
12
|
+
import styles from './Widget.module.css'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `<Widget>` — render any registered canvas widget inside a normal React page.
|
|
16
|
+
*
|
|
17
|
+
* Canvas widgets follow a stable contract: they resolve from a `type` string
|
|
18
|
+
* (via `getWidgetComponent`), read their fields from a flat `props` object, and
|
|
19
|
+
* render chrome-less (the canvas applies toolbar/anchors/selection externally).
|
|
20
|
+
* This component reproduces that contract off-canvas so any widget can be used
|
|
21
|
+
* as a first-class element in a page:
|
|
22
|
+
*
|
|
23
|
+
* <Widget type="markdown" content="# Hi" className="prose" />
|
|
24
|
+
* <Widget type="prototype" src="StartupSignup" width={900} height={600} />
|
|
25
|
+
* <Widget type="sticky-note" text="Note" color="yellow" draggable resizable />
|
|
26
|
+
*
|
|
27
|
+
* Prop mapping
|
|
28
|
+
* ------------
|
|
29
|
+
* Reserved props configure the wrapper / contract and are NOT forwarded into
|
|
30
|
+
* the widget's `props` object:
|
|
31
|
+
* type, id, className, style, onUpdate, widgetRef, children,
|
|
32
|
+
* draggable, resizable, chrome
|
|
33
|
+
* Every OTHER prop is spread into the widget `props` object, so all widget
|
|
34
|
+
* fields (content, settings, size — unique or not) are passable as props.
|
|
35
|
+
*
|
|
36
|
+
* onUpdate
|
|
37
|
+
* --------
|
|
38
|
+
* Defaults to internal local state (uncontrolled): widgets that self-heal or
|
|
39
|
+
* edit through `onUpdate(partial)` keep working, and resize/drag persist for
|
|
40
|
+
* the lifetime of the element. Pass your own `onUpdate` for controlled mode —
|
|
41
|
+
* it receives the same partial-merge object and you own the props.
|
|
42
|
+
*
|
|
43
|
+
* chrome
|
|
44
|
+
* ------
|
|
45
|
+
* `chrome` (default true) renders the canvas-style selection outline and the
|
|
46
|
+
* drag/select handle (the trigger dot that reveals a grab handle on hover),
|
|
47
|
+
* matching how widgets look on the canvas — minus the action menu. Set
|
|
48
|
+
* `chrome={false}` for a bare widget with no selection affordance.
|
|
49
|
+
*
|
|
50
|
+
* draggable / resizable
|
|
51
|
+
* ---------------------
|
|
52
|
+
* `resizable` (default false) is forwarded to the widget, which renders its own
|
|
53
|
+
* ResizeHandle and persists width/height through `onUpdate`. `draggable`
|
|
54
|
+
* (default false) is a page-level affordance: the widget body and the select
|
|
55
|
+
* handle become pointer-draggable and the offset is applied as a CSS translate.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const RESERVED = new Set([
|
|
59
|
+
'type',
|
|
60
|
+
'id',
|
|
61
|
+
'className',
|
|
62
|
+
'style',
|
|
63
|
+
'onUpdate',
|
|
64
|
+
'widgetRef',
|
|
65
|
+
'children',
|
|
66
|
+
'draggable',
|
|
67
|
+
'resizable',
|
|
68
|
+
'chrome',
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
const DRAG_THRESHOLD = 3
|
|
72
|
+
|
|
73
|
+
function isForwardRef(Component) {
|
|
74
|
+
return Component != null && Component.$$typeof === Symbol.for('react.forward_ref')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Widget(
|
|
78
|
+
{
|
|
79
|
+
type,
|
|
80
|
+
id,
|
|
81
|
+
className,
|
|
82
|
+
style,
|
|
83
|
+
onUpdate,
|
|
84
|
+
draggable = false,
|
|
85
|
+
resizable = false,
|
|
86
|
+
chrome = true,
|
|
87
|
+
...rest
|
|
88
|
+
},
|
|
89
|
+
ref,
|
|
90
|
+
) {
|
|
91
|
+
const autoId = useId()
|
|
92
|
+
const widgetId = id || `widget-${type || 'unknown'}-${autoId}`
|
|
93
|
+
|
|
94
|
+
// Collect every non-reserved prop into the flat widget props object.
|
|
95
|
+
const incomingProps = useMemo(() => {
|
|
96
|
+
const out = {}
|
|
97
|
+
for (const key of Object.keys(rest)) {
|
|
98
|
+
if (!RESERVED.has(key)) out[key] = rest[key]
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [JSON.stringify(rest)])
|
|
103
|
+
|
|
104
|
+
// Local override state powers the default (uncontrolled) onUpdate so that
|
|
105
|
+
// self-healing widgets, resizing, and editing all persist without a canvas.
|
|
106
|
+
const [overrides, setOverrides] = useState(null)
|
|
107
|
+
|
|
108
|
+
const handleUpdate = useCallback(
|
|
109
|
+
(partial) => {
|
|
110
|
+
if (!partial || typeof partial !== 'object') return
|
|
111
|
+
if (typeof onUpdate === 'function') {
|
|
112
|
+
onUpdate(partial)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
setOverrides((prev) => ({ ...(prev || {}), ...partial }))
|
|
116
|
+
},
|
|
117
|
+
[onUpdate],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const effectiveProps = useMemo(
|
|
121
|
+
() => (overrides ? { ...incomingProps, ...overrides } : incomingProps),
|
|
122
|
+
[incomingProps, overrides],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Selection + drag offset (canvas-style chrome affordances).
|
|
126
|
+
const [selected, setSelected] = useState(false)
|
|
127
|
+
const [hovered, setHovered] = useState(false)
|
|
128
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
|
129
|
+
const offsetRef = useRef(offset)
|
|
130
|
+
offsetRef.current = offset
|
|
131
|
+
const containerRef = useRef(null)
|
|
132
|
+
|
|
133
|
+
const selectWidget = useCallback(() => setSelected(true), [])
|
|
134
|
+
|
|
135
|
+
// Click anywhere outside the widget deselects it.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!chrome || !selected) return undefined
|
|
138
|
+
const onDocPointerDown = (e) => {
|
|
139
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
140
|
+
setSelected(false)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
document.addEventListener('pointerdown', onDocPointerDown)
|
|
144
|
+
return () => document.removeEventListener('pointerdown', onDocPointerDown)
|
|
145
|
+
}, [chrome, selected])
|
|
146
|
+
|
|
147
|
+
// Shared drag/click handler. `fromHandle` distinguishes a click on the select
|
|
148
|
+
// handle (selects when no drag occurs) from a body drag.
|
|
149
|
+
const beginPointer = useCallback(
|
|
150
|
+
(e, fromHandle) => {
|
|
151
|
+
// Let interactive controls (inputs, links, resize handle…) keep working.
|
|
152
|
+
if (
|
|
153
|
+
!fromHandle &&
|
|
154
|
+
e.target.closest(
|
|
155
|
+
'input, textarea, select, button, a, [contenteditable="true"], [data-widget-resize-handle]',
|
|
156
|
+
)
|
|
157
|
+
) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (fromHandle) e.stopPropagation()
|
|
161
|
+
if (!draggable && !fromHandle) return
|
|
162
|
+
|
|
163
|
+
const startX = e.clientX
|
|
164
|
+
const startY = e.clientY
|
|
165
|
+
const start = offsetRef.current
|
|
166
|
+
let moved = false
|
|
167
|
+
|
|
168
|
+
const move = (ev) => {
|
|
169
|
+
const dx = ev.clientX - startX
|
|
170
|
+
const dy = ev.clientY - startY
|
|
171
|
+
if (!moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
|
172
|
+
moved = true
|
|
173
|
+
if (draggable) setOffset({ x: start.x + dx, y: start.y + dy })
|
|
174
|
+
}
|
|
175
|
+
const up = () => {
|
|
176
|
+
document.removeEventListener('pointermove', move)
|
|
177
|
+
document.removeEventListener('pointerup', up)
|
|
178
|
+
if (fromHandle && !moved) selectWidget()
|
|
179
|
+
}
|
|
180
|
+
document.addEventListener('pointermove', move)
|
|
181
|
+
document.addEventListener('pointerup', up)
|
|
182
|
+
},
|
|
183
|
+
[draggable, selectWidget],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const Component = getWidgetComponent(type)
|
|
187
|
+
if (!Component) {
|
|
188
|
+
if (typeof console !== 'undefined') {
|
|
189
|
+
console.warn(`[storyboard] <Widget>: unknown widget type "${type}"`)
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const elementProps = {
|
|
195
|
+
id: widgetId,
|
|
196
|
+
props: effectiveProps,
|
|
197
|
+
onUpdate: handleUpdate,
|
|
198
|
+
resizable: !!resizable,
|
|
199
|
+
selected: chrome ? selected : false,
|
|
200
|
+
onSelect: chrome ? selectWidget : () => {},
|
|
201
|
+
}
|
|
202
|
+
if (isForwardRef(Component)) {
|
|
203
|
+
elementProps.ref = ref
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const containerStyle = {
|
|
207
|
+
...style,
|
|
208
|
+
...(draggable
|
|
209
|
+
? {
|
|
210
|
+
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
211
|
+
touchAction: 'none',
|
|
212
|
+
}
|
|
213
|
+
: null),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const showHandle = chrome && (hovered || selected)
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
ref={containerRef}
|
|
221
|
+
className={[
|
|
222
|
+
styles.widget,
|
|
223
|
+
chrome ? styles.chromeContainer : '',
|
|
224
|
+
draggable ? styles.draggable : '',
|
|
225
|
+
className,
|
|
226
|
+
]
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join(' ')}
|
|
229
|
+
style={containerStyle}
|
|
230
|
+
data-widget-type={type}
|
|
231
|
+
data-widget-selected={chrome && selected ? '' : undefined}
|
|
232
|
+
onPointerDown={draggable ? (e) => beginPointer(e, false) : undefined}
|
|
233
|
+
onClick={chrome ? selectWidget : undefined}
|
|
234
|
+
onMouseEnter={chrome ? () => setHovered(true) : undefined}
|
|
235
|
+
onMouseLeave={chrome ? () => setHovered(false) : undefined}
|
|
236
|
+
>
|
|
237
|
+
<div
|
|
238
|
+
className={[styles.widgetSlot, chrome && selected ? styles.widgetSlotSelected : '']
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.join(' ')}
|
|
241
|
+
>
|
|
242
|
+
{createElement(Component, elementProps)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{chrome && (
|
|
246
|
+
<div className={styles.toolbar}>
|
|
247
|
+
<span
|
|
248
|
+
className={`${styles.triggerDot} ${showHandle ? styles.triggerDotHidden : ''}`}
|
|
249
|
+
aria-hidden="true"
|
|
250
|
+
/>
|
|
251
|
+
<div className={`${styles.toolbarContent} ${showHandle ? styles.toolbarContentVisible : ''}`}>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
className={`${styles.selectHandle} ${selected ? styles.selectHandleActive : ''} ${
|
|
255
|
+
draggable ? styles.selectHandleDraggable : ''
|
|
256
|
+
}`}
|
|
257
|
+
onPointerDown={(e) => beginPointer(e, true)}
|
|
258
|
+
aria-label={
|
|
259
|
+
selected ? (draggable ? 'Drag to move widget' : 'Widget selected') : 'Select widget'
|
|
260
|
+
}
|
|
261
|
+
aria-pressed={selected}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export default forwardRef(Widget)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/* <Widget> — page-embeddable canvas widget.
|
|
2
|
+
Mirrors the canvas WidgetChrome selection outline + drag/select handle,
|
|
3
|
+
minus the action menu. */
|
|
4
|
+
|
|
5
|
+
.widget {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
position: relative;
|
|
8
|
+
max-width: 100%;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.chromeContainer {
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.draggable {
|
|
16
|
+
user-select: none;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Widget slot — selection outline targets this. */
|
|
20
|
+
.widgetSlot {
|
|
21
|
+
position: relative;
|
|
22
|
+
border-radius: 4px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.widgetSlotSelected {
|
|
26
|
+
outline: 4px solid var(--color-background-accent-emphasis, #2f81f7);
|
|
27
|
+
outline-offset: 2px;
|
|
28
|
+
border-radius: 4px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Toolbar — absolutely positioned below the widget so it never affects the
|
|
32
|
+
widget's box dimensions. */
|
|
33
|
+
.toolbar {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: flex-end;
|
|
37
|
+
height: 28px;
|
|
38
|
+
position: absolute;
|
|
39
|
+
left: 0;
|
|
40
|
+
right: 0;
|
|
41
|
+
top: calc(100% + 10px);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Invisible hit-area bridging the 10px gap so hover survives the pointer
|
|
45
|
+
crossing the padding between the widget and its handle. */
|
|
46
|
+
.toolbar::before {
|
|
47
|
+
content: '';
|
|
48
|
+
position: absolute;
|
|
49
|
+
left: 0;
|
|
50
|
+
right: 0;
|
|
51
|
+
bottom: 100%;
|
|
52
|
+
height: 10px;
|
|
53
|
+
pointer-events: auto;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Trigger dot — visible at rest, hidden once the handle is revealed. */
|
|
57
|
+
.triggerDot {
|
|
58
|
+
width: 6px;
|
|
59
|
+
height: 6px;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
background: var(--color-border-muted, #d0d7de);
|
|
62
|
+
opacity: 0.5;
|
|
63
|
+
margin-left: auto;
|
|
64
|
+
transition: opacity 120ms;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
68
|
+
background: var(--color-border-muted, #373e47);
|
|
69
|
+
opacity: 0.6;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.triggerDotHidden {
|
|
73
|
+
opacity: 0;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Toolbar content — holds the select handle. */
|
|
78
|
+
.toolbarContent {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: flex-end;
|
|
82
|
+
opacity: 0;
|
|
83
|
+
pointer-events: none;
|
|
84
|
+
transition: opacity 120ms;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.toolbarContentVisible {
|
|
88
|
+
opacity: 1;
|
|
89
|
+
pointer-events: auto;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Select handle — rounded rect. Click selects; drag (when draggable) moves. */
|
|
93
|
+
.selectHandle {
|
|
94
|
+
all: unset;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
width: 16px;
|
|
97
|
+
height: 16px;
|
|
98
|
+
border-radius: 4px;
|
|
99
|
+
border: 1.6px solid var(--color-border-muted, #d0d7de);
|
|
100
|
+
background: var(--color-background, #ffffff);
|
|
101
|
+
transition: background 100ms, border-color 100ms;
|
|
102
|
+
flex-shrink: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.selectHandleDraggable {
|
|
106
|
+
cursor: grab;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.selectHandleDraggable:active {
|
|
110
|
+
cursor: grabbing;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandle {
|
|
114
|
+
background: var(--color-background-muted, #161b22);
|
|
115
|
+
border-color: var(--color-border-muted, #373e47);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.selectHandle:hover {
|
|
119
|
+
border-color: var(--color-background-accent-emphasis, #2f81f7);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.selectHandleActive,
|
|
123
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive {
|
|
124
|
+
background: var(--color-background-accent-emphasis, #2f81f7);
|
|
125
|
+
border-color: var(--color-background-accent-emphasis, #2f81f7);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.selectHandleActive:hover,
|
|
129
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive:hover {
|
|
130
|
+
background: var(--color-background-accent-emphasis, #388bfd);
|
|
131
|
+
border-color: var(--color-background-accent-emphasis, #388bfd);
|
|
132
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import Widget from './Widget.jsx'
|
|
4
|
+
import {
|
|
5
|
+
registerWidget,
|
|
6
|
+
_resetWidgetRegistry,
|
|
7
|
+
} from '../../../core/stores/widgetRegistry.js'
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
_resetWidgetRegistry()
|
|
11
|
+
vi.restoreAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
// A minimal probe widget that renders its props and exposes onUpdate so we can
|
|
15
|
+
// assert the standard contract the dispatcher supplies.
|
|
16
|
+
function ProbeWidget({ id, props, onUpdate, resizable }) {
|
|
17
|
+
return (
|
|
18
|
+
<div data-testid="probe" data-id={id} data-resizable={String(resizable)}>
|
|
19
|
+
<span data-testid="text">{props?.text ?? ''}</span>
|
|
20
|
+
<span data-testid="width">{String(props?.width ?? '')}</span>
|
|
21
|
+
<button type="button" onClick={() => onUpdate({ text: 'updated' })}>
|
|
22
|
+
update
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('<Widget>', () => {
|
|
29
|
+
it('resolves a registered type and maps non-reserved props into the widget props object', () => {
|
|
30
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
31
|
+
|
|
32
|
+
render(<Widget type="probe" text="hello" width={320} />)
|
|
33
|
+
|
|
34
|
+
expect(screen.getByTestId('probe')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByTestId('text').textContent).toBe('hello')
|
|
36
|
+
expect(screen.getByTestId('width').textContent).toBe('320')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('applies className and data-widget-type to the wrapper', () => {
|
|
40
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
41
|
+
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<Widget type="probe" className="my-class" text="x" />,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const wrapper = container.querySelector('[data-widget-type="probe"]')
|
|
47
|
+
expect(wrapper).toBeTruthy()
|
|
48
|
+
expect(wrapper.className).toContain('my-class')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('forwards the resizable prop to the widget', () => {
|
|
52
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
53
|
+
|
|
54
|
+
render(<Widget type="probe" resizable text="x" />)
|
|
55
|
+
expect(screen.getByTestId('probe').getAttribute('data-resizable')).toBe('true')
|
|
56
|
+
|
|
57
|
+
_resetWidgetRegistry()
|
|
58
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
59
|
+
render(<Widget type="probe" text="y" />)
|
|
60
|
+
const probes = screen.getAllByTestId('probe')
|
|
61
|
+
expect(probes[probes.length - 1].getAttribute('data-resizable')).toBe('false')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('defaults onUpdate to internal local state (uncontrolled)', () => {
|
|
65
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
66
|
+
|
|
67
|
+
render(<Widget type="probe" text="initial" />)
|
|
68
|
+
expect(screen.getByTestId('text').textContent).toBe('initial')
|
|
69
|
+
|
|
70
|
+
fireEvent.click(screen.getByText('update'))
|
|
71
|
+
expect(screen.getByTestId('text').textContent).toBe('updated')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('uses a consumer-supplied onUpdate when provided (controlled)', () => {
|
|
75
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
76
|
+
const onUpdate = vi.fn()
|
|
77
|
+
|
|
78
|
+
render(<Widget type="probe" text="initial" onUpdate={onUpdate} />)
|
|
79
|
+
fireEvent.click(screen.getByText('update'))
|
|
80
|
+
|
|
81
|
+
expect(onUpdate).toHaveBeenCalledWith({ text: 'updated' })
|
|
82
|
+
// Controlled: local state is not applied, text stays as the incoming prop.
|
|
83
|
+
expect(screen.getByTestId('text').textContent).toBe('initial')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not forward reserved props into the widget props object', () => {
|
|
87
|
+
function PropsSpy({ props }) {
|
|
88
|
+
return <pre data-testid="json">{JSON.stringify(props)}</pre>
|
|
89
|
+
}
|
|
90
|
+
registerWidget('spy', { component: PropsSpy })
|
|
91
|
+
|
|
92
|
+
render(
|
|
93
|
+
<Widget
|
|
94
|
+
type="spy"
|
|
95
|
+
id="fixed-id"
|
|
96
|
+
className="c"
|
|
97
|
+
draggable
|
|
98
|
+
resizable
|
|
99
|
+
text="keep"
|
|
100
|
+
/>,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const parsed = JSON.parse(screen.getByTestId('json').textContent)
|
|
104
|
+
expect(parsed).toEqual({ text: 'keep' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('renders nothing and warns for an unknown type', () => {
|
|
108
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
109
|
+
const { container } = render(<Widget type="does-not-exist" />)
|
|
110
|
+
|
|
111
|
+
expect(container.firstChild).toBeNull()
|
|
112
|
+
expect(warn).toHaveBeenCalledWith(
|
|
113
|
+
expect.stringContaining('unknown widget type "does-not-exist"'),
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('applies a translate transform to the wrapper when draggable is set', () => {
|
|
118
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
119
|
+
|
|
120
|
+
const { container } = render(<Widget type="probe" draggable text="x" />)
|
|
121
|
+
const wrapper = container.querySelector('[data-widget-type="probe"]')
|
|
122
|
+
expect(wrapper.style.transform).toContain('translate(')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('renders the chrome select handle by default and selects on handle click', () => {
|
|
126
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
127
|
+
|
|
128
|
+
const { container } = render(<Widget type="probe" text="x" />)
|
|
129
|
+
const handle = container.querySelector('[aria-label="Select widget"]')
|
|
130
|
+
expect(handle).toBeTruthy()
|
|
131
|
+
expect(handle.getAttribute('aria-pressed')).toBe('false')
|
|
132
|
+
|
|
133
|
+
// pointerDown without movement selects on pointerUp.
|
|
134
|
+
fireEvent.pointerDown(handle, { clientX: 0, clientY: 0 })
|
|
135
|
+
fireEvent.pointerUp(document, { clientX: 0, clientY: 0 })
|
|
136
|
+
|
|
137
|
+
expect(container.querySelector('[aria-pressed="true"]')).toBeTruthy()
|
|
138
|
+
expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('selects when clicking anywhere on the widget body', () => {
|
|
142
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
143
|
+
|
|
144
|
+
const { container } = render(<Widget type="probe" text="x" />)
|
|
145
|
+
expect(container.querySelector('[data-widget-selected]')).toBeNull()
|
|
146
|
+
|
|
147
|
+
fireEvent.click(screen.getByTestId('probe'))
|
|
148
|
+
expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('deselects when clicking outside the widget', () => {
|
|
152
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
153
|
+
|
|
154
|
+
const { container } = render(
|
|
155
|
+
<div>
|
|
156
|
+
<Widget type="probe" text="x" />
|
|
157
|
+
<button type="button">outside</button>
|
|
158
|
+
</div>,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
fireEvent.click(screen.getByTestId('probe'))
|
|
162
|
+
expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
|
|
163
|
+
|
|
164
|
+
fireEvent.pointerDown(screen.getByText('outside'))
|
|
165
|
+
expect(container.querySelector('[data-widget-selected]')).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('omits chrome (no handle, no selection) when chrome={false}', () => {
|
|
169
|
+
registerWidget('probe', { component: ProbeWidget })
|
|
170
|
+
|
|
171
|
+
const { container } = render(<Widget type="probe" chrome={false} text="x" />)
|
|
172
|
+
expect(container.querySelector('[aria-label="Select widget"]')).toBeNull()
|
|
173
|
+
expect(screen.getByTestId('probe')).toBeInTheDocument()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not forward the chrome prop into the widget props object', () => {
|
|
177
|
+
function PropsSpy({ props }) {
|
|
178
|
+
return <pre data-testid="json">{JSON.stringify(props)}</pre>
|
|
179
|
+
}
|
|
180
|
+
registerWidget('spy', { component: PropsSpy })
|
|
181
|
+
|
|
182
|
+
render(<Widget type="spy" chrome text="keep" />)
|
|
183
|
+
const parsed = JSON.parse(screen.getByTestId('json').textContent)
|
|
184
|
+
expect(parsed).toEqual({ text: 'keep' })
|
|
185
|
+
})
|
|
186
|
+
})
|
package/src/internals/index.js
CHANGED
|
@@ -62,6 +62,10 @@ export { default as AuthModal } from './AuthModal/AuthModal.jsx'
|
|
|
62
62
|
export { default as CanvasPage } from './canvas/CanvasPage.jsx'
|
|
63
63
|
export { useCanvas } from './canvas/useCanvas.js'
|
|
64
64
|
|
|
65
|
+
// Widget — render any registered canvas widget inside a normal React page.
|
|
66
|
+
export { default as Widget } from './canvas/widgets/Widget.jsx'
|
|
67
|
+
export { getWidgetComponent } from './canvas/widgets/index.js'
|
|
68
|
+
|
|
65
69
|
// Error boundaries
|
|
66
70
|
export { default as PrototypeErrorBoundary, ImportErrorFallback, AppErrorBoundary } from './PrototypeErrorBoundary.jsx'
|
|
67
71
|
|