diff --git a/.devcontainer/postinstall.sh b/.devcontainer/postinstall.sh index db5b4ed3013b..5c1d30a15435 100755 --- a/.devcontainer/postinstall.sh +++ b/.devcontainer/postinstall.sh @@ -14,17 +14,14 @@ sudo rm -rf /tmp/corepack-cache sudo rm -rf /usr/local/lib/node_modules/corepack # Manually remove global corepack # Reinstall corepack globally via npm -npm install -g corepack@latest # Install latest corepack version +npm install -g corepack@latest --force # Install latest corepack version sudo corepack enable # Re-enable corepack # Check corepack version after reinstall -echo "--- Corepack version after reinstall ---" corepack --version -echo "--- End corepack version check ---" - # Prepare pnpm (again, after corepack reinstall) -sudo corepack prepare pnpm@9.14.4 --activate +corepack prepare pnpm@9.14.4 --activate # Go to workspace directory cd /workspaces/webstudio @@ -42,7 +39,7 @@ find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' + # Install dependencies, build, and migrate pnpm install -pnpm run build +pnpm build pnpm migrations migrate # Add git aliases diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5168daa9b3ad..881ba5b98300 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -135,7 +135,7 @@ jobs: const results = [ await assertSize('./fixtures/ssg/dist/client', 352), await assertSize('./fixtures/react-router-netlify/build/client', 360), - await assertSize('./fixtures/webstudio-features/build/client', 926), + await assertSize('./fixtures/webstudio-features/build/client', 932), ] for (const result of results) { if (result.passed) { diff --git a/@types/scroll-timeline.d.ts b/@types/scroll-timeline.d.ts index 43e22b31c089..a68b734da508 100644 --- a/@types/scroll-timeline.d.ts +++ b/@types/scroll-timeline.d.ts @@ -12,6 +12,7 @@ declare class ScrollTimeline extends AnimationTimeline { interface ViewTimelineOptions { subject?: Element | Document | null; axis?: ScrollAxis; + inset?: string; } declare class ViewTimeline extends ScrollTimeline { diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 71fe141ecf9e..b80ee794654d 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -59,9 +59,15 @@ const $metas = computed( const availableComponents = new Set(); const metas: Meta[] = []; for (const [name, componentMeta] of componentMetas) { + if ( + isFeatureEnabled("animation") === false && + name.endsWith(":AnimateChildren") + ) { + continue; + } + // only set available components from component meta availableComponents.add(name); - if ( isFeatureEnabled("headSlotComponent") === false && name === "HeadSlot" diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index df94c7e956b0..d58404df94fe 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -1631,7 +1631,16 @@ export const PageSettings = ({ onDuplicate={() => { const newPageId = duplicatePage(pageId); if (newPageId !== undefined) { - onDuplicate(newPageId); + // In `canvas.tsx`, within `subscribeStyles`, we use `requestAnimationFrame` (RAF) for style recalculation. + // After `duplicatePage`, styles are not yet recalculated. + // To ensure they are properly updated, we use double RAF. + requestAnimationFrame(() => { + // At this tick styles are updating + requestAnimationFrame(() => { + // At this tick styles are updated + onDuplicate(newPageId); + }); + }); } }} > diff --git a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx index d9814fa1ef3b..418f367d2c4d 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx @@ -216,6 +216,12 @@ export const renderControl = ({ ); } + if (prop.type === "animationAction") { + throw new Error( + `Cannot render a fallback control for prop "${rest.propName}" with type animationAction` + ); + } + prop satisfies never; } diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx new file mode 100644 index 000000000000..c93070891e3a --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx @@ -0,0 +1,218 @@ +import { parseCss } from "@webstudio-is/css-data"; +import { StyleValue, toValue } from "@webstudio-is/css-engine"; +import { + Text, + Grid, + IconButton, + Label, + Separator, + Tooltip, +} from "@webstudio-is/design-system"; +import { MinusIcon, PlusIcon } from "@webstudio-is/icons"; +import type { AnimationKeyframe } from "@webstudio-is/sdk"; +import { Fragment, useMemo, useState } from "react"; +import { + CssValueInput, + type IntermediateStyleValue, +} from "~/builder/features/style-panel/shared/css-value-input"; +import { toKebabCase } from "~/builder/features/style-panel/shared/keyword-utils"; +import { CodeEditor } from "~/builder/shared/code-editor"; +import { useIds } from "~/shared/form-utils"; + +const unitOptions = [ + { + id: "%" as const, + label: "%", + type: "unit" as const, + }, +]; + +const OffsetInput = ({ + id, + value, + onChange, +}: { + id: string; + value: number | undefined; + onChange: (value: number | undefined) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + return ( + []} + unitOptions={unitOptions} + intermediateValue={intermediateValue} + styleSource="default" + /* same as offset has 0 - 100% */ + property={"fontStretch"} + value={ + value !== undefined + ? { + type: "unit", + value: Math.round(value * 1000) / 10, + unit: "%", + } + : undefined + } + onChange={(styleValue) => { + if (styleValue === undefined) { + setIntermediateValue(styleValue); + return; + } + + const clampedStyleValue = { ...styleValue }; + if ( + clampedStyleValue.type === "unit" && + clampedStyleValue.unit === "%" + ) { + clampedStyleValue.value = Math.min( + 100, + Math.max(0, clampedStyleValue.value) + ); + } + + setIntermediateValue(clampedStyleValue); + }} + onHighlight={(_styleValue) => { + /* @todo: think about preview */ + }} + onChangeComplete={(event) => { + setIntermediateValue(undefined); + + if (event.value.type === "unit" && event.value.unit === "%") { + onChange(Math.min(100, Math.max(0, event.value.value)) / 100); + return; + } + + setIntermediateValue({ + type: "invalid", + value: toValue(event.value), + }); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + onChange(undefined); + }} + /> + ); +}; + +const Keyframe = ({ + value, + onChange, +}: { + value: AnimationKeyframe; + onChange: (value: AnimationKeyframe | undefined) => void; +}) => { + const ids = useIds(["offset"]); + + const cssProperties = useMemo(() => { + let result = ``; + for (const [property, style] of Object.entries(value.styles)) { + result = `${result}${toKebabCase(property)}: ${toValue(style)};\n`; + } + return result; + }, [value.styles]); + + return ( + <> + + + { + onChange({ ...value, offset }); + }} + /> + + onChange(undefined)}> + + + + + + { + /* do nothing */ + }} + onChangeComplete={(cssText) => { + const parsedStyles = parseCss(`selector{${cssText}}`); + onChange({ + ...value, + styles: parsedStyles.reduce( + (r, { property, value }) => ({ ...r, [property]: value }), + {} + ), + }); + }} + /> + + + ); +}; + +export const Keyframes = ({ + value: keyframes, + onChange, +}: { + value: AnimationKeyframe[]; + onChange: (value: AnimationKeyframe[]) => void; +}) => { + const ids = useIds(["addKeyframe"]); + + return ( + + + + + onChange([...keyframes, { offset: undefined, styles: {} }]) + } + > + + + + + {keyframes.map((value, index) => ( + + + { + if (newValue === undefined) { + const newValues = [...keyframes]; + newValues.splice(index, 1); + onChange(newValues); + return; + } + + const newValues = [...keyframes]; + newValues[index] = newValue; + onChange(newValues); + }} + /> + + ))} + + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx new file mode 100644 index 000000000000..5b66afd4e424 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimationPanelContent } from "./animation-panel-content"; +import { theme } from "@webstudio-is/design-system"; +import { useState } from "react"; +import type { ScrollAnimation, ViewAnimation } from "@webstudio-is/sdk"; + +const meta = { + title: "Builder/Settings Panel/Animation Panel Content", + component: AnimationPanelContent, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(newValue as ScrollAnimation); + }} + /> + ); +}; + +const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(newValue as ViewAnimation); + }} + /> + ); +}; + +export const ScrollAnimationStory: Story = { + render: ScrollAnimationTemplate, + args: { + type: "scroll", + value: { + name: "scroll-animation", + timing: { + rangeStart: ["start", { type: "unit", value: 0, unit: "%" }], + rangeEnd: ["end", { type: "unit", value: 100, unit: "%" }], + }, + keyframes: [ + { + offset: 0, + styles: { + opacity: { type: "unit", value: 0, unit: "%" }, + color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 }, + }, + }, + ], + }, + onChange: () => {}, + }, +}; + +export const ViewAnimationStory: Story = { + render: ViewAnimationTemplate, + args: { + type: "view", + value: { + name: "view-animation", + timing: { + rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }], + rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }], + }, + keyframes: [ + { + offset: 0, + styles: { + opacity: { type: "unit", value: 0, unit: "%" }, + color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 }, + }, + }, + ], + }, + onChange: () => {}, + }, +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx new file mode 100644 index 000000000000..96239dc4ce95 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx @@ -0,0 +1,410 @@ +import { + Box, + Grid, + Label, + Select, + theme, + toast, +} from "@webstudio-is/design-system"; +import { toPascalCase } from "~/builder/features/style-panel/shared/keyword-utils"; +import { useIds } from "~/shared/form-utils"; + +import type { + RangeUnitValue, + ScrollAnimation, + ViewAnimation, +} from "@webstudio-is/sdk"; +import { + RANGE_UNITS, + rangeUnitValueSchema, + scrollAnimationSchema, + viewAnimationSchema, +} from "@webstudio-is/sdk"; +import { + CssValueInput, + type IntermediateStyleValue, +} from "~/builder/features/style-panel/shared/css-value-input/css-value-input"; +import { toValue, type StyleValue } from "@webstudio-is/css-engine"; +import { useState } from "react"; +import { Keyframes } from "./animation-keyframes"; +import { styleConfigByName } from "~/builder/features/style-panel/shared/configs"; +import { titleCase } from "title-case"; + +type Props = { + type: "scroll" | "view"; + value: ScrollAnimation | ViewAnimation; + onChange: (value: ScrollAnimation | ViewAnimation) => void; +}; + +const fillModeDescriptions: Record< + NonNullable, + string +> = { + none: "No animation is applied before or after the active period", + forwards: + "The animation state is applied after the active period. Prefered for Out Animations", + backwards: + "The animation state is applied before the active period. Prefered for In Animations", + both: "The animation state is applied before and after the active period", +}; + +const fillModeNames = Object.keys(fillModeDescriptions) as NonNullable< + ViewAnimation["timing"]["fill"] +>[]; + +/** + * https://developer.mozilla.org/en-US/docs/Web/CSS/animation-range-start + * + * + **/ +const viewTimelineRangeName = { + entry: + "Animates during the subject element entry (starts entering → fully visible)", + exit: "Animates during the subject element exit (starts exiting → fully hidden)", + contain: + "Animates only while the subject element is fully in view (fullly visible after entering → starts exiting)", + cover: + "Animates entire time the subject element is visible (starts entering → ends after exiting)", + "entry-crossing": + "Animates as the subject element enters (leading edge → trailing edge enters view)", + "exit-crossing": + "Animates as the subject element exits (leading edge → trailing edge leaves view)", +}; + +/** + * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges + * However, for simplicity and type unification with the view, we will use the names "start" and "end," + * which will be transformed as follows: + * - "start" → `calc(0% + range)` + * - "end" → `calc(100% - range)` + */ +const scrollTimelineRangeName = { + start: "Distance from the top of the scroll container where animation begins", + end: "Distance from the bottom of the scroll container where animation ends", +}; + +const unitOptions = RANGE_UNITS.map((unit) => ({ + id: unit, + label: unit, + type: "unit" as const, +})); + +const RangeValueInput = ({ + id, + value, + onChange, +}: { + id: string; + value: RangeUnitValue; + onChange: (value: RangeUnitValue) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + return ( + { + setIntermediateValue(styleValue); + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + getOptions={() => []} + onHighlight={() => { + /* Nothing to Highlight */ + }} + onChangeComplete={(event) => { + const parsedValue = rangeUnitValueSchema.safeParse(event.value); + if (parsedValue.success) { + onChange(parsedValue.data); + setIntermediateValue(undefined); + return; + } + + setIntermediateValue({ + type: "invalid", + value: toValue(event.value), + }); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + }} + /> + ); +}; + +const EasingInput = ({ + id, + value, + onChange, +}: { + id: string; + value: string | undefined; + onChange: (value: string | undefined) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + const property = "animationTimingFunction"; + + return ( + [ + ...styleConfigByName(property).items.map((item) => ({ + type: "keyword" as const, + value: item.name, + })), + ]} + property={property} + intermediateValue={intermediateValue} + onChange={(styleValue) => { + setIntermediateValue(styleValue); + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onHighlight={() => { + /* Nothing to Highlight */ + }} + onChangeComplete={(event) => { + onChange(toValue(event.value)); + setIntermediateValue(undefined); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + }} + /> + ); +}; + +export const AnimationPanelContent = ({ onChange, value, type }: Props) => { + const fieldIds = useIds([ + "rangeStartName", + "rangeStartValue", + "rangeEndName", + "rangeEndValue", + "fill", + "easing", + ] as const); + + const timelineRangeDescriptions = + type === "scroll" ? scrollTimelineRangeName : viewTimelineRangeName; + + const timelineRangeNames = Object.keys(timelineRangeDescriptions); + + const animationSchema = + type === "scroll" ? scrollAnimationSchema : viewAnimationSchema; + + const handleChange = (rawValue: unknown) => { + const parsedValue = animationSchema.safeParse(rawValue); + if (parsedValue.success) { + onChange(parsedValue.data); + return; + } + + toast.error("Animation schema is incompatible, try fix"); + }; + + return ( + + + + + + toPascalCase(timelineRangeName) + } + value={value.timing.rangeStart?.[0] ?? timelineRangeNames[0]!} + getDescription={(timelineRangeName: string) => ( + + { + timelineRangeDescriptions[ + timelineRangeName as keyof typeof timelineRangeDescriptions + ] + } + + )} + onChange={(timelineRangeName) => { + handleChange({ + ...value, + timing: { + ...value.timing, + rangeStart: [ + timelineRangeName, + value.timing.rangeStart?.[1] ?? { + type: "unit", + value: 0, + unit: "%", + }, + ], + }, + }); + }} + /> + { + const defaultTimelineRangeName = timelineRangeNames[0]!; + + handleChange({ + ...value, + timing: { + ...value.timing, + rangeStart: [ + value.timing.rangeStart?.[0] ?? defaultTimelineRangeName, + rangeStart, + ], + }, + }); + }} + /> + + + + + toPascalCase(animationType) + } + value={value.type} + getDescription={(animationType: AnimationAction["type"]) => ( + + {animationTypeDescription[animationType]} + + )} + onChange={(typeValue) => { + handleChange({ ...value, type: typeValue, animations: [] }); + }} + /> + + + + + { + handleChange({ ...value, axis }); + }} + > + {Object.entries(animationAxisDescription).map( + ([key, { icon, label, description }]) => ( + + {label} + {description} + + } + > + {icon} + + ) + )} + + + + {value.type === "scroll" && ( + + + + subject.value)} + value={value.subject ?? "self"} + getLabel={(subject) => + subjects.find((s) => s.value === subject)?.label ?? "-" + } + onItemHighlight={(subject) => { + const selector = + subjects.find((s) => s.value === subject)?.selector ?? undefined; + $hoveredInstanceSelector.set(selector); + }} + onChange={(subject) => { + const newValue = { + ...value, + subject: subject === "self" ? undefined : subject, + }; + const parsedValue = animationActionSchema.safeParse(newValue); + + if (parsedValue.success) { + const subjectItem = subjects.find((s) => s.value === subject); + + if (subjectItem === undefined) { + toast.error(`Subject "${newValue.subject}" not found`); + return; + } + + if ( + subjectItem.isTimelineExists === false && + newValue.subject !== undefined + ) { + serverSyncStore.createTransaction( + [$breakpoints, $styles, $styleSources, $styleSourceSelections], + (breakpoints, styles, styleSources, styleSourceSelections) => { + if (newValue.subject === undefined) { + return; + } + + setListedCssProperty( + breakpoints, + styleSources, + styleSourceSelections, + styles + )(subjectItem.instanceId, "viewTimelineName", { + type: "unparsed", + value: newValue.subject, + }); + } + ); + } + + onChange(parsedValue.data); + return; + } + + toast.error("Schemas are incompatible, try fix"); + }} + /> + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx index 36f2f19bebf6..f1c7719e1434 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx +++ b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx @@ -24,6 +24,7 @@ import { renderControl } from "../controls/combined"; import { usePropsLogic, type PropAndMeta } from "./use-props-logic"; import { serverSyncStore } from "~/shared/sync"; import { $selectedInstanceKey } from "~/shared/awareness"; +import { AnimateSection } from "./animation/animation-section"; type Item = { name: string; @@ -165,10 +166,29 @@ export const PropsSection = (props: PropsSectionProps) => { const hasItems = logic.addedProps.length > 0 || addingProp || logic.initialProps.length > 0; + const animationAction = logic.initialProps.find( + (prop) => prop.meta.type === "animationAction" + ); + + const hasAnimation = animationAction !== undefined; + const showPropertiesSection = isDesignMode || (isContentMode && logic.initialProps.length > 0); - return ( + return hasAnimation ? ( + <> + + logic.handleChangeByPropName(animationAction.propName, { + type: "animationAction", + value, + }) + } + /> + + + ) : ( <> { throw new Error( "A prop with type string[] must have a meta, we can't provide a default one because we need a list of options" ); + + case "animationAction": + return { + type: "animationAction", + control: "animationAction", + required: false, + }; case "json": throw new Error( "A prop with type json must have a meta, we can't provide a default one because we need a list of options" diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index b340ac9d4369..1d52b92b850d 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -49,7 +49,11 @@ export type PropValue = | { type: "expression"; value: string } | { type: "asset"; value: Asset["id"] } | { type: "page"; value: Extract["value"] } - | { type: "action"; value: Extract["value"] }; + | { type: "action"; value: Extract["value"] } + | { + type: "animationAction"; + value: Extract["value"]; + }; // Weird code is to make type distributive // https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx index 1d4805b86f80..092519209dc5 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -36,7 +36,7 @@ import { useMemo, type ComponentProps, } from "react"; -import { useUnitSelect } from "./unit-select"; +import { useUnitSelect, type UnitOption } from "./unit-select"; import { parseIntermediateOrInvalidValue } from "./parse-intermediate-or-invalid-value"; import { toValue } from "@webstudio-is/css-engine"; import { @@ -58,6 +58,7 @@ import { isComplexValue, ValueEditorDialog, } from "./value-editor-dialog"; +import { useEffectEvent } from "~/shared/hook-utils/effect-event"; // We need to enable scrub on properties that can have numeric value. const canBeNumber = (property: StyleProperty, value: CssValueInputValue) => { @@ -81,15 +82,21 @@ const scrubUnitAcceleration = new Map([ const useScrub = ({ value, + intermediateValue, + defaultUnit, property, onChange, onChangeComplete, + onAbort, shouldHandleEvent, }: { + defaultUnit: Unit | undefined; value: CssValueInputValue; + intermediateValue: CssValueInputValue | undefined; property: StyleProperty; - onChange: (value: CssValueInputValue) => void; + onChange: (value: CssValueInputValue | undefined) => void; onChangeComplete: (value: StyleValue) => void; + onAbort: () => void; shouldHandleEvent?: (node: Node) => boolean; }): [ React.RefObject, @@ -102,11 +109,19 @@ const useScrub = ({ const onChangeCompleteRef = useRef(onChangeComplete); const valueRef = useRef(value); + const intermediateValueRef = useRef(intermediateValue); + onChangeCompleteRef.current = onChangeComplete; onChangeRef.current = onChange; valueRef.current = value; + const updateIntermediateValue = useEffectEvent(() => { + intermediateValueRef.current = intermediateValue; + }); + + const onAbortStable = useEffectEvent(onAbort); + // const type = valueRef.current.type; // Since scrub is going to call onChange and onChangeComplete callbacks, it will result in a new value and potentially new callback refs. @@ -124,7 +139,7 @@ const useScrub = ({ return; } - let unit: Unit = "number"; + let unit: Unit = defaultUnit ?? "number"; const validateValue = (numericValue: number) => { let value: CssValueInputValue = { @@ -193,6 +208,20 @@ const useScrub = ({ if (valueRef.current.type === "unit") { unit = valueRef.current.unit; } + + updateIntermediateValue(); + }, + onAbort() { + onAbortStable(); + // Returning focus that we've moved above + scrubRef.current?.removeAttribute("tabindex"); + onChangeRef.current(intermediateValueRef.current); + + // Otherwise selectionchange event can be triggered after 300-1000ms after focus + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); }, onValueInput(event) { // Moving focus to container of the input to hide the caret @@ -221,7 +250,13 @@ const useScrub = ({ }, shouldHandleEvent, }); - }, [shouldHandleEvent, property]); + }, [ + shouldHandleEvent, + property, + updateIntermediateValue, + onAbortStable, + defaultUnit, + ]); return [scrubRef, inputRef]; }; @@ -273,7 +308,9 @@ type CssValueInputProps = Pick< /** * Selected item in the dropdown */ - getOptions?: () => Array; + getOptions?: () => Array< + KeywordValue | VarValue | (KeywordValue & { description?: string }) + >; onChange: (value: CssValueInputValue | undefined) => void; onChangeComplete: (event: ChangeCompleteEvent) => void; onHighlight: (value: StyleValue | undefined) => void; @@ -283,6 +320,9 @@ type CssValueInputProps = Pick< onReset: () => void; icon?: ReactNode; showSuffix?: boolean; + unitOptions?: UnitOption[]; + id?: string; + placeholder?: string; }; const initialValue: IntermediateStyleValue = { @@ -429,6 +469,7 @@ const Description = styled(Box, { width: theme.spacing[27] }); * - Evaluated math expression: "2px + 3em" (like CSS calc()) */ export const CssValueInput = ({ + id, autoFocus, icon, prefix, @@ -444,6 +485,8 @@ export const CssValueInput = ({ fieldSizing, variant, text, + unitOptions, + placeholder, ...props }: CssValueInputProps) => { const value = props.intermediateValue ?? props.value ?? initialValue; @@ -455,6 +498,9 @@ export const CssValueInput = ({ StyleValue | undefined >(); + const defaultUnit = + unitOptions?.[0]?.type === "unit" ? unitOptions[0].id : undefined; + const onChange = (input: string | undefined) => { if (input === undefined) { props.onChange(undefined); @@ -500,7 +546,11 @@ export const CssValueInput = ({ return; } - const parsedValue = parseIntermediateOrInvalidValue(property, value); + const parsedValue = parseIntermediateOrInvalidValue( + property, + value, + defaultUnit + ); if (parsedValue.type === "invalid") { props.onChange(parsedValue); @@ -527,6 +577,7 @@ export const CssValueInput = ({ highlightedIndex, closeMenu, } = useCombobox({ + inputId: id, // Used for description to match the item when nothing is highlighted yet and value is still in non keyword mode getItems: getOptions, value, @@ -555,6 +606,7 @@ export const CssValueInput = ({ const inputProps = getInputProps(); const [isUnitsOpen, unitSelectElement] = useUnitSelect({ + options: unitOptions, property, value, onChange: (unitOrKeyword) => { @@ -656,11 +708,14 @@ export const CssValueInput = ({ }, []); const [scrubRef, inputRef] = useScrub({ + defaultUnit, value, property, + intermediateValue: props.intermediateValue, onChange: props.onChange, onChangeComplete: (value) => onChangeComplete({ value, type: "scrub-end" }), shouldHandleEvent, + onAbort, }); const menuProps = getMenuProps(); @@ -734,10 +789,22 @@ export const CssValueInput = ({ : undefined; if (valueForDescription) { - const key = `${property}:${toValue( - valueForDescription - )}` as keyof typeof declarationDescriptions; - description = declarationDescriptions[key]; + const option = getOptions().find( + (item) => + item.type === "keyword" && item.value === valueForDescription.value + ); + if ( + option !== undefined && + "description" in option && + option?.description + ) { + description = option.description; + } else { + const key = `${property}:${toValue( + valueForDescription + )}` as keyof typeof declarationDescriptions; + description = declarationDescriptions[key]; + } } else if (highlightedValue?.type === "var") { description = "CSS custom property (variable)"; } else if (highlightedValue === undefined) { @@ -905,10 +972,12 @@ export const CssValueInput = ({ { + const unitGroups = + properties[property as keyof typeof properties]?.unitGroups ?? []; + + for (const unitGroup of unitGroups) { + if (unitGroup === "number") { + continue; + } + + if (unitGroup === "length") { + return "px"; + } + + return units[unitGroup][0]!; + } + + if (unitGroups.includes("number" as never)) { + return "number"; + } + + return "px"; +}; + export const parseIntermediateOrInvalidValue = ( property: StyleProperty, styleValue: IntermediateStyleValue | InvalidValue, + defaultUnit: Unit = getDefaultUnit(property), originalValue?: string ): StyleValue => { let value = styleValue.value.trim(); @@ -40,7 +70,11 @@ export const parseIntermediateOrInvalidValue = ( const unit = "unit" in styleValue ? styleValue.unit : undefined; // Use number as a fallback for custom properties - const fallbackUnitAsString = property.startsWith("--") ? "" : "px"; + const fallbackUnitAsString = property.startsWith("--") + ? "" + : defaultUnit === "number" + ? "" + : defaultUnit; const testUnit = unit === "number" ? "" : (unit ?? fallbackUnitAsString); @@ -133,6 +167,7 @@ export const parseIntermediateOrInvalidValue = ( ...styleValue, value: value.replace(/,/g, "."), }, + defaultUnit, originalValue ?? value ); } diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts index ab3e225918a0..122c79e4d31f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts @@ -41,19 +41,30 @@ describe("Parse intermediate or invalid value without math evaluation", () => { } }); - test("fallback to px", () => { - for (const propery of properties) { - const result = parseIntermediateOrInvalidValue(propery, { - type: "intermediate", - value: "10", - }); + test.each(properties)(`fallback to px for property = "%s"`, (propery) => { + const result = parseIntermediateOrInvalidValue(propery, { + type: "intermediate", + value: "10", + }); - expect(result).toEqual({ - type: "unit", - value: 10, - unit: "px", - }); - } + expect(result).toEqual({ + type: "unit", + value: 10, + unit: "px", + }); + }); + + test("fallback to % if px is not supported", () => { + const result = parseIntermediateOrInvalidValue("fontStretch", { + type: "intermediate", + value: "10", + }); + + expect(result).toEqual({ + type: "unit", + value: 10, + unit: "%", + }); }); test("switch on new unit if previous not known", () => { diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx index 3d28a0b97785..2fd279b4c5f0 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx @@ -29,6 +29,7 @@ type UseUnitSelectType = { value: { type: "unit"; value: Unit } | { type: "keyword"; value: string } ) => void; onCloseAutoFocus: (event: Event) => void; + options?: UnitOption[]; }; export const useUnitSelect = ({ @@ -36,12 +37,14 @@ export const useUnitSelect = ({ value, onChange, onCloseAutoFocus, + options: unitOptions, }: UseUnitSelectType): [boolean, JSX.Element | undefined] => { const [isOpen, setIsOpen] = useState(false); const options = useMemo( - () => buildOptions(property, value, nestedSelectButtonUnitless), - [property, value] + () => + unitOptions ?? buildOptions(property, value, nestedSelectButtonUnitless), + [property, value, unitOptions] ); const unit = diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 5492a915086e..686ae7fb9fb4 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -40,6 +40,7 @@ import { $selectedInstancePathWithRoot, type InstancePath, } from "~/shared/awareness"; +import type { InstanceSelector } from "~/shared/tree-utils"; const $presetStyles = computed($registeredComponentMetas, (metas) => { const presetStyles = new Map(); @@ -376,6 +377,17 @@ export const useParentComputedStyleDecl = (property: StyleProperty) => { return useStore($store); }; +export const getInstanceStyleDecl = ( + property: StyleProperty, + instanceSelector: InstanceSelector +) => { + return getComputedStyleDecl({ + model: $model.get(), + instanceSelector, + property, + }); +}; + export const useComputedStyles = (properties: StyleProperty[]) => { // cache each computed style store const cachedStores = useRef( diff --git a/apps/builder/app/builder/shared/code-editor.tsx b/apps/builder/app/builder/shared/code-editor.tsx index 8f4528e78827..48b95650cb47 100644 --- a/apps/builder/app/builder/shared/code-editor.tsx +++ b/apps/builder/app/builder/shared/code-editor.tsx @@ -12,7 +12,12 @@ import { highlightSpecialChars, highlightActiveLine, } from "@codemirror/view"; -import { bracketMatching, indentOnInput } from "@codemirror/language"; +import { + bracketMatching, + indentOnInput, + LanguageSupport, + LRLanguage, +} from "@codemirror/language"; import { autocompletion, closeBrackets, @@ -30,12 +35,20 @@ import { foldGutterExtension, getMinMaxHeightVars, } from "./code-editor-base"; +import { cssCompletionSource, cssLanguage } from "@codemirror/lang-css"; const wrapperStyle = css({ position: "relative", - // 1 line is 16px - // set min 10 lines and max 20 lines - ...getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }), + + variants: { + size: { + default: getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }), + keyframe: getMinMaxHeightVars({ minHeight: "60px", maxHeight: "120px" }), + }, + }, + defaultVariants: { + size: "default", + }, }); const getHtmlExtensions = () => [ @@ -80,20 +93,55 @@ const getMarkdownExtensions = () => [ keymap.of(closeBracketsKeymap), ]; +const cssPropertiesLanguage = LRLanguage.define({ + name: "css", + parser: cssLanguage.configure({ top: "Styles" }).parser, +}); +const cssProperties = new LanguageSupport( + cssPropertiesLanguage, + cssPropertiesLanguage.data.of({ + autocomplete: cssCompletionSource, + }) +); + +const getCssPropertiesExtensions = () => [ + highlightActiveLine(), + highlightSpecialChars(), + indentOnInput(), + cssProperties, + // render autocomplete in body + // to prevent popover scroll overflow + tooltips({ parent: document.body }), + autocompletion({ icons: false }), +]; + export const CodeEditor = forwardRef< HTMLDivElement, Omit, "extensions"> & { - lang?: "html" | "markdown"; + lang?: "html" | "markdown" | "css-properties"; title?: ReactNode; + size?: "default" | "keyframe"; } ->(({ lang, title, ...editorContentProps }, ref) => { +>(({ lang, title, size, ...editorContentProps }, ref) => { const extensions = useMemo(() => { if (lang === "html") { return getHtmlExtensions(); } + if (lang === "markdown") { return getMarkdownExtensions(); } + + if (lang === "css-properties") { + return getCssPropertiesExtensions(); + } + + if (lang === undefined) { + return []; + } + + lang satisfies never; + return []; }, [lang]); @@ -120,7 +168,7 @@ export const CodeEditor = forwardRef< }; }, []); return ( -
+
{ - return ( - - -

- HELLO WORLD -

-
- - Start scrolling, and when the current box scrolls out, the “HELLO WORLD” - header will fly in and become hoverable. (During the animation, it won’t - be hoverable.) - - - - When you see this box, the “HELLO WORLD” header will fly out. - -
- ); -}; - -export default Playground; - -// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files. -export const config = { - maxDuration: 30, -}; diff --git a/apps/builder/app/shared/nano-states/misc.ts b/apps/builder/app/shared/nano-states/misc.ts index f8002292e64e..efd1d85cb2f8 100644 --- a/apps/builder/app/shared/nano-states/misc.ts +++ b/apps/builder/app/shared/nano-states/misc.ts @@ -62,10 +62,25 @@ export const $propsIndex = computed($props, (props) => { }; }); +/** + * $styles contains actual styling rules + * (breakpointId, styleSourceId, property, value, listed), tied to styleSourceIds + * $styles.styleSourceId -> $styleSources.id + */ export const $styles = atom(new Map()); +/** + * styleSources defines where styles come from (local or token). + * + * $styles contains actual styling rules, tied to styleSourceIds. + * $styles.styleSourceId -> $styleSources.id + */ export const $styleSources = atom(new Map()); +/** + * This is a list of connections between instances (instanceIds) and styleSources. + * $styleSourceSelections.values[] -> $styleSources.id[] + */ export const $styleSourceSelections = atom(new Map()); export type StyleSourceSelector = { diff --git a/apps/builder/package.json b/apps/builder/package.json index 01ed76d8cbed..76ad2efc71b4 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -129,6 +129,7 @@ "@types/react": "^18.2.70", "@types/react-dom": "^18.2.25", "@webstudio-is/tsconfig": "workspace:*", + "fast-glob": "^3.3.2", "html-tags": "^4.0.0", "react-router-dom": "^6.28.1", "react-test-renderer": "18.3.0-canary-14898b6a9-20240318", diff --git a/apps/builder/vite.config.ts b/apps/builder/vite.config.ts index eced690c8c74..4db7986825b9 100644 --- a/apps/builder/vite.config.ts +++ b/apps/builder/vite.config.ts @@ -9,20 +9,17 @@ import { getAuthorizationServerOrigin, isBuilderUrl, } from "./app/shared/router-utils/origins"; -import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; +import fg from "fast-glob"; -const isFolderEmpty = (folderPath: string) => { - if (!existsSync(folderPath)) { - return true; // Folder does not exist - } - const contents = readdirSync(folderPath); - - return contents.length === 0; -}; +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); -const hasPrivateFolders = !isFolderEmpty( - path.join(__dirname, "../../packages/sdk-components-animation/private-src") -); +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; export default defineConfig(({ mode }) => { if (mode === "test") { diff --git a/fixtures/webstudio-features/.template/package.json b/fixtures/webstudio-features/.template/package.json index b21dce86c93a..85ed77245ffd 100644 --- a/fixtures/webstudio-features/.template/package.json +++ b/fixtures/webstudio-features/.template/package.json @@ -8,5 +8,8 @@ "@webstudio-is/sdk-components-react-radix": "workspace:*", "@webstudio-is/sdk-components-react-router": "workspace:*", "webstudio": "workspace:*" + }, + "devDependencies": { + "fast-glob": "^3.3.2" } } diff --git a/fixtures/webstudio-features/.template/vite.config.ts b/fixtures/webstudio-features/.template/vite.config.ts index af40508cdc7b..b33951009b77 100644 --- a/fixtures/webstudio-features/.template/vite.config.ts +++ b/fixtures/webstudio-features/.template/vite.config.ts @@ -3,7 +3,33 @@ import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; // @ts-ignore import { dedupeMeta } from "./proxy-emulator/dedupe-meta"; +import { existsSync, readdirSync } from "fs"; +// @ts-ignore +import path from "path"; +// @ts-ignore +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, plugins: [reactRouter(), dedupeMeta], }); diff --git a/fixtures/webstudio-features/package.json b/fixtures/webstudio-features/package.json index bde13cfe1728..8b8c6f7c2b58 100644 --- a/fixtures/webstudio-features/package.json +++ b/fixtures/webstudio-features/package.json @@ -34,7 +34,8 @@ "webstudio": "workspace:*", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.25", - "typescript": "5.7.3" + "typescript": "5.7.3", + "fast-glob": "^3.3.2" }, "engines": { "node": ">=20.0.0" diff --git a/fixtures/webstudio-features/vite.config.ts b/fixtures/webstudio-features/vite.config.ts index af40508cdc7b..b33951009b77 100644 --- a/fixtures/webstudio-features/vite.config.ts +++ b/fixtures/webstudio-features/vite.config.ts @@ -3,7 +3,33 @@ import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; // @ts-ignore import { dedupeMeta } from "./proxy-emulator/dedupe-meta"; +import { existsSync, readdirSync } from "fs"; +// @ts-ignore +import path from "path"; +// @ts-ignore +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, plugins: [reactRouter(), dedupeMeta], }); diff --git a/packages/css-data/src/__generated__/properties.ts b/packages/css-data/src/__generated__/properties.ts index 68e3f33ba324..20c5ab46b0c6 100644 --- a/packages/css-data/src/__generated__/properties.ts +++ b/packages/css-data/src/__generated__/properties.ts @@ -31,8 +31,8 @@ export const properties = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--view-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name", @@ -41,12 +41,22 @@ export const properties = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--scroll-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name", }, + viewTimelineInset: { + unitGroups: ["length", "percentage"], + inherited: false, + initial: { + type: "keyword", + value: "auto", + }, + mdnUrl: + "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset", + }, "-webkit-line-clamp": { unitGroups: ["number"], inherited: false, diff --git a/packages/css-data/src/custom-data.ts b/packages/css-data/src/custom-data.ts index 68c3f81c5b89..50b29abef4e7 100644 --- a/packages/css-data/src/custom-data.ts +++ b/packages/css-data/src/custom-data.ts @@ -80,8 +80,8 @@ propertiesData.viewTimelineName = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--view-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name", }; @@ -89,13 +89,24 @@ propertiesData.scrollTimelineName = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--scroll-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name", }; +propertiesData.viewTimelineInset = { + unitGroups: ["length", "percentage"], + inherited: false, + initial: { + type: "keyword", + value: "auto", + }, + mdnUrl: + "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset", +}; + keywordValues.listStyleType = [ "disc", "circle", diff --git a/packages/css-engine/src/__generated__/types.ts b/packages/css-engine/src/__generated__/types.ts index ff109f02b318..5a61f0b29c4d 100644 --- a/packages/css-engine/src/__generated__/types.ts +++ b/packages/css-engine/src/__generated__/types.ts @@ -4,6 +4,7 @@ export type CamelCasedProperty = | "-webkit-box-orient" | "viewTimelineName" | "scrollTimelineName" + | "viewTimelineInset" | "-webkit-line-clamp" | "-webkit-overflow-scrolling" | "-webkit-tap-highlight-color" @@ -343,6 +344,7 @@ export type HyphenatedProperty = | "-webkit-box-orient" | "view-timeline-name" | "scroll-timeline-name" + | "view-timeline-inset" | "-webkit-line-clamp" | "-webkit-overflow-scrolling" | "-webkit-tap-highlight-color" diff --git a/packages/css-engine/src/core/prefixer.ts b/packages/css-engine/src/core/prefixer.ts index 6a26622a4b4b..bb40662796cc 100644 --- a/packages/css-engine/src/core/prefixer.ts +++ b/packages/css-engine/src/core/prefixer.ts @@ -31,7 +31,8 @@ export const prefixStyles = (styleMap: StyleMap) => { // https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name if ( property === "view-timeline-name" || - property === "scroll-timeline-name" + property === "scroll-timeline-name" || + property === "view-timeline-inset" ) { newStyleMap.set(`--${property}`, value); } diff --git a/packages/design-system/src/components/combobox.tsx b/packages/design-system/src/components/combobox.tsx index 894746fc689a..17f562070f93 100644 --- a/packages/design-system/src/components/combobox.tsx +++ b/packages/design-system/src/components/combobox.tsx @@ -281,7 +281,6 @@ export const useCombobox = ({ defaultHighlightedIndex, selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode isOpen, - onIsOpenChange(state) { const { type, isOpen, inputValue } = state; diff --git a/packages/design-system/src/components/floating-panel.tsx b/packages/design-system/src/components/floating-panel.tsx index 7c7353aa922e..903b0d7d2064 100644 --- a/packages/design-system/src/components/floating-panel.tsx +++ b/packages/design-system/src/components/floating-panel.tsx @@ -167,6 +167,13 @@ export const FloatingPanel = ({ event.preventDefault(); } }} + onEscapeKeyDown={(event) => { + if (event.target instanceof HTMLInputElement) { + event.preventDefault(); + + return; + } + }} ref={setContentElement} > {content} diff --git a/packages/design-system/src/components/input-field.tsx b/packages/design-system/src/components/input-field.tsx index b783a431d5c2..0b380d20e2ef 100644 --- a/packages/design-system/src/components/input-field.tsx +++ b/packages/design-system/src/components/input-field.tsx @@ -247,8 +247,17 @@ export const InputField = forwardRef( }); const unfocusContainerRef = useRef(null); const handleKeyDown: KeyboardEventHandler = (event) => { + // If Radix is preventing the Escape key from closing the dialog, + // it intercepts the key event at the document level. + // However, we still want to allow the user to unfocus the input field. + // This means we should not check `defaultPrevented`, but only verify + // if our event handler explicitly prevented it. + const isPreventedBefore = event.defaultPrevented; onKeyDown?.(event); - if (event.key === "Escape" && event.defaultPrevented === false) { + const isPreventedAfter = event.defaultPrevented; + const isEventPrevented = !isPreventedBefore && isPreventedAfter; + + if (event.key === "Escape" && !isEventPrevented) { event.preventDefault(); unfocusContainerRef.current?.focus(); } diff --git a/packages/design-system/src/components/primitives/numeric-gesture-control.ts b/packages/design-system/src/components/primitives/numeric-gesture-control.ts index 391e9246daa2..db2872bf0178 100644 --- a/packages/design-system/src/components/primitives/numeric-gesture-control.ts +++ b/packages/design-system/src/components/primitives/numeric-gesture-control.ts @@ -72,6 +72,7 @@ export type NumericScrubOptions = { direction?: NumericScrubDirection; onValueInput?: NumericScrubCallback; onValueChange?: NumericScrubCallback; + onAbort?: () => void; onStatusChange?: (status: "idle" | "scrubbing") => void; shouldHandleEvent?: (node: Node) => boolean; }; @@ -168,6 +169,7 @@ export const numericScrubControl = ( distanceThreshold = 0, onValueInput, onValueChange, + onAbort, onStatusChange, shouldHandleEvent, } = options; @@ -209,7 +211,9 @@ export const numericScrubControl = ( // Called on ESC key press or in cases of third-party pointer lock exit. const handlePointerLockChange = () => { if (document.pointerLockElement !== targetNode) { + // Reset the value to the initial value cleanup(); + onAbort?.(); return; } @@ -396,7 +400,7 @@ export const numericScrubControl = ( const eventNames = [ "pointerup", "pointerdown", - "pontercancel", + "pointercancel", "lostpointercapture", ] as const; eventNames.forEach((eventName) => diff --git a/packages/design-system/src/components/switch.tsx b/packages/design-system/src/components/switch.tsx index 5c68a56e21b2..08b2e8996cb3 100644 --- a/packages/design-system/src/components/switch.tsx +++ b/packages/design-system/src/components/switch.tsx @@ -30,9 +30,10 @@ const switchStyle = css({ backgroundColor: theme.colors.backgroundNeutralDark, }, - "&[data-state=checked]:not([data-disabled]):before": { - backgroundColor: theme.colors.backgroundPrimary, - }, + "&[data-state=checked]:not([data-disabled]):before, &[aria-checked=true]:not([data-disabled]):before": + { + backgroundColor: theme.colors.backgroundPrimary, + }, "&[data-disabled]:before": { backgroundColor: theme.colors.foregroundDisabled, diff --git a/packages/feature-flags/src/flags.ts b/packages/feature-flags/src/flags.ts index 2e87ceab06dc..756ab1091566 100644 --- a/packages/feature-flags/src/flags.ts +++ b/packages/feature-flags/src/flags.ts @@ -9,3 +9,4 @@ export const contentEditableMode = false; export const command = false; export const headSlotComponent = false; export const stylePanelAdvancedMode = false; +export const animation = false; diff --git a/packages/react-sdk/src/component-generator.ts b/packages/react-sdk/src/component-generator.ts index 36871dd71577..18ec71c5aa3d 100644 --- a/packages/react-sdk/src/component-generator.ts +++ b/packages/react-sdk/src/component-generator.ts @@ -104,7 +104,8 @@ const generatePropValue = ({ prop.type === "number" || prop.type === "boolean" || prop.type === "string[]" || - prop.type === "json" + prop.type === "json" || + prop.type === "animationAction" ) { return JSON.stringify(prop.value); } diff --git a/packages/sdk-components-animation/package.json b/packages/sdk-components-animation/package.json index 82a84f54f5b8..b7b01681de27 100644 --- a/packages/sdk-components-animation/package.json +++ b/packages/sdk-components-animation/package.json @@ -58,6 +58,7 @@ "@webstudio-is/icons": "workspace:*", "@webstudio-is/react-sdk": "workspace:*", "@webstudio-is/sdk": "workspace:*", + "change-case": "^5.4.4", "react-error-boundary": "^5.0.0", "scroll-timeline-polyfill": "^1.1.0" }, @@ -72,10 +73,12 @@ "@webstudio-is/sdk-components-react": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/tsconfig": "workspace:*", + "fast-glob": "^3.3.2", "playwright": "^1.50.1", "react": "18.3.0-canary-14898b6a9-20240318", "react-dom": "18.3.0-canary-14898b6a9-20240318", "type-fest": "^4.32.0", - "vitest": "^3.0.4" + "vitest": "^3.0.4", + "zod": "^3.22.4" } } diff --git a/packages/sdk-components-animation/private-src b/packages/sdk-components-animation/private-src index b977781462fd..4ea51f4ca02a 160000 --- a/packages/sdk-components-animation/private-src +++ b/packages/sdk-components-animation/private-src @@ -1 +1 @@ -Subproject commit b977781462fd46fc000b0785b2026c6501786b36 +Subproject commit 4ea51f4ca02a44eec97acb13a463c7f15eb266a0 diff --git a/packages/sdk-components-animation/src/animate-children.tsx b/packages/sdk-components-animation/src/animate-children.tsx new file mode 100644 index 000000000000..5662e2eb8da8 --- /dev/null +++ b/packages/sdk-components-animation/src/animate-children.tsx @@ -0,0 +1,48 @@ +import type { Hook } from "@webstudio-is/react-sdk"; +import type { AnimationAction } from "@webstudio-is/sdk"; +import { forwardRef, type ElementRef } from "react"; +import { animationCanPlayOnCanvasProperty } from "./shared/consts"; + +type ScrollProps = { + debug?: boolean; + children?: React.ReactNode; + action: AnimationAction; +}; + +export const AnimateChildren = forwardRef, ScrollProps>( + ({ debug = false, action, ...props }, ref) => { + return
; + } +); + +const displayName = "AnimateChildren"; +AnimateChildren.displayName = displayName; + +const namespace = "@webstudio-is/sdk-components-animation"; + +export const hooksAnimateChildren: Hook = { + onNavigatorUnselect: (context, event) => { + if ( + event.instancePath.length > 0 && + event.instancePath[0].component === `${namespace}:${displayName}` + ) { + context.setMemoryProp( + event.instancePath[0], + animationCanPlayOnCanvasProperty, + undefined + ); + } + }, + onNavigatorSelect: (context, event) => { + if ( + event.instancePath.length > 0 && + event.instancePath[0].component === `${namespace}:${displayName}` + ) { + context.setMemoryProp( + event.instancePath[0], + animationCanPlayOnCanvasProperty, + true + ); + } + }, +}; diff --git a/packages/sdk-components-animation/src/components.ts b/packages/sdk-components-animation/src/components.ts index b8a77f059a67..ab47b1c28903 100644 --- a/packages/sdk-components-animation/src/components.ts +++ b/packages/sdk-components-animation/src/components.ts @@ -1 +1 @@ -export { Scroll } from "./scroll"; +export { AnimateChildren } from "./animate-children"; diff --git a/packages/sdk-components-animation/src/hooks.ts b/packages/sdk-components-animation/src/hooks.ts index 1ade99e3faec..056ef4c318cc 100644 --- a/packages/sdk-components-animation/src/hooks.ts +++ b/packages/sdk-components-animation/src/hooks.ts @@ -1,3 +1,4 @@ import type { Hook } from "@webstudio-is/react-sdk"; +import { hooksAnimateChildren } from "./animate-children"; -export const hooks: Hook[] = []; +export const hooks: Hook[] = [hooksAnimateChildren]; diff --git a/packages/sdk-components-animation/src/metas.ts b/packages/sdk-components-animation/src/metas.ts index cb0ff5c3b541..3f70a4394ccb 100644 --- a/packages/sdk-components-animation/src/metas.ts +++ b/packages/sdk-components-animation/src/metas.ts @@ -1 +1 @@ -export {}; +export { meta as AnimateChildren } from "./scroll.ws"; diff --git a/packages/sdk-components-animation/src/props.ts b/packages/sdk-components-animation/src/props.ts index cb0ff5c3b541..677fd7a91a70 100644 --- a/packages/sdk-components-animation/src/props.ts +++ b/packages/sdk-components-animation/src/props.ts @@ -1 +1 @@ -export {}; +export { propsMeta as AnimateChildren } from "./scroll.ws"; diff --git a/packages/sdk-components-animation/src/scroll.tsx b/packages/sdk-components-animation/src/scroll.tsx deleted file mode 100644 index 17e0cc3f5aab..000000000000 --- a/packages/sdk-components-animation/src/scroll.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { forwardRef, type ElementRef } from "react"; -import type { AnimationAction } from "./shared/animation-types"; - -type ScrollProps = { - debug?: boolean; - children?: React.ReactNode; - action: AnimationAction; -}; - -export const Scroll = forwardRef, ScrollProps>( - ({ debug = false, action, ...props }, ref) => { - return
; - } -); diff --git a/packages/sdk-components-animation/src/scroll.ws.ts b/packages/sdk-components-animation/src/scroll.ws.ts new file mode 100644 index 000000000000..ce46b4356b08 --- /dev/null +++ b/packages/sdk-components-animation/src/scroll.ws.ts @@ -0,0 +1,23 @@ +import { SlotComponentIcon } from "@webstudio-is/icons/svg"; +import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk"; + +export const meta: WsComponentMeta = { + category: "general", + type: "container", + description: "Animate Children", + icon: SlotComponentIcon, + order: 5, + label: "Animate Children", +}; + +export const propsMeta: WsComponentPropsMeta = { + props: { + action: { + required: false, + control: "animationAction", + type: "animationAction", + description: "Animation Action", + }, + }, + initialProps: ["action"], +}; diff --git a/packages/sdk-components-animation/src/shared/animation-types.tsx b/packages/sdk-components-animation/src/shared/animation-types.tsx deleted file mode 100644 index ba1349e6c249..000000000000 --- a/packages/sdk-components-animation/src/shared/animation-types.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type { StyleValue, UnitValue } from "@webstudio-is/css-engine"; - -export type KeyframeStyles = { [property: string]: StyleValue | undefined }; - -export type AnimationKeyframe = { - offset: number | undefined; - // We are using composite: auto as the default value for now - // composite?: CompositeOperationOrAuto; - styles: KeyframeStyles; -}; - -const RANGE_UNITS = [ - "%", - "px", - // Does not supported by polyfill and we are converting it to px ourselfs - "cm", - "mm", - "q", - "in", - "pt", - "pc", - "em", - "rem", - "ex", - "rex", - "cap", - "rcap", - "ch", - "rch", - "lh", - "rlh", - "vw", - "svw", - "lvw", - "dvw", - "vh", - "svh", - "lvh", - "dvh", - "vi", - "svi", - "lvi", - "dvi", - "vb", - "svb", - "lvb", - "dvb", - "vmin", - "svmin", - "lvmin", - "dvmin", - "vmax", - "svmax", - "lvmax", - "dvmax", -] as const; - -export type RangeUnit = (typeof RANGE_UNITS)[number]; - -export const isRangeUnit = (value: unknown): value is RangeUnit => - RANGE_UNITS.includes(value as RangeUnit); - -export type RangeUnitValue = { type: "unit"; value: number; unit: RangeUnit }; - -({}) as RangeUnitValue satisfies UnitValue; - -type KeyframeEffectOptions = { - easing?: string; - fill?: FillMode; -}; - -/** - * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges - * However, for simplicity and type unification with the view, we will use the names "start" and "end," - * which will be transformed as follows: - * - "start" → `calc(0% + range)` - * - "end" → `calc(100% - range)` - */ -export type ScrollNamedRange = "start" | "end"; - -/** - * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges - * However, for simplicity and type unification with the view, we will use the names "start" and "end," - * See ScrollNamedRange type for more information. - */ -export type ScrollRangeValue = [name: ScrollNamedRange, value: RangeUnitValue]; - -type ScrollRangeOptions = { - rangeStart?: ScrollRangeValue | undefined; - rangeEnd?: ScrollRangeValue | undefined; -}; - -/* -type AnimationTiming = { - delay?: number; - duration?: number; - easing?: string; - fill?: FillMode; -}; -*/ - -type AnimationAxis = "block" | "inline" | "x" | "y"; - -type ScrollAction = { - type: "scroll"; - source?: "closest" | "nearest" | "root"; - axis?: AnimationAxis; - animations: { - timing: KeyframeEffectOptions & ScrollRangeOptions; - keyframes: AnimationKeyframe[]; - }[]; -}; - -export type ViewNamedRange = - | "contain" - | "cover" - | "entry" - | "exit" - | "entry-crossing" - | "exit-crossing"; - -export type ViewRangeValue = [name: ViewNamedRange, value: RangeUnitValue]; - -type ViewRangeOptions = { - rangeStart?: ViewRangeValue | undefined; - rangeEnd?: ViewRangeValue | undefined; -}; - -type ViewAction = { - type: "view"; - subject?: string; - - axis?: AnimationAxis; - animations: { - timing: KeyframeEffectOptions & ViewRangeOptions; - keyframes: AnimationKeyframe[]; - }[]; -}; - -export type AnimationAction = ScrollAction | ViewAction; diff --git a/packages/sdk-components-animation/src/shared/consts.ts b/packages/sdk-components-animation/src/shared/consts.ts new file mode 100644 index 000000000000..0a2dee9706ec --- /dev/null +++ b/packages/sdk-components-animation/src/shared/consts.ts @@ -0,0 +1,2 @@ +export const animationCanPlayOnCanvasProperty = + "data-ws-animation-can-play-on-canvas"; diff --git a/packages/sdk-components-animation/tsconfig.json b/packages/sdk-components-animation/tsconfig.json index b3abf47f839c..0744a4ea7304 100644 --- a/packages/sdk-components-animation/tsconfig.json +++ b/packages/sdk-components-animation/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "@webstudio-is/tsconfig/base.json", - "include": ["src", "../../@types/**/scroll-timeline.d.ts", "private-src"], + "include": [ + "src", + "../../@types/**/scroll-timeline.d.ts", + "private-src", + "../sdk/src/schema/animation-schema.ts" + ], "compilerOptions": { "types": ["react/experimental", "react-dom/experimental", "@types/node"] } diff --git a/packages/sdk-components-animation/vitest.config.ts b/packages/sdk-components-animation/vitest.config.ts index cd93bbe1ed1f..e43995a09338 100644 --- a/packages/sdk-components-animation/vitest.config.ts +++ b/packages/sdk-components-animation/vitest.config.ts @@ -1,6 +1,30 @@ import { defineConfig } from "vitest/config"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, test: { passWithNoTests: true, workspace: [ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 10d1c9e110c4..c248ab590f94 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -23,3 +23,30 @@ export * from "./resources-generator"; export * from "./page-meta-generator"; export * from "./url-pattern"; export * from "./css"; + +export type { + AnimationAction, + AnimationActionScroll, + AnimationActionView, + AnimationKeyframe, + KeyframeStyles, + RangeUnit, + RangeUnitValue, + ScrollNamedRange, + ScrollRangeValue, + ViewNamedRange, + ViewRangeValue, + ScrollAnimation, + ViewAnimation, + InsetUnitValue, +} from "./schema/animation-schema"; + +export { + animationActionSchema, + scrollAnimationSchema, + viewAnimationSchema, + rangeUnitValueSchema, + animationKeyframeSchema, + insetUnitValueSchema, + RANGE_UNITS, +} from "./schema/animation-schema"; diff --git a/packages/sdk/src/schema/animation-schema.ts b/packages/sdk/src/schema/animation-schema.ts new file mode 100644 index 000000000000..1e972d9fbf76 --- /dev/null +++ b/packages/sdk/src/schema/animation-schema.ts @@ -0,0 +1,222 @@ +import { StyleValue } from "@webstudio-is/css-engine"; +import { z } from "zod"; + +// Helper for creating union of string literals from array +const literalUnion = (arr: T) => + z.union( + arr.map((val) => z.literal(val)) as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[], + ] + ); + +// Range Units +export const RANGE_UNITS = [ + "%", + "px", + "cm", + "mm", + "q", + "in", + "pt", + "pc", + "em", + "rem", + "ex", + "rex", + "cap", + "rcap", + "ch", + "rch", + "lh", + "rlh", + "vw", + "svw", + "lvw", + "dvw", + "vh", + "svh", + "lvh", + "dvh", + "vi", + "svi", + "lvi", + "dvi", + "vb", + "svb", + "lvb", + "dvb", + "vmin", + "svmin", + "lvmin", + "dvmin", + "vmax", + "svmax", + "lvmax", + "dvmax", +] as const; + +export const rangeUnitSchema = literalUnion(RANGE_UNITS); + +export const rangeUnitValueSchema = z.union([ + z.object({ + type: z.literal("unit"), + value: z.number(), + unit: rangeUnitSchema, + }), + z.object({ + type: z.literal("unparsed"), + value: z.string(), + }), +]); + +// view-timeline-inset +export const insetUnitValueSchema = z.union([ + rangeUnitValueSchema, + z.object({ + type: z.literal("keyword"), + value: z.literal("auto"), + }), +]); + +// @todo: Fix Keyframe Styles +export const keyframeStylesSchema = z.record(StyleValue); + +// Animation Keyframe +export const animationKeyframeSchema = z.object({ + offset: z.number().optional(), + styles: keyframeStylesSchema, +}); + +// Keyframe Effect Options +export const keyframeEffectOptionsSchema = z.object({ + easing: z.string().optional(), + fill: z + .union([ + z.literal("none"), + z.literal("forwards"), + z.literal("backwards"), + z.literal("both"), + ]) + .optional(), // FillMode +}); + +// Scroll Named Range +export const scrollNamedRangeSchema = z.union([ + z.literal("start"), + z.literal("end"), +]); + +// Scroll Range Value +export const scrollRangeValueSchema = z.tuple([ + scrollNamedRangeSchema, + rangeUnitValueSchema, +]); + +// Scroll Range Options +export const scrollRangeOptionsSchema = z.object({ + rangeStart: scrollRangeValueSchema.optional(), + rangeEnd: scrollRangeValueSchema.optional(), +}); + +// Animation Axis +export const animationAxisSchema = z.union([ + z.literal("block"), + z.literal("inline"), + z.literal("x"), + z.literal("y"), +]); + +// View Named Range +export const viewNamedRangeSchema = z.union([ + z.literal("contain"), + z.literal("cover"), + z.literal("entry"), + z.literal("exit"), + z.literal("entry-crossing"), + z.literal("exit-crossing"), +]); + +// View Range Value +export const viewRangeValueSchema = z.tuple([ + viewNamedRangeSchema, + rangeUnitValueSchema, +]); + +// View Range Options +export const viewRangeOptionsSchema = z.object({ + rangeStart: viewRangeValueSchema.optional(), + rangeEnd: viewRangeValueSchema.optional(), +}); + +const baseAnimation = z.object({ + name: z.string().optional(), + description: z.string().optional(), + keyframes: z.array(animationKeyframeSchema), +}); + +export const scrollAnimationSchema = baseAnimation.merge( + z.object({ + timing: keyframeEffectOptionsSchema.merge(scrollRangeOptionsSchema), + }) +); + +// Scroll Action +export const scrollActionSchema = z.object({ + type: z.literal("scroll"), + source: z + .union([z.literal("closest"), z.literal("nearest"), z.literal("root")]) + .optional(), + axis: animationAxisSchema.optional(), + animations: z.array(scrollAnimationSchema), + isPinned: z.boolean().optional(), +}); + +export const viewAnimationSchema = baseAnimation.merge( + z.object({ + timing: keyframeEffectOptionsSchema.merge(viewRangeOptionsSchema), + }) +); + +// View Action +export const viewActionSchema = z.object({ + type: z.literal("view"), + subject: z.string().optional(), + axis: animationAxisSchema.optional(), + animations: z.array(viewAnimationSchema), + + insetStart: insetUnitValueSchema.optional(), + + insetEnd: insetUnitValueSchema.optional(), + + isPinned: z.boolean().optional(), +}); + +// Animation Action +export const animationActionSchema = z.discriminatedUnion("type", [ + scrollActionSchema, + viewActionSchema, +]); + +// Helper function to check if a value is a valid range unit +export const isRangeUnit = ( + value: unknown +): value is z.infer => + rangeUnitSchema.safeParse(value).success; + +// Type exports +export type RangeUnit = z.infer; +export type RangeUnitValue = z.infer; +export type KeyframeStyles = z.infer; +export type AnimationKeyframe = z.infer; +export type ScrollNamedRange = z.infer; +export type ScrollRangeValue = z.infer; +export type ViewNamedRange = z.infer; +export type ViewRangeValue = z.infer; +export type AnimationActionScroll = z.infer; +export type AnimationActionView = z.infer; +export type AnimationAction = z.infer; +export type ScrollAnimation = z.infer; +export type ViewAnimation = z.infer; +export type InsetUnitValue = z.infer; diff --git a/packages/sdk/src/schema/prop-meta.ts b/packages/sdk/src/schema/prop-meta.ts index e10577d3c34a..e19e9798df1f 100644 --- a/packages/sdk/src/schema/prop-meta.ts +++ b/packages/sdk/src/schema/prop-meta.ts @@ -167,6 +167,13 @@ const TextContent = z.object({ defaultValue: z.string().optional(), }); +const AnimationAction = z.object({ + ...common, + control: z.literal("animationAction"), + type: z.literal("animationAction"), + defaultValue: z.undefined().optional(), +}); + export const PropMeta = z.union([ Number, Range, @@ -187,6 +194,7 @@ export const PropMeta = z.union([ Date, Action, TextContent, + AnimationAction, ]); export type PropMeta = z.infer; diff --git a/packages/sdk/src/schema/props.ts b/packages/sdk/src/schema/props.ts index 6848538b1231..a17cbf521242 100644 --- a/packages/sdk/src/schema/props.ts +++ b/packages/sdk/src/schema/props.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { animationActionSchema } from "./animation-schema"; const PropId = z.string(); @@ -80,6 +81,11 @@ export const Prop = z.union([ }) ), }), + z.object({ + ...baseProp, + type: z.literal("animationAction"), + value: animationActionSchema, + }), ]); export type Prop = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 531aa26c67ef..104726878254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,6 +452,9 @@ importers: '@webstudio-is/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 html-tags: specifier: ^4.0.0 version: 4.0.0 @@ -921,6 +924,9 @@ importers: '@types/react-dom': specifier: ^18.2.25 version: 18.2.25 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 typescript: specifier: 5.7.3 version: 5.7.3 @@ -1856,6 +1862,9 @@ importers: '@webstudio-is/sdk': specifier: workspace:* version: link:../sdk + change-case: + specifier: ^5.4.4 + version: 5.4.4 react-error-boundary: specifier: ^5.0.0 version: 5.0.0(react@18.3.0-canary-14898b6a9-20240318) @@ -1893,6 +1902,9 @@ importers: '@webstudio-is/tsconfig': specifier: workspace:* version: link:../tsconfig + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 playwright: specifier: ^1.50.1 version: 1.50.1 @@ -1908,6 +1920,9 @@ importers: vitest: specifier: ^3.0.4 version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.7)(@vitest/browser@3.0.5)(jsdom@20.0.3)(msw@2.7.0(@types/node@22.10.7)(typescript@5.7.3)) + zod: + specifier: ^3.22.4 + version: 3.22.4 packages/sdk-components-react: dependencies: