Widget playground mobile mode
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled

This commit is contained in:
Konstantin Wohlwend 2025-07-14 20:04:41 -07:00
parent c93086c379
commit fe5db59c30
11 changed files with 322 additions and 94 deletions

View File

@ -12,3 +12,5 @@ NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# For prod, leave it empty. For local developm
NEXT_PUBLIC_STACK_HEAD_TAGS='[{ "tagName": "script", "attributes": {}, "innerHTML": "// insert head tags here" }]'
STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)

View File

@ -6,3 +6,5 @@ STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8113
STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=true

View File

@ -1,6 +1,8 @@
"use client";
import { PacificaCard } from '@/components/pacifica/card';
import { DndContext, pointerWithin, useDraggable, useDroppable } from '@dnd-kit/core';
import useResizeObserver from '@react-hook/resize-observer';
import { range } from '@stackframe/stack-shared/dist/utils/arrays';
import { StackAssertionError, errorToNiceString, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
@ -16,7 +18,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { FaBorderNone, FaPen, FaPlus, FaTrash } from 'react-icons/fa';
import * as jsxRuntime from 'react/jsx-runtime';
import { PageLayout } from "../page-layout";
import { useAdminApp } from '../use-admin-app';
type SerializedWidget = {
version: 1,
@ -51,11 +52,15 @@ async function compileWidgetSource(source: string): Promise<Result<string, strin
function createErrorWidget(id: string, errorMessage: string): Widget<any, any> {
return {
id,
MainComponent: () => <Card style={{ inset: '0', position: 'absolute', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{errorMessage}
</div>
</Card>,
MainComponent: () => (
<PacificaCard
style={{ inset: '0', position: 'absolute', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}
>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{errorMessage}
</div>
</PacificaCard>
),
defaultSettings: null as any,
defaultState: null as any,
};
@ -90,7 +95,7 @@ async function deserializeWidget(serializedWidget: SerializedWidget): Promise<Wi
type Widget<Settings, State> = {
id: string,
MainComponent: React.ComponentType<{ settings: Settings, state: State, stateRef: ReadonlyRef<State>, setState: (updater: (state: State) => State) => void, widthInGridUnits: number, heightInGridUnits: number }>,
MainComponent: React.ComponentType<{ settings: Settings, state: State, stateRef: ReadonlyRef<State>, setState: (updater: (state: State) => State) => void, widthInGridUnits: number, heightInGridUnits: number, isMobileMode: boolean }>,
SettingsComponent?: React.ComponentType<{ settings: Settings, setSettings: (updater: (settings: Settings) => Settings) => void }>,
defaultSettings: Settings,
defaultState: State,
@ -153,13 +158,15 @@ class WidgetInstanceGrid {
const width = options.width ?? 24;
const height = options.height ?? "auto";
const nonEmptyElements = widgetInstances.map((instance, index) => ({
instance,
x: (index * WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH) % width,
y: Math.floor(index / Math.floor(width / WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH)) * WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
width: WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH,
height: WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
}));
const nonEmptyElements = widgetInstances
.map((instance, index) => ({
instance,
x: (index * WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH) % width,
y: Math.floor(index / Math.floor(width / WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH)) * WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
width: WidgetInstanceGrid.DEFAULT_ELEMENT_WIDTH,
height: WidgetInstanceGrid.DEFAULT_ELEMENT_HEIGHT,
}))
.sort((a, b) => Math.sign(a.x - b.x) + 0.1 * Math.sign(a.y - b.y));
// Do some sanity checks to prevent bugs early
for (const element of nonEmptyElements) {
@ -470,8 +477,8 @@ class WidgetInstanceGrid {
edgesDelta.bottom !== 0 ? this.clampResize(x, y, { ...edgesDelta, bottom: decr(edgesDelta.bottom) }) : null,
edgesDelta.right !== 0 ? this.clampResize(x, y, { ...edgesDelta, right: decr(edgesDelta.right) }) : null,
].filter(isNotNull);
let maxScore = -1;
let bestCandidate: { top: number, left: number, bottom: number, right: number } | null = null;
let maxScore = 0;
let bestCandidate: { top: number, left: number, bottom: number, right: number } = { top: 0, left: 0, bottom: 0, right: 0 };
for (const candidate of candidates) {
const score = Math.abs(candidate.top) + Math.abs(candidate.left) + Math.abs(candidate.bottom) + Math.abs(candidate.right);
if (score > maxScore) {
@ -479,9 +486,6 @@ class WidgetInstanceGrid {
bestCandidate = candidate;
}
}
if (!bestCandidate) {
throw new StackAssertionError(`No candidate found for ${cacheKey}`);
}
this._clampResizeCache.set(cacheKey, bestCandidate);
}
}
@ -554,7 +558,7 @@ class WidgetInstanceGrid {
const widgets: Widget<any, any>[] = [
{
id: "$sub-grid",
MainComponent: ({ widthInGridUnits, heightInGridUnits, state, stateRef, setState }) => {
MainComponent: ({ widthInGridUnits, heightInGridUnits, state, stateRef, setState, isMobileMode }) => {
const widgetGridRef = mapRef(stateRef, (state) => WidgetInstanceGrid.fromSerialized(state.serializedGrid));
const [color] = useState("#" + Math.floor(Math.random() * 16777215).toString(16) + "22");
@ -575,6 +579,7 @@ const widgets: Widget<any, any>[] = [
return (
<div style={{ backgroundColor: color, padding: '16px' }}>
<SwappableWidgetInstanceGrid
isMobileMode={isMobileMode}
gridRef={widgetGridRef}
setGrid={setWidgetGrid}
/>
@ -615,11 +620,13 @@ const widgets: Widget<any, any>[] = [
export const defaultSettings = {name: "world"};
`);
const stackAdminApp = useAdminApp();
const [compilationResult, setCompilationResult] = useState<Result<string, string> | null>(null);
return (
<Card style={{ inset: '0', position: 'absolute' }}>
<PacificaCard
title="Widget builder"
subtitle="This is a subtitle"
>
<textarea value={source} onChange={(e) => setSource(e.target.value)} style={{ width: '100%', height: '35%', fontFamily: "monospace" }} />
<Button onClick={async () => {
const result = await compileWidgetSource(source);
@ -644,7 +651,7 @@ const widgets: Widget<any, any>[] = [
{compilationResult.error}
</div>
)}
</Card>
</PacificaCard>
);
},
defaultSettings: {},
@ -652,12 +659,18 @@ const widgets: Widget<any, any>[] = [
},
];
const widgetInstances: WidgetInstance<any>[] = [];
const gridGapPixels = 32;
const mobileModeWidgetHeight = 384;
const mobileModeCutoffWidth = 768;
export default function PageClient() {
const [widgetGridRef, setWidgetGrid] = useInstantState(WidgetInstanceGrid.fromWidgetInstances(widgetInstances));
const [widgetGridRef, setWidgetGrid] = useInstantState(WidgetInstanceGrid.fromWidgetInstances(widgets.map((w, i) => ({
id: "initial" + i,
settingsOrUndefined: undefined,
stateOrUndefined: undefined,
widget: w,
}))));
const [isAltDown, setIsAltDown] = useState(false);
useEffect(() => {
@ -685,7 +698,7 @@ export default function PageClient() {
fillWidth
>
<SwappableWidgetInstanceGridContext.Provider value={{ isEditing: isAltDown }}>
<SwappableWidgetInstanceGrid gridRef={widgetGridRef} setGrid={setWidgetGrid} />
<SwappableWidgetInstanceGrid gridRef={widgetGridRef} setGrid={setWidgetGrid} isMobileMode="auto" />
</SwappableWidgetInstanceGridContext.Provider>
</PageLayout>
);
@ -697,12 +710,24 @@ const SwappableWidgetInstanceGridContext = React.createContext<{
isEditing: false,
});
function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanceGrid>, setGrid: (grid: WidgetInstanceGrid) => void }) {
function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanceGrid>, setGrid: (grid: WidgetInstanceGrid) => void, isMobileMode: boolean | "auto" }) {
const [overElementPosition, setOverElementPosition] = useState<[number, number] | null>(null);
const [hoverSwap, setHoverSwap] = useState<[string, [number, number, number, number, number, number]] | null>(null);
const [activeWidgetId, setActiveWidgetId] = useState<string | null>(null);
const gridContainerRef = useRef<HTMLDivElement>(null);
const context = React.use(SwappableWidgetInstanceGridContext);
const [isMobileModeIfAuto, setMobileModeIfAuto] = useState<boolean>(false);
useResizeObserver(gridContainerRef, (entry, observer) => {
const shouldBeMobileMode = entry.contentRect.width < mobileModeCutoffWidth;
if (isMobileModeIfAuto !== shouldBeMobileMode) {
setMobileModeIfAuto(shouldBeMobileMode);
}
});
const isMobileMode = props.isMobileMode === "auto" ? isMobileModeIfAuto : props.isMobileMode;
let hasAlreadyRenderedEmpty = false;
return (
<DndContext
@ -771,21 +796,35 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
<div
ref={gridContainerRef}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${props.gridRef.current.width}, 1fr)`,
gridTemplateRows: `repeat(${props.gridRef.current.height}, 1fr)`,
...isMobileMode ? {
display: 'flex',
flexDirection: 'column',
} : {
display: 'grid',
gridTemplateColumns: `repeat(${props.gridRef.current.width}, 1fr)`,
gridTemplateRows: `repeat(${props.gridRef.current.height}, 1fr)`,
},
gap: gridGapPixels,
userSelect: 'none',
WebkitUserSelect: 'none',
overflow: 'none',
}}
>
{range(props.gridRef.current.height).map((y) => (
{!isMobileMode && range(props.gridRef.current.height).map((y) => (
<div key={y} style={{ height: '16px', gridColumn: `1 / ${props.gridRef.current.width + 1}`, gridRow: `${y + 1} / ${y + 2}` }} />
))}
{[...props.gridRef.current].map(({ instance, x, y, width, height }) => {
const isHoverSwap = !!hoverSwap && !!instance && (hoverSwap[0] === instance.id);
if (isMobileMode && !instance) {
if (hasAlreadyRenderedEmpty) return null;
hasAlreadyRenderedEmpty = true;
}
return (
<Droppable
isMobileMode={isMobileMode}
key={instance?.id ?? JSON.stringify({ x, y })}
isEmpty={!instance}
isOver={overElementPosition?.[0] === x && overElementPosition[1] === y}
@ -808,6 +847,7 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
width: isHoverSwap ? `${hoverSwap[1][2]}px` : (hoverSwap && activeWidgetId === instance.id ? `${hoverSwap[1][4]}px` : undefined),
height: isHoverSwap ? `${hoverSwap[1][3]}px` : (hoverSwap && activeWidgetId === instance.id ? `${hoverSwap[1][5]}px` : undefined),
}}
isMobileMode={isMobileMode}
onDeleteWidget={async () => {
props.setGrid(props.gridRef.current.withRemoved(x, y));
}}
@ -853,7 +893,7 @@ function SwappableWidgetInstanceGrid(props: { gridRef: ReadonlyRef<WidgetInstanc
);
}
function Droppable(props: { isOver: boolean, children: React.ReactNode, style?: React.CSSProperties, x: number, y: number, width: number, height: number, isEmpty: boolean, grid: WidgetInstanceGrid, onAddWidget: () => void }) {
function Droppable(props: { isMobileMode: boolean, isOver: boolean, children: React.ReactNode, style?: React.CSSProperties, x: number, y: number, width: number, height: number, isEmpty: boolean, grid: WidgetInstanceGrid, onAddWidget: () => void }) {
const { setNodeRef, active } = useDroppable({
id: JSON.stringify([props.x, props.y]),
});
@ -870,18 +910,19 @@ function Droppable(props: { isOver: boolean, children: React.ReactNode, style?:
borderRadius: '8px',
gridColumn: `${props.x + 1} / span ${props.width}`,
gridRow: `${props.y + 1} / span ${props.height}`,
minHeight: props.isMobileMode ? mobileModeWidgetHeight : undefined,
...props.style,
}}
>
<style>{`
@keyframes stack-animation-fade-in {
0% {
opacity: 0;
@keyframes stack-animation-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
100% {
opacity: 1;
}
}
`}</style>
{shouldRenderAddWidget && (<>
<div
@ -918,6 +959,7 @@ function Draggable(props: {
height: number,
activeWidgetId: string | null,
isEditing: boolean,
isMobileMode: boolean,
onDeleteWidget: () => Promise<void>,
settings: any,
setSettings: (settings: any) => Promise<void>,
@ -1047,9 +1089,8 @@ function Draggable(props: {
ref={setNodeRef}
className="stack-recursive-backface-hidden"
style={{
position: 'absolute',
width: '100%',
height: '100%',
minWidth: '100%',
minHeight: '100%',
display: 'flex',
zIndex: isDragging ? 100000 : undefined,
@ -1067,8 +1108,14 @@ function Draggable(props: {
<div
className={cn(isDragging && 'bg-white dark:bg-black border-black/20 dark:border-white/20')}
style={{
position: 'absolute',
inset: 0,
...props.isMobileMode ? {
position: 'relative',
width: '100%',
height: '100%',
} : {
position: 'absolute',
inset: 0,
},
flexGrow: 1,
alignSelf: 'stretch',
boxShadow: isEditing ? '0 0 32px 0 #8882' : '0 0 0 0 transparent',
@ -1087,15 +1134,24 @@ function Draggable(props: {
isDeleting ? 'scale(0.8)' : '',
].filter(Boolean).join(' '),
opacity: isDeleting ? 0 : 1,
display: "flex",
flexDirection: "row",
}}
>
<div
data-pacifica-children-flex-grow
data-pacifica-children-min-width-0
style={{
flexGrow: 1,
display: "flex",
flexDirection: "row",
}}
>
<SwappableWidgetInstanceGridContext.Provider value={{ isEditing: isEditingSubGrid }}>
<props.widgetInstance.widget.MainComponent
settings={props.widgetInstance.settingsOrUndefined}
isMobileMode={props.isMobileMode}
state={props.state}
stateRef={props.stateRef}
setState={props.setState}
@ -1113,15 +1169,17 @@ function Draggable(props: {
transition: 'opacity 0.2s ease',
// note: Safari has a weird display glitch with transparent background images when animating opacity in a parent element, so we just don't render it while deleting
backgroundImage: !isDeleting ? 'radial-gradient(circle at top, #ffffff08, #ffffff02), radial-gradient(circle at top right, #ffffff04, transparent, transparent)' : undefined,
borderRadius: 'inherit',
}}
/>
<div
inert
className={cn(isEditing && !isDragging && "bg-white/20 dark:bg-black/20")}
className={cn(isEditing && !isDragging && "bg-white/50 dark:bg-black/50")}
style={{
position: 'absolute',
inset: 0,
backdropFilter: isEditing && !isDragging ? 'blur(4px)' : 'none',
backdropFilter: isEditing && !isDragging ? 'drop-shadow(0 0 2px) blur(4px)' : 'none',
borderRadius: 'inherit',
}}
/>
{!isDragging && (
@ -1182,7 +1240,7 @@ function Draggable(props: {
}}
/>
</div>
{[-1, 0, 1].flatMap(x => [-1, 0, 1].map(y => (x !== 0 || y !== 0) && (
{!props.isMobileMode && [-1, 0, 1].flatMap(x => [-1, 0, 1].map(y => (x !== 0 || y !== 0) && (
<ResizeHandle
key={`${x},${y}`}
widgetInstance={props.widgetInstance}

View File

@ -183,3 +183,28 @@ body:has(.show-site-loading-indicator) .site-loading-indicator {
transform: translateX(0%);
}
}
/* Pacifica styles */
[data-pacifica-surface] {
backdrop-filter: contrast(40%) blur(24px);
background-color: hsl(var(--background), 0.2);
color: hsl(var(--card-foreground));
}
.dark [data-pacifica-surface] {
background-image: radial-gradient(circle at top, #ffffff0d, #ffffff04), radial-gradient(circle at top right, #ffffff04, transparent, transparent);
}
[data-pacifica-border] {
border: 2px solid rgba(127, 127, 127, 0.2);
box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.02);
}
:where([data-pacifica-children-flex-grow] > *) {
flex-grow: 1;
}
:where([data-pacifica-children-min-width-0] > *) {
min-width: 0;
}

View File

@ -0,0 +1,131 @@
import { componentWrapper, forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { cn } from "../../lib/utils";
import { PacificaSurface } from "./surface";
const PacificaCard = componentWrapper<
typeof PacificaSurface,
{
title?: React.ReactNode,
subtitle?: React.ReactNode,
header?: React.ReactNode,
footer?: React.ReactNode,
innerProps?: Omit<React.ComponentPropsWithRef<"div">, "children">,
}
>("PacificaCard", ({ className, title, subtitle, header, footer, children, innerProps, ...props }, ref) => {
const fullHeader = (title || subtitle || header || footer) && <>
{header}
{title && (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight capitalize", className)}
>
{title}
</h3>
)}
{subtitle && (
<h4
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
>
{subtitle}
</h4>
)}
</>;
return (
<PacificaSurface
ref={ref}
className={cn(
"rounded-xl",
className
)}
{...filterUndefined(props)}
>
<div
className="p-6 overflow-y-auto rounded-[inherit] flex-grow-1"
data-pacifica-border
>
{fullHeader && (
<div className="flex flex-col space-y-0.5 pb-4">
{fullHeader}
</div>
)}
<div {...innerProps}>
{children}
</div>
</div>
</PacificaSurface>
);
});
PacificaCard.displayName = "PacificaCard";
const PacificaCardHeader = forwardRefIfNeeded<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6 pb-0", className)}
{...props}
/>
));
PacificaCardHeader.displayName = "PacificaCardHeader";
const PacificaCardTitle = forwardRefIfNeeded<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight capitalize", className)}
{...props}
/>
));
PacificaCardTitle.displayName = "PacificaCardTitle";
const PacificaCardDescription = forwardRefIfNeeded<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
PacificaCardDescription.displayName = "PacificaCardDescription";
const PacificaCardContent = forwardRefIfNeeded<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6", className)} {...props} />
));
PacificaCardContent.displayName = "PacificaCardContent";
const PacificaCardSubtitle = forwardRefIfNeeded<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<h4
ref={ref}
className={cn("text-sm text-muted-foreground font-bold", className)}
{...props}
/>
));
const PacificaCardFooter = forwardRefIfNeeded<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
PacificaCardFooter.displayName = "PacificaCardFooter";
export { PacificaCard, PacificaCardContent, PacificaCardDescription, PacificaCardFooter, PacificaCardHeader, PacificaCardSubtitle, PacificaCardTitle };

View File

@ -0,0 +1,26 @@
import { componentWrapper } from "@stackframe/stack-shared/dist/utils/react";
import { cn } from "../../../../../packages/stack-ui/dist/lib/utils";
export const PacificaSurface = componentWrapper<
"div",
{}
>("PacificaSurface", ({ children, className, ...props }, ref) => {
return (
<div
ref={ref}
data-pacifica-children-flex-grow
className={cn("relative flex flex-col stretch min-h-0", className)}
{...props}
>
<div
className="absolute inset-0 rounded-[inherit]"
style={{
zIndex: -9999999,
}}
inert
data-pacifica-surface
/>
{children}
</div>
);
});

View File

@ -75,6 +75,10 @@ export class StackAssertionError extends Error {
},
enumerable: false,
});
if (process.env.NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR === "true") {
debugger;
}
}
}
StackAssertionError.prototype.name = "StackAssertionError";

View File

@ -3,6 +3,17 @@ import { isBrowserLike } from "./env";
import { neverResolve } from "./promises";
import { deindent } from "./strings";
export function componentWrapper<
C extends React.ComponentType<any> | keyof React.JSX.IntrinsicElements,
ExtraProps extends {} = {}
>(displayName: string, render: React.ForwardRefRenderFunction<RefFromComponent<C>, React.ComponentPropsWithRef<C> & ExtraProps>) {
const Component = forwardRefIfNeeded(render);
Component.displayName = displayName;
return Component;
}
type RefFromComponent<C extends React.ComponentType<any> | keyof React.JSX.IntrinsicElements> = NonNullable<RefFromComponentDistCond<React.ComponentPropsWithRef<C>["ref"]>>;
type RefFromComponentDistCond<A> = A extends React.RefObject<infer T> ? T : never; // distributive conditional type; see https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
export function forwardRefIfNeeded<T, P = {}>(render: React.ForwardRefRenderFunction<T, P>): React.FC<P & { ref?: React.Ref<T> }> {
// TODO: when we drop support for react 18, remove this
@ -14,38 +25,6 @@ export function forwardRefIfNeeded<T, P = {}>(render: React.ForwardRefRenderFunc
return ((props: P) => render(props, (props as any).ref)) as any;
}
}
import.meta.vitest?.test("forwardRefIfNeeded", ({ expect }) => {
// Mock React.version and React.forwardRef
const originalVersion = React.version;
const originalForwardRef = React.forwardRef;
try {
// Test with React version < 19
Object.defineProperty(React, 'version', { value: '18.2.0', writable: true });
// Create a render function
const renderFn = (props: any, ref: any) => null;
// Call forwardRefIfNeeded
const result = forwardRefIfNeeded(renderFn);
// Verify the function returns something
expect(result).toBeDefined();
// Test with React version >= 19
Object.defineProperty(React, 'version', { value: '19.0.0', writable: true });
// Call forwardRefIfNeeded again with React 19
const result19 = forwardRefIfNeeded(renderFn);
// Verify the function returns something
expect(result19).toBeDefined();
} finally {
// Restore original values
Object.defineProperty(React, 'version', { value: originalVersion });
React.forwardRef = originalForwardRef;
}
});
export function getNodeText(node: React.ReactNode): string {
if (["number", "string"].includes(typeof node)) {

View File

@ -1,3 +1,4 @@
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
import { replaceAll } from '@stackframe/stack-shared/dist/utils/strings';
import autoprefixer from 'autoprefixer';
@ -6,8 +7,7 @@ import * as path from 'path';
import postcss from 'postcss';
import postcssNested from 'postcss-nested';
const sentinel = '--stack-sentinel--';
const scopeName = 'stack-scope'
const scopeSelector = ':where(.stack-scope)';
async function main() {
const args = process.argv.slice(2);
@ -23,26 +23,26 @@ async function main() {
let content = fs.readFileSync(inputPath, 'utf8');
// set the scope and sentinel, sentinel is used for same level selectors later
content = `.${scopeName}, .${sentinel} {\n${content}\n}`;
content = `${scopeSelector}, .--stack-sentinel-- {\n${content}\n}`;
// use postcss to nest the scope
content = await postcss([autoprefixer, postcssNested]).process(content, { from: undefined }).css;
// swap the case like .scope img to img.scope
content = content.replace(/(\.--stack-sentinel--\s)([*a-zA-Z0-9\-]+)([^,{\n]*)/g, `$2.${scopeName}$3`)
content = content.replace(/(\.--stack-sentinel--\s)([*a-zA-Z0-9\-]+)([^,{\n]*)/g, `$2${scopeSelector}$3`)
// swap the case like .scope [data-foo="bar"] to [data-foo="bar"] .scope
content = content.replace(/(\.--stack-sentinel--\s)(\[.*?\])([^,{\n]*)/g, `$2 .${scopeName}$3`)
content = content.replace(/(\.--stack-sentinel--\s)(\[.*?\])([^,{\n]*)/g, `$2 ${scopeSelector}$3`)
// replace the remaining sentinels
content = replaceAll(content, sentinel + ' ', scopeName);
content = replaceAll(content, '.--stack-sentinel-- ', scopeSelector);
// remove all :root
content = replaceAll(content, ':root', '');
// double check that all sentinels were replaced
if (content.includes(sentinel)) {
throw new Error('Sentinel not replaced');
if (content.includes('--stack-sentinel--')) {
throw new StackAssertionError('Sentinel not replaced', { content });
}
// output css file for debugging

View File

@ -1,10 +1,10 @@
'use client';
import { Button } from '@stackframe/stack-ui';
import { KeyRound } from 'lucide-react';
import { useId } from 'react';
import { useStackApp } from '..';
import { useTranslation } from '../lib/translations';
import { KeyRound } from 'lucide-react';
export function PasskeyButton({

View File

@ -66,12 +66,13 @@ function convertColorsToCSS(theme: Theme) {
}
return deindent`
.stack-scope {
${colorsToCSSVars(colors.light)}
}
html:has(head > [data-stack-theme="dark"]) .stack-scope {
${colorsToCSSVars(colors.dark)}
}`;
.stack-scope {
${colorsToCSSVars(colors.light)}
}
html:has(head > [data-stack-theme="dark"]) .stack-scope {
${colorsToCSSVars(colors.dark)}
}
`;
}