@oslokommune/punkt-elements 18.7.0 → 18.7.2

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 (187) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{accordion-NO9LYXaN.cjs → accordion-CsrLQXEA.cjs} +1 -1
  3. package/dist/{accordion-BI-Qiznz.js → accordion-DoxhiwK-.js} +5 -5
  4. package/dist/{alert-HFqn-cvb.cjs → alert-DH3VqNqh.cjs} +1 -1
  5. package/dist/{alert-CayKowTW.js → alert-DxAGxSX_.js} +6 -6
  6. package/dist/{async-directive-CAZoZMez.js → async-directive-BSwJ8-GL.js} +2 -2
  7. package/dist/{async-directive-DlB0Sf4j.cjs → async-directive-CfPXShXu.cjs} +1 -1
  8. package/dist/{backlink-DYO8qA9C.cjs → backlink-CDH824db.cjs} +1 -1
  9. package/dist/{backlink-BHtxOlTw.js → backlink-CGc7r9CS.js} +3 -3
  10. package/dist/{breadcrumbs-HG9wzGtr.cjs → breadcrumbs-B37tsIoh.cjs} +1 -1
  11. package/dist/{breadcrumbs-BlRDuZbZ.js → breadcrumbs-DRGhIXmN.js} +2 -2
  12. package/dist/{button-CIb0IxTg.cjs → button-Cx6Xdp6t.cjs} +1 -1
  13. package/dist/{button-DWMHtdWk.js → button-D6QhHPWH.js} +5 -5
  14. package/dist/{calendar-CBghTpkZ.js → calendar-42o_q-jI.js} +3 -3
  15. package/dist/{calendar-CA1mz04B.cjs → calendar-CAwIY7x_.cjs} +1 -1
  16. package/dist/{card-CsSNvjZV.cjs → card-CB72gwBC.cjs} +1 -1
  17. package/dist/{card-PXDm9JXv.js → card-cyZE94EO.js} +6 -6
  18. package/dist/{checkbox-DReZOEy2.js → checkbox-C6JdANel.js} +4 -4
  19. package/dist/{checkbox-CMu4pF8-.cjs → checkbox-CddODhna.cjs} +1 -1
  20. package/dist/{class-map-BL_YL_Ed.js → class-map-DSoj1J68.js} +1 -1
  21. package/dist/{class-map-BEib48YL.cjs → class-map-DupQbnhE.cjs} +1 -1
  22. package/dist/{combobox-DcBFiuwP.js → combobox-Cqqokx73.js} +172 -128
  23. package/dist/combobox-N3NP77le.cjs +137 -0
  24. package/dist/components/combobox/combobox-base.d.ts +14 -12
  25. package/dist/components/combobox/combobox-handlers.d.ts +1 -0
  26. package/dist/components/combobox/combobox-utils.d.ts +3 -1
  27. package/dist/components/combobox/combobox-value.d.ts +5 -0
  28. package/dist/components/combobox/combobox.attributes.test.d.ts +0 -0
  29. package/dist/components/combobox/combobox.d.ts +31 -2
  30. package/dist/components/combobox/combobox.preselection.test.d.ts +0 -0
  31. package/dist/components/combobox/index.d.ts +1 -1
  32. package/dist/components/index.d.ts +1 -1
  33. package/dist/components/listbox/listbox.d.ts +3 -1
  34. package/dist/{consent-Yt1MpkUy.js → consent-CsgKWDsR.js} +2 -2
  35. package/dist/{consent-D1SMUJLs.cjs → consent-DnE6IPEj.cjs} +1 -1
  36. package/dist/{datepicker-NSWMVoGi.cjs → datepicker-BzRhOIkG.cjs} +1 -1
  37. package/dist/{datepicker-DboWQcrh.js → datepicker-CtSYgxMs.js} +11 -11
  38. package/dist/{directive-helpers-Ddr8wUxL.cjs → directive-helpers-2mcpDDv5.cjs} +1 -1
  39. package/dist/{directive-helpers-CohhRNQz.js → directive-helpers-ChEduoup.js} +1 -1
  40. package/dist/{element-DRc10X2R.cjs → element-D_mCyFeX.cjs} +1 -1
  41. package/dist/{element-e2MAMNEP.js → element-J8nynuVk.js} +1 -0
  42. package/dist/{element-with-slot-GwTT3WRD.js → element-with-slot-BOuXlWSY.js} +2 -2
  43. package/dist/{element-with-slot-DgtT8lCH.cjs → element-with-slot-C66Jck5e.cjs} +1 -1
  44. package/dist/{fileupload-BYVtnSaI.cjs → fileupload-3OtW4Xlo.cjs} +1 -1
  45. package/dist/{fileupload-CqxWUH0y.js → fileupload-PJWEBD88.js} +8 -8
  46. package/dist/{footer-YrlXRq7e.js → footer-ByAxlj9_.js} +4 -4
  47. package/dist/{footer-CLSihvYS.cjs → footer-Ca3DM544.cjs} +1 -1
  48. package/dist/{header-DsaPCLjJ.js → header-CxfDtS3Y.js} +11 -11
  49. package/dist/{header-DzVHM7gZ.cjs → header-D2SD2JLt.cjs} +1 -1
  50. package/dist/{header-menu-wcnhBSzs.cjs → header-menu-B9ABa0WW.cjs} +1 -1
  51. package/dist/{header-menu-DKAcUFzh.js → header-menu-CIuu8_UC.js} +4 -4
  52. package/dist/{heading-OfJKjUi2.cjs → heading-4ZHACOeL.cjs} +1 -1
  53. package/dist/{heading-C23xQS4l.js → heading-BHWdVbvN.js} +2 -2
  54. package/dist/{helptext-DyHzv2ZG.cjs → helptext-6HsH-I6a.cjs} +1 -1
  55. package/dist/{helptext-DqdOdYRj.js → helptext-BESB9Vlq.js} +4 -4
  56. package/dist/{icon-DBFTFd-J.cjs → icon-BzxBkRyE.cjs} +1 -1
  57. package/dist/{icon-jEFbKTB_.js → icon-DkVqRWbC.js} +1 -1
  58. package/dist/{if-defined-r8tKY4tG.js → if-defined-BILLSM7i.js} +1 -1
  59. package/dist/if-defined-BbUZeoaQ.cjs +1 -0
  60. package/dist/{input-element-kvI_5er5.cjs → input-element-DhIEFeuP.cjs} +1 -1
  61. package/dist/{input-element-BiUfK_gU.js → input-element-Qb0w3ChW.js} +2 -2
  62. package/dist/{input-wrapper-Bwh720ke.js → input-wrapper-Bg06k-cj.js} +6 -6
  63. package/dist/{input-wrapper-wyvCnWPp.cjs → input-wrapper-D64Qn-QH.cjs} +1 -1
  64. package/dist/{link-BWAhHUku.cjs → link-Bk7aDZCC.cjs} +1 -1
  65. package/dist/{link-BvjWWWlc.js → link-C7fOEv66.js} +4 -4
  66. package/dist/{linkcard-6CbGf4I_.js → linkcard-Bguo9oCv.js} +4 -4
  67. package/dist/{linkcard-D6fRaZSc.cjs → linkcard-rZpQuO1Z.cjs} +1 -1
  68. package/dist/{listbox-CsP0qbU1.js → listbox-CrQboPG6.js} +80 -63
  69. package/dist/listbox-kV4OO1Qa.cjs +111 -0
  70. package/dist/{loader-B5t78Yk7.cjs → loader-BAn68gBb.cjs} +1 -1
  71. package/dist/{loader-BS7EJqxD.js → loader-C3gwO0i3.js} +4 -4
  72. package/dist/{messagebox-Bt0IvUlD.js → messagebox-DAE59dMX.js} +4 -4
  73. package/dist/{messagebox-DBVUz-DN.cjs → messagebox-WH2HjT5I.cjs} +1 -1
  74. package/dist/{modal-C1g2fiUq.cjs → modal-DZm_fn3K.cjs} +1 -1
  75. package/dist/{modal-z5RzZ-fr.js → modal-DdC1_qT3.js} +5 -5
  76. package/dist/pkt-accordion.cjs +1 -1
  77. package/dist/pkt-accordion.js +1 -1
  78. package/dist/pkt-alert.cjs +1 -1
  79. package/dist/pkt-alert.js +1 -1
  80. package/dist/pkt-backlink.cjs +1 -1
  81. package/dist/pkt-backlink.js +1 -1
  82. package/dist/pkt-breadcrumbs.cjs +1 -1
  83. package/dist/pkt-breadcrumbs.js +1 -1
  84. package/dist/pkt-button.cjs +1 -1
  85. package/dist/pkt-button.js +1 -1
  86. package/dist/pkt-calendar.cjs +1 -1
  87. package/dist/pkt-calendar.js +1 -1
  88. package/dist/pkt-card.cjs +1 -1
  89. package/dist/pkt-card.js +1 -1
  90. package/dist/pkt-checkbox.cjs +1 -1
  91. package/dist/pkt-checkbox.js +1 -1
  92. package/dist/pkt-combobox.cjs +1 -1
  93. package/dist/pkt-combobox.js +1 -1
  94. package/dist/pkt-consent.cjs +1 -1
  95. package/dist/pkt-consent.js +1 -1
  96. package/dist/pkt-datepicker.cjs +1 -1
  97. package/dist/pkt-datepicker.js +1 -1
  98. package/dist/pkt-fileupload.cjs +1 -1
  99. package/dist/pkt-fileupload.js +1 -1
  100. package/dist/pkt-footer.cjs +1 -1
  101. package/dist/pkt-footer.js +1 -1
  102. package/dist/pkt-header-menu.cjs +1 -1
  103. package/dist/pkt-header-menu.js +1 -1
  104. package/dist/pkt-header.cjs +1 -1
  105. package/dist/pkt-header.js +1 -1
  106. package/dist/pkt-heading.cjs +1 -1
  107. package/dist/pkt-heading.js +1 -1
  108. package/dist/pkt-helptext.cjs +1 -1
  109. package/dist/pkt-helptext.js +1 -1
  110. package/dist/pkt-icon.cjs +1 -1
  111. package/dist/pkt-icon.js +1 -1
  112. package/dist/pkt-index.cjs +1 -1
  113. package/dist/pkt-index.js +34 -34
  114. package/dist/pkt-input-wrapper.cjs +1 -1
  115. package/dist/pkt-input-wrapper.js +1 -1
  116. package/dist/pkt-link.cjs +1 -1
  117. package/dist/pkt-link.js +1 -1
  118. package/dist/pkt-linkcard.cjs +1 -1
  119. package/dist/pkt-linkcard.js +1 -1
  120. package/dist/pkt-listbox.cjs +1 -1
  121. package/dist/pkt-listbox.js +1 -1
  122. package/dist/pkt-loader.cjs +1 -1
  123. package/dist/pkt-loader.js +1 -1
  124. package/dist/pkt-messagebox.cjs +1 -1
  125. package/dist/pkt-messagebox.js +1 -1
  126. package/dist/pkt-modal.cjs +1 -1
  127. package/dist/pkt-modal.js +1 -1
  128. package/dist/{pkt-options-controller-AFYyv3-b.cjs → pkt-options-controller-DXAAVcBJ.cjs} +1 -1
  129. package/dist/{pkt-options-controller-DKkmDDzT.js → pkt-options-controller-XcIgx_v1.js} +1 -1
  130. package/dist/pkt-progressbar.cjs +1 -1
  131. package/dist/pkt-progressbar.js +1 -1
  132. package/dist/pkt-radiobutton.cjs +1 -1
  133. package/dist/pkt-radiobutton.js +1 -1
  134. package/dist/pkt-searchinput.cjs +1 -1
  135. package/dist/pkt-searchinput.js +1 -1
  136. package/dist/pkt-select.cjs +1 -1
  137. package/dist/pkt-select.js +1 -1
  138. package/dist/pkt-tabs.cjs +1 -1
  139. package/dist/pkt-tabs.js +1 -1
  140. package/dist/pkt-tag.cjs +1 -1
  141. package/dist/pkt-tag.js +1 -1
  142. package/dist/pkt-textarea.cjs +1 -1
  143. package/dist/pkt-textarea.js +1 -1
  144. package/dist/pkt-textinput.cjs +1 -1
  145. package/dist/pkt-textinput.js +1 -1
  146. package/dist/pkt-timepicker.cjs +1 -1
  147. package/dist/pkt-timepicker.js +1 -1
  148. package/dist/{progressbar-BPHLC7ce.js → progressbar-D1x6MwdY.js} +5 -5
  149. package/dist/{progressbar-BpcsDCnG.cjs → progressbar-DsE4-qaW.cjs} +1 -1
  150. package/dist/{radiobutton-bGdAEz26.js → radiobutton-B2P48MMf.js} +4 -4
  151. package/dist/{radiobutton-B6pzZQZV.cjs → radiobutton-CdwYbHnx.cjs} +1 -1
  152. package/dist/{ref-BkV4Cd8U.cjs → ref-AuNEkS5a.cjs} +1 -1
  153. package/dist/{ref-lE1mGUOL.js → ref-DNowxdYH.js} +2 -2
  154. package/dist/{repeat-uBgVG8Ch.cjs → repeat-B3ksuY2F.cjs} +1 -1
  155. package/dist/{repeat-CCnTfedv.js → repeat-CZ1oMXnW.js} +2 -2
  156. package/dist/{searchinput-DrUcPajl.js → searchinput-CmyWnNr6.js} +4 -4
  157. package/dist/{searchinput-CiPB8xvh.cjs → searchinput-vi7twt5b.cjs} +1 -1
  158. package/dist/{select-ay8WHI7p.cjs → select-TAwBNhhf.cjs} +1 -1
  159. package/dist/{select-D7r2s7Cw.js → select-c15YAQbr.js} +7 -7
  160. package/dist/shared-strings/nb.d.ts +1 -0
  161. package/dist/{tabs-DqzPOdi9.cjs → tabs-hiTrWsXq.cjs} +1 -1
  162. package/dist/{tabs-DHvCjUi4.js → tabs-kxsX1ZEf.js} +4 -4
  163. package/dist/{tag-Cojf4jgA.cjs → tag-CGlxtBOH.cjs} +1 -1
  164. package/dist/{tag-D2Xvsy1i.js → tag-bztxjcF0.js} +5 -5
  165. package/dist/{textarea-s4IDLsBi.cjs → textarea-BHGZhMN4.cjs} +1 -1
  166. package/dist/{textarea-CzAL8e6L.js → textarea-BPQVrOp9.js} +9 -9
  167. package/dist/{textinput-B9w4jhIP.cjs → textinput-Cv4tal5W.cjs} +1 -1
  168. package/dist/{textinput-DT3HR9eU.js → textinput-DHGUOsIA.js} +8 -8
  169. package/dist/{timepicker-C-Ip5Jgl.cjs → timepicker-BBUYnYzM.cjs} +1 -1
  170. package/dist/{timepicker-BE0UouVr.js → timepicker-C8DHbOpM.js} +8 -8
  171. package/package.json +3 -3
  172. package/src/components/combobox/combobox-base.ts +42 -28
  173. package/src/components/combobox/combobox-handlers.ts +11 -5
  174. package/src/components/combobox/combobox-utils.ts +9 -1
  175. package/src/components/combobox/combobox-value.ts +32 -16
  176. package/src/components/combobox/combobox.accessibility.test.ts +29 -3
  177. package/src/components/combobox/combobox.attributes.test.ts +82 -0
  178. package/src/components/combobox/combobox.core.test.ts +46 -1
  179. package/src/components/combobox/combobox.interaction.test.ts +167 -0
  180. package/src/components/combobox/combobox.preselection.test.ts +271 -0
  181. package/src/components/combobox/combobox.ts +143 -79
  182. package/src/components/combobox/index.ts +6 -1
  183. package/src/components/index.ts +1 -0
  184. package/src/components/listbox/listbox.ts +56 -29
  185. package/dist/combobox-BZm_12qR.cjs +0 -140
  186. package/dist/if-defined-BeVQnl3C.cjs +0 -1
  187. package/dist/listbox-D95b5zAd.cjs +0 -106
