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
|
// jsdom lacks SVG geometry methods
|
||||||
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
|
(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
|
// Robust localStorage mock for jsdom environment
|
||||||
const localStorageMock = (() => {
|
const localStorageMock = (() => {
|
||||||
let store: Record<string, string> = {};
|
let store: Record<string, string> = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user