fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditableAreaProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div' } = defineProps<EditableAreaProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.isEditing.value ? 'edit' : 'preview'"
:data-empty="ctx.isEmpty.value ? '' : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:style="ctx.autoResize.value ? { display: 'inline-grid' } : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditableCancelTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableCancelTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="cancel"
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
@click="ctx.cancel"
>
<slot>Cancel</slot>
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditableEditTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableEditTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="edit"
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? '' : undefined"
@click="ctx.edit"
>
<slot>Edit</slot>
</Primitive>
</template>
@@ -0,0 +1,85 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditableInputProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { nextTick, onMounted, watch } from 'vue';
import { Primitive } from '../primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'input' } = defineProps<EditableInputProps>();
const ctx = useEditableContext();
const { forwardRef, currentElement } = useForwardExpose();
function syncRef(): void {
const el = currentElement.value as HTMLInputElement | undefined;
ctx.inputRef.value = el;
}
function focusAndSelect(): void {
const el = ctx.inputRef.value;
if (!el) return;
el.focus({ preventScroll: true });
if (ctx.selectOnFocus.value) el.select();
}
onMounted(() => {
syncRef();
if (ctx.startWithEditMode.value) focusAndSelect();
});
watch(ctx.isEditing, (editing) => {
if (!editing) return;
nextTick(() => {
syncRef();
focusAndSelect();
});
});
function onInput(event: Event): void {
ctx.inputValue.value = (event.target as HTMLInputElement).value;
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
ctx.cancel();
return;
}
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.isComposing) return;
const mode = ctx.submitMode.value;
if (mode === 'enter' || mode === 'both') {
event.preventDefault();
ctx.submit();
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:value="ctx.inputValue.value"
:placeholder="ctx.placeholder.value.edit"
:disabled="ctx.disabled.value"
:readonly="ctx.readonly.value"
:maxlength="ctx.maxLength.value"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:hidden="ctx.autoResize.value ? undefined : !ctx.isEditing.value"
:style="ctx.autoResize.value ? {
all: 'unset',
gridArea: '1 / 1 / auto / auto',
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
} : undefined"
aria-label="editable input"
@input="onInput"
@keydown="onKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditablePreviewProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<EditablePreviewProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
const text = computed(() => ctx.modelValue.value || ctx.placeholder.value.preview);
const showPlaceholder = computed(() => ctx.isEmpty.value);
function onFocus(): void {
if (ctx.activationMode.value === 'focus') ctx.edit();
}
function onDoubleClick(): void {
if (ctx.activationMode.value === 'dblclick') ctx.edit();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
tabindex="0"
:hidden="ctx.autoResize.value ? undefined : ctx.isEditing.value"
:data-placeholder-shown="showPlaceholder ? '' : undefined"
:data-state="ctx.isEditing.value ? 'edit' : 'preview'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:style="ctx.autoResize.value ? {
whiteSpace: 'pre',
userSelect: 'none',
gridArea: '1 / 1 / auto / auto',
visibility: ctx.isEditing.value ? 'hidden' : undefined,
overflow: 'hidden',
textOverflow: 'ellipsis',
} : undefined"
@focusin="onFocus"
@dblclick="onDoubleClick"
>
<slot>{{ text }}</slot>
</Primitive>
</template>
@@ -0,0 +1,164 @@
<script lang="ts">
import type { EditableActivationMode, EditablePlaceholder, EditableSubmitMode } from './context';
import type { PrimitiveProps } from '../primitive';
export interface EditableRootProps extends PrimitiveProps {
/** Controlled value. Use `v-model`. */
modelValue?: string;
/** Uncontrolled initial value. @default '' */
defaultValue?: string;
/** Placeholder for edit / preview. A single string applies to both. */
placeholder?: string | EditablePlaceholder;
/** When the preview should switch to edit mode. @default 'focus' */
activationMode?: EditableActivationMode;
/** How edits are committed. @default 'blur' */
submitMode?: EditableSubmitMode;
/** Mount in edit mode. */
startWithEditMode?: boolean;
/** Select the input content on focus. */
selectOnFocus?: boolean;
/** Grid-based auto resize mode — preview and input share a grid cell. */
autoResize?: boolean;
/** Max input length. */
maxLength?: number;
/** Disabled state. */
disabled?: boolean;
/** Read-only state. */
readonly?: boolean;
}
export interface EditableRootEmits {
'update:modelValue': [value: string];
'update:state': [state: 'edit' | 'submit' | 'cancel'];
submit: [value: string];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../primitive';
import { provideEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
modelValue,
defaultValue = '',
placeholder = 'Enter text…',
activationMode = 'focus',
submitMode = 'blur',
startWithEditMode = false,
selectOnFocus = false,
autoResize = false,
maxLength,
disabled = false,
readonly = false,
} = defineProps<EditableRootProps>();
const emit = defineEmits<EditableRootEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const localValue = ref<string>(modelValue ?? defaultValue);
watch(() => modelValue, (v) => {
if (v === undefined || v === localValue.value) return;
localValue.value = v;
});
const inputValue = ref<string>(localValue.value);
const isEditing = ref<boolean>(startWithEditMode);
const inputRef = shallowRef<HTMLInputElement | undefined>();
// Keep the draft in sync when modelValue changes from outside.
watch(localValue, (v) => {
inputValue.value = v;
});
const resolvedPlaceholder = computed<EditablePlaceholder>(() =>
typeof placeholder === 'string'
? { edit: placeholder, preview: placeholder }
: placeholder,
);
const isEmpty = computed(() => localValue.value === '');
function commitModel(v: string): void {
if (v === localValue.value) return;
localValue.value = v;
emit('update:modelValue', v);
}
function edit(): void {
if (disabled || readonly) return;
inputValue.value = localValue.value;
isEditing.value = true;
emit('update:state', 'edit');
}
function cancel(): void {
isEditing.value = false;
inputValue.value = localValue.value;
emit('update:state', 'cancel');
}
function submit(): void {
commitModel(inputValue.value);
isEditing.value = false;
emit('update:state', 'submit');
emit('submit', inputValue.value);
}
function onFocusOutCapture(event: FocusEvent): void {
if (!isEditing.value) return;
const root = currentElement.value;
const next = event.relatedTarget as Node | null;
if (root && next && root.contains(next)) return;
if (submitMode === 'blur' || submitMode === 'both') submit();
else cancel();
}
provideEditableContext({
modelValue: localValue,
inputValue,
isEditing,
placeholder: resolvedPlaceholder,
isEmpty,
disabled: toRef(() => disabled),
readonly: toRef(() => readonly),
maxLength: toRef(() => maxLength),
activationMode: toRef(() => activationMode),
submitMode: toRef(() => submitMode),
selectOnFocus: toRef(() => selectOnFocus),
autoResize: toRef(() => autoResize),
startWithEditMode: toRef(() => startWithEditMode),
inputRef,
edit,
cancel,
submit,
});
</script>
<template>
<Primitive
v-bind="$attrs"
:ref="forwardRef"
:as="as"
:data-state="isEditing ? 'edit' : 'preview'"
:data-empty="isEmpty ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
@focusout.capture="onFocusOutCapture"
>
<slot
:model-value="localValue"
:is-editing="isEditing"
:is-empty="isEmpty"
:edit="edit"
:cancel="cancel"
:submit="submit"
/>
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface EditableSubmitTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableSubmitTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="submit"
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
@click="ctx.submit"
>
<slot>Submit</slot>
</Primitive>
</template>
@@ -0,0 +1,172 @@
import {
EditableArea,
EditableCancelTrigger,
EditableEditTrigger,
EditableInput,
EditablePreview,
EditableRoot,
EditableSubmitTrigger,
} from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createEditable(rootProps: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
rootProps,
{
default: () => h(EditableArea, null, {
default: () => [
h(EditablePreview),
h(EditableInput),
h(EditableEditTrigger),
h(EditableSubmitTrigger),
h(EditableCancelTrigger),
],
}),
},
);
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Editable', () => {
it('renders preview with default placeholder when empty', () => {
const w = createEditable({ placeholder: 'Click to edit' });
const preview = w.find('span');
expect(preview.text()).toBe('Click to edit');
expect(preview.attributes('data-placeholder-shown')).toBe('');
w.unmount();
});
it('renders model value in preview', () => {
const w = createEditable({ modelValue: 'Hello' });
expect(w.find('span').text()).toBe('Hello');
w.unmount();
});
it('focus activation enters edit mode', async () => {
const w = createEditable({ defaultValue: 'X', activationMode: 'focus' });
await w.find('span').trigger('focusin');
await nextTick();
const input = w.find('input').element as HTMLInputElement;
expect(input.hidden).toBe(false);
expect((w.find('span').element as HTMLElement).hidden).toBe(true);
w.unmount();
});
it('dblclick activation enters edit mode only on dblclick', async () => {
const w = createEditable({ activationMode: 'dblclick' });
await w.find('span').trigger('focusin');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
await w.find('span').trigger('dblclick');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
it('edit trigger switches to edit mode', async () => {
const w = createEditable({ activationMode: 'none' });
const buttons = w.findAll('button');
const editBtn = buttons.find(b => b.attributes('aria-label') === 'edit')!;
await editBtn.trigger('click');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
it('Enter submits when submitMode includes enter', async () => {
const value = ref('initial');
const w = mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
{
modelValue: value.value,
submitMode: 'enter',
startWithEditMode: true,
'onUpdate:modelValue': (v: string) => (value.value = v),
},
{
default: () => h(EditableArea, null, {
default: () => [h(EditablePreview), h(EditableInput)],
}),
},
);
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'changed';
await input.trigger('input');
press(input.element, 'Enter');
await nextTick();
expect(value.value).toBe('changed');
w.unmount();
});
it('Escape cancels and restores model value', async () => {
const w = createEditable({ defaultValue: 'orig', submitMode: 'enter' });
const editBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!;
await editBtn.trigger('click');
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'new';
await input.trigger('input');
press(input.element, 'Escape');
await nextTick();
expect(w.find('span').text()).toBe('orig');
w.unmount();
});
it('submit trigger emits submit', async () => {
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
const submitBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'submit')!;
await submitBtn.trigger('click');
await nextTick();
const root = w.findComponent(EditableRoot);
expect(root.emitted('update:modelValue')?.at(-1)).toEqual(['v2']);
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
w.unmount();
});
it('cancel trigger reverts draft', async () => {
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'draft';
await input.trigger('input');
const cancelBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'cancel')!;
await cancelBtn.trigger('click');
await nextTick();
expect(w.find('span').text()).toBe('v1');
expect(w.findComponent(EditableRoot).emitted('update:modelValue')).toBeUndefined();
w.unmount();
});
it('disabled blocks edit activation', async () => {
const w = createEditable({ defaultValue: 'v', disabled: true });
await w.find('span').trigger('focusin');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
w.unmount();
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { Ref, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type EditableActivationMode = 'focus' | 'dblclick' | 'none';
export type EditableSubmitMode = 'blur' | 'enter' | 'none' | 'both';
export interface EditablePlaceholder {
edit: string;
preview: string;
}
export interface EditableContext {
/** Current committed value (mirrors v-model). */
modelValue: Ref<string>;
/** Draft value bound to the input while editing. */
inputValue: Ref<string>;
/** Whether the component is in edit mode. */
isEditing: Ref<boolean>;
/** Resolved placeholder per mode. */
placeholder: Ref<EditablePlaceholder>;
/** Whether `modelValue` is empty. */
isEmpty: Ref<boolean>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
maxLength: Ref<number | undefined>;
activationMode: Ref<EditableActivationMode>;
submitMode: Ref<EditableSubmitMode>;
selectOnFocus: Ref<boolean>;
autoResize: Ref<boolean>;
startWithEditMode: Ref<boolean>;
/** Reactive ref to the `<EditableInput>` element, used for focus/select. */
inputRef: ShallowRef<HTMLInputElement | undefined>;
edit: () => void;
cancel: () => void;
submit: () => void;
}
export const {
inject: useEditableContext,
provide: provideEditableContext,
} = useContextFactory<EditableContext>('editable');
+24
View File
@@ -0,0 +1,24 @@
export { default as EditableRoot } from './EditableRoot.vue';
export { default as EditableArea } from './EditableArea.vue';
export { default as EditablePreview } from './EditablePreview.vue';
export { default as EditableInput } from './EditableInput.vue';
export { default as EditableEditTrigger } from './EditableEditTrigger.vue';
export { default as EditableSubmitTrigger } from './EditableSubmitTrigger.vue';
export { default as EditableCancelTrigger } from './EditableCancelTrigger.vue';
export {
provideEditableContext,
useEditableContext,
type EditableContext,
type EditableActivationMode,
type EditableSubmitMode,
type EditablePlaceholder,
} from './context';
export type { EditableRootProps, EditableRootEmits } from './EditableRoot.vue';
export type { EditableAreaProps } from './EditableArea.vue';
export type { EditablePreviewProps } from './EditablePreview.vue';
export type { EditableInputProps } from './EditableInput.vue';
export type { EditableEditTriggerProps } from './EditableEditTrigger.vue';
export type { EditableSubmitTriggerProps } from './EditableSubmitTrigger.vue';
export type { EditableCancelTriggerProps } from './EditableCancelTrigger.vue';