Skip to content
Merged
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
33 changes: 24 additions & 9 deletions apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -390,16 +391,30 @@ export function PlaygroundCodeView({
);

const renderHeaderMetadata = useStableCallback((item: PlaygroundItem) => {
const isEditing = item.edit === true;
return (
<label className="flex cursor-pointer items-center gap-[4px] text-xs select-none">
<input
type="checkbox"
className="cursor-pointer"
checked={item.edit === true}
onChange={(event) => toggleEdit(item.id, event.target.checked)}
/>
Edit
</label>
<button
type="button"
onClick={() => toggleEdit(item.id, !isEditing)}
aria-pressed={isEditing}
className={`-mr-[8px] flex cursor-pointer items-center gap-1 rounded-sm border py-1 pr-2 pl-1.5 text-xs transition ${
isEditing
? 'border-blue-400/50 bg-blue-500/25 text-blue-600'
: 'border bg-transparent text-neutral-500 hover:border-neutral-300 hover:bg-neutral-100 hover:text-neutral-700'
}`}
>
{isEditing ? (
<>
<IconCheckboxFill size={12} />
Editing
</>
) : (
<>
<IconSquircleLg size={12} className="opacity-50" />
Edit
</>
)}
</button>
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<label className="flex cursor-pointer items-center gap-[4px] text-xs select-none">
<input
type="checkbox"
className="cursor-pointer"
checked={editing}
onChange={(event) => setEditing(event.target.checked)}
<button
type="button"
onClick={() => setEditing((current) => !current)}
aria-pressed={editing}
className="playground-edit-toggle"
>
<IconCheckboxFill
size={12}
className="playground-edit-toggle-icon-on"
/>
Edit
</label>
<IconSquircleLg size={12} className="playground-edit-toggle-icon-off" />
<span className="playground-edit-toggle-label-on">Editing</span>
<span className="playground-edit-toggle-label-off">Edit</span>
</button>
);
});
}, [editing]);

return (
<FileDiff
Expand Down
54 changes: 29 additions & 25 deletions apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,29 @@ interface PlaygroundVirtualizerViewProps {
showAnnotations: boolean;
}

// Builds the per-file "Edit" checkbox rendered into a diff header's metadata
// slot. The header lives in the diff's shadow root (outside app CSS), so the
// control is styled inline. Returns the label element plus its input so the
// caller can wire the change handler once the diff instance exists.
function createEditToggle(): { element: HTMLElement; input: HTMLInputElement } {
const label = document.createElement('label');
label.style.display = 'inline-flex';
label.style.alignItems = 'center';
label.style.gap = '4px';
label.style.cursor = 'pointer';
label.style.fontSize = '12px';
label.style.userSelect = 'none';
// Both edit-toggle state icons, inlined as SVG markup. @pierre/icons ships
// React components (used by the CodeView and React-Virtualizer toggles), but
// this vanilla view builds DOM directly, so the same two glyphs
// (IconCheckboxFill / IconSquircleLg) are inlined here. The shared
// `.playground-edit-toggle` styles show whichever matches `aria-pressed`.
const EDIT_TOGGLE_ICON_ON = `<svg class="playground-edit-toggle-icon-on" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 0C1.412 0 0 1.412 0 8s1.412 8 8 8 8-1.412 8-8-1.412-8-8-8m4.08 5.975a.75.75 0 0 0-1.16-.95L6.943 9.884 5.03 7.97a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.11-.055z"/></svg>`;
const EDIT_TOGGLE_ICON_OFF = `<svg class="playground-edit-toggle-icon-off" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.788 4.296C1.588 5.194 1.5 6.387 1.5 8s.088 2.806.288 3.704c.196.88.478 1.381.802 1.706s.826.607 1.706.802c.898.2 2.091.288 3.704.288s2.806-.088 3.704-.288c.88-.195 1.381-.478 1.706-.802s.607-.826.802-1.706c.2-.898.288-2.091.288-3.704s-.088-2.806-.288-3.704c-.195-.88-.478-1.381-.802-1.706s-.826-.606-1.706-.802C10.806 1.588 9.613 1.5 8 1.5s-2.806.088-3.704.288c-.88.196-1.381.478-1.706.802s-.606.826-.802 1.706M0 8c0-6.588 1.412-8 8-8s8 1.412 8 8-1.412 8-8 8-8-1.412-8-8"/></svg>`;

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}<span class="playground-edit-toggle-label-on">Editing</span><span class="playground-edit-toggle-label-off">Edit</span>`;
return button;
}

const VIRTUALIZER_CUSTOM_CSS = `${ITEM_UNSAFE_CSS}
Expand Down Expand Up @@ -176,7 +177,7 @@ export function PlaygroundVirtualizerView({
},
});
editors.push(editor);
const { element: editToggle, input } = createEditToggle();
const editToggle = createEditToggle();

const rerenderWithAnnotations = () => {
instance.render({
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions apps/docs/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down