@aiquants/menu-bar 1.16.1 → 1.17.1

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,202 @@
1
+ /**
2
+ * @module MenuBar
3
+ * @description This module provides the main menu bar component.
4
+ * @description このモジュールは、メインメニューバーコンポーネントを提供します。
5
+ */
6
+ import { useCallback, useEffect, useLayoutEffect, useState } from "react"
7
+
8
+ const useSafeLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
9
+
10
+ import { MenuContent } from "./contents/MenuContent"
11
+ import type { IMenu, MenuNavigationPayload } from "./contents/MenuList"
12
+ import { OverlayShadow } from "./contents/OverlayShadow"
13
+ import { MenuSwitch } from "./elements/MenuSwitch"
14
+ import type { Theme } from "./types"
15
+
16
+ /**
17
+ * @interface IMenuBarProps
18
+ * @description Props for the MenuBar component.
19
+ * @description MenuBar コンポーネントの Props。
20
+ * @property {IMenu[]} menuList - The list of menu items. メニュー項目のリスト。
21
+ * @property {React.ReactNode} [menuHeader] - The header content of the menu. メニューのヘッダーコンテンツ。
22
+ * @property {() => void} onClickSignout - Function to handle sign out. サインアウトを処理する関数。
23
+ * @property {boolean} [isMenuhide] - Whether to hide the menu. メニューを非表示にするかどうか。
24
+ * @property {Theme} theme - The current theme.
25
+ * @property {() => void} toggleTheme - Function to toggle the theme.
26
+ * @property {(payload: MenuNavigationPayload) => void} [onMenuNavigate] - Function to receive menu navigation events. メニュー遷移イベントを受け取る関数。
27
+ * @property {boolean | "auto"} [closeOnEscape] - Controls whether the Escape key closes the menu.
28
+ * - "auto" (default): closes when NOT pinned, stays open when pinned. ピン留めなしでは閉じ、ピン留め中は閉じない。
29
+ * - true: always closes on Escape (resets pin if pinned). 常に閉じる。
30
+ * - false: never closes on Escape. 常に閉じない。
31
+ */
32
+ interface IMenuBarProps {
33
+ menuList: IMenu[]
34
+ menuHeader?: React.ReactNode
35
+ onClickSignout(): void
36
+ isMenuhide?: boolean
37
+ theme: Theme
38
+ toggleTheme: () => void
39
+ onMenuNavigate?(payload: MenuNavigationPayload): void
40
+ onPinChange?(pinned: boolean): void
41
+ pinnedSubmenuIds?: number[]
42
+ initialPinnedSubmenuIds?: number[]
43
+ onSubmenuPinChange?(pinnedIds: number[]): void
44
+ closeOnEscape?: boolean | "auto"
45
+ }
46
+
47
+ /**
48
+ * @function MenuBar
49
+ * @description A component that renders the main menu bar.
50
+ * @description メインメニューバーを描画するコンポーネント。
51
+ * @param {IMenuBarProps} props - The props for the component.
52
+ * @returns {React.JSX.Element | null} The rendered menu bar.
53
+ */
54
+ export const MenuBar = (props: IMenuBarProps): React.JSX.Element | null => {
55
+ const { menuList, menuHeader, onClickSignout, isMenuhide, theme, toggleTheme, onMenuNavigate, onPinChange, pinnedSubmenuIds: propPinnedSubmenuIds, initialPinnedSubmenuIds, onSubmenuPinChange, closeOnEscape = "auto" } = props
56
+ const [isPinned, setIsPinned] = useState(() => {
57
+ try {
58
+ return localStorage.getItem("aiquants-menu-pinned") === "true"
59
+ } catch {
60
+ return false
61
+ }
62
+ })
63
+ const [isMenuOpen, setIsMenuOpen] = useState(isPinned)
64
+
65
+ const [pinnedSubmenuIds, setPinnedSubmenuIds] = useState<number[]>(() => {
66
+ if (initialPinnedSubmenuIds) {
67
+ return initialPinnedSubmenuIds
68
+ }
69
+ try {
70
+ const saved = localStorage.getItem("aiquants-submenu-pinned")
71
+ if (saved) {
72
+ const parsed = JSON.parse(saved)
73
+ if (Array.isArray(parsed)) {
74
+ return parsed.filter((id) => typeof id === "number")
75
+ }
76
+ }
77
+ } catch {}
78
+ return []
79
+ })
80
+
81
+ const activePinnedSubmenuIds = propPinnedSubmenuIds !== undefined ? propPinnedSubmenuIds : pinnedSubmenuIds
82
+
83
+ const handleToggleSubmenuPin = useCallback(
84
+ (id: number) => {
85
+ const currentIds = propPinnedSubmenuIds !== undefined ? propPinnedSubmenuIds : pinnedSubmenuIds
86
+ const nextIds = currentIds.includes(id) ? currentIds.filter((x) => x !== id) : [...currentIds, id]
87
+
88
+ if (propPinnedSubmenuIds === undefined) {
89
+ setPinnedSubmenuIds(nextIds)
90
+ try {
91
+ localStorage.setItem("aiquants-submenu-pinned", JSON.stringify(nextIds))
92
+ } catch {}
93
+ }
94
+
95
+ if (onSubmenuPinChange) {
96
+ onSubmenuPinChange(nextIds)
97
+ }
98
+ },
99
+ [propPinnedSubmenuIds, pinnedSubmenuIds, onSubmenuPinChange],
100
+ )
101
+
102
+ // ピン状態変化時に CSS 変数をセットしコールバックで通知
103
+ useSafeLayoutEffect(() => {
104
+ const width = isPinned ? "250px" : "0px"
105
+ document.documentElement.style.setProperty("--aiquants-menu-pinned-width", width)
106
+ onPinChange?.(isPinned)
107
+ }, [isPinned, onPinChange])
108
+
109
+ // アンマウント時に CSS 変数をリセット
110
+ useSafeLayoutEffect(() => {
111
+ return () => {
112
+ document.documentElement.style.setProperty("--aiquants-menu-pinned-width", "0px")
113
+ }
114
+ }, [])
115
+
116
+ const handleMenuOpen = useCallback(() => {
117
+ setIsMenuOpen(true)
118
+ }, [])
119
+
120
+ const handleMenuClose = useCallback(() => {
121
+ setIsMenuOpen(false)
122
+ setIsPinned(false)
123
+ try {
124
+ localStorage.removeItem("aiquants-menu-pinned")
125
+ } catch {}
126
+ }, [])
127
+
128
+ const handleTogglePin = useCallback(() => {
129
+ setIsPinned((prev) => {
130
+ const next = !prev
131
+ if (next) {
132
+ try {
133
+ localStorage.setItem("aiquants-menu-pinned", "true")
134
+ } catch {}
135
+ } else {
136
+ try {
137
+ localStorage.removeItem("aiquants-menu-pinned")
138
+ } catch {}
139
+ }
140
+ return next
141
+ })
142
+ }, [])
143
+
144
+ const handleMenuSelected = useCallback(
145
+ (payload: MenuNavigationPayload) => {
146
+ if (onMenuNavigate) {
147
+ onMenuNavigate(payload)
148
+ }
149
+ if (!isPinned) {
150
+ setIsMenuOpen(false)
151
+ }
152
+ },
153
+ [onMenuNavigate, isPinned],
154
+ )
155
+
156
+ const handleEscKey = useCallback(
157
+ (e: KeyboardEvent) => {
158
+ if (e.key === "Escape") {
159
+ handleMenuClose()
160
+ }
161
+ },
162
+ [handleMenuClose],
163
+ )
164
+
165
+ // Esc キーで閉じるかの実効値。
166
+ // "auto" (デフォルト): ピン留めなしでは閉じ、ピン留め中は閉じない。true/false で強制。
167
+ // 想定外の値が渡された場合は安全側 (閉じない) に倒す。
168
+ const escapeCloses = closeOnEscape === true || (closeOnEscape === "auto" && !isPinned)
169
+
170
+ // Escキーでメニューを閉じる処理 (escapeCloses が true のときのみリスナーを登録)
171
+ useEffect(() => {
172
+ if (isMenuOpen && escapeCloses) {
173
+ window.addEventListener("keydown", handleEscKey, { passive: true })
174
+ } else {
175
+ window.removeEventListener("keydown", handleEscKey)
176
+ }
177
+
178
+ return () => {
179
+ window.removeEventListener("keydown", handleEscKey)
180
+ }
181
+ }, [isMenuOpen, escapeCloses, handleEscKey])
182
+
183
+ return isMenuhide ? null : (
184
+ <div className={`aiquants-menu-bar ${theme}`}>
185
+ <MenuSwitch onMenuClose={handleMenuClose} onMenuOpen={handleMenuOpen} open={!isMenuOpen} theme={theme} />
186
+ <MenuContent
187
+ menuList={menuList}
188
+ menuHeader={menuHeader}
189
+ onClickSignout={onClickSignout}
190
+ onMenuSelected={handleMenuSelected}
191
+ open={isMenuOpen}
192
+ theme={theme}
193
+ toggleTheme={toggleTheme}
194
+ isPinned={isPinned}
195
+ onTogglePin={handleTogglePin}
196
+ pinnedSubmenuIds={activePinnedSubmenuIds}
197
+ onToggleSubmenuPin={handleToggleSubmenuPin}
198
+ />
199
+ <OverlayShadow isOverlay={isMenuOpen && !isPinned} onOverlayClick={handleMenuClose} />
200
+ </div>
201
+ )
202
+ }
@@ -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 min-w-0 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="truncate 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 min-w-0 items-center justify-center whitespace-nowrap 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
+ <span className="truncate">サインアウト</span>
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ )
83
+ }