Two non-text-contrast failures, both flagged by Leonie #5621: 1. PersonMentionEditor mention pill: decoration-brand-mint (#A6DAD8) on white is ≈1.7:1 — fails the 3:1 minimum for meaningful UI indicators. Switch to decoration-ink/50, which matches the read-mode .person-mention rule (≈6.4:1) and keeps a unified underline language across modes. 2. MentionDropdown highlighted-row ring: ring-brand-mint on bg-brand-mint/20 is ≈2.5:1 — same failure class. Switch to ring-brand-navy (≈14.5:1 against the highlight background) so keyboard-driven selection has a clearly visible indicator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
266 lines
8.0 KiB
Svelte
266 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
|
import { Editor } from '@tiptap/core';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import { Mention } from '@tiptap/extension-mention';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import type { components } from '$lib/generated/api';
|
|
import type { PersonMention } from '$lib/types';
|
|
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
|
|
import MentionDropdown from './MentionDropdown.svelte';
|
|
|
|
type Person = components['schemas']['Person'];
|
|
|
|
type Props = {
|
|
value: string;
|
|
mentionedPersons: PersonMention[];
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
onfocus?: () => void;
|
|
onblur?: () => void;
|
|
onSelectionChange?: (text: string | null) => void;
|
|
};
|
|
|
|
let {
|
|
value = $bindable(''),
|
|
mentionedPersons = $bindable([]),
|
|
placeholder = '',
|
|
disabled = false,
|
|
onfocus,
|
|
onblur,
|
|
onSelectionChange
|
|
}: Props = $props();
|
|
|
|
let editorEl: HTMLDivElement;
|
|
let editor: Editor | null = null;
|
|
|
|
// Single reactive state object shared with MentionDropdown. Mutating these
|
|
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
|
// this is required because Svelte 5's `mount()` does NOT return prop
|
|
// accessors; setting `instance.items = ...` does not update the component.
|
|
let dropdownState = $state<{
|
|
items: Person[];
|
|
command: (item: Person) => void;
|
|
clientRect: (() => DOMRect | null) | null;
|
|
}>({
|
|
items: [],
|
|
command: () => {},
|
|
clientRect: null
|
|
});
|
|
|
|
type DropdownExports = {
|
|
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
};
|
|
|
|
onMount(() => {
|
|
// Custom Mention node: uses personId / displayName instead of the
|
|
// default id / label attribute names so the mentionSerializer can
|
|
// round-trip correctly without attribute remapping.
|
|
const CustomMention = Mention.extend({
|
|
addAttributes() {
|
|
return {
|
|
personId: {
|
|
default: null,
|
|
parseHTML: (el) => el.getAttribute('data-person-id'),
|
|
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
|
|
},
|
|
displayName: {
|
|
default: null,
|
|
parseHTML: (el) => el.getAttribute('data-display-name'),
|
|
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
|
|
},
|
|
mentionSuggestionChar: {
|
|
default: '@',
|
|
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
|
|
renderHTML: (attrs) => ({
|
|
'data-mention-suggestion-char': attrs.mentionSuggestionChar
|
|
})
|
|
}
|
|
};
|
|
}
|
|
});
|
|
|
|
editor = new Editor({
|
|
element: editorEl,
|
|
// Initial editable state honors the `disabled` prop. The reactive
|
|
// $effect below keeps it in sync if the prop flips after mount —
|
|
// without this, a keyboard user can tab into the contenteditable
|
|
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
|
editable: !disabled,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: false,
|
|
bold: false,
|
|
italic: false,
|
|
strike: false,
|
|
code: false,
|
|
blockquote: false,
|
|
codeBlock: false,
|
|
bulletList: false,
|
|
orderedList: false,
|
|
hardBreak: false,
|
|
horizontalRule: false
|
|
}),
|
|
CustomMention.configure({
|
|
renderHTML({ node }) {
|
|
// Underline color matches the read-mode .person-mention rule
|
|
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
|
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
|
return [
|
|
'span',
|
|
{
|
|
'data-type': 'mention',
|
|
'data-person-id': node.attrs.personId,
|
|
'data-display-name': node.attrs.displayName,
|
|
class:
|
|
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
|
},
|
|
`@${node.attrs.displayName}`
|
|
];
|
|
},
|
|
renderText({ node }) {
|
|
return `@${node.attrs.displayName}`;
|
|
},
|
|
suggestion: {
|
|
char: '@',
|
|
items: async ({ query }: { query: string }) => {
|
|
if (!query) return [];
|
|
try {
|
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
|
if (!res.ok) return [];
|
|
return ((await res.json()) as Person[]).slice(0, 5);
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
|
command({ editor: ed, range, props }) {
|
|
const p = props as unknown as { personId: string; displayName: string };
|
|
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
|
|
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
|
|
ed.chain()
|
|
.focus()
|
|
.insertContentAt(range, [
|
|
{
|
|
type: 'mention',
|
|
attrs: { personId: p.personId, displayName: p.displayName }
|
|
},
|
|
{ type: 'text', text: ' ' }
|
|
])
|
|
.run();
|
|
},
|
|
render() {
|
|
let component: object | null = null;
|
|
let exports: DropdownExports | null = null;
|
|
|
|
// Tiptap's SuggestionProps types `command` against the default
|
|
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
|
// personId/displayName, so we cast the renderProps locally.
|
|
type LooseRenderProps = {
|
|
items: unknown;
|
|
command: (props: { personId: string; displayName: string }) => void;
|
|
query: string;
|
|
clientRect?: (() => DOMRect | null) | null;
|
|
};
|
|
|
|
const updateState = (renderProps: LooseRenderProps) => {
|
|
dropdownState.items = renderProps.items as Person[];
|
|
// AC-1: pass typed query as displayName, not person.displayName
|
|
dropdownState.command = (item: Person) =>
|
|
renderProps.command({
|
|
personId: item.id,
|
|
displayName: renderProps.query
|
|
});
|
|
dropdownState.clientRect = renderProps.clientRect ?? null;
|
|
};
|
|
|
|
return {
|
|
onStart(renderProps) {
|
|
updateState(renderProps as unknown as LooseRenderProps);
|
|
const mounted = mount(MentionDropdown, {
|
|
target: document.body,
|
|
props: { model: dropdownState }
|
|
});
|
|
component = mounted as object;
|
|
exports = mounted as unknown as DropdownExports;
|
|
},
|
|
onUpdate(renderProps) {
|
|
updateState(renderProps as unknown as LooseRenderProps);
|
|
},
|
|
onKeyDown({ event }) {
|
|
// Escape is handled by the suggestion plugin itself.
|
|
if (event.key === 'Escape') return false;
|
|
return exports?.onKeyDown(event) ?? false;
|
|
},
|
|
onExit() {
|
|
if (component) {
|
|
unmount(component);
|
|
component = null;
|
|
exports = null;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
})
|
|
],
|
|
content: deserialize(value, mentionedPersons),
|
|
editorProps: {
|
|
attributes: {
|
|
role: 'textbox',
|
|
'aria-multiline': 'true',
|
|
'aria-label': m.transcription_editor_aria_label(),
|
|
...(placeholder ? { 'data-placeholder': placeholder } : {}),
|
|
class: [
|
|
'min-h-[120px] px-1 py-2.5',
|
|
'font-serif text-base leading-relaxed text-ink',
|
|
'focus:outline-none',
|
|
'tiptap-editor-inner'
|
|
].join(' ')
|
|
}
|
|
},
|
|
onUpdate({ editor: ed }) {
|
|
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
|
value = text;
|
|
mentionedPersons = mp;
|
|
},
|
|
onFocus() {
|
|
onfocus?.();
|
|
},
|
|
onBlur() {
|
|
onblur?.();
|
|
},
|
|
onSelectionUpdate({ editor: ed }) {
|
|
const { from, to } = ed.state.selection;
|
|
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
|
}
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
editor?.destroy();
|
|
});
|
|
|
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
|
//
|
|
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
|
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
|
// idempotence check, the effect would loop on every prop pass-through.
|
|
$effect(() => {
|
|
const shouldBeEditable = !disabled;
|
|
if (editor && editor.isEditable !== shouldBeEditable) {
|
|
editor.setEditable(shouldBeEditable);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
|
class:opacity-50={disabled}
|
|
class:pointer-events-none={disabled}
|
|
aria-disabled={disabled ? 'true' : undefined}
|
|
bind:this={editorEl}
|
|
></div>
|