合并 master 的滑动条样式改造
音效面板跟随 master 从 form 提交改为按钮 onClick,保留 canonical soundEffectDialog 与 canGenerate 守卫。 音效锁定态断言改读 aria-readonly,适配 AutoGrowTextArea 改用 Lexical contentEditable。
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import {
|
||||
$createLineBreakNode,
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
getNearestEditorFromDOMNode,
|
||||
} from 'lexical';
|
||||
|
||||
export function setPlainTextEditorValue(
|
||||
contentEditable: HTMLElement,
|
||||
value: string,
|
||||
) {
|
||||
const editor = getNearestEditorFromDOMNode(contentEditable);
|
||||
if (!editor) {
|
||||
throw new Error('Expected a Lexical editor root');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
editor.update(
|
||||
() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
value.split('\n').forEach((line, index, lines) => {
|
||||
if (line) {
|
||||
paragraph.append($createTextNode(line));
|
||||
}
|
||||
if (index < lines.length - 1) {
|
||||
paragraph.append($createLineBreakNode());
|
||||
}
|
||||
});
|
||||
$getRoot().clear().append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
},
|
||||
{ discrete: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlainTextEditorHost(contentEditable: HTMLElement) {
|
||||
const host = contentEditable.closest<HTMLElement>(
|
||||
'.auto-grow-text-area',
|
||||
);
|
||||
if (!host) {
|
||||
throw new Error('Expected an auto-grow plain-text editor host');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
@@ -1,288 +1,175 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
beforeEach(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => new DOMRect());
|
||||
Range.prototype.getClientRects = vi.fn(() => [] as unknown as DOMRectList);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'ClipboardEvent',
|
||||
class ClipboardEvent extends Event {
|
||||
clipboardData: DataTransfer;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
init: EventInit & { clipboardData: DataTransfer },
|
||||
) {
|
||||
super(type, init);
|
||||
this.clipboardData = init.clipboardData;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function pastePlainText(editor: HTMLElement, value: string) {
|
||||
const clipboardData = {
|
||||
files: [],
|
||||
getData: (type: string) => (type === 'text/plain' ? value : ''),
|
||||
items: [],
|
||||
types: ['text/plain'],
|
||||
} as unknown as DataTransfer;
|
||||
fireEvent(
|
||||
editor,
|
||||
new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledEditor({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<>
|
||||
<AutoGrowTextArea
|
||||
aria-label="受控编辑器"
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
<output aria-label="React 文本状态">{value}</output>
|
||||
<button type="button" onClick={() => setValue('外部\n更新')}>
|
||||
外部更新
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AutoGrowTextArea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
it('keeps React state authoritative and preserves exact line breaks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor initialValue={'第一行\n第二行'} />);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'第一行\n第二行',
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.getAttribute('rows')).toBe('1');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(input.className).toContain('disabled:opacity-60');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新内容' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: '外部更新' }));
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe(
|
||||
'外部\n更新',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
it('publishes edits through onValueChange and enforces maxLength', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor maxLength={4} />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '12345');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('1234'),
|
||||
);
|
||||
expect(editor.textContent).toBe('1234');
|
||||
});
|
||||
|
||||
it('supports undo and redo while keeping React state synchronized', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledEditor />);
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: '受控编辑器' });
|
||||
await user.click(editor);
|
||||
pastePlainText(editor, '草稿');
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿'),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
await user.keyboard('{Control>}z{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('');
|
||||
|
||||
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
|
||||
expect(screen.getByLabelText('React 文本状态').textContent).toBe('草稿');
|
||||
});
|
||||
|
||||
it('exposes read-only and disabled accessibility without form controls', () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="只读编辑器"
|
||||
value="只读内容"
|
||||
disabled
|
||||
readOnly
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 82 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
const editor = screen.getByRole('textbox', { name: '只读编辑器' });
|
||||
expect(editor.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(editor.getAttribute('aria-readonly')).toBe('true');
|
||||
expect(editor.getAttribute('tabindex')).toBe('-1');
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
expect(container.querySelector('form')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a distinct internal viewport for overflow content', async () => {
|
||||
const { container } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
aria-label="滚动编辑器"
|
||||
value={Array.from({ length: 20 }, (_, index) => `第 ${index + 1} 行`).join(
|
||||
'\n',
|
||||
)}
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
const viewport = await waitFor(() => {
|
||||
const element = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-viewport]',
|
||||
);
|
||||
expect(element).not.toBeNull();
|
||||
return element!;
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 30;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="短内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
const content = container.querySelector<HTMLElement>(
|
||||
'[data-overlayscrollbars-content]',
|
||||
);
|
||||
expect(input.style.height).toBe('80px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(viewport).not.toBe(content);
|
||||
expect(
|
||||
viewport.contains(
|
||||
screen.getByRole('textbox', { name: '滚动编辑器' }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
|
||||
const onInput = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="非受控自动增长输入框"
|
||||
defaultValue="初始内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onInput={onInput}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'非受控自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 90;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
fireEvent.input(input, {
|
||||
target: { value: '第一行\n第二行\n第三行\n第四行' },
|
||||
});
|
||||
expect(input.style.height).toBe('92px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
fireEvent.input(input, {
|
||||
target: { value: '很多行内容'.repeat(30) },
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 20;
|
||||
fireEvent.input(input, { target: { value: '短内容' } });
|
||||
expect(input.style.height).toBe('40px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(onInput).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('measures content-box height without double-counting padding or borders', () => {
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'内容盒输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 120;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 122,
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 120,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('104px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 160;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="更多多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user