@@ -27,14 +27,15 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
27
27
  @property({ type: Boolean, attribute: 'allow-user-input' }) allowUserInput: boolean = false
28
28
  @property({ type: Boolean }) typeahead: boolean = false
29
29
  @property({ type: Boolean, attribute: 'include-search' }) includeSearch: boolean = false
30
- @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder: string = ''
30
+ @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder: string | null =
31
+ null
31
32
  @property({ type: Boolean }) multiple: boolean = false
32
33
  @property({ type: Number }) maxlength: number | null = null
33
34
  @property({ type: String, attribute: 'display-value-as' }) displayValueAs: string =
34
35
  specs.props.displayValueAs.default
35
36
  @property({ type: String, attribute: 'tag-placement' })
36
37
  tagPlacement: TPktComboboxTagPlacement | null = null
37
- // Internal use only — syncs to _isOptionsOpen in updated(), but does not
38
+ // Internal use only — syncs to _isOptionsOpen in willUpdate(), but does not
38
39
  // reliably open the listbox as a declarative attribute (requires focus state
39
40
  // and populated _options). Not part of the public API / component spec.
40
41
  // Used in tests to set initial open state without relying on focus or options population.
@@ -45,14 +46,15 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
45
46
  @state() protected _value: string[] = []
46
47
  @state() protected _isOptionsOpen = false
