@kids-reporter/routing-ui 0.1.0-alpha.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.
Files changed (129) hide show
  1. package/.prettierignore +17 -0
  2. package/babel.config.cjs +31 -0
  3. package/dist/components/button.d.ts +23 -0
  4. package/dist/components/button.d.ts.map +1 -0
  5. package/dist/components/button.js +82 -0
  6. package/dist/components/button.js.map +1 -0
  7. package/dist/components/index.d.ts +3 -0
  8. package/dist/components/index.d.ts.map +1 -0
  9. package/dist/components/index.js +21 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/components/input.d.ts +20 -0
  12. package/dist/components/input.d.ts.map +1 -0
  13. package/dist/components/input.js +143 -0
  14. package/dist/components/input.js.map +1 -0
  15. package/dist/constants/default-values.d.ts +12 -0
  16. package/dist/constants/default-values.d.ts.map +1 -0
  17. package/dist/constants/default-values.js +148 -0
  18. package/dist/constants/default-values.js.map +1 -0
  19. package/dist/footer.d.ts +10 -0
  20. package/dist/footer.d.ts.map +1 -0
  21. package/dist/footer.js +144 -0
  22. package/dist/footer.js.map +1 -0
  23. package/dist/header/desktop-header.d.ts +14 -0
  24. package/dist/header/desktop-header.d.ts.map +1 -0
  25. package/dist/header/desktop-header.js +85 -0
  26. package/dist/header/desktop-header.js.map +1 -0
  27. package/dist/header/header-context.d.ts +16 -0
  28. package/dist/header/header-context.d.ts.map +1 -0
  29. package/dist/header/header-context.js +37 -0
  30. package/dist/header/header-context.js.map +1 -0
  31. package/dist/header/index.d.ts +12 -0
  32. package/dist/header/index.d.ts.map +1 -0
  33. package/dist/header/index.js +72 -0
  34. package/dist/header/index.js.map +1 -0
  35. package/dist/header/menu/header-menu-item-group.d.ts +7 -0
  36. package/dist/header/menu/header-menu-item-group.d.ts.map +1 -0
  37. package/dist/header/menu/header-menu-item-group.js +31 -0
  38. package/dist/header/menu/header-menu-item-group.js.map +1 -0
  39. package/dist/header/menu/header-menu-item.d.ts +9 -0
  40. package/dist/header/menu/header-menu-item.d.ts.map +1 -0
  41. package/dist/header/menu/header-menu-item.js +92 -0
  42. package/dist/header/menu/header-menu-item.js.map +1 -0
  43. package/dist/header/menu/index.d.ts +15 -0
  44. package/dist/header/menu/index.d.ts.map +1 -0
  45. package/dist/header/menu/index.js +170 -0
  46. package/dist/header/menu/index.js.map +1 -0
  47. package/dist/header/mobile-header.d.ts +9 -0
  48. package/dist/header/mobile-header.d.ts.map +1 -0
  49. package/dist/header/mobile-header.js +46 -0
  50. package/dist/header/mobile-header.js.map +1 -0
  51. package/dist/header/post-title-setter.d.ts +6 -0
  52. package/dist/header/post-title-setter.d.ts.map +1 -0
  53. package/dist/header/post-title-setter.js +22 -0
  54. package/dist/header/post-title-setter.js.map +1 -0
  55. package/dist/header/shared-components.d.ts +31 -0
  56. package/dist/header/shared-components.d.ts.map +1 -0
  57. package/dist/header/shared-components.js +256 -0
  58. package/dist/header/shared-components.js.map +1 -0
  59. package/dist/hooks/index.d.ts +4 -0
  60. package/dist/hooks/index.d.ts.map +1 -0
  61. package/dist/hooks/index.js +36 -0
  62. package/dist/hooks/index.js.map +1 -0
  63. package/dist/hooks/use-is-at-top.d.ts +3 -0
  64. package/dist/hooks/use-is-at-top.d.ts.map +1 -0
  65. package/dist/hooks/use-is-at-top.js +26 -0
  66. package/dist/hooks/use-is-at-top.js.map +1 -0
  67. package/dist/hooks/use-media-query.d.ts +7 -0
  68. package/dist/hooks/use-media-query.d.ts.map +1 -0
  69. package/dist/hooks/use-media-query.js +47 -0
  70. package/dist/hooks/use-media-query.js.map +1 -0
  71. package/dist/hooks/use-scroll-level.d.ts +11 -0
  72. package/dist/hooks/use-scroll-level.d.ts.map +1 -0
  73. package/dist/hooks/use-scroll-level.js +53 -0
  74. package/dist/hooks/use-scroll-level.js.map +1 -0
  75. package/dist/icons/index.d.ts +17 -0
  76. package/dist/icons/index.d.ts.map +1 -0
  77. package/dist/icons/index.js +341 -0
  78. package/dist/icons/index.js.map +1 -0
  79. package/dist/index.d.ts +10 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +101 -0
  82. package/dist/index.js.map +1 -0
  83. package/dist/styles.css +475 -0
  84. package/dist/types/index.d.ts +10 -0
  85. package/dist/types/index.d.ts.map +1 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/utils/cn.d.ts +8 -0
  89. package/dist/utils/cn.d.ts.map +1 -0
  90. package/dist/utils/cn.js +27 -0
  91. package/dist/utils/cn.js.map +1 -0
  92. package/dist/utils/generate-social-media-config.d.ts +9 -0
  93. package/dist/utils/generate-social-media-config.d.ts.map +1 -0
  94. package/dist/utils/generate-social-media-config.js +55 -0
  95. package/dist/utils/generate-social-media-config.js.map +1 -0
  96. package/dist/utils/index.d.ts +3 -0
  97. package/dist/utils/index.d.ts.map +1 -0
  98. package/dist/utils/index.js +28 -0
  99. package/dist/utils/index.js.map +1 -0
  100. package/eslint.config.mjs +55 -0
  101. package/package.json +51 -0
  102. package/prettier.config.mjs +13 -0
  103. package/scripts/build.sh +18 -0
  104. package/src/components/button.tsx +108 -0
  105. package/src/components/index.tsx +2 -0
  106. package/src/components/input.tsx +171 -0
  107. package/src/constants/default-values.tsx +153 -0
  108. package/src/footer.tsx +151 -0
  109. package/src/header/desktop-header.tsx +128 -0
  110. package/src/header/header-context.tsx +56 -0
  111. package/src/header/index.tsx +96 -0
  112. package/src/header/menu/header-menu-item-group.tsx +37 -0
  113. package/src/header/menu/header-menu-item.tsx +132 -0
  114. package/src/header/menu/index.tsx +206 -0
  115. package/src/header/mobile-header.tsx +61 -0
  116. package/src/header/post-title-setter.tsx +22 -0
  117. package/src/header/shared-components.tsx +326 -0
  118. package/src/hooks/index.ts +3 -0
  119. package/src/hooks/use-is-at-top.ts +23 -0
  120. package/src/hooks/use-media-query.ts +57 -0
  121. package/src/hooks/use-scroll-level.ts +52 -0
  122. package/src/icons/index.tsx +358 -0
  123. package/src/index.ts +9 -0
  124. package/src/styles.css +475 -0
  125. package/src/types/index.ts +10 -0
  126. package/src/utils/cn.ts +41 -0
  127. package/src/utils/generate-social-media-config.ts +75 -0
  128. package/src/utils/index.ts +2 -0
  129. package/tsconfig.json +33 -0
