sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ToastHost } from './ToastHost';
|
||||
|
||||
describe('ToastHost', () => {
|
||||
it('stacks notifications and dismisses one by its close button', async () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<ToastHost onDismiss={onDismiss} toasts={[
|
||||
{ id: 'first', message: '最初の通知', variant: 'info' },
|
||||
{ id: 'second', title: '完了', message: '次の通知', variant: 'success' },
|
||||
]} />);
|
||||
|
||||
expect(screen.getByText('最初の通知')).toBeInTheDocument();
|
||||
expect(screen.getByText('次の通知')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: '完了を閉じる' }));
|
||||
expect(onDismiss).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
it('invokes an action and dismisses the toast', async () => {
|
||||
const onDismiss = vi.fn();
|
||||
const onAction = vi.fn();
|
||||
render(<ToastHost onDismiss={onDismiss} toasts={[{ id: 'task-1', title: 'タスク完了', message: '確認できます', variant: 'success', actionLabel: 'タスクを開く', onAction }]} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'タスクを開く' }));
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
expect(onDismiss).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('renders an optional completion visual beside the notification', () => {
|
||||
render(<ToastHost onDismiss={vi.fn()} toasts={[{ id: 'task-1', message: '完了', variant: 'success', visual: <span data-testid="pet-visual">pet</span> }]} />);
|
||||
expect(screen.getByTestId('pet-visual')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ToastState } from '../../hooks/useToast';
|
||||
|
||||
interface ToastHostProps {
|
||||
toasts: ToastState[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const variantClass = {
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-950',
|
||||
error: 'border-red-200 bg-red-50 text-red-950',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-950',
|
||||
};
|
||||
|
||||
/** 右下に積み上がる、OS 通知権限に依存しないアプリ内通知。 */
|
||||
export function ToastHost({ toasts, onDismiss }: ToastHostProps) {
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<section aria-label="アプリ内通知" className="pointer-events-none fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-[70] flex max-w-sm flex-col-reverse gap-2 sm:left-auto sm:right-4">
|
||||
{toasts.map(toast => (
|
||||
<article
|
||||
key={toast.id}
|
||||
role={toast.variant === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto rounded-xl border px-3 py-2.5 shadow-lg motion-safe:animate-[toast-enter_160ms_ease-out] ${variantClass[toast.variant]}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{toast.visual && <div className="shrink-0" aria-hidden="true">{toast.visual}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <p className="text-xs font-semibold">{toast.title}</p>}
|
||||
<p className="text-sm">{toast.message}</p>
|
||||
{toast.actionLabel && toast.onAction && (
|
||||
<button type="button" onClick={() => { toast.onAction?.(); onDismiss(toast.id); }} className="mt-1 text-xs font-semibold underline underline-offset-2">
|
||||
{toast.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => onDismiss(toast.id)} aria-label={`${toast.title ?? toast.message}を閉じる`} className="rounded p-1 text-current/70 hover:bg-black/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
|
||||
export function ToastPet() {
|
||||
const { data } = useActivePet();
|
||||
const framesPerRow = usePetFrameAnalysis(data?.spriteUrl ?? null, data?.gridCols ?? null, data?.gridRows ?? null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setPrefersReducedMotion(media.matches);
|
||||
update();
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
if (!data?.settings.enabled || !data.pet) return null;
|
||||
return <PetSprite name={data.pet.name} imageUrl={data.imageUrl} frameWidth={data.frameWidth} frameHeight={data.frameHeight} gridCols={data.gridCols} gridRows={data.gridRows} framesPerRow={framesPerRow} state="done" size={48} reducedMotion={data.settings.reducedMotion || prefersReducedMotion} />;
|
||||
}
|
||||
Reference in New Issue
Block a user