feat(Board): add always-editable RoleField specimen input

This commit is contained in:
Ilia Mashkov
2026-06-24 15:27:20 +03:00
parent 738ed3b4ed
commit 59097ca9ad
4 changed files with 274 additions and 0 deletions
@@ -0,0 +1,83 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import RoleField from './RoleField.svelte';
const { Story } = defineMeta({
title: 'Widgets/Board/RoleField',
component: RoleField,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Always-editable plain-text specimen field for one role. Uncontrolled while focused (no caret jump), commits on blur. Header blocks Enter (single-line); body allows line breaks. Paste inserts plain text only.',
},
story: { inline: false },
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
let headerText = $state('The Art of Harmonious Type');
let bodyText = $state(
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion.',
);
let emptyText = $state('');
</script>
<Story
name="Header (single-line)"
args={{
role: 'header',
text: headerText,
fontName: 'Georgia',
size: 48,
weight: 700,
leading: 1.1,
tracking: 0,
oncommit: t => (headerText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
<Story
name="Body (multi-line)"
args={{
role: 'body',
text: bodyText,
fontName: 'Georgia',
size: 18,
weight: 400,
leading: 1.5,
tracking: 0,
oncommit: t => (bodyText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
<Story
name="Empty (placeholder)"
args={{
role: 'body',
text: emptyText,
fontName: 'Georgia',
size: 18,
weight: 400,
leading: 1.5,
tracking: 0,
oncommit: t => (emptyText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
@@ -0,0 +1,127 @@
<!--
Component: RoleField
Always-live plain-text specimen field for one role (header/body). Edits stay
uncontrolled while the field is focused (the prop never writes back over the
caret), and commit to the board on blur. The editable node is wrapped so any
frame transition animates the wrapper, never the node being typed in.
-->
<script lang="ts">
import type { Role } from '$entities/Pairing';
interface Props {
/**
* Which role this field edits — drives Enter behaviour and the placeholder.
*/
role: Role;
/**
* Current committed specimen text for the role.
*/
text: string;
/**
* Font family the specimen renders in.
*/
fontName: string;
/**
* Font size in px.
*/
size: number;
/**
* Numeric font weight.
*/
weight: number;
/**
* Unitless line-height multiplier.
*/
leading: number;
/**
* Letter spacing in px.
*/
tracking: number;
/**
* Called with the field's text when it commits (on blur).
*/
oncommit: (text: string) => void;
/**
* Extra CSS classes for the wrapper.
*/
class?: string;
}
let {
role,
text,
fontName,
size,
weight,
leading,
tracking,
oncommit,
class: className = '',
}: Props = $props();
let element = $state<HTMLDivElement>();
let focused = $state(false);
const placeholder = $derived(role === 'header' ? 'Header text…' : 'Body text…');
/**
* Sync the prop into the DOM only while unfocused. External updates (cycling,
* reset, another field's commit) must never move the caret mid-edit, so we skip
* the write whenever the field has focus. innerText keeps the content plain.
*/
$effect(() => {
const next = text;
if (element && !focused && element.innerText !== next) {
element.innerText = next;
}
});
function handleBlur() {
focused = false;
if (element) {
oncommit(element.innerText);
}
}
function handlePaste(event: ClipboardEvent) {
// Strip formatting: insert the clipboard's plain text only.
event.preventDefault();
const plain = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, plain);
}
function handleKeydown(event: KeyboardEvent) {
// Header is single-line: Enter commits (blur) instead of inserting a break.
if (role === 'header' && event.key === 'Enter') {
event.preventDefault();
element?.blur();
}
}
</script>
<div class={className}>
<div
bind:this={element}
contenteditable="plaintext-only"
spellcheck="false"
role="textbox"
tabindex="0"
aria-label="{role} specimen"
data-placeholder={placeholder}
onfocus={() => (focused = true)}
onblur={handleBlur}
onpaste={handlePaste}
onkeydown={handleKeydown}
class="
w-full min-h-[1.4em] outline-none whitespace-pre-wrap break-words
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
focus:outline-none
"
style:font-family={`"${fontName}"`}
style:font-size="{size}px"
style:font-weight={weight}
style:line-height={leading}
style:letter-spacing="{tracking}px"
>
</div>
</div>
@@ -0,0 +1,51 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import { tick } from 'svelte';
import {
describe,
expect,
it,
vi,
} from 'vitest';
import RoleField from './RoleField.svelte';
const baseProps = { fontName: 'Georgia', size: 24, weight: 400, leading: 1.4, tracking: 0 };
describe('RoleField', () => {
it('renders the initial text', async () => {
render(RoleField, { props: { role: 'header', text: 'Hello', oncommit: () => {}, ...baseProps } });
await tick();
expect(screen.getByRole('textbox').textContent).toBe('Hello');
});
it('commits the field text on blur (not on input)', async () => {
const oncommit = vi.fn();
render(RoleField, { props: { role: 'body', text: 'Start', oncommit, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
field.textContent = 'Edited';
await fireEvent.blur(field);
expect(oncommit).toHaveBeenCalledWith('Edited');
});
it('prevents Enter on the header role (single-line)', async () => {
render(RoleField, { props: { role: 'header', text: 'Title', oncommit: () => {}, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
field.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('allows Enter on the body role (multi-line)', async () => {
render(RoleField, { props: { role: 'body', text: 'Para', oncommit: () => {}, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
field.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
+13
View File
@@ -19,6 +19,19 @@ Element.prototype.animate = vi.fn().mockReturnValue({
// jsdom lacks SVG geometry methods
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
// jsdom lacks innerText; back it with textContent so contenteditable specs work.
if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
Object.defineProperty(HTMLElement.prototype, 'innerText', {
configurable: true,
get(this: HTMLElement) {
return this.textContent ?? '';
},
set(this: HTMLElement, value: string) {
this.textContent = value;
},
});
}
// Robust localStorage mock for jsdom environment
const localStorageMock = (() => {
let store: Record<string, string> = {};