@aiquants/menu-bar 1.18.2 → 1.19.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.
@@ -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
- }
@@ -1,134 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react"
2
- import type { Theme } from "../types"
3
-
4
- export interface IMenuSwitchProps {
5
- /**
6
- * MenuBar コンポーネントからの指示:
7
- * open === true (または !isOpen): メニューは「閉じている」状態 ➔ 4本線ハンバーガーを表示
8
- * open === false (または isOpen): メニューは「開いている」状態 ➔ ✕印を表示
9
- */
10
- open?: boolean
11
- isOpen?: boolean
12
- onMenuOpen?: () => void
13
- onMenuClose?: () => void
14
- onToggle?: (nextOpen: boolean) => void
15
- theme?: Theme
16
- pinned?: boolean
17
- isPinned?: boolean
18
- className?: string
19
- }
20
-
21
- /**
22
- * 4本線バーと✕印アニメーションの従前の仕様の MenuSwitch コンポーネント。
23
- */
24
- export const MenuSwitch = (props: IMenuSwitchProps): React.JSX.Element => {
25
- const { open, isOpen, onMenuOpen, onMenuClose, onToggle, theme = "light", className } = props
26
-
27
- // メニューが閉じた状態(=4本線表示)かどうか
28
- // open が指定されている場合は open (open === !isMenuOpen)
29
- // isOpen が指定されている場合は !isOpen
30
- const isClosed = open ?? (isOpen !== undefined ? !isOpen : true)
31
-
32
- const stateRef = useRef(isClosed)
33
-
34
- const refSpan1 = useRef<HTMLSpanElement>(null)
35
- const refSpan2 = useRef<HTMLSpanElement>(null)
36
- const refSpan3 = useRef<HTMLSpanElement>(null)
37
- const refSpan4 = useRef<HTMLSpanElement>(null)
38
- const refMenuString = useRef<HTMLDivElement>(null)
39
-
40
- // 4本線ハンバーガーを表示(メニューが閉じている時)
41
- const showHamburgerStyle = useCallback(() => {
42
- if (refSpan1.current && refSpan2.current && refSpan3.current && refSpan4.current && refMenuString.current) {
43
- const color = theme === "dark" ? "#fff" : "#000"
44
- refSpan1.current.style.backgroundColor = color
45
- refSpan1.current.style.width = "100%"
46
- refSpan1.current.style.height = "3px"
47
- refSpan1.current.style.transform = "none"
48
-
49
- refSpan2.current.style.opacity = "1"
50
- refSpan2.current.style.backgroundColor = color
51
-
52
- refSpan3.current.style.opacity = "1"
53
- refSpan3.current.style.backgroundColor = color
54
-
55
- refSpan4.current.style.backgroundColor = color
56
- refSpan4.current.style.width = "100%"
57
- refSpan4.current.style.height = "3px"
58
- refSpan4.current.style.transform = "none"
59
-
60
- refMenuString.current.style.color = color
61
- refMenuString.current.classList.remove("after:content-['close']")
62
- refMenuString.current.classList.remove("after:ml-0.5")
63
- refMenuString.current.classList.add("after:content-['menu']")
64
- refMenuString.current.classList.add("after:-ml-0.5")
65
- stateRef.current = true
66
- }
67
- }, [theme])
68
-
69
- // ✕印を表示(メニューが開いている時)
70
- const showCrossStyle = useCallback(() => {
71
- if (refSpan1.current && refSpan2.current && refSpan3.current && refSpan4.current && refMenuString.current) {
72
- // 開いている時は背景オーバーレイが暗いため常に白色
73
- refSpan1.current.style.backgroundColor = "#fff"
74
- refSpan1.current.style.width = "32px"
75
- refSpan1.current.style.height = "4px"
76
- refSpan1.current.style.transform = "translateY(9px) rotate(-45deg)"
77
-
78
- refSpan2.current.style.opacity = "0"
79
- refSpan3.current.style.opacity = "0"
80
-
81
- refSpan4.current.style.backgroundColor = "#fff"
82
- refSpan4.current.style.width = "32px"
83
- refSpan4.current.style.height = "4px"
84
- refSpan4.current.style.transform = "translateY(-9px) rotate(45deg)"
85
-
86
- refMenuString.current.style.color = "#fff"
87
- refMenuString.current.classList.remove("after:content-['menu']")
88
- refMenuString.current.classList.remove("after:-ml-0.5")
89
- refMenuString.current.classList.add("after:content-['close']")
90
- refMenuString.current.classList.add("after:ml-0.5")
91
- stateRef.current = false
92
- }
93
- }, [])
94
-
95
- useEffect(() => {
96
- if (isClosed) {
97
- showHamburgerStyle()
98
- } else {
99
- showCrossStyle()
100
- }
101
- }, [isClosed, showHamburgerStyle, showCrossStyle])
102
-
103
- const handleToggle = useCallback(() => {
104
- if (stateRef.current) {
105
- // 現在 4本線(閉じている状態) ➔ クリックでメニューを開く
106
- showCrossStyle()
107
- onMenuOpen?.()
108
- onToggle?.(true)
109
- } else {
110
- // 現在 ✕印(開いている状態) ➔ クリックでメニューを閉じる
111
- showHamburgerStyle()
112
- onMenuClose?.()
113
- onToggle?.(false)
114
- }
115
- }, [showCrossStyle, showHamburgerStyle, onMenuOpen, onMenuClose, onToggle])
116
-
117
- const isMenuOpened = !isClosed
118
-
119
- return (
120
- <button
121
- type="button"
122
- data-aqmb-menu-switch="true"
123
- aria-label={isMenuOpened ? "Close menu" : "Open menu"}
124
- aria-expanded={isMenuOpened}
125
- className={`aiquants-menu-switch z-[2147483647] inline-block h-[27px] w-[27px] cursor-pointer align-middle outline-hidden ${className || ""}`}
126
- onClick={handleToggle}>
127
- <span ref={refSpan1} className="absolute top-0 left-0 box-border inline-block h-[3px] w-full select-none transition-all duration-500" />
128
- <span ref={refSpan2} className="absolute top-[6px] left-0 box-border inline-block h-[3px] w-full select-none transition-all duration-500" />
129
- <span ref={refSpan3} className="absolute top-[12px] left-0 box-border inline-block h-[3px] w-full select-none transition-all duration-500" />
130
- <span ref={refSpan4} className="absolute top-[18px] left-0 box-border inline-block h-[3px] w-full select-none transition-all duration-500" />
131
- <div ref={refMenuString} className="absolute w-full text-xs transition-all duration-500 after:mt-2 after:block" />
132
- </button>
133
- )
134
- }