sync: update from private repo (9a86f49b)
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync
2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions
@@ -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} />;
}