package/src/footer.tsx ADDED
@@ -0,0 +1,151 @@
1
+ 'use client'
2
+
3
+ import Image from 'next/image'
4
+
5
+ import Button from './components/button'
6
+ import {
7
+ ADDITIONAL_MENU_ITEMS,
8
+ DONATE_URL,
9
+ PRIVACY_POLICY,
10
+ SOCIAL_MEDIA_ITEMS,
11
+ } from './constants/default-values'
12
+ import { MenuItem, SocialMediaHrefs } from './types'
13
+ import { generateSocialMediaConfig } from './utils/generate-social-media-config'
14
+
15
+ type FooterProps = {
16
+ socialMediaHrefs?: SocialMediaHrefs
17
+ additionalMenuItems?: MenuItem[]
18
+ donateUrl?: string
19
+ privacyPolicyUrl?: string
20
+ }
21
+
22
+ const Footer = ({
23
+ socialMediaHrefs = SOCIAL_MEDIA_ITEMS.map((item) => item.href),
24
+ additionalMenuItems = ADDITIONAL_MENU_ITEMS,
25
+ donateUrl = DONATE_URL,
26
+ privacyPolicyUrl = PRIVACY_POLICY,
27
+ }: FooterProps) => {
28
+ const socialMediaConfig = generateSocialMediaConfig(socialMediaHrefs)
29
+
30
+ return (
31
+ <footer className="w-full bg-neutral-white">
32
+ {/* Main Footer Content */}
33
+ <div className="py-12 desktop:py-14 w-full bg-neutral-white px-(--margin-mobile) desktop:px-(--margin-desktop)">
34
+ <div className="max-w-300 mx-auto">
35
+ <div className="gap-8 flex flex-col items-center desktop:flex-row desktop:justify-between">
36
+ {/* Logo and Description */}
37
+ <div className="max-w-100 gap-6 flex w-full flex-col items-center desktop:items-start">
38
+ <div className="flex items-center">
39
+ <a href="/" className="flex items-center">
40
+ <Image
41
+ src="/assets/images/footer-logo.svg"
42
+ alt="少年報導者"
43
+ loading="lazy"
44
+ width={238}
45
+ height={26}
46
+ />
47
+ </a>
48
+ </div>
49
+ <p className="prose-p2 w-full text-neutral-900">
50
+ 《少年報導者》是由非營利媒體《報導者》針對兒少打造的深度新聞報導品牌,與兒童和少年一起理解世界,參與未來。
51
+ </p>
52
+ <Button size={44} variant="secondary" asChild className="w-75">
53
+ <a href={donateUrl} target="_blank" rel="noreferrer">
54
+ 贊助我們
55
+ </a>
56
+ </Button>
57
+ </div>
58
+
59
+ <div className="gap-6 flex flex-row">
60
+ <div className="gap-2 flex flex-col">
61
+ {additionalMenuItems.slice(0, 4).map((link) => (
62
+ <a
63
+ key={link.label}
64
+ href={link.href}
65
+ className="prose-p2-bold min-w-30 text-neutral-900 transition-colors duration-200 hover:text-red-400"
66
+ target={link.external ? '_blank' : undefined}
67
+ rel={link.external ? 'noopener noreferrer' : undefined}
68
+ >
69
+ {link.label}
70
+ </a>
71
+ ))}
72
+ </div>
73
+ <div className="gap-2 flex flex-col">
74
+ {additionalMenuItems.slice(4).map((link) => (
75
+ <a
76
+ key={link.label}
77
+ href={link.href}
78
+ className="prose-p2-bold min-w-30 text-neutral-900 transition-colors duration-200 hover:text-red-400"
79
+ target={link.external ? '_blank' : undefined}
80
+ rel={link.external ? 'noopener noreferrer' : undefined}
81
+ >
82
+ {link.label}
83
+ </a>
84
+ ))}
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="py-6 w-full bg-red-400 px-(--margin-mobile) desktop:px-(--margin-desktop)">
92
+ <div className="max-w-300 mx-auto">
93
+ <div className="gap-5 desktop:gap-4 flex flex-col items-center desktop:flex-row desktop:justify-between">
94
+ <div className="gap-4 order-1 flex items-center desktop:order-2">
95
+ {socialMediaConfig.map((social) => {
96
+ const IconComponent = social.icon
97
+ return (
98
+ <a
99
+ key={social.label}
100
+ href={social.href}
101
+ className="relative text-neutral-white transition-colors duration-200 hover:text-neutral-200"
102
+ target="_blank"
103
+ rel="noopener noreferrer"
104
+ aria-label={social.label}
105
+ >
106
+ <div className="peer w-6 h-6 relative z-10 flex items-center justify-center rounded-full text-neutral-white transition-all duration-200 hover:text-red-500">
107
+ {IconComponent && <IconComponent />}
108
+ </div>
109
+ <div className="p-2 peer-hover:bg-white absolute top-1/2 left-1/2 z-1 flex h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full transition-all duration-200"></div>
110
+ </a>
111
+ )
112
+ })}
113
+ </div>
114
+
115
+ <div className="prose-p3 text-center text-neutral-white desktop:order-1 desktop:text-left">
116
+ <p className="desktop:inline">
117
+ 衛部救字第1131363879號|勸募期間 2025/1/1~12/31
118
+ <span className="hidden desktop:inline">|</span>
119
+ </p>
120
+ <p className="desktop:inline">
121
+ <a
122
+ href={privacyPolicyUrl}
123
+ target="_blank"
124
+ className="desktop:ml-1 text-neutral-white underline"
125
+ rel="noopener noreferrer"
126
+ >
127
+ 隱私政策
128
+ </a>
129
+
130
+ <a
131
+ href="https://www.twreporter.org/a/license-footer"
132
+ target="_blank"
133
+ className="desktop:ml-1 text-neutral-white underline"
134
+ rel="noopener noreferrer"
135
+ >
136
+ 許可協議
137
+ </a>
138
+ </p>
139
+ <p className="hidden desktop:inline">|</p>
140
+ <p className="desktop:inline">
141
+ Copyright © {new Date().getFullYear()} The Reporter
142
+ </p>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </footer>
148
+ )
149
+ }
150
+
151
+ export default Footer
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import { LoginIcon } from '../icons'
4
+ import type { MenuItem } from '../types'
5
+ import { cn } from '../utils/cn'
6
+ import {
7
+ ActionButtons,
8
+ BottomNavigation,
9
+ HamburgerButton,
10
+ LogoLink,
11
+ } from './shared-components'
12
+
13
+ type DesktopHeaderProps = {
14
+ onHamburgerOverlayOpen: () => void
15
+ keywords: string[]
16
+ compactMode: boolean
17
+ postTitle?: string
18
+ hide: boolean
19
+ searchPlaceholder: string
20
+ subscribeUrl: string
21
+ menuItems: MenuItem[]
22
+ }
23
+
24
+ export function DesktopHeader({
25
+ onHamburgerOverlayOpen,
26
+ keywords,
27
+ compactMode,
28
+ postTitle,
29
+ hide,
30
+ searchPlaceholder,
31
+ subscribeUrl,
32
+ menuItems,
33
+ }: DesktopHeaderProps) {
34
+ return (
35
+ <>
36
+ <div className="hidden h-(--desktop-header-height) desktop:block"></div>
37
+ <div
38
+ className={cn(
39
+ 'top-0 ease-in-out fixed left-1/2 z-1000 hidden w-full -translate-x-1/2 transform transition-all duration-500 desktop:block',
40
+ compactMode && 'bg-white',
41
+ hide
42
+ ? 'pointer-events-none -translate-y-full opacity-0'
43
+ : 'translate-y-0 pointer-events-auto opacity-100'
44
+ )}
45
+ >
46
+ <div className="px-12 hidden w-full bg-transparent desktop:block">
47
+ <div className="max-w-300 mx-auto">
48
+ <div
49
+ className={cn(
50
+ 'px-4 flex items-center justify-between py-[18px]',
51
+ compactMode && 'py-2.5'
52
+ )}
53
+ >
54
+ <div className={'flex items-center'}>
55
+ <div
56
+ className={cn(
57
+ 'ease-in-out overflow-hidden transition-all duration-500',
58
+ compactMode
59
+ ? 'translate-x-0 max-w-12 mr-4 w-auto scale-100 opacity-100'
60
+ : '-translate-x-2 w-0 max-w-0 pointer-events-none scale-95 opacity-0'
61
+ )}
62
+ >
63
+ <HamburgerButton
64
+ onHamburgerOverlayOpen={onHamburgerOverlayOpen}
65
+ small
66
+ />
67
+ </div>
68
+ <div className={compactMode ? 'mr-12' : 'mr-8'}>
69
+ <LogoLink compactMode={compactMode} />
70
+ </div>
71
+ {postTitle && (
72
+ <div className="pr-12 block">
73
+ <p className="prose-p2 font-medium tracking-wide max-w-124 overflow-hidden text-ellipsis whitespace-nowrap text-neutral-900">
74
+ {postTitle}
75
+ </p>
76
+ </div>
77
+ )}
78
+ <div
79
+ className={cn(
80
+ 'ease-in-out overflow-hidden transition-all duration-500',
81
+ compactMode
82
+ ? 'max-h-0 -translate-x-8 scale-95 opacity-0'
83
+ : 'max-h-20 max-w-auto scale-100 opacity-100',
84
+ postTitle && compactMode && 'max-w-0'
85
+ )}
86
+ >
87
+ <span className="prose-p2 font-medium translate-y-0 inline-block tracking-[2.2px]! text-nowrap text-neutral-900 opacity-100">
88
+ 理解世界 × 參與未來
89
+ </span>
90
+ </div>
91
+ </div>
92
+
93
+ <div className="gap-4 flex items-center">
94
+ <ActionButtons
95
+ tags={keywords}
96
+ hideCtaButtons={compactMode}
97
+ searchPlaceholder={searchPlaceholder}
98
+ subscribeUrl={subscribeUrl}
99
+ />
100
+ <a
101
+ href="/login"
102
+ className="w-8 h-8 flex items-center justify-center rounded-full text-red-400 transition-colors duration-200 hover:text-red-500"
103
+ aria-label="登入"
104
+ >
105
+ <LoginIcon />
106
+ </a>
107
+ </div>
108
+ </div>
109
+
110
+ <div
111
+ className={cn(
112
+ 'ease-in-out overflow-hidden transition-all duration-500',
113
+ compactMode
114
+ ? 'h-0 -translate-y-4 opacity-0'
115
+ : 'translate-y-0 h-auto opacity-100'
116
+ )}
117
+ >
118
+ <BottomNavigation
119
+ onHamburgerOverlayOpen={onHamburgerOverlayOpen}
120
+ menuItems={menuItems}
121
+ />
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </>
127
+ )
128
+ }
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+ import {
3
+ createContext,
4
+ ReactNode,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useState,
9
+ } from 'react'
10
+
11
+ type HeaderContextType = {
12
+ postTitle?: string
13
+ setPostTitle: (title?: string) => void
14
+ isMenuOpen: boolean
15
+ openMenu: () => void
16
+ closeMenu: () => void
17
+ keywords: string[]
18
+ }
19
+
20
+ const HeaderContext = createContext<HeaderContextType | undefined>(undefined)
21
+
22
+ export function HeaderProvider({
23
+ children,
24
+ keywords,
25
+ }: {
26
+ children: ReactNode
27
+ keywords: string[]
28
+ }) {
29
+ const [postTitle, setPostTitle] = useState<string | undefined>(undefined)
30
+ const [isMenuOpen, setIsMenuOpen] = useState(false)
31
+ const openMenu = useCallback(() => setIsMenuOpen(true), [])
32
+ const closeMenu = useCallback(() => setIsMenuOpen(false), [])
33
+
34
+ const contextValue = useMemo(
35
+ () => ({
36
+ postTitle,
37
+ setPostTitle,
38
+ isMenuOpen,
39
+ openMenu,
40
+ closeMenu,
41
+ keywords,
42
+ }),
43
+ [postTitle, setPostTitle, isMenuOpen, openMenu, closeMenu, keywords]
44
+ )
45
+
46
+ return (
47
+ <HeaderContext.Provider value={contextValue}>
48
+ {children}
49
+ </HeaderContext.Provider>
50
+ )
51
+ }
52
+
53
+ export function useHeaderContext() {
54
+ const context = useContext(HeaderContext)
55
+ return context
56
+ }
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+ import {
3
+ ADDITIONAL_MENU_ITEMS,
4
+ DONATE_URL,
5
+ MENU_ITEMS,
6
+ SEARCH_PLACEHOLDER,
7
+ SOCIAL_MEDIA_ITEMS,
8
+ SUBSCRIBE_URL,
9
+ } from '../constants/default-values'
10
+ import {
11
+ ScrollLevel,
12
+ useIsAtTop,
13
+ useMediaQuery,
14
+ useScrollLevel,
15
+ } from '../hooks'
16
+ import type { MenuItem, SocialMediaHrefs } from '../types'
17
+ import { DesktopHeader } from './desktop-header'
18
+ import { useHeaderContext } from './header-context'
19
+ import Menu from './menu'
20
+ import { MobileHeader } from './mobile-header'
21
+
22
+ type HeaderProps = {
23
+ menuItems?: MenuItem[]
24
+ additionalMenuItems?: MenuItem[]
25
+ socialMediaHrefs?: SocialMediaHrefs
26
+ searchPlaceholder?: string
27
+ subscribeUrl?: string
28
+ donateUrl?: string
29
+ }
30
+
31
+ function Header({
32
+ menuItems = MENU_ITEMS,
33
+ additionalMenuItems = ADDITIONAL_MENU_ITEMS,
34
+ socialMediaHrefs = SOCIAL_MEDIA_ITEMS.map((item) => item.href),
35
+ searchPlaceholder = SEARCH_PLACEHOLDER,
36
+ subscribeUrl = SUBSCRIBE_URL,
37
+ donateUrl = DONATE_URL,
38
+ }: HeaderProps) {
39
+ const context = useHeaderContext()
40
+ const postTitle = context?.postTitle
41
+ const isMenuOpen = context?.isMenuOpen || false
42
+ const openMenu = context?.openMenu
43
+ const closeMenu = context?.closeMenu
44
+ const keywords = context?.keywords || []
45
+ const onHamburgerOverlayOpen = () => {
46
+ openMenu?.()
47
+ }
48
+
49
+ const onCloseMenu = () => {
50
+ closeMenu?.()
51
+ }
52
+
53
+ const isMobile = useMediaQuery('(max-width: 768px)')
54
+
55
+ const isAtTop = useIsAtTop()
56
+ const scrollingLevel = useScrollLevel({
57
+ scrollDownDistance: 150,
58
+ throttleThreshold: 50,
59
+ })
60
+
61
+ const isScrollingDown = scrollingLevel === ScrollLevel.DOWN_HIDDEN
62
+
63
+ return (
64
+ <>
65
+ <DesktopHeader
66
+ onHamburgerOverlayOpen={onHamburgerOverlayOpen}
67
+ keywords={keywords}
68
+ hide={isScrollingDown}
69
+ compactMode={!isAtTop}
70
+ postTitle={isAtTop ? undefined : postTitle}
71
+ searchPlaceholder={searchPlaceholder}
72
+ subscribeUrl={subscribeUrl}
73
+ menuItems={menuItems}
74
+ />
75
+ <MobileHeader
76
+ onCloseMenu={onCloseMenu}
77
+ showCloseButtonWhenMenuOpen={isMobile}
78
+ onHamburgerOverlayOpen={onHamburgerOverlayOpen}
79
+ isMenuOpen={isMenuOpen}
80
+ />
81
+ <Menu
82
+ isOpen={isMenuOpen}
83
+ onClose={closeMenu || (() => undefined)}
84
+ keywords={keywords}
85
+ menuItems={menuItems}
86
+ additionalMenuItems={additionalMenuItems}
87
+ socialMediaHrefs={socialMediaHrefs}
88
+ donateUrl={donateUrl}
89
+ subscribeUrl={subscribeUrl}
90
+ searchPlaceholder={searchPlaceholder}
91
+ />
92
+ </>
93
+ )
94
+ }
95
+
96
+ export default Header
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import { MenuItem } from '../../types'
4
+ import HeaderMenuItem from './header-menu-item'
5
+
6
+ function MenuItemGroup({
7
+ isMenuOpen,
8
+ menuItems,
9
+ }: {
10
+ isMenuOpen: boolean
11
+ menuItems: MenuItem[]
12
+ }) {
13
+ const [currentExpandedItem, setCurrentExpandedItem] = useState<string | null>(
14
+ null
15
+ )
16
+
17
+ const handleClick = (label: string | null) => {
18
+ setCurrentExpandedItem(label)
19
+ }
20
+
21
+ useEffect(() => {
22
+ if (!isMenuOpen) {
23
+ setCurrentExpandedItem(null)
24
+ }
25
+ }, [isMenuOpen])
26
+
27
+ return menuItems.map((item) => (
28
+ <HeaderMenuItem
29
+ key={item.label}
30
+ {...item}
31
+ isExpanded={currentExpandedItem === item.label}
32
+ onExpand={handleClick}
33
+ />
34
+ ))
35
+ }
36
+
37
+ export default MenuItemGroup
@@ -0,0 +1,132 @@
1
+ import { MenuItem } from '../../types'
2
+ import { cn } from '../../utils/cn'
3
+ import { useHeaderContext } from '../header-context'
4
+
5
+ type HeaderMenuItemProps = {
6
+ contentClassName?: string
7
+ isExpanded?: boolean
8
+ onExpand?: (label: string | null) => void
9
+ } & MenuItem
10
+
11
+ function HeaderMenuItem({
12
+ label,
13
+ href,
14
+ subItems,
15
+ external,
16
+ showIcon,
17
+ icon,
18
+ isExpanded,
19
+ contentClassName,
20
+ onExpand,
21
+ }: HeaderMenuItemProps) {
22
+ const hasSubItems = subItems && subItems.length > 0
23
+ const context = useHeaderContext()
24
+ const closeMenu = context?.closeMenu
25
+
26
+ const handleExpand = () => {
27
+ if (hasSubItems) {
28
+ onExpand?.(isExpanded ? null : label)
29
+ }
30
+ }
31
+
32
+ const content = (
33
+ <div
34
+ className={cn(
35
+ 'group flex w-full items-center justify-between text-neutral-900 transition-colors duration-100 group-hover:text-neutral-900',
36
+ contentClassName
37
+ )}
38
+ >
39
+ <div className="gap-2 flex items-center">
40
+ {showIcon && icon && (
41
+ <div className="w-4 h-4 flex items-center justify-center">{icon}</div>
42
+ )}
43
+ <span
44
+ className={cn(
45
+ 'prose-p1-bold',
46
+ isExpanded && 'text-red-400 group-hover:text-red-400'
47
+ )}
48
+ >
49
+ {label}
50
+ </span>
51
+ </div>
52
+
53
+ {hasSubItems && (
54
+ <div
55
+ className={cn(
56
+ 'w-6 h-6 flex items-center justify-center transition-transform',
57
+ isExpanded && 'rotate-180 text-red-400 group-hover:text-red-400'
58
+ )}
59
+ >
60
+ <svg width="14" height="7" viewBox="0 0 14 7" fill="none">
61
+ <path
62
+ d="M1 1L7 6L13 1"
63
+ stroke="currentColor"
64
+ strokeWidth="2.5"
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ />
68
+ </svg>
69
+ </div>
70
+ )}
71
+ </div>
72
+ )
73
+
74
+ if (hasSubItems) {
75
+ return (
76
+ <div className="w-full">
77
+ <button
78
+ onClick={handleExpand}
79
+ className="px-6 tablet:px-8 py-2 flex w-full cursor-pointer items-center justify-between transition-colors duration-200 hover:bg-neutral-black/5 active:bg-neutral-black/10"
80
+ >
81
+ {content}
82
+ </button>
83
+ <div
84
+ className={cn(
85
+ 'ease-in-out overflow-hidden transition-all duration-300',
86
+ isExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
87
+ )}
88
+ >
89
+ {subItems.map((subItem, index) => (
90
+ <a
91
+ key={index}
92
+ href={subItem.href}
93
+ className="px-6 tablet:px-12 py-2 pl-12 prose-p2 font-medium block transition-colors duration-200 hover:bg-neutral-black/5 hover:text-neutral-900 active:bg-neutral-black/10"
94
+ onClick={closeMenu}
95
+ >
96
+ {subItem.label}
97
+ </a>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ if (external) {
105
+ return (
106
+ <a
107
+ href={href}
108
+ target="_blank"
109
+ rel="noopener noreferrer"
110
+ className={
111
+ 'px-6 tablet:px-8 py-2 block transition-colors duration-200 hover:bg-neutral-black/5 active:bg-neutral-black/10'
112
+ }
113
+ >
114
+ {content}
115
+ </a>
116
+ )
117
+ }
118
+
119
+ return (
120
+ <a
121
+ href={href}
122
+ className={
123
+ 'px-6 tablet:px-8 py-2 block transition-colors duration-200 hover:bg-neutral-black/5 active:bg-neutral-black/10'
124
+ }
125
+ onClick={closeMenu}
126
+ >
127
+ {content}
128
+ </a>
129
+ )
130
+ }
131
+
132
+ export default HeaderMenuItem