diff --git a/apps/docs/src/content/docs/api/spread-sheet-table.mdx b/apps/docs/src/content/docs/api/spread-sheet-table.mdx index 451b902..6c09d47 100644 --- a/apps/docs/src/content/docs/api/spread-sheet-table.mdx +++ b/apps/docs/src/content/docs/api/spread-sheet-table.mdx @@ -26,6 +26,7 @@ import { SpreadSheetTable } from '@heynow-jp/react-spread-sheet-table' | `readOnly` | `boolean` | `false` | テーブル全体を読み取り専用に | | `cellMeta` | `(row: T, columnKey: keyof T, rowIndex: number) => CellMeta \| undefined` | - | セルごとの追加スタイル・ツールチップを返すコールバック | | `autoWidth` | `boolean` | `false` | テーブル幅をカラム幅の合計に合わせる | +| `showValidationToast` | `boolean` | `true` | 編集拒否や入力形式エラーを組み込みトーストで表示する | ## ReactSpreadSheetTableProvider diff --git a/openspec/changes/add-validation-toast-control/proposal.md b/openspec/changes/add-validation-toast-control/proposal.md new file mode 100644 index 0000000..2499105 --- /dev/null +++ b/openspec/changes/add-validation-toast-control/proposal.md @@ -0,0 +1,19 @@ +# Change: バリデーショントーストの表示を制御できるようにする + +## Why + +利用側が独自のエラー表示を持つ場合、ライブラリ内蔵のトーストと同じエラーが重複して表示される。 +バリデーションや拒否通知は維持したまま、内蔵トーストだけを非表示にできるようにする。 + +## What Changes + +- `SpreadSheetTable`に`showValidationToast`プロパティを追加する。 +- 既定値は`true`とし、既存の表示挙動を維持する。 +- `false`の場合もバリデーション処理と`onRowChangeRejected`などの通知は継続する。 +- 非表示中に発生したメッセージを保持せず、再表示時に古いエラーが現れないようにする。 +- APIリファレンスを更新する。 + +## Impact + +- Affected specs: `validation` +- Affected code: `SpreadSheetTable`の公開型と表示、Toast、コンポーネントテスト、ドキュメント diff --git a/openspec/changes/add-validation-toast-control/specs/validation/spec.md b/openspec/changes/add-validation-toast-control/specs/validation/spec.md new file mode 100644 index 0000000..180f2f5 --- /dev/null +++ b/openspec/changes/add-validation-toast-control/specs/validation/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Validation Toast Visibility Control + +`SpreadSheetTable`は、バリデーション処理や利用側への通知を無効にせず、組み込みバリデーショントーストの表示だけを制御できなければならない(MUST)。 + +表示設定を省略した場合は、後方互換性のため組み込みトーストを表示しなければならない(MUST)。 + +#### Scenario: Show the built-in toast by default + +- **GIVEN** `showValidationToast`を指定していない +- **WHEN** セル編集がバリデーションエラーで拒否される +- **THEN** 組み込みトーストにエラーメッセージが表示される + +#### Scenario: Hide only the built-in toast + +- **GIVEN** `showValidationToast={false}`を指定している +- **WHEN** セル編集がバリデーションエラーで拒否される +- **THEN** 組み込みトーストは表示されない +- **AND** `onRowChangeRejected`には拒否理由が通知される + +#### Scenario: Do not show a stale error after enabling the toast + +- **GIVEN** 組み込みトーストを非表示にしている間にバリデーションエラーが発生した +- **WHEN** `showValidationToast`を`true`へ変更する +- **THEN** 非表示中に発生したエラーメッセージは表示されない diff --git a/openspec/changes/add-validation-toast-control/tasks.md b/openspec/changes/add-validation-toast-control/tasks.md new file mode 100644 index 0000000..68d7f5c --- /dev/null +++ b/openspec/changes/add-validation-toast-control/tasks.md @@ -0,0 +1,8 @@ +## 1. Implementation + +- [x] 1.1 内蔵トーストの既定表示をコンポーネントテストで固定する。 +- [x] 1.2 `showValidationToast`を公開型と`SpreadSheetTable`へ追加する。 +- [x] 1.3 トースト非表示時も拒否通知が維持されることをテストする。 +- [x] 1.4 非表示中のメッセージが再表示時に残らないようにする。 +- [x] 1.5 APIリファレンスを更新する。 +- [x] 1.6 typecheck、lint、test、buildを実行する。 diff --git a/packages/core/src/components/SpreadSheetTable.test.tsx b/packages/core/src/components/SpreadSheetTable.test.tsx new file mode 100644 index 0000000..33fb9d2 --- /dev/null +++ b/packages/core/src/components/SpreadSheetTable.test.tsx @@ -0,0 +1,103 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterAll, describe, expect, it, vi } from 'vitest' +import type { TableInstance } from '../core/types' +import { useSpreadSheetTable } from '../hooks/use-spread-sheet-table' +import { SpreadSheetTable } from './SpreadSheetTable' + +type TestRow = { + id: string + minutes: number +} + +type InternalTableInstance = TableInstance & { + __handleCellChange: ( + rowIndex: number, + columnKey: keyof TestRow, + value: TestRow[keyof TestRow], + ) => void +} + +vi.hoisted(() => { + class ResizeObserverStub { + disconnect() {} + observe() {} + unobserve() {} + } + + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) + +afterAll(() => vi.unstubAllGlobals()) + +const renderRejectedEdit = (showValidationToast?: boolean) => { + const container = document.createElement('div') + const root = createRoot(container) + const onRowChangeRejected = vi.fn() + let table: InternalTableInstance | undefined + + const Harness = ({ showToast }: { showToast?: boolean }) => { + table = useSpreadSheetTable({ + columns: [{ type: 'number', key: 'minutes', header: 'Minutes' }], + initialData: [{ id: 'row-1', minutes: 30 }], + rowKey: 'id', + processRowChange: () => ({ + status: 'rejected', + issues: [ + { + columnKey: 'minutes', + result: { level: 'error', message: 'Minutes are invalid' }, + }, + ], + }), + onRowChangeRejected, + }) as InternalTableInstance + + return + } + + act(() => root.render()) + act(() => table?.__handleCellChange(0, 'minutes', 1000)) + + return { + container, + onRowChangeRejected, + root, + rerender: (showToast?: boolean) => act(() => root.render()), + } +} + +const unmount = (root: Root) => act(() => root.unmount()) + +describe('SpreadSheetTable validation toast', () => { + it('shows rejected edit errors by default', () => { + const { container, root } = renderRejectedEdit() + + expect(container).toHaveTextContent('入力エラー') + expect(container).toHaveTextContent('Minutes are invalid') + + unmount(root) + }) + + it('does not show rejected edit errors when the toast is disabled', () => { + const { container, onRowChangeRejected, root } = renderRejectedEdit(false) + + expect(container).not.toHaveTextContent('入力エラー') + expect(container).not.toHaveTextContent('Minutes are invalid') + expect(onRowChangeRejected).toHaveBeenCalledOnce() + + unmount(root) + }) + + it('does not retain errors that occurred while the toast was disabled', () => { + const { container, rerender, root } = renderRejectedEdit(false) + + rerender(true) + + expect(container).not.toHaveTextContent('入力エラー') + expect(container).not.toHaveTextContent('Minutes are invalid') + + unmount(root) + }) +}) diff --git a/packages/core/src/components/SpreadSheetTable.tsx b/packages/core/src/components/SpreadSheetTable.tsx index 1a60052..74d92ee 100644 --- a/packages/core/src/components/SpreadSheetTable.tsx +++ b/packages/core/src/components/SpreadSheetTable.tsx @@ -58,6 +58,7 @@ type SpreadSheetTableComponentProps = { readonly height?: number readonly cellMeta?: CellMetaFn readonly autoWidth?: boolean + readonly showValidationToast?: boolean } const ROW_HEIGHT = 32 @@ -192,6 +193,7 @@ function SpreadSheetTableInner({ height, cellMeta, autoWidth = false, + showValidationToast = true, }: SpreadSheetTableComponentProps) { const store = (table as TableInstance & { __store: TableStore }).__store const handleCellChange = ( @@ -655,7 +657,7 @@ function SpreadSheetTableInner({ onBlur={handleBlur} tabIndex={0} > - +
= { readonly store: TableStore + readonly visible?: boolean } -function ToastInner({ store }: ToastProps) { +function ToastInner({ store, visible = true }: ToastProps) { useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) const messages = store.getToastMessages() @@ -17,18 +18,22 @@ function ToastInner({ store }: ToastProps) { // biome-ignore lint/correctness/useExhaustiveDependencies: toastVersion resets the dismiss timer when a new toast arrives while one is already visible useEffect(() => { + if (!visible) { + if (hasMessages) store.clearToast() + return + } if (!hasMessages) return const timer = setTimeout(() => { store.clearToast() }, AUTO_DISMISS_MS) return () => clearTimeout(timer) - }, [hasMessages, toastVersion, store]) + }, [hasMessages, toastVersion, store, visible]) const handleClose = useCallback(() => { store.clearToast() }, [store]) - if (!hasMessages) return null + if (!visible || !hasMessages) return null return (
diff --git a/packages/core/src/core/types/table.ts b/packages/core/src/core/types/table.ts index 958dcd2..38454f9 100644 --- a/packages/core/src/core/types/table.ts +++ b/packages/core/src/core/types/table.ts @@ -161,4 +161,5 @@ export type SpreadSheetTableProps = { readonly readOnly?: boolean readonly cellMeta?: (row: T, columnKey: keyof T, rowIndex: number) => CellMeta | undefined readonly autoWidth?: boolean + readonly showValidationToast?: boolean }