@oslokommune/punkt-elements 13.6.10 → 13.6.12

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 (39) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/{combobox-cK_746ek.cjs → combobox-BFOjlFIj.cjs} +1 -1
  3. package/dist/{combobox-DxNotM0u.js → combobox-DaiEdUKx.js} +1 -1
  4. package/dist/datepicker-C244h82t.cjs +190 -0
  5. package/dist/datepicker-DwOkktaP.js +859 -0
  6. package/dist/index.d.ts +48 -13
  7. package/dist/{input-wrapper-D_JdEqcO.js → input-wrapper-CQzXG44g.js} +22 -22
  8. package/dist/{input-wrapper-C9rZEgju.cjs → input-wrapper-DVjNwf8-.cjs} +11 -12
  9. package/dist/pkt-combobox.cjs +1 -1
  10. package/dist/pkt-combobox.js +1 -1
  11. package/dist/pkt-datepicker.cjs +1 -1
  12. package/dist/pkt-datepicker.js +1 -1
  13. package/dist/pkt-index.cjs +1 -1
  14. package/dist/pkt-index.js +6 -6
  15. package/dist/pkt-input-wrapper.cjs +1 -1
  16. package/dist/pkt-input-wrapper.js +1 -1
  17. package/dist/pkt-select.cjs +1 -1
  18. package/dist/pkt-select.js +1 -1
  19. package/dist/pkt-textarea.cjs +1 -1
  20. package/dist/pkt-textarea.js +1 -1
  21. package/dist/pkt-textinput.cjs +1 -1
  22. package/dist/pkt-textinput.js +1 -1
  23. package/dist/{select-D7OQaUrQ.js → select-DKkoxmgj.js} +1 -1
  24. package/dist/{select-Cf1RWSsI.cjs → select-DynzsPo0.cjs} +1 -1
  25. package/dist/{textarea-CXu8UUsY.cjs → textarea-BS1tgktz.cjs} +1 -1
  26. package/dist/{textarea-C0vTWTov.js → textarea-COG1CH_s.js} +1 -1
  27. package/dist/{textinput-C6wccDhZ.cjs → textinput-CCK8ti2y.cjs} +1 -1
  28. package/dist/{textinput-CmZrfH4A.js → textinput-CTOtfcTR.js} +1 -1
  29. package/package.json +2 -2
  30. package/src/components/checkbox/checkbox.ts +17 -1
  31. package/src/components/datepicker/datepicker-popup.test.ts +77 -0
  32. package/src/components/datepicker/datepicker-popup.ts +137 -0
  33. package/src/components/datepicker/datepicker-utils.ts +13 -8
  34. package/src/components/datepicker/datepicker.ts +64 -48
  35. package/src/components/input-wrapper/input-wrapper.ts +7 -7
  36. package/src/components/radiobutton/radiobutton.ts +14 -1
  37. package/src/components/textarea/textarea.ts +4 -1
  38. package/dist/datepicker-BEMo4X9s.js +0 -770
  39. package/dist/datepicker-n49TAIAt.cjs +0 -169
