From 59097ca9ad286ad17493e4e08534aed9c1af0d78 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 24 Jun 2026 15:27:20 +0300 Subject: [PATCH] feat(Board): add always-editable RoleField specimen input --- .../ui/RoleField/RoleField.stories.svelte | 83 ++++++++++++ .../Board/ui/RoleField/RoleField.svelte | 127 ++++++++++++++++++ .../ui/RoleField/RoleField.svelte.test.ts | 51 +++++++ vitest.setup.jsdom.ts | 13 ++ 4 files changed, 274 insertions(+) create mode 100644 src/widgets/Board/ui/RoleField/RoleField.stories.svelte create mode 100644 src/widgets/Board/ui/RoleField/RoleField.svelte create mode 100644 src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts diff --git a/src/widgets/Board/ui/RoleField/RoleField.stories.svelte b/src/widgets/Board/ui/RoleField/RoleField.stories.svelte new file mode 100644 index 0000000..87103ab --- /dev/null +++ b/src/widgets/Board/ui/RoleField/RoleField.stories.svelte @@ -0,0 +1,83 @@ + + + + + (headerText = t), + }} +> + {#snippet template(args: ComponentProps)} + + {/snippet} + + + (bodyText = t), + }} +> + {#snippet template(args: ComponentProps)} + + {/snippet} + + + (emptyText = t), + }} +> + {#snippet template(args: ComponentProps)} + + {/snippet} + diff --git a/src/widgets/Board/ui/RoleField/RoleField.svelte b/src/widgets/Board/ui/RoleField/RoleField.svelte new file mode 100644 index 0000000..27cb78e --- /dev/null +++ b/src/widgets/Board/ui/RoleField/RoleField.svelte @@ -0,0 +1,127 @@ + + + +
+
(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" + > +
+
diff --git a/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts b/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts new file mode 100644 index 0000000..3b83a71 --- /dev/null +++ b/src/widgets/Board/ui/RoleField/RoleField.svelte.test.ts @@ -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); + }); +}); diff --git a/vitest.setup.jsdom.ts b/vitest.setup.jsdom.ts index 36b8174..7df9e82 100644 --- a/vitest.setup.jsdom.ts +++ b/vitest.setup.jsdom.ts @@ -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 = {};