設定画面 (#34) (#397)

Reviewed-on: #397
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #397 でマージされました.
このコミットが含まれているのは:
2026-07-07 00:48:41 +09:00
committed by みてるぞ
コミット f5b632ed89
71個のファイルの変更7785行の追加584行の削除
+34
ファイルの表示
@@ -0,0 +1,34 @@
import type { Transition } from 'framer-motion'
import type { ClientAnimationMode } from '@/lib/settings'
type ClientAnimationTransitionOptions = {
normal: Transition
reduced?: Transition
off?: Transition }
export const clientAnimationsOff = (
animationMode: ClientAnimationMode,
): boolean =>
animationMode === 'off'
export const clientAnimationTransition = (
animationMode: ClientAnimationMode,
{ normal, reduced, off }: ClientAnimationTransitionOptions,
): Transition => {
if (animationMode === 'off')
return off ?? { duration: 0 }
if (animationMode === 'reduced')
return reduced ?? { duration: .08, ease: 'linear' as const }
return normal
}
export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
+65
ファイルの表示
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_KEY_BINDINGS,
formatKeyBinding,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
describe ('keyboardShortcuts', () => {
it ('defines the default key bindings used by the settings screen', () => {
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true } })
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
})
it ('formats key bindings for display', () => {
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
expect (formatKeyBinding (null)).toBe ('未割当')
})
it ('normalises keyboard events without assigning space by default', () => {
const event = { key: 'P',
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false } as KeyboardEvent
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
})
it ('detects conflicting action bindings', () => {
const conflicts = getConflictingShortcutActionIds ({
...DEFAULT_KEY_BINDINGS,
'navigation.posts': { key: 'p' },
'navigation.tags': { key: 'p' } })
expect (conflicts.has ('navigation.posts')).toBe (true)
expect (conflicts.has ('navigation.tags')).toBe (true)
expect (conflicts.has ('navigation.wiki')).toBe (false)
})
it ('resolves route scopes and editable event targets', () => {
const input = document.createElement ('input')
const div = document.createElement ('div')
div.setAttribute ('role', 'textbox')
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
expect (isEditableEventTarget (input)).toBe (true)
expect (isEditableEventTarget (div)).toBe (true)
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
})
})
+328
ファイルの表示
@@ -0,0 +1,328 @@
export type ShortcutScope =
| 'global'
| 'posts'
| 'postSearch'
| 'tags'
| 'settings'
| 'wiki'
| 'materials'
export type ShortcutActionId =
| 'global.openShortcutHelp'
| 'global.focusSearch'
| 'navigation.posts'
| 'navigation.tags'
| 'navigation.materials'
| 'navigation.wiki'
| 'pagination.next'
| 'pagination.previous'
| 'settings.account'
| 'settings.behavior'
| 'settings.theme'
| 'settings.keyboard'
export type KeyBinding = {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
export type ShortcutDefinition = {
id: ShortcutActionId
label: string
scope: ShortcutScope
defaultBinding: KeyBinding | null
}
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面',
posts: '広場',
postSearch: '広場検索',
tags: 'タグ',
settings: '設定',
wiki: 'Wiki',
materials: '素材',
}
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true },
},
{
id: 'global.focusSearch',
label: '検索欄へ移動',
scope: 'global',
defaultBinding: { key: '/' },
},
{
id: 'navigation.posts',
label: '広場へ移動',
scope: 'global',
defaultBinding: { key: 'p' },
},
{
id: 'navigation.tags',
label: 'タグへ移動',
scope: 'global',
defaultBinding: { key: 't' },
},
{
id: 'navigation.materials',
label: '素材へ移動',
scope: 'global',
defaultBinding: { key: 'm' },
},
{
id: 'navigation.wiki',
label: 'Wiki へ移動',
scope: 'global',
defaultBinding: { key: 'w' },
},
{
id: 'pagination.next',
label: '次のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowRight' },
},
{
id: 'pagination.previous',
label: '前のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowLeft' },
},
{
id: 'settings.account',
label: '設定: アカウント',
scope: 'settings',
defaultBinding: { key: '1' },
},
{
id: 'settings.behavior',
label: '設定: 動作',
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
scope: 'settings',
defaultBinding: { key: '4' },
},
]
export const SHORTCUT_DEFINITIONS_BY_ID =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, ShortcutDefinition>> (
(definitions, definition) => {
definitions[definition.id] = definition
return definitions
},
{
'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0],
'global.focusSearch': SHORTCUT_DEFINITIONS[1],
'navigation.posts': SHORTCUT_DEFINITIONS[2],
'navigation.tags': SHORTCUT_DEFINITIONS[3],
'navigation.materials': SHORTCUT_DEFINITIONS[4],
'navigation.wiki': SHORTCUT_DEFINITIONS[5],
'pagination.next': SHORTCUT_DEFINITIONS[6],
'pagination.previous': SHORTCUT_DEFINITIONS[7],
'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.behavior': SHORTCUT_DEFINITIONS[9],
'settings.theme': SHORTCUT_DEFINITIONS[10],
'settings.keyboard': SHORTCUT_DEFINITIONS[11],
},
)
export const DEFAULT_KEY_BINDINGS =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, KeyBinding | null>> (
(bindings, definition) => {
bindings[definition.id] = definition.defaultBinding
return bindings
},
{
'global.openShortcutHelp': null,
'global.focusSearch': null,
'navigation.posts': null,
'navigation.tags': null,
'navigation.materials': null,
'navigation.wiki': null,
'pagination.next': null,
'pagination.previous': null,
'settings.account': null,
'settings.behavior': null,
'settings.theme': null,
'settings.keyboard': null,
},
)
const normaliseKey = (key: string): string => {
if (key === 'Spacebar')
return ' '
if (key.length === 1 && key !== ' ')
return key.toLowerCase ()
return key
}
const isModifierKey = (key: string): boolean =>
key === 'Shift'
|| key === 'Control'
|| key === 'Alt'
|| key === 'Meta'
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
if (!(value) || typeof value !== 'object')
return null
const binding = value as Partial<KeyBinding>
if (typeof binding.key !== 'string')
return null
const key = normaliseKey (binding.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(binding.ctrl ? { ctrl: true } : { }),
...(binding.shift ? { shift: true } : { }),
...(binding.alt ? { alt: true } : { }),
...(binding.meta ? { meta: true } : { }),
}
}
export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => {
if (binding == null)
return null
const parts = [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
binding.key,
].filter ((part): part is string => part != null)
return parts.join ('+')
}
const keyLabel = (key: string): string => {
switch (key)
{
case ' ':
return 'Space'
case 'ArrowLeft':
return 'ArrowLeft'
case 'ArrowRight':
return 'ArrowRight'
case 'ArrowUp':
return 'ArrowUp'
case 'ArrowDown':
return 'ArrowDown'
case 'Escape':
return 'Escape'
case 'Backspace':
return 'Backspace'
case 'Delete':
return 'Delete'
default:
return key.length === 1 ? key.toUpperCase () : key
}
}
export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null)
return '未割当'
return [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
keyLabel (binding.key),
].filter ((part): part is string => part != null)
.join (' + ')
}
export const keyBindingFromKeyboardEvent = (
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
): KeyBinding | null => {
const key = normaliseKey (event.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(event.ctrlKey ? { ctrl: true } : { }),
...(event.shiftKey ? { shift: true } : { }),
...(event.altKey ? { alt: true } : { }),
...(event.metaKey ? { meta: true } : { }),
}
}
export const isEditableEventTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element))
return false
return target.closest (
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
) != null
}
export const getConflictingShortcutActionIds = (
bindings: Record<ShortcutActionId, KeyBinding | null>,
): Set<ShortcutActionId> => {
const grouped = new Map<string, ShortcutActionId[]> ()
Object.entries (bindings).forEach (([actionId, binding]) => {
const serialised = serialiseKeyBinding (binding)
if (serialised == null)
return
const ids = grouped.get (serialised) ?? []
ids.push (actionId as ShortcutActionId)
grouped.set (serialised, ids)
})
return new Set (
[...grouped.values ()]
.filter (actionIds => actionIds.length > 1)
.flat (),
)
}
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
if (pathname.startsWith ('/posts/search'))
return 'postSearch'
if (pathname.startsWith ('/posts'))
return 'posts'
if (pathname.startsWith ('/tags'))
return 'tags'
if (pathname.startsWith ('/users/settings'))
return 'settings'
if (pathname.startsWith ('/wiki'))
return 'wiki'
if (pathname.startsWith ('/materials'))
return 'materials'
return 'global'
}
export const isSpaceBinding = (binding: KeyBinding | null): boolean =>
binding?.key === ' '
+108
ファイルの表示
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { applyClientAnimationMode,
applyThemeTokens,
buildThemeTokens,
CLIENT_SETTINGS_STORAGE_KEY,
DARK_THEME_TOKENS,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
getClientThemeMode,
LIGHT_THEME_TOKENS,
loadClientSettings,
setClientListSettings,
setClientThemeAppearance } from '@/lib/settings'
import type { FetchPostsOrder } from '@/types'
const installMatchMedia = (matches: boolean) => {
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
matches,
addEventListener: vi.fn (),
removeEventListener: vi.fn () })) })
}
describe ('settings', () => {
beforeEach (() => {
localStorage.clear ()
document.documentElement.removeAttribute ('data-animation')
document.documentElement.removeAttribute ('style')
document.documentElement.className = ''
installMatchMedia (false)
})
it ('stores list settings under the shared client settings key', () => {
setClientListSettings ('postSearch', {
limit: 50,
order: 'title:asc' })
expect (getClientListLimit ('postSearch')).toBe (50)
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
})
it ('ignores invalid list limits and keeps stored order strings', () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
expect (getClientListLimit ('postList')).toBeNull ()
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
DEFAULT_POST_LIST_ORDER)
})
it ('stores theme mode and light/dark slot selections in client settings', () => {
setClientThemeAppearance ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
expect (getClientThemeMode ()).toBe ('dark')
expect (loadClientSettings ().appearance).toMatchObject ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
})
it ('applies animation mode to the document dataset', () => {
applyClientAnimationMode ('off')
expect (document.documentElement.dataset.animation).toBe ('off')
applyClientAnimationMode ('reduced')
expect (document.documentElement.dataset.animation).toBe ('reduced')
applyClientAnimationMode ('normal')
expect (document.documentElement.dataset.animation).toBeUndefined ()
})
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
const tokens = buildThemeTokens ('light', {
link: '221.2 83.2% 53.3%',
topNavMenuLink: '221.2 83.2% 53.3%' })
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
applyThemeTokens (tokens)
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
'213.1 93.9% 67.8%')
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
'hsl(213.1 93.9% 67.8%)')
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+26
ファイルの表示
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import {
CLIENT_SETTINGS_EVENT,
getClientBehaviorSettings,
} from '@/lib/settings'
import type { ClientBehaviorSettings } from '@/lib/settings'
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
const [settings, setSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const handleClientSettingsChange = () => {
setSettings (getClientBehaviorSettings ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
return settings
}
+322
ファイルの表示
@@ -0,0 +1,322 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
serialiseKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import {
getClientKeyboardSettings,
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { PropsWithChildren } from 'react'
type ShortcutHandler = () => void
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
type KeyboardShortcutsContextValue = {
activeScope: ShortcutScope
keyboardSettings: ClientKeyboardSettings
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
registerHandlers: (handlers: ShortcutHandlers) => () => void
}
const KeyboardShortcutsContext =
createContext<KeyboardShortcutsContextValue | null> (null)
const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
if (element.closest ('[hidden], [aria-hidden="true"]') != null)
return false
if (
(element instanceof HTMLInputElement
|| element instanceof HTMLButtonElement
|| element instanceof HTMLSelectElement
|| element instanceof HTMLTextAreaElement)
&& element.disabled
)
return false
const style = window.getComputedStyle (element)
if (
style.display === 'none'
|| style.visibility === 'hidden'
|| style.pointerEvents === 'none'
)
return false
return element.getClientRects ().length > 0
}
const focusSearchTarget = (): void => {
const targets = [
...document.querySelectorAll<HTMLElement> (
'[data-shortcut-focus-search="true"]',
),
]
const target = targets.find (isFocusableShortcutTarget)
if (target == null)
return
target.focus ()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
target.select ()
}
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
const activeScope = useMemo<ShortcutScope> (
() => resolveShortcutScope (location.pathname),
[location.pathname],
)
const effectiveBindings = useMemo (
() => getEffectiveKeyBindings (keyboardSettings.bindings),
[keyboardSettings.bindings],
)
const conflictActionIds = useMemo (
() => getConflictingShortcutActionIds (effectiveBindings),
[effectiveBindings],
)
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
{ },
),
[registeredHandlers],
)
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
const next = setClientKeyboardSettings (settings)
setKeyboardSettingsState (next)
}, [])
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
const registrationId = ++registrationIdRef.current
setRegisteredHandlers (current => ({
...current,
[registrationId]: handlers,
}))
return () => {
setRegisteredHandlers (current => {
const next = { ...current }
delete next[registrationId]
return next
})
}
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'global.openShortcutHelp': () => {
setShortcutHelpOpen (true)
},
'global.focusSearch': () => {
focusSearchTarget ()
},
'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}),
[guardedNavigate],
)
const availableActionIds = useMemo<Set<ShortcutActionId>> (
() => new Set (
SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition =>
builtinHandlers[definition.id] != null
|| mergedRegisteredHandlers[definition.id] != null)
.map (definition => definition.id),
),
[activeScope, builtinHandlers, mergedRegisteredHandlers],
)
useEffect (() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
keyboardSettings.enabled === false
|| event.defaultPrevented
|| event.isComposing
|| event.key === 'Process'
|| event.repeat
)
return
if (isEditableEventTarget (event.target))
return
if (shortcutHelpOpen)
return
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
const serialisedBinding = serialiseKeyBinding (binding)
if (serialisedBinding == null)
return
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
definition.scope === 'global'
|| definition.scope === activeScope
)).filter (definition => {
const currentBinding = effectiveBindings[definition.id]
return (
currentBinding != null
&& serialiseKeyBinding (currentBinding) === serialisedBinding
)
}).filter (definition => !(conflictActionIds.has (definition.id)))
if (candidates.length !== 1)
return
const actionId = candidates[0].id
const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
if (handler == null)
return
event.preventDefault ()
handler ()
}
document.addEventListener ('keydown', onKeyDown)
return () => document.removeEventListener ('keydown', onKeyDown)
}, [
activeScope,
conflictActionIds,
effectiveBindings,
builtinHandlers,
keyboardSettings.enabled,
mergedRegisteredHandlers,
shortcutHelpOpen,
])
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
activeScope,
keyboardSettings,
effectiveBindings,
conflictActionIds,
saveKeyboardSettings,
registerHandlers,
}), [
activeScope,
conflictActionIds,
effectiveBindings,
keyboardSettings,
registerHandlers,
saveKeyboardSettings,
])
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<ShortcutHelpDialog
open={shortcutHelpOpen}
onOpenChange={setShortcutHelpOpen}
activeScope={activeScope}
effectiveBindings={effectiveBindings}
conflictActionIds={conflictActionIds}
availableActionIds={availableActionIds}/>
</KeyboardShortcutsContext.Provider>)
}
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
const context = useContext (KeyboardShortcutsContext)
if (context == null)
throw new Error ('KeyboardShortcutsProvider が必要です.')
return context
}
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
const { registerHandlers } = useKeyboardShortcutSettings ()
const handlersRef = useRef (handlers)
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
useEffect (() => {
handlersRef.current = handlers
}, [handlers])
const stableHandlers = useMemo<ShortcutHandlers> (() => {
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
const key = actionId as ShortcutActionId
nextHandlers[key] = () => {
handlersRef.current[key]?.()
}
return nextHandlers
}, { })
}, [actionIdsKey])
useEffect (
() => registerHandlers (stableHandlers),
[registerHandlers, stableHandlers],
)
}
export {
DEFAULT_KEY_BINDINGS,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
}
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}