From f350003ebe7be1d07d11ece42792fa9bde84be11 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 2 Aug 2026 06:29:20 -0700 Subject: [PATCH] Remove Style feature (#593) --- .changeset/remove-style-feature.md | 7 + .../src/error-boundary.tsx | 5 +- apps/e2e-app-vite/src/index.css | 8 - apps/e2e-app-vite/src/pierre-diff-fixture.tsx | 2 +- apps/e2e-react-grab-development-aliases.ts | 7 + apps/openstory/stories/renderer.stories.tsx | 1 - apps/web-extension/src/content/react-grab.ts | 29 +- apps/website/components/ui/badge.tsx | 2 +- apps/website/components/ui/button.tsx | 2 +- apps/website/components/ui/card.tsx | 25 +- apps/website/components/ui/dialog.tsx | 7 - apps/website/components/ui/dropdown-menu.tsx | 147 -- apps/website/components/ui/select.tsx | 46 +- apps/website/components/ui/table.tsx | 22 +- apps/website/components/ui/tabs.tsx | 2 +- apps/website/lib/utils.ts | 7 +- package.json | 2 +- packages/react-grab/docs/architecture.md | 2 +- packages/react-grab/e2e/api-methods.spec.ts | 59 + .../e2e/combinatorial-interactions.spec.ts | 52 - packages/react-grab/e2e/context-menu.spec.ts | 19 +- .../e2e/edit-panel-autocomplete.spec.ts | 142 -- .../react-grab/e2e/edit-panel-color.spec.ts | 302 --- packages/react-grab/e2e/edit-panel-helpers.ts | 564 ------ packages/react-grab/e2e/edit-panel.spec.ts | 1801 ----------------- packages/react-grab/e2e/fixtures.ts | 2 +- .../e2e/keyboard-navigation.spec.ts | 32 - packages/react-grab/e2e/perf-bench.spec.ts | 9 +- packages/react-grab/e2e/perf-constants.ts | 2 + packages/react-grab/e2e/prompt-mode.spec.ts | 4 +- packages/react-grab/e2e/react-grab.expect.ts | 2 +- .../react-grab/e2e/shift-multi-select.spec.ts | 73 - packages/react-grab/e2e/slot.spec.ts | 27 - .../e2e/three-fiber-selection.spec.ts | 6 +- .../react-grab/e2e/toolbar-actions.spec.ts | 206 +- packages/react-grab/e2e/toolbar-menu.spec.ts | 128 +- packages/react-grab/e2e/web-extension.spec.ts | 191 ++ packages/react-grab/package.json | 2 +- .../edit-panel/active-property-control.tsx | 69 - .../components/edit-panel/color-picker.tsx | 167 -- .../src/components/edit-panel/constants.ts | 13 - .../src/components/edit-panel/copy-button.tsx | 22 - .../components/edit-panel/cycle-control.tsx | 63 - .../edit-panel/discard-confirmation.ts | 35 - .../src/components/edit-panel/index.tsx | 801 -------- .../components/edit-panel/narrow-property.ts | 15 - .../components/edit-panel/property-list.tsx | 253 --- .../edit-panel/property-values-equal.ts | 16 - .../src/components/edit-panel/step-arrow.tsx | 57 - .../components/edit-panel/step-controller.ts | 83 - .../components/edit-panel/step-property.ts | 48 - .../src/components/edit-panel/style-store.ts | 205 -- .../edit-panel/tailwind-autoapply.ts | 359 ---- .../components/edit-panel/value-stepper.tsx | 370 ---- .../src/components/icons/icon-comment.tsx | 27 - .../src/components/icons/icon-style.tsx | 27 - .../react-grab/src/components/renderer.tsx | 17 +- .../src/components/selection-label/index.tsx | 38 +- packages/react-grab/src/components/slot.tsx | 163 -- .../src/components/toolbar/index.tsx | 147 +- .../react-grab/src/components/ui/input.tsx | 36 - packages/react-grab/src/constants.ts | 69 +- packages/react-grab/src/core/edit-mode.ts | 322 --- packages/react-grab/src/core/index.tsx | 231 +-- packages/react-grab/src/core/plugins/edit.ts | 19 - packages/react-grab/src/demo.ts | 2 +- packages/react-grab/src/styles.css | 130 -- packages/react-grab/src/types.ts | 131 +- .../src/utils/build-editable-properties.ts | 118 -- .../src/utils/collect-design-tokens.ts | 297 --- .../src/utils/create-slot-render-segments.ts | 44 - .../src/utils/css-aggregate-group.ts | 54 - .../src/utils/css-baseline-measurement.ts | 92 - .../src/utils/css-property-bounds.ts | 64 - .../src/utils/css-property-builders.ts | 100 - packages/react-grab/src/utils/css-snapshot.ts | 26 - .../src/utils/css-value-resolution.ts | 63 - .../src/utils/expand-aggregate-longhands.ts | 14 - .../src/utils/find-tailwind-class.ts | 96 - .../src/utils/format-color-label.ts | 6 - .../react-grab/src/utils/format-css-value.ts | 44 - .../src/utils/format-edit-prompt.ts | 93 - .../src/utils/get-shadow-active-element.ts | 9 - .../src/utils/is-numeric-draft-query.ts | 4 - .../react-grab/src/utils/is-numeric-query.ts | 3 - .../src/utils/is-type-to-edit-key.ts | 2 - .../normalize-toolbar-default-action-id.ts | 4 + .../react-grab/src/utils/parse-any-color.ts | 14 +- packages/react-grab/src/utils/parse-color.ts | 11 - .../src/utils/parse-numeric-value.ts | 20 - .../react-grab/src/utils/pick-next-option.ts | 15 - .../react-grab/src/utils/preview-styles.ts | 46 - .../src/utils/property-definitions.ts | 508 ----- .../src/utils/property-search-index.ts | 169 -- .../sort-properties-by-recommendation.ts | 91 - .../src/utils/split-negative-prefix.ts | 9 - .../src/utils/tailwind-class-map.ts | 472 ----- .../src/utils/tailwind-palette-data.ts | 288 --- .../tests/is-type-to-edit-key.test.ts | 17 - 99 files changed, 614 insertions(+), 10060 deletions(-) create mode 100644 .changeset/remove-style-feature.md delete mode 100644 packages/react-grab/e2e/edit-panel-autocomplete.spec.ts delete mode 100644 packages/react-grab/e2e/edit-panel-color.spec.ts delete mode 100644 packages/react-grab/e2e/edit-panel-helpers.ts delete mode 100644 packages/react-grab/e2e/edit-panel.spec.ts delete mode 100644 packages/react-grab/e2e/slot.spec.ts create mode 100644 packages/react-grab/e2e/web-extension.spec.ts delete mode 100644 packages/react-grab/src/components/edit-panel/active-property-control.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/color-picker.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/constants.ts delete mode 100644 packages/react-grab/src/components/edit-panel/copy-button.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/cycle-control.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/discard-confirmation.ts delete mode 100644 packages/react-grab/src/components/edit-panel/index.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/narrow-property.ts delete mode 100644 packages/react-grab/src/components/edit-panel/property-list.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/property-values-equal.ts delete mode 100644 packages/react-grab/src/components/edit-panel/step-arrow.tsx delete mode 100644 packages/react-grab/src/components/edit-panel/step-controller.ts delete mode 100644 packages/react-grab/src/components/edit-panel/step-property.ts delete mode 100644 packages/react-grab/src/components/edit-panel/style-store.ts delete mode 100644 packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts delete mode 100644 packages/react-grab/src/components/edit-panel/value-stepper.tsx delete mode 100644 packages/react-grab/src/components/icons/icon-comment.tsx delete mode 100644 packages/react-grab/src/components/icons/icon-style.tsx delete mode 100644 packages/react-grab/src/components/slot.tsx delete mode 100644 packages/react-grab/src/components/ui/input.tsx delete mode 100644 packages/react-grab/src/core/edit-mode.ts delete mode 100644 packages/react-grab/src/core/plugins/edit.ts delete mode 100644 packages/react-grab/src/utils/build-editable-properties.ts delete mode 100644 packages/react-grab/src/utils/collect-design-tokens.ts delete mode 100644 packages/react-grab/src/utils/create-slot-render-segments.ts delete mode 100644 packages/react-grab/src/utils/css-aggregate-group.ts delete mode 100644 packages/react-grab/src/utils/css-baseline-measurement.ts delete mode 100644 packages/react-grab/src/utils/css-property-bounds.ts delete mode 100644 packages/react-grab/src/utils/css-property-builders.ts delete mode 100644 packages/react-grab/src/utils/css-snapshot.ts delete mode 100644 packages/react-grab/src/utils/css-value-resolution.ts delete mode 100644 packages/react-grab/src/utils/expand-aggregate-longhands.ts delete mode 100644 packages/react-grab/src/utils/find-tailwind-class.ts delete mode 100644 packages/react-grab/src/utils/format-color-label.ts delete mode 100644 packages/react-grab/src/utils/format-css-value.ts delete mode 100644 packages/react-grab/src/utils/format-edit-prompt.ts delete mode 100644 packages/react-grab/src/utils/get-shadow-active-element.ts delete mode 100644 packages/react-grab/src/utils/is-numeric-draft-query.ts delete mode 100644 packages/react-grab/src/utils/is-numeric-query.ts delete mode 100644 packages/react-grab/src/utils/is-type-to-edit-key.ts create mode 100644 packages/react-grab/src/utils/normalize-toolbar-default-action-id.ts delete mode 100644 packages/react-grab/src/utils/parse-numeric-value.ts delete mode 100644 packages/react-grab/src/utils/pick-next-option.ts delete mode 100644 packages/react-grab/src/utils/preview-styles.ts delete mode 100644 packages/react-grab/src/utils/property-definitions.ts delete mode 100644 packages/react-grab/src/utils/property-search-index.ts delete mode 100644 packages/react-grab/src/utils/sort-properties-by-recommendation.ts delete mode 100644 packages/react-grab/src/utils/split-negative-prefix.ts delete mode 100644 packages/react-grab/src/utils/tailwind-class-map.ts delete mode 100644 packages/react-grab/src/utils/tailwind-palette-data.ts delete mode 100644 packages/react-grab/tests/is-type-to-edit-key.test.ts diff --git a/.changeset/remove-style-feature.md b/.changeset/remove-style-feature.md new file mode 100644 index 000000000..6684b0dd3 --- /dev/null +++ b/.changeset/remove-style-feature.md @@ -0,0 +1,7 @@ +--- +"react-grab": minor +"grab": minor +"@react-grab/cli": minor +--- + +Remove the Style action and panel, including its keyboard shortcuts, inline previews, CSS property controls, design-token handling, and edit-related action context and renderer types. Simplify the toolbar to its Copy control while keeping Comment available through the context menu and action menu. diff --git a/apps/e2e-app-vite-upstream/src/error-boundary.tsx b/apps/e2e-app-vite-upstream/src/error-boundary.tsx index 159cab317..92936e48b 100644 --- a/apps/e2e-app-vite-upstream/src/error-boundary.tsx +++ b/apps/e2e-app-vite-upstream/src/error-boundary.tsx @@ -1,9 +1,6 @@ import { Component, useState } from "react"; -export class FixtureErrorBoundary extends Component< - FixtureErrorBoundaryProps, - FixtureErrorBoundaryState -> { +class FixtureErrorBoundary extends Component { state: FixtureErrorBoundaryState = { didError: false }; static getDerivedStateFromError = (): FixtureErrorBoundaryState => ({ didError: true }); diff --git a/apps/e2e-app-vite/src/index.css b/apps/e2e-app-vite/src/index.css index 2223772ed..f1d8c73cd 100644 --- a/apps/e2e-app-vite/src/index.css +++ b/apps/e2e-app-vite/src/index.css @@ -1,9 +1 @@ @import "tailwindcss"; - -/* Library-agnostic design tokens used by the design-token prompt e2e test. - Definition-only (never referenced), so they add no visual change. */ -:root { - --rg-test-space-4: 16px; - --rg-test-space-6: 24px; - --rg-test-brand: #123456; -} diff --git a/apps/e2e-app-vite/src/pierre-diff-fixture.tsx b/apps/e2e-app-vite/src/pierre-diff-fixture.tsx index adba3b9d7..ea58ab6f9 100644 --- a/apps/e2e-app-vite/src/pierre-diff-fixture.tsx +++ b/apps/e2e-app-vite/src/pierre-diff-fixture.tsx @@ -20,7 +20,7 @@ export default SmallInput `, }; -export const PierreDiff = () => ( +const PierreDiff = () => ( { actions={createMenuActions(Boolean(props.filePath))} onInputChange={noop} onInputSubmit={noop} - onToggleExpand={noop} onConfirmDismiss={noop} onToggleActive={noop} onToolbarStateChange={noop} diff --git a/apps/web-extension/src/content/react-grab.ts b/apps/web-extension/src/content/react-grab.ts index 67dbdd3ee..083f14eb6 100644 --- a/apps/web-extension/src/content/react-grab.ts +++ b/apps/web-extension/src/content/react-grab.ts @@ -20,10 +20,11 @@ const turndownService = new TurndownService(); let extensionApi: ReactGrabAPI | null = null; let lastToolbarState: ToolbarState | null = null; let isApplyingExternalState = false; +let hasHydratedInitialState = false; let stateChangeUnsubscribe: (() => void) | null = null; const handleToolbarStateFromApi = (toolbarState: ToolbarState | null): void => { - if (isApplyingExternalState) return; + if (isApplyingExternalState || !hasHydratedInitialState) return; if (!toolbarState) return; if ( lastToolbarState && @@ -39,6 +40,18 @@ const handleToolbarStateFromApi = (toolbarState: ToolbarState | null): void => { window.postMessage({ type: "__REACT_GRAB_TOOLBAR_STATE_SAVE__", state: toolbarState }, "*"); }; +const applyExternalToolbarState = (api: ReactGrabAPI, toolbarState: ToolbarState): void => { + isApplyingExternalState = true; + try { + api.setToolbarState(toolbarState); + } finally { + isApplyingExternalState = false; + } + + const appliedToolbarState = api.getToolbarState(); + handleToolbarStateFromApi(appliedToolbarState); +}; + const subscribeToStateChanges = (api: ReactGrabAPI): void => { if (stateChangeUnsubscribe) { stateChangeUnsubscribe(); @@ -125,9 +138,7 @@ const handleToolbarStateChange = async (state: ToolbarState): Promise => { await initializeReactGrab(); const api = getActiveApi(); if (api) { - isApplyingExternalState = true; - api.setToolbarState(state); - isApplyingExternalState = false; + applyExternalToolbarState(api, state); } }; @@ -186,17 +197,21 @@ const queryInitialState = (): Promise => { const startup = async (): Promise => { const initialState = await queryInitialState(); + lastToolbarState = initialState.toolbarState; const api = await initializeReactGrab(); if (api) { if (initialState.toolbarState) { - isApplyingExternalState = true; - api.setToolbarState(initialState.toolbarState); - isApplyingExternalState = false; + applyExternalToolbarState(api, initialState.toolbarState); } else if (!initialState.enabled) { api.setEnabled(false); } } + + // Core normalizes persisted action IDs in a microtask after plugin registration. + await Promise.resolve(); + hasHydratedInitialState = true; + handleToolbarStateFromApi(api?.getToolbarState() ?? null); }; if (document.readyState === "loading") { diff --git a/apps/website/components/ui/badge.tsx b/apps/website/components/ui/badge.tsx index 0f5045cba..cf3777b28 100644 --- a/apps/website/components/ui/badge.tsx +++ b/apps/website/components/ui/badge.tsx @@ -42,4 +42,4 @@ function Badge({ ); } -export { Badge, badgeVariants }; +export { Badge }; diff --git a/apps/website/components/ui/button.tsx b/apps/website/components/ui/button.tsx index fb5b30b34..c20c07284 100644 --- a/apps/website/components/ui/button.tsx +++ b/apps/website/components/ui/button.tsx @@ -64,4 +64,4 @@ function Button({ ); } -export { Button, buttonVariants }; +export { Button }; diff --git a/apps/website/components/ui/card.tsx b/apps/website/components/ui/card.tsx index a73bb7600..95f6cc632 100644 --- a/apps/website/components/ui/card.tsx +++ b/apps/website/components/ui/card.tsx @@ -56,33 +56,10 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { ); } -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; +export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/apps/website/components/ui/dialog.tsx b/apps/website/components/ui/dialog.tsx index ffe7f0380..2f0ca662b 100644 --- a/apps/website/components/ui/dialog.tsx +++ b/apps/website/components/ui/dialog.tsx @@ -19,10 +19,6 @@ function DialogPortal({ ...props }: React.ComponentProps; } -function DialogClose({ ...props }: React.ComponentProps) { - return ; -} - function DialogOverlay({ className, ...props @@ -135,13 +131,10 @@ function DialogDescription({ export { Dialog, - DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, - DialogOverlay, - DialogPortal, DialogTitle, DialogTrigger, }; diff --git a/apps/website/components/ui/dropdown-menu.tsx b/apps/website/components/ui/dropdown-menu.tsx index 0be18f4fb..5e73ba140 100644 --- a/apps/website/components/ui/dropdown-menu.tsx +++ b/apps/website/components/ui/dropdown-menu.tsx @@ -4,18 +4,11 @@ import * as React from "react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; -import { CheckIcon, ChevronRightIcon } from "lucide-react"; function DropdownMenu({ ...props }: React.ComponentProps) { return ; } -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ; -} - function DropdownMenuTrigger({ ...props }: React.ComponentProps) { @@ -44,10 +37,6 @@ function DropdownMenuContent({ ); } -function DropdownMenuGroup({ ...props }: React.ComponentProps) { - return ; -} - function DropdownMenuItem({ className, inset, @@ -71,76 +60,6 @@ function DropdownMenuItem({ ); } -function DropdownMenuCheckboxItem({ - className, - children, - checked, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean; -}) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ; -} - -function DropdownMenuRadioItem({ - className, - children, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean; -}) { - return ( - - - - - - - {children} - - ); -} - function DropdownMenuLabel({ className, inset, @@ -174,77 +93,11 @@ function DropdownMenuSeparator({ ); } -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return ( - - ); -} - -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return ; -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean; -}) { - return ( - - {children} - - - ); -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - export { DropdownMenu, - DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, }; diff --git a/apps/website/components/ui/select.tsx b/apps/website/components/ui/select.tsx index b5f872ef3..0e581fba1 100644 --- a/apps/website/components/ui/select.tsx +++ b/apps/website/components/ui/select.tsx @@ -10,16 +10,6 @@ function Select({ ...props }: React.ComponentProps) return ; } -function SelectGroup({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SelectValue({ ...props }: React.ComponentProps) { return ; } @@ -88,16 +78,6 @@ function SelectContent({ ); } -function SelectLabel({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SelectItem({ className, children, @@ -122,19 +102,6 @@ function SelectItem({ ); } -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - function SelectScrollUpButton({ className, ...props @@ -171,15 +138,4 @@ function SelectScrollDownButton({ ); } -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -}; +export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }; diff --git a/apps/website/components/ui/table.tsx b/apps/website/components/ui/table.tsx index 86d90c865..11dfe475c 100644 --- a/apps/website/components/ui/table.tsx +++ b/apps/website/components/ui/table.tsx @@ -30,16 +30,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { ); } -function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { - return ( - tr]:last:border-b-0", className)} - {...props} - /> - ); -} - function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( ) { ); } -function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { - return ( - - ); -} - -export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; +export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell }; diff --git a/apps/website/components/ui/tabs.tsx b/apps/website/components/ui/tabs.tsx index aed98a6d6..32de35387 100644 --- a/apps/website/components/ui/tabs.tsx +++ b/apps/website/components/ui/tabs.tsx @@ -78,4 +78,4 @@ function TabsContent({ className, ...props }: React.ComponentProps twMerge(clsx(inputs)); diff --git a/package.json b/package.json index 708d16df2..93881d4c5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ ], "type": "module", "scripts": { - "build": "cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab", + "build": "rm -rf packages/react-grab/dist && cp README.md packages/react-grab/README.md && turbo run build --filter=@react-grab/cli --filter=react-grab --filter=grab", "build:fixtures": "pnpm --filter @react-grab/e2e-app-vite build && pnpm --filter @react-grab/e2e-app-vite-upstream build && pnpm --filter @react-grab/e2e-app-next build:production-test && pnpm --filter @react-grab/e2e-app-tanstack-start build", "dev": "turbo run dev --filter=react-grab", "test": "turbo run test", diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 6bb502372..4a961fd7a 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -11,7 +11,7 @@ This document covers how react-grab's internals work. It's intended to aid in un - **Keep orchestration in one place, factor out utilities** The [core/index.tsx](../src/core/index.tsx) file is large on purpose. SolidJS components are setup functions that run once (not render functions that re-execute on every update), so `init()` is effectively a single procedural function that wires up the store, plugin registry, event listeners, effects, and renderer. The reactive graph handles all subsequent updates from there. Smaller concerns like element detection, animation freezing, clipboard writing, and bounds calculation are factored out into focused utility files under `utils/`, but the orchestration logic stays together in one place so you can follow the full activation-to-copy flow without jumping between files. - **Implement all user-facing actions as plugins** - Every user-facing action - copy snippet, copy HTML, copy styles, add comment, open in editor - is implemented as a plugin that registers context-menu entries and hooks. The core doesn't hardcode any clipboard behavior. When the user triggers a copy, the content passes through a pipeline of plugin transforms (`onBeforeCopy`, `transformSnippet`, `transformCopyContent`) before it reaches the clipboard. This means external consumers can modify or replace any step of the copy process without forking the library. + Every user-facing action - copy, comment, and open in editor - is implemented as a plugin that registers context-menu entries and hooks. The core doesn't hardcode any clipboard behavior. When the user triggers a copy, the content passes through a pipeline of plugin transforms (`onBeforeCopy`, `transformSnippet`, `transformCopyContent`) before it reaches the clipboard. This means external consumers can modify or replace any step of the copy process without forking the library. - **Lazy-load the rendering layer separately from the interaction logic** The SolidJS UI components (canvas overlay, toolbar, selection labels, context menus) are loaded via a dynamic `import()` so that the interaction-detection logic in `core/index.tsx` can initialize immediately without waiting for the heavier rendering code to parse and execute. If the dynamic import fails for any reason, react-grab logs the error but continues to function without the visual overlay. diff --git a/packages/react-grab/e2e/api-methods.spec.ts b/packages/react-grab/e2e/api-methods.spec.ts index ee03ffe56..c9726eeab 100644 --- a/packages/react-grab/e2e/api-methods.spec.ts +++ b/packages/react-grab/e2e/api-methods.spec.ts @@ -361,6 +361,30 @@ test.describe("API Methods", () => { }); test.describe("Toolbar state", () => { + test("preserves unregistered actions in externally applied state", async ({ reactGrab }) => { + const defaultAction = await reactGrab.page.evaluate(() => { + const api = (window as LifecycleWindow).__REACT_GRAB__; + if (!api) throw new Error("React Grab API unavailable"); + + api.setToolbarState({ defaultAction: "custom-action" }); + return api.getToolbarState()?.defaultAction; + }); + + expect(defaultAction).toBe("custom-action"); + }); + + test("preserves registered actions in externally applied state", async ({ reactGrab }) => { + const defaultAction = await reactGrab.page.evaluate(() => { + const api = (window as LifecycleWindow).__REACT_GRAB__; + if (!api) throw new Error("React Grab API unavailable"); + + api.setToolbarState({ defaultAction: "comment" }); + return api.getToolbarState()?.defaultAction; + }); + + expect(defaultAction).toBe("comment"); + }); + test("isolates throwing state subscribers", async ({ reactGrab }) => { const result = await reactGrab.page.evaluate(() => { const api = (window as LifecycleWindow).__REACT_GRAB__; @@ -448,6 +472,41 @@ test.describe("API Methods", () => { }); test.describe("dispose()", () => { + test("does not normalize persisted toolbar state after same-turn disposal", async ({ + reactGrab, + }) => { + const persistedDefaultAction = await reactGrab.page.evaluate(async () => { + const targetWindow = window as LifecycleWindow; + const initializeReactGrab = targetWindow.initReactGrab; + if (!initializeReactGrab) throw new Error("React Grab initializer unavailable"); + + targetWindow.__REACT_GRAB__?.dispose(); + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "removed-action", + }), + ); + + const disposedApi = initializeReactGrab(); + disposedApi.dispose(); + await Promise.resolve(); + + const persistedState = JSON.parse( + localStorage.getItem("react-grab-toolbar-state") ?? "null", + ); + localStorage.removeItem("react-grab-toolbar-state"); + targetWindow.__REACT_GRAB__ = initializeReactGrab(); + return persistedState?.defaultAction; + }); + + expect(persistedDefaultAction).toBe("removed-action"); + }); + test("should clear the disposed global API", async ({ reactGrab }) => { await reactGrab.dispose(); diff --git a/packages/react-grab/e2e/combinatorial-interactions.spec.ts b/packages/react-grab/e2e/combinatorial-interactions.spec.ts index fbfc13b0c..4879b3d99 100644 --- a/packages/react-grab/e2e/combinatorial-interactions.spec.ts +++ b/packages/react-grab/e2e/combinatorial-interactions.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from "./fixtures.js"; import { goToSizedPerfGrid } from "./perf-fixtures.js"; -import { openEditPanel, isEditPanelVisible, BUTTON_SELECTOR } from "./edit-panel-helpers.js"; const STATE_SETTLE_WAIT_MS = 300; // Copy commits run source resolution before writing the clipboard, which can @@ -371,24 +370,6 @@ test.describe("Combinatorial Interactions", () => { expect(boundsAfter).toEqual(boundsBefore); }); - test("style panel opens for an arrow-frozen ancestor selection", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); - await reactGrab.pressArrowUp(); - await reactGrab.waitForSelectionBox(); - - const bounds = await reactGrab.getSelectionBoxBounds(); - if (!bounds) throw new Error("Expected a frozen selection box"); - await reactGrab.rightClickAtPosition( - bounds.x + bounds.width / 2, - bounds.y + bounds.height / 2, - ); - await expect.poll(() => reactGrab.isContextMenuVisible()).toBe(true); - await reactGrab.clickContextMenuItem("Style"); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - }); - test("arrow keys typed inside the prompt textarea do not move the selection", async ({ reactGrab, }) => { @@ -403,37 +384,4 @@ test.describe("Combinatorial Interactions", () => { expect(await reactGrab.getInputValue()).toBe("helXXlo"); }); }); - - test.describe("Style panel crossed with lifecycle", () => { - test("style panel survives a viewport resize", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - - await reactGrab.setViewportSize(1000, 650); - await reactGrab.page.waitForTimeout(STATE_SETTLE_WAIT_MS); - - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - }); - - test("style panel survives page scrolling", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - - await reactGrab.scrollPage(300); - await reactGrab.page.waitForTimeout(STATE_SETTLE_WAIT_MS); - - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - }); - - test("dispose while the style panel is open tears down and allows reinitialization", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - - await reactGrab.dispose(); - await reactGrab.page.waitForTimeout(STATE_SETTLE_WAIT_MS); - - await reactGrab.reinitialize(); - await reactGrab.activate(); - expect(await reactGrab.isOverlayVisible()).toBe(true); - }); - }); }); diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts index e2969ca02..9852151fe 100644 --- a/packages/react-grab/e2e/context-menu.spec.ts +++ b/packages/react-grab/e2e/context-menu.spec.ts @@ -49,7 +49,7 @@ test.describe("Context Menu", () => { await reactGrab.page.keyboard.up(reactGrab.modifierKey); }); - test("should show context menu with Copy and Open items", async ({ reactGrab }) => { + test("should show only the built-in context menu actions", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverUntilSelected("li"); await reactGrab.rightClickElement("li"); @@ -57,6 +57,9 @@ test.describe("Context Menu", () => { const isContextMenuVisible = await reactGrab.isContextMenuVisible(); expect(isContextMenuVisible).toBe(true); + const menuInfo = await reactGrab.getContextMenuInfo(); + expect(menuInfo.menuItems).toEqual(["Copy", "Comment", "Open"]); + const isCopyEnabled = await reactGrab.isContextMenuItemEnabled("Copy"); expect(isCopyEnabled).toBe(true); @@ -420,8 +423,8 @@ test.describe("Context Menu", () => { name: "custom-prompt-action", actions: [ { - id: "custom-edit", - label: "Custom Edit", + id: "custom-prompt", + label: "Custom Prompt", shortcut: "E", onAction: (context: { enterPromptMode?: () => void }) => { context.enterPromptMode?.(); @@ -437,7 +440,9 @@ test.describe("Context Menu", () => { const menuInfo = await reactGrab.getContextMenuInfo(); expect(menuInfo.isVisible).toBe(true); - expect(menuInfo.menuItems.map((item: string) => item.toLowerCase())).toContain("custom edit"); + expect(menuInfo.menuItems.map((item: string) => item.toLowerCase())).toContain( + "custom prompt", + ); }); test("custom action should trigger enterPromptMode", async ({ reactGrab }) => { @@ -456,8 +461,8 @@ test.describe("Context Menu", () => { name: "custom-prompt-action", actions: [ { - id: "custom-edit", - label: "Custom Edit", + id: "custom-prompt", + label: "Custom Prompt", shortcut: "E", onAction: (context: { enterPromptMode?: () => void }) => { context.enterPromptMode?.(); @@ -472,7 +477,7 @@ test.describe("Context Menu", () => { await reactGrab.rightClickElement("li:first-child"); await reactGrab.page.waitForTimeout(100); - await reactGrab.clickContextMenuItem("Custom edit"); + await reactGrab.clickContextMenuItem("Custom prompt"); await reactGrab.page.waitForTimeout(200); const isPromptMode = await reactGrab.isPromptModeActive(); diff --git a/packages/react-grab/e2e/edit-panel-autocomplete.spec.ts b/packages/react-grab/e2e/edit-panel-autocomplete.spec.ts deleted file mode 100644 index 23309f134..000000000 --- a/packages/react-grab/e2e/edit-panel-autocomplete.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expect, test } from "./fixtures.js"; -import { - BUTTON_SELECTOR, - clearEditStorage, - getActivePropertyKey, - openEditPanel, - setSearchInputValue, -} from "./edit-panel-helpers.js"; - -const VECTOR_SHAPE_SELECTOR = "[data-testid='autocomplete-vector-shape']"; -const VECTOR_TARGET_OFFSET_PX = 24; -const VECTOR_TARGET_SIZE_PX = 48; - -const addVectorTarget = async (page: Page): Promise => { - await page.evaluate( - ({ offsetPx, sizePx }) => { - if (document.querySelector("[data-testid='autocomplete-vector']")) return; - const vectorElement = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - vectorElement.setAttribute("data-testid", "autocomplete-vector"); - vectorElement.setAttribute("viewBox", "0 0 24 24"); - vectorElement.style.position = "fixed"; - vectorElement.style.left = `${offsetPx}px`; - vectorElement.style.top = `${offsetPx}px`; - vectorElement.style.width = `${sizePx}px`; - vectorElement.style.height = `${sizePx}px`; - vectorElement.style.fill = "oklch(62.3% 0.214 259.815)"; - vectorElement.style.stroke = "oklch(70.7% 0.165 254.624)"; - vectorElement.style.zIndex = "1"; - const circleElement = document.createElementNS("http://www.w3.org/2000/svg", "circle"); - circleElement.setAttribute("data-testid", "autocomplete-vector-shape"); - circleElement.setAttribute("cx", "12"); - circleElement.setAttribute("cy", "12"); - circleElement.setAttribute("r", "8"); - circleElement.style.fill = "oklch(62.3% 0.214 259.815)"; - circleElement.style.stroke = "#60a5fa"; - circleElement.style.strokeWidth = "2"; - vectorElement.appendChild(circleElement); - document.body.appendChild(vectorElement); - }, - { offsetPx: VECTOR_TARGET_OFFSET_PX, sizePx: VECTOR_TARGET_SIZE_PX }, - ); -}; - -test.describe("Style Panel Autocomplete", () => { - test.beforeEach(async ({ reactGrab }) => { - await clearEditStorage(reactGrab.page); - }); - - test("tailwind color search terms select the matching color property", async ({ reactGrab }) => { - const expectations = [ - ["color", "color"], - ["text color", "color"], - ["text-color", "color"], - ["text-[#", "color"], - ["text-red", "color"], - ["text-blue", "color"], - ["text-primary", "color"], - ["text-[color:var(--color-primary)]", "color"], - ["bg", "background-color"], - ["bg-[#", "background-color"], - ["bg-muted", "background-color"], - ["bg-[var(--color-accent)]", "background-color"], - ["background color", "background-color"], - ["border-color", "border-color"], - ["border-[#", "border-color"], - ["border-t-red", "border-color"], - ["border-x-blue", "border-color"], - ]; - - await openEditPanel(reactGrab, BUTTON_SELECTOR); - for (const [query, expectedPropertyKey] of expectations) { - await setSearchInputValue(reactGrab.page, query); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page), query).toBe(expectedPropertyKey); - } - }); - - test("tailwind svg color search terms select fill", async ({ reactGrab }) => { - await addVectorTarget(reactGrab.page); - const expectations = [["fill-[#", "fill"]]; - - await openEditPanel(reactGrab, VECTOR_SHAPE_SELECTOR); - for (const [query, expectedPropertyKey] of expectations) { - await setSearchInputValue(reactGrab.page, query); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page), query).toBe(expectedPropertyKey); - } - }); - - test("tailwind text size and property name terms select font size", async ({ reactGrab }) => { - const expectations = [ - "text", - "text-sm", - "text-xl", - "text-[12px]", - "text-[length:12px]", - "font size", - "font-size", - ]; - - await openEditPanel(reactGrab, BUTTON_SELECTOR); - for (const query of expectations) { - await setSearchInputValue(reactGrab.page, query); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page), query).toBe("font-size"); - } - }); - - test("common tailwind enum terms select their enum property", async ({ reactGrab }) => { - const expectations = [ - ["text-center", "text-align"], - ["border-dashed", "border-style"], - ["font-mono", "font-family"], - ["font-bold", "font-weight"], - ["uppercase", "text-transform"], - ]; - - await openEditPanel(reactGrab, BUTTON_SELECTOR); - for (const [query, expectedPropertyKey] of expectations) { - await setSearchInputValue(reactGrab.page, query); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page), query).toBe(expectedPropertyKey); - } - }); - - test("common tailwind numeric prefix terms select their property", async ({ reactGrab }) => { - const expectations = [ - ["border-2", "border-width"], - ["tracking-tight", "letter-spacing"], - ["leading-6", "line-height"], - ["rounded-lg", "border-radius"], - ]; - - await openEditPanel(reactGrab, BUTTON_SELECTOR); - for (const [query, expectedPropertyKey] of expectations) { - await setSearchInputValue(reactGrab.page, query); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page), query).toBe(expectedPropertyKey); - } - }); -}); diff --git a/packages/react-grab/e2e/edit-panel-color.spec.ts b/packages/react-grab/e2e/edit-panel-color.spec.ts deleted file mode 100644 index fe7feb34e..000000000 --- a/packages/react-grab/e2e/edit-panel-color.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { expect, test } from "./fixtures.js"; -import { - ATTRIBUTE_NAME, - BUTTON_SELECTOR, - EDIT_PANEL_ATTR, - EDIT_PROPERTY_ATTR, - getActivePropertyValue, - clearEditStorage, - getActivePropertyKey, - getInlineStyleProperty, - getVisiblePropertyKeys, - openEditPanel, - setSearchInputValue, -} from "./edit-panel-helpers.js"; - -const PLAIN_TEXT_SELECTOR = "[data-testid='deeply-nested-text']"; - -test.describe("Style Panel Color Controls", () => { - test.beforeEach(async ({ reactGrab }) => { - await clearEditStorage(reactGrab.page); - }); - - test("text color and background are pinned to the top of the list", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const propertyKeys = await getVisiblePropertyKeys(reactGrab.page); - expect(propertyKeys.slice(0, 2)).toEqual(["background-color", "color"]); - }); - - test("background is offered even when the element has a transparent background", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, PLAIN_TEXT_SELECTOR); - const propertyKeys = await getVisiblePropertyKeys(reactGrab.page); - expect(propertyKeys).toContain("background-color"); - expect(propertyKeys).toContain("color"); - }); - - test("transparent color values are labeled as transparent", async ({ reactGrab }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, PLAIN_TEXT_SELECTOR); - await setSearchInputValue(page, "background"); - await expect.poll(() => getActivePropertyKey(page)).toBe("background-color"); - - await expect.poll(() => getActivePropertyValue(page)).toBe("transparent"); - }); - - test("typing bg-[#hex] applies the background color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "bg-[#ff0000]"); - await reactGrab.page.waitForTimeout(120); - const background = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "background-color", - ); - // The browser normalizes the applied inline color to rgb form. - expect(background.replace(/\s/g, "")).toBe("rgb(255,0,0)"); - }); - - test("typing text-[rgb(...)] applies the text color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-[rgb(0_128_255)]"); - await reactGrab.page.waitForTimeout(120); - const color = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "color"); - expect(color.replace(/\s/g, "")).toBe("rgb(0,128,255)"); - }); - - test("typing bg-red-500 applies the palette background color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "bg-red-500"); - await reactGrab.page.waitForTimeout(120); - const background = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "background-color", - ); - expect(background.replace(/\s/g, "")).toBe("rgb(239,68,68)"); - }); - - test("typing text-slate-500 applies the palette text color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-slate-500"); - await reactGrab.page.waitForTimeout(120); - const color = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "color"); - expect(color.replace(/\s/g, "")).toBe("rgb(100,116,139)"); - }); - - test("typing bg-black applies the keyword color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "bg-black"); - await reactGrab.page.waitForTimeout(120); - const background = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "background-color", - ); - expect(background.replace(/\s/g, "")).toBe("rgb(0,0,0)"); - }); - - test("typing bg-grey-500 resolves the grey alias to gray", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "bg-grey-500"); - await reactGrab.page.waitForTimeout(120); - const background = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "background-color", - ); - expect(background.replace(/\s/g, "")).toBe("rgb(107,114,128)"); - }); - - test("a typed color persists on Enter even when an unchanged color row is active", async ({ - reactGrab, - }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(page, "text-gray-500"); - await page.waitForTimeout(120); - // Surface the (unchanged) background-color row so it becomes active. - await setSearchInputValue(page, "background"); - await expect.poll(() => getActivePropertyKey(page)).toBe("background-color"); - - await page.keyboard.press("Enter"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("color: #6b7280"); - }); - - test("reverting an unset background to transparent leaks no prior color", async ({ - reactGrab, - }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, PLAIN_TEXT_SELECTOR); - await setSearchInputValue(page, "bg-red-500"); - await page.waitForTimeout(100); - await setSearchInputValue(page, "bg-[#00000000]"); - await page.waitForTimeout(100); - // The revert overwrites the preview — the prior red must be gone, not stuck. - const background = await getInlineStyleProperty(page, PLAIN_TEXT_SELECTOR, "background-color"); - expect(background.replace(/\s/g, "")).toBe("rgba(0,0,0,0)"); - }); - - test("a theme color token without a fixed value is not applied", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "bg-primary"); - await reactGrab.page.waitForTimeout(120); - const background = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "background-color", - ); - expect(background).toBe(""); - // The color row is still surfaced so the value can be picked manually. - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("background-color"); - }); - - test("picking a color on an unset (transparent) row produces an opaque color", async ({ - reactGrab, - }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, PLAIN_TEXT_SELECTOR); - await page.locator(`[${EDIT_PANEL_ATTR}] [${EDIT_PROPERTY_ATTR}="background-color"]`).click(); - - await page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const nativePicker = host?.shadowRoot?.querySelector( - `[${panelAttr}] input[type="color"]`, - ); - if (!nativePicker) throw new Error("native color picker not found"); - nativePicker.value = "#ff0000"; - nativePicker.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - await page.waitForTimeout(120); - - const background = await getInlineStyleProperty(page, PLAIN_TEXT_SELECTOR, "background-color"); - expect(background.replace(/\s/g, "")).toBe("rgb(255,0,0)"); - }); - - test("a space before the bracket is treated as the arbitrary separator", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text [13px]"); - await reactGrab.page.waitForTimeout(120); - const fontSize = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size"); - expect(fontSize).toBe("13px"); - }); - - test("typing text-[13px] applies font size, not a text color", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-[13px]"); - await reactGrab.page.waitForTimeout(120); - const fontSize = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size"); - const color = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "color"); - expect(fontSize).toBe("13px"); - expect(color).toBe(""); - }); - - test("typing text-[2rem] resolves rem to px font size", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-[2rem]"); - await reactGrab.page.waitForTimeout(120); - const fontSize = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size"); - expect(fontSize).toBe("32px"); - }); - - test("arrow keys on a color row open the color picker", async ({ reactGrab }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(page, "background"); - await expect.poll(() => getActivePropertyKey(page)).toBe("background-color"); - - await page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const nativePicker = host?.shadowRoot?.querySelector( - `[${panelAttr}] input[type="color"]`, - ); - if (!nativePicker) throw new Error("native color picker not found"); - (window as unknown as { __pickerClicks: number }).__pickerClicks = 0; - nativePicker.addEventListener("click", () => { - (window as unknown as { __pickerClicks: number }).__pickerClicks += 1; - }); - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - - await page.keyboard.press("ArrowRight"); - await expect - .poll(() => - page.evaluate(() => (window as unknown as { __pickerClicks: number }).__pickerClicks), - ) - .toBeGreaterThan(0); - }); - - test("arbitrary px lengths do not apply to unitless properties", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "opacity-[50px]"); - await reactGrab.page.waitForTimeout(120); - const opacity = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "opacity"); - expect(opacity).toBe(""); - }); - - test("clearing the search restores the first numeric cursor, not a color row", async ({ - reactGrab, - }) => { - const { page } = reactGrab; - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(page, "padding"); - await page.waitForTimeout(80); - await setSearchInputValue(page, ""); - await page.waitForTimeout(80); - const activeKey = await getActivePropertyKey(page); - expect(activeKey).not.toBe("background-color"); - expect(activeKey).not.toBe("color"); - }); - - test("a length: data-type hint applies a unitless value as px", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-[length:20]"); - await reactGrab.page.waitForTimeout(120); - const fontSize = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size"); - expect(fontSize).toBe("20px"); - }); - - test("unitless arbitrary values are not auto-applied as pixels", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "leading-[1.5]"); - await reactGrab.page.waitForTimeout(120); - const lineHeight = await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "line-height"); - expect(lineHeight).toBe(""); - }); - - test("default text color stays available in the property list", async ({ reactGrab }) => { - await reactGrab.page.evaluate((selector) => { - document.querySelector(selector)?.classList.remove("text-white"); - }, BUTTON_SELECTOR); - await openEditPanel(reactGrab, BUTTON_SELECTOR); - - const propertyKeys = await getVisiblePropertyKeys(reactGrab.page); - expect(propertyKeys).toContain("color"); - - const isColorPickerVisible = await reactGrab.page.evaluate( - ({ attrName, panelAttr, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const colorPropertyRow = shadowRoot?.querySelector( - `[${propertyAttr}="color"]`, - ); - colorPropertyRow?.click(); - const colorPickerButton = shadowRoot?.querySelector( - `[${panelAttr}] button[aria-label="Pick color"]`, - ); - const pickerBounds = colorPickerButton?.getBoundingClientRect(); - return Boolean(pickerBounds && pickerBounds.width > 0 && pickerBounds.height > 0); - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - expect(isColorPickerVisible).toBe(true); - }); -}); diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts deleted file mode 100644 index 88258c97c..000000000 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ /dev/null @@ -1,564 +0,0 @@ -import type { Page } from "@playwright/test"; -import { expect } from "./fixtures.js"; -import type { ReactGrabPageObject } from "./fixtures.js"; -import { ATTRIBUTE_NAME } from "./constants.js"; - -export { ATTRIBUTE_NAME }; -export const EDIT_PANEL_ATTR = "data-react-grab-edit-panel"; -export const EDIT_PROPERTY_ATTR = "data-react-grab-edit-property"; -export const SEARCH_INPUT_ATTR = "data-react-grab-input"; -export const COPY_BUTTON_ATTR = "data-react-grab-copy-button"; -export const TAILWIND_LABEL_ATTR = "data-react-grab-tailwind-label"; -export const IDLE_BUFFER_MS = 700; -export const DISCARD_PROMPT_IDLE_MS = 2000; -export const BUTTON_SELECTOR = "[data-testid='nested-button']"; -export const MAIN_TITLE_SELECTOR = "[data-testid='main-title']"; - -export const isEditPanelVisible = async (page: Page): Promise => - page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return false; - return shadowRoot.querySelector(`[${panelAttr}]`) !== null; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - -export const getVisiblePropertyKeys = async (page: Page): Promise => - page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return []; - const propertyRows = shadowRoot.querySelectorAll(`[${propertyAttr}]`); - return Array.from(propertyRows).map( - (propertyRow) => propertyRow.getAttribute(propertyAttr) ?? "", - ); - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - -export const getActivePropertyKey = async (page: Page): Promise => - page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return null; - const activePropertyRow = shadowRoot.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - return activePropertyRow?.getAttribute(propertyAttr) ?? null; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - -export const getActivePropertyValue = async (page: Page): Promise => - page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return null; - const activePropertyRow = shadowRoot.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - if (!activePropertyRow) return null; - const valueElement = activePropertyRow.querySelector("[data-react-grab-value]"); - if (valueElement) { - return valueElement.getAttribute("data-react-grab-value"); - } - return activePropertyRow.textContent?.trim() ?? null; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - -export interface ActiveSliderVisualState { - key: string | null; - width: number | null; - hasBaseRail: boolean; - fillOpacity: number | null; - fillBackground: string | null; - handleOpacity: number | null; - maxHashMarkOpacity: number; -} - -export interface VisibleSliderVisualState { - left: number | null; - top: number | null; - width: number | null; - height: number | null; - maxHashMarkOpacity: number; -} - -export interface PropertyRowBounds { - key: string; - isActive: boolean; - left: number; - top: number; - width: number; - height: number; -} - -export const getActiveSliderVisualState = async (page: Page): Promise => - page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const activePropertyRow = shadowRoot?.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - const slider = activePropertyRow?.querySelector("[role='slider']"); - const baseRailElement = - slider?.querySelector("[data-react-grab-slider-base]") ?? null; - const fillElement = slider?.querySelector("[data-react-grab-slider-fill]"); - const handleElement = slider?.querySelector("[data-react-grab-slider-handle]"); - const hashMarks = Array.from( - slider?.querySelectorAll("[data-react-grab-slider-hash-mark]") ?? [], - ); - const fillStyle = fillElement ? getComputedStyle(fillElement) : null; - return { - key: activePropertyRow?.getAttribute(propertyAttr) ?? null, - width: slider?.getBoundingClientRect().width ?? null, - hasBaseRail: baseRailElement !== null, - fillOpacity: fillStyle ? Number(fillStyle.opacity) : null, - fillBackground: fillStyle?.backgroundColor ?? null, - handleOpacity: handleElement ? Number(getComputedStyle(handleElement).opacity) : null, - maxHashMarkOpacity: Math.max( - 0, - ...hashMarks.map((hashMark) => Number(getComputedStyle(hashMark).opacity)), - ), - }; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - -export const getVisibleSliderVisualState = async (page: Page): Promise => - page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const sliderElements = Array.from( - shadowRoot?.querySelectorAll(`[${panelAttr}] [role='slider']`) ?? [], - ); - const visibleSlider = - sliderElements.find((sliderElement) => { - const sliderBounds = sliderElement.getBoundingClientRect(); - return sliderBounds.width > 0 && sliderBounds.height > 0; - }) ?? null; - const sliderBounds = visibleSlider?.getBoundingClientRect(); - const hashMarks = Array.from( - visibleSlider?.querySelectorAll("[data-react-grab-slider-hash-mark]") ?? [], - ); - return { - left: sliderBounds?.left ?? null, - top: sliderBounds?.top ?? null, - width: sliderBounds?.width ?? null, - height: sliderBounds?.height ?? null, - maxHashMarkOpacity: Math.max( - 0, - ...hashMarks.map((hashMark) => Number(getComputedStyle(hashMark).opacity)), - ), - }; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - -export const hoverVisibleSlider = async (page: Page): Promise => { - const sliderVisualState = await getVisibleSliderVisualState(page); - if ( - sliderVisualState.left === null || - sliderVisualState.top === null || - sliderVisualState.width === null || - sliderVisualState.height === null - ) { - throw new Error("Visible slider not found"); - } - await page.mouse.move( - sliderVisualState.left + sliderVisualState.width / 2, - sliderVisualState.top + sliderVisualState.height / 2, - ); -}; - -const getActiveTailwindLabelInfo = async ( - page: Page, -): Promise<{ text: string | null; tailwindLeft: number | null; valueLeft: number | null }> => - page.evaluate( - ({ attrName, tailwindLabelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const tailwindLabelElements = Array.from( - shadowRoot?.querySelectorAll( - `[data-react-grab-edit-panel] [${tailwindLabelAttr}]`, - ) ?? [], - ); - const tailwindLabel = - tailwindLabelElements.find((element) => element.getBoundingClientRect().width > 0) ?? null; - const valueText = - tailwindLabel - ?.closest("[data-react-grab-value]") - ?.querySelector("[data-react-grab-value-text]") ?? null; - return { - text: tailwindLabel?.textContent ?? null, - tailwindLeft: tailwindLabel?.getBoundingClientRect().left ?? null, - valueLeft: valueText?.getBoundingClientRect().left ?? null, - }; - }, - { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, - ); - -export const getActiveTailwindLabelOrder = async ( - page: Page, -): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => { - const { tailwindLeft, valueLeft } = await getActiveTailwindLabelInfo(page); - return { tailwindLeft, valueLeft }; -}; - -export const getActiveTailwindLabelText = async (page: Page): Promise => - (await getActiveTailwindLabelInfo(page)).text; - -export interface SearchInputFocusVisualState { - outlineStyle: string; - boxShadow: string; -} - -export const getSearchInputFocusVisualState = async ( - page: Page, -): Promise => - page.evaluate( - ({ attrName, inputAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const input = host?.shadowRoot?.querySelector(`[${inputAttr}]`); - if (!input) { - return { outlineStyle: "", boxShadow: "" }; - } - const computedStyle = getComputedStyle(input); - return { - outlineStyle: computedStyle.outlineStyle, - boxShadow: computedStyle.boxShadow, - }; - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR }, - ); - -export interface OverlayFocusVisualState { - label: string; - outlineStyle: string; - boxShadow: string; -} - -export const getOverlayFocusVisualStates = async ( - page: Page, - elementSelector: string, -): Promise => - page.evaluate( - ({ attrName, elementSelector }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const root = shadowRoot?.querySelector(`[${attrName}]`) ?? shadowRoot; - const elements = Array.from(root?.querySelectorAll(elementSelector) ?? []); - return elements - .filter((element) => element.getClientRects().length > 0) - .map((element) => { - element.focus({ preventScroll: true }); - const computedStyle = getComputedStyle(element); - return { - label: - element.getAttribute("data-react-grab-menu-item") ?? - element.getAttribute("data-react-grab-edit-property") ?? - element.getAttribute("aria-label") ?? - element.getAttribute("role") ?? - element.tagName.toLowerCase(), - outlineStyle: computedStyle.outlineStyle, - boxShadow: computedStyle.boxShadow, - }; - }); - }, - { attrName: ATTRIBUTE_NAME, elementSelector }, - ); - -export const typeInSearchInput = async (page: Page, text: string): Promise => { - await page - .locator(`[${ATTRIBUTE_NAME}]`) - .locator(`[${SEARCH_INPUT_ATTR}]`) - .pressSequentially(text); -}; - -export const setSearchInputValue = async (page: Page, value: string): Promise => { - await page.evaluate( - ({ attrName, inputAttr, nextValue }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) throw new Error("No shadow root"); - const input = shadowRoot.querySelector(`[${inputAttr}]`); - if (!input) throw new Error("Search input not found"); - input.focus(); - input.value = nextValue; - input.dispatchEvent( - new InputEvent("input", { bubbles: true, composed: true, data: nextValue }), - ); - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR, nextValue: value }, - ); -}; - -export const getInlineStyleProperty = async ( - page: Page, - selector: string, - property: string, -): Promise => - page.evaluate( - ({ elementSelector, propertyName }) => { - const element = document.querySelector(elementSelector); - if (!(element instanceof HTMLElement)) return ""; - return element.style.getPropertyValue(propertyName); - }, - { elementSelector: selector, propertyName: property }, - ); - -export const getInlineStyleAttribute = async (page: Page, selector: string): Promise => - page.evaluate((elementSelector) => { - const element = document.querySelector(elementSelector); - if (!(element instanceof HTMLElement)) return ""; - return element.getAttribute("style") ?? ""; - }, selector); - -export const dispatchOutsideDismiss = async (page: Page): Promise => - page.evaluate(() => { - window.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); - }); - -export const isEditPanelCompact = async (page: Page): Promise => - page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return false; - const panel = shadowRoot.querySelector(`[${panelAttr}]`); - return panel?.getAttribute("data-rg-compact") === "true"; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - -export const isHeaderCopyButtonVisible = async (page: Page): Promise => - page.evaluate( - ({ attrName, copyButtonAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const copyButton = shadowRoot?.querySelector(`[${copyButtonAttr}]`); - if (!copyButton) return false; - const copyButtonBounds = copyButton.getBoundingClientRect(); - return copyButtonBounds.width > 0 && copyButtonBounds.height > 0; - }, - { attrName: ATTRIBUTE_NAME, copyButtonAttr: COPY_BUTTON_ATTR }, - ); - -export interface ButtonVisualStyle { - backgroundColor: string; - borderColor: string; - color: string; - height: string; - paddingLeft: string; - paddingRight: string; -} - -export const getOverlayButtonVisualStyle = async ( - page: Page, - selector: string, -): Promise => - page.evaluate( - ({ attrName, buttonSelector }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const button = shadowRoot?.querySelector(buttonSelector); - if (!button) throw new Error("Button not found"); - const label = button.querySelector("span") ?? button; - const buttonStyle = getComputedStyle(button); - const labelStyle = getComputedStyle(label); - return { - backgroundColor: buttonStyle.backgroundColor, - borderColor: buttonStyle.borderColor, - color: labelStyle.color, - height: buttonStyle.height, - paddingLeft: buttonStyle.paddingLeft, - paddingRight: buttonStyle.paddingRight, - }; - }, - { attrName: ATTRIBUTE_NAME, buttonSelector: selector }, - ); - -export const isDiscardPromptVisible = async (page: Page): Promise => - page.evaluate( - ({ attrName }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const confirmButton = shadowRoot?.querySelector( - "[data-react-grab-discard-button='confirm']", - ); - if (!confirmButton) return false; - const confirmButtonBounds = confirmButton.getBoundingClientRect(); - return confirmButtonBounds.width > 0 && confirmButtonBounds.height > 0; - }, - { attrName: ATTRIBUTE_NAME }, - ); - -// Reach the discard prompt via the keyboard. From a compact panel the -// first Escape only expands it, so a second is needed; from the full -// panel one Escape is enough. Returns once the prompt is visible. -export const openDiscardPromptViaEscape = async (page: Page): Promise => { - for (let attempt = 0; attempt < 2; attempt++) { - await page.keyboard.press("Escape"); - await page.waitForTimeout(80); - if (await isDiscardPromptVisible(page)) return; - } -}; - -export const focusDiscardButton = async ( - page: Page, - action: "cancel" | "confirm", -): Promise => { - await page.evaluate( - ({ attrName, actionName }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const button = shadowRoot?.querySelector( - `[data-react-grab-discard-button='${actionName}']`, - ); - if (!button) throw new Error("Discard button not found"); - button.focus(); - }, - { attrName: ATTRIBUTE_NAME, actionName: action }, - ); -}; - -export const clickHeaderCopyButton = async (page: Page): Promise => { - await page.evaluate( - ({ attrName, copyButtonAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const button = shadowRoot?.querySelector(`[${copyButtonAttr}]`); - if (!button) throw new Error("Copy button not found"); - button.click(); - }, - { attrName: ATTRIBUTE_NAME, copyButtonAttr: COPY_BUTTON_ATTR }, - ); -}; - -export const clickDiscardButton = async ( - page: Page, - action: "cancel" | "confirm", -): Promise => { - await page.evaluate( - ({ attrName, actionName }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const button = shadowRoot?.querySelector( - `[data-react-grab-discard-button='${actionName}']`, - ); - if (!button) throw new Error("Discard button not found"); - button.click(); - }, - { attrName: ATTRIBUTE_NAME, actionName: action }, - ); -}; - -export const dragActiveSlider = async (page: Page): Promise => { - const sliderBounds = await page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const activePropertyRow = shadowRoot?.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - const slider = activePropertyRow?.querySelector("[role='slider']"); - if (!slider) throw new Error("Active slider not found"); - const bounds = slider.getBoundingClientRect(); - return { - left: bounds.left, - top: bounds.top, - width: bounds.width, - height: bounds.height, - }; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - const sliderCenterY = sliderBounds.top + sliderBounds.height / 2; - await page.mouse.move(sliderBounds.left + sliderBounds.width * 0.25, sliderCenterY); - await page.mouse.down(); - await page.mouse.move(sliderBounds.left + sliderBounds.width * 0.75, sliderCenterY, { - steps: 4, - }); - await page.mouse.up(); -}; - -export const getPropertyRowBounds = async (page: Page): Promise => - page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const propertyRows = Array.from( - shadowRoot?.querySelectorAll(`[${propertyAttr}]`) ?? [], - ); - return propertyRows.map((propertyRow) => { - const propertyRowBounds = propertyRow.getBoundingClientRect(); - return { - key: propertyRow.getAttribute(propertyAttr) ?? "", - isActive: propertyRow.getAttribute("aria-current") === "true", - left: propertyRowBounds.left, - top: propertyRowBounds.top, - width: propertyRowBounds.width, - height: propertyRowBounds.height, - }; - }); - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - -export const getEditPanelCompactAttr = async (page: Page): Promise => - page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const panel = shadowRoot?.querySelector(`[${panelAttr}]`); - return panel?.getAttribute("data-rg-compact") ?? null; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - -export const readSessionStorageEntries = async (page: Page): Promise> => - page.evaluate(() => { - const sessionStorageEntries: Record = {}; - for (let storageIndex = 0; storageIndex < sessionStorage.length; storageIndex++) { - const sessionStorageKey = sessionStorage.key(storageIndex); - if (sessionStorageKey?.startsWith("react-grab:edit:")) { - sessionStorageEntries[sessionStorageKey] = sessionStorage.getItem(sessionStorageKey) ?? ""; - } - } - return sessionStorageEntries; - }); - -export const clearEditStorage = async (page: Page): Promise => { - await page.evaluate(() => { - const editStorageKeysToRemove: string[] = []; - for (let storageIndex = 0; storageIndex < sessionStorage.length; storageIndex++) { - const sessionStorageKey = sessionStorage.key(storageIndex); - if (sessionStorageKey?.startsWith("react-grab:edit:")) { - editStorageKeysToRemove.push(sessionStorageKey); - } - } - for (const sessionStorageKey of editStorageKeysToRemove) { - sessionStorage.removeItem(sessionStorageKey); - } - }); -}; - -export const openEditPanel = async ( - reactGrab: ReactGrabPageObject, - selector: string, -): Promise => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(selector); - await reactGrab.rightClickElement(selector); - await reactGrab.clickContextMenuItem("Style"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - await reactGrab.page.locator(`[${ATTRIBUTE_NAME}]`).locator(`[${SEARCH_INPUT_ATTR}]`).focus(); -}; diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts deleted file mode 100644 index b60573b92..000000000 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ /dev/null @@ -1,1801 +0,0 @@ -import { expect, test } from "./fixtures.js"; -import { - ATTRIBUTE_NAME, - BUTTON_SELECTOR, - COPY_BUTTON_ATTR, - DISCARD_PROMPT_IDLE_MS, - EDIT_PANEL_ATTR, - EDIT_PROPERTY_ATTR, - IDLE_BUFFER_MS, - SEARCH_INPUT_ATTR, - clearEditStorage, - clickDiscardButton, - clickHeaderCopyButton, - dispatchOutsideDismiss, - dragActiveSlider, - focusDiscardButton, - getActivePropertyKey, - getActivePropertyValue, - getActiveSliderVisualState, - getActiveTailwindLabelOrder, - getActiveTailwindLabelText, - getEditPanelCompactAttr, - getInlineStyleAttribute, - getInlineStyleProperty, - getOverlayButtonVisualStyle, - getOverlayFocusVisualStates, - getPropertyRowBounds, - getSearchInputFocusVisualState, - getVisiblePropertyKeys, - hoverVisibleSlider, - isDiscardPromptVisible, - isEditPanelCompact, - isEditPanelVisible, - isHeaderCopyButtonVisible, - MAIN_TITLE_SELECTOR, - openDiscardPromptViaEscape, - openEditPanel, - readSessionStorageEntries, - setSearchInputValue, - typeInSearchInput, -} from "./edit-panel-helpers.js"; - -// Leaf element (text-only table cell with uniform `p-2`) whose center -// point hits the element itself — nested containers like the card -// select whichever child sits at their center. -const UNIFORM_PADDING_SELECTOR = "[data-testid='th-1']"; - -test.describe("Style Panel", () => { - test.beforeEach(async ({ reactGrab }) => { - await clearEditStorage(reactGrab.page); - }); - - test.describe("Opening", () => { - test("right-click -> Style opens the panel", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - }); - - test("search input has no focus ring", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await expect( - reactGrab.page.locator(`[${ATTRIBUTE_NAME}]`).locator(`[${SEARCH_INPUT_ATTR}]`), - ).toBeFocused(); - const focusVisualState = await getSearchInputFocusVisualState(reactGrab.page); - expect(focusVisualState.outlineStyle).toBe("none"); - expect(focusVisualState.boxShadow).toBe("none"); - }); - - test("context menu rows have no focus ring", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.rightClickElement(BUTTON_SELECTOR); - const focusVisualStates = await getOverlayFocusVisualStates( - reactGrab.page, - '[role="menu"], [data-react-grab-menu-item]', - ); - expect(focusVisualStates.map((state) => state.label)).toContain("style"); - expect( - focusVisualStates.filter( - (state) => state.outlineStyle !== "none" || state.boxShadow !== "none", - ), - ).toEqual([]); - }); - - test("Style controls have no focus ring", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const focusVisualStates = await getOverlayFocusVisualStates( - reactGrab.page, - '[data-react-grab-input], [data-react-grab-edit-property], button, [role="slider"]', - ); - expect(focusVisualStates.map((state) => state.label)).toContain("Search properties"); - expect( - focusVisualStates.filter( - (state) => state.outlineStyle !== "none" || state.boxShadow !== "none", - ), - ).toEqual([]); - }); - - test("hovering a style row keeps row height stable", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - const beforeRows = await getPropertyRowBounds(reactGrab.page); - const targetRowIndex = beforeRows.findIndex((row) => !row.isActive); - const targetRow = beforeRows[targetRowIndex]; - if (!targetRow) throw new Error("Expected an inactive style row"); - - await reactGrab.page.mouse.move( - targetRow.left + targetRow.width / 2, - targetRow.top + targetRow.height / 2, - ); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe(targetRow.key); - - const afterRows = await getPropertyRowBounds(reactGrab.page); - const afterTargetRow = afterRows[targetRowIndex]; - if (!afterTargetRow) throw new Error("Expected hovered style row to remain visible"); - expect(Math.abs(afterTargetRow.height - targetRow.height)).toBeLessThan(0.5); - }); - - test("S opens Style from the context menu", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.rightClickElement(BUTTON_SELECTOR); - - await reactGrab.page.keyboard.press("s"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - }); - - test("Enter triggers Comment from the context menu", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.rightClickElement(BUTTON_SELECTOR); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); - }); - }); - - test.describe("Dismissal", () => { - test("Escape dismisses the panel", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - }); - - test("Escape remains owned by the panel after a host input receives focus", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const focusedTestId = await reactGrab.page.evaluate(() => { - const hostInput = document.querySelector("[data-testid='test-input']"); - hostInput?.focus(); - return document.activeElement?.getAttribute("data-testid") ?? null; - }); - expect(focusedTestId).toBe("test-input"); - - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - }); - - test("Escape opens discard prompt after host focus when tweaks are pending", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - const focusedTestId = await reactGrab.page.evaluate(() => { - const hostInput = document.querySelector("[data-testid='test-input']"); - hostInput?.focus(); - return document.activeElement?.getAttribute("data-testid") ?? null; - }); - expect(focusedTestId).toBe("test-input"); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("second Escape discards inline preview from the discard prompt", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - const duringTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(duringTweak.length).toBeGreaterThan(0); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - - const afterDismiss = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(afterDismiss).toBe(beforeTweak); - }); - - test("discard prompt auto-hides after idle", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await reactGrab.page.waitForTimeout(DISCARD_PROMPT_IDLE_MS + 100); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("Escape on focused No button confirms discard", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - await focusDiscardButton(reactGrab.page, "cancel"); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("outside mousedown without a grabbable target dismisses the panel", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await dispatchOutsideDismiss(reactGrab.page); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - }); - - test("second outside dismiss confirms discard prompt", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await dispatchOutsideDismiss(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await dispatchOutsideDismiss(reactGrab.page); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("Escape in compact mode expands the panel before prompting to discard", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("clicking outside in compact mode expands and prompts to discard directly", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - - await dispatchOutsideDismiss(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("canceling mouse-move discard prompt consumes the pointer handoff", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await clickDiscardButton(reactGrab.page, "cancel"); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - await reactGrab.page.mouse.move(20, 20); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("canceling outside-click discard prompt consumes the pointer handoff", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await dispatchOutsideDismiss(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await clickDiscardButton(reactGrab.page, "cancel"); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - await reactGrab.page.mouse.move(20, 20); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("mouse movement while discard prompt is visible does not consume the handoff", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await clickDiscardButton(reactGrab.page, "cancel"); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - await reactGrab.page.mouse.move(20, 20); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("keyboard navigation after pointer tweak does not arm mouse-move discard prompt", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await dragActiveSlider(reactGrab.page); - await expect.poll(() => isHeaderCopyButtonVisible(reactGrab.page)).toBe(true); - - await reactGrab.page.keyboard.press("ArrowDown"); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("net-zero tweak dismiss restores preview inline styles", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "px-2"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await dispatchOutsideDismiss(reactGrab.page); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("toolbar menu dismiss restores preview inline styles", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await reactGrab.rightClickToolbarToggle(); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("renderer disable dismiss restores preview inline styles", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await reactGrab.page.evaluate(() => { - window.__REACT_GRAB__?.setEnabled(false); - }); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("disposal restores preview inline styles", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await reactGrab.page.evaluate(() => { - window.__REACT_GRAB__?.dispose(); - }); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("held arrow repeat stops while discard prompt is visible", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.down("ArrowRight"); - await reactGrab.page.waitForTimeout(360); - // A keyboard Escape from compact only expands; the held arrow would - // re-collapse it. An outside click goes straight to the discard - // prompt, which is what freezes the held-repeat value. - await dispatchOutsideDismiss(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - const valueAtPrompt = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - - await reactGrab.page.waitForTimeout(180); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(valueAtPrompt); - await reactGrab.page.keyboard.up("ArrowRight"); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - }); - - test("mouse movement without pending tweaks does not open the discard prompt", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - - // Nothing was tweaked, so the handoff was never armed. - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - }); - - test("mouse-move discard prompt Yes reverts the keyboard tweak", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await clickDiscardButton(reactGrab.page, "confirm"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); - }); - - test("a fresh keyboard tweak re-arms the consumed pointer handoff", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await clickDiscardButton(reactGrab.page, "cancel"); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - // The handoff is one-shot, but a new keyboard commit re-arms it. - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.mouse.move(40, 40); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("typing a tailwind class arms the mouse-move discard handoff", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - // px-4 (16px) differs from the button's px-2 (8px), so it's a real, - // submittable edit rather than a net-zero one. - await typeInSearchInput(reactGrab.page, "px-4"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); - - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("touch pointer movement does not consume the keyboard handoff", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - // A touch pointermove is ignored by the mouse-only handoff, so it must - // neither open the prompt nor consume the arm. - await reactGrab.page.evaluate(() => { - window.dispatchEvent( - new PointerEvent("pointermove", { pointerType: "touch", bubbles: true }), - ); - }); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); - - // The still-armed handoff fires on the next real mouse move. - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - }); - - test.describe("Property listing", () => { - test("non-uniform padding emits y/x aggregate rows (not all 4 sides)", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const keys = await getVisiblePropertyKeys(reactGrab.page); - expect(keys).toContain("padding-top,padding-bottom"); - expect(keys).toContain("padding-left,padding-right"); - }); - - test("font-size surfaces for elements that have explicit sizing", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const keys = await getVisiblePropertyKeys(reactGrab.page); - expect(keys).toContain("font-size"); - }); - - test("border-radius surfaces for rounded elements", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const keys = await getVisiblePropertyKeys(reactGrab.page); - expect(keys).toContain("border-radius"); - }); - - test("properties matching the baseline are hidden from the default list", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const keys = await getVisiblePropertyKeys(reactGrab.page); - expect(keys.some((key) => key.startsWith("margin"))).toBe(false); - - await typeInSearchInput(reactGrab.page, "margin"); - await reactGrab.page.waitForTimeout(80); - const searched = await getVisiblePropertyKeys(reactGrab.page); - expect(searched.some((key) => key.startsWith("margin"))).toBe(true); - }); - - test("typing a search query reveals non-canonical properties", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const beforeSearch = await getVisiblePropertyKeys(reactGrab.page); - expect(beforeSearch).not.toContain("padding-top"); - - await typeInSearchInput(reactGrab.page, "padding"); - await reactGrab.page.waitForTimeout(80); - const afterSearch = await getVisiblePropertyKeys(reactGrab.page); - expect(afterSearch).toContain("padding-top"); - }); - }); - - test.describe("Tailwind alias ranking", () => { - test("typing 'pl' surfaces padding-left even when consolidated", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "pl"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("padding-left"); - }); - - test("typing 'pt' ranks padding-top first", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "pt"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("padding-top"); - }); - - test("typing 'rounded' ranks border-radius first", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "rounded"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("border-radius"); - }); - - test("typing 'text' ranks font-size first", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "text"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("font-size"); - }); - - test("typing 'font-mono' ranks font-family first", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "font-mono"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("font-family"); - }); - - test("typing a partial Tailwind alias uses prefix search", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "font-mo"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("font-family"); - }); - - test("typing 'uppercase' ranks text-transform first", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "uppercase"); - await expect - .poll(async () => (await getVisiblePropertyKeys(reactGrab.page))[0]) - .toBe("text-transform"); - }); - }); - - test.describe("Tweaking", () => { - test("ArrowRight increments the active property's displayed value", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const valueBeforeIncrement = await getActivePropertyValue(reactGrab.page); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const valueAfterIncrement = await getActivePropertyValue(reactGrab.page); - expect(valueAfterIncrement).not.toBe(valueBeforeIncrement); - }); - - test("ArrowLeft decrements the active property's displayed value", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const valueAfterIncrement = await getActivePropertyValue(reactGrab.page); - await reactGrab.page.keyboard.press("ArrowLeft"); - await reactGrab.page.waitForTimeout(80); - const valueAfterDecrement = await getActivePropertyValue(reactGrab.page); - expect(valueAfterDecrement).not.toBe(valueAfterIncrement); - }); - - test("tweak applies an inline style on the target element", async ({ reactGrab }) => { - const inlineStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const inlineStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(inlineStyleAfterTweak.length).toBeGreaterThan(0); - expect(inlineStyleAfterTweak).not.toBe(inlineStyleBeforeTweak); - }); - - test("idle numeric rows show slider fill without the handle caret", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "line height"); - await reactGrab.page.waitForTimeout(80); - - const activeSliderVisualState = await getActiveSliderVisualState(reactGrab.page); - expect(activeSliderVisualState.key).toBe("line-height"); - expect(activeSliderVisualState.width ?? 0).toBeGreaterThan(0); - expect(activeSliderVisualState.fillOpacity ?? 0).toBeGreaterThan(0); - expect(activeSliderVisualState.handleOpacity).toBe(0); - }); - - test("hovering a numeric row shows slider unit marks and handle", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "line height"); - await reactGrab.page.waitForTimeout(80); - - await expect - .poll(async () => (await getActiveSliderVisualState(reactGrab.page)).handleOpacity) - .toBe(0); - await expect - .poll(async () => (await getActiveSliderVisualState(reactGrab.page)).maxHashMarkOpacity) - .toBe(0); - - await reactGrab.page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const activePropertyRow = shadowRoot?.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - activePropertyRow?.querySelector("[role='slider']")?.scrollIntoView({ - block: "center", - inline: "center", - }); - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - - const sliderBounds = await reactGrab.page.evaluate( - ({ attrName, propertyAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const activePropertyRow = shadowRoot?.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - const slider = activePropertyRow?.querySelector("[role='slider']"); - const sliderBounds = slider?.getBoundingClientRect(); - return sliderBounds - ? { - left: sliderBounds.left, - top: sliderBounds.top, - width: sliderBounds.width, - height: sliderBounds.height, - } - : null; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - if (!sliderBounds) throw new Error("Active slider not found"); - - await reactGrab.page.mouse.move( - sliderBounds.left + sliderBounds.width / 2, - sliderBounds.top + sliderBounds.height / 2, - ); - await reactGrab.page.waitForTimeout(220); - - const afterHover = await getActiveSliderVisualState(reactGrab.page); - expect(afterHover.handleOpacity ?? 0).toBeGreaterThan(0); - expect(afterHover.maxHashMarkOpacity).toBeGreaterThan(0); - }); - - test("active highlight re-syncs its width when the panel resizes", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.waitForTimeout(200); - - const readHighlightFit = () => - reactGrab.page.evaluate( - ({ attrName, propertyAttr }) => { - const root = document.querySelector(`[${attrName}]`)?.shadowRoot; - const activeRow = root?.querySelector( - `[${propertyAttr}][aria-current="true"]`, - ); - const highlight = activeRow - ?.closest("[role='menu']") - ?.querySelector("[aria-hidden='true']"); - if (!activeRow || !highlight) throw new Error("active row or highlight not found"); - return { rowWidth: activeRow.offsetWidth, highlightWidth: highlight.offsetWidth }; - }, - { attrName: ATTRIBUTE_NAME, propertyAttr: EDIT_PROPERTY_ATTR }, - ); - - const initial = await readHighlightFit(); - expect(initial.highlightWidth).toBe(initial.rowWidth); - - await reactGrab.page.evaluate( - ({ attrName, panelAttr }) => { - const surface = document - .querySelector(`[${attrName}]`) - ?.shadowRoot?.querySelector(`[${panelAttr}] > div`); - if (!surface) throw new Error("panel surface not found"); - surface.style.width = "300px"; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - - await expect - .poll(async () => { - const { rowWidth, highlightWidth } = await readHighlightFit(); - return rowWidth > 200 && highlightWidth === rowWidth; - }) - .toBe(true); - }); - - test("hovering another row after slider adjustment updates the active property", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const activePropertyKeyBeforeHover = await getActivePropertyKey(reactGrab.page); - - await dragActiveSlider(reactGrab.page); - const propertyRows = await getPropertyRowBounds(reactGrab.page); - const hoverTargetRow = propertyRows.find( - (propertyRow) => !propertyRow.isActive && propertyRow.width > 0 && propertyRow.height > 0, - ); - if (!hoverTargetRow) throw new Error("Hover target row not found"); - - await reactGrab.page.mouse.move( - hoverTargetRow.left + hoverTargetRow.width / 2, - hoverTargetRow.top + hoverTargetRow.height / 2, - ); - - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe(hoverTargetRow.key); - expect(await getActivePropertyKey(reactGrab.page)).not.toBe(activePropertyKeyBeforeHover); - }); - - test("discard prompt expands to full panel", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(220); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - }); - - test("Tailwind label appears to the left of the value", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.down("Shift"); - try { - await reactGrab.page.waitForTimeout(80); - - const order = await getActiveTailwindLabelOrder(reactGrab.page); - expect(order.tailwindLeft).not.toBeNull(); - expect(order.valueLeft).not.toBeNull(); - expect(order.tailwindLeft ?? 0).toBeLessThan(order.valueLeft ?? 0); - } finally { - await reactGrab.page.keyboard.up("Shift"); - } - }); - - test("Tailwind label names the aggregate class on a uniform padding row", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); - await reactGrab.page.keyboard.down("Shift"); - try { - await expect.poll(() => getActiveTailwindLabelText(reactGrab.page)).toBe("p-2"); - } finally { - await reactGrab.page.keyboard.up("Shift"); - } - }); - - test("stepping an out-of-range value never jumps against the arrow direction", async ({ - reactGrab, - }) => { - // 200px border-radius is above the 96px row max but applies no - // layout (unlike a 128px font, whose reflow makes the hover-based - // panel open flaky) so the regression is isolated cleanly. - await reactGrab.page.evaluate((buttonSelector) => { - const button = document.querySelector(buttonSelector); - if (button instanceof HTMLElement) button.style.borderRadius = "200px"; - }, BUTTON_SELECTOR); - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "rounded"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); - - // Stepping up from an above-max value is a no-op; settle then - // assert it held (can't poll for the absence of a change). - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyValue(reactGrab.page)).toBe("200px"); - - // Alt opts out of token snapping for a raw step: ArrowLeft → 199 proves - // the step came off the real 200, not a clamp to the 96px max (which - // would land on 95). - await reactGrab.page.keyboard.down("Alt"); - await reactGrab.page.keyboard.press("ArrowLeft"); - await reactGrab.page.keyboard.up("Alt"); - await expect.poll(() => getActivePropertyValue(reactGrab.page)).toBe("199px"); - }); - - test("rounded-full's infinite radius displays as a finite clamped value", async ({ - reactGrab, - }) => { - await reactGrab.page.evaluate((buttonSelector) => { - const button = document.querySelector(buttonSelector); - if (button instanceof HTMLElement) button.style.borderRadius = "calc(infinity * 1px)"; - }, BUTTON_SELECTOR); - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "rounded"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); - expect(await getActivePropertyValue(reactGrab.page)).toBe("96px"); - }); - - test("ArrowRight/ArrowLeft snap a length through the design-token scale", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "padding"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); - - // p-2 (8px) starts inside the [--rg-test-space-4: 16px, --rg-test-space-6: 24px] - // scale, so the arrows snap to the next/previous token rather than ±1px. - await reactGrab.page.keyboard.press("ArrowRight"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("16px"); - - await reactGrab.page.keyboard.press("ArrowRight"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("24px"); - - await reactGrab.page.keyboard.press("ArrowLeft"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("16px"); - }); - - test("ArrowRight past the top of the token scale falls back to a raw step", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "padding"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); - - // 8px → 16px → 24px walks the token scale; the next press is at the - // scale's max token, so it nudges by a raw pixel instead of dead-ending. - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.keyboard.press("ArrowRight"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("24px"); - await reactGrab.page.keyboard.press("ArrowRight"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("25px"); - }); - - test("Alt+ArrowRight does a fine raw step instead of snapping to a token", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "padding"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); - - // Plain ArrowRight would snap 8px up to the 16px token; Alt opts out for - // a precise ±1px nudge so values can land between tokens. - await reactGrab.page.keyboard.down("Alt"); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.keyboard.up("Alt"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("9px"); - }); - - test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page); - await reactGrab.page.keyboard.press("ArrowDown"); - await reactGrab.page.waitForTimeout(80); - const activePropertyKeyAfterDown = await getActivePropertyKey(reactGrab.page); - expect(activePropertyKeyAfterDown).not.toBe(initialActivePropertyKey); - }); - - test("Shift+ArrowRight steps by 10×", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const valueBeforeStep = await getActivePropertyValue(reactGrab.page); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const valueAfterOneStep = await getActivePropertyValue(reactGrab.page); - - await reactGrab.page.keyboard.down("Shift"); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.keyboard.up("Shift"); - await reactGrab.page.waitForTimeout(80); - const valueAfterShiftStep = await getActivePropertyValue(reactGrab.page); - - const parseNumericDisplayValue = (text: string | null): number => - Number.parseFloat((text ?? "").replace(/[^\d.-]/g, "")) || 0; - const oneStepDelta = Math.abs( - parseNumericDisplayValue(valueAfterOneStep) - parseNumericDisplayValue(valueBeforeStep), - ); - const shiftStepDelta = Math.abs( - parseNumericDisplayValue(valueAfterShiftStep) - parseNumericDisplayValue(valueAfterOneStep), - ); - expect(shiftStepDelta).toBeGreaterThan(oneStepDelta); - }); - - test("typing 'size' on a square element steps width and height together", async ({ - reactGrab, - }) => { - const squareSelector = "[data-testid='gradient-div']"; - await openEditPanel(reactGrab, squareSelector); - await setSearchInputValue(reactGrab.page, "size"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("width,height"); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const width = await getInlineStyleProperty(reactGrab.page, squareSelector, "width"); - const height = await getInlineStyleProperty(reactGrab.page, squareSelector, "height"); - expect(width).not.toBe(""); - expect(width).toBe(height); - }); - - test("ArrowRight cycles font-family", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await typeInSearchInput(reactGrab.page, "font family"); - await reactGrab.page.waitForTimeout(80); - expect(await getActivePropertyKey(reactGrab.page)).toBe("font-family"); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - const fontFamily = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "font-family", - ); - expect(fontFamily.length).toBeGreaterThan(0); - }); - }); - - test.describe("Compact mode", () => { - test("keyboard tweak collapses the panel", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - }); - - test("compact mode is sticky once committed", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - }); - - test("compact keyboard tweak opens discard prompt on hover", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - - await hoverVisibleSlider(reactGrab.page); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - }); - - test("typing in search re-expands the compact panel", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - await typeInSearchInput(reactGrab.page, "q"); - await expect.poll(() => isEditPanelCompact(reactGrab.page)).toBe(false); - }); - - test("full search does not direct-apply unit values", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - const paddingLeftBeforeTyping = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "padding-left", - ); - - await typeInSearchInput(reactGrab.page, "50px"); - await reactGrab.page.waitForTimeout(80); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("false"); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")).toBe( - paddingLeftBeforeTyping, - ); - }); - - test("compact inline numeric edit survives decimal drafts", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const activePropertyKey = await getActivePropertyKey(reactGrab.page); - expect(activePropertyKey).toBe("padding-left,padding-right"); - - await setSearchInputValue(reactGrab.page, "24"); - await reactGrab.page.waitForTimeout(80); - await setSearchInputValue(reactGrab.page, "24."); - await reactGrab.page.waitForTimeout(80); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe(activePropertyKey); - - await setSearchInputValue(reactGrab.page, "24.5"); - await reactGrab.page.waitForTimeout(80); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe(activePropertyKey); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")).toBe( - "25px", - ); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-right")).toBe( - "25px", - ); - }); - - test("compact unitless replacement keeps paused second digits", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const activePropertyKey = await getActivePropertyKey(reactGrab.page); - expect(activePropertyKey).toBe("padding-left,padding-right"); - - await typeInSearchInput(reactGrab.page, "24"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - await typeInSearchInput(reactGrab.page, "3"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - await typeInSearchInput(reactGrab.page, "6"); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe(activePropertyKey); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")) - .toBe("36px"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-right")) - .toBe("36px"); - }); - - test("compact inline numeric edit accepts matching CSS units", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - const activePropertyKey = await getActivePropertyKey(reactGrab.page); - expect(activePropertyKey).toBe("padding-left,padding-right"); - - await typeInSearchInput(reactGrab.page, "50px"); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe(activePropertyKey); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")) - .toBe("50px"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-right")) - .toBe("50px"); - - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - await typeInSearchInput(reactGrab.page, "6"); - await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - await typeInSearchInput(reactGrab.page, "0px"); - await reactGrab.page.waitForTimeout(80); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe(activePropertyKey); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")).toBe( - "60px", - ); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-right")).toBe( - "60px", - ); - }); - - test("compact unit edit keeps a searched active property targeted", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "font size"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("font-size"); - const paddingLeftBeforeTyping = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "padding-left", - ); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - await setSearchInputValue(reactGrab.page, "50px"); - await reactGrab.page.waitForTimeout(80); - - expect(await getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - expect(await getActivePropertyKey(reactGrab.page)).toBe("font-size"); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size")).toBe( - "50px", - ); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-left")).toBe( - paddingLeftBeforeTyping, - ); - }); - - test("type-to-edit: hover + type m then t → margin-top focused", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("mt", { delay: 50 }); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - await reactGrab.page.waitForTimeout(80); - const searchValue = await reactGrab.page.evaluate( - ({ attrName, inputAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const input = shadowRoot?.querySelector(`[${inputAttr}]`); - return input?.value ?? null; - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR }, - ); - expect(searchValue).toBe("mt"); - const activeKey = await getActivePropertyKey(reactGrab.page); - expect(activeKey).toBe("margin-top"); - }); - - test("type-to-edit: hover + type m-t-dash → search shows mt-, active is margin-top", async ({ - reactGrab, - }) => { - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("mt-", { delay: 50 }); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - await reactGrab.page.waitForTimeout(80); - const searchValue = await reactGrab.page.evaluate( - ({ attrName, inputAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const input = shadowRoot?.querySelector(`[${inputAttr}]`); - return input?.value ?? null; - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR }, - ); - expect(searchValue).toBe("mt-"); - const activeKey = await getActivePropertyKey(reactGrab.page); - expect(activeKey).toBe("margin-top"); - }); - - test("typing a tailwind prefix (e.g. mt) sets compact state", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const compactAttrBeforeTyping = await reactGrab.page.evaluate( - ({ attrName, panelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const panel = shadowRoot?.querySelector(`[${panelAttr}]`); - return panel?.getAttribute("data-rg-compact") ?? null; - }, - { attrName: ATTRIBUTE_NAME, panelAttr: EDIT_PANEL_ATTR }, - ); - expect(compactAttrBeforeTyping).toBe("false"); - await typeInSearchInput(reactGrab.page, "mt"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - }); - - test("typing a complete tailwind class (mt-5) applies value + compact", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const marginTopBeforeTyping = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "margin-top", - ); - await setSearchInputValue(reactGrab.page, "mt-5"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) - .not.toBe(marginTopBeforeTyping); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) - .toContain("20"); - }); - - test("typing -m-4 applies a negative margin", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "-m-4"); - - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) - .toBe("-16px"); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-left")).toBe( - "-16px", - ); - }); - - test("typing -mt-[8px] applies a negative arbitrary margin", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "-mt-[8px]"); - - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) - .toBe("-8px"); - }); - - test("typing font-mono applies font family + compact", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "font-mono"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-family")) - .toContain("ui-monospace"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - }); - - test("typing uppercase applies text transform + compact", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "uppercase"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "text-transform")) - .toBe("uppercase"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - }); - - test("typing p-4 on non-uniform spacing writes to both axis aggregates", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "p-4"); - for (const paddingProperty of [ - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - ]) { - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, paddingProperty)) - .toContain("16"); - } - }); - - test("typing multiple tailwind classes applies each token", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "p-4 mt-5"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-top")) - .toBe("16px"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) - .toBe("20px"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - }); - - test("typing border-t-4 writes only the top border width", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "border-t-4"); - - const borderRightWidth = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "border-right-width", - ); - const borderBottomWidth = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "border-bottom-width", - ); - const borderLeftWidth = await getInlineStyleProperty( - reactGrab.page, - BUTTON_SELECTOR, - "border-left-width", - ); - - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "border-top-width")) - .toBe("4px"); - expect(borderRightWidth).toBe(""); - expect(borderBottomWidth).toBe(""); - expect(borderLeftWidth).toBe(""); - }); - - test("typing py 40 applies padding-y", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "py 40"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-top")) - .toBe("160px"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "padding-bottom")) - .toBe("160px"); - await expect.poll(() => getEditPanelCompactAttr(reactGrab.page)).toBe("true"); - }); - - test("compact value updates live on subsequent tweaks", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - const firstDisplayedValue = await getActivePropertyValue(reactGrab.page); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const secondDisplayedValue = await getActivePropertyValue(reactGrab.page); - expect(secondDisplayedValue).not.toBe(firstDisplayedValue); - }); - }); - - test.describe("Commit behavior", () => { - test("Enter does not write to sessionStorage (in-memory only)", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - - const sessionStorageEntries = await readSessionStorageEntries(reactGrab.page); - expect(Object.keys(sessionStorageEntries).length).toBe(0); - }); - - test("Escape does not write to sessionStorage (in-memory only)", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - // Escape from compact: expand, then prompt to discard, then confirm. - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - - const sessionStorageEntries = await readSessionStorageEntries(reactGrab.page); - expect(Object.keys(sessionStorageEntries).length).toBe(0); - }); - - test("inline styles persist on commit (not reverted)", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const inlineStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(inlineStyleAfterTweak.length).toBeGreaterThan(0); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await reactGrab.page.waitForTimeout(200); - - const afterCommit = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(afterCommit).toBe(inlineStyleAfterTweak); - }); - - test("copied prompt matches the preview when aggregate and longhand tweaks overlap", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "pt-8"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("32px"); - await setSearchInputValue(reactGrab.page, "p-6"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("24px"); - await setSearchInputValue(reactGrab.page, "pt-1"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("4px"); - expect( - await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"), - ).toBe("24px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 4px;"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("padding-right: 24px;"); - expect(clipboardContent).not.toContain("padding-top: 24px;"); - }); - - test("copied prompt emits a longhand stepped back to its original under a changed aggregate", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "pt-8"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("32px"); - await setSearchInputValue(reactGrab.page, "p-6"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("24px"); - // Back to the original 8px: the padding-top override must still - // be emitted or the prompt claims the p-6 fan-out covers the top. - await setSearchInputValue(reactGrab.page, "pt-2"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("8px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 8px;"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("padding-right: 24px;"); - expect(clipboardContent).not.toContain("padding-top: 24px;"); - }); - - test("copied prompt matches the preview across overlapping full, longhand, and axis aggregates", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "p-6"); - await expect - .poll(() => - getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), - ) - .toBe("24px"); - await setSearchInputValue(reactGrab.page, "pt-8"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("32px"); - // px-2 sends left/right back to their 8px original on top of the - // p-6 fan-out; it must still be emitted so the prompt doesn't claim - // padding: 24px covers the horizontal sides. - await setSearchInputValue(reactGrab.page, "px-2"); - await expect - .poll(() => - getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), - ) - .toBe("8px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-left: 8px;"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("padding-top: 32px;"); - expect(clipboardContent).toContain("padding-right: 8px;"); - expect(clipboardContent).toContain("padding-bottom: 24px;"); - expect(clipboardContent).not.toContain("padding-right: 24px;"); - }); - - test("re-committing an aggregate over a prior longhand keeps prompt and preview in sync", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "p-6"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("24px"); - await setSearchInputValue(reactGrab.page, "pt-2"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("8px"); - // Re-committing the wider padding aggregate fans out to every side - // (the preview overwrites the pt override wholesale), so the dropped - // longhand keeps prompt == preview: top must follow, not stay at 8px. - await setSearchInputValue(reactGrab.page, "p-7"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("28px"); - expect( - await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"), - ).toBe("28px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("padding-top: 28px;"); - expect(clipboardContent).toContain("padding-right: 28px;"); - expect(clipboardContent).not.toContain("padding-top: 8px;"); - }); - - test("copied prompt annotates a length matching a design token", async ({ reactGrab }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - await setSearchInputValue(reactGrab.page, "p-4"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("16px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect - .poll(() => reactGrab.getClipboardContent()) - .toContain("/* var(--rg-test-space-4) */"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("padding-top: 16px; /* var(--rg-test-space-4) */"); - expect(clipboardContent).toContain("Prefer the design token"); - }); - - test("copied prompt annotates a color matching a design token", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "text-[#123456]"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "color")) - .not.toBe(""); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect - .poll(() => reactGrab.getClipboardContent()) - .toContain("/* var(--rg-test-brand) */"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("color: #123456; /* var(--rg-test-brand) */"); - }); - - test("copied prompt leaves a non-token length unannotated", async ({ reactGrab }) => { - await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); - // 17px has no matching design token, so the prompt keeps the raw value. - await setSearchInputValue(reactGrab.page, "p-[17px]"); - await expect - .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) - .toBe("17px"); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 17px;"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).not.toContain("/* var("); - expect(clipboardContent).not.toContain("Prefer the design token"); - }); - - test("header Copy button appears after a pending tweak and submits", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(false); - - await dragActiveSlider(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelCompact(reactGrab.page)).toBe(false); - expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(true); - - const inlineStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(inlineStyleAfterTweak.length).toBeGreaterThan(0); - await clickHeaderCopyButton(reactGrab.page); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect - .poll(() => reactGrab.getClipboardContent()) - .toContain("best expresses the underlying layout intent"); - - const afterCommit = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(afterCommit).toBe(inlineStyleAfterTweak); - }); - - test("header Copy button matches the neutral discard button style", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await dragActiveSlider(reactGrab.page); - await reactGrab.page.waitForTimeout(80); - - const copyButtonStyle = await getOverlayButtonVisualStyle( - reactGrab.page, - `[${COPY_BUTTON_ATTR}]`, - ); - - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - const cancelButtonStyle = await getOverlayButtonVisualStyle( - reactGrab.page, - "[data-react-grab-discard-button='cancel']", - ); - - expect(copyButtonStyle).toEqual(cancelButtonStyle); - }); - }); - - test.describe("Element switching", () => { - test("panel stays open while pointer moves over other elements", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.mouse.move(10, 10); - await reactGrab.page.waitForTimeout(80); - await reactGrab.page.mouse.move(400, 400); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - }); - - test("clicking another element switches the style target and keeps applied styles", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const buttonStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - expect(buttonStyleAfterTweak.length).toBeGreaterThan(0); - - await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); - await reactGrab.page.waitForTimeout(150); - - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( - buttonStyleAfterTweak, - ); - - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const titleStyleAfterTweak = await getInlineStyleAttribute( - reactGrab.page, - MAIN_TITLE_SELECTOR, - ); - expect(titleStyleAfterTweak.length).toBeGreaterThan(0); - }); - - test("edits compound across switched elements on copy", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); - await reactGrab.page.waitForTimeout(150); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - const buttonStyleBeforeCopy = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - const titleStyleBeforeCopy = await getInlineStyleAttribute( - reactGrab.page, - MAIN_TITLE_SELECTOR, - ); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - await expect - .poll(() => reactGrab.getClipboardContent()) - .toContain("best expresses the underlying layout intent"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent.match(/```css/g)?.length).toBe(2); - - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( - buttonStyleBeforeCopy, - ); - expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).toBe( - titleStyleBeforeCopy, - ); - }); - - test("discarding after switching restores every styled element", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); - await reactGrab.page.waitForTimeout(150); - const titleStyleBeforeTweak = await getInlineStyleAttribute( - reactGrab.page, - MAIN_TITLE_SELECTOR, - ); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).not.toBe( - titleStyleBeforeTweak, - ); - - await openDiscardPromptViaEscape(reactGrab.page); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( - buttonStyleBeforeTweak, - ); - expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).toBe( - titleStyleBeforeTweak, - ); - }); - - test("copy reverts a switched-away element whose tweak was undone", async ({ reactGrab }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - const buttonOriginalValue = (await getActivePropertyValue(reactGrab.page)) ?? ""; - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - // Token snapping makes ArrowRight/ArrowLeft asymmetric, so restore the - // exact original value to net the button's edits back to zero. - await setSearchInputValue(reactGrab.page, buttonOriginalValue.replace(/[^\d.-]/g, "")); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); - await reactGrab.page.waitForTimeout(150); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - const titleStyleBeforeCopy = await getInlineStyleAttribute( - reactGrab.page, - MAIN_TITLE_SELECTOR, - ); - - await reactGrab.page.keyboard.press("Enter"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent.match(/```css/g)?.length).toBe(1); - - // The button netted no edits, so copy must not leave it styled. - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( - buttonStyleBeforeTweak, - ); - expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).toBe( - titleStyleBeforeCopy, - ); - }); - - test("session edits from a previous element keep the discard prompt armed", async ({ - reactGrab, - }) => { - await openEditPanel(reactGrab, BUTTON_SELECTOR); - const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); - await reactGrab.page.keyboard.press("ArrowRight"); - await reactGrab.page.waitForTimeout(80); - - await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); - await reactGrab.page.waitForTimeout(150); - - await reactGrab.page.keyboard.press("Escape"); - await reactGrab.page.waitForTimeout(80); - expect(await isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); - - await reactGrab.page.keyboard.press("Escape"); - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); - expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( - buttonStyleBeforeTweak, - ); - }); - }); - - test.describe("Comment plugin coexistence", () => { - test("registerCommentAction restores the Comment context menu item", async ({ reactGrab }) => { - await reactGrab.page.evaluate(() => { - window.__REACT_GRAB__?.unregisterPlugin("comment"); - }); - await reactGrab.registerCommentAction(); - await reactGrab.activate(); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.rightClickElement(BUTTON_SELECTOR); - await reactGrab.clickContextMenuItem("Comment"); - await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); - }); - }); -}); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 565bc817a..c1e63bc28 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -1105,7 +1105,7 @@ const createReactGrabPageObject = ( const parts = spanText.split("."); componentName = parts[0] ?? null; tagName = parts[1] ?? null; - } else if (spanText && !spanText.includes("Editing") && !tagName) { + } else if (spanText && !tagName) { tagName = spanText; } } diff --git a/packages/react-grab/e2e/keyboard-navigation.spec.ts b/packages/react-grab/e2e/keyboard-navigation.spec.ts index b519c0977..b05e56a55 100644 --- a/packages/react-grab/e2e/keyboard-navigation.spec.ts +++ b/packages/react-grab/e2e/keyboard-navigation.spec.ts @@ -1,5 +1,4 @@ import { expect, test, type ReactGrabPageObject } from "./fixtures.js"; -import { isEditPanelVisible } from "./edit-panel-helpers.js"; import { ATTRIBUTE_NAME } from "./constants.js"; const clickSelectionDiscardButton = async ( @@ -345,37 +344,6 @@ test.describe("Keyboard Navigation", () => { expect(await reactGrab.isPendingDismissVisible()).toBe(false); }); - test("S should continue through the discard-selection prompt", async ({ reactGrab }) => { - await showKeyboardSelectionDiscardPrompt(reactGrab); - await reactGrab.page.locator("[data-react-grab-discard-copy]").focus(); - - await reactGrab.pressKey("s"); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - expect(await reactGrab.isPendingDismissVisible()).toBe(false); - }); - - test("Enter on the focused Copy button copies without opening the Style panel", async ({ - reactGrab, - }) => { - await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); - await reactGrab.activate(); - await reactGrab.hoverUntilSelected("[data-testid='todo-list'] li:first-child"); - - await reactGrab.page.keyboard.press("ArrowUp"); - await reactGrab.waitForSelectionBox(); - await reactGrab.page.mouse.move(10, 10); - await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); - - await reactGrab.page.locator("[data-react-grab-discard-copy]").focus(); - await reactGrab.page.keyboard.press("Enter"); - - // Enter on Copy must copy, not fall through to the Enter-to-expand - // shortcut that would open the Style panel. - await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).not.toBe(""); - expect(await isEditPanelVisible(reactGrab.page)).toBe(false); - }); - test("Enter on the focused Yes button discards without copying", async ({ reactGrab }) => { await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); await reactGrab.activate(); diff --git a/packages/react-grab/e2e/perf-bench.spec.ts b/packages/react-grab/e2e/perf-bench.spec.ts index b7619a89d..4ad002307 100644 --- a/packages/react-grab/e2e/perf-bench.spec.ts +++ b/packages/react-grab/e2e/perf-bench.spec.ts @@ -16,15 +16,14 @@ import { import { captureAnimationSchedulingControls } from "./perf-animation-controls.js"; import { PERF_ANIMATION_CONTROL_TEST_TIMEOUT_MS, + PERF_CI_INP_HARD_LIMIT_MS, PERF_COPY_COMPLETION_TIMEOUT_MS, PERF_DENSE_ANIMATION_TEST_TIMEOUT_MS, + PERF_LOCAL_INP_SOFT_LIMIT_MS, PERF_PLAYWRIGHT_SUITE_MODE, } from "./perf-constants.js"; import { idleFrame, recordScenario } from "./perf-recorder.js"; -// web-vitals "needs improvement" threshold is 200ms; we cap synthetic -// headless runs at 100ms so a real regression stands out from noise. -const INP_SOFT_LIMIT_MS = 100; // Massive-grid scenarios render ~35k React cells and intentionally run at // single-digit fps, so they blow past the default 60s budget on CI runners. const MASSIVE_GRID_TEST_TIMEOUT_MS = 300_000; @@ -139,7 +138,9 @@ test.describe("@perf benchmarks", () => { await idleFrame(page, 4); }, ); - expect.soft(aggregate.inp).toBeLessThan(INP_SOFT_LIMIT_MS); + const inpLimitMs = + process.env.PERF_SHARDABLE === "1" ? PERF_CI_INP_HARD_LIMIT_MS : PERF_LOCAL_INP_SOFT_LIMIT_MS; + if (!process.env.COVERAGE) expect.soft(aggregate.inp).toBeLessThan(inpLimitMs); }); // The "Grabbing… forever" case. Source resolution (bundle + source-map fetches diff --git a/packages/react-grab/e2e/perf-constants.ts b/packages/react-grab/e2e/perf-constants.ts index e27772199..1ec59813c 100644 --- a/packages/react-grab/e2e/perf-constants.ts +++ b/packages/react-grab/e2e/perf-constants.ts @@ -21,6 +21,8 @@ export const PERF_DEFAULT_TEST_TIMEOUT_MS = 60_000; export const PERF_DENSE_ANIMATION_TEST_TIMEOUT_MS = 120_000; export const PERF_DEEP_TEST_TIMEOUT_MS = 360_000; export const PERF_COPY_COMPLETION_TIMEOUT_MS = 5_000; +export const PERF_LOCAL_INP_SOFT_LIMIT_MS = 100; +export const PERF_CI_INP_HARD_LIMIT_MS = 200; export const PERF_CPU_PROFILE_SAMPLING_INTERVAL_US = 1_000; export const PERF_CPU_PROFILE_CAPTURE_DEADLINE_MS = 60_000; export const PERF_CPU_PROFILE_STOP_DEADLINE_MS = 10_000; diff --git a/packages/react-grab/e2e/prompt-mode.spec.ts b/packages/react-grab/e2e/prompt-mode.spec.ts index 2449d418f..33115e65e 100644 --- a/packages/react-grab/e2e/prompt-mode.spec.ts +++ b/packages/react-grab/e2e/prompt-mode.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures.js"; test.describe("Prompt Mode", () => { test.describe("Entering Prompt Mode", () => { - test("context menu edit should enter prompt mode when comment action is registered", async ({ + test("context menu Comment should enter prompt mode when the action is registered", async ({ reactGrab, }) => { await reactGrab.registerCommentAction(); @@ -247,7 +247,7 @@ test.describe("Prompt Mode", () => { await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); - test("context menu edit maintains overlay in prompt mode", async ({ reactGrab }) => { + test("comment prompt mode maintains the overlay", async ({ reactGrab }) => { await reactGrab.registerCommentAction(); await reactGrab.enterPromptMode("li:first-child"); diff --git a/packages/react-grab/e2e/react-grab.expect.ts b/packages/react-grab/e2e/react-grab.expect.ts index b70ebc72a..5d99b451c 100644 --- a/packages/react-grab/e2e/react-grab.expect.ts +++ b/packages/react-grab/e2e/react-grab.expect.ts @@ -39,7 +39,7 @@ for (const test of [ "Find the collapse button on the toolbar and click it to collapse, then click again to expand. Drag the toolbar toward the top of the viewport and verify it snaps to the edge. Right-click the toggle button to open a menu, then press Escape to close it.", "Activate and hover the Todo List. Press ArrowDown twice to move the highlight to successive elements, then ArrowUp to go back. Press ArrowLeft and ArrowRight to move between sibling elements.", "Activate via toolbar. Click and drag from above 'Buy groceries' down past 'Write code' to create a selection rectangle. Release and verify multiple elements are selected with a count label and Copied feedback.", - "Activate, hover the heading, right-click, and click Comment. Verify a text input appears. Type 'test comment' and press Enter. Find a comments button on the toolbar and click it to verify the comment appears in a dropdown.", + "Activate, hover the heading, right-click, and click Comment. Verify a text input appears. Type 'test comment' and press Enter. Verify the submitted comment is copied with the selected element.", "Activate and hover the card labeled 'Outer Card', verify a highlight and label appear. Move into 'Middle Card' and verify the label updates. Hover 'Nested Button' and click to copy it, verify the Copied feedback.", "Activate and scroll down to 'Scrollable Content'. Hover a scrollable item and verify the highlight is correctly positioned. Scroll further to 'Dynamic Elements', hover one and click to copy it.", "Activate and hover the 'Edge Case Elements' section. Verify zero-size and invisible elements are not highlighted. Go to 'Dynamic Elements', click Add Element, verify the new element is highlightable, then click Remove on one and verify the overlay does not get stuck.", diff --git a/packages/react-grab/e2e/shift-multi-select.spec.ts b/packages/react-grab/e2e/shift-multi-select.spec.ts index 00359045a..2607bbff6 100644 --- a/packages/react-grab/e2e/shift-multi-select.spec.ts +++ b/packages/react-grab/e2e/shift-multi-select.spec.ts @@ -329,79 +329,6 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); }); - test("should expand the first frozen label without toggling the element below", async ({ - reactGrab, - }) => { - await reactGrab.activate(); - - const firstItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(0); - const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li").nth(1); - - const firstBox = await firstItem.boundingBox(); - const secondBox = await secondItem.boundingBox(); - if (!firstBox || !secondBox) throw new Error("Could not get bounding boxes"); - - const firstLabelAnchorX = firstBox.x + firstBox.width * SHIFT_LABEL_CLICK_ANCHOR_RATIO; - const secondLabelAnchorX = - secondBox.x + secondBox.width * SHIFT_LABEL_SECOND_CLICK_ANCHOR_RATIO; - - await reactGrab.page.mouse.move(firstLabelAnchorX, firstBox.y + firstBox.height / 2); - await reactGrab.waitForSelectionBox(); - - await reactGrab.page.keyboard.up("Shift"); - await reactGrab.page.keyboard.down("Shift"); - await reactGrab.page.mouse.click(firstLabelAnchorX, firstBox.y + firstBox.height / 2); - await reactGrab.page.mouse.click(secondLabelAnchorX, secondBox.y + secondBox.height / 2); - - await expect - .poll(async () => - reactGrab.page.evaluate(() => { - const host = document.querySelector("[data-react-grab]"); - const shadowRoot = host?.shadowRoot; - const labels = shadowRoot?.querySelectorAll( - "[data-react-grab-selection-label]", - ); - return labels?.length ?? 0; - }), - ) - .toBe(SHIFT_LABEL_ANCHORED_COUNT); - - const firstLabelBounds = await reactGrab.page.evaluate(() => { - const host = document.querySelector("[data-react-grab]"); - const shadowRoot = host?.shadowRoot; - const label = shadowRoot?.querySelector("[data-react-grab-selection-label]"); - if (!label) return null; - const rect = label.getBoundingClientRect(); - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; - }); - if (!firstLabelBounds) throw new Error("Could not get first frozen label bounds"); - - await reactGrab.page.mouse.click( - firstLabelBounds.x + firstLabelBounds.width / 2, - firstLabelBounds.y + firstLabelBounds.height / 2, - ); - - // Label expansion now opens the style-tweak edit panel (this PR - // re-pointed handleToggleExpand from prompt mode to edit mode). - // The "expansion" behavior the test was guarding against ("don't - // toggle the element below") is still verified by checking that - // the active surface opens for the first label — the panel's - // existence proves the first-label-click landed and didn't bubble - // down to toggle the element underneath. - await expect - .poll(async () => - reactGrab.page.evaluate((attrName) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - return Boolean(shadowRoot?.querySelector("[data-react-grab-edit-panel]")); - }, "data-react-grab"), - ) - .toBe(true); - - await reactGrab.page.keyboard.up("Shift"); - await reactGrab.pressEscape(); - }); - test("should render a copied indicator per shift-selected element after release", async ({ reactGrab, }) => { diff --git a/packages/react-grab/e2e/slot.spec.ts b/packages/react-grab/e2e/slot.spec.ts deleted file mode 100644 index c6b9b88b8..000000000 --- a/packages/react-grab/e2e/slot.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { createSlotRenderSegments } from "../src/utils/create-slot-render-segments.js"; - -test.describe("Slot", () => { - test("keeps decimal literals in the rendered character stream", () => { - const renderSegments = createSlotRenderSegments("-13.5px"); - const rightAlignedText = [...renderSegments.rightAlignedSegments] - .reverse() - .map((segment) => segment.value) - .join(""); - - expect(`${renderSegments.prefixLiterals.join("")}${rightAlignedText}`).toBe("-13.5px"); - expect( - renderSegments.rightAlignedSegments.map((segment) => [ - segment.value, - segment.digitDistanceFromRight, - ]), - ).toEqual([ - ["x", 0], - ["p", 0], - ["5", 0], - [".", 1], - ["3", 1], - ["1", 2], - ]); - }); -}); diff --git a/packages/react-grab/e2e/three-fiber-selection.spec.ts b/packages/react-grab/e2e/three-fiber-selection.spec.ts index 36bdd7098..010fe6469 100644 --- a/packages/react-grab/e2e/three-fiber-selection.spec.ts +++ b/packages/react-grab/e2e/three-fiber-selection.spec.ts @@ -91,10 +91,7 @@ test.describe("React Three Fiber selection", () => { expect(clipboardContent).toContain('selector: mesh[name="left-cube"]'); }); - test("distinguishes adjacent meshes and disables DOM-only style editing", async ({ - reactGrab, - page, - }) => { + test("distinguishes adjacent meshes", async ({ reactGrab, page }) => { await reactGrab.activate(); const pointerPosition = await moveToThreeObject( page, @@ -106,7 +103,6 @@ test.describe("React Three Fiber selection", () => { const contextMenuInfo = await reactGrab.getContextMenuInfo(); expect(contextMenuInfo.tagBadgeText).toBe("ThreeGrabBox.mesh"); - expect(await reactGrab.isContextMenuItemEnabled("Style")).toBe(false); await reactGrab.clickContextMenuItem("Copy"); await expect.poll(() => reactGrab.getClipboardContent()).toContain(' { await expect.poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }).toBe(true); @@ -9,43 +8,46 @@ const waitForToolbar = async (reactGrab: ReactGrabPageObject) => { test.describe("Toolbar Action Buttons", () => { test.describe("Layout", () => { - test("renders copy, comment, and style buttons, all unpressed initially", async ({ - reactGrab, - }) => { + test("renders only the copy button, unpressed initially", async ({ reactGrab }) => { await waitForToolbar(reactGrab); + const actionIds = await reactGrab.page + .locator("[data-react-grab-toolbar-action]") + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-react-grab-toolbar-action")), + ); + expect(actionIds).toEqual(["copy"]); expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); }); - }); - test.describe("Active-state attribution", () => { - test("clicking Comment marks only the Comment button as pressed", async ({ reactGrab }) => { + test("represents the selected default action with the single button", async ({ reactGrab }) => { await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.setToolbarState({ defaultAction: "comment" }); + }); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); - }); + await expect + .poll(() => reactGrab.getToolbarActionPressed("comment"), { timeout: 2000 }) + .toBe(false); + await expect( + reactGrab.page.locator('[data-react-grab-toolbar-action="comment"]'), + ).toHaveAttribute("aria-label", "Comment element"); - test("clicking Style marks only the Style button as pressed", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("edit"); + await reactGrab.clickToolbarAction("comment"); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); + expect(await reactGrab.getToolbarActionPressed("comment")).toBe(true); + await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); + await reactGrab.clickElement(BUTTON_SELECTOR); + await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(true); }); + }); - test("clicking Copy marks only the Copy button as pressed", async ({ reactGrab }) => { + test.describe("Active-state attribution", () => { + test("clicking Copy marks it as pressed", async ({ reactGrab }) => { await waitForToolbar(reactGrab); await reactGrab.clickToolbarAction("copy"); expect(await reactGrab.getToolbarActionPressed("copy")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); }); test("activating via API (no toolbar button) marks Copy as pressed", async ({ reactGrab }) => { @@ -53,169 +55,55 @@ test.describe("Toolbar Action Buttons", () => { await reactGrab.activate(); expect(await reactGrab.getToolbarActionPressed("copy")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); }); - test("clicking a different action while active switches without deactivating", async ({ - reactGrab, - }) => { + test("switches from API activation to the selected Comment default", async ({ reactGrab }) => { await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("copy"); - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(true); - - await reactGrab.clickToolbarAction("comment"); - - expect(await reactGrab.isOverlayVisible()).toBe(true); - await expect - .poll(() => reactGrab.getToolbarActionPressed("comment"), { timeout: 2000 }) - .toBe(true); - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); - }); - - test("clicking the already-active action toggles selection off", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); - expect(await reactGrab.isOverlayVisible()).toBe(true); - - await reactGrab.clickToolbarAction("comment"); + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.setToolbarState({ defaultAction: "comment" }); + }); + await reactGrab.activate(); - await expect.poll(() => reactGrab.isOverlayVisible(), { timeout: 2000 }).toBe(false); expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); - }); - - test("Escape resets every action button to unpressed", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); await reactGrab.clickToolbarAction("comment"); expect(await reactGrab.getToolbarActionPressed("comment")).toBe(true); - await reactGrab.deactivate(); - - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); - }); - - test("context menu Comment marks only the Comment button as pressed", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); - await reactGrab.activate(); await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.rightClickElement(BUTTON_SELECTOR); - await reactGrab.clickContextMenuItem("Comment"); - - await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(true); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(false); - }); - }); - - test.describe("Mode activation", () => { - test("Comment button selects an element into prompt mode", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); - await reactGrab.hoverUntilSelected(LIST_ITEM_SELECTOR); - await reactGrab.clickElement(LIST_ITEM_SELECTOR); - + await reactGrab.clickElement(BUTTON_SELECTOR); await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(true); }); - test("Style button selects an element into the style panel", async ({ reactGrab }) => { + test("updates the armed action when the default changes", async ({ reactGrab }) => { await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("edit"); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.clickElement(BUTTON_SELECTOR); - - await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - }); + await reactGrab.clickToolbarAction("copy"); + await reactGrab.rightClickToolbarToggle(); + await reactGrab.clickToolbarMenuItem("comment"); - test("Style button opens the style panel from a comment discard prompt", async ({ - reactGrab, - }) => { - await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); + expect(await reactGrab.getToolbarActionPressed("comment")).toBe(true); await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); await reactGrab.clickElement(BUTTON_SELECTOR); - await reactGrab.typeInInput("Discard this comment"); - await reactGrab.page.mouse.click(10, 10); - await expect.poll(() => reactGrab.isPendingDismissVisible(), { timeout: 2000 }).toBe(true); - - await reactGrab.clickToolbarAction("edit"); - - await expect.poll(() => isEditPanelVisible(reactGrab.page), { timeout: 2000 }).toBe(true); - await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(false); - expect(await reactGrab.getInputValue()).toBe(""); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); + await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(true); }); - test("Style shortcut opens the style panel from a comment discard prompt", async ({ - reactGrab, - }) => { + test("Escape resets the Copy button to unpressed", async ({ reactGrab }) => { await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.clickElement(BUTTON_SELECTOR); - await reactGrab.typeInInput("Discard this comment"); - await reactGrab.page.mouse.click(10, 10); - await expect.poll(() => reactGrab.isPendingDismissVisible(), { timeout: 2000 }).toBe(true); + await reactGrab.clickToolbarAction("copy"); + expect(await reactGrab.getToolbarActionPressed("copy")).toBe(true); - await reactGrab.page.keyboard.press("s"); + await reactGrab.deactivate(); - await expect.poll(() => isEditPanelVisible(reactGrab.page), { timeout: 2000 }).toBe(true); - await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(false); - expect(await reactGrab.getInputValue()).toBe(""); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); + expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); }); - test("Style button opens from a context-menu comment after pending toolbar selection", async ({ - reactGrab, - }) => { + test("context menu Comment leaves the Copy button unpressed", async ({ reactGrab }) => { await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("edit"); + await reactGrab.activate(); await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); await reactGrab.rightClickElement(BUTTON_SELECTOR); await reactGrab.clickContextMenuItem("Comment"); - await reactGrab.typeInInput("Discard this context comment"); - await reactGrab.page.mouse.click(10, 10); - await expect.poll(() => reactGrab.isPendingDismissVisible(), { timeout: 2000 }).toBe(true); - - await reactGrab.clickToolbarAction("edit"); - - await expect.poll(() => isEditPanelVisible(reactGrab.page), { timeout: 2000 }).toBe(true); - await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(false); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); - }); - - test("Style button switches from API comment selection mode", async ({ reactGrab }) => { - await waitForToolbar(reactGrab); - await reactGrab.page.evaluate(() => { - window.__REACT_GRAB__?.comment(); - }); - await expect - .poll(() => reactGrab.getToolbarActionPressed("comment"), { timeout: 2000 }) - .toBe(true); - await reactGrab.clickToolbarAction("edit"); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.clickElement(BUTTON_SELECTOR); - - await expect.poll(() => isEditPanelVisible(reactGrab.page), { timeout: 2000 }).toBe(true); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); - }); - - test("keyboard shortcut switches a pending toolbar selection to style mode", async ({ - reactGrab, - }) => { - await waitForToolbar(reactGrab); - await reactGrab.clickToolbarAction("comment"); - await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - - await reactGrab.page.keyboard.press("s"); - - await expect.poll(() => isEditPanelVisible(reactGrab.page), { timeout: 2000 }).toBe(true); - expect(await reactGrab.getToolbarActionPressed("edit")).toBe(true); - expect(await reactGrab.getToolbarActionPressed("comment")).toBe(false); + await expect.poll(() => reactGrab.isPromptModeActive(), { timeout: 2000 }).toBe(true); + expect(await reactGrab.getToolbarActionPressed("copy")).toBe(false); }); }); }); diff --git a/packages/react-grab/e2e/toolbar-menu.spec.ts b/packages/react-grab/e2e/toolbar-menu.spec.ts index 937b5f00f..31d186c3a 100644 --- a/packages/react-grab/e2e/toolbar-menu.spec.ts +++ b/packages/react-grab/e2e/toolbar-menu.spec.ts @@ -30,8 +30,12 @@ test.describe("Toolbar Menu", () => { await expect.poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }).toBe(true); - const labels = await reactGrab.getToolbarMenuItemLabels(); - expect(labels.length).toBeGreaterThan(0); + const actionIds = await reactGrab.page + .locator("[data-react-grab-toolbar-menu] [data-react-grab-menu-item]") + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-react-grab-menu-item")), + ); + expect(actionIds).toEqual(["copy", "comment"]); }); test("clicking a menu item should close the menu", async ({ reactGrab }) => { @@ -47,6 +51,126 @@ test.describe("Toolbar Menu", () => { await expect.poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }).toBe(false); }); + + test("preserves a registered persisted default action", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "comment", + }), + ); + }); + await reactGrab.page.reload(); + + await expect + .poll(() => + reactGrab.page.evaluate(() => window.__REACT_GRAB__?.getToolbarState()?.defaultAction), + ) + .toBe("comment"); + }); + + test("preserves the default action when toolbar state changes", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.setToolbarState({ defaultAction: "comment" }); + }); + + await reactGrab.clickToolbarCollapse(); + + const defaultActions = () => + reactGrab.page.evaluate(() => { + const persistedState = JSON.parse( + localStorage.getItem("react-grab-toolbar-state") ?? "null", + ); + return { + api: window.__REACT_GRAB__?.getToolbarState()?.defaultAction, + persisted: persistedState?.defaultAction, + }; + }); + await expect.poll(defaultActions).toEqual({ api: "comment", persisted: "comment" }); + }); + + test("normalizes the removed Style action persisted as the default", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "edit", + }), + ); + }); + await reactGrab.page.reload(); + + const defaultAction = () => + reactGrab.page.evaluate(() => { + const apiDefaultAction = window.__REACT_GRAB__?.getToolbarState()?.defaultAction; + const persistedState = JSON.parse( + localStorage.getItem("react-grab-toolbar-state") ?? "null", + ); + return { apiDefaultAction, persistedDefaultAction: persistedState?.defaultAction }; + }); + await expect.poll(defaultAction).toEqual({ + apiDefaultAction: "copy", + persistedDefaultAction: "copy", + }); + }); + + test("preserves a custom default until its plugin registers", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "custom-action", + }), + ); + }); + await reactGrab.page.reload(); + + await expect + .poll(() => + reactGrab.page.evaluate(() => window.__REACT_GRAB__?.getToolbarState()?.defaultAction), + ) + .toBe("custom-action"); + + await reactGrab.clickToolbarAction("custom-action"); + await reactGrab.hoverUntilSelected("li:first-child"); + await reactGrab.clickElement("li:first-child"); + await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(true); + await expect + .poll(() => + reactGrab.page.evaluate(() => window.__REACT_GRAB__?.getToolbarState()?.defaultAction), + ) + .toBe("custom-action"); + + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.registerPlugin({ + name: "custom-action", + actions: [ + { + id: "custom-action", + label: "Custom", + showInToolbarMenu: true, + onAction: () => {}, + }, + ], + }); + }); + await expect( + reactGrab.page.locator('[data-react-grab-toolbar-action="custom-action"]'), + ).toHaveAttribute("aria-label", "Custom element"); + }); }); test.describe("Interaction with Other Dropdowns", () => { diff --git a/packages/react-grab/e2e/web-extension.spec.ts b/packages/react-grab/e2e/web-extension.spec.ts new file mode 100644 index 000000000..6b795a5e7 --- /dev/null +++ b/packages/react-grab/e2e/web-extension.spec.ts @@ -0,0 +1,191 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ReactGrabAPI, ToolbarState } from "../src/types.js"; +import { test, expect } from "./fixtures.js"; + +interface ExtensionTestWindow extends Window { + __REACT_GRAB__?: ReactGrabAPI; + savedToolbarStates?: ToolbarState[]; +} + +const E2E_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_CONTENT_SCRIPT_URL = `/@fs${path.resolve( + E2E_DIRECTORY, + "../../../apps/web-extension/src/content/react-grab.ts", +)}`; + +test("extension hydration does not overwrite its valid default with stale page state", async ({ + reactGrab, +}) => { + await reactGrab.page.evaluate(() => { + const targetWindow = window as ExtensionTestWindow; + targetWindow.__REACT_GRAB__?.dispose(); + targetWindow.savedToolbarStates = []; + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "edit", + }), + ); + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") { + window.postMessage( + { + type: "__REACT_GRAB_STATE_RESPONSE__", + enabled: true, + toolbarState: { + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "comment", + }, + }, + "*", + ); + } + if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") { + targetWindow.savedToolbarStates?.push(event.data.state); + } + }); + }); + + await reactGrab.page.addScriptTag({ type: "module", url: EXTENSION_CONTENT_SCRIPT_URL }); + + await expect + .poll(() => + reactGrab.page.evaluate( + () => (window as ExtensionTestWindow).__REACT_GRAB__?.getToolbarState()?.defaultAction, + ), + ) + .toBe("comment"); + + const savedDefaultActions = await reactGrab.page.evaluate(() => + (window as ExtensionTestWindow).savedToolbarStates?.map((state) => state.defaultAction), + ); + expect(savedDefaultActions).not.toContain("copy"); +}); + +test("extension hydration never saves a removed page default action", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const targetWindow = window as ExtensionTestWindow; + targetWindow.__REACT_GRAB__?.dispose(); + targetWindow.savedToolbarStates = []; + localStorage.setItem( + "react-grab-toolbar-state", + JSON.stringify({ + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "edit", + }), + ); + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") { + window.postMessage( + { + type: "__REACT_GRAB_STATE_RESPONSE__", + enabled: true, + toolbarState: null, + }, + "*", + ); + } + if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") { + targetWindow.savedToolbarStates?.push(event.data.state); + } + }); + }); + + await reactGrab.page.addScriptTag({ type: "module", url: EXTENSION_CONTENT_SCRIPT_URL }); + + await expect + .poll(() => + reactGrab.page.evaluate( + () => (window as ExtensionTestWindow).__REACT_GRAB__?.getToolbarState()?.defaultAction, + ), + ) + .toBe("copy"); + + await expect + .poll(() => + reactGrab.page.evaluate(() => + (window as ExtensionTestWindow).savedToolbarStates?.map((state) => state.defaultAction), + ), + ) + .toEqual(["copy"]); +}); + +test("extension hydration preserves a custom default until its plugin registers", async ({ + reactGrab, +}) => { + await reactGrab.page.evaluate(() => { + const targetWindow = window as ExtensionTestWindow; + targetWindow.__REACT_GRAB__?.dispose(); + targetWindow.savedToolbarStates = []; + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type === "__REACT_GRAB_QUERY_STATE__") { + window.postMessage( + { + type: "__REACT_GRAB_STATE_RESPONSE__", + enabled: true, + toolbarState: { + edge: "bottom", + ratio: 0.5, + collapsed: false, + enabled: true, + defaultAction: "custom-action", + }, + }, + "*", + ); + } + if (event.data?.type === "__REACT_GRAB_TOOLBAR_STATE_SAVE__") { + targetWindow.savedToolbarStates?.push(event.data.state); + } + }); + }); + + await reactGrab.page.addScriptTag({ type: "module", url: EXTENSION_CONTENT_SCRIPT_URL }); + + await expect + .poll(() => + reactGrab.page.evaluate( + () => (window as ExtensionTestWindow).__REACT_GRAB__?.getToolbarState()?.defaultAction, + ), + ) + .toBe("custom-action"); + expect( + await reactGrab.page.evaluate(() => + (window as ExtensionTestWindow).savedToolbarStates?.map((state) => state.defaultAction), + ), + ).not.toContain("copy"); + + await reactGrab.page.evaluate(() => { + window.__REACT_GRAB__?.registerPlugin({ + name: "custom-action", + actions: [ + { + id: "custom-action", + label: "Custom", + showInToolbarMenu: true, + onAction: () => {}, + }, + ], + }); + }); + await expect( + reactGrab.page.locator('[data-react-grab-toolbar-action="custom-action"]'), + ).toHaveAttribute("aria-label", "Custom element"); +}); diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index c3aaeefe2..058893c19 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -103,7 +103,7 @@ "test:perf:baseline": "PERF_LABEL=baseline playwright test --grep @perf --reporter=list", "test:expect": "bun e2e/react-grab.expect.ts", "typecheck": "tsc --noEmit", - "prepublishOnly": "pnpm build", + "prepublishOnly": "rm -rf dist && pnpm build", "test:e2e:ui": "playwright test --ui", "perf:deopt": "node scripts/deopt-trace.mjs" }, diff --git a/packages/react-grab/src/components/edit-panel/active-property-control.tsx b/packages/react-grab/src/components/edit-panel/active-property-control.tsx deleted file mode 100644 index 7638f90d0..000000000 --- a/packages/react-grab/src/components/edit-panel/active-property-control.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Match, Switch, type Component } from "solid-js"; -import type { EditableProperty } from "../../types.js"; -import { ColorPicker } from "./color-picker.js"; -import { CycleControl } from "./cycle-control.js"; -import { narrowColor, narrowEnum, narrowNumeric } from "./narrow-property.js"; -import { ValueStepper } from "./value-stepper.js"; - -interface ActivePropertyControlProps { - property: EditableProperty; - activeKey: "left" | "right" | null; - onStep: (direction: 1 | -1) => void; - onCommit: (value: number | string, source: "keyboard" | "pointer") => void; - onEditComplete: () => void; - onInvalidCommit: () => void; - onInteract: () => void; - onColorPickerRegister?: (trigger: () => void) => () => void; - showLabel: boolean; - tailwindLabel?: string | null; - emphasized?: boolean; -} - -export const ActivePropertyControl: Component = (props) => ( - - - {(numeric) => ( - - )} - - - {(color) => ( - - )} - - - {(enumProp) => ( - - )} - - -); diff --git a/packages/react-grab/src/components/edit-panel/color-picker.tsx b/packages/react-grab/src/components/edit-panel/color-picker.tsx deleted file mode 100644 index 0554687fb..000000000 --- a/packages/react-grab/src/components/edit-panel/color-picker.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { createSignal, onCleanup, onMount, Show, type Component } from "solid-js"; -import { formatColorLabel } from "../../utils/format-color-label.js"; -import { isKeyboardEventComposing } from "../../utils/is-keyboard-event-composing.js"; -import { parseAnyColor } from "../../utils/parse-any-color.js"; -import { Input } from "../ui/input.js"; -import { EDIT_LABEL_CLASS, EDIT_VALUE_CLASS } from "./constants.js"; - -// Native only accepts `#rrggbb` (no alpha, no -// shorthand). Strip the alpha byte if present so the picker opens at -// the right colour without rejecting the value. -const stripHexAlpha = (hex: string): string => (hex.length === 9 ? hex.slice(0, 7) : hex); - -interface ColorPickerProps { - label?: string; - value: string; - onCommit: (value: string, source: "keyboard" | "pointer") => void; - onEditComplete?: () => void; - onInvalidCommit?: () => void; - onRegisterTrigger?: (trigger: () => void) => () => void; - onInteract?: () => void; - emphasized?: boolean; -} - -export const ColorPicker: Component = (props) => { - const [draftText, setDraftText] = createSignal(null); - const isEditing = () => draftText() !== null; - const displayValue = () => formatColorLabel(props.value); - let nativePickerRef: HTMLInputElement | undefined; - - // isMounted gates the native picker's `onInput` against firing - // after dismiss: the OS color dialog can outlive `` detach - // (Firefox/Safari), and a delayed `onInput` would otherwise commit - // an untracked inline style on the original element with no - // preview baseline to revert from. - let isMounted = true; - onCleanup(() => { - isMounted = false; - }); - - onMount(() => { - const openPicker = () => nativePickerRef?.click(); - const unregisterTrigger = props.onRegisterTrigger?.(openPicker); - onCleanup(() => unregisterTrigger?.()); - }); - - const commitHex = () => { - const text = draftText(); - if (text === null) return; - setDraftText(null); - const normalizedHexColor = parseAnyColor(text); - if (!normalizedHexColor) { - props.onInvalidCommit?.(); - } else if (normalizedHexColor.toLowerCase() !== props.value.toLowerCase()) { - props.onCommit(normalizedHexColor, "keyboard"); - } - props.onEditComplete?.(); - }; - - const cancelHex = () => { - if (!isEditing()) return; - setDraftText(null); - props.onEditComplete?.(); - }; - - const handleHexKeyDown = (event: KeyboardEvent) => { - if (isKeyboardEventComposing(event)) return; - event.stopImmediatePropagation(); - if (event.key === "Enter") { - event.preventDefault(); - commitHex(); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - cancelHex(); - } - }; - - const handleSwatchClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - props.onInteract?.(); - nativePickerRef?.click(); - }; - - return ( -
- - {(text) => ( - - )} - -
- event.stopPropagation()} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setDraftText(displayValue()); - }} - textContent={displayValue()} - /> - } - > - setDraftText(event.currentTarget.value)} - onKeyDown={handleHexKeyDown} - onBlur={commitHex} - onPointerDown={(event) => event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - /> - -
-
- ); -}; diff --git a/packages/react-grab/src/components/edit-panel/constants.ts b/packages/react-grab/src/components/edit-panel/constants.ts deleted file mode 100644 index a186b4624..000000000 --- a/packages/react-grab/src/components/edit-panel/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const EDIT_LABEL_CLASS = "text-[13px] leading-4 font-medium"; -export const EDIT_VALUE_CLASS = "text-[12px] leading-4 font-medium tabular-nums"; - -export const HIDDEN_FOCUS_PRESERVING_STYLE = { - position: "absolute" as const, - opacity: 0, - "pointer-events": "none" as const, - width: "0", - height: "0", - margin: "0", - padding: "0", - overflow: "hidden" as const, -}; diff --git a/packages/react-grab/src/components/edit-panel/copy-button.tsx b/packages/react-grab/src/components/edit-panel/copy-button.tsx deleted file mode 100644 index f7f47fdeb..000000000 --- a/packages/react-grab/src/components/edit-panel/copy-button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { Component } from "solid-js"; -import { Button } from "../ui/button.js"; - -interface EditPanelCopyButtonProps { - onCopy: () => void; -} - -export const EditPanelCopyButton: Component = (props) => ( - -); diff --git a/packages/react-grab/src/components/edit-panel/cycle-control.tsx b/packages/react-grab/src/components/edit-panel/cycle-control.tsx deleted file mode 100644 index fd9695b72..000000000 --- a/packages/react-grab/src/components/edit-panel/cycle-control.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Show, type Component } from "solid-js"; -import type { EnumEditableOption } from "../../types.js"; -import { EDIT_LABEL_CLASS } from "./constants.js"; -import { StepArrow } from "./step-arrow.js"; - -interface CycleControlProps { - label?: string; - value: string; - options: ReadonlyArray; - activeKey: "left" | "right" | null; - onStep: (direction: 1 | -1) => void; -} - -export const CycleControl: Component = (props) => { - const currentLabel = () => { - const match = props.options.find((option) => option.value === props.value); - return match?.label ?? props.value; - }; - - return ( -
- - - -
- props.onStep(-1)} - /> - event.stopPropagation()} - onMouseDown={(event) => event.preventDefault()} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - props.onStep(1); - }} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - props.onStep(-1); - }} - textContent={currentLabel()} - /> - props.onStep(1)} - /> -
-
- ); -}; diff --git a/packages/react-grab/src/components/edit-panel/discard-confirmation.ts b/packages/react-grab/src/components/edit-panel/discard-confirmation.ts deleted file mode 100644 index 6a82e142b..000000000 --- a/packages/react-grab/src/components/edit-panel/discard-confirmation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createSignal, onCleanup, type Accessor } from "solid-js"; -import { EDIT_DISCARD_PROMPT_IDLE_MS } from "../../constants.js"; - -interface DiscardConfirmation { - isPending: Accessor; - show: () => void; - hide: () => void; - cleanup: () => void; -} - -export const createDiscardConfirmation = (): DiscardConfirmation => { - const [isPending, setIsPending] = createSignal(false); - let timerId: ReturnType | undefined; - - const hide = () => { - clearTimeout(timerId); - setIsPending(false); - }; - - const show = () => { - setIsPending(true); - clearTimeout(timerId); - timerId = setTimeout(() => { - setIsPending(false); - }, EDIT_DISCARD_PROMPT_IDLE_MS); - }; - - const cleanup = () => { - clearTimeout(timerId); - }; - - onCleanup(cleanup); - - return { isPending, show, hide, cleanup }; -}; diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx deleted file mode 100644 index 945cea381..000000000 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ /dev/null @@ -1,801 +0,0 @@ -import { - createEffect, - createMemo, - createSignal, - onCleanup, - onMount, - Show, - type Component, -} from "solid-js"; -import { - DROPDOWN_EDGE_TRANSFORM_ORIGIN, - EDIT_PANEL_ACTIVE_KEY_FLASH_MS, - EDIT_PANEL_ADJUSTING_IDLE_MS, - EDIT_INLINE_NUMERIC_REPLACE_IDLE_MS, - EDIT_PANEL_MAX_WIDTH_PX, - EDIT_PANEL_MIN_WIDTH_PX, - EDIT_PROPERTY_LIST_MAX_HEIGHT_PX, - EDIT_SLIDER_SPRING_EASING, - EDIT_VALUE_BUMP_MS, - EDIT_VALUE_BUMP_PX, - REACT_GRAB_INPUT_ATTRIBUTE, - Z_INDEX_OVERLAY, -} from "../../constants.js"; -import type { - DropdownAnchor, - EditableProperty, - EditPanelState, - OverlayDismissSource, - PendingEdits, -} from "../../types.js"; -import { clampToRange } from "../../utils/clamp-to-range.js"; -import { focusInOverlay } from "../../utils/focus-in-overlay.js"; -import { cn } from "../../utils/cn.js"; -import { createAnchoredDropdown } from "../../utils/create-anchored-dropdown.js"; -import { findTailwindClass } from "../../utils/find-tailwind-class.js"; -import { formatEditableValue, roundEditableNumericValue } from "../../utils/format-css-value.js"; -import { getShadowActiveElement } from "../../utils/get-shadow-active-element.js"; -import { getTagDisplay } from "../../utils/get-tag-display.js"; -import { createPointerMovePromptHandoff } from "../../utils/create-pointer-move-prompt-handoff.js"; -import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js"; -import { isKeyboardEventComposing } from "../../utils/is-keyboard-event-composing.js"; -import { isTypeToEditKey } from "../../utils/is-type-to-edit-key.js"; -import { ignoreRealInput } from "../../utils/runtime-mode.js"; -import { registerOverlayDismiss } from "../../utils/register-overlay-dismiss.js"; -import { suppressMenuEvent } from "../../utils/suppress-menu-event.js"; -import { TagBadge } from "../selection-label/tag-badge.js"; -import { Button } from "../ui/button.js"; -import { Surface } from "../ui/surface.js"; -import { ActivePropertyControl } from "./active-property-control.js"; -import { HIDDEN_FOCUS_PRESERVING_STYLE } from "./constants.js"; -import { EditPanelCopyButton } from "./copy-button.js"; -import { createDiscardConfirmation } from "./discard-confirmation.js"; -import { PropertyList } from "./property-list.js"; -import { arePropertyValuesEqual } from "./property-values-equal.js"; -import { createModifierTracker } from "../../utils/modifier-tracker.js"; -import { createStepController } from "./step-controller.js"; -import { stepProperty } from "./step-property.js"; -import { createStyleStore } from "./style-store.js"; -import { createTailwindAutoApply } from "./tailwind-autoapply.js"; - -interface EditPanelProps { - state: EditPanelState | null; - position: DropdownAnchor | null; - onDismiss: () => void; - onSubmit: (pendingEdits: PendingEdits) => void; - onPendingEditsChange?: (pendingEdits: PendingEdits) => void; - onInteractingChange?: (interacting: boolean) => void; -} - -export const EditPanel: Component = (props) => ( - - {(state) => ( - - {(_element) => ( - props.position} - onDismiss={props.onDismiss} - onSubmit={props.onSubmit} - onPendingEditsChange={props.onPendingEditsChange} - onInteractingChange={props.onInteractingChange} - /> - )} - - )} - -); - -interface EditPanelBodyProps { - state: EditPanelState; - position: () => DropdownAnchor | null; - onDismiss: () => void; - onSubmit: (pendingEdits: PendingEdits) => void; - onPendingEditsChange?: (pendingEdits: PendingEdits) => void; - onInteractingChange?: (interacting: boolean) => void; -} - -interface CommitOptions { - flashDirection?: 1 | -1; - shouldFocus?: boolean; - shouldCompact?: boolean; - isFromKeyRepeat?: boolean; - source?: "keyboard" | "pointer"; -} - -const EditPanelBody: Component = (props) => { - const initialProperties = props.state.properties; - - let searchInputRef: HTMLTextAreaElement | undefined; - const preview = props.state.preview; - - const [searchQuery, setSearchQuery] = createSignal(props.state.initialSearchQuery ?? ""); - const [inlineNumericSearchQuery, setInlineNumericSearchQuery] = createSignal(null); - const [activeKey, setActiveKey] = createSignal<"left" | "right" | null>(null); - const styleStore = createStyleStore({ - initialProperties, - searchQuery: () => inlineNumericSearchQuery() ?? searchQuery(), - }); - // Colors are pinned on top but aren't slider-steppable, so the arrow-key - // cursor lands on the first numeric row instead. - const firstNumericActiveIndex = (): number => { - const numericIndex = styleStore - .filteredProperties() - .findIndex((property) => property.kind === "numeric"); - return numericIndex > 0 ? numericIndex : 0; - }; - const [activeIndex, setActiveIndex] = createSignal(firstNumericActiveIndex()); - const hasPendingStyles = createMemo(() => styleStore.hasPendingStyles()); - const hasSubmittableEdits = createMemo( - () => hasPendingStyles() || Boolean(props.state.hasSessionEdits), - ); - const [isCompact, setIsCompact] = createSignal(false); - - let activeKeyTimerId: ReturnType | undefined; - let interactingIdleTimerId: ReturnType | undefined; - let inlineNumericReplaceTimerId: ReturnType | undefined; - let shouldReplaceInlineNumericInput = false; - const [isTransientInteraction, setIsTransientInteraction] = createSignal(false); - const isInteracting = createMemo(() => isTransientInteraction() || hasPendingStyles()); - const [isHeaderHovered, setIsHeaderHovered] = createSignal(false); - const pointerMovePromptHandoff = createPointerMovePromptHandoff(); - - const tagDisplay = createMemo(() => - getTagDisplay({ - tagName: props.state.tagName, - componentName: props.state.componentName, - }), - ); - - const activeProperty = createMemo(() => { - const properties = styleStore.filteredProperties(); - if (properties.length === 0) return null; - const index = Math.min(Math.max(0, activeIndex()), properties.length - 1); - return properties[index]; - }); - - let containerRef: HTMLDivElement | undefined; - - const dropdown = createAnchoredDropdown(() => containerRef, props.position); - - const flashActiveKey = (direction: "left" | "right") => { - setActiveKey(direction); - clearTimeout(activeKeyTimerId); - activeKeyTimerId = setTimeout(() => { - setActiveKey((currentKey) => (currentKey === direction ? null : currentKey)); - }, EDIT_PANEL_ACTIVE_KEY_FLASH_MS); - }; - - const effectiveInteracting = createMemo(() => isInteracting() && !isHeaderHovered()); - - createEffect(() => { - const nextInteracting = effectiveInteracting(); - props.onInteractingChange?.(nextInteracting); - }); - - const markAsInteracting = () => { - setIsTransientInteraction(true); - clearTimeout(interactingIdleTimerId); - interactingIdleTimerId = setTimeout(() => { - setIsTransientInteraction(false); - }, EDIT_PANEL_ADJUSTING_IDLE_MS); - }; - - const ensureSearchFocused = () => { - queueMicrotask(() => { - if (!searchInputRef) return; - if (getShadowActiveElement(searchInputRef) !== searchInputRef) { - focusInOverlay(searchInputRef, { preventScroll: true }); - } - }); - }; - - const keepInlineNumericSearchQuery = () => { - if (inlineNumericSearchQuery() === null) setInlineNumericSearchQuery(searchQuery()); - }; - - const cancelInlineNumericReplacement = () => { - shouldReplaceInlineNumericInput = false; - clearTimeout(inlineNumericReplaceTimerId); - }; - - const queueInlineNumericReplacement = () => { - shouldReplaceInlineNumericInput = false; - clearTimeout(inlineNumericReplaceTimerId); - inlineNumericReplaceTimerId = setTimeout(() => { - shouldReplaceInlineNumericInput = true; - }, EDIT_INLINE_NUMERIC_REPLACE_IDLE_MS); - }; - - const queueInlineNumericReplacementForQuery = (query: string) => { - const trimmedQuery = query.trim(); - const numericDigits = trimmedQuery.replace(/^-/, "").replace(".", ""); - if (/[a-z%]+$/i.test(trimmedQuery) || numericDigits.length > 1) { - queueInlineNumericReplacement(); - } else cancelInlineNumericReplacement(); - }; - - const replaceInlineNumericPrefix = (nextSearchQuery: string): string => { - if (!shouldReplaceInlineNumericInput) return nextSearchQuery; - const currentSearchQuery = searchQuery(); - if (!currentSearchQuery || !nextSearchQuery.startsWith(currentSearchQuery)) { - cancelInlineNumericReplacement(); - return nextSearchQuery; - } - const appendedQuery = nextSearchQuery.slice(currentSearchQuery.length); - if (!/^[-.\d]/.test(appendedQuery)) return nextSearchQuery; - cancelInlineNumericReplacement(); - return appendedQuery; - }; - - const tryReplaceInlineNumericFromKey = (event: KeyboardEvent): boolean => { - if (!shouldReplaceInlineNumericInput) return false; - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (!/^[-.\d]$/.test(event.key)) return false; - event.preventDefault(); - event.stopImmediatePropagation(); - cancelInlineNumericReplacement(); - const nextSearchQuery = event.key; - if (searchInputRef) searchInputRef.value = nextSearchQuery; - if (autoApply.tryApplyNumericValue(nextSearchQuery)) { - keepInlineNumericSearchQuery(); - setSearchQuery(nextSearchQuery); - queueInlineNumericReplacementForQuery(nextSearchQuery); - ensureSearchFocused(); - return true; - } - setSearchQuery(nextSearchQuery); - ensureSearchFocused(); - return true; - }; - - const expandPanel = () => { - cancelInlineNumericReplacement(); - setInlineNumericSearchQuery(null); - setIsCompact(false); - }; - - const commit = ( - property: EditableProperty, - nextValue: number | string, - options: CommitOptions = {}, - ) => { - styleStore.applyStyle(property, nextValue); - preview.apply(property.cssProperties, formatEditableValue(property, nextValue)); - props.onPendingEditsChange?.(styleStore.buildPendingEdits()); - markAsInteracting(); - if (!options.isFromKeyRepeat) discardConfirmation.hide(); - if (options.flashDirection) flashActiveKey(options.flashDirection === 1 ? "right" : "left"); - if (options.shouldFocus) ensureSearchFocused(); - if (options.shouldCompact) setIsCompact(true); - if (options.source === "keyboard") pointerMovePromptHandoff.arm(); - }; - - const isShiftHeld = createModifierTracker((event) => event.shiftKey); - const isAltHeld = createModifierTracker((event) => event.altKey); - - const stepActiveProperty = ( - direction: 1 | -1, - shiftHeld: boolean, - altHeld: boolean, - fromRepeat: boolean, - source: "keyboard" | "pointer", - ): EditableProperty | null => { - const property = activeProperty(); - if (!property) return null; - const nextValue = stepProperty( - property, - direction, - shiftHeld, - altHeld, - props.state.designTokens, - ); - if (nextValue === null) { - flashActiveKey(direction === 1 ? "right" : "left"); - return null; - } - commit(property, nextValue, { - flashDirection: direction, - shouldFocus: true, - isFromKeyRepeat: fromRepeat, - source, - }); - return property; - }; - - const stepFromKeyboard = ( - direction: 1 | -1, - shiftHeld: boolean, - altHeld: boolean, - fromRepeat: boolean, - ) => { - if (!stepActiveProperty(direction, shiftHeld, altHeld, fromRepeat, "keyboard")) return; - setIsCompact(true); - }; - - const stepFromPointer = (direction: 1 | -1) => { - stepActiveProperty(direction, false, false, false, "pointer"); - }; - - const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld, isAltHeld }); - - const commitActive = (rawValue: number | string, source: "keyboard" | "pointer") => { - const property = activeProperty(); - if (!property) return; - if (property.kind === "numeric" && typeof rawValue === "number") { - const clamped = roundEditableNumericValue(clampToRange(rawValue, property.min, property.max)); - if (clamped !== property.value) commit(property, clamped, { source }); - return; - } - if (typeof rawValue !== "string") return; - if (!arePropertyValuesEqual(property, rawValue, property.value)) { - commit(property, rawValue, { source }); - } - }; - - const activeTailwindLabel = createMemo(() => { - if (!isShiftHeld()) return null; - const property = activeProperty(); - if (!property || property.kind !== "numeric") return null; - return findTailwindClass(property.key, property.value); - }); - - const autoApply = createTailwindAutoApply({ - initialProperties, - searchQuery, - isCompact, - activeProperty, - commit: (property, value, options) => { - commit(property, value, { ...options, source: "keyboard" }); - }, - setIsCompact, - }); - - const isSearchInputHidden = createMemo( - () => isCompact() && searchQuery() !== "" && autoApply.isInlineNumericEdit(), - ); - - const updateSearchQuery = (nextSearchQuery: string) => { - if (autoApply.tryApplyNumericValue(nextSearchQuery)) { - keepInlineNumericSearchQuery(); - setSearchQuery(nextSearchQuery); - queueInlineNumericReplacementForQuery(nextSearchQuery); - ensureSearchFocused(); - return; - } - cancelInlineNumericReplacement(); - if (autoApply.isInlineNumericDraft(nextSearchQuery)) { - keepInlineNumericSearchQuery(); - setSearchQuery(nextSearchQuery); - ensureSearchFocused(); - return; - } - setSearchQuery(nextSearchQuery); - setInlineNumericSearchQuery(null); - setActiveIndex(nextSearchQuery.trim() === "" ? firstNumericActiveIndex() : 0); - expandPanel(); - autoApply.applyTailwindClass(nextSearchQuery); - }; - - const tryAppendUnfocusedSearchKey = (event: KeyboardEvent): boolean => { - if (event.metaKey || event.ctrlKey || event.altKey || !isTypeToEditKey(event.key)) return false; - event.preventDefault(); - event.stopImmediatePropagation(); - const nextSearchQuery = `${searchQuery()}${event.key}`; - if (searchInputRef) searchInputRef.value = nextSearchQuery; - updateSearchQuery(nextSearchQuery); - ensureSearchFocused(); - return true; - }; - - const handleSubmit = () => { - discardConfirmation.hide(); - pointerMovePromptHandoff.clear(); - props.onSubmit(styleStore.buildPendingEdits()); - }; - - const discardConfirmation = createDiscardConfirmation(); - let panelSurfaceRef: HTMLDivElement | undefined; - - const playShake = () => { - if (!panelSurfaceRef) return; - panelSurfaceRef.classList.remove("animate-shake"); - // Force reflow so re-adding the class restarts the animation. - void panelSurfaceRef.offsetWidth; - panelSurfaceRef.classList.add("animate-shake"); - }; - - const closePanel = (mode: "preserve" | "discard") => { - discardConfirmation.hide(); - if (mode === "discard") preview.restore(); - props.onDismiss(); - }; - - const attemptDismiss = (source: OverlayDismissSource) => { - stepController.cancelRepeat(); - if (source === "pointer") pointerMovePromptHandoff.clear(); - if (discardConfirmation.isPending()) { - closePanel("discard"); - return; - } - if (!hasSubmittableEdits()) { - closePanel(preview.hasAppliedStyles() ? "discard" : "preserve"); - return; - } - // Always reveal the full panel before anything destructive. A keyboard - // Escape stops there (so a stray Escape can't nuke pending changes on - // the collapsed view); an outside click continues to the discard prompt. - const wasCompact = isCompact(); - expandPanel(); - if (source === "keyboard" && wasCompact) return; - discardConfirmation.show(); - playShake(); - }; - - const navigateActive = (direction: 1 | -1) => { - const properties = styleStore.filteredProperties(); - if (properties.length === 0) return; - setActiveIndex((current) => (current + direction + properties.length) % properties.length); - expandPanel(); - }; - - const cancelDiscardPrompt = () => { - pointerMovePromptHandoff.clear(); - discardConfirmation.hide(); - }; - - let colorPickerTriggers: Array<() => void> = []; - const registerColorPickerTrigger = (trigger: () => void) => { - colorPickerTriggers.push(trigger); - return () => { - colorPickerTriggers = colorPickerTriggers.filter( - (registeredTrigger) => registeredTrigger !== trigger, - ); - }; - }; - - const getCurrentColorPickerTrigger = () => - colorPickerTriggers[colorPickerTriggers.length - 1] ?? null; - - const pressArrowOrOpenColorPicker = (key: "ArrowLeft" | "ArrowRight", event: KeyboardEvent) => { - if (activeProperty()?.kind === "color") { - if (!event.repeat) getCurrentColorPickerTrigger()?.(); - return; - } - stepController.pressArrow(key, event.repeat, event.shiftKey, event.altKey); - }; - - const keyHandlers: Record void> = { - ArrowUp: () => navigateActive(-1), - ArrowDown: () => navigateActive(1), - ArrowLeft: (event) => pressArrowOrOpenColorPicker("ArrowLeft", event), - ArrowRight: (event) => pressArrowOrOpenColorPicker("ArrowRight", event), - Tab: (event) => navigateActive(event.shiftKey ? -1 : 1), - Enter: () => { - if (discardConfirmation.isPending()) return; - const property = activeProperty(); - const colorPickerTrigger = getCurrentColorPickerTrigger(); - // Opening the picker is only a convenience for an untouched color - // row with nothing else staged. If any edit is pending, Enter must - // submit it — otherwise the picker interaction dismisses the panel - // and the pending change is discarded instead of copied. - const isUnchangedColor = - property?.kind === "color" && !styleStore.hasChangedStyleFor(property.key); - if (isUnchangedColor && colorPickerTrigger && !hasSubmittableEdits()) { - colorPickerTrigger(); - return; - } - handleSubmit(); - }, - Escape: () => attemptDismiss("keyboard"), - }; - - const handleSearchKeyDown = (event: KeyboardEvent) => { - // Chromium reports keyCode 229 on the IME commit tick after isComposing resets. - if (isKeyboardEventComposing(event)) return; - if (discardConfirmation.isPending()) { - const target = event.composedPath()[0]; - const isOnDiscardButton = - target instanceof HTMLElement && - target.closest("[data-react-grab-discard-button]") !== null; - if (isOnDiscardButton && (event.key === "Tab" || event.key === "Enter")) return; - if ( - event.key === "Tab" || - event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "ArrowLeft" || - event.key === "ArrowRight" - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - return; - } - } - const handler = keyHandlers[event.key]; - if (!handler) return; - event.preventDefault(); - event.stopImmediatePropagation(); - handler(event); - }; - - onMount(() => { - queueMicrotask(() => { - focusInOverlay(searchInputRef, { preventScroll: true }); - if (searchInputRef) { - const length = searchInputRef.value.length; - searchInputRef.setSelectionRange(length, length); - } - }); - dropdown.measure(); - const initialQuery = searchQuery(); - if (initialQuery) autoApply.applyTailwindClass(initialQuery); - - const unregisterDismiss = registerOverlayDismiss({ - isOpen: () => true, - onDismiss: attemptDismiss, - shouldIgnoreKeyboardEvent: (event) => { - const target = event.composedPath()[0]; - return ( - discardConfirmation.isPending() && - target instanceof HTMLElement && - target.closest("[data-react-grab-discard-button]") !== null - ); - }, - shouldIgnoreRightClick: true, - shouldIgnoreInputEvents: true, - }); - - const handleWindowKeyDown = ignoreRealInput((event: KeyboardEvent) => { - if (isEventFromOverlay(event, REACT_GRAB_INPUT_ATTRIBUTE)) return; - if (tryReplaceInlineNumericFromKey(event)) return; - if (tryAppendUnfocusedSearchKey(event)) return; - handleSearchKeyDown(event); - }); - const handleWindowKeyUp = ignoreRealInput((event: KeyboardEvent) => { - stepController.releaseKey(event.key); - }); - const handleWindowPointerMove = ignoreRealInput((event: PointerEvent) => { - if (event.pointerType !== "mouse") return; - if (discardConfirmation.isPending()) return; - if (!pointerMovePromptHandoff.consume()) return; - if (!hasSubmittableEdits()) return; - attemptDismiss("pointer"); - }); - window.addEventListener("keydown", handleWindowKeyDown, { capture: true }); - window.addEventListener("keyup", handleWindowKeyUp, { capture: true }); - window.addEventListener("pointermove", handleWindowPointerMove, { capture: true }); - - onCleanup(() => { - unregisterDismiss(); - window.removeEventListener("keydown", handleWindowKeyDown, { capture: true }); - window.removeEventListener("keyup", handleWindowKeyUp, { capture: true }); - window.removeEventListener("pointermove", handleWindowPointerMove, { capture: true }); - clearTimeout(activeKeyTimerId); - clearTimeout(interactingIdleTimerId); - clearTimeout(inlineNumericReplaceTimerId); - discardConfirmation.cleanup(); - dropdown.clearAnimationHandles(); - setIsTransientInteraction(false); - }); - }); - - const handleSelectProperty = (index: number) => { - setActiveIndex(index); - ensureSearchFocused(); - }; - - // Intentionally not built on AnchoredDropdownSurface: unlike the toolbar and - // hierarchy menus, this panel is a focus-trapping dialog that needs its own - // container ref, dialog ARIA, compact-mode attribute, a list-height CSS var, - // and source-aware dismissal — folding those into the shared surface would - // add per-consumer optionality without simplifying anything here. - return ( - -