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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user