275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
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 }));
|
|
}
|
|
|
|
// EditableRoot defers its outside-blur decision by a macrotask.
|
|
function waitForBlurTimers() {
|
|
return new Promise(resolve => setTimeout(resolve, 20));
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
it('keeps edit mode when the focused edit trigger hides itself (real focus)', async () => {
|
|
const w = createEditable({ defaultValue: 'v', activationMode: 'none' });
|
|
const editBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!;
|
|
(editBtn.element as HTMLButtonElement).focus();
|
|
(editBtn.element as HTMLButtonElement).click();
|
|
await nextTick();
|
|
await waitForBlurTimers();
|
|
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
|
|
expect(document.activeElement).toBe(w.find('input').element);
|
|
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
|
|
w.unmount();
|
|
});
|
|
|
|
it('keeps edit mode when the really-focused preview hides itself (focus activation)', async () => {
|
|
const w = createEditable({ defaultValue: 'v', activationMode: 'focus' });
|
|
(w.find('span').element as HTMLElement).focus();
|
|
await nextTick();
|
|
await waitForBlurTimers();
|
|
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
|
|
expect(document.activeElement).toBe(w.find('input').element);
|
|
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
|
|
w.unmount();
|
|
});
|
|
|
|
it('still submits when focus genuinely leaves the root (submitMode blur)', async () => {
|
|
const outside = document.createElement('button');
|
|
document.body.appendChild(outside);
|
|
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'blur' });
|
|
await nextTick();
|
|
const input = w.find('input');
|
|
(input.element as HTMLInputElement).focus();
|
|
(input.element as HTMLInputElement).value = 'v2';
|
|
await input.trigger('input');
|
|
outside.focus();
|
|
await waitForBlurTimers();
|
|
const root = w.findComponent(EditableRoot);
|
|
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
|
|
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
|
|
outside.remove();
|
|
w.unmount();
|
|
});
|
|
|
|
it('hides parts even when consumer display utility classes override [hidden]', async () => {
|
|
const style = document.createElement('style');
|
|
style.textContent = '.u-block { display: block; } .u-inline-flex { display: inline-flex; }';
|
|
document.head.appendChild(style);
|
|
const w = mount(
|
|
defineComponent({
|
|
setup() {
|
|
return () => h(
|
|
EditableRoot,
|
|
{ defaultValue: 'v', activationMode: 'none' },
|
|
{
|
|
default: () => h(EditableArea, null, {
|
|
default: () => [
|
|
h(EditablePreview, { class: 'u-block' }),
|
|
h(EditableInput, { class: 'u-block' }),
|
|
h(EditableEditTrigger, { class: 'u-inline-flex' }),
|
|
h(EditableSubmitTrigger, { class: 'u-inline-flex' }),
|
|
h(EditableCancelTrigger, { class: 'u-inline-flex' }),
|
|
],
|
|
}),
|
|
},
|
|
);
|
|
},
|
|
}),
|
|
{ attachTo: document.body },
|
|
);
|
|
const button = (label: string) =>
|
|
w.findAll('button').find(b => b.attributes('aria-label') === label)!.element as HTMLElement;
|
|
expect(getComputedStyle(w.find('input').element).display).toBe('none');
|
|
expect(getComputedStyle(button('submit')).display).toBe('none');
|
|
expect(getComputedStyle(button('cancel')).display).toBe('none');
|
|
expect(getComputedStyle(button('edit')).display).toBe('inline-flex');
|
|
await w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!.trigger('click');
|
|
await nextTick();
|
|
expect(getComputedStyle(w.find('span').element).display).toBe('none');
|
|
expect(getComputedStyle(button('edit')).display).toBe('none');
|
|
expect(getComputedStyle(w.find('input').element).display).toBe('block');
|
|
expect(getComputedStyle(button('submit')).display).toBe('inline-flex');
|
|
expect(getComputedStyle(button('cancel')).display).toBe('inline-flex');
|
|
style.remove();
|
|
w.unmount();
|
|
});
|
|
|
|
it('submit and cancel triggers are no-ops while not editing', async () => {
|
|
const w = createEditable({ defaultValue: 'v1' });
|
|
const submitBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'submit')!;
|
|
const cancelBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'cancel')!;
|
|
await submitBtn.trigger('click');
|
|
await cancelBtn.trigger('click');
|
|
const root = w.findComponent(EditableRoot);
|
|
expect(root.emitted('submit')).toBeUndefined();
|
|
expect(root.emitted('update:state')).toBeUndefined();
|
|
w.unmount();
|
|
});
|
|
});
|