import type { Meta, StoryObj } from '@storybook/html-vite'; import { userEvent, waitFor, expect } from 'storybook/test'; import { Header } from '../tools/header'; import { createEditorContainer, simulateClick, waitForToolbar, selectTextInBlock, waitForPointerEvents, waitForInlineToolbarOpen } from './helpers'; import type { EditorFactoryOptions } from './helpers'; import type { OutputData } from '@/types'; interface InlineToolbarArgs extends EditorFactoryOptions { minHeight: number; data: OutputData | undefined; } // Constants const BLOCK_TESTID = '[data-blok-testid="block-wrapper"]'; const INLINE_TOOLBAR_TESTID = '[data-blok-testid="inline-toolbar"]'; const INLINE_TOOL_INPUT_TESTID = '[data-blok-testid="inline-tool-input"]'; const CONTENTEDITABLE_SELECTOR = '[contenteditable="true"]'; const LINK_TOOL_SELECTOR = '[data-blok-item-name="link"]'; const CONVERT_TO_SELECTOR = '[data-blok-item-name="convert-to"]'; const POPOVER_OPENED_SELECTOR = '[data-blok-popover-opened="true"]'; const NESTED_POPOVER_SELECTOR = '[data-blok-nested="true"]'; const TIMEOUT_INIT = { timeout: 5000 }; const TIMEOUT_ACTION = { timeout: 5000 }; const sampleData: OutputData = { time: Date.now(), version: '1.0.0', blocks: [ { id: 'inline-block-1', type: 'paragraph', data: { text: 'Select this text to see the inline toolbar appear with formatting options.' }, }, { id: 'inline-block-2', type: 'paragraph', data: { text: 'This paragraph has bold text and italic text already applied.' }, }, { id: 'inline-block-3', type: 'paragraph', data: { text: 'This paragraph contains a link to example website.' }, }, ], }; const createEditor = (args: InlineToolbarArgs): HTMLElement => createEditorContainer(args); /** * Helper to select text within an element */ const selectText = (element: Element, start: number, end: number): void => { const range = document.createRange(); const textNode = element.firstChild; if (textNode && textNode.nodeType === Node.TEXT_NODE) { range.setStart(textNode, start); range.setEnd(textNode, end); const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } }; const meta: Meta = { title: 'Components/Inline Toolbar', tags: ['autodocs'], args: { minHeight: 300, data: sampleData, tools: { header: Header, }, }, render: createEditor, }; export default meta; type Story = StoryObj; /** * Default state: Inline toolbar is hidden until text is selected. */ export const Default: Story = { args: { data: sampleData, }, }; /** * Inline toolbar visible after selecting text. */ export const WithTextSelection: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const block = canvasElement.querySelector(BLOCK_TESTID); expect(block).toBeInTheDocument(); }, TIMEOUT_INIT ); }); await step('Select text to show inline toolbar', async () => { const block = canvasElement.querySelector(BLOCK_TESTID); const contentEditable = block?.querySelector(CONTENTEDITABLE_SELECTOR); if (contentEditable) { simulateClick(contentEditable); selectText(contentEditable, 0, 11); document.dispatchEvent(new Event('selectionchange')); } await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); }, TIMEOUT_ACTION ); }); }, }; /** * Bold tool active state when selected text is bold. */ export const BoldActive: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); expect(blocks.length).toBeGreaterThan(1); }, TIMEOUT_INIT ); await waitForToolbar(canvasElement); }); await step('Select bold text to show active state', async () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); const secondBlock = blocks[1]; // Use 'strong' selector - browsers normalize to if (secondBlock) { await selectTextInBlock(secondBlock, 'strong'); } await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); // Verify bold tool shows active state const boldTool = document.querySelector('[data-blok-item-name="bold"]'); expect(boldTool).toHaveAttribute('data-blok-popover-item-active', 'true'); }, TIMEOUT_ACTION ); }); }, }; /** * Italic tool active state when selected text is italic. */ export const ItalicActive: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); expect(blocks.length).toBeGreaterThan(1); }, TIMEOUT_INIT ); await waitForToolbar(canvasElement); }); await step('Select italic text to show active state', async () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); const secondBlock = blocks[1]; // Use 'em' selector - browsers normalize to if (secondBlock) { await selectTextInBlock(secondBlock, 'em'); } await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); // Verify italic tool shows active state const italicTool = document.querySelector('[data-blok-item-name="italic"]'); expect(italicTool).toHaveAttribute('data-blok-popover-item-active', 'true'); }, TIMEOUT_ACTION ); }); }, }; /** * Link tool active state when cursor is in a link. */ export const LinkActive: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); expect(blocks.length).toBeGreaterThan(2); }, TIMEOUT_INIT ); await waitForToolbar(canvasElement); }); await step('Select link text to show active/unlink state', async () => { const blocks = canvasElement.querySelectorAll(BLOCK_TESTID); const thirdBlock = blocks[2]; if (thirdBlock) { await selectTextInBlock(thirdBlock, 'a'); } await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); // Verify link tool shows active state const linkTool = document.querySelector(LINK_TOOL_SELECTOR); expect(linkTool).toHaveAttribute('data-blok-popover-item-active', 'true'); }, TIMEOUT_ACTION ); }); }, }; /** * Link input field shown when clicking the link tool. */ export const LinkInputShown: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor and toolbar to initialize', async () => { await waitFor( () => { const block = canvasElement.querySelector(BLOCK_TESTID); expect(block).toBeInTheDocument(); }, TIMEOUT_INIT ); // Wait for toolbar to be created (happens in requestIdleCallback) await waitForToolbar(canvasElement); }); await step('Select text to show inline toolbar', async () => { const block = canvasElement.querySelector(BLOCK_TESTID); const contentEditable = block?.querySelector(CONTENTEDITABLE_SELECTOR); if (contentEditable) { // Focus the element first simulateClick(contentEditable); // Create a proper selection using the same pattern as other working tests const range = document.createRange(); const textNode = contentEditable.firstChild; if (textNode && textNode.nodeType === Node.TEXT_NODE) { // Select "Select this" (11 characters) range.setStart(textNode, 0); range.setEnd(textNode, 11); } else { // Fallback: select all content range.selectNodeContents(contentEditable); } const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); // Focus the element to ensure selection is active (contentEditable as HTMLElement).focus(); // Dispatch selectionchange event document.dispatchEvent(new Event('selectionchange')); } // Wait for the debounced selection handler (180ms) plus popover creation await new Promise((resolve) => setTimeout(resolve, 300)); await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); }, TIMEOUT_ACTION ); // Verify the toolbar has actual popover content (not just the empty wrapper) await waitForInlineToolbarOpen(); }); await step('Click link tool to show input', async () => { // The link tool should now be available const linkTool = document.querySelector(LINK_TOOL_SELECTOR); expect(linkTool).toBeInTheDocument(); if (linkTool) { // Click the link tool (linkTool as HTMLElement).click(); } // Wait for nested popover to appear await waitFor( () => { // The input is inside a nested popover const linkInput = document.querySelector(INLINE_TOOL_INPUT_TESTID); expect(linkInput).toBeInTheDocument(); }, TIMEOUT_ACTION ); }); }, }; /** * Convert-to dropdown opened in inline toolbar. */ export const ConvertToDropdownOpen: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const block = canvasElement.querySelector(BLOCK_TESTID); expect(block).toBeInTheDocument(); }, TIMEOUT_INIT ); await waitForToolbar(canvasElement); }); await step('Select text to show inline toolbar', async () => { const block = canvasElement.querySelector(BLOCK_TESTID); const contentEditable = block?.querySelector(CONTENTEDITABLE_SELECTOR); if (contentEditable) { simulateClick(contentEditable); selectText(contentEditable, 0, 11); (contentEditable as HTMLElement).focus(); document.dispatchEvent(new Event('selectionchange')); } // Wait for the debounced selection handler (180ms) plus popover creation time await new Promise((resolve) => setTimeout(resolve, 300)); await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); }, TIMEOUT_ACTION ); }); await step('Click convert-to button to open nested popover', async () => { // Wait for the convert-to button to appear (it's rendered async after inline toolbar opens) await waitFor( () => { const convertToButton = document.querySelector(CONVERT_TO_SELECTOR); expect(convertToButton).toBeInTheDocument(); }, TIMEOUT_ACTION ); const convertToButton = document.querySelector(CONVERT_TO_SELECTOR); if (convertToButton) { // Use simulateClick helper which dispatches mousedown, mouseup, and click events // This is needed for proper popover event handling simulateClick(convertToButton); } // Wait a bit for the popover animation await new Promise((resolve) => setTimeout(resolve, 100)); await waitFor( () => { // The nested popover should be opened const nestedPopover = document.querySelector(NESTED_POPOVER_SELECTOR + POPOVER_OPENED_SELECTOR); expect(nestedPopover).toBeInTheDocument(); }, TIMEOUT_ACTION ); }); }, }; /** * Inline toolbar tool hover state. * Note: Adds --force-hover class to bypass @media (hover: hover) in headless browsers. */ export const ToolHoverState: Story = { args: { data: sampleData, }, parameters: { chromatic: { delay: 500 }, }, play: async ({ canvasElement, step }) => { await step('Wait for editor to initialize', async () => { await waitFor( () => { const block = canvasElement.querySelector(BLOCK_TESTID); expect(block).toBeInTheDocument(); }, TIMEOUT_INIT ); await waitForToolbar(canvasElement); }); await step('Select text to show inline toolbar', async () => { const block = canvasElement.querySelector(BLOCK_TESTID); const contentEditable = block?.querySelector(CONTENTEDITABLE_SELECTOR); if (contentEditable) { simulateClick(contentEditable); selectText(contentEditable, 0, 11); (contentEditable as HTMLElement).focus(); document.dispatchEvent(new Event('selectionchange')); } // Wait for the debounced selection handler (180ms) plus popover creation time await new Promise((resolve) => setTimeout(resolve, 300)); await waitFor( () => { const inlineToolbar = document.querySelector(INLINE_TOOLBAR_TESTID); expect(inlineToolbar).toBeInTheDocument(); }, TIMEOUT_ACTION ); }); await step('Hover over bold tool', async () => { // Wait for popover to open AND for pointer-events to be enabled await waitForPointerEvents(`${INLINE_TOOLBAR_TESTID} [data-blok-testid="popover-container"]`); // Small delay for CSS animation to complete await new Promise((resolve) => setTimeout(resolve, 150)); // Find the bold tool specifically by its data attribute const boldTool = document.querySelector('[data-blok-item-name="bold"]'); if (boldTool) { // Add force-hover state to show hover styles in headless browsers boldTool.setAttribute('data-blok-force-hover', 'true'); await userEvent.hover(boldTool); } }); }, };