Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/react-grab/e2e/selection-session-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test, expect } from "./fixtures.js";
import { ATTRIBUTE_NAME } from "./constants.js";
import type { Page } from "@playwright/test";

interface SelectionSessionBarState {
isVisible: boolean;
multiSelectEnabled: boolean;
selectedText: string | null;
copyDisabled: boolean | null;
}

const getSelectionSessionBarState = async (page: Page): Promise<SelectionSessionBarState> => {
return page.evaluate((attributeName: string) => {
const host = document.querySelector(`[${attributeName}]`);
const root = host?.shadowRoot?.querySelector(`[${attributeName}]`);
const bar = root?.querySelector<HTMLElement>("[data-react-grab-selection-session-bar]");
const multiSelectButton = bar?.querySelector(
"[data-react-grab-selection-session-enable-multi-select]",
);
const selectedText = bar?.querySelector<HTMLElement>(".tabular-nums")?.textContent ?? null;
const copyButton = bar?.querySelector<HTMLButtonElement>(
"[data-react-grab-selection-session-copy]",
);

return {
isVisible: Boolean(bar),
multiSelectEnabled: !multiSelectButton,
selectedText,
copyDisabled: copyButton?.disabled ?? null,
};
}, ATTRIBUTE_NAME);
};

test.describe("Selection session bar", () => {
test("should opt into persistent multi-select and copy the accumulated selection", async ({
reactGrab,
}) => {
await reactGrab.activate();

await expect
.poll(() => getSelectionSessionBarState(reactGrab.page))
.toMatchObject({
isVisible: true,
multiSelectEnabled: false,
});
await reactGrab.page.evaluate((attributeName) => {
const host = document.querySelector(`[${attributeName}]`);
const root = host?.shadowRoot?.querySelector(`[${attributeName}]`);
root
?.querySelector<HTMLButtonElement>(
"[data-react-grab-selection-session-enable-multi-select]",
)
?.click();
}, ATTRIBUTE_NAME);

const todoItems = reactGrab.page.locator("[data-testid='todo-list'] li");
await todoItems.nth(0).click({ force: true });
await todoItems.nth(1).click({ force: true });

await expect
.poll(() => getSelectionSessionBarState(reactGrab.page))
.toMatchObject({
isVisible: true,
multiSelectEnabled: true,
selectedText: "2 selected",
copyDisabled: false,
});

await reactGrab.page.evaluate((attributeName) => {
const host = document.querySelector(`[${attributeName}]`);
const root = host?.shadowRoot?.querySelector(`[${attributeName}]`);
root?.querySelector<HTMLButtonElement>("[data-react-grab-selection-session-copy]")?.click();
}, ATTRIBUTE_NAME);

await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
});
10 changes: 10 additions & 0 deletions packages/react-grab/src/components/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ContextMenu } from "./context-menu.js";
import { EditPanel } from "./edit-panel/index.js";
import { ToolbarMenu } from "./toolbar/toolbar-menu.js";
import { HierarchyMenu } from "./toolbar/hierarchy-menu.js";
import { SelectionSessionBar } from "./selection-session-bar.js";

export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
return (
Expand All @@ -26,6 +27,15 @@ export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
labelInstances={props.labelInstances}
/>
<FrozenGlow visible={props.isFrozen ?? false} />
<SelectionSessionBar
visible={props.selectionSessionVisible ?? false}
selectedCount={props.selectionSessionSelectedCount ?? 0}
multiSelectEnabled={props.selectionSessionMultiSelectEnabled ?? false}
onCancel={props.onSelectionSessionCancel}
onEnableMultiSelect={props.onSelectionSessionEnableMultiSelect}
onClear={props.onSelectionSessionClear}
onCopy={props.onSelectionSessionCopy}
/>
<Show when={props.selectionLabelVisible && (props.frozenLabelEntries?.length ?? 0) > 0}>
<Index each={props.frozenLabelEntries ?? []}>
{(entry, entryIndex) => (
Expand Down
150 changes: 150 additions & 0 deletions packages/react-grab/src/components/selection-session-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createEffect, createSignal, on, onCleanup, onMount, Show, type Component } from "solid-js";
import {
SELECTION_SESSION_BAR_ACCENT_COLOR,
SELECTION_SESSION_BAR_BACKGROUND_COLOR,
SELECTION_SESSION_BAR_HEIGHT_PX,
SELECTION_SESSION_ANIMATION_DURATION_MS,
SELECTION_SESSION_ANIMATION_EASING,
Z_INDEX_OVERLAY,
} from "../constants.js";
import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";

interface SelectionSessionBarProps {
visible: boolean;
selectedCount: number;
multiSelectEnabled: boolean;
onCancel?: () => void;
onEnableMultiSelect?: () => void;
onClear?: () => void;
onCopy?: () => void;
}

export const SelectionSessionBar: Component<SelectionSessionBarProps> = (props) => {
const hostname = window.location.hostname || "this page";
const [shouldRender, setShouldRender] = createSignal(props.visible);
const [isAnimatedIn, setIsAnimatedIn] = createSignal(false);
let enterAnimationFrameId: number | null = null;
let exitAnimationTimerId: number | null = null;

onMount(() => {
if (props.visible) {
enterAnimationFrameId = nativeRequestAnimationFrame(() => setIsAnimatedIn(true));
}
});

createEffect(
on(
() => props.visible,
(visible) => {
if (enterAnimationFrameId !== null) {
nativeCancelAnimationFrame(enterAnimationFrameId);
enterAnimationFrameId = null;
}
if (exitAnimationTimerId !== null) {
window.clearTimeout(exitAnimationTimerId);
exitAnimationTimerId = null;
}

if (visible) {
setShouldRender(true);
enterAnimationFrameId = nativeRequestAnimationFrame(() => {
enterAnimationFrameId = null;
setIsAnimatedIn(true);
});
return;
}

setIsAnimatedIn(false);
exitAnimationTimerId = window.setTimeout(() => {
exitAnimationTimerId = null;
setShouldRender(false);
}, SELECTION_SESSION_ANIMATION_DURATION_MS);
},
{ defer: true },
),
);

onCleanup(() => {
if (enterAnimationFrameId !== null) nativeCancelAnimationFrame(enterAnimationFrameId);
if (exitAnimationTimerId !== null) window.clearTimeout(exitAnimationTimerId);
});

return (
<Show when={shouldRender()}>
<div
data-react-grab-selection-session-bar
class="fixed inset-x-0 top-0 px-2.5 flex items-center text-white font-sans antialiased [font-synthesis:none] [box-shadow:0_1px_0_rgba(255,255,255,0.08),0_2px_10px_rgba(0,0,0,0.16)] pointer-events-none will-change-[transform,opacity]"
style={{
height: `${SELECTION_SESSION_BAR_HEIGHT_PX}px`,
background: SELECTION_SESSION_BAR_BACKGROUND_COLOR,
opacity: isAnimatedIn() ? 1 : 0,
transform: isAnimatedIn() ? "translateY(0)" : "translateY(-100%)",
transition: `transform ${SELECTION_SESSION_ANIMATION_DURATION_MS}ms ${SELECTION_SESSION_ANIMATION_EASING}, opacity ${SELECTION_SESSION_ANIMATION_DURATION_MS}ms ease-out`,
"z-index": Z_INDEX_OVERLAY,
}}
>
<div class="flex items-center">
<button
data-react-grab-ignore-events
data-react-grab-selection-session-cancel
aria-label="Cancel selection"
type="button"
class="size-7 flex items-center justify-center rounded-md text-[21px] leading-none font-light text-white/65 hover:text-white hover:bg-white/8 pointer-events-auto cursor-pointer interactive-scale"
onClick={() => props.onCancel?.()}
>
×
</button>
</div>

<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-1.5 text-[13px] font-medium whitespace-nowrap">
<span class="text-white/95">Selecting</span>
<span class="text-white/35">·</span>
<span class="text-white/65 max-sm:hidden">{hostname}</span>
<Show when={props.multiSelectEnabled}>
<span class="text-white/35">·</span>
<span class="text-white/85 tabular-nums">{props.selectedCount} selected</span>
</Show>
</div>

<div class="ml-auto flex items-center gap-1.5">
<Show
when={props.multiSelectEnabled}
fallback={
<button
data-react-grab-ignore-events
data-react-grab-selection-session-enable-multi-select
type="button"
class="h-7 px-2.5 rounded-md text-[12px] font-medium text-white/75 bg-white/8 hover:text-white hover:bg-white/12 pointer-events-auto cursor-pointer interactive-scale"
onClick={() => props.onEnableMultiSelect?.()}
>
Multiple
</button>
}
>
<button
data-react-grab-ignore-events
data-react-grab-selection-session-clear
type="button"
disabled={props.selectedCount === 0}
class="h-7 px-2.5 rounded-md text-[12px] font-medium text-white/65 hover:text-white hover:bg-white/8 disabled:opacity-35 disabled:cursor-default pointer-events-auto cursor-pointer interactive-scale"
onClick={() => props.onClear?.()}
>
Clear
</button>
<button
data-react-grab-ignore-events
data-react-grab-selection-session-copy
type="button"
disabled={props.selectedCount === 0}
class="h-7 min-w-14 px-2.5 rounded-md text-[12px] font-semibold text-white disabled:opacity-40 disabled:cursor-default pointer-events-auto cursor-pointer interactive-scale"
style={{ background: SELECTION_SESSION_BAR_ACCENT_COLOR }}
onClick={() => props.onCopy?.()}
>
Copy{props.selectedCount > 0 ? ` ${props.selectedCount}` : ""}
</button>
</Show>
</div>
</div>
</Show>
);
};
5 changes: 5 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ export const OVERLAY_BORDER_COLOR_DEFAULT = overlayColor(0.5);
export const OVERLAY_FILL_COLOR_DEFAULT = overlayColor(0.08);
export const FROZEN_GLOW_COLOR = overlayColor(0.15);
export const FROZEN_GLOW_EDGE_PX = 50;
export const SELECTION_SESSION_BAR_HEIGHT_PX = 42;
export const SELECTION_SESSION_BAR_BACKGROUND_COLOR = "#162f47";
export const SELECTION_SESSION_BAR_ACCENT_COLOR = "#0485ff";
export const SELECTION_SESSION_ANIMATION_DURATION_MS = 180;
export const SELECTION_SESSION_ANIMATION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";

export const ARROW_HEIGHT_PX = 8;
export const ARROW_MIN_SIZE_PX = 4;
Expand Down
Loading
Loading