47
48
  @state() protected _userInfoMessage: TComboboxMessageKey = ''
49
+ @state() protected _messageValue: string | null = null
48
50
  @state() protected _addValueText: string | null = null
49
51
  @state() protected _maxIsReached: boolean = false
50
52
  @state() protected _search: string = ''
51
53
  @state() protected _inputFocus: boolean = false
52
- protected _internalValueSync = false
54
+ @state() protected _skipInputTab: boolean = false
53
55
  protected _optionsFromSlot = false
54
56
  protected _lastSlotGeneration = 0
55
- /** When true, the next handleFocus call will not reopen the dropdown. */
57
+ /** Set when focus returns to the input on close, so handleFocus does not reopen the list. */
56
58
  protected _suppressNextOpen = false
57
59
 
58
60
  // Refs
@@ -69,10 +71,7 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
69
71
  return `${this._value.length} valgt`
70
72
  }
71
73
 
72
- /**
73
- * Focuses the appropriate trigger element after closing the listbox.
74
- * Select-only: the combobox input div. Editable: the text input.
75
- */
74
+ /** Returns focus to the combobox after the listbox closes without a selection. */
76
75
  protected focusTrigger(): void {
77
76
  if (this._hasTextInput) {
78
77
  this.inputRef.value?.focus()
@@ -81,18 +80,35 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
81
80
  }
82
81
  }
