ui: Refactor data-attrs constants, enum for bool strings (#27002)

* refactor: Data-attribute constants + boolean string enum

* refactor: Use CSS class string constants

* refactor: Address review comments
This commit is contained in:
Aleksander Grygier
2026-08-13 20:01:12 +02:00
committed by GitHub
parent fa4ec4590c
commit bdffafa5df
40 changed files with 280 additions and 189 deletions
@@ -5,6 +5,7 @@
ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
import { UI_DATA_ATTRS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import {
createBase64DataUrl,
@@ -90,7 +91,7 @@
const index = currentIndex;
setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
const thumbnail = document.querySelector(`[${UI_DATA_ATTRS.THUMBNAIL_INDEX}="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 0);
@@ -1,7 +1,7 @@
<script lang="ts">
import { FileText, Music, Video } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
interface PreviewItem {
id: string;
@@ -36,7 +36,7 @@
<HorizontalScrollCarousel class="max-w-full">
{#each items as item, index (item.id)}
<button
data-thumbnail-index={index}
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
@@ -109,7 +109,7 @@
}: Props = $props();
// Component References
// Shared handle of the two input renderers (textarea + contenteditable).
// Shared handle of the two input renderers (plain textarea + rich chat form input).
type ChatInputHandle = {
focus(): void;
resetHeight(): void;
@@ -125,11 +125,11 @@
$state(undefined);
let inputRef: ChatInputHandle | undefined = $state(undefined);
// Render-mode gate: the plain textarea by default, the contenteditable
// Render-mode gate: the plain textarea by default, the rich chat form input
// while the buffer carries a `file://` mention link or a complete code
// span (badges and code chips need a DOM the textarea cannot provide).
// Demotes back once neither remains.
let useContenteditable = $state(false);
let useRichInput = $state(false);
// Audio Recording State
let isRecording = $state(false);
@@ -241,16 +241,15 @@
}
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return;
if (useRichInput === wantRichInput) return;
if (!caretOffsetPinned) {
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
}
useContenteditable = wantContenteditable;
useRichInput = wantRichInput;
queueCaretRestore();
});
@@ -314,7 +313,7 @@
// Caret inside a fenced code block (closed, or still open
// while being typed): Enter adds a line, never submits. The
// contenteditable consumes this case locally; this gate
// rich chat form input consumes this case locally; this gate
// covers the plain textarea, where skipping submit lets the
// native newline through.
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
@@ -507,9 +506,9 @@
value = built.newValue;
onValueChange?.(built.newValue);
// Already in contenteditable mode: no renderer flip, so the swap
// Already in rich chat form input mode: no renderer flip, so the swap
// effect's caret restore never runs.
if (useContenteditable) {
if (useRichInput) {
queueCaretRestore();
}
}
@@ -614,7 +613,7 @@
onPaste={handlePaste}
{disabled}
{placeholder}
{useContenteditable}
{useRichInput}
/>
{#if mcpResourceStore.hasAttachments}
@@ -4,7 +4,7 @@
import { FolderOpen } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH } from '$lib/constants';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
@@ -120,7 +120,7 @@
});
useScrollActiveRow({
dataIndex: 'result',
dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
getContainer: () => listContainer,
getCount: () => queryResults.length,
getIndex: () => nav.hoveredIndex,
@@ -1,6 +1,7 @@
<script lang="ts">
import { Folder } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { UI_DATA_ATTRS } from '$lib/constants';
import { highlightMatch } from '$lib/utils';
import { fly } from 'svelte/transition';
@@ -46,7 +47,7 @@
{#each results as path, index (path)}
<button
type="button"
data-result-index={index}
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
data-highlighted={index === hoveredIndex ? '' : undefined}
class={cn(
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
@@ -10,7 +10,7 @@
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
useContenteditable?: boolean;
useRichInput?: boolean;
}
let {
@@ -20,7 +20,7 @@
onKeydown,
onPaste,
placeholder = 'Ask anything...',
useContenteditable = false,
useRichInput = false,
value = $bindable('')
}: Props = $props();
@@ -30,32 +30,30 @@
// The two renderers share one imperative handle (focus/caret/height), so
// the parent can drive whichever variant is mounted through this one.
export function getElement() {
return useContenteditable ? richRef?.getElement() : basicRef?.getElement();
return useRichInput ? richRef?.getElement() : basicRef?.getElement();
}
export function focus() {
if (useContenteditable) richRef?.focus();
if (useRichInput) richRef?.focus();
else basicRef?.focus();
}
export function resetHeight() {
if (useContenteditable) richRef?.resetHeight();
if (useRichInput) richRef?.resetHeight();
else basicRef?.resetHeight();
}
export function getCaretOffset(): number {
return useContenteditable
? (richRef?.getCaretOffset() ?? 0)
: (basicRef?.getCaretOffset() ?? 0);
return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
}
export function setCaretOffset(offset: number) {
if (useContenteditable) richRef?.setCaretOffset(offset);
if (useRichInput) richRef?.setCaretOffset(offset);
else basicRef?.setCaretOffset(offset);
}
</script>
{#if useContenteditable}
{#if useRichInput}
<ChatFormInputRich
bind:this={richRef}
class={className}
@@ -48,7 +48,7 @@
}
}
// Plain-text caret offsets, shared with the contenteditable variant so
// Plain-text caret offsets, shared with the rich chat form input variant so
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
@@ -1,6 +1,6 @@
<script lang="ts">
import { CODE_BLOCK } from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores';
import type { ChatFormInputRichToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
@@ -53,7 +53,7 @@
// browser's native undo stack.
const history = new SourceHistory();
// Browsers disagree on what an empty contenteditable contains (`<br>`,
// Browsers disagree on what an empty rich chat form input contains (`<br>`,
// `<div><br></div>`, or nothing), so emptiness is decided by the
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
@@ -61,7 +61,7 @@
const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
}
function renderTokens(tokens: ChatFormInputRichToken[]) {
@@ -69,7 +69,7 @@
const caret = rangeToTextOffset(rootElement, safeRange());
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.replaceChildren(buildFragment(tokens));
syncCodeBlockHatches(rootElement);
@@ -127,7 +127,9 @@
}
function highlightCodeBlocks(root: HTMLElement) {
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="code_block"]')) {
for (const el of root.querySelectorAll<HTMLElement>(
`code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
)) {
highlightCodeBlockElement(el);
}
}
@@ -151,7 +153,10 @@
}
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
if (
node instanceof HTMLElement &&
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
) {
const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) {
@@ -189,11 +194,13 @@
* (deduped via the data attribute) swapped on mode change.
*/
function loadHighlightTheme(isDark: boolean) {
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
document
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
.forEach((s) => s.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
@@ -311,7 +318,7 @@
source[source.length - 2] !== '\n' &&
last?.nodeType === Node.TEXT_NODE
) {
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.appendChild(document.createTextNode('\n'));
restoreCaret(source.length);
resizeHeight();
@@ -404,7 +411,10 @@
let node: Node | null = container.parentNode;
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') {
if (
node instanceof HTMLElement &&
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
) {
const tail = document.createRange();
tail.setStart(container, offset);
@@ -462,7 +472,11 @@
const first = rootElement.firstChild;
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'code_block') return false;
if (
!(first instanceof HTMLElement) ||
first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
)
return false;
const range = safeRange();
@@ -483,7 +497,7 @@
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
@@ -507,7 +521,11 @@
const second = first.nextSibling;
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'code_block') return;
if (
!(second instanceof HTMLElement) ||
second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
)
return;
const range = safeRange();
const onHatch =
@@ -1,7 +1,7 @@
<script lang="ts" generics="T">
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import type { Snippet } from 'svelte';
@@ -55,7 +55,7 @@
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
dataIndex: 'picker',
dataAttr: UI_DATA_ATTRS.PICKER_INDEX,
getContainer: () => listContainer,
getCount: () => items.length,
getIndex: () => selectedIndex,
@@ -1,4 +1,5 @@
<script lang="ts">
import { UI_DATA_ATTRS } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
@@ -24,7 +25,7 @@
<button
type="button"
data-picker-index={dataIndex}
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
{disabled}
{onclick}
{onmouseenter}
@@ -120,7 +120,7 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormInput (a plain textarea, or a contenteditable for
* - Composes ChatFormInput (a plain textarea, or a ChatFormInputRich for
* messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
@@ -268,10 +268,10 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
/**
* The message editor. Renders a plain auto-resizing textarea by default,
* or a contenteditable that renders `[name](file://...)` mention links as
* or a ChatFormInputRich that renders `[name](file://...)` mention links as
* inline chips (keeping the value as the markdown source string) once a
* mention link lands in the buffer. The variant is selected via the
* `useContenteditable` prop; both share one imperative handle.
* `useRichInput` prop; both share one imperative handle.
*/
export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte';
@@ -384,14 +384,14 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
* tool, scoped to the conversation cwd (or server home when unset).
* Selection splices a `[name](file:///<abs path>)` link into the input.
*/
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
export { default as ChatFormPickerMention } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
/**
* `/`-triggered slash-command picker. Lists the available slash commands
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
* hands the command to the parent for dispatch.
*/
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte';
export { default as ChatFormPickerCommand } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte';
/**
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)