Operand

thee, sea, us.

gram: docs

> ./packages/vue/src/components/ContentControlWidgets.vue

<!--
Interactive UI for typed content controls (checkbox / dropdown / date) — the
Vue mirror of the React ContentControlWidgets. The painter draws a
`.layout-sdt-widget` trigger on each typed control; this delegates clicks on
those triggers: a checkbox toggles immediately, a dropdown opens a menu of its
list items, a date opens a date picker. Selections run through the shared
`setContentControlValueTr` (normal undoable edits that update content + state).
-->
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
import type { EditorView } from 'prosemirror-view';
import {
findContentControlsInPM,
setContentControlValueTr,
setContentControlValueAtPosTr,
addRepeatingSectionItemTr,
removeRepeatingSectionItemTr,
} from '@eigenpal/docx-editor-core/prosemirror';
import type { ContentControlValue } from '@eigenpal/docx-editor-core/agent';
const WIDGET_SELECTOR = '.layout-sdt-widget, .layout-inline-sdt-widget';
/** Parse the PM position out of a `sdt@<pos>` group id. */
function posFromGroupId(id: string | undefined): number | null {
const m = /^sdt@(\d+)$/.exec(id ?? '');
return m ? Number(m[1]) : null;
}
function posFromDataset(value: string | undefined): number | null {
if (value == null || value === '') return null;
const pos = Number(value);
return Number.isFinite(pos) ? pos : null;
}
type ControlTarget = {
tag?: string;
pos?: number;
};
function targetFromTrigger(trigger: HTMLElement): ControlTarget | null {
const pos = posFromGroupId(trigger.dataset.sdtGroupId) ?? posFromDataset(trigger.dataset.sdtPos);
const tag = trigger.dataset.sdtTag;
if (pos != null) return tag ? { pos, tag } : { pos };
return tag ? { tag } : null;
}
const props = defineProps<{
container: HTMLElement | null;
view: EditorView | null;
}>();
type Popup =
| {
kind: 'dropdown';
target: ControlTarget;
items: { displayText: string; value: string }[];
current: string;
x: number;
y: number;
}
| { kind: 'date'; target: ControlTarget; current: string; x: number; y: number };
const popup = ref<Popup | null>(null);
const popupEl = ref<HTMLElement | null>(null);
function apply(target: ControlTarget, value: ContentControlValue): void {
const view = props.view;
if (view) {
try {
const tr =
target.pos != null
? setContentControlValueAtPosTr(view.state, target.pos, value)
: target.tag
? setContentControlValueTr(view.state, { tag: target.tag }, value)
: null;
if (!tr) return;
view.dispatch(tr);
view.focus(); // return focus so keyboard (undo, typing) works after the edit
} catch {
// Locked / invalid — ignore in the UI layer.
}
}
popup.value = null;
}
function repeat(btn: HTMLElement): void {
const view = props.view;
const pos = posFromGroupId(btn.dataset.sdtGroupId);
if (!view || pos == null) return;
try {
const tr =
btn.dataset.sdtRepeat === 'add'
? addRepeatingSectionItemTr(view.state, pos)
: removeRepeatingSectionItemTr(view.state, pos);
view.dispatch(tr);
view.focus();
} catch {
// Last-item removal / invalid — ignore in the UI layer.
}
}
function onMouseDown(e: MouseEvent): void {
const t = e.target as HTMLElement;
if (t?.closest?.(WIDGET_SELECTOR) || t?.closest?.('.layout-sdt-repeat-btn')) {
e.preventDefault();
}
}
function activate(trigger: HTMLElement): void {
const view = props.view;
const kind = trigger.dataset.sdtWidget;
const target = targetFromTrigger(trigger);
if (!view || !kind || !target) return;
const control =
target.pos != null
? findContentControlsInPM(view.state.doc).find((c) => c.pos === target.pos)
: target.tag
? findContentControlsInPM(view.state.doc, { tag: target.tag })[0]
: undefined;
const rect = trigger.getBoundingClientRect();
if (kind === 'checkbox') {
apply(target, { kind: 'checkbox', checked: !control?.checked });
} else if (kind === 'dropdown') {
popup.value = {
kind: 'dropdown',
target,
items: control?.listItems ?? [],
current: control?.text ?? '',
x: rect.left,
y: rect.bottom + 2,
};
} else if (kind === 'date') {
popup.value = {
kind: 'date',
target,
current: control?.dateValue ?? '',
x: rect.left,
y: rect.bottom + 2,
};
}
}
function onClick(e: MouseEvent): void {
const repeatBtn = (e.target as HTMLElement)?.closest?.('.layout-sdt-repeat-btn') as HTMLElement | null;
if (repeatBtn) {
e.preventDefault();
e.stopPropagation();
repeat(repeatBtn);
return;
}
const trigger = (e.target as HTMLElement)?.closest?.(WIDGET_SELECTOR) as HTMLElement | null;
if (!trigger) return;
e.preventDefault();
e.stopPropagation();
activate(trigger);
}
// Keyboard activation (Enter/Space) — explicit, not reliant on native button click.
function onTriggerKeyDown(e: KeyboardEvent): void {
if (e.key !== 'Enter' && e.key !== ' ') return;
const trigger = (e.target as HTMLElement)?.closest?.(WIDGET_SELECTOR) as HTMLElement | null;
if (!trigger) return;
e.preventDefault();
activate(trigger);
}
function onDocMouseDown(e: MouseEvent): void {
if (popup.value && !popupEl.value?.contains(e.target as Node)) popup.value = null;
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') popup.value = null;
}
// (Re)bind delegated listeners when the container element changes.
let bound: HTMLElement | null = null;
watch(
() => props.container,
(el) => {
if (bound) {
bound.removeEventListener('mousedown', onMouseDown);
bound.removeEventListener('click', onClick);
bound.removeEventListener('keydown', onTriggerKeyDown);
}
bound = el ?? null;
if (bound) {
bound.addEventListener('mousedown', onMouseDown);
bound.addEventListener('click', onClick);
bound.addEventListener('keydown', onTriggerKeyDown);
}
},
{ immediate: true }
);
watch(popup, (p) => {
if (p) {
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
if (p.kind === 'dropdown') {
// Move focus into the menu (selected option, else first) for keyboard use.
void nextTick(() => {
const opts = popupEl.value?.querySelectorAll<HTMLElement>('.layout-sdt-widget-option');
if (!opts?.length) return;
([...opts].find((o) => o.getAttribute('aria-selected') === 'true') ?? opts[0]).focus();
});
}
} else {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
}
});
function onPopupKeyDown(e: KeyboardEvent): void {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const opts = [...(popupEl.value?.querySelectorAll<HTMLElement>('.layout-sdt-widget-option') ?? [])];
if (!opts.length) return;
e.preventDefault();
const i = opts.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % opts.length : (i - 1 + opts.length) % opts.length;
opts[next].focus();
}
onBeforeUnmount(() => {
if (bound) {
bound.removeEventListener('mousedown', onMouseDown);
bound.removeEventListener('click', onClick);
bound.removeEventListener('keydown', onTriggerKeyDown);
}
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
});
function onDateInput(e: Event): void {
const value = (e.target as HTMLInputElement).value;
if (value && popup.value) apply(popup.value.target, { kind: 'date', date: value });
}
</script>
<template>
<div
v-if="popup"
ref="popupEl"
class="layout-sdt-widget-popup"
:role="popup.kind === 'dropdown' ? 'listbox' : undefined"
:style="{ position: 'fixed', top: popup.y + 'px', left: popup.x + 'px', zIndex: 1000 }"
@keydown="onPopupKeyDown"
@mousedown.prevent
>
<template v-if="popup.kind === 'dropdown'">
<div v-if="popup.items.length === 0" class="layout-sdt-widget-empty">No options</div>
<button
v-for="it in popup.items"
:key="it.value"
type="button"
role="option"
:aria-selected="it.displayText === popup.current"
class="layout-sdt-widget-option"
:class="{ 'is-selected': it.displayText === popup.current }"
@click="apply(popup.target, { kind: 'dropdown', value: it.value })"
>
{{ it.displayText }}
</button>
</template>
<input
v-else
type="date"
class="layout-sdt-widget-date"
:value="popup.current"
@change="onDateInput"
/>
</div>
</template>