@oslokommune/punkt-react 16.16.2 → 16.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,305 @@
1
+ 'use client'
2
+
3
+ import { type HTMLAttributes, type Ref, useEffect, useMemo, useRef, useState } from 'react'
4
+ import {
5
+ DEFAULT_HEADER_FOOTER_URL,
6
+ type IHeaderMenuButton,
7
+ type IHeaderMenuLink,
8
+ type IHeaderMenuSection,
9
+ type IHeaderMenuServices,
10
+ type IPktHeaderMenu as ISharedPktHeaderMenu,
11
+ type THeaderFooterApi,
12
+ type THeaderMenuLocale,
13
+ } from 'shared-types'
14
+ import { deriveSocialIcon, fetchHeaderFooterData, mapOdsIcon, selectLocaleData } from 'shared-utils/header-menu'
15
+
16
+ import { PktAccordion } from '../accordion/Accordion'
17
+ import { PktAccordionItem } from '../accordion/AccordionItem'
18
+ import { PktIcon } from '../icon/Icon'
19
+
20
+ export type {
21
+ THeaderFooterApi,
22
+ THeaderMenuLocale,
23
+ IHeaderMenuLink,
24
+ IHeaderMenuButton,
25
+ IHeaderMenuSection,
26
+ IHeaderMenuServices,
27
+ }
28
+
29
+ export interface IPktHeaderMenu extends Omit<HTMLAttributes<HTMLElement>, 'onError'>, ISharedPktHeaderMenu {
30
+ /** Forwarded to the host element. */
31
+ ref?: Ref<HTMLElement>
32
+ /** Fired when the payload has been fetched (or `data` was supplied). */
33
+ onDataLoaded?: (data: THeaderFooterApi) => void
34
+ /** Fired when the fetch fails. */
35
+ onDataError?: (error: Error) => void
36
+ /** Optional id of the toggle button that controls this menu. */
37
+ ariaLabelledBy?: string
38
+ }
39
+
40
+ type LoadState = 'idle' | 'loading' | 'ready' | 'error'
41
+
42
+ /**
43
+ * `<PktHeaderMenu>` — global mega menu for Oslo kommune.
44
+ *
45
+ * Fetches the live header/footer payload on mount and renders the
46
+ * `megamenu` slice for the current locale. Mirrors `pkt-header-menu`
47
+ * (Punkt Elements) — both implementations share types and data
48
+ * helpers from `shared-utils/header-menu`.
49
+ *
50
+ * This component is purely presentational with respect to focus/scroll-lock;
51
+ * the parent header is expected to control `open` and own focus
52
+ * management.
53
+ */
54
+ export const PktHeaderMenu = ({
55
+ dataUrl = DEFAULT_HEADER_FOOTER_URL,
56
+ data,
57
+ locale = 'nb-NO',
58
+ open = false,
59
+ mobileBreakpoint = 768,
60
+ ariaLabelledBy,
61
+ className,
62
+ onDataLoaded,
63
+ onDataError,
64
+ ref,
65
+ ...rest
66
+ }: IPktHeaderMenu) => {
67
+ const [loadState, setLoadState] = useState<LoadState>(data ? 'ready' : 'idle')
68
+ const [fetchedData, setFetchedData] = useState<THeaderFooterApi | undefined>(undefined)
69
+ const [isMobile, setIsMobile] = useState(false)
70
+
71
+ // Stable refs for the latest callbacks so effect deps stay narrow.
72
+ const onDataLoadedRef = useRef(onDataLoaded)
73
+ const onDataErrorRef = useRef(onDataError)
74
+ useEffect(() => {
75
+ onDataLoadedRef.current = onDataLoaded
76
+ }, [onDataLoaded])
77
+ useEffect(() => {
78
+ onDataErrorRef.current = onDataError
79
+ }, [onDataError])
80
+
81
+ // Track viewport width via matchMedia.
82
+ useEffect(() => {
83
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
84
+ const query = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`)
85
+ setIsMobile(query.matches)
86
+ const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches)
87
+ query.addEventListener('change', handler)
88
+ return () => query.removeEventListener('change', handler)
89
+ }, [mobileBreakpoint])
90
+
91
+ // When the supplied pre-fetched data, fire the loaded callback
92
+ // and short-circuit the network call.
93
+ useEffect(() => {
94
+ if (data) {
95
+ setLoadState('ready')
96
+ onDataLoadedRef.current?.(data)
97
+ }
98
+ }, [data])
99
+
100
+ // Fetch when no `data` prop is supplied.
101
+ useEffect(() => {
102
+ if (data) return
103
+ const controller = new AbortController()
104
+ setLoadState('loading')
105
+
106
+ fetchHeaderFooterData<string>(dataUrl, controller.signal)
107
+ .then((payload) => {
108
+ if (controller.signal.aborted) return
109
+ setFetchedData(payload)
110
+ setLoadState('ready')
111
+ onDataLoadedRef.current?.(payload)
112
+ })
113
+ .catch((error: Error) => {
114
+ if (error.name === 'AbortError') return
115
+ setLoadState('error')
116
+ onDataErrorRef.current?.(error)
117
+ })
118
+
119
+ return () => controller.abort()
120
+ }, [data, dataUrl])
121
+
122
+ const effectiveData = data ?? fetchedData
123
+ const localeData = useMemo(() => selectLocaleData(effectiveData, locale), [effectiveData, locale])
124
+
125
+ const hostClasses = ['pkt-header-menu', open && 'pkt-header-menu--open', className].filter(Boolean).join(' ')
126
+
127
+ if (loadState === 'loading') {
128
+ return (
129
+ <section {...rest} ref={ref} className={hostClasses}>
130
+ <nav aria-busy="true">
131
+ <p className="pkt-header-menu__loading">Laster meny…</p>
132
+ </nav>
133
+ </section>
134
+ )
135
+ }
136
+
137
+ if (loadState === 'error' || !localeData) {
138
+ return (
139
+ <section {...rest} ref={ref} className={hostClasses}>
140
+ <nav aria-hidden={!open}>
141
+ <p className="pkt-header-menu__error">Kunne ikke laste meny.</p>
142
+ </nav>
143
+ </section>
144
+ )
145
+ }
146
+
147
+ const { megamenu, i18n } = localeData
148
+ const navAriaLabel = i18n?.navAriaLabel || 'Hovedmeny'
149
+
150
+ return (
151
+ <section {...rest} ref={ref} className={hostClasses} aria-labelledby={ariaLabelledBy || undefined}>
152
+ <nav aria-label={navAriaLabel}>
153
+ {isMobile ? (
154
+ <MobileAccordion services={megamenu.services} sections={megamenu.sections} />
155
+ ) : (
156
+ <>
157
+ <Services services={megamenu.services} />
158
+ <Buttons buttons={megamenu.buttons} mobile={false} />
159
+ <Sections sections={megamenu.sections} />
160
+ </>
161
+ )}
162
+ <Buttons buttons={megamenu.buttons} mobile={true} />
163
+ <Footer links={megamenu.links} some={megamenu.some} />
164
+ </nav>
165
+ </section>
166
+ )
167
+ }
168
+
169
+ const ServicesList = ({ services }: { services: IHeaderMenuServices }) => (
170
+ <ul className="pkt-header-menu__services-list">
171
+ {services.links.map((link, index) => (
172
+ <li className="pkt-header-menu__service" key={`service-${index}`}>
173
+ <a className="pkt-header-menu__service-link" href={link.url}>
174
+ <PktIcon className="pkt-header-menu__service-icon" name={mapOdsIcon(link.icon)} aria-hidden="true" />
175
+ <span className="pkt-header-menu__service-text">{link.text}</span>
176
+ </a>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ )
181
+
182
+ const MobileAccordion = ({ services, sections }: { services: IHeaderMenuServices; sections: IHeaderMenuSection[] }) => (
183
+ <div className="pkt-header-menu__sections">
184
+ <PktAccordion className="pkt-header-menu__sections-inner" skin="plus-minus" name="header-menu-accordion">
185
+ <PktAccordionItem
186
+ className="pkt-header-menu__section"
187
+ skin="plus-minus"
188
+ id="pkt-header-menu-services"
189
+ title={services.title}
190
+ >
191
+ <ServicesList services={services} />
192
+ </PktAccordionItem>
193
+ {sections.map((section, index) => (
194
+ <PktAccordionItem
195
+ key={`section-${index}`}
196
+ className="pkt-header-menu__section"
197
+ skin="plus-minus"
198
+ id={`pkt-header-menu-section-${index}`}
199
+ title={section.title}
200
+ >
201
+ <SectionList links={section.links} />
202
+ </PktAccordionItem>
203
+ ))}
204
+ </PktAccordion>
205
+ </div>
206
+ )
207
+
208
+ const Services = ({ services }: { services: IHeaderMenuServices }) => (
209
+ <div className="pkt-header-menu__services">
210
+ <h2 className="pkt-header-menu__services-title">{services.title}</h2>
211
+ <ServicesList services={services} />
212
+ </div>
213
+ )
214
+
215
+ const Buttons = ({ buttons, mobile }: { buttons: IHeaderMenuButton[] | undefined; mobile: boolean }) => {
216
+ if (!buttons || buttons.length === 0) return null
217
+ const classes = ['pkt-header-menu__buttons', mobile && 'pkt-header-menu__buttons--mobile'].filter(Boolean).join(' ')
218
+ return (
219
+ <div className={classes}>
220
+ {buttons.map((button, index) => (
221
+ <a
222
+ key={`button-${index}`}
223
+ className="pkt-btn pkt-btn--secondary pkt-btn--icon-right pkt-btn--small"
224
+ href={button.url}
225
+ >
226
+ <PktIcon
227
+ className="pkt-btn__icon"
228
+ name={button.iconName ? mapOdsIcon(button.iconName) : 'user'}
229
+ aria-hidden="true"
230
+ />
231
+ <span className="pkt-btn__text">{button.text}</span>
232
+ </a>
233
+ ))}
234
+ </div>
235
+ )
236
+ }
237
+
238
+ const Sections = ({ sections }: { sections: IHeaderMenuSection[] }) => {
239
+ if (!sections || sections.length === 0) return null
240
+
241
+ return (
242
+ <div className="pkt-header-menu__sections">
243
+ <div className="pkt-header-menu__sections-inner">
244
+ {sections.map((section, index) => (
245
+ <div className="pkt-header-menu__section" key={`section-${index}`}>
246
+ <h2 className="pkt-header-menu__section-title">{section.title}</h2>
247
+ <SectionList links={section.links} />
248
+ </div>
249
+ ))}
250
+ </div>
251
+ </div>
252
+ )
253
+ }
254
+
255
+ const SectionList = ({ links }: { links: IHeaderMenuLink[] }) => (
256
+ <ul className="pkt-header-menu__section-list">
257
+ {links.map((link, index) => (
258
+ <li key={`section-link-${index}`}>
259
+ <a className="pkt-header-menu__section-link" href={link.url}>
260
+ {link.text}
261
+ </a>
262
+ </li>
263
+ ))}
264
+ </ul>
265
+ )
266
+
267
+ const Footer = ({ links, some }: { links: IHeaderMenuLink[]; some: IHeaderMenuLink[] }) => {
268
+ const hasLinks = links && links.length > 0
269
+ const hasSome = some && some.length > 0
270
+ if (!hasLinks && !hasSome) return null
271
+
272
+ return (
273
+ <div className="pkt-header-menu__footer">
274
+ {hasLinks && (
275
+ <ul className="pkt-header-menu__footer-list">
276
+ {links.map((link, index) => (
277
+ <li key={`footer-link-${index}`}>
278
+ <a className="pkt-header-menu__footer-link" href={link.url}>
279
+ {link.text}
280
+ </a>
281
+ </li>
282
+ ))}
283
+ </ul>
284
+ )}
285
+ {hasSome && (
286
+ <ul className="pkt-header-menu__footer-list pkt-header-menu__footer-list--social">
287
+ {some.map((entry, index) => (
288
+ <SocialLink key={`some-${index}`} entry={entry} />
289
+ ))}
290
+ </ul>
291
+ )}
292
+ </div>
293
+ )
294
+ }
295
+
296
+ const SocialLink = ({ entry }: { entry: IHeaderMenuLink }) => {
297
+ const iconName = deriveSocialIcon(entry.url, entry.text)
298
+ return (
299
+ <li>
300
+ <a className="pkt-header-menu__social-link" href={entry.url} aria-label={entry.text}>
301
+ {iconName ? <PktIcon name={iconName} aria-hidden="true" /> : <span>{entry.text}</span>}
302
+ </a>
303
+ </li>
304
+ )
305
+ }
@@ -15,6 +15,7 @@ export { ItemRenderers } from './fileupload/QueueDisplay'
15
15
  export { PktFooter } from './footer/Footer'
16
16
  export { PktFooterSimple } from './footerSimple/FooterSimple'
17
17
  export { PktHeader } from './header/Header'
18
+ export { PktHeaderMenu } from './header-menu/HeaderMenu'
18
19
  export { PktHeaderService } from './header/HeaderService'
19
20
  export { PktHeading } from './heading/Heading'
20
21
  export { PktHelptext } from './helptext/Helptext'
@@ -12,6 +12,7 @@ export type { IPktFileUpload } from './fileupload/FileUpload'
12
12
  export type { IPktFooter } from './footer/Footer'
13
13
  export type { IPktFooterSimple } from './footerSimple/FooterSimple'
14
14
  export type { IPktHeader } from './header/Header'
15
+ export type { IPktHeaderMenu } from './header-menu/HeaderMenu'
15
16
  export type { IPktHeaderService } from './header/HeaderService'
16
17
  export type { IPktInputWrapper } from './inputwrapper/InputWrapper'
17
18
  export type { IPktLinkCard } from './linkcard/LinkCard'