feat(transcribe): add HelpPopover primitive and wire (?) chip into panel header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
84
frontend/src/lib/components/HelpPopover.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
placement?: Placement;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { label, placement = 'bottom', children }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
||||||
|
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||||||
|
const popoverEl = document.getElementById(popoverId);
|
||||||
|
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const placementClass: Record<Placement, string> = {
|
||||||
|
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||||||
|
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||||||
|
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||||||
|
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={popoverId}
|
||||||
|
onclick={toggle}
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
id={popoverId}
|
||||||
|
role="tooltip"
|
||||||
|
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
77
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPopover(label = 'Help') {
|
||||||
|
return render(HelpPopover, { props: { label } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelpPopover — initial state', () => {
|
||||||
|
it('renders a trigger button with the given label', async () => {
|
||||||
|
renderPopover();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
|
||||||
|
renderPopover();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — open / close interactions', () => {
|
||||||
|
it('opens on click: aria-expanded true, popover in DOM', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
const btn = page.getByRole('button', { name: /Help/ });
|
||||||
|
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on Esc key', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on outside click', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||||
|
|
||||||
|
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpPopover — aria wiring', () => {
|
||||||
|
it('trigger aria-controls matches popover element id', async () => {
|
||||||
|
renderPopover();
|
||||||
|
await page.getByRole('button', { name: /Help/ }).click();
|
||||||
|
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
const controls = btn.getAttribute('aria-controls');
|
||||||
|
expect(controls).toBeTruthy();
|
||||||
|
const popover = document.getElementById(controls!);
|
||||||
|
expect(popover).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'read' | 'edit';
|
mode: 'read' | 'edit';
|
||||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||||
>
|
>
|
||||||
<!-- Segmented toggle -->
|
<!-- Segmented toggle + help chip -->
|
||||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||||
type="button"
|
<button
|
||||||
data-testid="mode-read"
|
type="button"
|
||||||
aria-disabled={!hasBlocks}
|
data-testid="mode-read"
|
||||||
onclick={handleReadClick}
|
aria-disabled={!hasBlocks}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
onclick={handleReadClick}
|
||||||
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||||
>
|
>
|
||||||
{m.mode_read()}
|
{m.mode_read()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="mode-edit"
|
data-testid="mode-edit"
|
||||||
onclick={() => onModeChange('edit')}
|
onclick={() => onModeChange('edit')}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||||
|
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||||
|
</HelpPopover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status line (hidden on mobile to save space) -->
|
<!-- Status line (hidden on mobile to save space) -->
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('TranscriptionPanelHeader', () => {
|
describe('TranscriptionPanelHeader', () => {
|
||||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||||
render(TranscriptionPanelHeader, {
|
render(TranscriptionPanelHeader, {
|
||||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
|||||||
expect(statusText).not.toBeNull();
|
expect(statusText).not.toBeNull();
|
||||||
expect(statusText!.textContent).toContain('2026');
|
expect(statusText!.textContent).toContain('2026');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
expect(helpBtn).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||||
|
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user