@aiquants/menu-bar 1.16.0 → 1.17.0

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