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
37 changes: 37 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ body,
}

.block {
position: relative;
margin: 0 -8px;
padding: 0 8px;
border-radius: 4px;
Expand All @@ -169,6 +170,42 @@ body,
outline-color: #3b82f6;
}

/* Sits in the Page's left margin; wide enough to bridge the gap so hover persists. */
.block-handle {
position: absolute;
top: 50%;
right: 100%;
transform: translateY(-50%);
display: none;
padding-right: 4px;
}

.block:hover > .block-handle,
.block-selected > .block-handle,
.block-handle:focus-within {
display: flex;
}

.block-delete {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: #777;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}

.block-delete:hover,
.block-delete:focus-visible {
color: #b91c1c;
background: rgba(185, 28, 28, 0.1);
}

.properties-panel .field {
display: flex;
flex-direction: column;
Expand Down
37 changes: 36 additions & 1 deletion src/components/Worksheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ export function Worksheet() {
const { state, dispatch } = useWorksheet();
const insertionIndex = useInsertionIndex();
const { setNodeRef } = useDroppable({ id: WORKSHEET_DROPPABLE_ID });
const { selectedId } = state;

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
if (selectedId === null || isTextEntry(document.activeElement)) return;
event.preventDefault();
dispatch({ type: 'remove', id: selectedId });
return;
}
if (event.key !== 'Escape') return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
Expand All @@ -21,7 +28,7 @@ export function Worksheet() {
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [dispatch]);
}, [dispatch, selectedId]);

return (
<main
Expand Down Expand Up @@ -59,6 +66,9 @@ export function Worksheet() {
);
}

const isTextEntry = (element: Element | null) =>
element?.closest('input, textarea, select, [contenteditable]') != null;

function InsertionLine() {
return <div role="separator" className="insertion-line" />;
}
Expand All @@ -84,6 +94,31 @@ function BlockHost({
onClick={select}
onFocus={select}
>
<div className="block-handle">
<button
type="button"
className="block-delete"
aria-label="Delete Block"
onClick={(event) => {
event.stopPropagation();
dispatch({ type: 'remove', id: block.id });
}}
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 4h11M6 4V2.5h4V4M4 4l.6 9h6.8l.6-9M6.5 6.5v4M9.5 6.5v4" />
</svg>
</button>
</div>
<View
data={block.data}
autoFocus={autoFocus}
Expand Down
80 changes: 80 additions & 0 deletions src/components/delete-block.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { WorksheetProvider } from '../worksheet/context';
import { BlockPalette } from './BlockPalette';
import { Worksheet } from './Worksheet';
import { WorksheetDnd } from './WorksheetDnd';

function setup() {
render(
<WorksheetProvider>
<WorksheetDnd>
<BlockPalette />
<Worksheet />
</WorksheetDnd>
</WorksheetProvider>,
);
}

const blocks = () => Array.from(document.querySelectorAll('.block'));
const insert = (name: string) =>
userEvent.click(screen.getByRole('button', { name }));
const hint = () => screen.queryByText('Drag a Block here from the left');

/** Moves focus out of the Block's text while keeping the Block selected. */
const blurText = () => (document.activeElement as HTMLElement).blur();

describe('deleting Blocks', () => {
it('removes a Block with the delete button on its handle', async () => {
setup();
await insert('Heading');
await insert('Body Text');

await userEvent.click(
blocks()[0].querySelector<HTMLElement>('[aria-label="Delete Block"]')!,
);

expect(blocks()).toHaveLength(1);
expect(blocks()[0].querySelector('.heading')).toBeNull();
});

it('shows the empty-Worksheet hint again after deleting the last Block', async () => {
setup();
await insert('Heading');
expect(hint()).toBeNull();

await userEvent.click(screen.getByRole('button', { name: 'Delete Block' }));

expect(hint()).not.toBeNull();
});

it.each(['{Delete}', '{Backspace}'])(
'removes the selected Block on %s when its text is not focused',
async (key) => {
setup();
await insert('Heading');
blurText();

await userEvent.keyboard(key);

expect(blocks()).toHaveLength(0);
},
);

it.each(['{Delete}', '{Backspace}'])(
'never removes the Block on %s while typing in its text',
async (key) => {
setup();
await insert('Heading');
await waitFor(() =>
expect(blocks()[0].querySelector('.heading')).toHaveFocus(),
);

await userEvent.keyboard(key);

expect(blocks()).toHaveLength(1);
},
);
});
46 changes: 46 additions & 0 deletions src/worksheet/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,49 @@ describe('worksheetReducer selection', () => {
expect(state.blocks.map((b) => b.id)).toEqual([...ids, state.selectedId]);
});
});

describe('worksheetReducer remove', () => {
const twoBlocks = () => {
let state = initialState;
state = worksheetReducer(state, { type: 'insert', blockType: 'heading' });
state = worksheetReducer(state, { type: 'insert', blockType: 'heading' });
return state;
};

it('removes the Block', () => {
const state = twoBlocks();
const [first, second] = state.blocks;

const next = worksheetReducer(state, { type: 'remove', id: first.id });

expect(next.blocks.map((b) => b.id)).toEqual([second.id]);
});

it('clears the selection when removing the selected Block', () => {
const state = twoBlocks();

const next = worksheetReducer(state, {
type: 'remove',
id: state.selectedId!,
});

expect(next.selectedId).toBeNull();
});

it('keeps the selection when removing another Block', () => {
const state = twoBlocks();

const next = worksheetReducer(state, {
type: 'remove',
id: state.blocks[0].id,
});

expect(next.selectedId).toBe(state.selectedId);
});

it('leaves the state unchanged for an unknown id', () => {
const state = twoBlocks();

expect(worksheetReducer(state, { type: 'remove', id: 'nope' })).toBe(state);
});
});
10 changes: 10 additions & 0 deletions src/worksheet/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type WorksheetState = {
export type WorksheetAction =
| { type: 'insert'; blockType: string; index?: number }
| { type: 'update'; id: string; data: unknown }
| { type: 'remove'; id: string }
| { type: 'select'; id: string }
| { type: 'deselect' };

Expand Down Expand Up @@ -56,6 +57,15 @@ export const createWorksheetReducer =
nextId: state.nextId + 1,
};
}
case 'remove':
return state.blocks.some((b) => b.id === action.id)
? {
...state,
blocks: state.blocks.filter((b) => b.id !== action.id),
selectedId:
state.selectedId === action.id ? null : state.selectedId,
}
: state;
case 'select':
return state.blocks.some((b) => b.id === action.id)
? { ...state, selectedId: action.id }
Expand Down
Loading