@liiift-studio/deploy-vercel-from-sanity 1.1.0 → 1.2.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/README.md +33 -19
- package/dist/index.d.mts +39 -9
- package/dist/index.d.ts +39 -9
- package/dist/index.js +768 -413
- package/dist/index.mjs +756 -445
- package/package.json +13 -7
- package/src/compat/code.tsx +29 -0
- package/src/compat/index.ts +14 -0
- package/src/compat/menu.tsx +225 -0
- package/src/compat/primitives.tsx +93 -0
- package/src/compat/resolve.ts +55 -0
- package/src/compat/toast.tsx +173 -0
- package/src/compat/tooltip.tsx +78 -0
- package/src/components/DeployHistory.tsx +19 -17
- package/src/components/DeployItem.tsx +38 -13
- package/src/components/DeployTargetForm.tsx +42 -16
- package/src/components/DeployTool.tsx +30 -8
- package/src/components/StatusBadge.tsx +1 -1
- package/src/components/TokenSetup.tsx +7 -7
- package/src/icons.tsx +31 -24
- package/src/index.ts +8 -3
- package/src/lib/api.ts +10 -4
- package/src/lib/helpers.ts +17 -0
- package/src/schema/schemaIcon.tsx +38 -0
- package/src/schema/vercelConfig.ts +34 -0
- package/src/schema/vercelDeploy.ts +2 -2
- package/src/version.ts +1 -1
- package/src/ui.tsx +0 -337
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/deploy-vercel-from-sanity",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3 through v6.",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3.30 through v6.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Liiift Studio",
|
|
7
7
|
"keywords": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"types": "./dist/index.d.ts",
|
|
23
23
|
"exports": {
|
|
24
24
|
".": {
|
|
25
|
-
"
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
26
|
"import": "./dist/index.mjs",
|
|
27
27
|
"require": "./dist/index.js",
|
|
28
28
|
"default": "./dist/index.mjs"
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"prepublishOnly": "npm run build"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@sanity/icons": ">=
|
|
43
|
-
"@sanity/ui": ">=2",
|
|
42
|
+
"@sanity/icons": ">=2 <6",
|
|
43
|
+
"@sanity/ui": ">=2 <5",
|
|
44
44
|
"react": ">=18",
|
|
45
|
-
"
|
|
45
|
+
"react-dom": ">=18",
|
|
46
|
+
"sanity": ">=3.30"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@sanity/icons": "^5",
|
|
@@ -52,10 +53,15 @@
|
|
|
52
53
|
"react": "^19",
|
|
53
54
|
"sanity": "^6",
|
|
54
55
|
"tsup": "^8",
|
|
55
|
-
"typescript": "^5"
|
|
56
|
+
"typescript": "^5",
|
|
57
|
+
"react-dom": "^19"
|
|
56
58
|
},
|
|
57
59
|
"repository": {
|
|
58
60
|
"type": "git",
|
|
59
61
|
"url": "https://github.com/Liiift-Studio/Deploy-Vercel-from-Sanity"
|
|
62
|
+
},
|
|
63
|
+
"sideEffects": true,
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20.19"
|
|
60
66
|
}
|
|
61
67
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Monospace block — Studio's Code where available, a styled <code> block otherwise
|
|
2
|
+
import type { CSSProperties, ComponentType, ReactNode } from 'react'
|
|
3
|
+
import { UI, resolveExport } from './resolve'
|
|
4
|
+
|
|
5
|
+
/** The real Code when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
6
|
+
const InstalledCode = resolveExport<ComponentType<{
|
|
7
|
+
size?: number
|
|
8
|
+
style?: CSSProperties
|
|
9
|
+
children: ReactNode
|
|
10
|
+
}>>(UI, 'Code')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Styling for the fallback Code element, approximating @sanity/ui's `size={1}`.
|
|
14
|
+
* `display: block` is included deliberately — `<code>` is inline by default while
|
|
15
|
+
* upstream's Code renders a block, so omitting it would concatenate every line.
|
|
16
|
+
*/
|
|
17
|
+
const FALLBACK_CODE_STYLE: CSSProperties = {
|
|
18
|
+
display: 'block',
|
|
19
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
20
|
+
fontSize: '0.8125rem',
|
|
21
|
+
lineHeight: 1.4,
|
|
22
|
+
margin: 0,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Monospace block. Uses Studio's Code where available, a plain `<code>` on @sanity/ui v4+. */
|
|
26
|
+
export function Code({ style, children }: { style?: CSSProperties; children: ReactNode }): React.JSX.Element {
|
|
27
|
+
if (InstalledCode) return <InstalledCode size={1} style={style}>{children}</InstalledCode>
|
|
28
|
+
return <code style={{ ...FALLBACK_CODE_STYLE, ...style }}>{children}</code>
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Single entry point for every @sanity/ui value this plugin uses, resolved against the installed major
|
|
2
|
+
export {
|
|
3
|
+
Box, Card, Flex, Grid, Text, Heading, Label, Badge, Spinner,
|
|
4
|
+
Button, TextInput, Select, Switch, Dialog, Stack,
|
|
5
|
+
} from './primitives'
|
|
6
|
+
export type { StackProps } from './primitives'
|
|
7
|
+
export { useToast, ToastViewport } from './toast'
|
|
8
|
+
export type { ToastParams, Toaster } from './toast'
|
|
9
|
+
export { Tooltip } from './tooltip'
|
|
10
|
+
export type { TooltipProps } from './tooltip'
|
|
11
|
+
export { ActionMenu } from './menu'
|
|
12
|
+
export type { ActionMenuProps, MenuAction } from './menu'
|
|
13
|
+
export { Code } from './code'
|
|
14
|
+
export { STACK_USES_GAP } from './resolve'
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Overflow menu — Studio's MenuButton where available, a WAI-ARIA menu button implementation otherwise
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
3
|
+
import type { ComponentType, SVGProps } from 'react'
|
|
4
|
+
import { UI, resolveExport } from './resolve'
|
|
5
|
+
import { Button, Card, Flex, Stack, Text } from './primitives'
|
|
6
|
+
|
|
7
|
+
/** An icon component, matching what this plugin's icon shim produces. */
|
|
8
|
+
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
|
9
|
+
|
|
10
|
+
/** One entry in an ActionMenu. Exactly one of `onClick` or `href` drives the behaviour. */
|
|
11
|
+
export type MenuAction =
|
|
12
|
+
| { key?: string; text: string; icon: IconComponent; tone?: 'critical'; onClick: () => void; href?: never }
|
|
13
|
+
| { key?: string; text: string; icon: IconComponent; tone?: 'critical'; href: string; onClick?: never }
|
|
14
|
+
|
|
15
|
+
/** Props for the compat ActionMenu — a declarative item list rather than nested JSX. */
|
|
16
|
+
export type ActionMenuProps = {
|
|
17
|
+
/** Stable DOM id for the trigger. */
|
|
18
|
+
id: string
|
|
19
|
+
/** Accessible name for the trigger, which is icon-only. */
|
|
20
|
+
label: string
|
|
21
|
+
items: MenuAction[]
|
|
22
|
+
buttonIcon: IconComponent
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const InstalledMenuButton = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'MenuButton')
|
|
26
|
+
const InstalledMenu = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'Menu')
|
|
27
|
+
const InstalledMenuItem = resolveExport<ComponentType<Record<string, unknown>>>(UI, 'MenuItem')
|
|
28
|
+
|
|
29
|
+
/** Whether the installed @sanity/ui still exports the full menu trio. */
|
|
30
|
+
const INSTALLED_MENU = InstalledMenuButton && InstalledMenu && InstalledMenuItem
|
|
31
|
+
? { MenuButton: InstalledMenuButton, Menu: InstalledMenu, MenuItem: InstalledMenuItem }
|
|
32
|
+
: null
|
|
33
|
+
|
|
34
|
+
/** Stable identity for an item, used for React keys and focus tracking. Labels can repeat; keys should not. */
|
|
35
|
+
const itemKey = (item: MenuAction, index: number): string => item.key ?? `${index}-${item.text}`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Overflow menu. Uses Studio's MenuButton where available and otherwise implements
|
|
39
|
+
* the WAI-ARIA menu button pattern locally: focus moves into the menu on open,
|
|
40
|
+
* Arrow/Home/End move between items with a roving tabindex, Escape and Tab close
|
|
41
|
+
* and return focus to the trigger.
|
|
42
|
+
*
|
|
43
|
+
* The item list is snapshotted while the menu is open, so background polling
|
|
44
|
+
* cannot insert or remove rows under the pointer.
|
|
45
|
+
*/
|
|
46
|
+
export function ActionMenu({ id, label, items, buttonIcon }: ActionMenuProps): React.JSX.Element {
|
|
47
|
+
const menuId = useId()
|
|
48
|
+
const [open, setOpen] = useState(false)
|
|
49
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
50
|
+
const wrapRef = useRef<HTMLDivElement>(null)
|
|
51
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
52
|
+
const itemRefs = useRef<(HTMLElement | null)[]>([])
|
|
53
|
+
|
|
54
|
+
// Snapshot taken at open time — the live `items` array is rebuilt on every poll.
|
|
55
|
+
const [frozenItems, setFrozenItems] = useState<MenuAction[]>(items)
|
|
56
|
+
const shownItems = open ? frozenItems : items
|
|
57
|
+
|
|
58
|
+
/** Close the menu and hand focus back to the trigger, as the menu button pattern requires. */
|
|
59
|
+
const close = useCallback((returnFocus = true) => {
|
|
60
|
+
setOpen(false)
|
|
61
|
+
if (returnFocus) triggerRef.current?.focus()
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const openMenu = useCallback((index: number) => {
|
|
65
|
+
setFrozenItems(items)
|
|
66
|
+
setActiveIndex(index)
|
|
67
|
+
setOpen(true)
|
|
68
|
+
}, [items])
|
|
69
|
+
|
|
70
|
+
// Move DOM focus to follow the active item while the menu is open.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!open || INSTALLED_MENU) return
|
|
73
|
+
itemRefs.current[activeIndex]?.focus()
|
|
74
|
+
}, [open, activeIndex])
|
|
75
|
+
|
|
76
|
+
// Fallback only — dismiss on outside pointer down. Escape and Tab are handled on the menu itself
|
|
77
|
+
// so they do not swallow keys belonging to any dialog the tool has open.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (INSTALLED_MENU || !open) return
|
|
80
|
+
const onPointerDown = (e: MouseEvent) => {
|
|
81
|
+
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
|
82
|
+
}
|
|
83
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
84
|
+
return () => document.removeEventListener('mousedown', onPointerDown)
|
|
85
|
+
}, [open])
|
|
86
|
+
|
|
87
|
+
const runAction = useCallback((item: MenuAction) => {
|
|
88
|
+
close()
|
|
89
|
+
item.onClick?.()
|
|
90
|
+
}, [close])
|
|
91
|
+
|
|
92
|
+
if (INSTALLED_MENU) {
|
|
93
|
+
const { MenuButton, Menu, MenuItem } = INSTALLED_MENU
|
|
94
|
+
return (
|
|
95
|
+
<MenuButton
|
|
96
|
+
id={id}
|
|
97
|
+
button={<Button mode="ghost" icon={buttonIcon} padding={2} aria-label={label} />}
|
|
98
|
+
popover={{ placement: 'bottom-end' }}
|
|
99
|
+
menu={
|
|
100
|
+
<Menu>
|
|
101
|
+
{items.map((item, i) => (
|
|
102
|
+
<MenuItem
|
|
103
|
+
key={itemKey(item, i)}
|
|
104
|
+
text={item.text}
|
|
105
|
+
icon={item.icon}
|
|
106
|
+
tone={item.tone}
|
|
107
|
+
{...(item.href
|
|
108
|
+
? { as: 'a', href: item.href, target: '_blank', rel: 'noreferrer' }
|
|
109
|
+
: { onClick: item.onClick })}
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
</Menu>
|
|
113
|
+
}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Arrow/Home/End/Escape/Tab handling for the open menu, per the WAI-ARIA menu button pattern. */
|
|
119
|
+
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
120
|
+
const last = shownItems.length - 1
|
|
121
|
+
if (e.key === 'Escape') { e.stopPropagation(); close(); return }
|
|
122
|
+
if (e.key === 'Tab') { close(false); return }
|
|
123
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => (i >= last ? 0 : i + 1)); return }
|
|
124
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => (i <= 0 ? last : i - 1)); return }
|
|
125
|
+
if (e.key === 'Home') { e.preventDefault(); setActiveIndex(0); return }
|
|
126
|
+
if (e.key === 'End') { e.preventDefault(); setActiveIndex(last); return }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
130
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openMenu(0) }
|
|
131
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); openMenu(items.length - 1) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div ref={wrapRef} style={{ position: 'relative' }}>
|
|
136
|
+
<Button
|
|
137
|
+
ref={triggerRef}
|
|
138
|
+
mode="ghost"
|
|
139
|
+
icon={buttonIcon}
|
|
140
|
+
padding={2}
|
|
141
|
+
id={id}
|
|
142
|
+
aria-label={label}
|
|
143
|
+
aria-haspopup="menu"
|
|
144
|
+
aria-expanded={open}
|
|
145
|
+
aria-controls={open ? menuId : undefined}
|
|
146
|
+
onClick={() => (open ? close() : openMenu(0))}
|
|
147
|
+
onKeyDown={onTriggerKeyDown}
|
|
148
|
+
/>
|
|
149
|
+
{open && (
|
|
150
|
+
<Card
|
|
151
|
+
id={menuId}
|
|
152
|
+
radius={2}
|
|
153
|
+
shadow={3}
|
|
154
|
+
padding={1}
|
|
155
|
+
role="menu"
|
|
156
|
+
aria-labelledby={id}
|
|
157
|
+
onKeyDown={onMenuKeyDown}
|
|
158
|
+
style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 1000, minWidth: 200 }}
|
|
159
|
+
>
|
|
160
|
+
{/* role="none" so the menu still directly owns its menuitem children. */}
|
|
161
|
+
<Stack space={1} role="none">
|
|
162
|
+
{shownItems.map((item, i) => {
|
|
163
|
+
const Icon = item.icon
|
|
164
|
+
const row = (
|
|
165
|
+
<Flex align="center" gap={2} paddingX={2} paddingY={2}>
|
|
166
|
+
<Icon width="1em" height="1em" aria-hidden="true" />
|
|
167
|
+
<Text size={1}>{item.text}</Text>
|
|
168
|
+
</Flex>
|
|
169
|
+
)
|
|
170
|
+
// A destructive item is wrapped in a critical-tone Card so it picks up Sanity's
|
|
171
|
+
// validated foreground/background pairing rather than a hand-picked colour.
|
|
172
|
+
const content = item.tone === 'critical'
|
|
173
|
+
? <Card tone="critical" radius={1}>{row}</Card>
|
|
174
|
+
: row
|
|
175
|
+
const shared = {
|
|
176
|
+
role: 'menuitem',
|
|
177
|
+
// Roving tabindex — the menu is one tab stop, arrows move within it.
|
|
178
|
+
tabIndex: i === activeIndex ? 0 : -1,
|
|
179
|
+
ref: (el: HTMLElement | null) => { itemRefs.current[i] = el },
|
|
180
|
+
onMouseEnter: () => setActiveIndex(i),
|
|
181
|
+
style: {
|
|
182
|
+
display: 'block',
|
|
183
|
+
width: '100%',
|
|
184
|
+
textAlign: 'left' as const,
|
|
185
|
+
cursor: 'pointer',
|
|
186
|
+
borderRadius: 3,
|
|
187
|
+
background: 'none',
|
|
188
|
+
border: 0,
|
|
189
|
+
padding: 0,
|
|
190
|
+
font: 'inherit',
|
|
191
|
+
color: 'inherit',
|
|
192
|
+
textDecoration: 'none',
|
|
193
|
+
// Card suppresses its own focus ring, so the active item draws one explicitly.
|
|
194
|
+
outline: i === activeIndex ? '2px solid var(--card-focus-ring-color, currentColor)' : 'none',
|
|
195
|
+
outlineOffset: -2,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
return item.href ? (
|
|
199
|
+
<a
|
|
200
|
+
key={itemKey(item, i)}
|
|
201
|
+
{...shared}
|
|
202
|
+
href={item.href}
|
|
203
|
+
target="_blank"
|
|
204
|
+
rel="noreferrer"
|
|
205
|
+
onClick={() => close(false)}
|
|
206
|
+
>
|
|
207
|
+
{content}
|
|
208
|
+
</a>
|
|
209
|
+
) : (
|
|
210
|
+
<button
|
|
211
|
+
key={itemKey(item, i)}
|
|
212
|
+
{...shared}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => runAction(item)}
|
|
215
|
+
>
|
|
216
|
+
{content}
|
|
217
|
+
</button>
|
|
218
|
+
)
|
|
219
|
+
})}
|
|
220
|
+
</Stack>
|
|
221
|
+
</Card>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Layout and control primitives resolved from the installed @sanity/ui, with plain-DOM fallbacks
|
|
2
|
+
import { createElement, forwardRef } from 'react'
|
|
3
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
4
|
+
import type * as SanityUi from '@sanity/ui'
|
|
5
|
+
import { UI, resolveExport, STACK_USES_GAP } from './resolve'
|
|
6
|
+
|
|
7
|
+
/** Loose props for a resolved @sanity/ui primitive — upstream types vary by major. */
|
|
8
|
+
type AnyProps = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a last-resort component that renders a plain DOM element, used when the
|
|
12
|
+
* installed @sanity/ui no longer exports a name this plugin needs. It drops the
|
|
13
|
+
* design-system props it cannot honour so React does not warn about unknown
|
|
14
|
+
* attributes, and keeps children so the tool stays usable rather than blank.
|
|
15
|
+
*
|
|
16
|
+
* @param tag DOM element to render in place of the missing component.
|
|
17
|
+
*/
|
|
18
|
+
function domFallback(tag: string): ComponentType<AnyProps> {
|
|
19
|
+
const Fallback = forwardRef<HTMLElement, AnyProps>(function SanityUiFallback(props, ref) {
|
|
20
|
+
const { children, style, id, className, onClick, href, title, ...rest } = props
|
|
21
|
+
// Forward only attributes that are meaningful on a bare element.
|
|
22
|
+
const passthrough: AnyProps = { style, id, className, onClick, href, title, ref }
|
|
23
|
+
for (const key of ['role', 'type', 'value', 'checked', 'placeholder', 'disabled', 'onChange', 'onKeyDown']) {
|
|
24
|
+
if (key in rest) passthrough[key] = rest[key]
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(rest)) {
|
|
27
|
+
if (key.startsWith('aria-') || key.startsWith('data-')) passthrough[key] = rest[key]
|
|
28
|
+
}
|
|
29
|
+
return createElement(tag, passthrough, children as ReactNode)
|
|
30
|
+
})
|
|
31
|
+
Fallback.displayName = `SanityUiFallback(${tag})`
|
|
32
|
+
return Fallback as unknown as ComponentType<AnyProps>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve one @sanity/ui export, falling back to a DOM element when the installed
|
|
37
|
+
* major no longer provides it. Keeps a relocated export from turning into a
|
|
38
|
+
* module-evaluation failure that stops the whole Studio from booting.
|
|
39
|
+
*
|
|
40
|
+
* @param name Export name on the @sanity/ui barrel.
|
|
41
|
+
* @param tag DOM element to degrade to.
|
|
42
|
+
*/
|
|
43
|
+
function primitive(name: string, tag: string): ComponentType<AnyProps> {
|
|
44
|
+
return resolveExport<ComponentType<AnyProps>>(UI, name) ?? domFallback(tag)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* The value comes through the seam so a relocated export degrades instead of
|
|
49
|
+
* failing to link; the *type* is taken from the installed @sanity/ui so call
|
|
50
|
+
* sites keep full prop checking. If a future major tombstones one of these as
|
|
51
|
+
* `never` — as v4 did to Tooltip and Menu — the assertion makes every call site
|
|
52
|
+
* a build error rather than a silent runtime blank.
|
|
53
|
+
*/
|
|
54
|
+
export const Box = primitive('Box', 'div') as typeof SanityUi.Box
|
|
55
|
+
export const Card = primitive('Card', 'div') as typeof SanityUi.Card
|
|
56
|
+
export const Flex = primitive('Flex', 'div') as typeof SanityUi.Flex
|
|
57
|
+
export const Grid = primitive('Grid', 'div') as typeof SanityUi.Grid
|
|
58
|
+
export const Text = primitive('Text', 'span') as typeof SanityUi.Text
|
|
59
|
+
export const Heading = primitive('Heading', 'h2') as typeof SanityUi.Heading
|
|
60
|
+
export const Label = primitive('Label', 'label') as typeof SanityUi.Label
|
|
61
|
+
export const Badge = primitive('Badge', 'span') as typeof SanityUi.Badge
|
|
62
|
+
export const Spinner = primitive('Spinner', 'span') as typeof SanityUi.Spinner
|
|
63
|
+
export const Button = primitive('Button', 'button') as typeof SanityUi.Button
|
|
64
|
+
export const TextInput = primitive('TextInput', 'input') as typeof SanityUi.TextInput
|
|
65
|
+
export const Select = primitive('Select', 'select') as typeof SanityUi.Select
|
|
66
|
+
export const Switch = primitive('Switch', 'input') as typeof SanityUi.Switch
|
|
67
|
+
export const Dialog = primitive('Dialog', 'div') as unknown as typeof SanityUi.Dialog
|
|
68
|
+
|
|
69
|
+
const SanityStack = primitive('Stack', 'div')
|
|
70
|
+
|
|
71
|
+
/** Props for the compat Stack. Mirrors the upstream surface this plugin uses. */
|
|
72
|
+
export type StackProps = {
|
|
73
|
+
/** Spacing step on Sanity's scale, forwarded as `gap` or `space` per installed major. */
|
|
74
|
+
space?: number
|
|
75
|
+
padding?: number
|
|
76
|
+
paddingX?: number
|
|
77
|
+
paddingY?: number
|
|
78
|
+
flex?: number
|
|
79
|
+
style?: React.CSSProperties
|
|
80
|
+
className?: string
|
|
81
|
+
role?: string
|
|
82
|
+
children?: ReactNode
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Vertical stack. Forwards `space` on @sanity/ui v2 and v3 and `gap` on v4+, so
|
|
87
|
+
* one call site spells spacing correctly on either major. See STACK_USES_GAP for
|
|
88
|
+
* how the two are told apart.
|
|
89
|
+
*/
|
|
90
|
+
export function Stack({ space, children, ...rest }: StackProps): React.JSX.Element {
|
|
91
|
+
const spacing = space === undefined ? {} : STACK_USES_GAP ? { gap: space } : { space }
|
|
92
|
+
return <SanityStack {...rest} {...spacing}>{children}</SanityStack>
|
|
93
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Reads the installed @sanity/ui and @sanity/icons namespaces so relocated exports degrade instead of failing to link
|
|
2
|
+
import * as sanityUi from '@sanity/ui'
|
|
3
|
+
import * as sanityIcons from '@sanity/icons'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The installed namespaces, read through an index signature.
|
|
7
|
+
*
|
|
8
|
+
* Both packages have split their barrels: @sanity/icons v5 removed the named
|
|
9
|
+
* `*Icon` exports, and @sanity/ui v4 moved Tooltip, Menu, MenuButton, MenuItem,
|
|
10
|
+
* Code, Popover and useToast into subpath entry points. Those subpaths do not
|
|
11
|
+
* exist on the earlier majors this plugin supports, so neither import shape works
|
|
12
|
+
* across the range.
|
|
13
|
+
*
|
|
14
|
+
* Both packages also still *declare* the moved names in their `.d.ts` — typed
|
|
15
|
+
* `never`, with a deprecation note — so a static named import type-checks at the
|
|
16
|
+
* import site and only fails when the value is used or evaluated. Reading the
|
|
17
|
+
* namespace turns a link-time failure into a value this code can branch on.
|
|
18
|
+
*
|
|
19
|
+
* Verified through Vite/Rollup: aliasing the namespace into a binding is what
|
|
20
|
+
* forces bundlers to materialise a real namespace object rather than rewriting
|
|
21
|
+
* member access into named bindings. Do not inline these back into direct
|
|
22
|
+
* `sanityUi.x` access without re-checking the emitted bundle.
|
|
23
|
+
*/
|
|
24
|
+
export const UI = sanityUi as unknown as Record<string, unknown>
|
|
25
|
+
export const ICONS = sanityIcons as unknown as Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Look up one export by name, returning undefined when the installed major no
|
|
29
|
+
* longer provides it.
|
|
30
|
+
*
|
|
31
|
+
* @param ns Namespace to read — {@link UI} or {@link ICONS}.
|
|
32
|
+
* @param name Exact export name, e.g. `Tooltip`.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveExport<T>(ns: Record<string, unknown>, name: string): T | undefined {
|
|
35
|
+
const value = ns[name]
|
|
36
|
+
// A deprecation tombstone can be present but not callable; only accept usable values.
|
|
37
|
+
return typeof value === 'function' || (typeof value === 'object' && value !== null)
|
|
38
|
+
? (value as T)
|
|
39
|
+
: undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the installed @sanity/ui expects `gap` rather than `space` on Stack.
|
|
44
|
+
*
|
|
45
|
+
* v4 renamed the prop, typed the old name `never`, and ignores it at runtime —
|
|
46
|
+
* so guessing wrong collapses every vertical gap silently. It also rewrote Stack
|
|
47
|
+
* from a `forwardRef` component into a plain function generic, in the same
|
|
48
|
+
* release. Probing the shape of Stack itself keeps the signal attached to the
|
|
49
|
+
* component whose prop is being chosen, rather than to an unrelated export.
|
|
50
|
+
*
|
|
51
|
+
* Verified: forwardRef object on @sanity/ui 2.16.27 and 3.5.3, plain function on
|
|
52
|
+
* 4.0.5. Passing both prop names is not an option — `gap` leaks to the DOM as a
|
|
53
|
+
* stray attribute on v2, and `space` leaks on v4.
|
|
54
|
+
*/
|
|
55
|
+
export const STACK_USES_GAP = typeof (UI.Stack as unknown) === 'function'
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Toast delivery — Studio's own toast system where available, a local live-region viewport otherwise
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { UI, resolveExport } from './resolve'
|
|
5
|
+
import { Box, Button, Card, Flex, Stack, Text } from './primitives'
|
|
6
|
+
import { CloseIcon } from '../icons'
|
|
7
|
+
|
|
8
|
+
/** A toast request — the subset of @sanity/ui's ToastParams this plugin uses. */
|
|
9
|
+
export type ToastParams = {
|
|
10
|
+
status?: 'success' | 'error' | 'warning' | 'info'
|
|
11
|
+
title?: ReactNode
|
|
12
|
+
description?: ReactNode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The object returned by useToast. Only `push` is used here. */
|
|
16
|
+
export type Toaster = { push: (params: ToastParams) => void }
|
|
17
|
+
|
|
18
|
+
/** A queued fallback toast. `id` is a monotonic counter, unique for the session. */
|
|
19
|
+
type LocalToast = ToastParams & { id: number }
|
|
20
|
+
|
|
21
|
+
/** How long a non-error fallback toast stays on screen, in milliseconds. */
|
|
22
|
+
const LOCAL_TOAST_MS = 6000
|
|
23
|
+
|
|
24
|
+
/** Most toasts kept on screen at once; older ones are dropped so the column cannot grow past the viewport. */
|
|
25
|
+
const MAX_VISIBLE_TOASTS = 4
|
|
26
|
+
|
|
27
|
+
/** Stacking index for the fallback viewport. High enough to clear Studio chrome, since it is not in Sanity's Layer stack. */
|
|
28
|
+
const TOAST_Z_INDEX = 1000000
|
|
29
|
+
|
|
30
|
+
/** Card tone per toast status, used only by the fallback viewport. */
|
|
31
|
+
const LOCAL_TOAST_TONE: Record<string, 'positive' | 'critical' | 'caution' | 'primary'> = {
|
|
32
|
+
success: 'positive',
|
|
33
|
+
error: 'critical',
|
|
34
|
+
warning: 'caution',
|
|
35
|
+
info: 'primary',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Statuses that stay until dismissed — they carry text the user is expected to act on. */
|
|
39
|
+
const PERSISTENT_STATUSES = new Set(['error', 'warning'])
|
|
40
|
+
|
|
41
|
+
let nextToastId = 0
|
|
42
|
+
let localToasts: LocalToast[] = []
|
|
43
|
+
const toastListeners = new Set<(toasts: LocalToast[]) => void>()
|
|
44
|
+
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>()
|
|
45
|
+
|
|
46
|
+
/** Publish the current fallback queue to every mounted viewport. */
|
|
47
|
+
function emitToasts(): void {
|
|
48
|
+
for (const listener of toastListeners) listener(localToasts)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remove one fallback toast and cancel its pending auto-dismiss. */
|
|
52
|
+
function removeLocalToast(id: number): void {
|
|
53
|
+
const timer = dismissTimers.get(id)
|
|
54
|
+
if (timer) {
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
dismissTimers.delete(id)
|
|
57
|
+
}
|
|
58
|
+
localToasts = localToasts.filter(t => t.id !== id)
|
|
59
|
+
emitToasts()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Start (or restart) the auto-dismiss countdown for one toast. Persistent statuses are left alone. */
|
|
63
|
+
function scheduleDismiss(id: number, status: ToastParams['status']): void {
|
|
64
|
+
if (PERSISTENT_STATUSES.has(status ?? 'info')) return
|
|
65
|
+
const existing = dismissTimers.get(id)
|
|
66
|
+
if (existing) clearTimeout(existing)
|
|
67
|
+
dismissTimers.set(id, setTimeout(() => removeLocalToast(id), LOCAL_TOAST_MS))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Queue a fallback toast, trimming the oldest if the column is already full. */
|
|
71
|
+
function pushLocalToast(params: ToastParams): void {
|
|
72
|
+
const toast: LocalToast = { ...params, id: nextToastId++ }
|
|
73
|
+
localToasts = [...localToasts, toast].slice(-MAX_VISIBLE_TOASTS)
|
|
74
|
+
emitToasts()
|
|
75
|
+
scheduleDismiss(toast.id, toast.status)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The real hook when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
79
|
+
const installedUseToast = resolveExport<() => Toaster>(UI, 'useToast')
|
|
80
|
+
|
|
81
|
+
/** Stable fallback toaster — identity never changes, so it is safe in dependency arrays. */
|
|
82
|
+
const localToaster: Toaster = { push: pushLocalToast }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Push toasts through Studio's toast system where available, or through
|
|
86
|
+
* {@link ToastViewport} on @sanity/ui v4+.
|
|
87
|
+
*
|
|
88
|
+
* The branch is decided once at module load, so the hook-call order of any
|
|
89
|
+
* component using this is constant across renders.
|
|
90
|
+
*/
|
|
91
|
+
export function useToast(): Toaster {
|
|
92
|
+
const real = installedUseToast
|
|
93
|
+
if (real) return real()
|
|
94
|
+
return localToaster
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Live region for fallback toasts.
|
|
99
|
+
*
|
|
100
|
+
* The wrapper stays mounted and empty when there is nothing to show: assistive
|
|
101
|
+
* tech has to observe a live region *before* content lands in it, so a region
|
|
102
|
+
* created and populated in the same commit is routinely missed. Renders nothing
|
|
103
|
+
* at all when the installed @sanity/ui provides its own toast system.
|
|
104
|
+
*/
|
|
105
|
+
export function ToastViewport(): React.JSX.Element | null {
|
|
106
|
+
const [toasts, setToasts] = useState<LocalToast[]>(localToasts)
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (installedUseToast) return
|
|
110
|
+
toastListeners.add(setToasts)
|
|
111
|
+
// Re-read after subscribing: a toast pushed between render and commit would otherwise be missed.
|
|
112
|
+
setToasts(localToasts)
|
|
113
|
+
return () => { toastListeners.delete(setToasts) }
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const hold = useCallback((id: number) => {
|
|
117
|
+
const timer = dismissTimers.get(id)
|
|
118
|
+
if (timer) {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
dismissTimers.delete(id)
|
|
121
|
+
}
|
|
122
|
+
}, [])
|
|
123
|
+
|
|
124
|
+
if (installedUseToast) return null
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Box
|
|
128
|
+
role="status"
|
|
129
|
+
aria-live="polite"
|
|
130
|
+
aria-atomic={false}
|
|
131
|
+
// pointer-events is released so the fixed column cannot swallow clicks on the tool beneath it.
|
|
132
|
+
style={{
|
|
133
|
+
position: 'fixed',
|
|
134
|
+
bottom: 16,
|
|
135
|
+
right: 16,
|
|
136
|
+
zIndex: TOAST_Z_INDEX,
|
|
137
|
+
width: 'min(360px, calc(100vw - 32px))',
|
|
138
|
+
pointerEvents: 'none',
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
<Stack space={2}>
|
|
142
|
+
{toasts.map(toast => (
|
|
143
|
+
<Card
|
|
144
|
+
key={toast.id}
|
|
145
|
+
padding={3}
|
|
146
|
+
radius={2}
|
|
147
|
+
shadow={3}
|
|
148
|
+
tone={LOCAL_TOAST_TONE[toast.status ?? 'info'] ?? 'primary'}
|
|
149
|
+
style={{ pointerEvents: 'auto' }}
|
|
150
|
+
onMouseEnter={() => hold(toast.id)}
|
|
151
|
+
onFocusCapture={() => hold(toast.id)}
|
|
152
|
+
onMouseLeave={() => scheduleDismiss(toast.id, toast.status)}
|
|
153
|
+
>
|
|
154
|
+
<Flex align="flex-start" gap={3}>
|
|
155
|
+
<Stack space={2} flex={1}>
|
|
156
|
+
{toast.title && <Text size={1} weight="semibold">{toast.title}</Text>}
|
|
157
|
+
{toast.description && <Text size={1} muted>{toast.description}</Text>}
|
|
158
|
+
</Stack>
|
|
159
|
+
<Button
|
|
160
|
+
mode="bleed"
|
|
161
|
+
padding={2}
|
|
162
|
+
icon={CloseIcon}
|
|
163
|
+
text=""
|
|
164
|
+
aria-label="Dismiss notification"
|
|
165
|
+
onClick={() => removeLocalToast(toast.id)}
|
|
166
|
+
/>
|
|
167
|
+
</Flex>
|
|
168
|
+
</Card>
|
|
169
|
+
))}
|
|
170
|
+
</Stack>
|
|
171
|
+
</Box>
|
|
172
|
+
)
|
|
173
|
+
}
|