@aiquants/menu-bar 1.18.1 → 1.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,68 +0,0 @@
1
- import { act, render, renderHook } from "@testing-library/react"
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
3
- import { dispatchMenuPinChange, getInitialPinState, subscribeMenuPinChange } from "../events/pinEvent"
4
- import { useMenuPinState } from "../hooks/useMenuPinState"
5
- import { MenuBar } from "../MenuBar"
6
-
7
- describe("pinEvent module & useMenuPinState hook", () => {
8
- beforeEach(() => {
9
- localStorage.clear()
10
- document.documentElement.style.removeProperty("--aiquants-menu-pinned-width")
11
- })
12
-
13
- afterEach(() => {
14
- vi.restoreAllMocks()
15
- })
16
-
17
- it("getInitialPinState reads document style or localStorage safely", () => {
18
- expect(getInitialPinState(240)).toEqual({ pinned: false, width: 0 })
19
-
20
- localStorage.setItem("aiquants-menu-pinned", "true")
21
- expect(getInitialPinState(240)).toEqual({ pinned: true, width: 240 })
22
-
23
- document.documentElement.style.setProperty("--aiquants-menu-pinned-width", "280px")
24
- expect(getInitialPinState(240)).toEqual({ pinned: true, width: 280 })
25
- })
26
-
27
- it("subscribeMenuPinChange fires immediately when immediate: true", () => {
28
- localStorage.setItem("aiquants-menu-pinned", "true")
29
- const listener = vi.fn()
30
-
31
- const unsubscribe = subscribeMenuPinChange(listener, { immediate: true })
32
- expect(listener).toHaveBeenCalledWith({ pinned: true, width: 240 })
33
-
34
- dispatchMenuPinChange(false, 0)
35
- expect(listener).toHaveBeenCalledWith({ pinned: false, width: 0 })
36
-
37
- unsubscribe()
38
- })
39
-
40
- it("useMenuPinState hook syncs state correctly and avoids hydration mismatch", () => {
41
- localStorage.setItem("aiquants-menu-pinned", "true")
42
- const { result } = renderHook(() => useMenuPinState({ defaultPinnedWidth: 240 }))
43
-
44
- expect(result.current).toEqual({ pinned: true, width: 240 })
45
-
46
- act(() => {
47
- dispatchMenuPinChange(false, 0)
48
- })
49
-
50
- expect(result.current).toEqual({ pinned: false, width: 0 })
51
- })
52
-
53
- it("MenuBar triggers onPinChange on toggle and dispatches CustomEvent", () => {
54
- const onPinChange = vi.fn()
55
- const eventListener = vi.fn()
56
- const unsubscribe = subscribeMenuPinChange(eventListener, { immediate: false })
57
-
58
- const { unmount } = render(<MenuBar menuList={[]} onClickSignout={() => {}} theme="light" toggleTheme={() => {}} isPinned={true} pinnedWidth={240} onPinChange={onPinChange} />)
59
-
60
- expect(eventListener).toHaveBeenCalledWith({ pinned: true, width: 240 })
61
- expect(document.documentElement.style.getPropertyValue("--aiquants-menu-pinned-width")).toBe("240px")
62
-
63
- unmount()
64
- expect(document.documentElement.style.getPropertyValue("--aiquants-menu-pinned-width")).toBe("0px")
65
-
66
- unsubscribe()
67
- })
68
- })
@@ -1,95 +0,0 @@
1
- import ExternalLink from "lucide-react/dist/esm/icons/external-link.js"
2
- import User from "lucide-react/dist/esm/icons/user.js"
3
- import type { UserProfile } from "../types/user"
4
-
5
- const DEFAULT_USER_ICON =
6
- "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2NiZDVlMSI+PHBhdGggZD0iTTEyIDEyYzIuMjEgMCA0LTEuNzkgNC00cy0xLjc5LTQtNC00LTQgMS43OS00IDQgMS43OSA0IDQgNHptMCAyYy0yLjY3IDAtOCAxLjM0LTggNHYyaDE2di0yYzAtMi42Ni01LjMzLTQtOC00eiIvPjwvc3ZnPg=="
7
-
8
- /**
9
- * Link component props interface.
10
- * リンクコンポーネントのプロパティ。
11
- */
12
- export interface LinkProps {
13
- /** Link destination URL */
14
- to: string
15
- /** CSS class name */
16
- className?: string
17
- /** Link content */
18
- children: React.ReactNode
19
- }
20
-
21
- /**
22
- * Generic Link component that can be replaced with router-specific implementation.
23
- * ルーター固有の実装で置き換え可能な汎用リンクコンポーネント。
24
- */
25
- export const DefaultLink = ({ to, className, children }: LinkProps): React.JSX.Element => (
26
- <a href={to} className={className}>
27
- {children}
28
- </a>
29
- )
30
-
31
- /**
32
- * SessionInfo component props interface.
33
- * ユーザーセッション情報を表示するコンポーネントのプロパティ。
34
- */
35
- export interface SessionInfoProps {
36
- /** User profile information from Google OAuth */
37
- user: UserProfile | null
38
- /** Custom Link component (optional, defaults to DefaultLink) */
39
- LinkComponent?: React.ComponentType<LinkProps>
40
- }
41
-
42
- /**
43
- * SessionInfo component for displaying user session information.
44
- * Displays user profile photo, navigation links, and user name when authenticated.
45
- *
46
- * ユーザーセッション情報を表示するコンポーネント。
47
- * 認証時にプロフィール写真、ナビゲーションリンク、ユーザー名を表示する。
48
- */
49
- export const SessionInfo = (props: SessionInfoProps): React.JSX.Element => {
50
- const { user, LinkComponent = DefaultLink } = props
51
-
52
- return (
53
- <div className="w-full">
54
- {user ? (
55
- <div className="aiquants-session-card group relative flex flex-col gap-2 rounded-xl border border-white/20 bg-white/10 p-2.5 shadow-sm backdrop-blur-md transition-all duration-200 hover:border-white/30 hover:bg-white/15 dark:border-slate-700/60 dark:bg-slate-800/60 dark:hover:border-slate-600/80 dark:hover:bg-slate-800/90">
56
- {/* アバター & ユーザー名 */}
57
- <div className="flex min-w-0 items-center gap-2.5">
58
- <div className="relative shrink-0">
59
- <div className="h-8 w-8 overflow-hidden rounded-full border border-white/40 shadow-xs dark:border-slate-500">
60
- <img className="h-full w-full rounded-full object-cover" src={user.photo || DEFAULT_USER_ICON} alt={user.displayName || "user profile"} />
61
- </div>
62
- <span className="absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-slate-900 bg-emerald-400" title="Online" />
63
- </div>
64
- <div className="min-w-0 flex-1 leading-tight">
65
- <div className="truncate font-semibold text-xs text-white tracking-tight dark:text-slate-100" title={user.displayName}>
66
- {user.displayName}
67
- </div>
68
- <div className="mt-0.5 font-medium text-[10px] text-white/75 tracking-tight dark:text-slate-400">Google Account</div>
69
- </div>
70
- </div>
71
-
72
- {/* アクションボタン */}
73
- <div className="grid grid-cols-2 gap-1.5 border-white/15 border-t pt-1.5 dark:border-slate-700/50">
74
- <LinkComponent
75
- className="flex items-center justify-center gap-1 rounded-md border border-white/15 bg-white/10 px-2 py-1 font-medium text-[11px] text-white transition-all duration-150 hover:bg-white/25 active:scale-95 dark:border-slate-600/50 dark:bg-slate-700/50 dark:text-slate-200 dark:hover:bg-slate-600/80"
76
- to="/">
77
- <User size={11} className="shrink-0 opacity-80" />
78
- <span className="truncate">プロフィール</span>
79
- </LinkComponent>
80
- <LinkComponent
81
- className="flex items-center justify-center gap-1 rounded-md border border-white/15 bg-white/10 px-2 py-1 font-medium text-[11px] text-white transition-all duration-150 hover:bg-white/25 active:scale-95 dark:border-slate-600/50 dark:bg-slate-700/50 dark:text-slate-200 dark:hover:bg-slate-600/80"
82
- to="https://myaccount.google.com/">
83
- <ExternalLink size={11} className="shrink-0 opacity-80" />
84
- <span className="truncate">Google</span>
85
- </LinkComponent>
86
- </div>
87
- </div>
88
- ) : (
89
- <div className="aiquants-session-card flex items-center justify-between rounded-xl border border-white/20 bg-white/10 p-2.5 shadow-xs dark:border-slate-700/60 dark:bg-slate-800/60">
90
- <span className="font-medium text-white/90 text-xs dark:text-slate-200">ログインしてください</span>
91
- </div>
92
- )}
93
- </div>
94
- )
95
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * @module MenuContent
3
- * @description This module provides the content for the menu bar.
4
- * @description このモジュールは、メニューバーのコンテンツを提供します。
5
- */
6
-
7
- import LogOut from "lucide-react/dist/esm/icons/log-out.js"
8
- import Pin from "lucide-react/dist/esm/icons/pin.js"
9
- import PinOff from "lucide-react/dist/esm/icons/pin-off.js"
10
- import type { Theme } from "../types"
11
- import { type IMenu, MenuList, type MenuNavigationPayload } from "./MenuList"
12
-
13
- /**
14
- * @interface IMenuContentProps
15
- * @description Props for the MenuContent component.
16
- * @description MenuContent コンポーネントの Props。
17
- */
18
- interface IMenuContentProps {
19
- menuList: IMenu[]
20
- menuHeader?: React.ReactNode
21
- open: boolean
22
- onMenuSelected(payload: MenuNavigationPayload): void
23
- onClickSignout(): void
24
- theme: Theme
25
- toggleTheme: () => void
26
- isPinned: boolean
27
- onTogglePin(): void
28
- pinnedSubmenuIds: number[]
29
- onToggleSubmenuPin(id: number): void
30
- }
31
-
32
- /**
33
- * Renders the content of the menu bar.
34
- * メニューバーのコンテンツを描画します。
35
- * @param {IMenuContentProps} props - The properties for the MenuContent component.
36
- * @returns {React.JSX.Element} The rendered menu content.
37
- */
38
- export const MenuContent = (props: IMenuContentProps): React.JSX.Element => {
39
- const { menuList, menuHeader, open, onMenuSelected, onClickSignout, theme, toggleTheme, isPinned, onTogglePin, pinnedSubmenuIds, onToggleSubmenuPin } = props
40
-
41
- return (
42
- <div className={`aiquants-menu-content fixed top-0 right-0 z-2147483642 flex min-h-dvh w-[240px] select-none flex-col transition-transform duration-500 ${open ? "aiquants-menu-translate-x-0" : "aiquants-menu-translate-x-250"}`}>
43
- <div className="flex shrink-0 flex-col justify-center border-white/15 border-b px-2.5 py-2 dark:border-slate-700/60">{menuHeader}</div>
44
- <div className="flex grow flex-col overflow-y-auto">
45
- <MenuList menuList={menuList} onMenuSelected={onMenuSelected} pinnedSubmenuIds={pinnedSubmenuIds} onToggleSubmenuPin={onToggleSubmenuPin} />
46
- </div>
47
- <div className="border-white/15 border-t px-2.5 py-1.5 dark:border-slate-700/60">
48
- <div className="flex items-center justify-between gap-1.5">
49
- <button
50
- type="button"
51
- onClick={onTogglePin}
52
- data-aqmb-pin-button
53
- className={`flex h-7 items-center justify-center rounded-md px-2 text-[11px] text-white shadow-2xs transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/60 ${isPinned ? "bg-amber-500 hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500" : "border border-white/20 bg-white/10 hover:bg-white/20 dark:border-slate-600/60 dark:bg-slate-700/60 dark:hover:bg-slate-600"}`}
54
- title={isPinned ? "ピン留め解除" : "ピン留め"}
55
- aria-label={isPinned ? "ピン留め解除" : "ピン留め"}>
56
- {isPinned ? <Pin size={12} /> : <PinOff size={12} />}
57
- </button>
58
- <button
59
- type="button"
60
- onClick={toggleTheme}
61
- data-aqmb-theme-button
62
- className="group relative flex h-7 items-center justify-center rounded-md border border-white/20 bg-white/10 px-2 text-[11px] text-white shadow-2xs backdrop-blur-xs transition-all duration-200 hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 dark:border-slate-600/60 dark:bg-slate-700/60 dark:hover:bg-slate-600"
63
- title={theme === "dark" ? "ライトモードに切り替え" : "ダークモードに切り替え"}
64
- aria-label={theme === "dark" ? "ライトモードに切り替え" : "ダークモードに切り替え"}>
65
- <span className="text-xs transition-transform duration-200 group-hover:scale-110">{theme === "dark" ? "🌞" : "🌙"}</span>
66
- </button>
67
- <button
68
- type="button"
69
- data-aqmb-signout-button
70
- className="aiquants-signout-button flex h-7 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md bg-red-500/90 px-2.5 font-medium text-[11px] text-white shadow-2xs transition-colors duration-200 hover:bg-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-300/60 dark:bg-red-600/90 dark:hover:bg-red-500"
71
- onClick={onClickSignout}>
72
- <LogOut size={11} className="shrink-0" />
73
- <span>サインアウト</span>
74
- </button>
75
- </div>
76
- </div>
77
- </div>
78
- )
79
- }
@@ -1,400 +0,0 @@
1
- /**
2
- * @module MenuList
3
- * @description This module provides the list of menu items.
4
- * @description このモジュールは、メニュー項目のリストを提供します。
5
- */
6
-
7
- import BarChart from "lucide-react/dist/esm/icons/bar-chart.js"
8
- import Download from "lucide-react/dist/esm/icons/download.js"
9
- import FileText from "lucide-react/dist/esm/icons/file-text.js"
10
- import LinkIcon from "lucide-react/dist/esm/icons/link.js"
11
- import Lock from "lucide-react/dist/esm/icons/lock.js"
12
- import Pin from "lucide-react/dist/esm/icons/pin.js"
13
- import PinOff from "lucide-react/dist/esm/icons/pin-off.js"
14
- import Table from "lucide-react/dist/esm/icons/table.js"
15
- import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
16
- import { Link, useLocation } from "react-router-dom"
17
-
18
- // SSR 環境では useLayoutEffect が警告を出すため useEffect にフォールバックする
19
- const useSafeLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
20
-
21
- // メニューバーのスクロール位置を永続化する sessionStorage のキー
22
- const SCROLL_STORAGE_KEY = "aiquants-menu-scroll"
23
-
24
- /**
25
- * @interface ISubmenu
26
- * @description Interface for a submenu item.
27
- * @description サブメニュー項目のインターフェース。
28
- * @property {number} subid - The unique ID of the submenu item. サブメニュー項目の一意の ID。
29
- * @property {string} [title] - The title of the submenu item. サブメニュー項目のタイトル。
30
- * @property {string} href - The URL for the submenu item. サブメニュー項目の URL。
31
- * @property {string} [mark] - The type of mark to display next to the item. 項目の横に表示するマークの種類。
32
- * @property {boolean} [new] - Whether the item is a new link. 新しいリンクかどうか。
33
- */
34
- export interface ISubmenu {
35
- subid: number
36
- title?: string
37
- href: string
38
- mark?: string
39
- new?: boolean
40
- }
41
-
42
- /**
43
- * @interface IMenu
44
- * @description Interface for a menu item.
45
- * @description メニュー項目のインターフェース。
46
- * @property {number} id - The unique ID of the menu item. メニュー項目の一意の ID。
47
- * @property {string} title - The title of the menu item. メニュー項目のタイトル。
48
- * @property {string} [href] - The URL for the menu item. メニュー項目の URL。
49
- * @property {ISubmenu[]} submenu - The list of submenu items. サブメニュー項目のリスト。
50
- */
51
- export interface IMenu {
52
- id: number
53
- title: string
54
- href?: string
55
- submenu: ISubmenu[]
56
- }
57
-
58
- /**
59
- * @typedef MenuNavigationPayload
60
- * @description Payload emitted when a menu or submenu item is selected.
61
- * @description メニューまたはサブメニューが選択された際に発火するペイロード。
62
- * @property {{ id: number; title: string; href?: string }} menu - Selected menu metadata.
63
- * @property {{ subid: number; title?: string; href: string; mark?: string; new?: boolean }} [submenu] - Selected submenu metadata.
64
- */
65
- export type MenuNavigationPayload = {
66
- menu: Pick<IMenu, "id" | "title" | "href">
67
- submenu?: Pick<ISubmenu, "subid" | "title" | "href" | "mark" | "new">
68
- }
69
-
70
- /**
71
- * @interface IMenuListProps
72
- * @description Props for the MenuList component.
73
- * @description MenuList コンポーネントの Props。
74
- * @property {IMenu[]} menuList - The list of menu items. メニュー項目のリスト。
75
- * @property {(payload: MenuNavigationPayload) => void} [onMenuSelected] - Function to handle menu item selection. メニュー項目選択を処理する関数。
76
- */
77
- interface IMenuListProps {
78
- menuList: IMenu[]
79
- onMenuSelected?(payload: MenuNavigationPayload): void
80
- pinnedSubmenuIds?: number[]
81
- onToggleSubmenuPin?(id: number): void
82
- }
83
-
84
- const regexp = new RegExp(/(^https?:\/\/)|(\.(html|htm)?$)/)
85
-
86
- const markIcons: Record<string, React.ElementType> = {
87
- report: FileText,
88
- slip: FileText,
89
- table: Table,
90
- chart: BarChart,
91
- download: Download,
92
- lock: Lock,
93
- href: LinkIcon,
94
- }
95
-
96
- /**
97
- * @function StyledLinkText
98
- * @description A component that renders a styled link with an optional icon.
99
- * @description オプションのアイコンを持つスタイル付きリンクを描画するコンポーネント。
100
- * @param {{ mark?: string; children: ReactNode }} props - The props for the component.
101
- * @returns {React.JSX.Element} The rendered styled link text.
102
- */
103
- const StyledLinkText = ({ mark, children }: { mark?: string; children: ReactNode }) => {
104
- const Icon = mark ? markIcons[mark] : undefined
105
- if (!Icon) {
106
- return <span className="mx-1 text-sky-600 hover:underline dark:text-sky-400">{children}</span>
107
- }
108
- return (
109
- <div className="flex flex-nowrap items-center">
110
- <Icon className="h-3.5 w-3.5 shrink-0" />
111
- <div className="aiquants-menu-link mx-1 hover:underline">{children}</div>
112
- </div>
113
- )
114
- }
115
-
116
- /**
117
- * @function buildMenuPayload
118
- * @description Creates a navigation payload from menu metadata.
119
- * @description メニューのメタデータからナビゲーション用ペイロードを生成します。
120
- * @param {IMenu} menu - The parent menu item. 親メニュー項目。
121
- * @param {ISubmenu} [submenu] - The selected submenu item if any. 選択されたサブメニュー項目。
122
- * @returns {MenuNavigationPayload} The constructed payload.
123
- */
124
- const buildMenuPayload = (menu: IMenu, submenu?: ISubmenu): MenuNavigationPayload => {
125
- if (!submenu) {
126
- return {
127
- menu: {
128
- id: menu.id,
129
- title: menu.title,
130
- href: menu.href,
131
- },
132
- }
133
- }
134
- return {
135
- menu: {
136
- id: menu.id,
137
- title: menu.title,
138
- href: menu.href,
139
- },
140
- submenu: {
141
- subid: submenu.subid,
142
- title: submenu.title,
143
- href: submenu.href,
144
- mark: submenu.mark,
145
- new: submenu.new,
146
- },
147
- }
148
- }
149
-
150
- /**
151
- * @function SubMenuItem
152
- * @description A component that renders a single submenu item.
153
- * @description 単一のサブメニュー項目を描画するコンポーネント。
154
- * @param {{ item: ISubmenu; parentMenu: IMenu; onMenuSelected?: (payload: MenuNavigationPayload) => void }} props - The props for the component.
155
- * @returns {React.JSX.Element} The rendered submenu item.
156
- */
157
- const SubMenuItem = ({ item, parentMenu, onMenuSelected }: { item: ISubmenu; parentMenu: IMenu; onMenuSelected?: (payload: MenuNavigationPayload) => void }) => {
158
- if (item.href === "separator") {
159
- return <hr className="aiquants-menu-separator mx-2 my-1 h-px" />
160
- }
161
-
162
- const linkContent = <StyledLinkText mark={item.mark}>{item.title}</StyledLinkText>
163
-
164
- const commonProps = {
165
- onClick: () => {
166
- if (!onMenuSelected) {
167
- return
168
- }
169
- onMenuSelected(buildMenuPayload(parentMenu, item))
170
- },
171
- }
172
-
173
- if (item.new) {
174
- return (
175
- <a href={item.href} rel="noopener noreferrer" target="_blank" {...commonProps}>
176
- {linkContent}
177
- </a>
178
- )
179
- }
180
-
181
- if (regexp.test(item.href)) {
182
- return (
183
- <a href={item.href} {...commonProps}>
184
- {linkContent}
185
- </a>
186
- )
187
- }
188
-
189
- return (
190
- <Link to={item.href} {...commonProps}>
191
- {linkContent}
192
- </Link>
193
- )
194
- }
195
-
196
- /**
197
- * @function MenuList
198
- * @description A component that renders the list of menu items.
199
- * @description メニュー項目のリストを描画するコンポーネント。
200
- * @param {IMenuListProps} props - The props for the component.
201
- * @returns {React.JSX.Element} The rendered menu list.
202
- */
203
- /**
204
- * href からクエリパラメータやハッシュ、末尾のスラッシュを除去して正規化します。
205
- */
206
- const normalizeHref = (href: string | undefined): string => {
207
- if (!href) return ""
208
- return href.split(/[?#]/)[0].replace(/(?!^\/)\/$/, "")
209
- }
210
-
211
- /**
212
- * 現在のパス名に合致するメニュー項目またはサブメニュー項目の親メニューIDを検索します。
213
- */
214
- const getActiveMenuId = (menuList: IMenu[], pathname: string): number | null => {
215
- const normalizedPath = pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
216
- for (const item of menuList) {
217
- if (normalizeHref(item.href) === normalizedPath) {
218
- return item.id
219
- }
220
- if (item.submenu) {
221
- for (const sub of item.submenu) {
222
- if (normalizeHref(sub.href) === normalizedPath) {
223
- return item.id
224
- }
225
- }
226
- }
227
- }
228
- return null
229
- }
230
-
231
- export const MenuList = (props: IMenuListProps): React.JSX.Element => {
232
- const { menuList, onMenuSelected, pinnedSubmenuIds = [], onToggleSubmenuPin } = props
233
-
234
- // 現在のパス名を取得 (非Router環境のテスト用フォールバック付き)
235
- let locationPathname = ""
236
- try {
237
- const location = useLocation()
238
- locationPathname = location.pathname
239
- } catch {
240
- if (typeof window !== "undefined") {
241
- locationPathname = window.location.pathname
242
- }
243
- }
244
-
245
- // 初回の開閉状態をパス名から特定
246
- const initialOpenMenuId = useMemo(() => {
247
- return getActiveMenuId(menuList, locationPathname)
248
- }, [menuList, locationPathname])
249
-
250
- const [openMenuId, setOpenMenuId] = useState<number | null>(initialOpenMenuId)
251
-
252
- // 自動展開済みのパス名と状態を追跡するRef。
253
- // これにより、非同期メニュー読込による menuList 更新時に、ユーザーが手動で閉じたアコーディオンが勝手に再展開されるのを防ぎます。
254
- const lastPathnameRef = useRef<string | null>(locationPathname)
255
- const hasAutoOpenedRef = useRef<boolean>(initialOpenMenuId !== null)
256
-
257
- useEffect(() => {
258
- // パス名変更時にフラグをリセットし、メニューを一旦閉じる
259
- if (lastPathnameRef.current !== locationPathname) {
260
- lastPathnameRef.current = locationPathname
261
- hasAutoOpenedRef.current = false
262
- }
263
-
264
- // 現在のパスに対して自動展開が未完了の場合のみ実行
265
- if (!hasAutoOpenedRef.current) {
266
- const activeId = getActiveMenuId(menuList, locationPathname)
267
- if (activeId !== null) {
268
- setOpenMenuId(activeId)
269
- hasAutoOpenedRef.current = true
270
- }
271
- }
272
- }, [locationPathname, menuList])
273
-
274
- const handleToggle = useCallback(
275
- (id: number) => {
276
- const isPinned = pinnedSubmenuIds.includes(id)
277
- if (isPinned) {
278
- // Keep the expanded state of the submenu when it is pinned
279
- return
280
- }
281
- setOpenMenuId((prevId) => (prevId === id ? null : id))
282
- },
283
- [pinnedSubmenuIds],
284
- )
285
-
286
- // スクロールコンテナへの参照。ページ遷移で再マウントされてもスクロール位置を維持するために使用する。
287
- const scrollRef = useRef<HTMLDivElement>(null)
288
-
289
- // マウント時に保存済みのスクロール位置を復元する。
290
- // サブメニューのピン留め展開状態と同様に、ページ遷移をまたいでもメニューバーのスクロール位置を保持する。
291
- // ペイント前に復元してスクロール位置のちらつきを防ぐため useLayoutEffect を使用する。
292
- useSafeLayoutEffect(() => {
293
- const el = scrollRef.current
294
- if (!el) {
295
- return
296
- }
297
- try {
298
- const saved = sessionStorage.getItem(SCROLL_STORAGE_KEY)
299
- if (saved !== null) {
300
- const top = Number.parseFloat(saved)
301
- if (!Number.isNaN(top)) {
302
- el.scrollTop = top
303
- }
304
- }
305
- } catch {}
306
- }, [])
307
-
308
- // スクロール時に現在位置を sessionStorage へ保存する。
309
- const handleScroll = useCallback(() => {
310
- const el = scrollRef.current
311
- if (!el) {
312
- return
313
- }
314
- try {
315
- sessionStorage.setItem(SCROLL_STORAGE_KEY, String(el.scrollTop))
316
- } catch {}
317
- }, [])
318
-
319
- const MenuListContent = useMemo(() => {
320
- return (
321
- <>
322
- {menuList.map((item) => (
323
- <div key={item.id}>
324
- {item.href ? (
325
- <div className="aiquants-menu-list-item relative mb-0.5 ml-1 flex items-center rounded-l-xl px-2.5 py-1 text-xs before:mr-2 before:inline-block before:h-2 before:w-2 before:shrink-0 before:rounded-full before:content-[''] sm:text-sm">
326
- {regexp.test(item.href) ? (
327
- <a
328
- href={item.href}
329
- onClick={() => {
330
- if (!onMenuSelected) {
331
- return
332
- }
333
- onMenuSelected(buildMenuPayload(item))
334
- }}>
335
- <span className="aiquants-menu-link hover:underline">{item.title}</span>
336
- </a>
337
- ) : (
338
- <Link
339
- to={item.href}
340
- onClick={() => {
341
- if (!onMenuSelected) {
342
- return
343
- }
344
- onMenuSelected(buildMenuPayload(item))
345
- }}>
346
- <span className="aiquants-menu-link hover:underline">{item.title}</span>
347
- </Link>
348
- )}
349
- </div>
350
- ) : (
351
- <details className="aiquants-menu-accordion-details" open={item.id === openMenuId || pinnedSubmenuIds.includes(item.id)}>
352
- {/** biome-ignore lint/a11y/noStaticElementInteractions: summary toggles submenu visibility */}
353
- <summary
354
- className="aiquants-menu-list-item aiquants-menu-accordion-summary relative mb-0.5 ml-1 list-none rounded-l-xl px-2.5 py-1 text-xs outline-none before:mr-2 before:inline-block before:h-2 before:w-2 before:shrink-0 before:rounded-full before:content-[''] sm:text-sm"
355
- onClick={(e) => {
356
- e.preventDefault()
357
- handleToggle(item.id)
358
- }}
359
- onKeyDown={(e) => {
360
- if (e.key === "Enter" || e.key === " ") {
361
- e.preventDefault()
362
- handleToggle(item.id)
363
- }
364
- }}>
365
- <span>{item.title}</span>
366
- <button
367
- type="button"
368
- onClick={(e) => {
369
- e.stopPropagation()
370
- e.preventDefault()
371
- if (onToggleSubmenuPin) {
372
- onToggleSubmenuPin(item.id)
373
- }
374
- }}
375
- className={`aiquants-submenu-pin-button ${pinnedSubmenuIds.includes(item.id) ? "pinned" : ""}`}
376
- title={pinnedSubmenuIds.includes(item.id) ? "ピン留め解除" : "ピン留め"}>
377
- {pinnedSubmenuIds.includes(item.id) ? <Pin size={13} /> : <PinOff size={13} />}
378
- </button>
379
- </summary>
380
- <div className="aiquants-menu-submenu pointer-events-auto my-0.5 mr-1 ml-3.5 rounded-lg px-2 py-1 shadow-sm">
381
- {item.submenu.map((subitem) => (
382
- <div key={subitem.subid} className="aiquants-menu-accordion-container block w-full whitespace-nowrap py-0.5 text-xs">
383
- <SubMenuItem item={subitem} parentMenu={item} onMenuSelected={onMenuSelected} />
384
- </div>
385
- ))}
386
- </div>
387
- </details>
388
- )}
389
- </div>
390
- ))}
391
- </>
392
- )
393
- }, [menuList, onMenuSelected, openMenuId, handleToggle, pinnedSubmenuIds])
394
-
395
- return (
396
- <div ref={scrollRef} onScroll={handleScroll} className="hidden-scrollbar h-0 grow overflow-y-scroll">
397
- {MenuListContent}
398
- </div>
399
- )
400
- }
@@ -1,30 +0,0 @@
1
- /**
2
- * @module OverlayShadow
3
- * @description This module provides an overlay shadow component.
4
- * @description このモジュールは、オーバーレイシャドウコンポーネントを提供します。
5
- */
6
-
7
- /**
8
- * @interface IOverlayProps
9
- * @description Props for the OverlayShadow component.
10
- * @description OverlayShadow コンポーネントの Props。
11
- * @property {boolean} isOverlay - Whether the overlay is visible. オーバーレイが表示されているかどうか。
12
- * @property {() => void} [onOverlayClick] - Function to handle overlay click. オーバーレイのクリックを処理する関数。
13
- */
14
- interface IOverlayProps {
15
- isOverlay: boolean
16
- onOverlayClick?(): void
17
- }
18
-
19
- /**
20
- * @function OverlayShadow
21
- * @description A component that renders an overlay shadow.
22
- * @description オーバーレイシャドウを描画するコンポーネント。
23
- * @param {IOverlayProps} props - The props for the component.
24
- * @returns {React.JSX.Element} The rendered overlay shadow.
25
- */
26
- export const OverlayShadow = (props: IOverlayProps): React.JSX.Element => {
27
- const { isOverlay, onOverlayClick } = props
28
-
29
- return <button type="button" className={`fixed inset-0 z-2147483640 cursor-default bg-black/50 transition-opacity duration-500 ${isOverlay ? "opacity-100" : "pointer-events-none opacity-0"}`} onClick={onOverlayClick} />
30
- }