From 3c609be6cc87bb1adc4df7a0a84d4e62333e6006 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 14 Aug 2026 19:07:50 -0700 Subject: [PATCH 1/2] Migrate Solid to 2.0 --- apps/openstory/package.json | 6 +- apps/openstory/preview.tsx | 2 +- .../playground/freeze-demo.stories.tsx | 6 +- .../playground/live-updates.stories.tsx | 6 +- apps/openstory/stories/renderer.stories.tsx | 10 +- apps/openstory/stories/target-box.tsx | 3 +- apps/openstory/tsconfig.json | 2 +- apps/openstory/vite.config.ts | 7 +- .../e2e/solid-source-location.spec.ts | 4 +- packages/react-grab/package.json | 5 +- packages/react-grab/solid-babel-plugin.ts | 4 +- .../src/components/context-menu.tsx | 82 ++- .../react-grab/src/components/frozen-glow.tsx | 17 +- .../src/components/menu/menu-item.tsx | 9 +- .../src/components/menu/menu-list.tsx | 3 +- .../src/components/menu/menu-panel.tsx | 3 +- .../src/components/menu/menu-provider.tsx | 5 +- .../src/components/menu/menu-store.ts | 7 +- .../src/components/overlay-canvas.tsx | 250 +++++---- .../selection-label/completion-view.tsx | 25 +- .../components/selection-label/error-view.tsx | 7 +- .../src/components/selection-label/index.tsx | 32 +- .../src/components/toolbar/hierarchy-menu.tsx | 12 +- .../src/components/toolbar/index.tsx | 153 +++--- .../toolbar/toolbar-action-button.tsx | 75 +-- .../components/toolbar/toolbar-content.tsx | 7 +- .../react-grab/src/components/tooltip.tsx | 50 +- .../ui/anchored-dropdown-surface.tsx | 9 +- .../react-grab/src/components/ui/button.tsx | 10 +- .../react-grab/src/components/ui/surface.tsx | 8 +- packages/react-grab/src/core/index.tsx | 498 +++++++++--------- .../react-grab/src/core/plugin-registry.ts | 23 +- packages/react-grab/src/core/store.ts | 327 +++++++----- packages/react-grab/src/types.ts | 2 +- .../src/utils/create-anchored-dropdown.ts | 120 ++--- .../create-component-name-for-element.ts | 40 +- .../src/utils/create-confirmation-keyboard.ts | 13 +- .../react-grab/src/utils/modifier-tracker.ts | 8 +- packages/react-grab/tsconfig.json | 2 +- packages/react-grab/vite.config.ts | 2 +- pnpm-lock.yaml | 341 ++++++++---- 41 files changed, 1190 insertions(+), 1005 deletions(-) diff --git a/apps/openstory/package.json b/apps/openstory/package.json index 6a8a01746..89c129dc8 100644 --- a/apps/openstory/package.json +++ b/apps/openstory/package.json @@ -13,14 +13,16 @@ "openstory": "^0.1.0" }, "devDependencies": { + "@solidjs/vite-plugin": "3.0.0-next.28", + "@solidjs/web": "2.0.0-rc.0", "@types/node": "^25.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "react-grab": "workspace:*", - "solid-js": "^1.9.12", + "solid-js": "2.0.0-rc.0", "typescript": "^6.0.3", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", - "vite-plugin-solid": "^2.11.12" + "vite-plugin-solid": "3.0.0-next.27" }, "optionalDependencies": { "react": "19.2.6", diff --git a/apps/openstory/preview.tsx b/apps/openstory/preview.tsx index c434a75cc..0cdb6c81f 100644 --- a/apps/openstory/preview.tsx +++ b/apps/openstory/preview.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "solid-js"; +import type { JSX } from "@solidjs/web"; import "react-grab/dist/styles.css"; import type { Preview } from "openstory/solid"; diff --git a/apps/openstory/stories/playground/freeze-demo.stories.tsx b/apps/openstory/stories/playground/freeze-demo.stories.tsx index 103e39572..c3cdfaf74 100644 --- a/apps/openstory/stories/playground/freeze-demo.stories.tsx +++ b/apps/openstory/stories/playground/freeze-demo.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "openstory/solid"; -import { onCleanup, onMount } from "solid-js"; +import { onSettled } from "solid-js"; import { createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -9,11 +9,11 @@ import { BouncingTimer } from "./bouncing-timer.react.js"; const ReactHost = () => { let hostElement: HTMLDivElement | undefined; - onMount(() => { + onSettled(() => { if (!hostElement) return; const reactRoot: Root = createRoot(hostElement); reactRoot.render(createElement(BouncingTimer)); - onCleanup(() => reactRoot.unmount()); + return () => reactRoot.unmount(); }); return
; diff --git a/apps/openstory/stories/playground/live-updates.stories.tsx b/apps/openstory/stories/playground/live-updates.stories.tsx index bf70df345..b5f29d3e3 100644 --- a/apps/openstory/stories/playground/live-updates.stories.tsx +++ b/apps/openstory/stories/playground/live-updates.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "openstory/solid"; -import { onCleanup, onMount } from "solid-js"; +import { onSettled } from "solid-js"; import { createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -9,11 +9,11 @@ import { LiveCounter } from "./live-counter.react.js"; const ReactHost = () => { let hostElement: HTMLDivElement | undefined; - onMount(() => { + onSettled(() => { if (!hostElement) return; const reactRoot: Root = createRoot(hostElement); reactRoot.render(createElement(LiveCounter)); - onCleanup(() => reactRoot.unmount()); + return () => reactRoot.unmount(); }); return
; diff --git a/apps/openstory/stories/renderer.stories.tsx b/apps/openstory/stories/renderer.stories.tsx index 2c502e89e..d1f91b1ca 100644 --- a/apps/openstory/stories/renderer.stories.tsx +++ b/apps/openstory/stories/renderer.stories.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"; +import { createEffect, createSignal, onSettled } from "solid-js"; import type { Meta, StoryContext, StoryObj } from "openstory/solid"; import { expect, waitFor } from "openstory/test"; import { ReactGrabRenderer } from "@react-grab-source/components/renderer.js"; @@ -89,16 +89,16 @@ const Scene = (props: SceneProps) => { }); }; - onMount(() => { + onSettled(() => { recompute(); window.addEventListener("resize", recompute); - onCleanup(() => { + return () => { window.removeEventListener("resize", recompute); if (pendingFrameHandle !== null) cancelAnimationFrame(pendingFrameHandle); - }); + }; }); - createEffect(on(() => props.selectedElement, scheduleRecompute, { defer: true })); + createEffect(() => props.selectedElement, scheduleRecompute, { defer: true }); const elementMeta = (): ElementMeta => { const element = elementRefs[props.selectedElement]; diff --git a/apps/openstory/stories/target-box.tsx b/apps/openstory/stories/target-box.tsx index 56c42d5f5..85320c78a 100644 --- a/apps/openstory/stories/target-box.tsx +++ b/apps/openstory/stories/target-box.tsx @@ -1,4 +1,5 @@ -import type { Component, JSX } from "solid-js"; +import type { Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { DEMO_BOUNDS } from "./demo-bounds.js"; export const TargetBox: Component = () => ( diff --git a/apps/openstory/tsconfig.json b/apps/openstory/tsconfig.json index 3affa00b5..9768444a4 100644 --- a/apps/openstory/tsconfig.json +++ b/apps/openstory/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNext", "moduleResolution": "Bundler", "jsx": "preserve", - "jsxImportSource": "solid-js", + "jsxImportSource": "@solidjs/web", "strict": true, "noEmit": true, "esModuleInterop": true, diff --git a/apps/openstory/vite.config.ts b/apps/openstory/vite.config.ts index 7b4007e6e..1d1229382 100644 --- a/apps/openstory/vite.config.ts +++ b/apps/openstory/vite.config.ts @@ -1,10 +1,10 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; -import solid from "vite-plugin-solid"; +import solid from "@solidjs/vite-plugin"; import { openstory } from "openstory/plugin"; import { solidSourceLocationBabelPlugin } from "../../packages/react-grab/solid-source-location-babel-plugin.js"; -const REACT_FILE_PATTERN = /\.react\.tsx$/; +const REACT_FILE_PATTERN = "**/*.react.tsx"; export default defineConfig(({ command }) => ({ plugins: [ @@ -21,11 +21,12 @@ export default defineConfig(({ command }) => ({ ], resolve: { alias: { + "solid-js/web": "@solidjs/web", "@react-grab-source": fileURLToPath( new URL("../../packages/react-grab/src", import.meta.url), ), }, - dedupe: ["solid-js", "solid-js/web"], + dedupe: ["solid-js", "@solidjs/web"], }, define: { "process.env.VERSION": JSON.stringify("[DEV]"), diff --git a/packages/react-grab/e2e/solid-source-location.spec.ts b/packages/react-grab/e2e/solid-source-location.spec.ts index e5ecb2e8e..f0d5be657 100644 --- a/packages/react-grab/e2e/solid-source-location.spec.ts +++ b/packages/react-grab/e2e/solid-source-location.spec.ts @@ -6,8 +6,8 @@ import { SOLID_SOURCE_LOCATION_ATTRIBUTE } from "../src/utils/resolve-solid-sour const TOOLBAR_ACTION_SELECTOR = "[data-react-grab-toolbar-action]"; const TOOLBAR_ACTION_SOURCE_PATH = "packages/react-grab/src/components/toolbar/toolbar-action-button.tsx"; -const TOOLBAR_ACTION_SOURCE_LINE_NUMBER = 24; -const TOOLBAR_ACTION_SOURCE_COLUMN_NUMBER = 5; +const TOOLBAR_ACTION_SOURCE_LINE_NUMBER = 39; +const TOOLBAR_ACTION_SOURCE_COLUMN_NUMBER = 7; test("exposes exact Solid sources only in development", async ({ reactGrab }, testInfo) => { await reactGrab.activate(); diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 058893c19..404fef756 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -117,14 +117,15 @@ "@jridgewell/trace-mapping": "^0.3.31", "@playwright/test": "^1.59.1", "@react-grab/playwright-coverage": "workspace:*", + "@solidjs/web": "2.0.0-rc.0", "@tailwindcss/cli": "^4.3.0", "@types/babel__core": "^7.20.5", "@types/node": "^25.6.2", "@types/react": "^19.2.14", - "babel-preset-solid": "^1.9.12", + "babel-preset-solid": "2.0.0-rc.0", "concurrently": "^9.2.1", "expect-sdk": "^0.1.2", - "solid-js": "^1.9.12", + "solid-js": "2.0.0-rc.0", "tailwindcss": "^4.3.0", "tsx": "^4.21.0", "vite-plus": "^0.1.20" diff --git a/packages/react-grab/solid-babel-plugin.ts b/packages/react-grab/solid-babel-plugin.ts index d6b26739b..516f100f6 100644 --- a/packages/react-grab/solid-babel-plugin.ts +++ b/packages/react-grab/solid-babel-plugin.ts @@ -33,14 +33,14 @@ export const cssTextPlugin = () => { export const solidWebBrowserPlugin = () => { const require = createRequire(import.meta.url); - const serverPath = require.resolve("solid-js/web"); + const serverPath = require.resolve("@solidjs/web"); const distDir = dirname(serverPath); const browserPath = resolve(distDir, "web.js"); return { name: "solid-web-browser", enforce: "pre" as const, resolveId(source: string) { - if (source === "solid-js/web") return browserPath; + if (source === "@solidjs/web") return browserPath; }, }; }; diff --git a/packages/react-grab/src/components/context-menu.tsx b/packages/react-grab/src/components/context-menu.tsx index 5caa21dc3..f6f5d8ac0 100644 --- a/packages/react-grab/src/components/context-menu.tsx +++ b/packages/react-grab/src/components/context-menu.tsx @@ -3,9 +3,7 @@ import { createMemo, createSignal, For, - on, - onCleanup, - onMount, + onSettled, Show, type Component, } from "solid-js"; @@ -98,8 +96,8 @@ export const ContextMenu: Component = (props) => { // Elements that were just mounted may not have been laid out yet, so without // deferring to the next frame the measured dimensions are zero and the menu // would flash at the wrong position before jumping to its correct spot. - createEffect(() => { - if (isVisible()) { + createEffect(isVisible, (visible) => { + if (visible) { nativeRequestAnimationFrame(measureContainer); } }); @@ -174,43 +172,41 @@ export const ContextMenu: Component = (props) => { // restore focus to whatever the host page had focused — but only if // nothing else has already claimed focus (e.g. the prompt-mode textarea // that an action opened), so we do not yank it back. - createEffect( - on(isVisible, (visible) => { - if (visible) { - // document.activeElement returns the shadow host when focus is - // inside the shadow root, so use the host's shadowRoot.activeElement - // to detect a focused element that already lives in our own DOM - // tree; we should not capture our own host as "previous". - const hostShadowRoot = containerRef?.getRootNode(); - const focusInsideHost = - hostShadowRoot instanceof ShadowRoot ? hostShadowRoot.activeElement : null; - const pageActiveElement = document.activeElement; - const wasFocusedOnPage = - pageActiveElement instanceof HTMLElement && - focusInsideHost === null && - !(containerRef instanceof Element && containerRef.contains(pageActiveElement)); - previouslyFocusedElement = wasFocusedOnPage ? pageActiveElement : null; - menuContainerRef?.focus({ preventScroll: true }); - return; - } - menuStore.setActiveItem(null); - const restoreTarget = previouslyFocusedElement; - previouslyFocusedElement = null; - if (!(restoreTarget instanceof HTMLElement) || !document.contains(restoreTarget)) return; - // Defer to the next frame so an action-triggered focus move (e.g. - // the prompt textarea focusing itself via queueMicrotask) lands - // first. If something other than the body has focus by then, the - // action's side effect deserves to keep it. - nativeRequestAnimationFrame(() => { - const currentActive = document.activeElement; - const isOrphanedFocus = currentActive === null || currentActive === document.body; - if (!isOrphanedFocus) return; - restoreTarget.focus({ preventScroll: true }); - }); - }), - ); + createEffect(isVisible, (visible) => { + if (visible) { + // document.activeElement returns the shadow host when focus is + // inside the shadow root, so use the host's shadowRoot.activeElement + // to detect a focused element that already lives in our own DOM + // tree; we should not capture our own host as "previous". + const hostShadowRoot = containerRef?.getRootNode(); + const focusInsideHost = + hostShadowRoot instanceof ShadowRoot ? hostShadowRoot.activeElement : null; + const pageActiveElement = document.activeElement; + const wasFocusedOnPage = + pageActiveElement instanceof HTMLElement && + focusInsideHost === null && + !(containerRef instanceof Element && containerRef.contains(pageActiveElement)); + previouslyFocusedElement = wasFocusedOnPage ? pageActiveElement : null; + menuContainerRef?.focus({ preventScroll: true }); + return; + } + menuStore.setActiveItem(null); + const restoreTarget = previouslyFocusedElement; + previouslyFocusedElement = null; + if (!(restoreTarget instanceof HTMLElement) || !document.contains(restoreTarget)) return; + // Defer to the next frame so an action-triggered focus move (e.g. + // the prompt textarea focusing itself via queueMicrotask) lands + // first. If something other than the body has focus by then, the + // action's side effect deserves to keep it. + nativeRequestAnimationFrame(() => { + const currentActive = document.activeElement; + const isOrphanedFocus = currentActive === null || currentActive === document.body; + if (!isOrphanedFocus) return; + restoreTarget.focus({ preventScroll: true }); + }); + }); - onMount(() => { + onSettled(() => { measureContainer(); const handleKeyDown = (event: KeyboardEvent) => { @@ -278,10 +274,10 @@ export const ContextMenu: Component = (props) => { const gatedHandleKeyDown = ignoreRealInput(handleKeyDown); window.addEventListener("keydown", gatedHandleKeyDown, { capture: true }); - onCleanup(() => { + return () => { unregisterOverlayDismiss(); window.removeEventListener("keydown", gatedHandleKeyDown, { capture: true }); - }); + }; }); const accessibleMenuLabel = createMemo(() => { diff --git a/packages/react-grab/src/components/frozen-glow.tsx b/packages/react-grab/src/components/frozen-glow.tsx index ac8549775..d417ce4e9 100644 --- a/packages/react-grab/src/components/frozen-glow.tsx +++ b/packages/react-grab/src/components/frozen-glow.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup, onMount, type Component } from "solid-js"; +import { createEffect, createSignal, onSettled, type Component } from "solid-js"; import { FADE_DURATION_MS, FROZEN_GLOW_COLOR, @@ -27,24 +27,27 @@ export const FrozenGlow: Component = (props) => { if (scopeContainer) { const handleViewportChange = () => setScopeRect(measureRect()); - onMount(() => { + onSettled(() => { const resizeObserver = new ResizeObserver(handleViewportChange); resizeObserver.observe(scopeContainer); window.addEventListener("scroll", handleViewportChange, { capture: true, passive: true }); window.addEventListener("resize", handleViewportChange); - onCleanup(() => { + return () => { resizeObserver.disconnect(); window.removeEventListener("scroll", handleViewportChange, { capture: true }); window.removeEventListener("resize", handleViewportChange); - }); + }; }); // ResizeObserver misses position-only layout shifts (e.g. content above // the container expanding while the showcase is idle), so re-measure at // the moment the glow fades in — the only time a stale rect would show. - createEffect(() => { - if (props.visible) handleViewportChange(); - }); + createEffect( + () => props.visible, + (visible) => { + if (visible) handleViewportChange(); + }, + ); } const top = () => { diff --git a/packages/react-grab/src/components/menu/menu-item.tsx b/packages/react-grab/src/components/menu/menu-item.tsx index 3dc381850..46d4c5d63 100644 --- a/packages/react-grab/src/components/menu/menu-item.tsx +++ b/packages/react-grab/src/components/menu/menu-item.tsx @@ -1,4 +1,5 @@ -import { onCleanup, onMount, type Component, type JSX } from "solid-js"; +import { onCleanup, onSettled, type Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { cn } from "../../utils/cn.js"; import { useMenuStore } from "./menu-context.js"; @@ -30,7 +31,7 @@ export const MenuItem: Component = (props) => { let buttonElement: HTMLButtonElement | undefined; - onMount(() => { + onSettled(() => { if (!buttonElement) return; store.registerItem({ value: registeredValue, @@ -50,8 +51,8 @@ export const MenuItem: Component = (props) => { data-react-grab-menu-item={props.dataId ?? registeredValue} type="button" role={role()} - aria-checked={role() === "menuitemradio" ? Boolean(props.checked) : undefined} - aria-disabled={Boolean(props.disabled)} + aria-checked={role() === "menuitemradio" ? (props.checked ? "true" : "false") : undefined} + aria-disabled={props.disabled ? "true" : "false"} tabindex={store.keyboardNavigation ? (isActive() ? 0 : -1) : undefined} disabled={props.disabled} class={cn( diff --git a/packages/react-grab/src/components/menu/menu-list.tsx b/packages/react-grab/src/components/menu/menu-list.tsx index b08107558..3f5d2c83b 100644 --- a/packages/react-grab/src/components/menu/menu-list.tsx +++ b/packages/react-grab/src/components/menu/menu-list.tsx @@ -1,4 +1,5 @@ -import type { Component, JSX } from "solid-js"; +import type { Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { cn } from "../../utils/cn.js"; import { useMenuStore } from "./menu-context.js"; diff --git a/packages/react-grab/src/components/menu/menu-panel.tsx b/packages/react-grab/src/components/menu/menu-panel.tsx index 51ba07e43..bf6900d03 100644 --- a/packages/react-grab/src/components/menu/menu-panel.tsx +++ b/packages/react-grab/src/components/menu/menu-panel.tsx @@ -1,4 +1,5 @@ -import type { Component, JSX } from "solid-js"; +import type { Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { cn } from "../../utils/cn.js"; import { Surface } from "../ui/surface.js"; diff --git a/packages/react-grab/src/components/menu/menu-provider.tsx b/packages/react-grab/src/components/menu/menu-provider.tsx index cce77638c..e012d0f1b 100644 --- a/packages/react-grab/src/components/menu/menu-provider.tsx +++ b/packages/react-grab/src/components/menu/menu-provider.tsx @@ -1,4 +1,5 @@ -import type { Component, JSX } from "solid-js"; +import type { Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { MenuContext, type MenuStore } from "./menu-context.js"; interface MenuProviderProps { @@ -7,5 +8,5 @@ interface MenuProviderProps { } export const MenuProvider: Component = (props) => ( - {props.children} + {props.children} ); diff --git a/packages/react-grab/src/components/menu/menu-store.ts b/packages/react-grab/src/components/menu/menu-store.ts index 8ffbfb076..8d12d2c26 100644 --- a/packages/react-grab/src/components/menu/menu-store.ts +++ b/packages/react-grab/src/components/menu/menu-store.ts @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, on, type Accessor } from "solid-js"; +import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"; import { createMenuHighlight } from "../../utils/create-menu-highlight.js"; import type { MenuItemRegistration, MenuStore } from "./menu-context.js"; @@ -48,7 +48,8 @@ export const createMenuStore = (options: CreateMenuStoreOptions = {}): MenuStore const highlight = createMenuHighlight(options.highlight ?? {}); createEffect( - on([activeValue, registryVersion], ([value]) => { + () => [activeValue(), registryVersion()] as const, + ([value]) => { if (value === null) { highlight.clearHighlight(); return; @@ -59,7 +60,7 @@ export const createMenuStore = (options: CreateMenuStoreOptions = {}): MenuStore } else { highlight.clearHighlight(); } - }), + }, ); const enabledValues = (): string[] => diff --git a/packages/react-grab/src/components/overlay-canvas.tsx b/packages/react-grab/src/components/overlay-canvas.tsx index 3e5bd7658..a910ecd51 100644 --- a/packages/react-grab/src/components/overlay-canvas.tsx +++ b/packages/react-grab/src/components/overlay-canvas.tsx @@ -1,4 +1,4 @@ -import { createEffect, on, onCleanup, onMount, type Component } from "solid-js"; +import { createEffect, onSettled, type Component } from "solid-js"; import type { OverlayBounds, SelectionLabelInstance } from "../types.js"; import { lerp } from "../utils/lerp.js"; import { @@ -421,158 +421,152 @@ export const OverlayCanvas: Component = (props) => { }; createEffect( - on( - () => - [ - props.selectionVisible, - props.selectionBounds, - props.selectionBoundsMultiple, - props.selectionShouldSnap, - ] as const, - ([isVisible, singleBounds, multipleBounds, shouldSnap]) => { - if (!isVisible || (!singleBounds && (!multipleBounds || multipleBounds.length === 0))) { - selectionAnimations = []; - scheduleAnimationFrame(); - return; - } - - let boundsToRender: readonly OverlayBounds[]; - if (multipleBounds && multipleBounds.length > 0) { - boundsToRender = multipleBounds; - } else if (singleBounds) { - boundsToRender = [singleBounds]; - } else { - boundsToRender = []; - } + () => + [ + props.selectionVisible, + props.selectionBounds, + props.selectionBoundsMultiple, + props.selectionShouldSnap, + ] as const, + ([isVisible, singleBounds, multipleBounds, shouldSnap]) => { + if (!isVisible || (!singleBounds && (!multipleBounds || multipleBounds.length === 0))) { + selectionAnimations = []; + scheduleAnimationFrame(); + return; + } - const existingSelectionById = new Map(); - for (const animation of selectionAnimations) { - existingSelectionById.set(animation.id, animation); - } + let boundsToRender: readonly OverlayBounds[]; + if (multipleBounds && multipleBounds.length > 0) { + boundsToRender = multipleBounds; + } else if (singleBounds) { + boundsToRender = [singleBounds]; + } else { + boundsToRender = []; + } - selectionAnimations = boundsToRender.map((bounds, index) => { - const animationId = `selection-${index}`; - const existingAnimation = existingSelectionById.get(animationId); + const existingSelectionById = new Map(); + for (const animation of selectionAnimations) { + existingSelectionById.set(animation.id, animation); + } - if (existingAnimation) { - updateAnimationTarget(existingAnimation, bounds); - if (shouldSnap) { - existingAnimation.current.x = existingAnimation.target.x; - existingAnimation.current.y = existingAnimation.target.y; - existingAnimation.current.width = existingAnimation.target.width; - existingAnimation.current.height = existingAnimation.target.height; - } - return existingAnimation; + selectionAnimations = boundsToRender.map((bounds, index) => { + const animationId = `selection-${index}`; + const existingAnimation = existingSelectionById.get(animationId); + + if (existingAnimation) { + updateAnimationTarget(existingAnimation, bounds); + if (shouldSnap) { + existingAnimation.current.x = existingAnimation.target.x; + existingAnimation.current.y = existingAnimation.target.y; + existingAnimation.current.width = existingAnimation.target.width; + existingAnimation.current.height = existingAnimation.target.height; } + return existingAnimation; + } - return createAnimatedBounds(animationId, bounds); - }); + return createAnimatedBounds(animationId, bounds); + }); - scheduleAnimationFrame(); - }, - ), + scheduleAnimationFrame(); + }, ); createEffect( - on( - () => [props.dragVisible, props.dragBounds] as const, - ([isVisible, bounds]) => { - if (!isVisible || !bounds) { - dragAnimation = null; - scheduleAnimationFrame(); - return; - } + () => [props.dragVisible, props.dragBounds] as const, + ([isVisible, bounds]) => { + if (!isVisible || !bounds) { + dragAnimation = null; + scheduleAnimationFrame(); + return; + } - if (dragAnimation) { - updateAnimationTarget(dragAnimation, bounds); - } else { - dragAnimation = createAnimatedBounds("drag", bounds); - } + if (dragAnimation) { + updateAnimationTarget(dragAnimation, bounds); + } else { + dragAnimation = createAnimatedBounds("drag", bounds); + } - scheduleAnimationFrame(); - }, - ), + scheduleAnimationFrame(); + }, ); createEffect( - on( - () => [props.grabbedBoxes, props.labelInstances] as const, - ([grabbedBoxes, labelInstances]) => { - const boxesToProcess = grabbedBoxes ?? []; - const instancesToProcess = labelInstances ?? []; - - const boxesById = new Map(); - for (const box of boxesToProcess) { - boxesById.set(box.id, box); + () => [props.grabbedBoxes, props.labelInstances] as const, + ([grabbedBoxes, labelInstances]) => { + const boxesToProcess = grabbedBoxes ?? []; + const instancesToProcess = labelInstances ?? []; + + const boxesById = new Map(); + for (const box of boxesToProcess) { + boxesById.set(box.id, box); + } + + // Build one id→animation index up-front so the per-instance lookups + // below are O(1). The previous .find() inside a for-loop produced + // O(boxes × animations) and O(labels × animations) hot work, both + // of which grow with multi-select. + const animationsById = new Map(); + for (const animation of grabbedAnimations) { + animationsById.set(animation.id, animation); + } + + for (const box of boxesToProcess) { + if (!animationsById.has(box.id)) { + const newAnimation = createAnimatedBounds(box.id, box.bounds, { + createdAt: box.createdAt, + }); + grabbedAnimations.push(newAnimation); + animationsById.set(box.id, newAnimation); } + } - // Build one id→animation index up-front so the per-instance lookups - // below are O(1). The previous .find() inside a for-loop produced - // O(boxes × animations) and O(labels × animations) hot work, both - // of which grow with multi-select. - const animationsById = new Map(); - for (const animation of grabbedAnimations) { - animationsById.set(animation.id, animation); + for (const animation of grabbedAnimations) { + const matchingBox = boxesById.get(animation.id); + if (matchingBox) { + updateAnimationTarget(animation, matchingBox.bounds); } + } + + const activeLabelIds = new Set(); + for (const instance of instancesToProcess) { + const boundsToRender = resolveBoundsArray(instance); + const targetOpacity = instance.status === "fading" ? 0 : 1; + + for (let index = 0; index < boundsToRender.length; index++) { + const bounds = boundsToRender[index]; + const animationId = `label-${instance.id}-${index}`; + activeLabelIds.add(animationId); - for (const box of boxesToProcess) { - if (!animationsById.has(box.id)) { - const newAnimation = createAnimatedBounds(box.id, box.bounds, { - createdAt: box.createdAt, + const existingAnimation = animationsById.get(animationId); + if (existingAnimation) { + updateAnimationTarget(existingAnimation, bounds, targetOpacity); + } else { + const newAnimation = createAnimatedBounds(animationId, bounds, { + opacity: 1, + targetOpacity, }); grabbedAnimations.push(newAnimation); - animationsById.set(box.id, newAnimation); - } - } - - for (const animation of grabbedAnimations) { - const matchingBox = boxesById.get(animation.id); - if (matchingBox) { - updateAnimationTarget(animation, matchingBox.bounds); + animationsById.set(animationId, newAnimation); } } + } - const activeLabelIds = new Set(); - for (const instance of instancesToProcess) { - const boundsToRender = resolveBoundsArray(instance); - const targetOpacity = instance.status === "fading" ? 0 : 1; - - for (let index = 0; index < boundsToRender.length; index++) { - const bounds = boundsToRender[index]; - const animationId = `label-${instance.id}-${index}`; - activeLabelIds.add(animationId); - - const existingAnimation = animationsById.get(animationId); - if (existingAnimation) { - updateAnimationTarget(existingAnimation, bounds, targetOpacity); - } else { - const newAnimation = createAnimatedBounds(animationId, bounds, { - opacity: 1, - targetOpacity, - }); - grabbedAnimations.push(newAnimation); - animationsById.set(animationId, newAnimation); - } - } + // Boxes stay in the store for their full fade-out, so an animation + // whose box is gone was cleared explicitly (reset/escape) and must + // not linger — an orphaned remnant can't track layout shifts and + // would freeze at stale coordinates. + grabbedAnimations = grabbedAnimations.filter((animation) => { + if (animation.id.startsWith("label-")) { + return activeLabelIds.has(animation.id); } + return boxesById.has(animation.id); + }); - // Boxes stay in the store for their full fade-out, so an animation - // whose box is gone was cleared explicitly (reset/escape) and must - // not linger — an orphaned remnant can't track layout shifts and - // would freeze at stale coordinates. - grabbedAnimations = grabbedAnimations.filter((animation) => { - if (animation.id.startsWith("label-")) { - return activeLabelIds.has(animation.id); - } - return boxesById.has(animation.id); - }); - - scheduleAnimationFrame(); - }, - ), + scheduleAnimationFrame(); + }, ); - onMount(() => { + onSettled(() => { initializeCanvas(); scheduleAnimationFrame(); @@ -598,7 +592,7 @@ export const OverlayCanvas: Component = (props) => { setupDprMediaQuery(); - onCleanup(() => { + return () => { window.removeEventListener("resize", handleWindowResize); if (currentDprMediaQuery) { currentDprMediaQuery.removeEventListener("change", handleDevicePixelRatioChange); @@ -609,7 +603,7 @@ export const OverlayCanvas: Component = (props) => { if (fadeWakeTimeoutId !== null) { window.clearTimeout(fadeWakeTimeoutId); } - }); + }; }); return ( diff --git a/packages/react-grab/src/components/selection-label/completion-view.tsx b/packages/react-grab/src/components/selection-label/completion-view.tsx index 87d4e9408..38de9d697 100644 --- a/packages/react-grab/src/components/selection-label/completion-view.tsx +++ b/packages/react-grab/src/components/selection-label/completion-view.tsx @@ -15,8 +15,19 @@ interface MoreOptionsButtonProps { } const MoreOptionsButton: Component = (props) => { + const bindNativeListeners = (element: HTMLButtonElement) => { + element.onpointerdown = (event) => { + event.stopImmediatePropagation(); + }; + element.onclick = (event) => { + event.stopImmediatePropagation(); + props.onClick(); + }; + }; + return ( - - {(tooltip) => ( - - )} - -
-); +export const ToolbarActionButton: Component = (props) => { + let buttonElement: HTMLButtonElement | undefined; + const handleContextMenu = (event: MouseEvent) => { + props.onContextMenu?.(event); + flush(); + }; + const bindButtonElement = (element: HTMLButtonElement) => { + buttonElement = element; + props.ref?.(element); + element.addEventListener("contextmenu", handleContextMenu); + }; + + onSettled(() => () => buttonElement?.removeEventListener("contextmenu", handleContextMenu)); + + return ( +
+ + + {(tooltip) => ( + + )} + +
+ ); +}; diff --git a/packages/react-grab/src/components/toolbar/toolbar-content.tsx b/packages/react-grab/src/components/toolbar/toolbar-content.tsx index 5da5eb1c1..70dda402a 100644 --- a/packages/react-grab/src/components/toolbar/toolbar-content.tsx +++ b/packages/react-grab/src/components/toolbar/toolbar-content.tsx @@ -1,4 +1,5 @@ -import type { Component, JSX } from "solid-js"; +import type { Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { cn } from "../../utils/cn.js"; import { isHorizontalEdge } from "../../utils/toolbar-position.js"; import { IconChevron } from "../icons/icon-chevron.jsx"; @@ -128,11 +129,11 @@ export const ToolbarContent: Component = (props) => { data-react-grab-ignore-events data-react-grab-toolbar-collapse aria-label={props.isCollapsed ? "Expand toolbar" : "Collapse toolbar"} - aria-expanded={!props.isCollapsed} + aria-expanded={!props.isCollapsed ? "true" : "false"} type="button" class="group contain-layout shrink-0 flex items-center justify-center cursor-pointer interactive-scale a11y-hitbox" onClick={props.onCollapseClick} - on:pointerdown={props.onCollapsePointerDown} + onPointerDown={props.onCollapsePointerDown} onPointerUp={props.onCollapsePointerUp} onPointerLeave={props.onCollapsePointerLeave} onPointerCancel={props.onCollapsePointerLeave} diff --git a/packages/react-grab/src/components/tooltip.tsx b/packages/react-grab/src/components/tooltip.tsx index 934c2ee6e..ff9908e4c 100644 --- a/packages/react-grab/src/components/tooltip.tsx +++ b/packages/react-grab/src/components/tooltip.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, on, onCleanup, Show } from "solid-js"; +import { createSignal, createEffect, onCleanup, Show } from "solid-js"; import type { Component } from "solid-js"; import { cn } from "../utils/cn.js"; import { TOOLTIP_DELAY_MS, TOOLTIP_GRACE_PERIOD_MS, Z_INDEX_OVERLAY } from "../constants.js"; @@ -21,34 +21,32 @@ export const Tooltip: Component = (props) => { let delayTimeoutId: ReturnType | undefined; createEffect( - on( - () => props.visible, - (isVisible) => { - if (delayTimeoutId !== undefined) { - clearTimeout(delayTimeoutId); - delayTimeoutId = undefined; - } + () => props.visible, + (isVisible) => { + if (delayTimeoutId !== undefined) { + clearTimeout(delayTimeoutId); + delayTimeoutId = undefined; + } - if (isVisible) { - // Reopening within the grace period skips the delay and the fade so - // moving between adjacent buttons reads as one continuous tooltip. - if (wasTooltipRecentlyVisible()) { - setShouldAnimate(false); - setDelayedVisible(true); - } else { - setShouldAnimate(true); - delayTimeoutId = setTimeout(() => { - setDelayedVisible(true); - }, TOOLTIP_DELAY_MS); - } + if (isVisible) { + // Reopening within the grace period skips the delay and the fade so + // moving between adjacent buttons reads as one continuous tooltip. + if (wasTooltipRecentlyVisible()) { + setShouldAnimate(false); + setDelayedVisible(true); } else { - if (delayedVisible()) { - lastCloseTimestamp = Date.now(); - } - setDelayedVisible(false); + setShouldAnimate(true); + delayTimeoutId = setTimeout(() => { + setDelayedVisible(true); + }, TOOLTIP_DELAY_MS); + } + } else { + if (delayedVisible()) { + lastCloseTimestamp = Date.now(); } - }, - ), + setDelayedVisible(false); + } + }, ); onCleanup(() => { diff --git a/packages/react-grab/src/components/ui/anchored-dropdown-surface.tsx b/packages/react-grab/src/components/ui/anchored-dropdown-surface.tsx index 0ea6be4af..4d4897c7a 100644 --- a/packages/react-grab/src/components/ui/anchored-dropdown-surface.tsx +++ b/packages/react-grab/src/components/ui/anchored-dropdown-surface.tsx @@ -1,4 +1,5 @@ -import { onCleanup, onMount, Show, type Component, type JSX } from "solid-js"; +import { onSettled, Show, type Component } from "solid-js"; +import type { JSX } from "@solidjs/web"; import type { DropdownAnchor } from "../../types.js"; import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, Z_INDEX_OVERLAY } from "../../constants.js"; import { cn } from "../../utils/cn.js"; @@ -32,7 +33,7 @@ export const AnchoredDropdownSurface: Component = () => props.position, ); - onMount(() => { + onSettled(() => { dropdown.measure(); const unregisterOverlayDismiss = props.onDismiss ? registerOverlayDismiss({ @@ -41,10 +42,10 @@ export const AnchoredDropdownSurface: Component = }) : undefined; - onCleanup(() => { + return () => { dropdown.clearAnimationHandles(); unregisterOverlayDismiss?.(); - }); + }; }); return ( diff --git a/packages/react-grab/src/components/ui/button.tsx b/packages/react-grab/src/components/ui/button.tsx index 9cf77248e..afcc845de 100644 --- a/packages/react-grab/src/components/ui/button.tsx +++ b/packages/react-grab/src/components/ui/button.tsx @@ -1,4 +1,5 @@ -import { splitProps, type Component, type JSX } from "solid-js"; +import { omit, type Component, type Element } from "solid-js"; +import type { JSX } from "@solidjs/web"; import { cn } from "../../utils/cn.js"; import { createVariants } from "../../utils/create-variants.js"; @@ -20,14 +21,15 @@ export const buttonVariants = createVariants( interface ButtonProps extends JSX.ButtonHTMLAttributes { variant?: "chip" | "destructive" | "ghost"; + children?: Element; } export const Button: Component = (props) => { - const [local, rest] = splitProps(props, ["variant", "class", "type"]); + const rest = omit(props, "variant", "class", "type"); return (