@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "1.1.0",
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
- "source": "./src/index.ts",
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": ">=3",
43
- "@sanity/ui": ">=2",
42
+ "@sanity/icons": ">=2 <6",
43
+ "@sanity/ui": ">=2 <5",
44
44
  "react": ">=18",
45
- "sanity": ">=3"
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
+ }