@aiquants/menu-bar 1.16.1 → 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.
- package/README.md +28 -3
- package/dist/index.es.js +1 -1
- package/dist/index.umd.js +2 -2
- package/dist/styles/menu-bar.css +1 -1
- package/dist/styles/menu-bar.standalone.css +3 -0
- package/package.json +6 -4
- package/src/MenuBar.tsx +202 -0
- package/src/components/SessionInfo.tsx +79 -0
- package/src/contents/MenuContent.tsx +83 -0
- package/src/contents/MenuList.tsx +393 -0
- package/src/contents/OverlayShadow.tsx +30 -0
- package/src/elements/MenuSwitch.tsx +118 -0
- package/src/index.ts +6 -0
- package/src/styles/components.entry.css +9 -0
- package/src/styles/menu-bar.css +233 -0
- package/src/styles/standalone.entry.css +12 -0
- package/src/types/user.ts +12 -0
- package/src/types.ts +1 -0
- package/dist/menu-bar.css +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { FocusEvent, MouseEvent } from "react"
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react"
|
|
3
|
+
import type { Theme } from "../types"
|
|
4
|
+
|
|
5
|
+
interface IMenuSwitchProps {
|
|
6
|
+
open: boolean
|
|
7
|
+
onMenuOpen?: () => void
|
|
8
|
+
onMenuClose?: () => void
|
|
9
|
+
theme: Theme
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders the toggle button that switches the main menu visibility.
|
|
14
|
+
* メインメニューの表示状態を切り替えるトグルボタンを描画。
|
|
15
|
+
*/
|
|
16
|
+
export const MenuSwitch = (props: IMenuSwitchProps): React.JSX.Element => {
|
|
17
|
+
const { open, onMenuOpen, onMenuClose, theme } = props
|
|
18
|
+
const state = useRef(open)
|
|
19
|
+
const refSpan1 = useRef<HTMLSpanElement>(null)
|
|
20
|
+
const refSpan2 = useRef<HTMLSpanElement>(null)
|
|
21
|
+
const refSpan3 = useRef<HTMLSpanElement>(null)
|
|
22
|
+
const refSpan4 = useRef<HTMLSpanElement>(null)
|
|
23
|
+
const refMenuString = useRef<HTMLDivElement>(null)
|
|
24
|
+
|
|
25
|
+
const styleOpen = useCallback(() => {
|
|
26
|
+
if (refSpan1.current && refSpan2.current && refSpan3.current && refSpan4.current && refMenuString.current) {
|
|
27
|
+
const color = theme === "dark" ? "#fff" : "#000"
|
|
28
|
+
refSpan1.current.style.backgroundColor = color
|
|
29
|
+
refSpan1.current.style.width = "100%"
|
|
30
|
+
refSpan1.current.style.height = "3px"
|
|
31
|
+
refSpan1.current.style.transform = ""
|
|
32
|
+
refSpan2.current.style.opacity = "1"
|
|
33
|
+
refSpan2.current.style.backgroundColor = color
|
|
34
|
+
refSpan3.current.style.opacity = "1"
|
|
35
|
+
refSpan3.current.style.backgroundColor = color
|
|
36
|
+
refSpan4.current.style.backgroundColor = color
|
|
37
|
+
refSpan4.current.style.width = "100%"
|
|
38
|
+
refSpan4.current.style.height = "3px"
|
|
39
|
+
refSpan4.current.style.transform = ""
|
|
40
|
+
refMenuString.current.style.color = color
|
|
41
|
+
refMenuString.current.classList.remove("after:content-['close']")
|
|
42
|
+
refMenuString.current.classList.remove("after:ml-0.5")
|
|
43
|
+
refMenuString.current.classList.add("after:content-['menu']")
|
|
44
|
+
refMenuString.current.classList.add("after:-ml-0.5")
|
|
45
|
+
state.current = true
|
|
46
|
+
}
|
|
47
|
+
}, [theme])
|
|
48
|
+
|
|
49
|
+
const styleClose = useCallback(() => {
|
|
50
|
+
if (refSpan1.current && refSpan2.current && refSpan3.current && refSpan4.current && refMenuString.current) {
|
|
51
|
+
// クローズボタンは背景オーバーレイが暗いため、常に白色
|
|
52
|
+
refSpan1.current.style.backgroundColor = "#fff"
|
|
53
|
+
refSpan1.current.style.width = "32px"
|
|
54
|
+
refSpan1.current.style.height = "4px"
|
|
55
|
+
refSpan1.current.style.transform = "translateY(9px) rotate(-45deg)"
|
|
56
|
+
refSpan2.current.style.opacity = "0"
|
|
57
|
+
refSpan3.current.style.opacity = "0"
|
|
58
|
+
refSpan4.current.style.backgroundColor = "#fff"
|
|
59
|
+
refSpan4.current.style.width = "32px"
|
|
60
|
+
refSpan4.current.style.height = "4px"
|
|
61
|
+
refSpan4.current.style.transform = "translateY(-9px) rotate(45deg)"
|
|
62
|
+
refMenuString.current.style.color = "#fff"
|
|
63
|
+
refMenuString.current.classList.remove("after:content-['menu']")
|
|
64
|
+
refMenuString.current.classList.remove("after:-ml-0.5")
|
|
65
|
+
refMenuString.current.classList.add("after:content-['close']")
|
|
66
|
+
refMenuString.current.classList.add("after:ml-0.5")
|
|
67
|
+
state.current = false
|
|
68
|
+
}
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const handleOpen = useCallback(() => {
|
|
72
|
+
styleOpen()
|
|
73
|
+
if (onMenuClose) {
|
|
74
|
+
onMenuClose()
|
|
75
|
+
}
|
|
76
|
+
}, [styleOpen, onMenuClose])
|
|
77
|
+
|
|
78
|
+
const handleClose = useCallback(() => {
|
|
79
|
+
styleClose()
|
|
80
|
+
if (onMenuOpen) {
|
|
81
|
+
onMenuOpen()
|
|
82
|
+
}
|
|
83
|
+
}, [styleClose, onMenuOpen])
|
|
84
|
+
|
|
85
|
+
const handleToggle = useCallback(() => {
|
|
86
|
+
if (state.current) {
|
|
87
|
+
handleClose()
|
|
88
|
+
} else {
|
|
89
|
+
handleOpen()
|
|
90
|
+
}
|
|
91
|
+
}, [handleOpen, handleClose])
|
|
92
|
+
|
|
93
|
+
const handleFocus = useCallback((event: FocusEvent<HTMLButtonElement>) => {
|
|
94
|
+
event.currentTarget.blur()
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
const handleMouseDown = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (open) {
|
|
103
|
+
styleOpen()
|
|
104
|
+
} else {
|
|
105
|
+
styleClose()
|
|
106
|
+
}
|
|
107
|
+
}, [open, styleOpen, styleClose])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<button type="button" data-aqmb-menu-switch className="aiquants-menu-switch fixed z-2147483647 inline-block h-[27px] w-[27px] cursor-pointer align-middle outline-hidden" onClick={handleToggle} onFocus={handleFocus} onMouseDown={handleMouseDown} tabIndex={-1}>
|
|
111
|
+
<span className="absolute top-0 left-0 box-border inline-block h-[3px] w-full select-none" ref={refSpan1} />
|
|
112
|
+
<span className="absolute top-1.5 left-0 box-border inline-block h-[3px] w-full select-none" ref={refSpan2} />
|
|
113
|
+
<span className="absolute top-3 left-0 box-border inline-block h-[3px] w-full select-none" ref={refSpan3} />
|
|
114
|
+
<span className="absolute top-[18px] left-0 box-border inline-block h-[3px] w-full select-none" ref={refSpan4} />
|
|
115
|
+
<div className="absolute w-full text-xs after:mt-2 after:block" ref={refMenuString} />
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { LinkProps, SessionInfoProps } from "./components/SessionInfo"
|
|
2
|
+
export { DefaultLink, SessionInfo } from "./components/SessionInfo"
|
|
3
|
+
export type { IMenu, ISubmenu, MenuNavigationPayload } from "./contents/MenuList"
|
|
4
|
+
export { MenuBar } from "./MenuBar"
|
|
5
|
+
export type { Theme } from "./types"
|
|
6
|
+
export type { UserProfile } from "./types/user"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Artifact A: components-only CSS (Tailwind ホスト向け).
|
|
3
|
+
* theme(reference) によりテーマトークンは var(--token, fallback) 形式で参照され、
|
|
4
|
+
* :root へのテーマ変数放出・ユーティリティ・preflight は一切含まない。
|
|
5
|
+
* ホストは @import "@aiquants/menu-bar/styles/menu-bar.css" layer(components) で読み込み、
|
|
6
|
+
* JSX ユーティリティはホスト側の @source スキャンで単一正準ビルドに生成する。
|
|
7
|
+
*/
|
|
8
|
+
@import "tailwindcss/theme.css" theme(reference);
|
|
9
|
+
@import "./menu-bar.css";
|