@oslokommune/punkt-elements 13.22.0 → 14.0.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.
@@ -0,0 +1,215 @@
1
+ import { PktElement } from '@/base-elements/element'
2
+ import { html, nothing } from 'lit'
3
+ import { customElement, property } from 'lit/decorators.js'
4
+ import { classMap } from 'lit/directives/class-map.js'
5
+ import {
6
+ User,
7
+ Representing,
8
+ UserMenuItem,
9
+ TInternalMenuItem,
10
+ convertUserMenuItem,
11
+ Booleanish,
12
+ booleanishConverter,
13
+ } from './types'
14
+
15
+ import '@/components/icon'
16
+ import '@/components/link'
17
+
18
+ export interface IPktHeaderUserMenu {
19
+ user: User
20
+ formattedLastLoggedIn?: string
21
+ representing?: Representing
22
+ userMenu?: UserMenuItem[]
23
+ canChangeRepresentation?: Booleanish
24
+ logoutOnClick?: Booleanish
25
+ }
26
+
27
+ @customElement('pkt-header-user-menu')
28
+ export class PktHeaderUserMenu
29
+ extends PktElement<IPktHeaderUserMenu>
30
+ implements IPktHeaderUserMenu
31
+ {
32
+ @property({ type: Object }) user!: User
33
+ @property({ type: String, attribute: 'formatted-last-logged-in' }) formattedLastLoggedIn?: string
34
+ @property({ type: Object }) representing?: Representing
35
+ @property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[]
36
+ @property({
37
+ type: Boolean,
38
+ attribute: 'can-change-representation',
39
+ converter: booleanishConverter,
40
+ })
41
+ canChangeRepresentation: Booleanish = false
42
+ @property({ type: Boolean, attribute: 'logout-on-click', converter: booleanishConverter })
43
+ logoutOnClick: Booleanish = false
44
+
45
+ private handleChangeRepresentation() {
46
+ this.dispatchEvent(
47
+ new CustomEvent('change-representation', {
48
+ bubbles: true,
49
+ composed: true,
50
+ }),
51
+ )
52
+ }
53
+
54
+ private handleLogout() {
55
+ this.dispatchEvent(
56
+ new CustomEvent('log-out', {
57
+ bubbles: true,
58
+ composed: true,
59
+ }),
60
+ )
61
+ }
62
+
63
+ private handleMenuItemClick(item: TInternalMenuItem) {
64
+ if ('onClick' in item && typeof item.onClick === 'function') {
65
+ item.onClick()
66
+ }
67
+ }
68
+
69
+ private renderLinkOrButton(item: TInternalMenuItem, className?: string) {
70
+ const isLink = 'href' in item
71
+ const classes = classMap({
72
+ 'pkt-user-menu__link': true,
73
+ 'pkt-link-button': !isLink,
74
+ 'pkt-link': !isLink,
75
+ 'pkt-link--icon-left': !isLink,
76
+ [className || '']: !!className,
77
+ })
78
+
79
+ if (isLink) {
80
+ return html`
81
+ <pkt-link
82
+ icon-name=${item.iconName || nothing}
83
+ href=${item.href}
84
+ aria-hidden="true"
85
+ class="pkt-user-menu__link ${className || ''}"
86
+ >
87
+ ${item.title}
88
+ </pkt-link>
89
+ `
90
+ }
91
+
92
+ return html`
93
+ <button class=${classes} type="button" @click=${() => this.handleMenuItemClick(item)}>
94
+ ${item.iconName
95
+ ? html`<pkt-icon
96
+ name=${item.iconName}
97
+ class="pkt-link__icon"
98
+ aria-hidden="true"
99
+ ></pkt-icon>`
100
+ : nothing}
101
+ ${item.title}
102
+ </button>
103
+ `
104
+ }
105
+
106
+ private renderLinkSection(links: TInternalMenuItem[]) {
107
+ return html`
108
+ <ul class="pkt-user-menu__sublist">
109
+ ${links.map(
110
+ (item) => html`
111
+ <li class="pkt-user-menu__subitem">${this.renderLinkOrButton(item)}</li>
112
+ `,
113
+ )}
114
+ </ul>
115
+ `
116
+ }
117
+
118
+ render() {
119
+ const internalMenuItems = this.userMenu?.map(convertUserMenuItem)
120
+
121
+ return html`
122
+ <nav class="pkt-user-menu" aria-label="Meny for innlogget bruker">
123
+ <ul class="pkt-user-menu__list">
124
+ <!-- User section -->
125
+ ${this.user
126
+ ? html`
127
+ <li class="pkt-user-menu__item">
128
+ <div class="pkt-user-menu__label">Pålogget som</div>
129
+ <div class="pkt-user-menu__name" translate="no">${this.user.name}</div>
130
+ ${this.formattedLastLoggedIn
131
+ ? html`
132
+ <div class="pkt-user-menu__last-logged-in">
133
+ Sist pålogget: <time>${this.formattedLastLoggedIn}</time>
134
+ </div>
135
+ `
136
+ : nothing}
137
+ </li>
138
+ `
139
+ : nothing}
140
+
141
+ <!-- User menu items -->
142
+ ${internalMenuItems && internalMenuItems.length > 0
143
+ ? html`
144
+ <li class="pkt-user-menu__item">${this.renderLinkSection(internalMenuItems)}</li>
145
+ `
146
+ : nothing}
147
+
148
+ <!-- Representing section -->
149
+ ${this.representing
150
+ ? html`
151
+ <li class="pkt-user-menu__item">
152
+ <div class="pkt-user-menu__label">Representerer</div>
153
+ <div class="pkt-user-menu__name" translate="no">${this.representing.name}</div>
154
+ ${this.representing.orgNumber
155
+ ? html`<div class="pkt-user-menu__org-number">
156
+ Org.nr. ${this.representing.orgNumber}
157
+ </div>`
158
+ : nothing}
159
+ ${this.canChangeRepresentation
160
+ ? html`
161
+ <ul class="pkt-user-menu__sublist mt-size-16">
162
+ <li class="pkt-user-menu__subitem">
163
+ ${this.renderLinkOrButton({
164
+ title: 'Endre organisasjon',
165
+ iconName: 'cogwheel',
166
+ onClick: () => this.handleChangeRepresentation(),
167
+ })}
168
+ </li>
169
+ </ul>
170
+ `
171
+ : nothing}
172
+ </li>
173
+ `
174
+ : nothing}
175
+
176
+ <!-- Change representation without representing object -->
177
+ ${!this.representing && this.canChangeRepresentation
178
+ ? html`
179
+ <li class="pkt-user-menu__item">
180
+ <ul class="pkt-user-menu__sublist">
181
+ <li class="pkt-user-menu__subitem">
182
+ ${this.renderLinkOrButton({
183
+ title: 'Endre organisasjon',
184
+ iconName: 'cogwheel',
185
+ onClick: () => this.handleChangeRepresentation(),
186
+ })}
187
+ </li>
188
+ </ul>
189
+ </li>
190
+ `
191
+ : nothing}
192
+
193
+ <!-- Logout -->
194
+ ${this.logoutOnClick
195
+ ? html`
196
+ <li class="pkt-user-menu__item">
197
+ ${this.renderLinkOrButton({
198
+ title: 'Logg ut',
199
+ iconName: 'exit',
200
+ onClick: () => this.handleLogout(),
201
+ })}
202
+ </li>
203
+ `
204
+ : nothing}
205
+ </ul>
206
+ </nav>
207
+ `
208
+ }
209
+ }
210
+
211
+ declare global {
212
+ interface HTMLElementTagNameMap {
213
+ 'pkt-header-user-menu': PktHeaderUserMenu
214
+ }
215
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Format a Date or ISO string to a readable Norwegian date format
3
+ * @param date - Date object or ISO date string
4
+ * @returns Formatted date string like "22. januar 2026, 14:30"
5
+ */
6
+ export const formatLastLoggedIn = (date: Date | string | undefined): string | undefined => {
7
+ if (!date) return undefined
8
+
9
+ const dateObj = typeof date === 'string' ? new Date(date) : date
10
+
11
+ if (isNaN(dateObj.getTime())) return undefined
12
+
13
+ return dateObj.toLocaleDateString('nb-NO', {
14
+ day: 'numeric',
15
+ month: 'long',
16
+ year: 'numeric',
17
+ hour: '2-digit',
18
+ minute: '2-digit',
19
+ })
20
+ }
@@ -0,0 +1,141 @@
1
+ import { PktElement } from '@/base-elements/element'
2
+ import { PktSlotController } from '@/controllers/pkt-slot-controller'
3
+ import { html, PropertyValues } from 'lit'
4
+ import { customElement, property } from 'lit/decorators.js'
5
+ import { ifDefined } from 'lit/directives/if-defined.js'
6
+ import { createRef, Ref, ref } from 'lit/directives/ref.js'
7
+ import {
8
+ User,
9
+ Representing,
10
+ UserMenuItem,
11
+ TPktHeaderMenu,
12
+ TLogOutButtonPlacement,
13
+ THeaderPosition,
14
+ THeaderScrollBehavior,
15
+ IPktHeader,
16
+ Booleanish,
17
+ booleanishConverter,
18
+ } from './types'
19
+
20
+ import './header-service'
21
+
22
+ /**
23
+ * PktHeader - Main header component for Oslo kommune services
24
+ *
25
+ * This component provides a complete header solution with:
26
+ * - Logo and service name
27
+ * - User menu with login/logout functionality
28
+ * - Search functionality
29
+ * - Responsive mobile menu
30
+ * - Fixed positioning with scroll-to-hide
31
+ *
32
+ * TODO: Add `type` prop to switch between `service` and `global` header types
33
+ */
34
+ @customElement('pkt-header')
35
+ export class PktHeader extends PktElement<IPktHeader> implements IPktHeader {
36
+ defaultSlot: Ref<HTMLElement> = createRef()
37
+ slotController!: PktSlotController
38
+
39
+ @property({ type: String, attribute: 'service-name' }) serviceName?: string
40
+ @property({ type: String, attribute: 'service-link' }) serviceLink?: string
41
+ @property({ type: String, attribute: 'logo-link' }) logoLink?: string
42
+ @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder = 'Søk'
43
+ @property({ type: String, attribute: 'search-value' }) searchValue = ''
44
+ @property({ type: Number, attribute: 'mobile-breakpoint' }) mobileBreakpoint: number = 768
45
+ @property({ type: Number, attribute: 'tablet-breakpoint' }) tabletBreakpoint: number = 1280
46
+ @property({ type: String, attribute: 'opened-menu' }) openedMenu: TPktHeaderMenu = 'none'
47
+ @property({ type: String, attribute: 'log-out-button-placement' })
48
+ logOutButtonPlacement: TLogOutButtonPlacement = 'none'
49
+ @property({ type: String }) position: THeaderPosition = 'fixed'
50
+ @property({ type: String, attribute: 'scroll-behavior' }) scrollBehavior: THeaderScrollBehavior =
51
+ 'hide'
52
+
53
+ @property({ type: Boolean, attribute: 'hide-logo', converter: booleanishConverter })
54
+ hideLogo: Booleanish = false
55
+ @property({ type: Boolean, converter: booleanishConverter }) compact: Booleanish = false
56
+ @property({ type: Boolean, attribute: 'show-search', converter: booleanishConverter })
57
+ showSearch: Booleanish = false
58
+ @property({
59
+ type: Boolean,
60
+ attribute: 'can-change-representation',
61
+ converter: booleanishConverter,
62
+ })
63
+ canChangeRepresentation: Booleanish = false
64
+ @property({ type: Boolean, attribute: 'has-log-out', converter: booleanishConverter })
65
+ hasLogOut: Booleanish = false
66
+
67
+ @property({ type: Object }) user?: User
68
+ @property({ type: Array, attribute: 'user-menu' }) userMenu?: UserMenuItem[]
69
+ @property({ type: Object }) representing?: Representing
70
+
71
+ // Deprecated props - emit warnings
72
+ @property({ type: Array, attribute: 'user-menu-footer' }) userMenuFooter?: UserMenuItem[]
73
+ @property({ type: Array, attribute: 'user-options' }) userOptions?: UserMenuItem[]
74
+
75
+ constructor() {
76
+ super()
77
+ this.slotController = new PktSlotController(this, this.defaultSlot)
78
+ }
79
+
80
+ firstUpdated(changedProperties: PropertyValues) {
81
+ super.firstUpdated(changedProperties)
82
+ this.emitDeprecationWarnings()
83
+ }
84
+
85
+ private emitDeprecationWarnings() {
86
+ if (this.userMenuFooter !== undefined) {
87
+ console.warn('[PktHeader] userMenuFooter is deprecated. Use userMenu instead.')
88
+ }
89
+ if (this.userOptions !== undefined) {
90
+ console.warn('[PktHeader] userOptions is deprecated. Use userMenu instead.')
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Convert deprecated props to new props
96
+ */
97
+ private get effectiveUserMenu(): UserMenuItem[] | undefined {
98
+ const menu = this.userMenu || []
99
+ const footer = this.userMenuFooter || []
100
+ const options = this.userOptions || []
101
+
102
+ if (footer.length || options.length) {
103
+ return [...menu, ...options, ...footer]
104
+ }
105
+ return this.userMenu
106
+ }
107
+
108
+ render() {
109
+ return html`
110
+ <pkt-header-service
111
+ service-name=${ifDefined(this.serviceName)}
112
+ service-link=${ifDefined(this.serviceLink)}
113
+ logo-link=${ifDefined(this.logoLink)}
114
+ search-placeholder=${this.searchPlaceholder}
115
+ search-value=${this.searchValue}
116
+ mobile-breakpoint=${this.mobileBreakpoint}
117
+ tablet-breakpoint=${this.tabletBreakpoint}
118
+ opened-menu=${this.openedMenu}
119
+ log-out-button-placement=${this.logOutButtonPlacement}
120
+ position=${this.position}
121
+ scroll-behavior=${this.scrollBehavior}
122
+ .hideLogo=${this.hideLogo}
123
+ .compact=${this.compact}
124
+ .showSearch=${this.showSearch}
125
+ .canChangeRepresentation=${this.canChangeRepresentation}
126
+ .hasLogOut=${this.hasLogOut}
127
+ .user=${this.user}
128
+ .userMenu=${this.effectiveUserMenu}
129
+ .representing=${this.representing}
130
+ >
131
+ <div class="pkt-contents" ${ref(this.defaultSlot)}></div>
132
+ </pkt-header-service>
133
+ `
134
+ }
135
+ }
136
+
137
+ declare global {
138
+ interface HTMLElementTagNameMap {
139
+ 'pkt-header': PktHeader
140
+ }
141
+ }
@@ -0,0 +1,15 @@
1
+ export { PktHeader } from './header'
2
+ export { PktHeaderService } from './header-service'
3
+ export { PktHeaderUserMenu } from './header-user-menu'
4
+
5
+ export type {
6
+ User,
7
+ Representing,
8
+ UserMenuItem,
9
+ TInternalMenuItem,
10
+ TPktHeaderMenu,
11
+ TLogOutButtonPlacement,
12
+ IPktHeader,
13
+ } from './types'
14
+
15
+ export { formatLastLoggedIn } from './header-utils'
@@ -0,0 +1,151 @@
1
+ import { PktIconName } from '@oslokommune/punkt-assets/dist/icons/icon'
2
+
3
+ /**
4
+ * Booleanish type that accepts boolean or string "true"/"false"
5
+ * This allows attributes to be set as strings in HTML
6
+ */
7
+ export type Booleanish = boolean | 'true' | 'false'
8
+
9
+ /**
10
+ * Converter for booleanish attributes
11
+ * Converts string "true"/"false" to boolean values
12
+ */
13
+ export const booleanishConverter = {
14
+ fromAttribute(value: string | boolean | null): boolean {
15
+ if (value === null || value === undefined) return false
16
+ if (value === '' || value === 'true' || value === true) return true
17
+ if (value === 'false' || value === false) return false
18
+ return Boolean(value)
19
+ },
20
+ toAttribute(value: boolean): string | null {
21
+ return value ? 'true' : 'false'
22
+ },
23
+ }
24
+
25
+ /**
26
+ * User object containing information about the logged-in user
27
+ */
28
+ export interface User {
29
+ /** Full name of the user */
30
+ name: string
31
+ /** Short name or initials (deprecated) */
32
+ shortname?: string
33
+ /** Last login timestamp (ISO string or Date) */
34
+ lastLoggedIn?: Date | string
35
+ }
36
+
37
+ /**
38
+ * Representation object containing information about the organization/entity being represented
39
+ */
40
+ export interface Representing {
41
+ /** Name of the organization or entity */
42
+ name: string
43
+ /** Short name or initials (deprecated) */
44
+ shortname?: string
45
+ /** Organization number */
46
+ orgNumber?: string | number
47
+ }
48
+
49
+ /**
50
+ * Menu item in the user menu
51
+ */
52
+ export interface UserMenuItem {
53
+ /** Icon name to display */
54
+ iconName?: PktIconName
55
+ /** Text for the menu item */
56
+ title: string
57
+ /** Link URL or click handler function */
58
+ target: string | (() => void)
59
+ }
60
+
61
+ /**
62
+ * Internal type for rendering links/buttons (supports both href and onClick)
63
+ */
64
+ interface InternalMenuItemBase {
65
+ title: string
66
+ iconName?: PktIconName
67
+ }
68
+
69
+ interface InternalMenuLink extends InternalMenuItemBase {
70
+ href: string
71
+ }
72
+
73
+ interface InternalMenuButton extends InternalMenuItemBase {
74
+ onClick: () => void
75
+ }
76
+
77
+ export type TInternalMenuItem = InternalMenuLink | InternalMenuButton
78
+
79
+ /**
80
+ * Helper to convert UserMenuItem (with target) to internal format
81
+ */
82
+ export const convertUserMenuItem = (item: UserMenuItem): TInternalMenuItem => {
83
+ if (typeof item.target === 'string') {
84
+ return { title: item.title, iconName: item.iconName, href: item.target }
85
+ }
86
+ return { title: item.title, iconName: item.iconName, onClick: item.target }
87
+ }
88
+
89
+ /**
90
+ * Type for which menu is currently open
91
+ */
92
+ export type TPktHeaderMenu = 'none' | 'slot' | 'search' | 'user'
93
+
94
+ /**
95
+ * Type for logout button placement
96
+ */
97
+ export type TLogOutButtonPlacement = 'userMenu' | 'header' | 'both' | 'none'
98
+
99
+ /**
100
+ * Position options for header
101
+ */
102
+ export type THeaderPosition = 'fixed' | 'relative'
103
+
104
+ /**
105
+ * Scroll behavior options for header
106
+ */
107
+ export type THeaderScrollBehavior = 'hide' | 'none'
108
+
109
+ /**
110
+ * Interface for the Header component props
111
+ */
112
+ export interface IPktHeader {
113
+ /** Hide the Oslo logo */
114
+ hideLogo?: Booleanish
115
+ /** Logo link URL */
116
+ logoLink?: string
117
+ /** Service name displayed in the header */
118
+ serviceName?: string
119
+ /** Service link URL */
120
+ serviceLink?: string
121
+ /** Use compact header height */
122
+ compact?: Booleanish
123
+ /** Header position. 'fixed' fixes to top of viewport, 'relative' follows document flow. Default: 'fixed' */
124
+ position?: THeaderPosition
125
+ /** Scroll behavior. 'hide' hides header on scroll down, 'none' keeps it visible. Default: 'hide' */
126
+ scrollBehavior?: THeaderScrollBehavior
127
+ /** User object for logged-in user */
128
+ user?: User
129
+ /** User menu items */
130
+ userMenu?: UserMenuItem[]
131
+ /** Representation object */
132
+ representing?: Representing
133
+ /** Allow user to change representation */
134
+ canChangeRepresentation?: Booleanish
135
+ /** Logout button placement */
136
+ logOutButtonPlacement?: TLogOutButtonPlacement
137
+ /** Whether there's a logout handler attached (required for logout button to show) */
138
+ hasLogOut?: Booleanish
139
+ /** Show search field */
140
+ showSearch?: Booleanish
141
+ /** Search field placeholder */
142
+ searchPlaceholder?: string
143
+ /** Controlled search value */
144
+ searchValue?: string
145
+ /** Custom breakpoint for responsive behavior in pixels. Default: 1024 */
146
+ mobileBreakpoint?: number
147
+ /** Custom breakpoint for tablet responsive behavior in pixels. Default: 1280 */
148
+ tabletBreakpoint?: number
149
+ /** Which menu is initially open */
150
+ openedMenu?: TPktHeaderMenu
151
+ }
@@ -11,6 +11,7 @@ export { PktCheckbox } from '@/components/checkbox'
11
11
  export { PktComponent } from '../base-elements/component-template.js'
12
12
  export { PktDateTags } from '@/components/datepicker/date-tags.js'
13
13
  export { PktDatepicker } from '@/components/datepicker/datepicker.js'
14
+ export { PktHeader, PktHeaderService, PktHeaderUserMenu } from '@/components/header'
14
15
  export { PktHelptext } from '@/components/helptext'
15
16
  export { PktHeading } from '@/components/heading'
16
17
  export { PktIcon } from '@/components/icon'
@@ -44,6 +45,15 @@ export type {
44
45
  TPktButtonType,
45
46
  } from '@/components/button'
46
47
 
48
+ export type {
49
+ User as IPktHeaderUser,
50
+ Representing as IPktHeaderRepresenting,
51
+ UserMenuItem as IPktHeaderUserMenuItem,
52
+ TPktHeaderMenu,
53
+ TLogOutButtonPlacement as TPktHeaderLogOutButtonPlacement,
54
+ IPktHeader,
55
+ } from '@/components/header'
56
+
47
57
  export type {
48
58
  IPktProgressbar,
49
59
  TProgressbarRole,