コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker 1b22ed43bd AI: address review on PR #470 2026-11-13 12:48:30 +09:00
Codex Night Worker b46d53e347 AI: address issue #273 2026-11-13 12:36:02 +09:00
43個のファイルの変更、552行の追加、1526行の削除
-94
ファイルの表示
@@ -1,94 +0,0 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
バイナリファイルは表示されません。
-11
ファイルの表示
@@ -1,11 +0,0 @@
Noto Sans Mongolian Regular, unmodified TrueType font.
Font copyright: Copyright 2018 Google Inc. All Rights Reserved.
- Source: https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSansMongolian
- Font: https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMongolian/NotoSansMongolian-Regular.ttf
- License: SIL Open Font License 1.1; see the accompanying LICENSE.
- Retrieved: 2026-11-08.
Used by the Mongolian UI at `/mn-Mong`.
The font and license are served together from this directory.
+1 -20
ファイルの表示
@@ -1,10 +1,9 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { createServer } from 'vite'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateHelpAssets, helpAssets } from './help-assets' import { generateHelpAssets } from './help-assets'
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help' import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
@@ -110,22 +109,4 @@ describe ('Build-time Help assets', () => {
it ('rejects an empty Help checkout', async () => { it ('rejects an empty Help checkout', async () => {
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found') await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
}) })
it ('defers missing Help errors until a Help module is requested', async () => {
const server = await createServer ({
configFile: false,
root,
plugins: [helpAssets ()],
server: { middlewareMode: true, watch: null } })
try
{
await server.pluginContainer.buildStart ({})
await expect (server.pluginContainer.load ('\0virtual:help/articles'))
.rejects.toThrow ('ENOENT')
}
finally
{
await server.close ()
}
})
}) })
+5 -6
ファイルの表示
@@ -159,9 +159,10 @@ export const helpAssets = (): Plugin => {
configResolved (config) { configResolved (config) {
state.root = path.resolve (config.root, 'help') state.root = path.resolve (config.root, 'help')
}, },
buildStart () { async buildStart () {
state.data = undefined state.data = generateHelpAssets (state.root)
this.addWatchFile (state.root) const generated = await data ()
generated.files.forEach (file => this.addWatchFile (file))
}, },
resolveId (id) { resolveId (id) {
return id.startsWith (prefix) ? `\0${ id }` : null return id.startsWith (prefix) ? `\0${ id }` : null
@@ -169,9 +170,7 @@ export const helpAssets = (): Plugin => {
async load (id) { async load (id) {
if (!(id.startsWith (`\0${ prefix }`))) if (!(id.startsWith (`\0${ prefix }`)))
return null return null
const generated = await data () const source = (await data ()).modules.get (id.slice (1))
generated.files.forEach (file => this.addWatchFile (file))
const source = generated.modules.get (id.slice (1))
if (source == null) if (source == null)
throw new Error (`Unknown Help asset: ${ id.slice (1) }`) throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
return source return source
+19 -30
ファイルの表示
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users' import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help' import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath, import { fetchLocales, isLocaleCode, legacyLocalePath,
localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales' switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys' import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isCountsPath } from '@/lib/routeLocales' import { isCountsPath } from '@/lib/routeLocales'
import { switchPostFilterLocale } from '@/lib/posts' import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki' import { switchWikiLocale } from '@/lib/wiki'
import { useLocale, useUILocale, useVerticalPreview } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
fetchUserThemeSlots, fetchUserThemeSlots,
@@ -46,7 +46,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'
import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom' import type { RouteMatch } from 'react-router-dom'
import type { AvailableLocale, LocaleCode, UILocaleCode } from '@/lib/locales' import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales' import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types' import type { User } from '@/types'
@@ -267,21 +267,15 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient () const client = useQueryClient ()
const location = useLocation () const location = useLocation ()
const locale = useLocale () const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const route = resolvePageRoute (location.pathname) const route = resolvePageRoute (location.pathname)
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname) const pagePath = unlocalisedPath (location.pathname)
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/') const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
const switchLocale = async (target: UILocaleCode): Promise<string> => { const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const targetLocale = target === 'mn-Mong' ? 'ja' : target
if (targetLocale === locale)
return switchLocalePath (location, target)
const resolve = route?.route.handle?.switchLocale const resolve = route?.route.handle?.switchLocale
const destination = return (resolve != null && route != null)
(resolve != null && route != null) ? resolve ({ location, params: route.params, locale, targetLocale })
? await resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale) : switchLocalePath (location, targetLocale)
return target === 'mn-Mong' ? `/mn-Mong${unlocalisedPath (destination)}` : destination
} }
const [user, setUserState] = useState<User | null> ( const [user, setUserState] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null) () => client.getQueryData<User> (sessionKeys.current) ?? null)
@@ -399,8 +393,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
<motion.div <motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'} layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }} transition={{ layout: appLayoutTransition }}
data-vertical-preview={verticalPreview || undefined} className="relative flex h-dvh w-full flex-col overflow-y-hidden">
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/> <TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper <RouteTransitionWrapper
user={user} user={user}
@@ -418,9 +411,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const LocaleRoute: FC = () => { const LocaleRoute: FC = () => {
const { locale } = useParams () const { locale } = useParams ()
const uiLocale = useUILocale ()
const location = useLocation () const location = useLocation ()
const verticalPreview = isVerticalPreviewPath (location.pathname)
const { i18n } = useTranslation () const { i18n } = useTranslation ()
const { data, isError } = useQuery ({ const { data, isError } = useQuery ({
queryKey: localesKeys.index, queryKey: localesKeys.index,
@@ -430,30 +421,29 @@ const LocaleRoute: FC = () => {
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnReconnect: true }) refetchOnReconnect: true })
const contentLocale = verticalPreview ? 'ja' : locale
const activeLocale = const activeLocale =
(isLocaleCode (contentLocale) (isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === contentLocale)) && data?.locales.some (entry => entry.code === locale))
? contentLocale ? locale
: null : null
useLayoutEffect (() => { useLayoutEffect (() => {
if (activeLocale != null) if (activeLocale != null)
document.documentElement.lang = uiLocale document.documentElement.lang = activeLocale
if (activeLocale != null && i18n.language !== uiLocale) if (activeLocale != null && i18n.language !== activeLocale)
void i18n.changeLanguage (uiLocale) void i18n.changeLanguage (activeLocale)
}, [activeLocale, uiLocale, i18n]) }, [activeLocale, i18n])
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null) const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null) if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/> return <NotFound/>
if (isError && data == null) if (isError && data == null)
return <ServiceUnavailable/> return <ServiceUnavailable/>
if (data == null) if (data == null)
return null return null
if (isLocaleCode (locale) || verticalPreview) if (isLocaleCode (locale))
{ {
return data.locales.some (entry => entry.code === contentLocale) return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/> ? <RoutedApp locales={data.locales}/>
: <NotFound/> : <NotFound/>
} }
@@ -465,8 +455,7 @@ const LocaleRoute: FC = () => {
const LocaleRedirect = ({ to }: { to: string }) => { const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
return <Navigate to={localiseTo ( return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
{ ...location, pathname: to }, locale, location.pathname)} replace/>
} }
+22 -10
ファイルの表示
@@ -12,24 +12,36 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => { describe ('PostFormTagsArea', () => {
it ('updates text and fetches autocomplete for the selected token', async () => { it ('replaces the selected token and restores the caret without losing other tags', async () => {
const setTags = vi.fn () const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>) const { rerender } = renderWithProviders (
<PostFormTagsArea tags="ぼっち 虹 後続" setTags={setTags}/>)
const textarea = screen.getByRole ('textbox') const textarea = screen.getByRole<HTMLTextAreaElement> ('textbox')
fireEvent.focus (textarea) fireEvent.focus (textarea)
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) fireEvent.change (textarea, { target: { value: 'ぼっち 虹な 後続' } })
fireEvent.change (textarea, { target: { value: '虹夏' } }) rerender (<PostFormTagsArea tags="ぼっち 虹な 後続" setTags={setTags}/>)
fireEvent.select (textarea, { target: { selectionStart: 6, selectionEnd: 6 } })
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete', {
{ locale: 'ja', params: { q: '虹', nico: '0' }, locale: 'ja', params: { q: '虹な', nico: '0' },
signal: expect.any (AbortSignal) }, signal: expect.any (AbortSignal) })
)
}) })
expect (setTags).toHaveBeenCalledWith ('虹夏') expect (setTags).toHaveBeenCalledWith ('ぼっち 虹な 後続')
fireEvent.mouseDown (await screen.findByText ('虹夏'))
expect (setTags).toHaveBeenLastCalledWith ('ぼっち 虹夏 後続')
rerender (<PostFormTagsArea tags="ぼっち 虹夏 後続" setTags={setTags}/>)
await waitFor (() => {
expect (textarea).toHaveFocus ()
expect (textarea.selectionStart).toBe (7)
expect (textarea.selectionEnd).toBe (7)
})
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
}) })
}) })
+9 -62
ファイルの表示
@@ -1,13 +1,10 @@
// TODO: TagSearch と共通化する. import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react' import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react' import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
@@ -41,24 +38,13 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | '
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => { const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const { label } = useUITranslation ('navigation') const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false) const [focused, setFocused] = useState (false)
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
const suggestions = (result.locale === locale && result.request === request
&& request.query === currentToken) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
const textarea = ref.current! const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ') const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue) setTags (newValue)
@@ -70,52 +56,18 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
}) })
} }
const { inputProps, suggestionProps, requestSuggestions } =
useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect })
const recompute = (pos: number, v: string = tags) => { const recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos) const { start, end, token } = getTokenAt (v, pos)
setRequest ({ query: token }) requestSuggestions (token)
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ())) if (!(token.trim ()))
{ return
setSuggestionsVsbl (false)
return
}
setBounds ({ start, end }) setBounds ({ start, end })
} }
useEffect (() => {
if (request.query.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale])
return ( return (
<FormField className="relative w-full" label={label ('tags')} messages={errors}> <FormField className="relative w-full" label={label ('tags')} messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
@@ -134,15 +86,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onFocus={() => setFocused (true)} onFocus={() => setFocused (true)}
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
setSuggestionsVsbl (false) inputProps.onBlur ()
}}/> }}/>
{focused && ( {focused && (
<TagSearchBox <TagSearchBox {...suggestionProps}/>)}
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</>)} </>)}
</FormField>) </FormField>)
} }
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const cardRef = useRef<HTMLDivElement> (null) const cardRef = useRef<HTMLDivElement> (null)
return ( return (
<div className="post-list flex flex-wrap gap-6 p-4"> <div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => { {posts.map ((post, i) => {
const sharedId = `page-${ post.id }` const sharedId = `page-${ post.id }`
const layoutId = sharedId const layoutId = sharedId
+5 -9
ファイルの表示
@@ -1,10 +1,9 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import { createPath, useLocation } from 'react-router-dom' import { createPath } from 'react-router-dom'
import { isVerticalPreviewPath, localeFromPath, import { localiseTo } from '@/lib/locales'
localiseTo, unlocalisedPath } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
@@ -39,18 +38,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const locale = useLocale () const locale = useLocale ()
const { pathname } = useLocation ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo ( const path = useMemo (
() => { () => {
const localised = localiseTo (to, locale, pathname) const localised = localiseTo (to, locale)
const destination = typeof localised === 'string' ? localised : createPath (localised) return typeof localised === 'string' ? localised : createPath (localised)
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
}, },
[to, locale, pathname], [to, locale],
) )
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString () return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
+4 -7
ファイルの表示
@@ -79,9 +79,7 @@ const TagLink: FC<Props> = ({ tag,
} as CSSProperties } as CSSProperties
const spanClass = 'tag-link-colour' const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour' const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = cn ( const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
'group [min-inline-size:0] [max-inline-size:100%]',
'overflow-hidden align-bottom')
const markerWrapClass = 'shrink-0 self-start md:self-auto' const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto' const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
@@ -91,8 +89,7 @@ const TagLink: FC<Props> = ({ tag,
return ( return (
<span <span
className={cn ( className={cn (
'inline-flex [min-inline-size:0] [max-inline-size:100%] flex-nowrap', 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'items-stretch align-baseline',
'gap-x-1 md:items-baseline')}> 'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && ( {(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && (
<span className={markerWrapClass}> <span className={markerWrapClass}>
@@ -156,8 +153,8 @@ const TagLink: FC<Props> = ({ tag,
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
className="ms-1 me-1 shrink-0" className="ml-1 mr-1 shrink-0"
style={{ paddingInlineStart: `${ (nestLevel - 1) }rem` }}> style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
↳ ↳
</span>)} </span>)}
{matchedAlias != null && ( {matchedAlias != null && (
+38 -1
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react' import { act, fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,6 +8,8 @@ import * as api from '@/lib/api'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { Tag } from '@/types'
const LocationOutput = () => { const LocationOutput = () => {
const location = useLocation () const location = useLocation ()
@@ -117,4 +119,39 @@ describe ('TagSearch', () => {
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3') expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument () expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
}) })
it ('aborts obsolete requests and ignores their late suggestions', async () => {
const responses: ((tags: Tag[]) => void)[] = []
vi.mocked (api.apiGet).mockImplementation (() => new Promise<Tag[]> (resolve => {
responses.push (resolve)
}))
const { unmount } = renderSearch ()
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: '虹' } })
const previousSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
expect (previousSignal).toBeInstanceOf (AbortSignal)
expect (previousSignal?.aborted).toBe (false)
fireEvent.change (input, { target: { value: 'ぼ' } })
expect (previousSignal?.aborted).toBe (true)
expect (api.apiGet).toHaveBeenLastCalledWith ('/tags/autocomplete', {
locale: 'ja', params: { q: 'ぼ', nico: '1' },
signal: expect.any (AbortSignal) })
const currentSignal = vi.mocked (api.apiGet).mock.calls.at (-1)?.[1]?.signal
await act (async () => {
responses[1] ([buildTag ({ name: 'ぼっち' })])
})
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
await act (async () => {
responses[0] ([buildTag ({ name: '虹夏' })])
})
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
expect (screen.getByText ('ぼっち')).toBeInTheDocument ()
unmount ()
expect (currentSignal?.aborted).toBe (true)
})
}) })
+29 -132
ファイルの表示
@@ -1,83 +1,46 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する. import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox'
import { useLocaleNavigate } from '@/lib/useLocale'
import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import type { ChangeEvent, FC, FormEvent } from 'react'
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
const TagSearch: FC = () => { const TagSearch: FC = () => {
const { message } = useUITranslation ('shared') const { message } = useUITranslation ('shared')
const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('') const [search, setSearch] = useState ('')
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const suggestions = result.locale === locale && result.request === request
? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => { const handleTagSelect = (tag: Tag) => {
const value = ev.target.value const parts = search.split (' ')
const query = value.trim ().split (' ').at (-1) ?? '' parts[parts.length - 1] = (
setSearch (value) (parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:')
setRequest ({ query }) ? ('not:' + tag.name)
setActiveIndex (-1) : tag.name)
setSuggestionsVsbl (query !== '') setSearch (parts.join (' ') + ' ')
} }
useEffect (() => { const { inputProps, suggestionProps, requestSuggestions,
if (request.query === '') clearSuggestions, compositionFlgRef } = useTagAutocomplete ({
return token: search.trim ().split (' ').at (-1) ?? '',
const controller = new AbortController () nicoFlg: true,
let active = true onSelect: handleTagSelect })
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => { const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
setActiveIndex (-1) const value = event.target.value
setSuggestionsVsbl (false) setSearch (value)
}, [locale]) requestSuggestions (value.trim ().split (' ').at (-1) ?? '')
}
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => { const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
ev.preventDefault () event.preventDefault ()
if (compositionFlgRef.current) if (compositionFlgRef.current)
return return
@@ -90,61 +53,7 @@ const TagSearch: FC = () => {
.filter (t => t !== 'type:or') .filter (t => t !== 'type:or')
.join (' '), .join (' '),
match })).toString () }`) match })).toString () }`)
setSuggestionsVsbl (false) clearSuggestions ()
setRequest ({ query: '' })
}
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| ev.nativeEvent.isComposing
|| ev.nativeEvent.keyCode === 229)
{
if (ev.key === 'Enter')
ev.preventDefault ()
return
}
switch (ev.key)
{
case verticalPreview ? 'ArrowRight' : 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case verticalPreview ? 'ArrowLeft' : 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
{
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
if (selected == null)
break
ev.preventDefault ()
handleTagSelect (selected)
}
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
}
const handleTagSelect = (tag: Tag) => {
const parts = search.split (' ')
parts[parts.length - 1] = (
(parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:')
? ('not:' + tag.name)
: tag.name)
setSearch (parts.join (' ') + ' ')
setRequest ({ query: '' })
setSuggestionsVsbl (false)
setActiveIndex (-1)
} }
useEffect (() => { useEffect (() => {
@@ -152,33 +61,21 @@ const TagSearch: FC = () => {
const anyFlg = query.get ('match') === 'any' const anyFlg = query.get ('match') === 'any'
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }` const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
setSearch (tagsQuery) setSearch (tagsQuery)
setRequest ({ query: '' }) clearSuggestions ()
setActiveIndex (-1) }, [location.search, clearSuggestions])
setSuggestionsVsbl (false)
}, [location.search])
return ( return (
<form onSubmit={handleSubmit} className="relative w-full"> <form onSubmit={handleSubmit} className="relative w-full">
<input type="text" <input type="text"
{...inputProps}
enterKeyHint="search" enterKeyHint="search"
data-shortcut-focus-search="true" data-shortcut-focus-search="true"
placeholder={message ('labels.tagSearch').text} placeholder={message ('labels.tagSearch').text}
value={search} value={search}
onChange={whenChanged} onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
compositionFlgRef.current = true
}}
onCompositionEnd={() => {
compositionFlgRef.current = false
}}
className={inputClass (false, className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox {...suggestionProps}/>
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</form>) </form>)
} }
+1 -1
ファイルの表示
@@ -15,7 +15,7 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
return return
return ( return (
<ul className="tag-search-suggestions absolute left-0 right-0 z-50 w-full <ul className="absolute left-0 right-0 z-50 w-full
bg-gray-100 dark:bg-gray-800 border dark:border-gray-600 bg-gray-100 dark:bg-gray-800 border dark:border-gray-600
rounded shadow"> rounded shadow">
{suggestions.map ((tag, i) => ( {suggestions.map ((tag, i) => (
+41 -74
ファイルの表示
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate, useUILocale, useVerticalPreview } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText' import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator' import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
@@ -12,9 +12,9 @@ import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText' import TranslatedText from '@/components/TranslatedText'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isUILocaleCode, import { isLocaleCode,
localeFromPath,
switchLocalePath, switchLocalePath,
uiLocaleFromPath,
unlocalisedPath } from '@/lib/locales' unlocalisedPath } from '@/lib/locales'
import { uiMessage, uiMessageLanguage } from '@/lib/messages' import { uiMessage, uiMessageLanguage } from '@/lib/messages'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
@@ -28,13 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, UILocaleCode } from '@/lib/locales' import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { type Props = {
user: User | null user: User | null
locales: AvailableLocale[] locales: AvailableLocale[]
resolveLocaleSwitch?: (locale: UILocaleCode) => Promise<string> } resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
type NavDensity = 'auto' | 'comfortable' | 'compact' type NavDensity = 'auto' | 'comfortable' | 'compact'
@@ -47,7 +47,7 @@ export const menuOutline = (
pathName: string }, pathName: string },
): Menu => { ): Menu => {
const pathName = unlocalisedPath (fullPathname) const pathName = unlocalisedPath (fullPathname)
const locale = uiLocaleFromPath (fullPathname) const locale = localeFromPath (fullPathname) ?? 'ja'
const message = ( const message = (
key: Parameters<typeof uiMessage>[1], key: Parameters<typeof uiMessage>[1],
@@ -137,10 +137,7 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => { const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared') const { message, label } = useUITranslation ('shared')
const locale = useLocale () const locale = useLocale ()
const uiLocale = useUILocale () const localeName = locales.find (entry => entry.code === locale)?.name
const verticalPreview = useVerticalPreview ()
const uiLocales = locales.filter (entry => isUILocaleCode (entry.code))
const localeName = uiLocales.find (entry => entry.code === uiLocale)?.name
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const location = useLocation () const location = useLocation ()
const currentLocation = useRef (location) const currentLocation = useRef (location)
@@ -183,12 +180,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
let node: HTMLElement | null = el let node: HTMLElement | null = el
while (node != null && node !== nav) while (node != null && node !== nav)
{ {
left += verticalPreview ? node.offsetTop : node.offsetLeft left += node.offsetLeft
const parent: Element | null = node.offsetParent const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null node = parent instanceof HTMLElement ? parent : null
} }
const width = node === nav const width = node === nav ? el.offsetWidth : 0
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
const visible = width > 0 const visible = width > 0
setHL (current => setHL (current =>
(current.left === left && current.width === width && current.visible === visible) (current.left === left && current.width === width && current.visible === visible)
@@ -206,7 +202,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const [moreVsbl, setMoreVsbl] = useState (false) const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0) const [submenuHeight, setSubmenuHeight] = useState (0)
const [submenuWidth, setSubmenuWidth] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '') const wikiIdStr = String (wikiId ?? '')
@@ -260,22 +255,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
animationsOff animationsOff
? undefined ? undefined
: (reducedAnimations : (reducedAnimations
? 'transition-[transform,width,height] duration-75 ease-linear' ? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width,height] duration-200 ease-out') : 'transition-[transform,width] duration-200 ease-out')
const submenuItemMotion = animationsOff ? { } : {
initial: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 } }
const submenuVariants = {
enter: (direction: -1 | 1) => ({
x: verticalPreview ? direction * 24 : 0,
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
centre: { x: 0, y: 0, opacity: 1 },
exit: (direction: -1 | 1) => ({
x: verticalPreview ? (-direction) * 24 : 0,
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
const prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -306,14 +287,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
return return
} }
if (verticalPreview)
{
setDesktop (true)
setDensity ('auto')
measure (moreVsbl ? -1 : activeIdx)
return
}
// Measure the same desktop elements while the mobile layout is active. // Measure the same desktop elements while the mobile layout is active.
layout.setAttribute ('data-measuring', '') layout.setAttribute ('data-measuring', '')
const previousDensity = layout.getAttribute ('data-density') ?? 'auto' const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
@@ -378,8 +351,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
removeEventListener ('resize', schedule) removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule) document.fonts?.removeEventListener ('loadingdone', schedule)
} }
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
menuLabels, verticalPreview])
useEffect (() => { useEffect (() => {
if (desktop == null || layoutReady) if (desktop == null || layoutReady)
@@ -406,8 +378,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
measure (moreVsbl ? -1 : activeIdx) measure (moreVsbl ? -1 : activeIdx)
if (!(desktop)) if (!(desktop))
setMoreVsbl (false) setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, }, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
menuLabels, verticalPreview])
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -421,7 +392,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const update = () => { const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height)) setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
} }
const observer = const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update) typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
@@ -433,7 +403,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
observer?.disconnect () observer?.disconnect ()
removeEventListener ('resize', update) removeEventListener ('resize', update)
} }
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview]) }, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => { useEffect (() => {
setMenuOpen (false) setMenuOpen (false)
@@ -445,18 +415,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
rounded px-1 focus-within:ring-2"> rounded px-1 focus-within:ring-2">
<Globe aria-hidden="true" size={16}/> <Globe aria-hidden="true" size={16}/>
<span className="top-nav-locale-name whitespace-nowrap text-sm"> <span className="top-nav-locale-name whitespace-nowrap text-sm">
<LocalisedText language={uiLocale}>{localeName}</LocalisedText> <LocalisedText language={locale}>{localeName}</LocalisedText>
</span> </span>
<select <select
aria-label={formatUIString (localeName ?? '', uiLocale)} aria-label={formatUIString (localeName ?? '', locale)}
lang={uiLocale} value={locale}
value={uiLocale}
disabled={switchingLocale} disabled={switchingLocale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0" className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={async event => { onChange={async event => {
const nextLocale = event.target.value const nextLocale = event.target.value
if (isUILocaleCode (nextLocale) if (isLocaleCode (nextLocale)
&& uiLocales.some (entry => entry.code === nextLocale)) && locales.some (entry => entry.code === nextLocale))
{ {
const request = ++localeSwitchRequest.current const request = ++localeSwitchRequest.current
setSwitchingLocale (true) setSwitchingLocale (true)
@@ -481,8 +450,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
} }
} }
}}> }}>
{uiLocales.map (entry => ( {locales.map (entry => isLocaleCode (entry.code) && (
<option key={entry.code} value={entry.code} lang={entry.code}> <option key={entry.code} value={entry.code}>
{formatUIString (entry.name, entry.code)} {formatUIString (entry.name, entry.code)}
</option>))} </option>))}
</select> </select>
@@ -492,7 +461,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0" <div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop ?? undefined} data-density={density} data-desktop={desktop ?? undefined} data-density={density}
data-layout-ready={layoutReady}> data-layout-ready={layoutReady}>
<nav ref={barRef} lang={uiMessageLanguage (uiLocale, 'posts')} <nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
className="top-nav-root top-nav-bar flex justify-between items-center w-full"> className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12"> <div className="top-nav-primary flex shrink-0 items-center h-12">
<PrefetchLink <PrefetchLink
@@ -510,11 +479,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight', 'top-nav-highlight',
highlightTransitionClass)} highlightTransitionClass)}
style={{ width: verticalPreview ? '100%' : hl.width, style={{ width: hl.width,
height: verticalPreview ? hl.width : undefined, transform: `translateX(${ hl.left }px)`,
transform: verticalPreview
? `translateY(${ hl.left }px)`
: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/> opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
@@ -588,9 +554,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
layout layout
className="top-nav-desktop-submenu relative z-20 shrink-0 className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu" overflow-hidden top-nav-submenu"
animate={verticalPreview && desktop animate={{ height: desktop == null ? 'auto' : submenuHeight }}
? { width: submenuWidth, height: '100%' }
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
@@ -602,8 +566,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
onLayoutAnimationComplete={() => { onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current) measure (highlightTargetRef.current)
}}> }}>
<div ref={submenuContentRef} <div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
{moreVsbl {moreVsbl
? ( ? (
moreMenu.map ((item, i) => ( moreMenu.map ((item, i) => (
@@ -613,11 +576,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{...((item.visible ?? true) {...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` } ? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { } : (animationsOff ? { }
: { initial: { : { initial: { x: 40, y: -40, opacity: 0 },
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { exit: { x: 40, y: -40, opacity: 0 } }))}
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)} onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold"> className="z-10 min-w-0 px-3 py-2 font-bold">
<h2> <h2>
@@ -638,7 +599,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition} transition={topNavTransition}
{...(visibleMenu[activeIdx] === item {...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}> : (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component} {subItem.component}
</motion.div>) </motion.div>)
: ( : (
@@ -648,7 +612,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition} transition={topNavTransition}
{...(visibleMenu[activeIdx] === item {...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` } ? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}> : (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink <PrefetchLink
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
@@ -666,8 +633,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="relative min-h-[40px]"> <div className="relative min-h-[40px]">
{animationsOff {animationsOff
? ( ? (
<div className="top-nav-submenu-items flex min-h-[40px] <div className="flex min-h-[40px] flex-wrap items-center px-3">
flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? []) {(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true) .filter (item => item.visible ?? true)
.map ((item, i) => ( .map ((item, i) => (
@@ -696,9 +662,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<motion.div <motion.div
key={activeIdx} key={activeIdx}
custom={dir} custom={dir}
variants={submenuVariants} variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
className="top-nav-submenu-items relative flex min-h-[40px] centre: { y: 0, opacity: 1 },
flex-wrap items-center px-3" exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter" initial="enter"
animate="centre" animate="centre"
exit="exit" exit="exit"
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ type Props = {
const Form: FC<Props> = ({ children, className }) => ( const Form: FC<Props> = ({ children, className }) => (
<div className={cn ('form-layout mx-auto max-w-xl space-y-4 p-4', className)}> <div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
{children} {children}
</div>) </div>)
+1 -1
ファイルの表示
@@ -61,7 +61,7 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const pages = getPages (page, totalPages, siblingCount) const pages = getPages (page, totalPages, siblingCount)
return ( return (
<nav className="pagination mt-4 flex justify-center" <nav className="mt-4 flex justify-center"
lang={message ('pagination.label').language} lang={message ('pagination.label').language}
aria-label={message ('pagination.label').text}> aria-label={message ('pagination.label').text}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+53 -10
ファイルの表示
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
@@ -20,18 +20,20 @@ describe ('TagInput', () => {
const setValue = vi.fn () const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="ぼっち 虹" setValue={setValue}/>) const { rerender } = render (
<TagInput value="ぼっち 虹" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
rerender (<TagInput value="ぼっち 虹夏" setValue={setValue}/>)
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete', {
{ locale: 'ja', params: { q: '虹夏', nico: '0' }, locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) }, signal: expect.any (AbortSignal) })
)
}) })
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
expect (await screen.findByText ('虹夏')).toBeInTheDocument ()
}) })
it ('does not fetch when the last token is blank', () => { it ('does not fetch when the last token is blank', () => {
@@ -54,10 +56,51 @@ describe ('TagInput', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete', {
{ locale: 'ja', params: { q: '虹夏', nico: '1' }, locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) }, signal: expect.any (AbortSignal) })
)
}) })
}) })
it ('preserves negation and blocks suggestion selection during composition', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏' })])
const { rerender } = render (<TagInput value="" setValue={setValue}/>)
const input = screen.getByRole ('textbox')
fireEvent.change (input, { target: { value: 'ぼっち NOT:虹' } })
rerender (<TagInput value="ぼっち NOT:虹" setValue={setValue}/>)
await screen.findByText ('虹夏')
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/autocomplete', {
locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) })
fireEvent.keyDown (input, { key: 'ArrowDown' })
fireEvent.compositionStart (input)
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (false)
expect (setValue).toHaveBeenCalledTimes (1)
fireEvent.compositionEnd (input)
fireEvent.keyDown (input, { key: 'Enter' })
expect (setValue).toHaveBeenLastCalledWith ('ぼっち not:虹夏 ')
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
})
it.each ([false, true]) ('includes unused tags only with includeUnused=%s', async unusedFlg => {
const tag = buildTag ({ name: '虹夏', postCount: 0 })
const response = Promise.resolve ([tag])
api.apiGet.mockReturnValueOnce (response)
const props = { setValue: vi.fn (), includeUnused: unusedFlg }
const { rerender } = render (<TagInput {...props} value=""/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹' } })
rerender (<TagInput {...props} value="虹"/>)
await act (async () => {
await response
})
expect (screen.queryByText ('虹夏') != null).toBe (unusedFlg)
})
}) })
+16 -116
ファイルの表示
@@ -1,11 +1,8 @@
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api' import { useTagAutocomplete } from '@/lib/useTagAutocomplete'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent, KeyboardEvent } from 'react' import type { FC, ChangeEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -19,136 +16,39 @@ type Props = {
setValue: (value: string) => void } setValue: (value: string) => void }
const TagInput: FC<Props> = ({ const TagInput: FC<Props> = (
includeNico = false, { includeNico: nicoFlg = false,
includeUnused = false, includeUnused: unusedFlg = false,
describedBy, describedBy, invalid, value, setValue },
invalid, ) => {
value,
setValue,
}) => {
const locale = useLocale ()
const [activeIndex, setActiveIndex] = useState (-1)
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
includeNico: boolean
request: { query: string }
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
const suggestions = (result.locale === locale && result.includeNico === includeNico
&& result.request === request
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal,
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
if (active)
{
setResult ({ locale, includeNico, request,
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, includeNico, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, includeNico, includeUnused])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale, includeNico])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ') const parts = value?.split (' ')
const negative = /^not:/i.test (parts[parts.length - 1]) const negative = /^not:/i.test (parts[parts.length - 1])
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}` parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
setValue (parts.join (' ') + ' ') setValue (parts.join (' ') + ' ')
setRequest ({ query: '' })
setActiveIndex (-1)
} }
// TODO: TagSearch からのコピペのため,共通化を考へる. const { inputProps, suggestionProps, requestSuggestions } =
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '',
switch (ev.key) nicoFlg, unusedFlg, onSelect: handleTagSelect })
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp': const whenChanged = (event: ChangeEvent<HTMLInputElement>) => {
ev.preventDefault () setValue (event.target.value)
setActiveIndex (i => Math.max (i - 1, -1)) const token = event.target.value.trim ().split (' ').at (-1) ?? ''
setSuggestionsVsbl (true) requestSuggestions (token, token.replace (/^not:/i, ''))
break
case 'Enter':
if (activeIndex < 0)
break
{
ev.preventDefault ()
const selected = suggestions[activeIndex]
if (selected)
handleTagSelect (selected)
}
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
} }
return ( return (
<div className="relative"> <div className="relative">
<input <input
{...inputProps}
type="text" type="text"
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
value={value} value={value}
onChange={whenChanged} onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className={inputClass (invalid)}/> className={inputClass (invalid)}/>
<TagSearchBox <TagSearchBox {...suggestionProps}/>
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>) </div>)
} }
+1 -1
ファイルの表示
@@ -12,7 +12,7 @@ export default forwardRef<HTMLTextAreaElement, Props> (
<textarea <textarea
ref={ref} ref={ref}
aria-invalid={invalid} aria-invalid={invalid}
className={cn ('rounded border [inline-size:100%] p-2 [block-size:8rem]', className={cn ('rounded border w-full p-2 h-32',
(invalid (invalid
? ['border-red-500 bg-red-50 text-red-900', ? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none focus:ring-2', 'focus:border-red-500 focus:outline-none focus:ring-2',
+5 -58
ファイルの表示
@@ -1,10 +1,7 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { clientAnimationTransition } from '@/lib/clientAnimation' import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useVerticalPreview } from '@/lib/useLocale'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -12,71 +9,21 @@ import type { FC, ReactNode } from 'react'
type Props = { type Props = {
children: ReactNode children: ReactNode
className?: string className?: string
animateLayout?: boolean animateLayout?: boolean }
verticalContent?: boolean }
const MainArea: FC<Props> = ( const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
{ children, className, animateLayout = true, verticalContent = true },
) => {
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const horizontal = verticalPreview && verticalContent
const { pathname, search } = useLocation ()
const rootRef = useRef<HTMLElement | null> (null)
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
animationMode, animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } }) { normal: { duration: .2, ease: 'easeOut' as const } },
)
useEffect (() => {
if (horizontal && rootRef.current != null)
rootRef.current.scrollLeft = 0
}, [horizontal, pathname, search])
useEffect (() => {
const root = rootRef.current
if (!horizontal || root == null)
return
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
return
// Nested tables, forms and code blocks retain their own scrolling.
let node = event.target instanceof HTMLElement ? event.target : null
while (node != null && node !== root)
{
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
return
const style = getComputedStyle (node)
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
return
node = node.parentElement
}
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
const delta = event.deltaY * unit
if ((delta < 0 && root.scrollLeft <= 0)
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
return
event.preventDefault ()
root.scrollLeft += delta
}
root.addEventListener ('wheel', handleWheel, { passive: false })
return () => root.removeEventListener ('wheel', handleWheel)
}, [horizontal])
return ( return (
<motion.main <motion.main
ref={rootRef}
data-vertical-content={horizontal || undefined}
tabIndex={horizontal ? 0 : undefined}
transition={{ layout: layoutTransition }} transition={{ layout: layoutTransition }}
className={cn ('main-area flex-1 overflow-y-auto p-4', className)} className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}> layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
{children} {children}
</motion.main>) </motion.main>)
-10
ファイルの表示
@@ -70,16 +70,6 @@ describe ('SidebarComponent', () => {
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
}) })
it ('limits sidebar scrolling to desktop to avoid clipping mobile suggestions', () => {
renderSidebar ()
// jsdom has no layout; guard the responsive classes responsible for clipping.
const sidebar = screen.getByText ('Sidebar body')
expect (sidebar).not.toHaveClass ('overflow-y-auto')
expect (sidebar).toHaveClass ('md:overflow-y-auto')
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => { it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700') localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn () const onWidthChange = vi.fn ()
+104 -127
ファイルの表示
@@ -1,14 +1,13 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation' import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { useVerticalPreview } from '@/lib/useLocale'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right' type SidebarSide = 'left' | 'right'
@@ -24,12 +23,12 @@ type Props = {
type ResizeState = { type ResizeState = {
pointerId: number pointerId: number
startSize: number startWidth: number
startX: number startX: number
startY: number
verticalPreview: boolean
side: SidebarSide } side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256 const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192 const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360 const MIN_MAIN_WIDTH = 360
@@ -38,97 +37,84 @@ const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8 const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = ( const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
sidebarKey: string, `sidebar:${ sidebarKey }:${ side }:width`
side: SidebarSide,
verticalPreview = false,
): string =>
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
const getSidebarMaxWidth = (containerSize: number): number => { const getSidebarMaxWidth = (containerWidth: number): number => {
const viewportWidth = const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH) const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth) const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth) return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
} }
const readStoredSidebarSize = ( const readStoredSidebarWidth = (
sidebarKey: string, sidebarKey: string,
side: SidebarSide, side: SidebarSide): number => {
verticalPreview: boolean,
): number => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH return DEFAULT_SIDEBAR_WIDTH
try const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
{ const width = value == null ? Number.NaN : Number (value)
const value = window.localStorage.getItem (
storageKeyForSidebar (sidebarKey, side, verticalPreview)) return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
const size = value == null ? Number.NaN : Number (value)
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
}
catch
{
return DEFAULT_SIDEBAR_WIDTH
}
} }
const SidebarComponent: FC<Props> = ( const SidebarComponent: FC<Props> = ({
{ children, className, maxWidth, maxWidthRatio, animateLayout = true, children,
onWidthChange, sidebarKey, side = 'left' }, className,
) => { maxWidth,
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared') const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
animationMode, animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } }) { normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null) const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth) const maxWidthRef = useRef (maxWidth)
const maxWidthRatioRef = useRef (maxWidthRatio) const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerSize = useCallback (() => { const getContainerWidth = useCallback (() => {
const container = rootRef.current?.closest ('[data-sidebar-container]') const containerWidth = rootRef.current
return verticalPreview ?.closest ('[data-sidebar-container]')
? container?.clientHeight ?? window.innerHeight ?.clientWidth
: container?.clientWidth ?? window.innerWidth ?? window.innerWidth
}, [verticalPreview]) return containerWidth
const getClampedSize = useCallback (( }, [])
nextSize: number, const getClampedWidth = useCallback ((
containerSize: number, nextWidth: number,
): number => { containerWidth: number): number => {
if (verticalPreview)
{
const maximum = Math.max (64, Math.min (
window.innerHeight * .6, containerSize - 160))
return Math.min (maximum, Math.max (64, nextSize))
}
const viewportWidth = const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO) const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const ratioMaxWidth = maxWidthRatioRef.current == null const ratioMaxWidth = maxWidthRatioRef.current == null
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current) ? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
const configuredMaxWidth = maxWidthRef.current == null const configuredMaxWidth = maxWidthRef.current == null
? getSidebarMaxWidth (containerSize) ? getSidebarMaxWidth (containerWidth)
: Math.max (MIN_SIDEBAR_WIDTH, : Math.max (MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current)) Math.min (viewportMaxWidth, maxWidthRef.current))
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH, const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
Math.min (ratioMaxWidth, configuredMaxWidth)) Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize)) return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [verticalPreview]) }, [])
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH) const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false) const [resizing, setResizing] = useState (false)
const sizeRef = useRef (size) const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null) const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<ResizeState | null> (null) const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null) const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null) const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
@@ -162,7 +148,7 @@ const SidebarComponent: FC<Props> = (
userSelect: document.body.style.userSelect } userSelect: document.body.style.userSelect }
} }
document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize' document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
}, [clearLongPressTimer]) }, [clearLongPressTimer])
@@ -173,50 +159,43 @@ const SidebarComponent: FC<Props> = (
const state = resizeRef.current const state = resizeRef.current
resizeRef.current = null resizeRef.current = null
try if (state != null)
{ window.localStorage.setItem (
if (state != null) storageKeyForSidebar (sidebarKey, state.side),
window.localStorage.setItem ( String (widthRef.current))
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
String (sizeRef.current))
}
catch
{
// Resizing remains usable when browser storage is unavailable.
}
setResizing (false) setResizing (false)
restoreBodyStyle () restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey]) }, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applySize = useCallback ((nextSize: number) => { const applyWidth = useCallback ((nextWidth: number) => {
if (nextSize === sizeRef.current) if (nextWidth === widthRef.current)
return return
sizeRef.current = nextSize widthRef.current = nextWidth
setSize (nextSize) setWidth (nextWidth)
}, []) }, [])
useEffect (() => { useEffect (() => {
maxWidthRef.current = maxWidth maxWidthRef.current = maxWidth
maxWidthRatioRef.current = maxWidthRatio maxWidthRatioRef.current = maxWidthRatio
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const nextSize = getClampedSize (sizeRef.current, containerSize) const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applySize (nextSize) applyWidth (nextWidth)
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio]) }, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
const updateSize = useCallback ((clientX: number, clientY: number) => { const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current const state = resizeRef.current
if (state == null) if (state == null)
return return
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const delta = state.verticalPreview const delta = state.side === 'left'
? clientY - state.startY ? clientX - state.startX
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX) : state.startX - clientX
const nextSize = getClampedSize (state.startSize + delta, containerSize) const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applySize (nextSize) applyWidth (nextWidth)
}, [applySize, getClampedSize, getContainerSize]) }, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => { const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch') if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
@@ -232,10 +211,8 @@ const SidebarComponent: FC<Props> = (
const state = { const state = {
pointerId: ev.pointerId, pointerId: ev.pointerId,
side, side,
startSize: sizeRef.current, startWidth: widthRef.current,
startX: ev.clientX, startX: ev.clientX }
startY: ev.clientY,
verticalPreview }
if (ev.pointerType === 'mouse') if (ev.pointerType === 'mouse')
{ {
@@ -243,7 +220,7 @@ const SidebarComponent: FC<Props> = (
return return
} }
pendingPressRef.current = state pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer () clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => { longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null) if (pendingPressRef.current != null)
@@ -257,7 +234,7 @@ const SidebarComponent: FC<Props> = (
if (resizeRef.current != null) if (resizeRef.current != null)
{ {
ev.preventDefault () ev.preventDefault ()
updateSize (ev.clientX, ev.clientY) updateWidth (ev.clientX)
return return
} }
@@ -285,58 +262,58 @@ const SidebarComponent: FC<Props> = (
finishResize () finishResize ()
} }
const resetSize = useCallback (() => { const resetWidth = useCallback (() => {
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize) const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applySize (nextSize) applyWidth (nextWidth)
window.localStorage.removeItem ( window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
storageKeyForSidebar (sidebarKey, side, verticalPreview)) }, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => { const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault () ev.preventDefault ()
ev.stopPropagation () ev.stopPropagation ()
resetSize () resetWidth ()
} }
useEffect (() => { useEffect (() => {
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const nextSize = getClampedSize ( const nextWidth = getClampedWidth (
readStoredSidebarSize (sidebarKey, side, verticalPreview), readStoredSidebarWidth (sidebarKey, side),
containerSize) containerWidth)
applySize (nextSize) applyWidth (nextWidth)
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview]) }, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => { useEffect (() => {
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const nextSize = getClampedSize (sizeRef.current, containerSize) const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applySize (nextSize) applyWidth (nextWidth)
}, [applySize, getClampedSize, getContainerSize]) }, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => { useEffect (() => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return return
const handleResize = () => { const handleResize = () => {
const containerSize = getContainerSize () const containerWidth = getContainerWidth ()
const nextSize = getClampedSize (sizeRef.current, containerSize) const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applySize (nextSize) applyWidth (nextWidth)
} }
window.addEventListener ('resize', handleResize) window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize) return () => window.removeEventListener ('resize', handleResize)
}, [applySize, getClampedSize, getContainerSize]) }, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => { useEffect (() => {
if (!verticalPreview) onWidthChange?.(width)
onWidthChange?.(size) }, [onWidthChange, width])
}, [onWidthChange, size, verticalPreview])
useEffect (() => () => finishResize (), [finishResize, verticalPreview]) useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = { const style = {
'--sidebar-width': `${ size }px`, '--sidebar-width': `${ width }px` } as CSSProperties
'--sidebar-height': `${ size }px` } as CSSProperties
return ( return (
<motion.div <motion.div
@@ -345,7 +322,7 @@ const SidebarComponent: FC<Props> = (
transition={{ layout: layoutTransition }} transition={{ layout: layoutTransition }}
style={style} style={style}
className={cn ( className={cn (
'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full', 'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}> className)}>
<Helmet> <Helmet>
<style> <style>
@@ -362,22 +339,22 @@ const SidebarComponent: FC<Props> = (
</style> </style>
</Helmet> </Helmet>
<div className="h-full md:overflow-y-auto p-4 sidebar"> <div className="h-full overflow-y-auto p-4 sidebar">
{children} {children}
</div> </div>
<div <div
role="separator" role="separator"
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'} aria-orientation="vertical"
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text} aria-label={message ('sidebarWidth').text}
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language} lang={message ('sidebarWidth').language}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd} onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd} onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
className={cn ( className={cn (
'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize', 'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none', 'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2', side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px', 'before:absolute before:top-0 before:bottom-0 before:w-px',
+4 -4
ファイルの表示
@@ -32,10 +32,10 @@ const buttonVariants = cva (
link: link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'}, 'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
size: { size: {
default: '[block-size:2.5rem] [padding-inline:1rem] [padding-block:.5rem]', default: 'h-10 px-4 py-2',
sm: '[block-size:2.25rem] rounded-md [padding-inline:.75rem]', sm: 'h-9 rounded-md px-3',
lg: '[block-size:2.75rem] rounded-md [padding-inline:2rem]', lg: 'h-11 rounded-md px-8',
icon: '[block-size:2.5rem] [inline-size:2.5rem]'}}, icon: 'h-10 w-10'}},
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default'}}) size: 'default'}})
-9
ファイルの表示
@@ -1,9 +0,0 @@
import { describe, expect, it } from 'vitest'
import { API_BASE_URL } from '@/config'
describe ('test API configuration', () => {
it ('resolves the sample configuration without a local config file', () => {
expect (API_BASE_URL).toBe ('/api')
})
})
+6 -11
ファイルの表示
@@ -9,8 +9,7 @@ import { createStaticHandler, createStaticRouter,
import { AppFrame, appRoutes, resolvePageRoute } from '@/App' import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api' import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n' import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
legacyLocalePath, uiLocaleFromPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server' import { withRenderContext } from '@/lib/renderContext.server'
@@ -73,16 +72,13 @@ export const render = async (
pathname => resolvePageRoute (pathname) != null) pathname => resolvePageRoute (pathname) != null)
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1] const segment = pathname.split ('/')[1]
const verticalPreview = isVerticalPreviewPath (pathname)
const known = resolvePageRoute (pathname) const known = resolvePageRoute (pathname)
if (legacy != null && known != null) if (legacy != null && known != null)
return { status: 308, location: legacy } return { status: 308, location: legacy }
const locale = isLocaleCode (segment) ? segment : 'ja' const locale = isLocaleCode (segment) ? segment : 'ja'
const uiLocale = uiLocaleFromPath (pathname)
const routePrefix = verticalPreview ? 'mn-Mong' : locale
if (known?.route.handle?.redirectTo != null) if (known?.route.handle?.redirectTo != null)
return { status: 308, return { status: 308,
location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` } location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext: RenderContext & { status: number } = { const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL, pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 } ...authentication, status: 200 }
@@ -110,15 +106,14 @@ export const render = async (
} }
} }
const prepare = known?.route.handle?.prepare const prepare = known?.route.handle?.prepare
const routeLocaleValid = isLocaleCode (segment) || verticalPreview let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
let status = known == null || !active || !routeLocaleValid ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null) && client.getQueryData (sessionKeys.current) == null)
status = 401 status = 401
if (prepare != null && known != null && status === 200) if (prepare != null && known != null && status === 200)
status = await prepare ({ client, params: known.params, locale, status = await prepare ({ client, params: known.params, locale,
searchParams: url.searchParams }) searchParams: url.searchParams })
const instance = createUII18n (uiLocale) const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes) const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url)) const context = await handler.query (new Request (url))
if (context instanceof Response) if (context instanceof Response)
@@ -188,11 +183,11 @@ export const render = async (
isAxiosError: true, response: { isAxiosError: true, response: {
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } } status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
})) } })) }
const bootstrap = JSON.stringify ({ state, status, locale: uiLocale, const bootstrap = JSON.stringify ({ state, status, locale,
url: url.pathname + url.search }) url: url.pathname + url.search })
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028') .replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
.replace (/\u2029/g, '\\u2029') .replace (/\u2029/g, '\\u2029')
return { status, html, head, locale: uiLocale, bootstrap, return { status, html, head, locale, bootstrap,
setCookies: requestContext.setCookies } setCookies: requestContext.setCookies }
} }
finally finally
-639
ファイルの表示
@@ -625,642 +625,3 @@ body
white-space: nowrap; white-space: nowrap;
will-change: transform; will-change: transform;
} }
/* Locally hosted Mongolian font; the OFL accompanies the font file. */
@font-face
{
font-family: 'BTRC Mongolian';
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Mongolian flows down each line, then into the next column on the right. */
:lang(mn-Mong)
{
font-family: 'BTRC Mongolian', 'Mongolian Baiti', 'Noto Sans JP Variable', sans-serif;
}
/* Include portalled dialogues and toasts, and controls with UA writing modes. */
html:lang(mn-Mong) body > :not(#root),
html:lang(mn-Mong) #root > :not(.app-layout),
html:lang(mn-Mong) :is(button, input, select, textarea),
[data-vertical-preview] :is(
.main-area:not(.posts-main-area, .tags-main-area, .wiki-main-area),
.sidebar > *, .tag-list-filter, .post-list > *,
.top-nav-brand-link, .top-nav-menu-link,
.top-nav-user-desktop, .top-nav-user-mobile, .top-nav-mobile-menu)
{
writing-mode: vertical-lr;
text-orientation: mixed;
}
/* Existing Japanese vertical decorations must also advance to the right. */
html:lang(mn-Mong) [style*='writing-mode']
{
writing-mode: vertical-lr !important;
}
/* Inline alignment turns left/right into top/bottom; flex cross alignment
and table vertical-align already follow the vertical writing mode. */
html:lang(mn-Mong) .text-left
{
text-align: start;
}
html:lang(mn-Mong) .text-right
{
text-align: end;
}
[data-vertical-preview]
{
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] > :not(.top-nav-layout)
{
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-vertical-preview] [data-sidebar-container]
{
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] .main-area
{
min-width: 0;
min-height: 0;
overscroll-behavior: contain;
}
[data-vertical-preview] .main-area[data-vertical-content]
{
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
/* Specialised lists below retain their own grid and table layouts. */
[data-vertical-preview] .main-area[data-vertical-content]:not(
.posts-main-area, .tags-main-area, .wiki-main-area)
{
writing-mode: vertical-lr;
text-orientation: mixed;
text-align: start;
overflow-y: auto;
}
[data-vertical-preview] .main-area[data-vertical-content]:not(
.posts-main-area, .tags-main-area, .wiki-main-area) > :is(div, article, section)
{
max-width: none;
max-inline-size: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] .prose
{
max-width: none;
max-inline-size: 100%;
}
/* Input and data widgets retain nested scrolling in the vertical text flow. */
[data-vertical-preview] .main-area[data-vertical-content] :is(
form, .form-layout, pre, .overflow-x-auto, .rc-md-editor, .not-prose)
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 100%;
overflow: auto;
}
[data-vertical-preview] .main-area[data-vertical-content] :is(form, .form-layout)
{
width: auto;
inline-size: min(36rem, 100%);
}
[data-vertical-preview] :where(.main-area[data-vertical-content]) :is(
button, input, select, textarea, code, time, table)
{
writing-mode: vertical-lr;
}
[data-vertical-preview] .main-area[data-vertical-content] :is(img, video, iframe, canvas)
{
max-height: 100%;
object-fit: contain;
}
[data-vertical-preview] .sidebar-panel
{
width: 100%;
height: var(--sidebar-height);
min-height: 0;
flex: none;
}
[data-vertical-preview] .sidebar
{
display: flex;
align-items: flex-start;
gap: 1rem;
direction: ltr;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
}
[data-vertical-preview] .sidebar > *
{
flex-shrink: 0;
}
[data-vertical-preview] .sidebar > form
{
writing-mode: vertical-lr;
text-orientation: mixed;
display: block;
width: auto;
height: 100%;
}
[data-vertical-preview] .sidebar > form > input[type='text']
{
writing-mode: vertical-lr;
text-orientation: mixed;
width: auto;
height: 100%;
min-height: 0;
padding: .75rem .5rem;
}
[data-vertical-preview] .sidebar > form > .tag-search-suggestions
{
inset: auto;
inset-inline-start: 0;
inset-block-start: 100%;
width: auto;
inline-size: 100%;
max-block-size: min(16rem, 75vw);
margin: 0;
overflow: auto;
}
[data-vertical-preview] .sidebar > div
{
writing-mode: vertical-lr;
text-orientation: mixed;
height: 100%;
margin: 0;
}
[data-vertical-preview] .sidebar :is(h2, h3, ul)
{
margin: 0;
margin-block-end: 1rem;
}
[data-vertical-preview] .sidebar-resize
{
display: block;
inset: auto 0 -.5rem;
width: 100%;
height: 1rem;
cursor: row-resize;
}
[data-vertical-preview] .sidebar-resize::before
{
inset: 50% 0 auto;
width: 100%;
height: 1px;
}
[data-vertical-preview] .top-nav-locale-name:has(:lang(mn-Mong))
{
display: inline;
writing-mode: vertical-lr;
text-orientation: mixed;
font-size: 1.25rem;
line-height: 1.5;
flex-shrink: 0;
}
[data-vertical-preview] .top-nav-menu-link:has(.top-nav-locale-name)
{
height: auto;
min-height: 2rem;
}
/* The post grid fills each column downwards before continuing to the right. */
[data-vertical-preview] .posts-main-area > div
{
display: flex;
height: 100%;
width: max-content;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .posts-main-area > div > div:first-child
{
flex: none;
flex-direction: row;
justify-content: space-between;
height: 100%;
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .posts-main-area > div > div:last-child
{
display: flex;
width: max-content;
height: 100%;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list
{
display: grid;
grid-auto-flow: column;
grid-auto-columns: 10rem;
grid-template-rows: repeat(auto-fill, min(10rem, 100%));
flex: none;
width: max-content;
height: 100%;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list > a
{
height: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] nav
{
flex: none;
align-self: flex-start;
margin: 0;
writing-mode: vertical-lr;
}
[data-vertical-preview] .main-area[data-vertical-content] .pagination
{
align-self: stretch;
inline-size: 100%;
}
[data-vertical-preview] .tags-main-area
{
display: flex;
align-items: stretch;
gap: 1.5rem;
}
[data-vertical-preview] .tag-list-filter
{
flex: none;
width: auto;
inline-size: min(24rem, 100%);
max-inline-size: none;
overflow: auto;
}
[data-vertical-preview] .tag-list-filter h1
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 8rem;
margin-block: 0;
}
[data-vertical-preview] .tag-list-filter input[type='number']
{
max-inline-size: 7rem;
}
[data-vertical-preview] .tag-list-results
{
display: flex;
flex: none;
gap: 1rem;
max-width: none;
margin: 0;
}
[data-vertical-preview] .tag-list-table
{
max-width: none;
overflow-x: visible;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-table table
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: max-content;
block-size: max-content;
min-width: 0;
}
[data-vertical-preview] .tag-list-table col
{
width: auto;
}
[data-vertical-preview] .tag-list-table :is(th, td)
{
min-inline-size: 2rem;
max-inline-size: 16rem;
}
[data-vertical-preview] .tag-list-table thead
{
border-bottom: 0;
border-block-end: 2px solid currentColor;
}
[data-vertical-preview] .tag-list-table td:nth-child(2),
[data-vertical-preview] .tag-list-table time
{
writing-mode: vertical-lr;
white-space: nowrap;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee,
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__static
{
white-space: normal;
overflow: visible;
text-overflow: clip;
max-inline-size: 16rem;
opacity: 1;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__animated
{
display: none;
}
/* Prose flows through the viewport height and grows into columns on the right. */
[data-vertical-preview] .wiki-main-area
{
display: flex;
gap: 1rem;
}
[data-vertical-preview] .wiki-main-area > div
{
flex: none;
writing-mode: vertical-lr;
text-orientation: mixed;
margin: 0;
}
[data-vertical-preview] .wiki-main-area > div > span
{
writing-mode: vertical-lr;
}
[data-vertical-preview] .wiki-main-area article,
[data-vertical-preview] .posts-main-area .localised-document
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: 100%;
block-size: max-content;
max-width: none;
margin: 0;
flex: none;
}
[data-vertical-preview] .main-area[data-vertical-content] .localised-document
{
max-width: none;
overflow: visible;
}
[data-vertical-preview] .wiki-main-area article :is(h1, h2, h3, h4, p, ul, ol, blockquote)
{
margin: 0;
margin-block: 1em;
}
[data-vertical-preview] .wiki-main-area article :is(ul, ol)
{
padding: 0;
padding-inline-start: 1.5em;
}
[data-vertical-preview] .wiki-main-area article :is(img, video, iframe, svg)
{
writing-mode: horizontal-tb;
max-height: 100%;
max-width: none;
object-fit: contain;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose),
[data-vertical-preview] .main-area[data-vertical-content] :is(code, time, input, select, textarea)
{
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose)
{
display: inline-block;
max-height: 100%;
max-width: min(48rem, 80vw);
overflow: auto;
vertical-align: top;
}
[data-vertical-preview] .wiki-main-area article .not-prose
{
width: max-content;
height: 100%;
}
[data-vertical-preview] .wiki-main-area .not-prose > div
{
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
[data-vertical-preview] .wiki-main-area .not-prose > div > div:last-child
{
flex: 1;
min-height: 0;
}
@media (min-width: 768px)
{
[data-vertical-preview].app-layout
{
flex-direction: row;
}
[data-vertical-preview] .top-nav-layout
{
display: flex;
width: max-content;
max-width: 50vw;
height: 100%;
}
[data-vertical-preview] .top-nav-bar
{
flex-direction: column;
flex: none;
width: 3.5rem;
height: 100%;
padding: .5rem;
gap: .5rem;
}
[data-vertical-preview] .top-nav-primary
{
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
height: auto;
overflow-y: auto;
}
[data-vertical-preview] .top-nav-brand-link
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: none;
white-space: nowrap;
margin-block: .5rem;
}
[data-vertical-preview] .top-nav-desktop-menu
{
flex-direction: column;
width: 100%;
height: auto;
}
[data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight)
{
width: 100%;
}
[data-vertical-preview] .top-nav-desktop-menu .top-nav-menu-link
{
justify-content: center;
width: 100%;
min-height: 3rem;
height: auto;
padding: .5rem;
}
[data-vertical-preview] .top-nav-menu-link > :not(svg, select),
[data-vertical-preview] .top-nav-more-menu h2
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: none;
white-space: nowrap;
}
[data-vertical-preview] .top-nav-highlight
{
inset-inline-start: 0;
}
[data-vertical-preview] .top-nav-account
{
flex-direction: column;
width: 100%;
margin: 0;
}
[data-vertical-preview] .top-nav-account .top-nav-user-desktop
{
max-width: 100%;
margin: 0;
padding: 0;
}
[data-vertical-preview] .top-nav-locale-name
{
display: inline;
writing-mode: vertical-lr;
}
[data-vertical-preview] .top-nav-desktop-submenu
{
min-width: 0;
height: 100%;
}
[data-vertical-preview] .top-nav-submenu-content
{
writing-mode: vertical-lr;
text-orientation: mixed;
width: max-content;
max-width: 35vw;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
[data-vertical-preview] .top-nav-submenu-content:not(.top-nav-more-menu) > div
{
height: 100%;
}
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
{
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
max-height: 100%;
padding-inline: .75rem;
padding-block: 0;
}
[data-vertical-preview] .top-nav-desktop-submenu .top-nav-menu-link
{
min-height: 0;
min-width: 40px;
padding-inline: .75rem;
padding-block: .5rem;
}
[data-vertical-preview] .top-nav-submenu-items .top-nav-menu-link
{
justify-content: center;
}
}
@media (max-width: 767px)
{
[data-vertical-preview] .top-nav-layout
{
max-height: 50dvh;
overflow-y: auto;
}
}
+4 -11
ファイルの表示
@@ -1,11 +1,9 @@
import { createInstance } from 'i18next' import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import { MONGOLIAN_LOCALE, SUPPORTED_LOCALES } from '@/lib/locales'
import type { i18n as I18n, TOptions } from 'i18next' import type { i18n as I18n, TOptions } from 'i18next'
import type { UILocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
// Keep Japanese placeholders editable without reporting them as completed translations. // Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => { const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
@@ -36,14 +34,9 @@ for (const [path, dictionary] of Object.entries (dictionaries))
const [, , locale, filename] = path.split ('/') const [, , locale, filename] = path.split ('/')
resources[locale] ??= { } resources[locale] ??= { }
resources[locale][filename.slice (0, -5)] = resources[locale][filename.slice (0, -5)] =
locale === 'ja' ? dictionary : translatedResource (dictionary) locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
} }
const namespaces = Object.keys (resources.ja ?? { }) const namespaces = Object.keys (resources.ja ?? { })
// No Mongolian translations yet; preserve the existing fallback language metadata.
resources[MONGOLIAN_LOCALE.code] ??= { }
for (const namespace of namespaces)
resources[MONGOLIAN_LOCALE.code][namespace] ??=
translatedResource ({ _untranslated: ['*'] })
export const createUII18n = (locale: string): I18n => { export const createUII18n = (locale: string): I18n => {
@@ -51,7 +44,7 @@ export const createUII18n = (locale: string): I18n => {
void instance.init ({ void instance.init ({
lng: locale, lng: locale,
fallbackLng: 'ja', fallbackLng: 'ja',
supportedLngs: [...SUPPORTED_LOCALES, MONGOLIAN_LOCALE.code], supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly', load: 'currentOnly',
initAsync: false, initAsync: false,
defaultNS: 'navigation', defaultNS: 'navigation',
@@ -129,7 +122,7 @@ export const languageUIMessage = (
export const untranslatedKeys = ( export const untranslatedKeys = (
locale: UILocaleCode, locale: LocaleCode,
namespace = 'navigation', namespace = 'navigation',
): string[] => { ): string[] => {
const collect = (resource: Record<string, unknown>, prefix = ''): string[] => const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
+6 -26
ファイルの表示
@@ -7,10 +7,6 @@ export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
export type LocaleCode = typeof SUPPORTED_LOCALES[number] export type LocaleCode = typeof SUPPORTED_LOCALES[number]
// This UI locale uses Japanese content until the API supports Mongolian.
export const MONGOLIAN_LOCALE = { code: 'mn-Mong', name: 'ᠮᠣᠩᠭᠣᠯ' } as const
export type UILocaleCode = LocaleCode | typeof MONGOLIAN_LOCALE.code
export type AvailableLocale = { code: string; name: string } export type AvailableLocale = { code: string; name: string }
@@ -32,19 +28,6 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
} }
// Frontend layout selection must not change the API's content locale.
export const isVerticalPreviewPath = (pathname: string): boolean =>
/^\/mn-Mong(?:[/?#]|$)/.test (pathname)
export const isUILocaleCode = (value: unknown): value is UILocaleCode =>
value === MONGOLIAN_LOCALE.code || isLocaleCode (value)
export const uiLocaleFromPath = (pathname: string): UILocaleCode =>
isVerticalPreviewPath (pathname) ? MONGOLIAN_LOCALE.code : localeFromPath (pathname) ?? 'ja'
export const resolveViewingLocale = ( export const resolveViewingLocale = (
pathname: string, pathname: string,
routeLocale?: unknown, routeLocale?: unknown,
@@ -57,29 +40,27 @@ export const currentViewingLocale = (): LocaleCode =>
export const unlocalisedPath = (pathname: string): string => { export const unlocalisedPath = (pathname: string): string => {
const locale = isVerticalPreviewPath (pathname) ? 'mn-Mong' : localeFromPath (pathname) const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/' return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
} }
export const localiseTo = (to: To, locale: LocaleCode, viewingPath = ''): To => { export const localiseTo = (to: To, locale: LocaleCode): To => {
const pathname = typeof to === 'string' ? to : to.pathname const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null if (pathname == null
|| !(pathname.startsWith ('/')) || !(pathname.startsWith ('/'))
|| pathname.startsWith ('//') || pathname.startsWith ('//')
|| localeFromPath (pathname) != null || localeFromPath (pathname) != null)
|| isVerticalPreviewPath (pathname))
return to return to
const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale const localised = `/${ locale }${ pathname }`
const localised = `/${ prefix }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised } return typeof to === 'string' ? localised : { ...to, pathname: localised }
} }
export const switchLocalePath = ( export const switchLocalePath = (
location: { pathname: string; search: string; hash: string }, location: { pathname: string; search: string; hash: string },
locale: UILocaleCode, locale: LocaleCode,
): string => ): string =>
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }` `/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
@@ -92,8 +73,7 @@ export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string }, location: { pathname: string; search: string; hash: string },
isPage: (pathname: string) => boolean = () => false, isPage: (pathname: string) => boolean = () => false,
): string | null => { ): string | null => {
if (localeFromPath (location.pathname) != null if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
return null return null
return switchLocalePath (location, automaticallySelectedLocale ()) return switchLocalePath (location, automaticallySelectedLocale ())
} }
+3 -3
ファイルの表示
@@ -1,19 +1,19 @@
import { translateUIMessage } from '@/lib/i18n' import { translateUIMessage } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json' import type navigation from '@/locales/ja/navigation.json'
import type { UILocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
export type UIMessageKey = keyof typeof navigation export type UIMessageKey = keyof typeof navigation
export const uiMessage = ( export const uiMessage = (
locale: UILocaleCode, locale: LocaleCode,
key: UIMessageKey, key: UIMessageKey,
values: Record<string, string | number> = { }, values: Record<string, string | number> = { },
): string => translateUIMessage (key, 'navigation', locale, values).text ): string => translateUIMessage (key, 'navigation', locale, values).text
export const uiMessageLanguage = (locale: UILocaleCode, key: UIMessageKey): 'ja' | 'ain' => export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana' translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
? 'ain' ? 'ain'
: 'ja' : 'ja'
+11
ファイルの表示
@@ -11,6 +11,17 @@ import type { Deerjikist,
TagVersion } from '@/types' TagVersion } from '@/types'
export const fetchTagAutocomplete = async (
query: string,
nicoFlg: boolean,
locale: LocaleCode,
signal: AbortSignal,
): Promise<Tag[]> =>
await apiGet ('/tags/autocomplete', {
locale, signal,
params: { q: query, nico: nicoFlg ? '1' : '0' } })
export const fetchTags = async ( export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated, updatedFrom, updatedTo, deprecated,
+4 -14
ファイルの表示
@@ -1,12 +1,11 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { isVerticalPreviewPath, localiseTo, import { localiseTo, resolveViewingLocale } from '@/lib/locales'
resolveViewingLocale, uiLocaleFromPath } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom' import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
import type { LocaleCode, UILocaleCode } from '@/lib/locales' import type { LocaleCode } from '@/lib/locales'
export const useLocale = (): LocaleCode => { export const useLocale = (): LocaleCode => {
@@ -16,22 +15,13 @@ export const useLocale = (): LocaleCode => {
} }
export const useUILocale = (): UILocaleCode =>
uiLocaleFromPath (useLocation ().pathname)
export const useLocaleNavigate = (): NavigateFunction => { export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate () const navigate = useNavigate ()
const locale = useLocale () const locale = useLocale ()
const { pathname } = useLocation ()
return useCallback ((to: To | number, options?: NavigateOptions) => { return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number') if (typeof to === 'number')
navigate (to) navigate (to)
else else
navigate (localiseTo (to, locale, pathname), options) navigate (localiseTo (to, locale), options)
}, [locale, navigate, pathname]) }, [locale, navigate])
} }
export const useVerticalPreview = (): boolean =>
isVerticalPreviewPath (useLocation ().pathname)
+142
ファイルの表示
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchTagAutocomplete } from '@/lib/tags'
import { useLocale } from '@/lib/useLocale'
import type { KeyboardEvent } from 'react'
import type { Tag } from '@/types'
export const useTagAutocomplete = (
{ token, onSelect, nicoFlg = false, unusedFlg = false }: {
token: string
onSelect: (tag: Tag) => void
nicoFlg?: boolean
unusedFlg?: boolean },
) => {
const locale = useLocale ()
const compositionFlgRef = useRef (false)
const [activeIndex, setActiveIndex] = useState (-1)
const [visible, setVisible] = useState (false)
const [request, setRequest] = useState ({ token: '', query: '' })
const [result, setResult] = useState<{
locale: string
nicoFlg: boolean
unusedFlg: boolean
request: { token: string; query: string }
tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] })
const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg
&& result.unusedFlg === unusedFlg && result.request === request
&& request.token === token) ? result.tags : []
const requestSuggestions = (token: string, query: string = token) => {
setRequest ({ token, query })
setActiveIndex (-1)
setVisible (token.trim () !== '')
}
const clearSuggestions = useCallback (() => {
setRequest ({ token: '', query: '' })
setActiveIndex (-1)
setVisible (false)
}, [])
useEffect (() => {
if (request.token.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await fetchTagAutocomplete (
request.query, nicoFlg, locale, controller.signal)
if (active)
{
setResult ({ locale, nicoFlg, unusedFlg, request,
tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, nicoFlg, unusedFlg])
useEffect (() => {
clearSuggestions ()
}, [locale, nicoFlg, clearSuggestions])
const handleTagSelect = (tag: Tag) => {
clearSuggestions ()
onSelect (tag)
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (compositionFlgRef.current
|| event.nativeEvent.isComposing
|| event.nativeEvent.keyCode === 229)
{
if (event.key === 'Enter')
event.preventDefault ()
return
}
switch (event.key)
{
case 'ArrowDown':
event.preventDefault ()
setActiveIndex (index => Math.min (index + 1, suggestions.length - 1))
setVisible (true)
break
case 'ArrowUp':
event.preventDefault ()
setActiveIndex (index => Math.max (index - 1, -1))
setVisible (true)
break
case 'Enter':
{
const selected = visible ? suggestions[activeIndex] : null
if (selected == null)
{
setVisible (false)
break
}
event.preventDefault ()
handleTagSelect (selected)
}
break
case 'Escape':
event.preventDefault ()
setVisible (false)
break
}
}
return {
requestSuggestions, clearSuggestions, compositionFlgRef,
inputProps: {
onFocus: () => setVisible (true),
onBlur: () => setVisible (false),
onKeyDown: handleKeyDown,
onCompositionStart: () => {
compositionFlgRef.current = true
},
onCompositionEnd: () => {
compositionFlgRef.current = false
} },
suggestionProps: {
suggestions: visible ? suggestions : [],
activeIndex,
onSelect: handleTagSelect } }
}
+2 -2
ファイルの表示
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText' import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n' import { translateUIMessage } from '@/lib/i18n'
import { useUILocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => { export const useUITranslation = (namespace = 'common') => {
const locale = useUILocale () const locale = useLocale ()
const { i18n } = useTranslation (namespace, { lng: locale }) const { i18n } = useTranslation (namespace, { lng: locale })
const message = useCallback ((key: string, values?: Record<string, string | number>) => { const message = useCallback ((key: string, values?: Record<string, string | number>) => {
+1 -1
ファイルの表示
@@ -152,7 +152,7 @@ export const msToTime = (ms: number): string => {
export const inputClass = (invalid?: boolean, className?: string): string => export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('[inline-size:100%] rounded border p-2', cn ('w-full rounded border p-2',
(invalid (invalid
? ['border-red-500 bg-red-50 text-red-900', ? ['border-red-500 bg-red-50 text-red-900',
'placeholder:text-red-300', 'placeholder:text-red-300',
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ import { I18nextProvider } from 'react-i18next'
import '@/index.css' import '@/index.css'
import App, { preloadPageRoute, resolvePageRoute } from '@/App' import App, { preloadPageRoute, resolvePageRoute } from '@/App'
import { createUII18n } from '@/lib/i18n' import { createUII18n } from '@/lib/i18n'
import { currentViewingLocale, uiLocaleFromPath } from '@/lib/locales' import { currentViewingLocale } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys' import { localesKeys } from '@/lib/queryKeys'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
@@ -22,7 +22,7 @@ const client = new QueryClient ({
const mount = (serverRendered: boolean) => { const mount = (serverRendered: boolean) => {
const instance = createUII18n (uiLocaleFromPath (window.location.pathname)) const instance = createUII18n (currentViewingLocale ())
const app = ( const app = (
<HelmetProvider context={helmetContext}> <HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
+1 -2
ファイルの表示
@@ -359,8 +359,7 @@ const CountsResourceContent = (
</ul> </ul>
</nav> </nav>
</SidebarComponent> </SidebarComponent>
<MainArea animateLayout={false} verticalContent={false} <MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle> <PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
<div ref={controlsArea} className="shrink-0" <div ref={controlsArea} className="shrink-0"
style={{ minHeight: definition == null ? controlsHeight : undefined }}> style={{ minHeight: definition == null ? controlsHeight : undefined }}>
+1 -1
ファイルの表示
@@ -107,7 +107,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
{post && <TagDetailSidebar post={post} className="hidden md:block"/>} {post && <TagDetailSidebar post={post} className="hidden md:block"/>}
<MainArea className="relative" verticalContent={false}> <MainArea className="relative">
{post {post
? ( ? (
<> <>
+1 -1
ファイルの表示
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
JSON.stringify (statesToSave)) JSON.stringify (statesToSave))
}}/> }}/>
<MainArea className="posts-main-area" verticalContent> <MainArea>
<TabGroup <TabGroup
rightAddon={ rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
+2 -9
ファイルの表示
@@ -35,10 +35,6 @@ const renderPage = () =>
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
tagsApi.fetchTag.mockReset ()
api.apiPut.mockReset ()
api.isApiError.mockReset ()
toastApi.toast.mockReset ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
}) })
@@ -55,11 +51,8 @@ describe ('TagDetailPage', () => {
}) })
it ('submits edited tag fields', async () => { it ('submits edited tag fields', async () => {
const updatedTag = buildTag ({ id: 7, name: 'new', aliases: ['alias'] }) tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
tagsApi.fetchTag api.apiPut.mockResolvedValueOnce (buildTag ({ id: 7, name: 'new', aliases: ['alias'] }))
.mockResolvedValue (updatedTag)
.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.apiPut.mockResolvedValueOnce (updatedTag)
renderPage () renderPage ()
+4 -4
ファイルの表示
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
}), [page, totalPages, location.search])) }), [page, totalPages, location.search]))
return ( return (
<MainArea className="tags-main-area min-w-0" verticalContent> <MainArea className="min-w-0">
<PageHead title={message ('title')}/> <PageHead title={message ('title')}/>
<div className="tag-list-filter max-w-xl"> <div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>{label ('title')}</PageTitle> <PageTitle>{label ('title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -358,8 +358,8 @@ const TagListPage: FC = () => {
{isError && <p role="alert">{label ('common:fetchError')}</p>} {isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (results.length > 0 ? ( {loading ? label ('common:loading') : (results.length > 0 ? (
<div className="tag-list-results mt-4 min-w-0 max-w-full"> <div className="mt-4 min-w-0 max-w-full">
<div className="tag-list-table min-w-0 max-w-full overflow-x-auto"> <div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[2000px] table-auto border-collapse"> <table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup> <colgroup>
<col className="w-72"/> <col className="w-72"/>
+1 -1
ファイルの表示
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
return <NotFound/> return <NotFound/>
return ( return (
<MainArea className="wiki-main-area" verticalContent> <MainArea>
<PageHead title={{ <PageHead title={{
text: `${displayTitle}${deprecated text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`, ? message ('labels.deprecatedSuffix').text : ''} Wiki`,
+1 -4
ファイルの表示
@@ -1,4 +1,3 @@
import path from 'node:path'
import { defineConfig, mergeConfig } from 'vitest/config' import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config' import viteConfig from './vite.config'
@@ -7,9 +6,7 @@ import viteConfig from './vite.config'
export default mergeConfig ( export default mergeConfig (
viteConfig, viteConfig,
defineConfig ({ defineConfig ({
test: { alias: { test: { environment: 'jsdom',
'@/config': path.resolve (__dirname, './src/config.sample.ts') },
environment: 'jsdom',
testTimeout: 30_000, testTimeout: 30_000,
exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'], exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'],
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',