fix(web): sketch save button shows saving and saved feedback (#2500)

* fix(web): ensure sketch save button shows saving state

The save handler completes so quickly that the saving UI state flashes
for less than a frame, making it impossible for users to perceive that
a save is actually in progress. Add a 500ms minimum delay on the save
path so the button's saving state is visible, giving users clear
feedback that their action was registered.

Fixes #109

* fix(web): show saved confirmation after sketch save completes

After a sketch save succeeds, show a checkmark icon on the save button
for 2 seconds so users get clear confirmation that the save completed,
addressing the missing success feedback reported in the issue.

Fixes #109

* test(web): add save feedback tests for SketchEditor

Cover the Save button's default, saving, disabled, and post-save
checkmark states, including the 2-second saved confirmation timeout.

Refs #109

* fix(web): gate saved checkmark on explicit save success

saveSketch now returns false on write failure (daemon down, 4xx/5xx,
disk error, dropped connection) instead of resolving normally.
handleSave checks the return value and skips the green checkmark when
the save did not persist.

Refs #109

* fix(web): make sketch save delay a minimum floor not additive

saveSketch previously added a hardcoded 500ms after writeProjectTextFile completed, making the total perceived save time writeTime + 500ms regardless of how long the real write took.

Now it measures elapsed write time and only sleeps for the remainder of 500ms — fast saves still get the minimum visible duration, but a save that takes 600ms skips the sleep entirely.

Refs #109

* fix(web): remove showSaved from sketch save button disable guard

saveSketch's handleSave already skips the save when onSave returns false, so showSaved was doubling as an accidental debounce that blocked the user from saving again for 2 seconds after each save — even when they had continued drawing during the save animation.

The saving prop from the parent and the dirty/canSave check remain in place, so the button is still disabled while the async write is in flight and when there is nothing new to persist.

Refs #109

* fix(web): clear savedTimerRef on unmount

savedTimerRef schedules a setTimeout that calls setShowSaved(false)
after the save confirmation period. When the component unmounts before
the timer fires (e.g., closing the sketch tab), the pending callback
triggers a state update on an unmounted component. Add a useEffect
cleanup to clear the timer, consistent with the existing ResizeObserver
cleanup in this component.

Refs #109

* fix(web): strip trailing whitespace on save button className line

The className="primary" attribute on the save button accumulated
trailing whitespace in the recent save feedback changes. Remove it.

Refs #109

* test(web): add sketch save minimum visibility test for FileWorkspace

Add a test that verifies the sketch save button keeps the "Saving…"
state visible for at least 500ms before transitioning to the saved
checkmark. Mocks fetchProjectFileText, writeProjectTextFile, and a
stub ResizeObserver to make the SketchEditor render cleanly.

Refs #109

* test(web): update SketchEditor save disable assertion after removing showSaved guard

a6aa82a removed showSaved from the save button disable guard, so the button no longer stays disabled for 2 seconds after each save completes. This test expected the save button to remain disabled after save resolution, which no longer matches the component behavior — the saving prop (false) and dirty/canSave check (true) leave the button enabled immediately after save.

Refs #109

* fix(web): clear saved indicator when sketch save fails

When a save succeeds (shows checkmark), then the user saves again
and the second save fails, the stale checkmark remained visible
until the original 2-second timeout expired. The handler now clears
the timer and hides the indicator immediately on failure.

Refs #109

* fix(web): clear saved checkmark when sketch goes dirty again

After a successful save, the checkmark remained visible for the full
2-second window even if the user resumed drawing during that time.
dirty would become true again while the button still showed "Saved",
contradicting the actual unsaved state.

A new useEffect watches the dirty prop and immediately clears the
timer and hides the indicator when the sketch becomes dirty again.

Refs #109

* refactor(web): fix useEffect dependency array indentation

The closing bracket of the useEffect dependency array was indented
one space too far, breaking the code style consistency in
SketchEditor.tsx.

Refs #109

* refactor(web): remove trailing blank line in FileWorkspace

A blank line with trailing whitespace was left between the useEffect
closing brace and the following if-statement, violating the file's
whitespace conventions.

Refs #109

* fix(web): give sketch save button a stable accessible name

Refs #109

* test(web): add a11y tests for sketch save button aria-label

Refs #109
This commit is contained in:
Muhammad Adnan R
2026-05-23 05:03:10 +00:00
committed by GitHub
parent 965d581990
commit de1665d0be
24 changed files with 408 additions and 5 deletions
@@ -724,7 +724,11 @@ export function FileWorkspace({
entry.discardRawItemsOnSave ? [] : entry.rawItems,
entry.items,
);
const startedAt = Date.now();
const file = await writeProjectTextFile(projectId, name, JSON.stringify(doc, null, 2));
const elapsed = Date.now() - startedAt;
// Ensures saving UI shows so the button does not flicker
if (elapsed < 500) await new Promise((resolve) => setTimeout(resolve, 500 - elapsed));
if (file) {
setSketches((curr) => ({
...curr,
@@ -745,8 +749,10 @@ export function FileWorkspace({
});
setActiveTab(name);
await onRefreshFiles();
return true;
} else {
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: false } }));
return false;
}
}
+33 -3
View File
@@ -1,8 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
import { Icon } from './Icon';
import { readDefaultSketchToolColor } from './sketch-colors';
import type { SketchItem } from './sketch-model';
const SAVED_VISIBLE_MS = 2000;
export type Tool = 'select' | 'pen' | 'text' | 'rect' | 'arrow' | 'eraser';
interface Props {
@@ -13,7 +16,7 @@ interface Props {
hasPreservedRawItems?: boolean;
onItemsChange: (items: SketchItem[]) => void;
onClear?: () => void;
onSave: () => Promise<void> | void;
onSave: () => Promise<boolean | void> | boolean | void;
onCancel?: () => void;
saving?: boolean;
dirty?: boolean;
@@ -39,6 +42,20 @@ export function SketchEditor({
const [size, setSize] = useState(2);
const drawingRef = useRef<SketchItem | null>(null);
const [, force] = useState(0);
const [showSaved, setShowSaved] = useState(false);
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => clearTimeout(savedTimerRef.current);
}, []);
useEffect(() => {
if (dirty) {
clearTimeout(savedTimerRef.current);
setShowSaved(false);
}
}, [dirty]);
// Text-tool modal. Replaces window.prompt() because Electron 28+
// disables that API by default and silently returns null, making
// the text tool a no-op in the desktop app. Same root cause as
@@ -189,6 +206,18 @@ export function SketchEditor({
textAnchorRef.current = null;
}
const handleSave = useCallback(async () => {
const ok = await onSave();
if (ok === false) {
clearTimeout(savedTimerRef.current);
setShowSaved(false);
return;
}
setShowSaved(true);
clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => setShowSaved(false), SAVED_VISIBLE_MS);
}, [onSave]);
return (
<div className="sketch-editor">
<div className="sketch-toolbar">
@@ -234,10 +263,11 @@ export function SketchEditor({
) : null}
<button
className="primary"
onClick={() => void onSave()}
onClick={handleSave}
disabled={saving || !canSave}
aria-label={saving ? t('sketch.saving') : showSaved ? t('sketch.saved') : t('common.save')}
>
{saving ? t('sketch.saving') : t('common.save')}
{saving ? t('sketch.saving') : showSaved ? <Icon name="check" size={14} /> : t('common.save')}
</button>
</div>
<div className="sketch-canvas-wrap" ref={wrapRef}>
+1
View File
@@ -929,6 +929,7 @@ export const ar: Dict = {
'sketch.save': 'حفظ الرسم',
'sketch.cancel': 'إلغاء',
'sketch.saving': 'جاري الحفظ...',
'sketch.saved': 'تم الحفظ',
'sketch.tooltipDirty': 'تغييرات غير محفوظة',
'sketch.tooltipClean': 'تم الحفظ',
'fileViewer.empty': 'اختر ملفاً لعرضه.',
+1
View File
@@ -817,6 +817,7 @@ export const de: Dict = {
'sketch.save': 'Sketch speichern',
'sketch.cancel': 'Abbrechen',
'sketch.saving': 'Speichern…',
'sketch.saved': 'Gespeichert',
'sketch.tooltipDirty': 'Ungespeicherte Änderungen',
'sketch.tooltipClean': 'Gespeichert',
'fileViewer.empty': 'Wählen Sie eine Datei zur Ansicht aus.',
+1
View File
@@ -1520,6 +1520,7 @@ export const en: Dict = {
'sketch.save': 'Save sketch',
'sketch.cancel': 'Cancel',
'sketch.saving': 'Saving…',
'sketch.saved': 'Saved',
'sketch.tooltipDirty': 'Unsaved changes',
'sketch.tooltipClean': 'Saved',
'fileViewer.empty': 'Select a file to view.',
+1
View File
@@ -818,6 +818,7 @@ export const esES: Dict = {
'sketch.save': 'Guardar boceto',
'sketch.cancel': 'Cancelar',
'sketch.saving': 'Guardando…',
'sketch.saved': 'Guardado',
'sketch.tooltipDirty': 'Cambios sin guardar',
'sketch.tooltipClean': 'Guardado',
'fileViewer.empty': 'Selecciona un archivo para verlo.',
+1
View File
@@ -953,6 +953,7 @@ export const fa: Dict = {
'sketch.save': 'ذخیره طرح',
'sketch.cancel': 'لغو',
'sketch.saving': 'در حال ذخیره…',
'sketch.saved': 'ذخیره شد',
'sketch.tooltipDirty': 'تغییرات ذخیره نشده',
'sketch.tooltipClean': 'ذخیره شد',
'fileViewer.empty': 'یک فایل را برای مشاهده انتخاب کنید.',
+1
View File
@@ -945,6 +945,7 @@ export const fr: Dict = {
'sketch.save': 'Enregistrer le croquis',
'sketch.cancel': 'Annuler',
'sketch.saving': 'Enregistrement…',
'sketch.saved': 'Enregistré',
'sketch.tooltipDirty': 'Modifications non enregistrées',
'sketch.tooltipClean': 'Enregistré',
'fileViewer.empty': 'Sélectionnez un fichier à afficher.',
+1
View File
@@ -929,6 +929,7 @@ export const hu: Dict = {
'sketch.save': 'Vázlat mentése',
'sketch.cancel': 'Mégse',
'sketch.saving': 'Mentés…',
'sketch.saved': 'Mentve',
'sketch.tooltipDirty': 'Mentetlen változtatások',
'sketch.tooltipClean': 'Mentve',
'fileViewer.empty': 'Válassz fájlt a megtekintéshez.',
+1
View File
@@ -1045,6 +1045,7 @@ export const id: Dict = {
'sketch.save': 'Simpan',
'sketch.cancel': 'Batal',
'sketch.saving': 'Menyimpan...',
'sketch.saved': 'Tersimpan',
'sketch.tooltipDirty': 'Ada perubahan belum disimpan',
'sketch.tooltipClean': 'Tersimpan',
+1
View File
@@ -844,6 +844,7 @@ export const it: Dict = {
'sketch.save': 'Salva schizzo',
'sketch.cancel': 'Annulla',
'sketch.saving': 'Salvataggio…',
'sketch.saved': 'Salvato',
'sketch.tooltipDirty': 'Modifiche non salvate',
'sketch.tooltipClean': 'Salvato',
'fileViewer.empty': 'Seleziona un file da visualizzare.',
+1
View File
@@ -816,6 +816,7 @@ export const ja: Dict = {
'sketch.save': 'スケッチを保存',
'sketch.cancel': 'キャンセル',
'sketch.saving': '保存中…',
'sketch.saved': '保存済み',
'sketch.tooltipDirty': '未保存の変更があります',
'sketch.tooltipClean': '保存済み',
'fileViewer.empty': 'ファイルを選択して表示します。',
+1
View File
@@ -929,6 +929,7 @@ export const ko: Dict = {
'sketch.save': '스케치 저장',
'sketch.cancel': '취소',
'sketch.saving': '저장 중…',
'sketch.saved': '저장됨',
'sketch.tooltipDirty': '저장되지 않은 변경 사항',
'sketch.tooltipClean': '저장됨',
'fileViewer.empty': '보려는 파일을 선택하세요.',
+1
View File
@@ -929,6 +929,7 @@ export const pl: Dict = {
'sketch.save': 'Zapisz szkic',
'sketch.cancel': 'Anuluj',
'sketch.saving': 'Zapisywanie…',
'sketch.saved': 'Zapisano',
'sketch.tooltipDirty': 'Niezapisane zmiany',
'sketch.tooltipClean': 'Zapisano',
'fileViewer.empty': 'Wybierz plik, aby go wyświetlić.',
+1
View File
@@ -952,6 +952,7 @@ export const ptBR: Dict = {
'sketch.save': 'Salvar esboço',
'sketch.cancel': 'Cancelar',
'sketch.saving': 'Salvando…',
'sketch.saved': 'Salvo',
'sketch.tooltipDirty': 'Alterações não salvas',
'sketch.tooltipClean': 'Salvo',
'fileViewer.empty': 'Selecione um arquivo para visualizar.',
+1
View File
@@ -952,6 +952,7 @@ export const ru: Dict = {
'sketch.save': 'Сохранить эскиз',
'sketch.cancel': 'Отмена',
'sketch.saving': 'Сохранение…',
'sketch.saved': 'Сохранено',
'sketch.tooltipDirty': 'Несохраненные изменения',
'sketch.tooltipClean': 'Сохранено',
'fileViewer.empty': 'Выберите файл для просмотра.',
+1
View File
@@ -870,6 +870,7 @@ export const th: Dict = {
'sketch.save': 'บันทึกรูป',
'sketch.cancel': 'ยกเลิก',
'sketch.saving': 'กำลังบันทึกรูปภาพ…',
'sketch.saved': 'บันทึกแล้ว',
'sketch.tooltipDirty': 'มีการเปลี่ยนผ่าน',
'sketch.tooltipClean': 'บันทึกสมบูรณ์',
'fileViewer.empty': 'คลิกที่ไฟล์เพื่อแสดงดู',
+1
View File
@@ -916,6 +916,7 @@ export const tr: Dict = {
'sketch.save': 'Taslağı kaydet',
'sketch.cancel': 'İptal et',
'sketch.saving': 'Kaydediliyor…',
'sketch.saved': 'Kaydedildi',
'sketch.tooltipDirty': 'Kaydedilmemiş değişiklikler',
'sketch.tooltipClean': 'Kaydedildi',
'fileViewer.empty': 'Görüntülemek için bir dosya seçin.',
+1
View File
@@ -971,6 +971,7 @@ export const uk: Dict = {
'sketch.save': 'Зберегти ескіз',
'sketch.cancel': 'Скасувати',
'sketch.saving': 'Збереження…',
'sketch.saved': 'Збережено',
'sketch.tooltipDirty': 'Незбережені зміни',
'sketch.tooltipClean': 'Збережено',
'fileViewer.empty': 'Виберіть файл для перегляду.',
+1
View File
@@ -1510,6 +1510,7 @@ export const zhCN: Dict = {
'sketch.save': '保存草图',
'sketch.cancel': '取消',
'sketch.saving': '保存中…',
'sketch.saved': '已保存',
'sketch.tooltipDirty': '尚未保存',
'sketch.tooltipClean': '已保存',
'fileViewer.empty': '请选择一个文件查看。',
+1
View File
@@ -1121,6 +1121,7 @@ export const zhTW: Dict = {
'sketch.save': '儲存草圖',
'sketch.cancel': '取消',
'sketch.saving': '儲存中…',
'sketch.saved': '已儲存',
'sketch.tooltipDirty': '尚未儲存',
'sketch.tooltipClean': '已儲存',
'fileViewer.empty': '請選擇一個檔案檢視。',
+1
View File
@@ -1836,6 +1836,7 @@ export interface Dict {
'sketch.save': string;
'sketch.cancel': string;
'sketch.saving': string;
'sketch.saved': string;
'sketch.tooltipDirty': string;
'sketch.tooltipClean': string;
'fileViewer.empty': string;
@@ -4,12 +4,16 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import { projectSplitClassName } from '../../src/components/ProjectView';
import { uploadProjectFiles } from '../../src/providers/registry';
import {
fetchProjectFileText,
uploadProjectFiles,
writeProjectTextFile,
} from '../../src/providers/registry';
import type { ProjectFile } from '../../src/types';
vi.mock('../../src/providers/registry', async () => {
@@ -18,17 +22,30 @@ vi.mock('../../src/providers/registry', async () => {
);
return {
...actual,
fetchProjectFileText: vi.fn(),
uploadProjectFiles: vi.fn(),
writeProjectTextFile: vi.fn(),
};
});
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
const mockedWriteProjectTextFile = vi.mocked(writeProjectTextFile);
let root: Root | null = null;
let host: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Needed else the ResizeObserver in SketchEditor crashes the test
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
unobserve() {}
};
});
afterEach(() => {
cleanup();
if (root) {
@@ -678,3 +695,71 @@ describe('scrollWorkspaceTabsWithWheel', () => {
expect(preventDefault).not.toHaveBeenCalled();
});
});
describe('FileWorkspace sketch save', () => {
it('keeps saving state visible for at least 500ms', async () => {
// Simulate user doing some edits in the workspace
const file: ProjectFile = {
name: 'test.sketch.json',
path: 'test.sketch.json',
type: 'file',
size: 100,
mtime: 1700000000,
kind: 'sketch',
mime: 'application/json',
};
mockedFetchProjectFileText.mockResolvedValue(
JSON.stringify({
version: 1,
items: [
{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 },
],
}),
);
mockedWriteProjectTextFile.mockResolvedValue(file);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
onTabsStateChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(document.querySelector('canvas')).not.toBeNull();
});
vi.useFakeTimers();
const btn = screen.getByText('Save') as HTMLButtonElement;
expect(btn.disabled).toBe(false);
await act(async () => {
fireEvent.click(btn);
});
expect(btn.textContent).toBe('Saving…');
expect(btn.disabled).toBe(true);
// Before the 500ms floor is reached, still saving
await act(async () => {
vi.advanceTimersByTime(400);
});
expect(btn.textContent).toBe('Saving…');
expect(btn.disabled).toBe(true);
// After 500ms total, saving should end and the checkmark should appear
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(btn.textContent).not.toBe('Saving…');
expect(btn.querySelector('svg')).not.toBeNull();
});
});
@@ -0,0 +1,262 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { SketchEditor } from '../../src/components/SketchEditor';
vi.mock('../../src/i18n', () => ({
useT: () => (key: string) => key,
}));
beforeAll(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
disconnect() {}
unobserve() {}
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
const noop = () => {};
function renderEditor(overrides: Partial<Parameters<typeof SketchEditor>[0]> = {}) {
return render(
<SketchEditor
items={[]}
onItemsChange={noop}
onSave={noop}
fileName="test.sketch.json"
{...overrides}
/>,
);
}
function saveButton(): HTMLButtonElement {
return document.querySelector('button.primary') as HTMLButtonElement;
}
describe('SketchEditor save', () => {
it('shows the Save label by default', () => {
renderEditor({ dirty: true });
expect(saveButton().textContent).toBe('common.save');
});
it('shows the saving label when saving', () => {
renderEditor({ saving: true, dirty: true });
expect(saveButton().textContent).toBe('sketch.saving');
});
it('disables the button while saving', () => {
renderEditor({ saving: true, dirty: true });
expect(saveButton().disabled).toBe(true);
});
it('disables the button when nothing is editable', () => {
renderEditor({ items: [], dirty: false, hasPreservedRawItems: false });
expect(saveButton().disabled).toBe(true);
});
it('enables the button when there are items', () => {
renderEditor({
items: [{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 }],
});
expect(saveButton().disabled).toBe(false);
});
it('enables the button when dirty', () => {
renderEditor({ dirty: true });
expect(saveButton().disabled).toBe(false);
});
it('enables the button when there are preserved raw items', () => {
renderEditor({ hasPreservedRawItems: true });
expect(saveButton().disabled).toBe(false);
});
it('calls onSave when clicked', () => {
const onSave = vi.fn();
renderEditor({ dirty: true, onSave });
fireEvent.click(saveButton());
expect(onSave).toHaveBeenCalledTimes(1);
});
it('shows the checkmark icon after save completes', async () => {
const onSave = vi.fn().mockResolvedValue(true);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(onSave).toHaveBeenCalledTimes(1);
const btn = saveButton();
expect(btn.textContent).not.toBe('common.save');
expect(btn.querySelector('svg')).not.toBeNull();
expect(btn.disabled).toBe(false);
});
it('reverts to the Save label after the saved indicator expires', async () => {
vi.useFakeTimers();
const onSave = vi.fn().mockResolvedValue(undefined);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().textContent).not.toBe('common.save');
act(() => {
vi.advanceTimersByTime(2000);
});
expect(saveButton().textContent).toBe('common.save');
expect(saveButton().disabled).toBe(false);
});
it('does not show the checkmark when save fails', async () => {
const onSave = vi.fn().mockResolvedValue(false);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(saveButton().textContent).toBe('common.save');
expect(saveButton().querySelector('svg')).toBeNull();
});
it('hides the checkmark when dirty becomes true after a successful save', async () => {
vi.useFakeTimers();
const onSave = vi.fn().mockResolvedValue(true);
const { rerender } = renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().querySelector('svg')).not.toBeNull();
// Parent updates dirty=false after successful save, then dirty=true when user draws again
rerender(
<SketchEditor
items={[]}
onItemsChange={noop}
onSave={onSave}
fileName="test.sketch.json"
dirty={false}
/>,
);
rerender(
<SketchEditor
items={[]}
onItemsChange={noop}
onSave={onSave}
fileName="test.sketch.json"
dirty={true}
/>,
);
expect(saveButton().textContent).toBe('common.save');
expect(saveButton().querySelector('svg')).toBeNull();
});
it('hides the checkmark when save fails if success indicator is still visible', async () => {
vi.useFakeTimers();
const onSave = vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().textContent).not.toBe('common.save');
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().textContent).toBe('common.save');
expect(saveButton().querySelector('svg')).toBeNull();
});
it('has an aria-label matching the default save state', () => {
renderEditor();
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
});
it('has an aria-label when dirty is true', () => {
renderEditor({ dirty: true });
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
});
it('has an aria-label showing saving state while saving', () => {
renderEditor({ saving: true, dirty: true });
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saving');
});
it('has an aria-label showing saved state after successful save', async () => {
const onSave = vi.fn().mockResolvedValue(true);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
const btn = saveButton();
expect(btn.getAttribute('aria-label')).toBe('sketch.saved');
expect(btn.querySelector('svg')).not.toBeNull();
});
it('reverts the aria-label to default after saved indicator expires', async () => {
vi.useFakeTimers();
const onSave = vi.fn().mockResolvedValue(true);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saved');
act(() => {
vi.advanceTimersByTime(2000);
});
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
});
it('keeps the aria-label as default when save fails', async () => {
const onSave = vi.fn().mockResolvedValue(false);
renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
});
it('shows the default aria-label when dirty becomes true after a successful save', async () => {
vi.useFakeTimers();
const onSave = vi.fn().mockResolvedValue(true);
const { rerender } = renderEditor({ dirty: true, onSave });
await act(async () => {
fireEvent.click(saveButton());
});
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saved');
rerender(
<SketchEditor
items={[]}
onItemsChange={noop}
onSave={onSave}
fileName="test.sketch.json"
dirty={false}
/>,
);
rerender(
<SketchEditor
items={[]}
onItemsChange={noop}
onSave={onSave}
fileName="test.sketch.json"
dirty={true}
/>,
);
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
expect(saveButton().querySelector('svg')).toBeNull();
});
});