feat(Board): add always-editable RoleField specimen input
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user