@oslokommune/punkt-react 16.16.2 → 16.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/CHANGELOG.md +18 -0
- package/dist/{dialog-polyfill.esm-BmVHGRTX-CuhXqEqJ.js → dialog-polyfill.esm-DrODM08x-C8rdFsL8.js} +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/punkt-react.es.js +1742 -1160
- package/dist/punkt-react.umd.js +226 -88
- package/dist/shared-types/header-menu.d.ts +108 -0
- package/dist/shared-types/index.d.ts +2 -0
- package/dist/shared-types/skin.d.ts +1 -1
- package/dist/shared-utils/header-menu/example-data.d.ts +11 -0
- package/dist/shared-utils/header-menu/fetch-header-footer-data.d.ts +12 -0
- package/dist/shared-utils/header-menu/icon-map.d.ts +34 -0
- package/dist/shared-utils/header-menu/index.d.ts +11 -0
- package/dist/shared-utils/header-menu/select-megamenu.d.ts +16 -0
- package/package.json +4 -4
- package/src/components/accordion/AccordionItem.tsx +20 -1
- package/src/components/header-menu/HeaderMenu.test.tsx +199 -0
- package/src/components/header-menu/HeaderMenu.tsx +305 -0
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +1 -0
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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'
|