83
82
 
83
+ protected focusAfterCommit(): void {
84
+ // The input sits inside the wrapper, so Tab would step back into it.
85
+ // Written to the DOM directly so it lands before focus moves.
86
+ this._skipInputTab = true
87
+ const input = this.inputRef.value
88
+ if (input && input.type !== 'hidden') input.setAttribute('tabindex', '-1')
89
+ this.triggerRef.value?.focus()
90
+ }
91
+
92
+ protected restoreInputTab(): void {
93
+ this._skipInputTab = false
94
+ }
95
+
84
96
  /**
85
97
  * Parses the value prop into an internal string array.
86
98
  */
87
99
  protected parseValue(): string[] {
88
- if (Array.isArray(this.value)) {
89
- return this.multiple ? this.value : this.value.length > 0 ? [this.value[0]] : []
100
+ return this.parseToValues(this.value)
101
+ }
102
+
103
+ protected parseToValues(raw: string | string[] | null | undefined): string[] {
104
+ if (Array.isArray(raw)) {
105
+ return this.multiple ? raw : raw.length > 0 ? [raw[0]] : []
90
106
  }
91
- if (this.value && this.multiple) {
92
- return this.value.split(',')
107
+ if (raw && this.multiple) {
108
+ return raw.split(',')
93
109
  }
94
- if (this.value) {
95
- return [this.value]
110
+ if (raw) {
111
+ return [raw]
96
112
  }
97
113
  return []
98
114
  }
@@ -104,26 +120,26 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
104
120
  this._maxIsReached = isMaxSelectionReached(this._value.length, this.maxlength)
105
121
  }
106
122
 
123
+ protected _pendingDispatchFrom: string[] | null = null
124
+
107
125
  /**
108
- * Syncs the public value property from internal _value state and dispatches
109
- * events if the value content changed. Always sets this.value as a string
110
- * to prevent array→string reflect cascades.
126
+ * Syncs the public value property from internal _value state. Always assigns
127
+ * a string to prevent array→string reflect cascades.
111
128
  */
112
- protected syncValueAndDispatch(oldInternal: string[]): void {
113
- const newInternal = this._value
114
-
115
- // Sync public value as a string (avoids array→string attribute reflect cascade)
116
- const newPublicStr = this.multiple ? newInternal.join(',') : newInternal[0] || ''
129
+ protected syncPublicValue(): void {
130
+ const newPublicStr = this.multiple ? this._value.join(',') : this._value[0] || ''
117
131
  const currentPublicStr = Array.isArray(this.value)
118
132
  ? this.value.join(',')
119
133
  : String(this.value || '')
120
134
 
121
135
  if (newPublicStr !== currentPublicStr) {
122
- this._internalValueSync = true
123
136
  this.value = newPublicStr
124
137
  }
138
+ }
139
+
140
+ protected dispatchValueChange(oldInternal: string[]): void {
141
+ const newInternal = this._value
125
142
 
126
- // Dispatch events if value content changed
127
143
  if (oldInternal?.join(',') !== newInternal.join(',')) {
128
144
  const eventValue = this.multiple ? [...newInternal] : newInternal[0] || ''
129
145
  this.onChange(eventValue)
@@ -147,9 +163,7 @@ export class ComboboxBase extends PktInputElement<IPktStringsProps<'combobox' |
147
163
  * No-op override of the base class valueChanged.
148
164
  * The base class version sets both this.value AND this._value, which creates
149
165
  * an infinite _value → valueChanged → value → parseValue → _value loop.
150
- * Combobox handles value sync and event dispatch in syncValueAndDispatch() instead.
166
+ * Combobox derives value in willUpdate() and dispatches in updated() instead.
151
167
  */
152
- protected override valueChanged(): void {
153
- // Intentionally empty — combobox manages value sync in updated()
154
- }
168
+ protected override valueChanged(): void {}
155
169
  }
@@ -43,7 +43,7 @@ export class ComboboxHandlers extends ComboboxValue {
43
43
  input.selectionDirection = 'backward'
44
44
  }
45
45
  } else {
46
- this._options = [...this.options]
46
+ this.refreshOptionsFromValue()
47
47
  }
48
48
  }
49
49
  }
@@ -51,8 +51,8 @@ export class ComboboxHandlers extends ComboboxValue {
51
51
  protected handleFocus(): void {
52
52
  if (this.disabled) return
53
53
 
54
- // After selecting a value in single+typeahead, focus returns to the input.
55
- // Skip reopening the dropdown so screen readers announce the selected value.
54
+ this._skipInputTab = false
55
+
56
56
  if (this._suppressNextOpen) {
57
57
  this._suppressNextOpen = false
58
58
  this._inputFocus = true
@@ -74,20 +74,26 @@ export class ComboboxHandlers extends ComboboxValue {
74
74
  }
75
75
  this._inputFocus = true
76
76
  this._search = ''
77
- this._options = [...this.options]
77
+ this.refreshOptionsFromValue()
78
78
  this._isOptionsOpen = true
79
79
  this.onFocus()
80
80
 
81
81
  this.requestUpdate()
82
82
  }
83
83
 
84
+ protected handleCloseOptions(): void {
85
+ this._isOptionsOpen = false
86
+ if (this._hasTextInput) this._suppressNextOpen = true
87
+ this.focusTrigger()
88
+ }
89
+
84
90
  protected handleFocusOut(e: FocusEvent): void {
85
91
  if (this.disabled || !this._isOptionsOpen) return
86
92
 
87
93
  const related = e.relatedTarget as Element | null
88
94
  const isFocusInsideCombobox =
89
95
  related?.closest('pkt-combobox')?.id === this.id ||
90
- (e.target as Element)?.getAttribute('data-focusfix') === this.id ||
96
+ (e.target as Element)?.classList?.contains('pkt-combobox__value') ||
91
97
  related === this.inputRef.value ||
92
98
  related === this.triggerRef.value
93
99
 
@@ -91,6 +91,11 @@ export const slotUtils = {
91
91
  // Options state synchronization (Lit-specific, in-place mutation)
92
92
 
93
93
  export const optionStateUtils = {
94
+ /** Copies options before the component writes to them, so selection state does not leak into the consumer's array. */
95
+ cloneOptions(options: IPktComboboxOption[]): IPktComboboxOption[] {
96
+ return Array.isArray(options) ? options.map((option) => ({ ...option })) : []
97
+ },
98
+
94
99
  /**
95
100
  * Ensures options have labels and fulltext set.
96
101
  * Also syncs selected state with current values.
@@ -124,8 +129,11 @@ export const optionStateUtils = {
124
129
  mergeWithUserAdded(
125
130
  newOptions: IPktComboboxOption[],
126
131
  previousOptions: IPktComboboxOption[],
132
+ values: string[],
127
133
  ): IPktComboboxOption[] {
128
- const userAddedValues = previousOptions.filter((option) => option?.userAdded && option.selected)
134
+ const userAddedValues = previousOptions.filter(
135
+ (option) => option?.userAdded && values.includes(option.value),
136
+ )
129
137
  const filteredUserAdded = userAddedValues.filter(
130
138
  (userOpt) =>
131
139
  !(Array.isArray(newOptions) ? newOptions : []).some((opt) => opt.value === userOpt.value),
@@ -1,7 +1,11 @@
1
- import type { IPktComboboxOption } from './combobox-types'
2
- import { findOptionByValue, isMaxSelectionReached } from 'shared-utils/combobox/option-utils'
1
+ import type { IPktComboboxOption, TPktComboboxDisplayValue } from './combobox-types'
2
+ import {
3
+ findOptionByValue,
4
+ getOptionDisplayText,
5
+ isMaxSelectionReached,
6
+ } from 'shared-utils/combobox/option-utils'
3
7
  import { getSingleValueForInput } from 'shared-utils/combobox/input-utils'
4
- import { selectionMutators } from './combobox-utils'
8
+ import { optionStateUtils, selectionMutators } from './combobox-utils'
5
9
  import { ComboboxBase } from './combobox-base'
6
10
  import type { TComboboxMessageKey } from 'shared-utils/combobox'
7
11
 
@@ -29,6 +33,7 @@ export class ComboboxValue extends ComboboxBase {
29
33
  let shouldOptionsBeOpen: boolean = false
30
34
  let shouldResetInput: boolean = true
31
35
  let userInfoMessage: TComboboxMessageKey = ''
36
+ let messageValue: string | null = null
32
37
  let searchValue: string | null = ''
33
38
 
34
39
  if (isDisabled) return
@@ -76,6 +81,14 @@ export class ComboboxValue extends ComboboxBase {
76
81
  this._value[0] && this.removeSelected(this._value[0])
77
82
  this.setSelected(valueFromOptions)
78
83
  shouldOptionsBeOpen = false
84
+ userInfoMessage = 'valueSelected'
85
+ // The two modes resolve displayValueAs through different helpers.
86
+ messageValue = this._hasTextInput
87
+ ? getSingleValueForInput(valueFromOptions || '', this.options, this.displayValueAs)
88
+ : getOptionDisplayText(
89
+ findOptionByValue(this.options, valueFromOptions),
90
+ this.displayValueAs as TPktComboboxDisplayValue,
91
+ )
79
92
  }
80
93
 
81
94
  // Multi-select with room for more selections
@@ -102,19 +115,15 @@ export class ComboboxValue extends ComboboxBase {
102
115
 
103
116
  this._isOptionsOpen = shouldOptionsBeOpen
104
117
  if (!shouldOptionsBeOpen) {
105
- if (isSingle && this._hasTextInput) {
106
- // Suppress the next handleFocus from reopening the dropdown,
107
- // then move focus back to the text input so screen readers
108
- // announce the selected value instead of the browser window.
109
- this._suppressNextOpen = true
110
- }
111
118
  window.setTimeout(() => {
112
- this.focusTrigger()
119
+ this.focusAfterCommit()
113
120
  }, 0)
114
121
  }
115
- this._userInfoMessage = userInfoMessage
116
122
  this._search = searchValue || ''
117
123
  this.resetComboboxInput(shouldResetInput)
124
+ // After resetComboboxInput, which clears any stale message in single mode.
125
+ this._userInfoMessage = userInfoMessage
126
+ this._messageValue = messageValue
118
127
  isMultiple && this.updateMaxReached()
119
128
  }
120
129
 
@@ -208,17 +217,24 @@ export class ComboboxValue extends ComboboxBase {
208
217
 
209
218
  if (!this.multiple) {
210
219
  this._isOptionsOpen = false
211
- if (this._hasTextInput) {
212
- this._suppressNextOpen = true
213
- }
214
220
  window.setTimeout(() => {
215
- this.focusTrigger()
221
+ this.focusAfterCommit()
216
222
  }, 0)
217
223
  }
218
224
 
219
225
  this.requestUpdate()
220
226
  }
221
227
 
228
+ /**
229
+ * Rebuilds _options from the consumer's options array. Selection lives in
230
+ * _value, not in this.options, so it has to be reapplied here.
231
+ */
232
+ protected refreshOptionsFromValue(): void {
233
+ const cloned = optionStateUtils.cloneOptions(this.options)
234
+ cloned.forEach((option) => (option.selected = this._value.includes(option.value)))
235
+ this._options = optionStateUtils.syncOptionsWithValues(cloned, this._value).options
236
+ }
237
+
222
238
  protected resetComboboxInput(shouldResetInput: boolean = true): void {
223
239
  this._addValueText = null
224
240
  if (this.inputRef.value && this.inputRef.value.type !== 'hidden' && shouldResetInput) {
@@ -233,7 +249,7 @@ export class ComboboxValue extends ComboboxBase {
233
249
  this.inputRef.value.value = ''
234
250
  }
235
251
  }
236
- this._options = [...this.options]
252
+ this.refreshOptionsFromValue()
237
253
  }
238
254
 
239
255
  protected removeLastValue(e: Event): void {
@@ -6,6 +6,7 @@ expect.extend(toHaveNoViolations)
6
6
 
7
7
  import './combobox'
8
8
  import { PktCombobox } from './combobox'
9
+ import type { PktListbox } from '../listbox/listbox'
9
10
  import type { IPktComboboxOption } from './combobox'
10
11
 
11
12
  const waitForCustomElements = async () => {
@@ -175,7 +176,14 @@ describe('PktCombobox', () => {
175
176
  await combobox.updateComplete
176
177
 
177
178
  const textInput = combobox.querySelector('input[type="text"]')
178
- expect(textInput?.getAttribute('aria-activedescendant')).toBeTruthy()
179
+ expect(textInput).not.toHaveAttribute('aria-activedescendant')
180
+
181
+ combobox.isOpen = true
182
+ await combobox.updateComplete
183
+
184
+ const active = textInput?.getAttribute('aria-activedescendant')
185
+ expect(active).toBeTruthy()
186
+ expect(combobox.querySelector(`[id="${active}"]`)).not.toBeNull()
179
187
  })
180
188
 
181
189
  test('listbox has correct id for aria-controls reference', async () => {
@@ -183,8 +191,26 @@ describe('PktCombobox', () => {
183
191
  const combobox = container.querySelector('pkt-combobox') as PktCombobox
184
192
  await combobox.updateComplete
185
193
 
186
- const listbox = combobox.querySelector('pkt-listbox')
187
- expect(listbox?.getAttribute('id')).toBe('test-combo-listbox')
194
+ const controller = combobox.querySelector('[aria-controls]')
195
+ const target = controller?.getAttribute('aria-controls')
196
+ expect(target).toBe('test-combo-listbox')
197
+ expect(combobox.querySelectorAll(`[id="${target}"]`).length).toBe(1)
198
+ expect(combobox.querySelector('pkt-listbox')?.hasAttribute('id')).toBe(false)
199
+ })
200
+
201
+ test('aria-controls still resolves after the combobox id changes', async () => {
202
+ const container = await createCombobox('id="test-combo" name="test" label="Test"')
203
+ const combobox = container.querySelector('pkt-combobox') as PktCombobox
204
+ await combobox.updateComplete
205
+
206
+ combobox.id = 'renamed-combo'
207
+ await combobox.updateComplete
208
+ await (combobox.querySelector('pkt-listbox') as PktListbox).updateComplete
209
+
210
+ const controller = combobox.querySelector('[aria-controls]')
211
+ const target = controller?.getAttribute('aria-controls')
212
+ expect(target).toBe('renamed-combo-listbox')
213
+ expect(combobox.querySelectorAll(`[id="${target}"]`).length).toBe(1)
188
214
  })
189
215
  })
190
216
 
@@ -0,0 +1,82 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import spec from 'componentSpecs/combobox.json'
4
+ import './combobox'
5
+ import { PktCombobox } from './combobox'
6
+
7
+ /**
8
+ * Attribute spelling -> the property it drives, with a value to set and the
9
+ * value the property should end up with. Mirrors IPktComboboxAttributes, and is
10
+ * checked against a live element so the spellings cannot drift.
11
+ */
12
+ const cases: { attribute: string; property: keyof PktCombobox; set: string; expect: unknown }[] = [
13
+ { attribute: 'allow-user-input', property: 'allowUserInput', set: '', expect: true },
14
+ { attribute: 'include-search', property: 'includeSearch', set: '', expect: true },
15
+ { attribute: 'haserror', property: 'hasError', set: '', expect: true },
16
+ { attribute: 'optionaltag', property: 'optionalTag', set: '', expect: true },
17
+ { attribute: 'requiredtag', property: 'requiredTag', set: '', expect: true },
18
+ { attribute: 'display-value-as', property: 'displayValueAs', set: 'value', expect: 'value' },
19
+ { attribute: 'search-placeholder', property: 'searchPlaceholder', set: 'x', expect: 'x' },
20
+ { attribute: 'tag-placement', property: 'tagPlacement', set: 'outside', expect: 'outside' },
21
+ { attribute: 'defaultvalue', property: 'defaultValue', set: 'x', expect: 'x' },
22
+ { attribute: 'errormessage', property: 'errorMessage', set: 'x', expect: 'x' },
23
+ { attribute: 'helptextdropdown', property: 'helptextDropdown', set: 'x', expect: 'x' },
24
+ {
25
+ attribute: 'helptextdropdownbutton',
26
+ property: 'helptextDropdownButton',
27
+ set: 'x',
28
+ expect: 'x',
29
+ },
30
+ { attribute: 'optionaltext', property: 'optionalText', set: 'x', expect: 'x' },
31
+ { attribute: 'requiredtext', property: 'requiredText', set: 'x', expect: 'x' },
32
+ { attribute: 'tagtext', property: 'tagText', set: 'x', expect: 'x' },
33
+ { attribute: 'hasfieldset', property: 'hasFieldset', set: 'true', expect: true },
34
+ { attribute: 'usewrapper', property: 'useWrapper', set: 'false', expect: false },
35
+ {
36
+ attribute: 'default-options',
37
+ property: 'defaultOptions',
38
+ set: '[{&quot;value&quot;:&quot;a&quot;}]',
39
+ expect: [{ value: 'a' }],
40
+ },
41
+ ]
42
+
43
+ const mount = async (attribute: string, value: string) => {
44
+ const container = document.createElement('div')
45
+ container.innerHTML = `<pkt-combobox id="a" name="a" label="L" ${attribute}="${value}"></pkt-combobox>`
46
+ document.body.appendChild(container)
47
+ await customElements.whenDefined('pkt-combobox')
48
+ const combobox = container.querySelector('pkt-combobox') as PktCombobox
49
+ await combobox.updateComplete
50
+ return combobox
51
+ }
52
+
53
+ afterEach(() => {
54
+ document.body.innerHTML = ''
55
+ })
56
+
57
+ describe('PktCombobox attributes', () => {
58
+ test.each(cases)(
59
+ '$attribute sets the $property property',
60
+ async ({ attribute, property, set, expect: expected }) => {
61
+ const combobox = await mount(attribute, set)
62
+
63
+ expect(combobox[property]).toEqual(expected)
64
+ },
65
+ )
66
+
67
+ test('kebab-case spellings of lowercased attributes do not work', async () => {
68
+ const combobox = await mount('has-error', '')
69
+
70
+ expect(combobox.hasError).toBe(false)
71
+ })
72
+
73
+ test('every renamed attribute in the component spec is declared', () => {
74
+ const renamed = Object.entries(spec.props)
75
+ .filter(([name, prop]) => 'attribute' in prop && prop.attribute !== name)
76
+ .map(([, prop]) => (prop as { attribute: string }).attribute)
77
+ // input-size and data-testid come from the shared input wrapper, not combobox
78
+ .filter((attribute) => !['input-size', 'data-testid'].includes(attribute))
79
+
80
+ expect(cases.map((c) => c.attribute).sort()).toEqual(renamed.sort())
81
+ })
82
+ })
@@ -730,7 +730,14 @@ describe('PktCombobox', () => {
730
730
  await combobox.updateComplete
731
731
 
732
732
  const textInput = combobox.querySelector('input[type="text"]')
733
- expect(textInput?.getAttribute('aria-activedescendant')).toBe('test-listbox--1')
733
+ expect(textInput).not.toHaveAttribute('aria-activedescendant')
734
+
735
+ combobox.isOpen = true
736
+ await combobox.updateComplete
737
+
738
+ const active = textInput?.getAttribute('aria-activedescendant')
739
+ expect(active).toBe('test-listbox-0')
740
+ expect(combobox.querySelector(`[id="${active}"]`)).not.toBeNull()
734
741
  })
735
742
  })
736
743
 
@@ -825,4 +832,42 @@ describe('PktCombobox', () => {
825
832
  expect(combobox['_options'].some((o: IPktComboboxOption) => o.value === 'c')).toBe(false)
826
833
  })
827
834
  })
835
+
836
+ describe('Consumer-owned options array', () => {
837
+ const mount = async (options: unknown[], value?: string) => {
838
+ const el = document.createElement('pkt-combobox') as PktCombobox
839
+ el.label = 'Velg'
840
+ ;(el as unknown as { options: unknown[] }).options = options
841
+ if (value) el.value = value
842
+ document.body.appendChild(el)
843
+ await waitForCustomElements()
844
+ await el.updateComplete
845
+ return el
846
+ }
847
+
848
+ test('does not mutate the array passed in', async () => {
849
+ const shared = [
850
+ { value: 'a', text: 'Alfa' },
851
+ { value: 'b', text: 'Bravo' },
852
+ ]
853
+ const before = JSON.stringify(shared)
854
+
855
+ await mount(shared, 'b')
856
+
857
+ expect(JSON.stringify(shared)).toBe(before)
858
+ })
859
+
860
+ test('two comboboxes do not share selection via the same options array', async () => {
861
+ const shared = [
862
+ { value: 'a', text: 'Alfa' },
863
+ { value: 'b', text: 'Bravo' },
864
+ ]
865
+
866
+ await mount(shared, 'b')
867
+ const second = await mount(shared)
868
+
869
+ expect(second.value).toBe('')
870
+ })
871
+ })
872
+
828
873
  })