diff --git a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
index 813a0f88e..8c43c5a6a 100644
--- a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
+++ b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
@@ -17,6 +17,7 @@ import {
type CodeViewReactOptions,
useStableCallback,
} from '@pierre/diffs/react';
+import { IconCheckboxFill, IconSquircleLg } from '@pierre/icons';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
@@ -390,16 +391,30 @@ export function PlaygroundCodeView({
);
const renderHeaderMetadata = useStableCallback((item: PlaygroundItem) => {
+ const isEditing = item.edit === true;
return (
-
+
);
});
diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
index d9a54e6ef..364ec52c0 100644
--- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
+++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
@@ -10,6 +10,7 @@ import {
} from '@pierre/diffs';
import type { EditorOptions } from '@pierre/diffs/editor';
import { FileDiff, useStableCallback, Virtualizer } from '@pierre/diffs/react';
+import { IconCheckboxFill, IconSquircleLg } from '@pierre/icons';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
@@ -219,19 +220,30 @@ function ElementVirtualizerDiff({
}
);
- const renderHeaderMetadata = useStableCallback(() => {
+ // Must NOT be a stable callback: FileDiff invokes renderHeaderMetadata
+ // synchronously during render, but useStableCallback only refreshes its inner
+ // ref in a commit-phase insertion effect. Reading `editing` (render-phase
+ // state) through a stable wrapper would render the button one toggle behind —
+ // the header would reflect the previous `editing` value. A per-`editing`
+ // useCallback hands renderDiffChildren the current closure each toggle.
+ const renderHeaderMetadata = useCallback(() => {
return (
-
+
+ Editing
+ Edit
+
);
- });
+ }, [editing]);
return (
`;
+const EDIT_TOGGLE_ICON_OFF = ``;
- const input = document.createElement('input');
- input.type = 'checkbox';
- input.style.cursor = 'pointer';
-
- const text = document.createElement('span');
- text.textContent = 'Edit';
-
- label.append(input, text);
- return { element: label, input };
+// Builds the per-file "Edit" toggle rendered into a diff header's metadata
+// slot. Slotted content is a light-DOM child of the diffs container, so the
+// app stylesheet reaches it: styling lives in the shared
+// `.playground-edit-toggle` class (globals.css), matching the CodeView and
+// React-Virtualizer toggles. Both state icons and both labels ("Edit" /
+// "Editing") are always present; CSS shows the pair matching `aria-pressed`,
+// so the caller only flips the attribute (and wires edit/cleanup) once the diff
+// instance exists.
+function createEditToggle(): HTMLButtonElement {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'playground-edit-toggle';
+ button.setAttribute('aria-pressed', 'false');
+ button.innerHTML = `${EDIT_TOGGLE_ICON_ON}${EDIT_TOGGLE_ICON_OFF}EditingEdit`;
+ return button;
}
const VIRTUALIZER_CUSTOM_CSS = `${ITEM_UNSAFE_CSS}
@@ -176,7 +177,7 @@ export function PlaygroundVirtualizerView({
},
});
editors.push(editor);
- const { element: editToggle, input } = createEditToggle();
+ const editToggle = createEditToggle();
const rerenderWithAnnotations = () => {
instance.render({
@@ -286,9 +287,12 @@ export function PlaygroundVirtualizerView({
);
// Attaching the editor flips the new-file surface to contentEditable;
- // detaching restores read-only review.
- input.addEventListener('change', () => {
- if (input.checked) {
+ // detaching restores read-only review. The button tracks its own state on
+ // `aria-pressed` (which also drives the shared toggle styles).
+ editToggle.addEventListener('click', () => {
+ const editing = editToggle.getAttribute('aria-pressed') !== 'true';
+ editToggle.setAttribute('aria-pressed', editing ? 'true' : 'false');
+ if (editing) {
editor.edit(instance);
} else {
editor.cleanUp();
diff --git a/apps/docs/app/globals.css b/apps/docs/app/globals.css
index 64a63fb5c..3e5ca80a6 100644
--- a/apps/docs/app/globals.css
+++ b/apps/docs/app/globals.css
@@ -264,6 +264,41 @@
@apply overflow-hidden rounded-lg border border-transparent dark:border-neutral-800;
}
+/* Per-file "Edit" toggle shared by the playground React Virtualizer and vanilla
+ Virtualizer views, matching the CodeView view's toggle. The vanilla view
+ renders it into a diff header's metadata slot; slotted content lives in the
+ light DOM, so this app stylesheet reaches it. Active state is driven by
+ `aria-pressed`; both state icons and both labels ("Edit" / "Editing") are
+ always present and CSS shows the matching pair, so the vanilla toggle only
+ has to flip the attribute. */
+.playground-edit-toggle {
+ @apply -mr-[8px] flex cursor-pointer items-center gap-1 rounded-sm border py-1 pr-2 pl-1.5 text-xs transition;
+ @apply bg-transparent text-neutral-500;
+}
+.playground-edit-toggle:hover:not([aria-pressed='true']) {
+ @apply border-neutral-300 bg-neutral-100 text-neutral-700;
+}
+.playground-edit-toggle[aria-pressed='true'] {
+ @apply border-blue-400/50 bg-blue-500/25 text-blue-600;
+}
+.playground-edit-toggle-icon-off {
+ @apply block opacity-50;
+}
+.playground-edit-toggle-icon-on,
+.playground-edit-toggle-label-on {
+ @apply hidden;
+}
+.playground-edit-toggle[aria-pressed='true'] .playground-edit-toggle-icon-off,
+.playground-edit-toggle[aria-pressed='true'] .playground-edit-toggle-label-off {
+ @apply hidden;
+}
+.playground-edit-toggle[aria-pressed='true'] .playground-edit-toggle-icon-on {
+ @apply block;
+}
+.playground-edit-toggle[aria-pressed='true'] .playground-edit-toggle-label-on {
+ @apply inline;
+}
+
.cv-scrollbar {
--cv-scrollbar-thumb-bg: light-dark(
color-mix(in lab, var(--background) 85%, black),