@@ -0,0 +1,77 @@
1
+ import '@testing-library/jest-dom'
2
+ import { fireEvent } from '@testing-library/dom'
3
+ import { vi } from 'vitest'
4
+
5
+ import { createElementTest, BaseTestConfig } from '../../tests/test-framework'
6
+ import './datepicker-popup'
7
+ import '../calendar/calendar'
8
+
9
+ import { PktDatepickerPopup } from './datepicker-popup'
10
+ import { PktCalendar } from '../calendar/calendar'
11
+
12
+ export interface IDatepickerPopupTest extends BaseTestConfig {
13
+ open?: boolean
14
+ }
15
+
16
+ const createPopupTest = async (config: IDatepickerPopupTest = {}) => {
17
+ const { container, element } = await createElementTest('pkt-datepicker-popup' as any, config)
18
+ return { container, popup: element as PktDatepickerPopup }
19
+ }
20
+
21
+ afterEach(() => {
22
+ document.body.innerHTML = ''
23
+ })
24
+
25
+ describe('PktDatepickerPopup', () => {
26
+ test('opens when show() is called', async () => {
27
+ const { popup } = await createPopupTest()
28
+ await popup.updateComplete
29
+
30
+ popup.show()
31
+ await popup.updateComplete
32
+ expect(popup.open).toBe(true)
33
+ })
34
+
35
+ test('closes when clicking outside', async () => {
36
+ const { popup } = await createPopupTest()
37
+ await popup.updateComplete
38
+
39
+ popup.show()
40
+ await popup.updateComplete
41
+ expect(popup.open).toBe(true)
42
+
43
+ fireEvent.click(document.body)
44
+ await popup.updateComplete
45
+ expect(popup.open).toBe(false)
46
+ })
47
+
48
+ test('closes on Escape key', async () => {
49
+ const { popup } = await createPopupTest()
50
+ await popup.updateComplete
51
+
52
+ popup.show()
53
+ await popup.updateComplete
54
+ expect(popup.open).toBe(true)
55
+
56
+ fireEvent.keyDown(popup, { key: 'Escape' })
57
+ await popup.updateComplete
58
+ expect(popup.open).toBe(false)
59
+ })
60
+
61
+ test('re-dispatches date-selected from child calendar', async () => {
62
+ const { popup } = await createPopupTest()
63
+ await popup.updateComplete
64
+
65
+ const handler = vi.fn()
66
+ popup.addEventListener('date-selected', (e: any) => handler(e))
67
+
68
+ const cal = popup.querySelector('pkt-calendar') as PktCalendar | null
69
+ expect(cal).toBeTruthy()
70
+ const detail = ['2025-09-17']
71
+ cal?.dispatchEvent(new CustomEvent('date-selected', { detail, bubbles: true, composed: true }))
72
+
73
+ expect(handler).toHaveBeenCalled()
74
+ const eventArg = handler.mock.calls[0][0]
75
+ expect(eventArg.detail).toEqual(detail)
76
+ })
77
+ })
@@ -0,0 +1,137 @@
1
+ import { html } from 'lit'
2
+ import { PktElement } from '@/base-elements/element'
3
+ import { customElement, property } from 'lit/decorators.js'
4
+ import { classMap } from 'lit/directives/class-map.js'
5
+ import { ref, createRef, Ref } from 'lit/directives/ref.js'
6
+
7
+ import { PktCalendar } from '../calendar/calendar'
8
+ import { calendarUtils } from './datepicker-utils'
9
+
10
+ @customElement('pkt-datepicker-popup')
11
+ export class PktDatepickerPopup extends PktElement {
12
+ @property({ type: Boolean, reflect: true }) open = false
13
+ @property({ type: Boolean }) multiple = false
14
+ @property({ type: Boolean }) range = false
15
+ @property({ type: Boolean }) weeknumbers = false
16
+ @property({ type: Boolean }) withcontrols = false
17
+ @property({ type: Number }) maxMultiple: number | null = null
18
+ @property({ type: Array }) selected: string[] = []
19
+ @property({ type: String }) earliest: string | null = null
20
+ @property({ type: String }) latest: string | null = null
21
+ @property({ type: Array }) excludedates: string[] = []
22
+ @property({ type: Array }) excludeweekdays: string[] = []
23
+ @property({ type: String }) currentmonth: string | null = null
24
+
25
+ popupRef: Ref<HTMLElement> = createRef()
26
+ calendarRef: Ref<HTMLElement> = createRef()
27
+
28
+ firstUpdated() {
29
+ // expose calendarRef for external use
30
+ this.calRef = this.calendarRef
31
+
32
+ document.addEventListener('keydown', this.handleDocumentKeydown)
33
+ document.addEventListener('click', this.handleDocumentClick)
34
+ }
35
+
36
+ disconnectedCallback() {
37
+ super.disconnectedCallback()
38
+ document.removeEventListener('click', this.handleDocumentClick)
39
+ document.removeEventListener('keydown', this.handleDocumentKeydown)
40
+ }
41
+
42
+ handleDocumentClick = (e: MouseEvent) => {
43
+ if (!this.open) return
44
+ const path = e.composedPath() as EventTarget[]
45
+ const host = this.parentElement as EventTarget | null
46
+ const popupNode = this.popupRef.value as EventTarget | null
47
+ if (
48
+ !path.includes(this) &&
49
+ !path.includes(popupNode as EventTarget) &&
50
+ !(host && path.includes(host))
51
+ ) {
52
+ this.hide()
53
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))
54
+ }
55
+ }
56
+
57
+ handleDocumentKeydown = (e: KeyboardEvent) => {
58
+ if (!this.open) return
59
+ if (e.key === 'Escape') {
60
+ this.hide()
61
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))
62
+ }
63
+ }
64
+
65
+ show() {
66
+ this.open = true
67
+ ;(this.calendarRef.value as HTMLElement | null)?.focus()
68
+ }
69
+
70
+ hide() {
71
+ this.open = false
72
+ }
73
+
74
+ toggle() {
75
+ this.open ? this.hide() : this.show()
76
+ }
77
+
78
+ contains(node: Node | null) {
79
+ return !!node && !!(this.popupRef.value as HTMLElement | null)?.contains(node as Node)
80
+ }
81
+
82
+ focusOnCurrentDate() {
83
+ const cal = this.calendarRef.value as PktCalendar
84
+ if (cal && typeof cal.focusOnCurrentDate === 'function') cal.focusOnCurrentDate()
85
+ }
86
+
87
+ addToSelected(e: Event, min?: string | null, max?: string | null) {
88
+ if (typeof calendarUtils.addToSelected === 'function') {
89
+ return calendarUtils.addToSelected(e, this.calendarRef as any, min, max)
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ handleDateSelect(date: Date) {
95
+ const cal = this.calendarRef.value as PktCalendar
96
+ if (cal && typeof cal.handleDateSelect === 'function') return cal.handleDateSelect(date)
97
+ return undefined
98
+ }
99
+
100
+ render() {
101
+ const classes = { 'pkt-calendar-popup': true, show: this.open, hide: !this.open }
102
+ return html`
103
+ <div
104
+ class="${classMap(classes)}"
105
+ ${ref(this.popupRef)}
106
+ id="date-popup"
107
+ ?hidden=${!this.open}
108
+ aria-hidden="${!this.open}"
109
+ >
110
+ <pkt-calendar
111
+ ${ref(this.calendarRef)}
112
+ ?multiple=${this.multiple}
113
+ ?range=${this.range}
114
+ ?weeknumbers=${this.weeknumbers}
115
+ ?withcontrols=${this.withcontrols}
116
+ .maxMultiple=${this.maxMultiple}
117
+ .selected=${this.selected}
118
+ .earliest=${this.earliest}
119
+ .latest=${this.latest}
120
+ .excludedates=${this.excludedates}
121
+ .excludeweekdays=${this.excludeweekdays}
122
+ .currentmonth=${this.currentmonth}
123
+ @date-selected=${(e: CustomEvent) => {
124
+ this.selected = e.detail
125
+ this.dispatchEvent(
126
+ new CustomEvent('date-selected', { detail: e.detail, bubbles: true, composed: true }),
127
+ )
128
+ }}
129
+ @close=${() => {
130
+ this.hide()
131
+ this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))
132
+ }}
133
+ ></pkt-calendar>
134
+ </div>
135
+ `
136
+ }
137
+ }
@@ -24,18 +24,23 @@ export const sleep = (ms: number): Promise<void> =>
24
24
  export const deviceDetection = {
25
25
  /**
26
26
  * Detects if the current device is iOS (iPhone, iPad, iPod)
27
+ * Handles modern iPad Safari which uses desktop user agent since iOS 13
27
28
  */
28
29
  isIOS(): boolean {
29
30
  const ua = navigator.userAgent
30
- return /iP(hone|od|ad)/.test(ua)
31
- },
32
31
 
33
- /**
34
- * Detects if the current device is Mobile Safari
35
- */
36
- isMobileSafari(): boolean {
37
- const ua = navigator.userAgent
38
- return /iP(hone|od|ad)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS/.test(ua)
32
+ // Legacy iOS detection (iPhone, iPod, older iPads)
33
+ if (/iP(hone|od|ad)/.test(ua)) {
34
+ return true
35
+ }
36
+
37
+ // Modern iPad detection (iOS 13+ iPads identify as Mac)
38
+ // Check for Mac + touch support
39
+ if (/Macintosh/.test(ua) && 'ontouchend' in document) {
40
+ return true
41
+ }
42
+
43
+ return false
39
44
  },
40
45
  }
41
46
 
@@ -12,6 +12,7 @@ import '@/components/calendar'
12
12
  import '@/components/icon'
13
13
  import '@/components/input-wrapper'
14
14
  import './date-tags'
15
+ import './datepicker-popup'
15
16
  import { PktSlotController } from '@/controllers/pkt-slot-controller'
16
17
  import { keyboardUtils } from './datepicker-utils'
17
18
  import {
@@ -20,19 +21,39 @@ import {
20
21
  valueUtils,
21
22
  inputTypeUtils,
22
23
  calendarUtils,
23
- eventUtils,
24
24
  cssUtils,
25
25
  dateProcessingUtils,
26
26
  formUtils,
27
27
  } from './datepicker-utils'
28
+ import { PktDatepickerPopup } from './datepicker-popup'
29
+ import { ElementProps } from '@/types/typeUtils'
30
+
31
+ type Props = ElementProps<
32
+ PktDatepicker,
33
+ | 'label'
34
+ | 'dateformat'
35
+ | 'multiple'
36
+ | 'maxlength'
37
+ | 'range'
38
+ | 'showRangeLabels'
39
+ | 'min'
40
+ | 'max'
41
+ | 'weeknumbers'
42
+ | 'withcontrols'
43
+ | 'excludedates'
44
+ | 'excludeweekdays'
45
+ | 'currentmonth'
46
+ | 'calendarOpen'
47
+ | 'timezone'
48
+ >
49
+
28
50
  @customElement('pkt-datepicker')
29
- export class PktDatepicker extends PktInputElement {
51
+ export class PktDatepicker extends PktInputElement<Props> {
30
52
  /**
31
53
  * Element attributes and properties
32
54
  */
33
55
  private _valueProperty: string = ''
34
- private documentClickListener?: (e: MouseEvent) => void
35
- private documentKeydownListener?: (e: KeyboardEvent) => void
56
+ datepickerPopupRef: Ref<PktDatepickerPopup> = createRef()
36
57
 
37
58
  @property({ type: String, reflect: true })
38
59
  get value(): string {
@@ -120,30 +141,10 @@ export class PktDatepicker extends PktInputElement {
120
141
  }
121
142
  this.name =
122
143
  valueUtils.normalizeNameForMultiple(this.name, this.multiple, this.range) || this.name
123
- this.documentClickListener = eventUtils.createDocumentClickListener(
124
- this.inputRef,
125
- this.inputRefTo,
126
- this.btnRef,
127
- () => this.calendarOpen,
128
- this.onBlur.bind(this),
129
- this.hideCalendar.bind(this),
130
- )
131
- this.documentKeydownListener = eventUtils.createDocumentKeydownListener(
132
- () => this.calendarOpen,
133
- this.hideCalendar.bind(this),
134
- )
135
- document.addEventListener('click', this.documentClickListener)
136
- document.addEventListener('keydown', this.documentKeydownListener)
137
144
  }
138
145
 
139
146
  disconnectedCallback(): void {
140
147
  super.disconnectedCallback()
141
- if (this.documentClickListener) {
142
- document.removeEventListener('click', this.documentClickListener)
143
- }
144
- if (this.documentKeydownListener) {
145
- document.removeEventListener('keydown', this.documentKeydownListener)
146
- }
147
148
  }
148
149
 
149
150
  onInput(): void {
@@ -271,7 +272,7 @@ export class PktDatepicker extends PktInputElement {
271
272
  }}
272
273
  @focus=${() => {
273
274
  this.onFocus()
274
- if (deviceDetection.isMobileSafari()) {
275
+ if (deviceDetection.isIOS()) {
275
276
  this.showCalendar()
276
277
  }
277
278
  }}
@@ -327,7 +328,7 @@ export class PktDatepicker extends PktInputElement {
327
328
  }}
328
329
  @focus=${() => {
329
330
  this.onFocus()
330
- if (deviceDetection.isMobileSafari()) {
331
+ if (deviceDetection.isIOS()) {
331
332
  this.showCalendar()
332
333
  }
333
334
  }}
@@ -380,7 +381,7 @@ export class PktDatepicker extends PktInputElement {
380
381
  }}
381
382
  @focus=${() => {
382
383
  this.onFocus()
383
- if (deviceDetection.isMobileSafari()) {
384
+ if (deviceDetection.isIOS()) {
384
385
  this.showCalendar()
385
386
  }
386
387
  }}
@@ -441,7 +442,7 @@ export class PktDatepicker extends PktInputElement {
441
442
  }}
442
443
  @focus=${() => {
443
444
  this.onFocus()
444
- if (deviceDetection.isMobileSafari()) {
445
+ if (deviceDetection.isIOS()) {
445
446
  this.showCalendar()
446
447
  }
447
448
  }}
@@ -465,16 +466,10 @@ export class PktDatepicker extends PktInputElement {
465
466
  }
466
467
 
467
468
  renderCalendar() {
468
- return html`<div
469
- class="pkt-calendar-popup pkt-${this.calendarOpen ? 'show' : 'hide'}"
470
- @focusout=${(e: FocusEvent) => {
471
- if (this.calendarOpen) this.handleFocusOut(e)
472
- }}
473
- id="${this.id}-popup"
474
- ${ref(this.popupRef)}
475
- >
476
- <pkt-calendar
477
- id="${this.id}-calendar"
469
+ return html`
470
+ <pkt-datepicker-popup
471
+ class="pkt-contents"
472
+ ?open=${this.calendarOpen}
478
473
  ?multiple=${this.multiple}
479
474
  ?range=${this.range}
480
475
  ?weeknumbers=${this.weeknumbers}
@@ -504,9 +499,9 @@ export class PktDatepicker extends PktInputElement {
504
499
  this.onBlur()
505
500
  this.hideCalendar()
506
501
  }}
507
- ${ref(this.calRef)}
508
- ></pkt-calendar>
509
- </div>`
502
+ ${ref(this.datepickerPopupRef)}
503
+ ></pkt-datepicker-popup>
504
+ `
510
505
  }
511
506
 
512
507
  render() {
@@ -551,7 +546,13 @@ export class PktDatepicker extends PktInputElement {
551
546
  strings=${this.strings}
552
547
  id-base=${this.id}
553
548
  @date-tag-removed=${(e: CustomEvent) => {
554
- this.calRef.value?.handleDateSelect(fromISOToDate(e.detail))
549
+ const popup = this.datepickerPopupRef.value
550
+ const date = fromISOToDate(e.detail)
551
+ if (popup && date && typeof popup.handleDateSelect === 'function') {
552
+ popup.handleDateSelect(date)
553
+ } else {
554
+ this.calRef.value?.handleDateSelect(date)
555
+ }
555
556
  }}
556
557
  ></pkt-date-tags>`
557
558
  : nothing}
@@ -595,28 +596,43 @@ export class PktDatepicker extends PktInputElement {
595
596
  }
596
597
 
597
598
  addToSelected = (e: Event | KeyboardEvent) => {
598
- calendarUtils.addToSelected(e, this.calRef, this.min, this.max)
599
- }
600
-
601
- private handleFocusOut(e: FocusEvent) {
602
- eventUtils.handleFocusOut(e, this, this.onBlur.bind(this), this.hideCalendar.bind(this))
599
+ const popup = this.datepickerPopupRef.value
600
+ if (popup && typeof popup.addToSelected === 'function') {
601
+ return popup.addToSelected(e, this.min, this.max)
602
+ }
603
+ return calendarUtils.addToSelected(e, this.calRef, this.min, this.max)
603
604
  }
604
605
 
605
606
  public async showCalendar() {
607
+ const popup = this.datepickerPopupRef.value
606
608
  this.calendarOpen = true
609
+ if (popup && typeof popup.show === 'function') {
610
+ popup.show()
611
+ if (deviceDetection.isIOS()) popup.focusOnCurrentDate()
612
+ return
613
+ }
607
614
  await sleep(20)
608
615
  this.handleCalendarPosition()
609
- if (deviceDetection.isMobileSafari()) {
616
+ if (deviceDetection.isIOS()) {
610
617
  this.calRef.value?.focusOnCurrentDate()
611
618
  }
612
619
  }
613
620
 
614
621
  public hideCalendar() {
622
+ const popup = this.datepickerPopupRef.value
615
623
  this.calendarOpen = false
624
+ if (popup && typeof popup.hide === 'function') return popup.hide()
616
625
  }
617
626
 
618
627
  public async toggleCalendar(e: Event) {
619
628
  e.preventDefault()
629
+ const popup = this.datepickerPopupRef.value
630
+ if (popup && typeof popup.toggle === 'function') {
631
+ const wasOpen = !!popup.open
632
+ popup.toggle()
633
+ this.calendarOpen = !wasOpen
634
+ return
635
+ }
620
636
  this.calendarOpen ? this.hideCalendar() : this.showCalendar()
621
637
  }
622
638
 
@@ -11,6 +11,7 @@ import { uuidish } from '@/utils/stringutils'
11
11
  import specs from 'componentSpecs/input-wrapper.json'
12
12
  import '@/components/helptext'
13
13
  import '@/components/icon'
14
+ import '@/components/alert'
14
15
 
15
16
  type TCounterPosition = 'top' | 'bottom'
16
17
  type Props = ElementProps<
@@ -190,16 +191,15 @@ export class PktInputWrapper extends PktElement<Props> {
190
191
 
191
192
  const errorElement = () => {
192
193
  if (this.hasError && this.errorMessage) {
193
- return html`<div
194
- role="alert"
195
- class="pkt-alert pkt-alert--error pkt-alert--compact"
194
+ return html`<pkt-alert
195
+ skin="error"
196
+ compact
197
+ id=${`${this.forId}-error`}
196
198
  aria-live="assertive"
197
199
  aria-atomic="true"
198
- id="${this.forId}-error"
199
200
  >
200
- <pkt-icon name="alert-error" class="pkt-alert__icon"></pkt-icon>
201
- <div class="pkt-alert__text">${unsafeHTML(this.errorMessage)}</div>
202
- </div>`
201
+ ${unsafeHTML(this.errorMessage)}
202
+ </pkt-alert>`
203
203
  } else {
204
204
  return nothing
205
205
  }
@@ -4,9 +4,22 @@ import { PktInputElement } from '@/base-elements/input-element'
4
4
  import { Ref, createRef, ref } from 'lit/directives/ref.js'
5
5
  import { html, nothing } from 'lit'
6
6
  import { classMap } from 'lit/directives/class-map.js'
7
+ import { ElementProps } from '@/types/typeUtils'
8
+
9
+ type Props = ElementProps<
10
+ PktRadioButton,
11
+ | 'checkHelptext'
12
+ | 'defaultChecked'
13
+ | 'hasTile'
14
+ | 'tagText'
15
+ | 'optionalTag'
16
+ | 'optionalText'
17
+ | 'requiredTag'
18
+ | 'requiredText'
19
+ >
7
20
 
8
21
  @customElement('pkt-radiobutton')
9
- export class PktRadioButton extends PktInputElement {
22
+ export class PktRadioButton extends PktInputElement<Props> {
10
23
  private inputRef: Ref<HTMLInputElement> = createRef()
11
24
 
12
25
  @property({ type: String, reflect: true }) value: string = ''
@@ -9,9 +9,12 @@ import { PktSlotController } from '@/controllers/pkt-slot-controller'
9
9
 
10
10
  import '@/components/input-wrapper'
11
11
  import '@/components/icon'
12
+ import { ElementProps } from '@/types/typeUtils'
13
+
14
+ type Props = ElementProps<PktTextarea, 'autocomplete' | 'rows'>
12
15
 
13
16
  @customElement('pkt-textarea')
14
- export class PktTextarea extends PktInputElement {
17
+ export class PktTextarea extends PktInputElement<Props> {
15
18
  private inputRef: Ref<HTMLTextAreaElement> = createRef()
16
19
  private helptextSlot: Ref<HTMLElement> = createRef()
17
20