/**
* Placeholder utilities for contenteditable elements
*
* Provides unified handling for:
* - Placeholder styling classes
* - Caret positioning when content is empty
* - Focus and input event handlers
*
* @license MIT
*/
/**
* Every array below pairs its `before:content-[attr(…)]` with
* `before:inline-block` + `before:w-0` + `before:whitespace-nowrap`. The
* placeholder pseudo shares the start of the first line box with the caret, so
* an in-flow one consumes real inline advance and the caret is painted past the
* placeholder text whenever its anchor owns a layout box — a block holding the
* mark engine's pending-format `` still reads as empty (Dom.isNodeEmpty
* strips it), still shows the placeholder, and put the caret at the end of it in
* Chromium, Firefox and WebKit alike. Zero width keeps the text painted (it
* overflows the box) while claiming no advance.
* Enforced by test/unit/architecture/placeholder-layout-inert-law.test.ts.
*/
/**
* Placeholder styling classes using Tailwind arbitrary variants.
* Applied to ::before pseudo-element only when element is empty.
* Uses data-placeholder attribute for the placeholder text.
*/
export const PLACEHOLDER_CLASSES: string[] = [
'empty:before:pointer-events-none',
'empty:before:text-block-placeholder',
'empty:before:cursor-text',
'empty:before:content-[attr(data-placeholder)]',
'empty:before:inline-block',
'empty:before:w-0',
'empty:before:whitespace-nowrap',
'data-[blok-empty=true]:before:pointer-events-none',
'data-[blok-empty=true]:before:text-block-placeholder',
'data-[blok-empty=true]:before:cursor-text',
'data-[blok-empty=true]:before:content-[attr(data-placeholder)]',
'data-[blok-empty=true]:before:inline-block',
'data-[blok-empty=true]:before:w-0',
'data-[blok-empty=true]:before:whitespace-nowrap',
];
/**
* Alternative placeholder classes using data-blok-placeholder-active attribute.
* Used by some tools that need a different attribute name.
*/
export const PLACEHOLDER_ACTIVE_CLASSES: string[] = [
'empty:before:pointer-events-none',
'empty:before:text-block-placeholder',
'empty:before:cursor-text',
'empty:before:content-[attr(data-blok-placeholder-active)]',
'empty:before:inline-block',
'empty:before:w-0',
'empty:before:whitespace-nowrap',
'data-[empty=true]:before:pointer-events-none',
'data-[empty=true]:before:text-block-placeholder',
'data-[empty=true]:before:cursor-text',
'data-[empty=true]:before:content-[attr(data-blok-placeholder-active)]',
'data-[empty=true]:before:inline-block',
'data-[empty=true]:before:w-0',
'data-[empty=true]:before:whitespace-nowrap',
];
/**
* Placeholder classes that only show placeholder when element is focused.
* Uses data-blok-placeholder-active attribute and only displays when element is empty AND focused.
* Used by paragraph tool.
*/
export const PLACEHOLDER_FOCUS_ONLY_CLASSES: string[] = [
'empty:focus:before:pointer-events-none',
'empty:focus:before:text-block-placeholder',
'empty:focus:before:cursor-text',
'empty:focus:before:content-[attr(data-blok-placeholder-active)]',
'empty:focus:before:inline-block',
'empty:focus:before:w-0',
'empty:focus:before:whitespace-nowrap',
'[&[data-empty=true]:focus]:before:pointer-events-none',
'[&[data-empty=true]:focus]:before:text-block-placeholder',
'[&[data-empty=true]:focus]:before:cursor-text',
'[&[data-empty=true]:focus]:before:content-[attr(data-blok-placeholder-active)]',
'[&[data-empty=true]:focus]:before:inline-block',
'[&[data-empty=true]:focus]:before:w-0',
'[&[data-empty=true]:focus]:before:whitespace-nowrap',
];
/**
* Placeholder visibility policy — the single vocabulary that selects which
* class array (and CSS `::before` visibility rule) a consumer wants:
*
* - `always` — visible whenever empty (uses `data-placeholder`)
* - `always-active` — visible whenever empty (uses `data-blok-placeholder-active`)
* - `focus` — visible only when empty AND focused
*/
export type PlaceholderVisibility = 'always' | 'always-active' | 'focus';
/**
* Resolve the placeholder class array for a given visibility policy.
*
* Consolidates the near-duplicate class-array variants behind one vocabulary
* keyed on {@link PlaceholderVisibility}. The class strings remain literal
* (Tailwind scans the source for them) — this is the single lookup seam
* callers use instead of importing the individual constants.
*
* @param visibility - when the placeholder should be shown
* @returns the matching class array
*/
export const getPlaceholderClasses = (visibility: PlaceholderVisibility): string[] => {
switch (visibility) {
case 'always-active':
return PLACEHOLDER_ACTIVE_CLASSES;
case 'focus':
return PLACEHOLDER_FOCUS_ONLY_CLASSES;
case 'always':
default:
return PLACEHOLDER_CLASSES;
}
};
/**
* Check if an element's content is empty
*
* @param element - The element to check
* @returns true if the element is empty or contains only a
*/
export const isContentEmpty = (element: HTMLElement): boolean => {
const content = element.innerHTML.trim();
return content === '' || content === '
';
};
/**
* Set caret to the start of an element.
* Clears any
tags to ensure clean empty state for placeholder.
*
* @param element - The element to set caret in
*/
export const setCaretToStart = (element: HTMLElement): void => {
// Clear any
tags to ensure clean empty state for placeholder
if (element.innerHTML === '
') {
// eslint-disable-next-line no-param-reassign
element.innerHTML = '';
}
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
};
/**
* Handler that sets caret to start when element is empty.
* Used for focus and input events.
*
* @param element - The element to handle
*/
const handleEmptyElement = (element: HTMLElement): void => {
if (!isContentEmpty(element)) return;
setCaretToStart(element);
};
/**
* Roles that support `aria-placeholder` (ARIA 1.2: it is supported only on
* textbox, searchbox and combobox — it is NOT a global attribute).
*/
const ARIA_PLACEHOLDER_ROLES = new Set(['textbox', 'searchbox', 'combobox']);
/**
* Whether the host's role permits `aria-placeholder`.
*
* `contenteditable` does NOT give an element a textbox role: a `