feat: add aria label, fix test, transition constant for better testing

This commit is contained in:
2026-02-09 09:37:13 +05:00
parent ede31942d0
commit 2bbe59107c
12 changed files with 232 additions and 32 deletions
+3 -3
View File
@@ -22,9 +22,9 @@
<CropInline /> <CropInline />
<div class="crop-controls"> <div class="crop-controls">
<Button label="Загрузить" icon={Upload} type="secondary" /> <Button label="Загрузить" ariaLabel="Upload" icon={Upload} type="secondary" />
<Button label="Редактировать" icon={Reset} type="outline" /> <Button label="Редактировать" ariaLabel="Edit" icon={Reset} type="outline" />
<Button label="Сбросить" icon={Pencil} type="outline" /> <Button label="Сбросить" ariaLabel="Reset" icon={Pencil} type="outline" />
</div> </div>
<SettingsGrid> <SettingsGrid>
+2 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import type { SlideDirectionType } from "$lib/constants"; import type { SlideDirectionType } from "$lib/constants";
import { PANEL_SETTINGS } from "$lib/constants";
import ChevronLeft from "~icons/lucide/chevron-left"; import ChevronLeft from "~icons/lucide/chevron-left";
import ChevronRight from "~icons/lucide/chevron-right"; import ChevronRight from "~icons/lucide/chevron-right";
@@ -25,13 +24,11 @@
if (current < max - 1) current++; if (current < max - 1) current++;
direction = "next"; direction = "next";
} }
let xDirection = $derived(direction == "next" ? PANEL_SETTINGS.PANEL_WIDTH : -PANEL_SETTINGS.PANEL_WIDTH);
</script> </script>
<Button icon={ChevronLeft} type="mini" onclick={prev} disabled={isFirst} /> <Button icon={ChevronLeft} ariaLabel="Previous slide" type="mini" onclick={prev} disabled={isFirst} />
<span class="panel-indicator">{current + 1} / {max}</span> <span class="panel-indicator">{current + 1} / {max}</span>
<Button icon={ChevronRight} type="mini" onclick={next} disabled={isLast} /> <Button icon={ChevronRight} ariaLabel="Next slide" type="mini" onclick={next} disabled={isLast} />
<style> <style>
.panel-indicator { .panel-indicator {
+5 -5
View File
@@ -2,7 +2,7 @@
import Card from "$components/layout/Card.svelte"; import Card from "$components/layout/Card.svelte";
import Badge from "$components/ui/Badge.svelte"; import Badge from "$components/ui/Badge.svelte";
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { PANEL_SETTINGS, type SlideDirectionType } from "$lib/constants"; import { PANEL_SETTINGS, TRANSITION_DURATION, type SlideDirectionType } from "$lib/constants";
import { downloadService, type DownloadItem } from "$services/downloadService"; import { downloadService, type DownloadItem } from "$services/downloadService";
import { konvaAllStagesState } from "$states/konvaAllStages.svelte"; import { konvaAllStagesState } from "$states/konvaAllStages.svelte";
import { konvaStageState } from "$states/konvaStage.svelte"; import { konvaStageState } from "$states/konvaStage.svelte";
@@ -62,8 +62,8 @@
{@const text = textsState?.texts[current]?.text} {@const text = textsState?.texts[current]?.text}
<div <div
class="konva-wrapper" class="konva-wrapper"
in:fly={{ x: xDirection, duration: 300 }} in:fly={{ x: xDirection, duration: TRANSITION_DURATION }}
out:fly={{ x: -xDirection, duration: 300 }} out:fly={{ x: -xDirection, duration: TRANSITION_DURATION }}
> >
<Preview {text} bind:stage={konvaStageState.stage} /> <Preview {text} bind:stage={konvaStageState.stage} />
</div> </div>
@@ -77,8 +77,8 @@
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<Button label="Скачать всё" icon={Download} onclick={downloadAll} /> <Button label="Скачать всё" ariaLabel="Download all" icon={Download} onclick={downloadAll} />
<Button label="Скачать" type="outline" icon={Download} onclick={downloadCurrent} /> <Button label="Скачать" ariaLabel="Download current" type="outline" icon={Download} onclick={downloadCurrent} />
</div> </div>
</div> </div>
</Card> </Card>
+4 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from "$components/ui/Button.svelte"; import Button from "$components/ui/Button.svelte";
import { TRANSITION_DURATION } from "$lib/constants";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import Cross from "~icons/lucide/x"; import Cross from "~icons/lucide/x";
@@ -12,10 +13,10 @@
let { text = $bindable(), id, ondelete }: Props = $props(); let { text = $bindable(), id, ondelete }: Props = $props();
</script> </script>
<div class="text-item" transition:fly={{ x: 600, duration: 300 }}> <li class="text-item" transition:fly={{ x: 600, duration: TRANSITION_DURATION }}>
<input type="text" bind:value={text} /> <input type="text" bind:value={text} />
<Button icon={Cross} type="danger" onclick={() => ondelete(id)} /> <Button icon={Cross} ariaLabel="Delete" type="danger" onclick={() => ondelete(id)} />
</div> </li>
<style> <style>
.text-item { .text-item {
+10 -2
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
text: string; text: string;
ariaLabel: string;
onenter: () => void; onenter: () => void;
} }
let { text = $bindable(), onenter }: Props = $props(); let { text = $bindable(), onenter, ariaLabel }: Props = $props();
function handleKeyboard(event: KeyboardEvent) { function handleKeyboard(event: KeyboardEvent) {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -13,7 +14,14 @@
} }
</script> </script>
<input bind:value={text} class="text-input" type="text" placeholder="Введите текст..." onkeydown={handleKeyboard} /> <input
aria-label={ariaLabel}
bind:value={text}
class="text-input"
type="text"
placeholder="Введите текст..."
onkeydown={handleKeyboard}
/>
<style> <style>
.text-input { .text-input {
+4 -4
View File
@@ -21,14 +21,14 @@
<Card title="Тексты панелей"> <Card title="Тексты панелей">
<InputGroup> <InputGroup>
<TextInput bind:text onenter={addText} /> <TextInput ariaLabel="Input new text" bind:text onenter={addText} />
<Button icon={Plus} onclick={addText} /> <Button icon={Plus} ariaLabel="Add text" onclick={addText} />
</InputGroup> </InputGroup>
<div class="texts-list"> <ul class="texts-list">
{#each textsState.texts as { text, id } (id)} {#each textsState.texts as { text, id } (id)}
<TextInlineEdit {id} {text} ondelete={() => deleteText(id)} /> <TextInlineEdit {id} {text} ondelete={() => deleteText(id)} />
{/each} {/each}
</div> </ul>
</Card> </Card>
<style> <style>
+3 -1
View File
@@ -4,13 +4,14 @@
interface Props { interface Props {
icon: Component; icon: Component;
ariaLabel: string;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
type?: "primary" | "secondary" | "danger" | "outline" | "mini"; type?: "primary" | "secondary" | "danger" | "outline" | "mini";
} }
let { icon: Icon, onclick = () => {}, disabled = false, label = "", type = "primary" }: Props = $props(); let { icon: Icon, ariaLabel, onclick = () => {}, disabled = false, label = "", type = "primary" }: Props = $props();
</script> </script>
<button <button
@@ -22,6 +23,7 @@
class:btn-mini={type === "mini"} class:btn-mini={type === "mini"}
{disabled} {disabled}
{onclick} {onclick}
aria-label={ariaLabel}
> >
<Icon /> <Icon />
{label} {label}
+2
View File
@@ -65,3 +65,5 @@ export const TextAlign = {
export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign]; export type TextAlignType = (typeof TextAlign)[keyof typeof TextAlign];
export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER; export const DEFAULT_TEXT_ALIGN: TextAlignType = TextAlign.CENTER;
export const TRANSITION_DURATION = import.meta.env.MODE === "test" ? 0 : 300;
+5
View File
@@ -75,6 +75,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
:global(ul) {
list-style: none;
text-indent: 0;
}
:global(body) { :global(body) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
@@ -8,6 +8,7 @@ describe("TextInput.svelte", () => {
props: { props: {
text: "Test text", text: "Test text",
onenter: vi.fn(), onenter: vi.fn(),
ariaLabel: "Test input",
}, },
}); });
@@ -21,6 +22,7 @@ describe("TextInput.svelte", () => {
props: { props: {
text: "Initial", text: "Initial",
onenter: vi.fn(), onenter: vi.fn(),
ariaLabel: "Test input",
}, },
}); });
@@ -37,6 +39,7 @@ describe("TextInput.svelte", () => {
props: { props: {
text: "Test", text: "Test",
onenter, onenter,
ariaLabel: "Test input",
}, },
}); });
@@ -53,6 +56,7 @@ describe("TextInput.svelte", () => {
props: { props: {
text: "Test", text: "Test",
onenter, onenter,
ariaLabel: "Test input",
}, },
}); });
+185 -9
View File
@@ -1,18 +1,194 @@
import TextManager from "$components/text/TextManager.svelte"; import TextManager from "$components/text/TextManager.svelte";
import { textsState } from "$states/texts.svelte"; import { textsState } from "$states/texts.svelte";
import { render, screen } from "@testing-library/svelte"; import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
describe("TextManager.svelte", () => { describe("TextManager.svelte", () => {
beforeEach(() => { beforeEach(() => {
while (textsState.texts.length > 0) { textsState.clear();
textsState.texts.pop();
}
}); });
it("should render without crashing", () => { describe("Rendering", () => {
render(TextManager); it("should render card with title, input and add button", () => {
const cardTitle = screen.getByText("Тексты панелей"); render(TextManager);
expect(cardTitle).toBeInTheDocument();
const cardTitle = screen.getByText("Тексты панелей");
expect(cardTitle).toBeInTheDocument();
const input = screen.getByRole("textbox");
expect(input).toBeInTheDocument();
const addButton = screen.getByRole("button", { name: /add/i });
expect(addButton).toBeInTheDocument();
});
it("should show empty list when textsState.texts is empty", () => {
render(TextManager);
const textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(0);
});
});
describe("Adding text", () => {
it("should call textsState.addText when add button is clicked", async () => {
const addTextSpy = vi.spyOn(textsState, "addText");
render(TextManager);
const input = screen.getByRole("textbox");
const addButton = screen.getByRole("button", { name: /add/i });
fireEvent.input(input, { target: { value: "New text" } });
fireEvent.click(addButton);
expect(addTextSpy).toHaveBeenCalledWith("New text");
});
it("should clear input after adding text", async () => {
render(TextManager);
const input = screen.getByRole("textbox");
const addButton = screen.getByRole("button", { name: /add/i });
fireEvent.input(input, { target: { value: "New text" } });
fireEvent.click(addButton);
await waitFor(() => {
expect(input).toHaveValue("");
});
});
it("should add text when Enter key is pressed", () => {
const addTextSpy = vi.spyOn(textsState, "addText");
render(TextManager);
const input = screen.getByRole("textbox");
fireEvent.input(input, { target: { value: "New text" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(addTextSpy).toHaveBeenCalledWith("New text");
});
});
describe("List and deletion", () => {
it("should render TextInlineEdit for each text", () => {
textsState.addText("Text 1");
textsState.addText("Text 2");
textsState.addText("Text 3");
render(TextManager);
const textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(3);
});
it("should pass correct props to TextInlineEdit", () => {
textsState.addText("Test text");
const textId = textsState.texts[0].id;
render(TextManager);
const textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(1);
const inputs = textItems[0].querySelectorAll('input[type="text"]');
expect(inputs).toHaveLength(1);
expect(inputs[0]).toHaveValue("Test text");
});
it("should call textsState.removeText when delete button is clicked", () => {
textsState.addText("Text to delete");
const textId = textsState.texts[0].id;
const removeTextSpy = vi.spyOn(textsState, "removeText");
render(TextManager);
const deleteButton = screen.getByRole("button", { name: /delete/i });
fireEvent.click(deleteButton);
expect(removeTextSpy).toHaveBeenCalledWith(textId);
});
it("should remove element from DOM after deletion", async () => {
textsState.addText("Text to delete");
render(TextManager);
let textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(1);
const deleteButton = screen.getByRole("button", { name: /delete/i });
fireEvent.click(deleteButton);
await waitFor(() => {
textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(0);
});
});
});
describe("Reactivity", () => {
it("should update list when textsState.texts changes externally", async () => {
render(TextManager);
let textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(0);
textsState.addText("New text");
await waitFor(() => {
textItems = screen.queryAllByRole("listitem");
expect(textItems).toHaveLength(1);
});
});
it("should update when text is removed externally", async () => {
vi.useFakeTimers();
textsState.addText("Text to remove");
const textId = textsState.texts[0].id;
render(TextManager);
await waitFor(() => {
expect(screen.queryAllByRole("listitem")).toHaveLength(1);
});
textsState.removeText(textId);
await vi.advanceTimersByTimeAsync(400);
await waitFor(() => {
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
vi.useRealTimers();
});
});
describe("Accessibility", () => {
// it("should keep focus in input after adding text", async () => {
// render(TextManager);
// const input = screen.getByRole("textbox");
// const addButton = screen.getByRole("button", { name: /add/i });
// fireEvent.input(input, { target: { value: "New text" } });
// fireEvent.click(addButton);
// await waitFor(() => {
// expect(input).toHaveFocus();
// });
// });
it("should have proper aria-labels for input and button", () => {
render(TextManager);
const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("aria-label", "Input new text");
const addButton = screen.getByRole("button", { name: /add/i });
expect(addButton).toHaveAttribute("aria-label", "Add text");
});
}); });
}); });
+5
View File
@@ -9,6 +9,7 @@ describe("Button.svelte", () => {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Test Button", label: "Test Button",
ariaLabel: "Test button",
}, },
}); });
@@ -20,6 +21,7 @@ describe("Button.svelte", () => {
const { container } = render(Button, { const { container } = render(Button, {
props: { props: {
icon: MockIcon, icon: MockIcon,
ariaLabel: "Test button",
}, },
}); });
@@ -33,6 +35,7 @@ describe("Button.svelte", () => {
icon: MockIcon, icon: MockIcon,
label: "Click me", label: "Click me",
onclick, onclick,
ariaLabel: "Test button",
}, },
}); });
@@ -49,6 +52,7 @@ describe("Button.svelte", () => {
icon: MockIcon, icon: MockIcon,
label: "Disabled", label: "Disabled",
disabled: true, disabled: true,
ariaLabel: "Test button",
}, },
}); });
@@ -61,6 +65,7 @@ describe("Button.svelte", () => {
props: { props: {
icon: MockIcon, icon: MockIcon,
label: "Enabled", label: "Enabled",
ariaLabel: "Test button",
}, },
}); });