From 65758bf9ff05f1d22940e82d7abf7c158ec1b1fc Mon Sep 17 00:00:00 2001 From: Tom Bigelajzen Date: Mon, 23 Feb 2026 11:57:05 +0200 Subject: [PATCH 1/5] Update splittext plan with CSS, a11y, SEO, BiDi, nesting, and line detection amendments - Add base CSS strategy (injectStyles, space handling, direction detection) - Simplify ARIA to container-level only - Add SEO strategy with preserveText visually-hidden duplicate - Add BiDi/shaping injection options (bidiResolver, shaper) - Add nested option (flatten/preserve/depth) for DOM structure control - Make line detection opt-in and note binary-search algorithm improvement - Add Safari whitespace normalization test results and Playwright test case Co-authored-by: Cursor --- .../text_splitter_package_1eeee927.plan.md | 243 ++++++++++++------ 1 file changed, 165 insertions(+), 78 deletions(-) diff --git a/.cursor/plans/text_splitter_package_1eeee927.plan.md b/.cursor/plans/text_splitter_package_1eeee927.plan.md index ff63d0ba..988bd5a5 100644 --- a/.cursor/plans/text_splitter_package_1eeee927.plan.md +++ b/.cursor/plans/text_splitter_package_1eeee927.plan.md @@ -80,7 +80,8 @@ The API will have: - **Customizable `` wrappers**: All split items wrapped in `` tags with configurable classes, styles, and attributes for styling and animation - **Lazy evaluation with caching**: Split types are computed on-demand when accessed, not eagerly on invocation - **Eager split when `type` provided**: If `type` option is specified, only those types are split immediately -- **Accessibility by default**: Add ARIA attributes automatically +- **Lines are opt-in**: Line detection is expensive (layout queries); lines are only computed when explicitly requested via `type: 'lines'` or `type: [..., 'lines']`. Accessing `.lines` without having requested it returns `[]` (or throws with a helpful message). +- **Accessibility by default**: Add ARIA at container level only (see Accessibility and SEO sections) - **Revertible**: Include a `revert()` method to restore original content - **Responsive support**: Optional `autoSplit` mode that re-splits on resize/font-load - `Intl.Segmenter` **API** for locale-sensitive text segmentation to split on meaningful items (graphemes, words or sentences) in a string @@ -146,7 +147,20 @@ interface SplitTextOptions { wrapperAttrs?: Record | WrapperAttrsConfig; // Custom attributes (data-*, etc.) // Accessibility - aria?: 'auto' | 'hidden' | 'none'; // default: 'auto' + aria?: 'auto' | 'none'; // default: 'auto' + + // SEO and a11y + preserveText?: boolean; // default: true - visually-hidden duplicate for SEO and screen readers + + // Base CSS (inline-block, white-space, etc.) + injectStyles?: boolean; // default: true - auto-inject minimal base stylesheet (deduplicated via data-splittext) + + // DOM structure + nested?: 'flatten' | 'preserve' | number; // default: 'flatten' + + // BiDi and shaping (optional external algorithms) + bidiResolver?: (text: string) => Array<{ text: string; direction: 'ltr' | 'rtl' }>; + shaper?: (text: string, font: string) => string[]; // Responsive re-splitting autoSplit?: boolean; @@ -154,7 +168,7 @@ interface SplitTextOptions { // Advanced splitBy?: string; // default: ' ' (space for words) - ignore?: string[]; // selectors to skip (e.g., ['sup', 'sub']) + ignore?: string[] | ((node: Node) => boolean); // selectors to skip or predicate (e.g., ['sup', 'sub']) preserveWhitespace?: boolean; } @@ -181,11 +195,11 @@ interface WrapperAttrsConfig { } interface SplitTextResult { - // Lazy getters - split on first access, return cached on subsequent access + // Lazy getters - split on first access, return cached on subsequent access (except lines, which are opt-in) // Each element is a wrapper that can be styled/animated get chars: HTMLSpanElement[]; // Splits into characters on first access get words: HTMLSpanElement[]; // Splits into words on first access - get lines: HTMLSpanElement[]; // Splits into lines on first access + get lines: HTMLSpanElement[]; // Only computed when type included 'lines'; otherwise returns [] (or throws with helpful message) get sentences: HTMLSpanElement[]; // Splits into sentences on first access // Methods @@ -233,20 +247,20 @@ Key files to implement: - **Use** `Intl.Segmenter` **API for locale-sensitive text splitting on meaningful items** (chars, words, sentences) - Create wrapper spans with appropriate classes after detection -1. `**src/lineDetection.ts**` - Range-based line detection: +1. `**src/lineDetection.ts`\*\* - Range-based line detection: - `detectLines(element)` - Main detection function using Range API - `detectLinesFromTextNode(textNode)` - Per-node detection with `getClientRects()` - Handle Safari whitespace normalization - Support for nested elements via TreeWalker -1. `**src/accessibility.ts**`: +1. `**src/accessibility.ts`\*\*: -- Add `aria-label` with original text to container -- Add `aria-hidden="true"` to split elements -- Handle nested elements appropriately +- Operate only on the container element (the element passed to `splitText`), not on individual wrapper spans. +- When `aria: 'auto'`: add `aria-hidden="true"` to the container so the split visual content is hidden from assistive tech; expose the original text via either the visually-hidden block (when `preserveText` is true, see SEO section) or `aria-label` on the container when `preserveText` is false. +- Do not add any ARIA attributes to individual wrapper spans. -1. `**src/utils.ts**`: +1. `**src/utils.ts`\*\*: - Text segmentation (handle emoji, unicode) - DOM manipulation helpers @@ -273,7 +287,8 @@ Test coverage for: - React hook lifecycle - **Lazy evaluation**: Verify no DOM changes until getter accessed - **Caching**: Verify same array reference returned on repeated access -- **Eager split with `type**`: Verify immediate DOM changes when type provided +- **Eager split with `type`**: Verify immediate DOM changes when type provided +- **Lines opt-in**: Verify `.lines` returns `[]` when `type` did not include `'lines'`; verify line detection runs only when `type: 'lines'` or `type: [..., 'lines']` was passed - **Cache invalidation**: Verify cache cleared on `revert()` and `autoSplit` resize - Use Playwright for E2E testing all APIs that depend on DOM APIs @@ -363,6 +378,54 @@ test('data-index attributes enable staggered animations', async ({ page }) => { }); ``` +**Safari whitespace normalization test (Playwright, run on WebKit):** + +```typescript +test('Safari: getClientRects line detection requires whitespace normalization', async ({ + page, +}) => { + await page.setContent(` +

+ Hello world with extra spaces + and + newlines that collapse +

+ `); + + const { withoutNorm, withNorm } = await page.evaluate(() => { + function detectLineCount(textNode) { + const text = textNode.textContent; + const range = document.createRange(); + let maxLineIndex = 0; + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, 0); + range.setEnd(textNode, i + 1); + maxLineIndex = Math.max(maxLineIndex, range.getClientRects().length - 1); + } + return maxLineIndex + 1; + } + + const el = document.querySelector('.sample'); + const originalContent = el.firstChild!.textContent!; + + // Without normalization (raw markup whitespace) + const withoutNorm = detectLineCount(el.firstChild as Text); + + // With normalization (collapsed whitespace) + el.firstChild!.textContent = originalContent.trim().replace(/\s+/g, ' '); + const withNorm = detectLineCount(el.firstChild as Text); + + el.firstChild!.textContent = originalContent; + return { withoutNorm, withNorm }; + }); + + // After normalization both browsers should detect the same (correct) line count. + // Without normalization, Safari detects one "line" per whitespace-separated word. + // This test documents the quirk; the implementation must always normalize. + expect(withNorm).toBe(2); +}); +``` + ### Phase 5: Documentation Following the [interact docs structure](packages/interact/docs/README.md): @@ -374,14 +437,14 @@ Following the [interact docs structure](packages/interact/docs/README.md): **Additional documentation for wrapper customization:** -1. `**docs/api/types.md**` - Update with wrapper option types: +1. `**docs/api/types.md`\*\* - Update with wrapper option types: - `WrapperClassConfig` interface documentation - `WrapperStyleConfig` interface documentation - `WrapperAttrsConfig` interface documentation - Explanation of global vs per-type configuration -2. `**docs/guides/styling-wrappers.md**` - New guide covering: +1. `**docs/guides/styling-wrappers.md`\*\* - New guide covering: - Default CSS classes (`split-c`, `split-w`, etc.) - Customizing wrapper classes @@ -390,7 +453,7 @@ Following the [interact docs structure](packages/interact/docs/README.md): - Best practices for `display: inline-block` with transforms - CSS custom properties for staggered animations -3. `**docs/examples/animations.md**` - Expanded with wrapper examples: +1. `**docs/examples/animations.md`\*\* - Expanded with wrapper examples: - **Fade-in character animation** using wrapperClass + CSS - **Slide-up word reveal** using wrapperStyle initial state @@ -399,7 +462,7 @@ Following the [interact docs structure](packages/interact/docs/README.md): - **CSS-only animations** using @keyframes and animation-delay - **Intersection Observer** trigger with wrapper data attributes -4. `**docs/examples/css-animations.md**` - New CSS-focused examples: +1. `**docs/examples/css-animations.md`\*\* - New CSS-focused examples: ```css /* Example: Typewriter effect */ @@ -424,7 +487,7 @@ Following the [interact docs structure](packages/interact/docs/README.md): } ``` -5. **README.md** - Quick start section update: +1. **README.md** - Quick start section update: ```typescript import { splitText } from '@wix/splittext'; @@ -460,15 +523,15 @@ const result = splitText('.headline'); const chars = result.chars; // Splits into chars NOW, caches result const chars2 = result.chars; // Returns cached result (no re-split) -// Lines are split separately when accessed -const lines = result.lines; // Splits into lines NOW, caches result +// Lines are only computed when type included 'lines' (opt-in; expensive) +const lines = result.lines; // Returns [] unless type: 'lines' was passed // Example 2: Eager split with type option const eagerResult = splitText('.headline', { type: 'words' }); // Words are split immediately on invocation -// Other types still use lazy evaluation -const lines2 = eagerResult.lines; // Splits into lines on access +// Other types still use lazy evaluation; .lines returns [] unless type included 'lines' +const lines2 = eagerResult.lines; // [] here since only 'words' was in type // Example 3: Multiple types eager const multiResult = splitText('.headline', { type: ['chars', 'words'] }); @@ -539,7 +602,7 @@ All split items are wrapped in `` elements to enable styling and animation **Default wrapper structure:** ```html - + H e l @@ -575,6 +638,11 @@ function createWrapper( // Apply default class span.classList.add(`split-${type[0]}`); // 'chars' -> 'split-c' + // Mark space-only wrappers so they can be styled for width (e.g. white-space: pre) + if (type === 'chars' && typeof content === 'string' && /^\s+$/.test(content)) { + span.classList.add('split-space'); + } + // Apply custom classes const customClass = resolveWrapperOption(options.wrapperClass, type); if (customClass) { @@ -673,6 +741,46 @@ chars.forEach((char, i) => { }); ``` +### Base CSS Strategy + +When `injectStyles` is true (default), the package injects a minimal base stylesheet once per document via a `