wave-ui 3.28.0 → 4.0.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.
Files changed (93) hide show
  1. package/dist/.nojekyll +0 -0
  2. package/dist/types/types/$waveui.d.ts +15 -1
  3. package/dist/types/types/colors.d.ts +2 -0
  4. package/dist/types/types/components/WAccordion.d.ts +92 -6
  5. package/dist/types/types/components/WAutocomplete.d.ts +437 -0
  6. package/dist/types/types/components/WCheckbox.d.ts +34 -0
  7. package/dist/types/types/components/WCheckboxes.d.ts +30 -0
  8. package/dist/types/types/components/WInput.d.ts +34 -0
  9. package/dist/types/types/components/WMenu.d.ts +14 -7
  10. package/dist/types/types/components/WRadio.d.ts +34 -0
  11. package/dist/types/types/components/WRadios.d.ts +30 -0
  12. package/dist/types/types/components/WSelect.d.ts +34 -0
  13. package/dist/types/types/components/WSwitch.d.ts +34 -0
  14. package/dist/types/types/components/WTable.d.ts +7 -0
  15. package/dist/types/types/components/WTooltip.d.ts +22 -8
  16. package/dist/types/types/components/WTransitions.d.ts +104 -0
  17. package/dist/types/types/components/WTransitions.js +2 -0
  18. package/dist/types/types/components/WTree.d.ts +7 -0
  19. package/dist/types/types/components/index.d.ts +2 -1
  20. package/dist/types/types/index.d.ts +1 -0
  21. package/dist/types/types/mixins/detachable.d.ts +7 -0
  22. package/dist/types/types/mixins/detachable.js +2 -0
  23. package/dist/wave-ui.cjs.js +3 -3
  24. package/dist/wave-ui.css +1 -1
  25. package/dist/wave-ui.esm.js +1743 -1365
  26. package/dist/wave-ui.umd.js +3 -3
  27. package/package.json +1 -1
  28. package/src/wave-ui/components/index.js +0 -1
  29. package/src/wave-ui/components/transitions/w-transition-bounce.vue +2 -1
  30. package/src/wave-ui/components/transitions/w-transition-expand.vue +3 -2
  31. package/src/wave-ui/components/transitions/w-transition-fade.vue +2 -1
  32. package/src/wave-ui/components/transitions/w-transition-scale-fade.vue +2 -1
  33. package/src/wave-ui/components/transitions/w-transition-scale.vue +2 -1
  34. package/src/wave-ui/components/transitions/w-transition-slide-fade.vue +2 -1
  35. package/src/wave-ui/components/transitions/w-transition-slide.vue +2 -1
  36. package/src/wave-ui/components/transitions/w-transition-twist.vue +2 -1
  37. package/src/wave-ui/components/w-accordion/index.vue +10 -5
  38. package/src/wave-ui/components/w-accordion/item.vue +29 -14
  39. package/src/wave-ui/components/w-alert.vue +27 -29
  40. package/src/wave-ui/components/w-autocomplete.vue +626 -192
  41. package/src/wave-ui/components/w-badge.vue +54 -53
  42. package/src/wave-ui/components/w-breadcrumbs.vue +7 -9
  43. package/src/wave-ui/components/w-button/button.vue +21 -23
  44. package/src/wave-ui/components/w-button/index.vue +4 -4
  45. package/src/wave-ui/components/w-card.vue +8 -7
  46. package/src/wave-ui/components/w-checkbox.vue +31 -11
  47. package/src/wave-ui/components/w-checkboxes.vue +21 -3
  48. package/src/wave-ui/components/w-confirm.vue +22 -22
  49. package/src/wave-ui/components/w-dialog.vue +1 -1
  50. package/src/wave-ui/components/w-divider.vue +5 -5
  51. package/src/wave-ui/components/w-drawer.vue +3 -3
  52. package/src/wave-ui/components/w-form-element.vue +2 -2
  53. package/src/wave-ui/components/w-icon.vue +12 -14
  54. package/src/wave-ui/components/w-image.vue +1 -1
  55. package/src/wave-ui/components/w-input.vue +43 -25
  56. package/src/wave-ui/components/w-list.vue +11 -12
  57. package/src/wave-ui/components/w-menu.vue +57 -55
  58. package/src/wave-ui/components/w-notification.vue +4 -4
  59. package/src/wave-ui/components/w-progress.vue +6 -7
  60. package/src/wave-ui/components/w-radio.vue +32 -7
  61. package/src/wave-ui/components/w-radios.vue +28 -3
  62. package/src/wave-ui/components/w-rating.vue +7 -9
  63. package/src/wave-ui/components/w-scrollable.vue +4 -4
  64. package/src/wave-ui/components/w-select.vue +119 -101
  65. package/src/wave-ui/components/w-slider.vue +26 -26
  66. package/src/wave-ui/components/w-spinner.vue +5 -7
  67. package/src/wave-ui/components/w-switch.vue +71 -47
  68. package/src/wave-ui/components/w-table.vue +69 -36
  69. package/src/wave-ui/components/w-tabs/index.vue +21 -24
  70. package/src/wave-ui/components/w-tag.vue +35 -38
  71. package/src/wave-ui/components/w-textarea.vue +22 -22
  72. package/src/wave-ui/components/w-timeline.vue +6 -6
  73. package/src/wave-ui/components/w-toolbar.vue +8 -8
  74. package/src/wave-ui/components/w-tooltip.vue +30 -25
  75. package/src/wave-ui/components/w-tree.vue +35 -16
  76. package/src/wave-ui/core.js +9 -1
  77. package/src/wave-ui/mixins/detachable.js +118 -55
  78. package/src/wave-ui/mixins/ripple.js +2 -2
  79. package/src/wave-ui/scss/_base.scss +82 -17
  80. package/src/wave-ui/scss/_colors.scss +6 -75
  81. package/src/wave-ui/scss/_layout.scss +39 -47
  82. package/src/wave-ui/scss/_ripple.scss +2 -2
  83. package/src/wave-ui/scss/_transitions.scss +19 -19
  84. package/src/wave-ui/scss/_typography.scss +8 -9
  85. package/src/wave-ui/scss/variables/_mixins.scss +24 -25
  86. package/src/wave-ui/scss/variables/_variables.scss +4 -149
  87. package/src/wave-ui/utils/colors.js +7 -4
  88. package/src/wave-ui/utils/config.js +3 -4
  89. package/src/wave-ui/utils/dynamic-css.js +42 -20
  90. package/src/wave-ui/utils/ripple.js +3 -2
  91. package/dist/types/types/components/WApp.d.ts +0 -83
  92. package/src/wave-ui/components/w-app.vue +0 -24
  93. /package/dist/types/types/components/{WApp.js → WAutocomplete.js} +0 -0
@@ -1,100 +1,196 @@
1
1
  <template lang="pug">
2
- .w-autocomplete(:class="classes" @click="onClick" :style="$attrs.style")
3
- template(v-if="selection.length")
4
- .w-autocomplete__selection(v-for="(item, i) in selection")
5
- slot(name="selection" :item="item" :unselect="i => unselectItem(i)")
6
- span(v-html="item[itemLabelKey]")
7
- w-button(@click.stop="unselectItem(i)" icon="wi-cross" xs text color="currentColor")
8
- .w-autocomplete__placeholder(
9
- v-if="!selection.length && !keywords && placeholder"
10
- v-html="placeholder")
11
- input.w-autocomplete__input(
12
- ref="input"
13
- :value="keywords"
14
- v-on="inputEventListeners"
15
- v-bind="inputAttrs")
16
- w-transition-slide-fade
17
- ul.w-autocomplete__menu(
18
- v-if="menuOpen"
19
- ref="menu"
20
- @mousedown="menuIsBeingClicked = true"
21
- @mouseup="setEndOfMenuClick"
22
- @touchstart="menuIsBeingClicked = true"
23
- @touchend="setEndOfMenuClick")
24
- li(
25
- v-for="(item, i) in filteredItems"
26
- :key="i"
27
- @click.stop="selectItem(item), $emit('item-click', item)"
28
- :class="{ highlighted: highlightedItem === item.uid }")
29
- slot(name="item" :item="item" :highlighted="highlightedItem === item.uid")
30
- span(v-html="item[itemLabelKey]")
31
- li.w-autocomplete__no-match(
32
- v-if="!filteredItems.length"
33
- :class="{ 'w-autocomplete__no-match--default': !$slots.noMatch }")
34
- slot(name="no-match")
35
- .caption(v-html="noMatch ?? 'No match.'")
36
- li.w-autocomplete__extra-item(
37
- v-if="$slots['extra-item']"
38
- @click="selectExtraItem"
39
- :class="{ highlighted: highlightedItem === 'extra-item' }")
40
- slot(name="extra-item")
2
+ component(
3
+ ref="formEl"
4
+ :is="formRegister ? 'w-form-element' : 'div'"
5
+ v-bind="formRegister && { validators, inputValue: selectionValue, disabled: isDisabled, readonly: isReadonly, isFocused, noBlurValidation }"
6
+ v-model:valid="valid"
7
+ @reset="onReset"
8
+ :wrap="hasLabel && labelPosition !== 'inside'"
9
+ :class="classes"
10
+ :style="$attrs.style")
11
+ template(v-if="labelPosition === 'left'")
12
+ label.w-autocomplete__label.w-autocomplete__label--left.w-form-el-shakable(
13
+ v-if="$slots.default || label"
14
+ :for="inputId"
15
+ :class="labelClasses")
16
+ slot {{ label }}
17
+
18
+ w-menu(
19
+ ref="menu"
20
+ v-model="menuOpen"
21
+ @close="onMenuClose"
22
+ :menu-class="`w-autocomplete__menu${menuClass ? ' ' + menuClass : ''}`"
23
+ transition="slide-fade-down"
24
+ :append-to="menuPropsComputed.appendTo"
25
+ align-left
26
+ custom
27
+ min-width="activator"
28
+ v-bind="menuPropsComputed")
29
+ .w-autocomplete__input-wrap(
30
+ @click="!isDisabled && !isReadonly && onWrapClick()"
31
+ :class="inputWrapClasses")
32
+ slot(name="icon-left")
33
+ w-icon.w-autocomplete__icon.w-autocomplete__icon--inner-left(
34
+ v-if="innerIconLeft"
35
+ tag="label"
36
+ :for="inputId"
37
+ @click.stop="$emit('click:inner-icon-left', $event)") {{ innerIconLeft }}
38
+
39
+ template(v-if="selection.length")
40
+ .w-autocomplete__selection(v-for="(item, i) in selection" :key="item.uid")
41
+ slot(name="selection" :item="item" :unselect="() => unselectItem(i)")
42
+ span(v-html="item[itemLabelKey]")
43
+ w-button(@click.stop="unselectItem(i)" icon="wi-cross" xs text color="currentColor")
44
+
45
+ .w-autocomplete__placeholder(
46
+ v-if="!selection.length && !keywords && placeholder && !showFloatingLabel"
47
+ v-html="placeholder")
48
+
49
+ input.w-autocomplete__input(
50
+ ref="input"
51
+ :id="inputId"
52
+ :value="keywords"
53
+ :name="inputName"
54
+ :disabled="isDisabled || null"
55
+ :readonly="isReadonly || null"
56
+ :tabindex="tabindex || null"
57
+ v-on="inputEventListeners"
58
+ v-bind="inputAttrs")
59
+
60
+ template(v-if="labelPosition === 'inside' && showLabelInside")
61
+ label.w-autocomplete__label.w-autocomplete__label--inside.w-form-el-shakable(
62
+ v-if="$slots.default || label"
63
+ :for="inputId"
64
+ :class="labelClasses")
65
+ slot {{ label }}
66
+
67
+ slot(name="icon-right")
68
+ w-icon.w-autocomplete__icon.w-autocomplete__icon--inner-right(
69
+ v-if="innerIconRight"
70
+ tag="label"
71
+ :for="inputId"
72
+ @click.stop="$emit('click:inner-icon-right', $event)") {{ innerIconRight }}
73
+
74
+ template(#content)
75
+ .w-autocomplete__list-wrap(
76
+ ref="listWrap"
77
+ @mousedown.capture="menuIsBeingClicked = true"
78
+ @mouseup.capture="setEndOfMenuClick"
79
+ @touchstart.capture="menuIsBeingClicked = true"
80
+ @touchend.capture="setEndOfMenuClick")
81
+ w-list.w-autocomplete__list(
82
+ v-if="filteredItems.length"
83
+ ref="list"
84
+ :items="listItems"
85
+ :model-value="null"
86
+ :item-label-key="itemLabelKey"
87
+ :item-value-key="itemValueKey"
88
+ :color="color"
89
+ :selection-color="color"
90
+ @item-select="onListItemSelect")
91
+ template(#item="{ item }")
92
+ slot(name="item" :item="item" :highlighted="highlightedItem === item.uid")
93
+ span(v-html="item[itemLabelKey]")
94
+ .w-autocomplete__no-match(
95
+ v-if="!filteredItems.length"
96
+ :class="{ 'w-autocomplete__no-match--default': !$slots['no-match'] }")
97
+ slot(name="no-match")
98
+ .caption(v-html="noMatch ?? 'No match.'")
99
+ .w-autocomplete__extra-item(
100
+ v-if="$slots['extra-item']"
101
+ @click.stop="selectExtraItem"
102
+ :class="{ highlighted: highlightedItem === 'extra-item' }")
103
+ slot(name="extra-item")
104
+
105
+ template(v-if="labelPosition === 'right'")
106
+ label.w-autocomplete__label.w-autocomplete__label--right.w-form-el-shakable(
107
+ v-if="$slots.default || label"
108
+ :for="inputId"
109
+ :class="labelClasses")
110
+ slot {{ label }}
41
111
  </template>
42
112
 
43
113
  <script>
114
+ import { computed } from 'vue'
115
+ import FormElementMixin, { useWaveUiFormIds } from '../mixins/form-elements'
116
+
44
117
  export default {
45
118
  name: 'w-autocomplete',
46
- inheritAttrs: false, // The attrs should only be added to the input not the wrapper.
119
+ mixins: [FormElementMixin],
120
+ inheritAttrs: false,
121
+
122
+ setup () {
123
+ return useWaveUiFormIds()
124
+ },
47
125
 
48
126
  props: {
49
127
  items: { type: Array, required: true },
50
- modelValue: { type: [String, Number, Array] }, // String or Number if single selections, Array if multiple.
128
+ modelValue: { type: [String, Number, Array] },
51
129
  placeholder: { type: String },
52
- openOnKeydown: { type: Boolean }, // By default the menu is always open for selection.
130
+ label: { type: String },
131
+ labelPosition: { type: String, default: 'inside' },
132
+ staticLabel: { type: Boolean },
133
+ innerIconLeft: { type: String },
134
+ innerIconRight: { type: String },
135
+ openOnKeydown: { type: Boolean },
53
136
  multiple: { type: Boolean },
54
- // When multiple is on, prevents duplicate items selections by default, unless this is set to true.
55
137
  allowDuplicates: { type: Boolean },
56
138
  noMatch: { type: String },
57
- // Contains the unique selection value for each item.
58
- // Can be a numeric ID, a slug, etc. (outside of Wave UI)
59
139
  itemValueKey: { type: String, default: 'value' },
60
- // Contains the string to display for each item.
61
140
  itemLabelKey: { type: String, default: 'label' },
62
- // Contains the string to search keywords into for each item.
63
- // This can for instance be an aggregation of multiple fields (outside of Wave UI).
64
- itemSearchableKey: { type: String, default: 'searchable' }
141
+ itemSearchableKey: { type: String, default: 'searchable' },
142
+ color: { type: String, default: 'primary' },
143
+ bgColor: { type: String },
144
+ labelColor: { type: String, default: 'primary' },
145
+ outline: { type: Boolean },
146
+ round: { type: Boolean },
147
+ shadow: { type: Boolean },
148
+ tile: { type: Boolean },
149
+ xs: { type: Boolean },
150
+ sm: { type: Boolean },
151
+ md: { type: Boolean },
152
+ lg: { type: Boolean },
153
+ xl: { type: Boolean },
154
+ dark: { type: Boolean },
155
+ light: { type: Boolean },
156
+ fitToContent: { type: Boolean },
157
+ menuClass: { type: String },
158
+ menuProps: { type: Object }
159
+ // From mixin: id, name, disabled, readonly, required, tabindex, validators, noBlurValidation.
160
+ // Computed from mixin: inputId, inputName, isDisabled, isReadonly, labelClasses.
65
161
  },
66
162
 
67
- // item-select is also from keyboard, 'item-click' may be useful for mouseenter mouseleave events.
68
- emits: ['update:modelValue', 'input', 'focus', 'blur', 'keydown', 'item-click', 'item-select', 'extra-item-select'],
163
+ emits: [
164
+ 'update:modelValue', 'input', 'focus', 'blur', 'keydown',
165
+ 'item-click', 'item-select', 'extra-item-select',
166
+ 'click:inner-icon-left', 'click:inner-icon-right'
167
+ ],
69
168
 
70
169
  data: () => ({
71
170
  keywords: '',
72
171
  selection: [],
73
172
  menuOpen: false,
74
173
  highlightedItem: null,
75
- // The focus-blur events occur more times than it should emit to the outside due to the menu
76
- // item clicking. So keep the focus on as long as the user is interacting with the autocomplete.
174
+ isFocused: false,
77
175
  menuIsBeingClicked: false
78
176
  }),
79
177
 
80
178
  computed: {
81
- // Keep the autocomplete matching as fast as possible by caching optimized search strings.
82
179
  normalizedKeywords () {
83
180
  return this.normalize(this.keywords)
84
181
  },
85
182
 
86
- // Keep the autocomplete matching as fast as possible by caching optimized search strings.
87
183
  optimizedItemsForSearch () {
88
184
  return this.items.map((item, i) => ({
89
185
  ...item,
90
186
  uid: i,
91
- searchable: this.normalize(item[this.itemSearchableKey] || '')
187
+ searchable: this.normalize(item[this.itemSearchableKey] || item[this.itemLabelKey] || '')
92
188
  }))
93
189
  },
94
190
 
95
191
  filteredItems () {
96
- let items = this.optimizedItemsForSearch // Array of objects.
97
- const isItemNotSelected = item => !this.selection.find(i => i.uid === item.uid)
192
+ let items = this.optimizedItemsForSearch
193
+ const isItemNotSelected = item => !this.selection.find(s => s.uid === item.uid)
98
194
 
99
195
  if (this.keywords) {
100
196
  items = items.filter(item => {
@@ -103,7 +199,6 @@ export default {
103
199
  return true
104
200
  })
105
201
  }
106
-
107
202
  else if (this.multiple && !this.allowDuplicates) items = items.filter(isItemNotSelected)
108
203
 
109
204
  return items
@@ -112,20 +207,89 @@ export default {
112
207
  highlightedItemIndex () {
113
208
  if (this.highlightedItem === null) return -1
114
209
  if (this.highlightedItem === 'extra-item') return this.filteredItems.length
115
-
116
210
  return this.filteredItems.findIndex(item => item.uid === this.highlightedItem)
117
211
  },
118
212
 
119
- wrapperAttrs () {
120
- const { style, class: classes } = this.$attrs
121
- return { style, class: classes }
213
+ // filteredItems enriched with a per-item `class` property for the highlighted state.
214
+ // w-list reads item[itemClassKey] (default: 'class') and applies it to the item label div.
215
+ listItems () {
216
+ return this.filteredItems.map(item => ({
217
+ ...item,
218
+ class: this.highlightedItem === item.uid ? 'highlighted' : undefined
219
+ }))
220
+ },
221
+
222
+ hasValue () {
223
+ return this.selection.length > 0 || !!this.keywords
224
+ },
225
+
226
+ hasLabel () {
227
+ return !!(this.label || this.$slots.default)
228
+ },
229
+
230
+ showFloatingLabel () {
231
+ return this.hasLabel && this.labelPosition === 'inside' && !this.staticLabel
232
+ },
233
+
234
+ showLabelInside () {
235
+ return !this.staticLabel || (!this.hasValue && !this.placeholder)
236
+ },
237
+
238
+ selectionValue () {
239
+ if (!this.selection.length) return null
240
+ if (this.multiple) return this.selection.map(item => item[this.itemValueKey])
241
+ return this.selection[0][this.itemValueKey]
242
+ },
243
+
244
+ presetSize () {
245
+ return (this.xs && 'xs') || (this.sm && 'sm') || (this.md && 'md') || (this.lg && 'lg') || (this.xl && 'xl') || null
246
+ },
247
+
248
+ menuPropsComputed () {
249
+ const { appendTo, ...rest } = this.menuProps || {}
250
+ return { appendTo, ...rest }
251
+ },
252
+
253
+ classes () {
254
+ return {
255
+ 'w-autocomplete': true,
256
+ 'w-autocomplete--dark': this.dark,
257
+ 'w-autocomplete--light': this.light,
258
+ 'w-autocomplete--disabled': this.isDisabled,
259
+ 'w-autocomplete--readonly': this.isReadonly,
260
+ 'w-autocomplete--fit-to-content': this.fitToContent,
261
+ [`w-autocomplete--${this.hasValue ? 'filled' : 'empty'}`]: true,
262
+ 'w-autocomplete--focused': (this.isFocused || this.menuOpen) && !this.isReadonly,
263
+ 'w-autocomplete--floating-label': this.showFloatingLabel,
264
+ 'w-autocomplete--no-padding': !this.outline && !this.bgColor && !this.shadow && !this.round,
265
+ 'w-autocomplete--has-placeholder': !!this.placeholder,
266
+ 'w-autocomplete--inner-icon-left': !!this.innerIconLeft,
267
+ 'w-autocomplete--inner-icon-right': !!this.innerIconRight,
268
+ 'w-autocomplete--open': this.menuOpen,
269
+ 'w-autocomplete--multiple': this.multiple,
270
+ [`size--${this.presetSize}`]: !!this.presetSize,
271
+ [this.$attrs.class]: !!this.$attrs.class
272
+ }
273
+ },
274
+
275
+ inputWrapClasses () {
276
+ return {
277
+ [this.valid === false ? this.validationColor : this.color]: this.color || this.valid === false,
278
+ [`${this.bgColor}--bg`]: this.bgColor,
279
+ 'w-autocomplete__input-wrap--round': this.round,
280
+ 'w-autocomplete__input-wrap--tile': this.tile,
281
+ 'w-autocomplete__input-wrap--box': this.outline || this.bgColor || this.shadow,
282
+ 'w-autocomplete__input-wrap--underline': !this.outline,
283
+ 'w-autocomplete__input-wrap--shadow': this.shadow,
284
+ 'w-autocomplete__input-wrap--no-padding': !this.outline && !this.bgColor && !this.shadow && !this.round,
285
+ 'w-autocomplete__input-wrap--inner-icon-left': !!this.innerIconLeft,
286
+ 'w-autocomplete__input-wrap--inner-icon-right': !!this.innerIconRight
287
+ }
122
288
  },
123
289
 
124
290
  inputAttrs () {
125
- // Remove class and style which are meant to stay on the wrapper.
126
291
  // eslint-disable-next-line no-unused-vars
127
292
  const { style, class: classes, ...attrs } = this.$attrs
128
-
129
293
  return attrs
130
294
  },
131
295
 
@@ -136,11 +300,14 @@ export default {
136
300
  },
137
301
  focus: e => {
138
302
  if (this.menuIsBeingClicked) return
139
- this.onFocus(e)
303
+ this.isFocused = true
304
+ if (!this.openOnKeydown) this.openMenu()
140
305
  this.$emit('focus', e)
141
306
  },
142
307
  blur: e => {
143
- if (!this.menuIsBeingClicked) this.$emit('blur', e)
308
+ if (this.menuIsBeingClicked) return
309
+ this.isFocused = false
310
+ this.$emit('blur', e)
144
311
  },
145
312
  keydown: e => {
146
313
  this.onKeydown(e)
@@ -150,47 +317,44 @@ export default {
150
317
  compositionstart: this.onCompositionStart,
151
318
  compositionupdate: this.onCompositionUpdate
152
319
  }
153
- },
154
-
155
- classes () {
156
- return {
157
- 'w-autocomplete--open': this.menuOpen,
158
- 'w-autocomplete--filled': this.selection.length,
159
- 'w-autocomplete--has-keywords': this.keywords,
160
- 'w-autocomplete--empty': !this.selection.length && !this.keywords,
161
- // With the inheritAttrs set to false any class on the component would be lost, so add it back.
162
- [this.$attrs.class]: !!this.$attrs.class
163
- }
164
320
  }
165
321
  },
166
322
 
167
323
  methods: {
168
- // Replace all the accents and non-latin characters with equivalent letters. E.g. é -> e.
169
324
  normalize (string) {
170
- return string.toLowerCase().normalize('NFKD').replace(/\p{Diacritic}/gu, '').replace(/œ/g, 'oe')
325
+ return String(string).toLowerCase().normalize('NFKD').replace(/\p{Diacritic}/gu, '').replace(/œ/g, 'oe')
326
+ },
327
+
328
+ findItemByValue (value) {
329
+ return this.optimizedItemsForSearch.find(
330
+ item => item[this.itemValueKey] === value || String(item[this.itemValueKey]) === String(value)
331
+ )
171
332
  },
172
333
 
173
- // Selection can be made from click or enter key.
174
334
  selectItem (item) {
175
335
  if (!this.multiple) this.selection = []
176
336
  this.selection.push(item)
177
337
  this.highlightedItem = item.uid
178
338
  this.keywords = ''
179
- const emitPayload = this.multiple ? this.selection.map(item => item[this.itemValueKey]) : item[this.itemValueKey]
180
- // Unlike input, item-select is only emitted when selecting (not unselecting).
339
+ const emitPayload = this.multiple
340
+ ? this.selection.map(i => i[this.itemValueKey])
341
+ : item[this.itemValueKey]
181
342
  this.$emit('item-select', item)
182
343
  this.$emit('update:modelValue', emitPayload)
183
344
  this.$emit('input', emitPayload)
184
- this.$refs.input.focus()
345
+ this.$nextTick(() => this.$refs.input?.focus())
185
346
  if (!this.multiple) this.closeMenu()
186
347
  },
187
348
 
188
349
  unselectItem (i) {
189
350
  this.selection.splice(i ?? this.selection.length - 1, 1)
190
351
  this.highlightedItem = null
191
- this.$emit('update:modelValue', null)
192
- this.$emit('input', null)
193
- this.$refs.input.focus()
352
+ const emitPayload = this.multiple
353
+ ? (this.selection.length ? this.selection.map(item => item[this.itemValueKey]) : null)
354
+ : null
355
+ this.$emit('update:modelValue', emitPayload)
356
+ this.$emit('input', emitPayload)
357
+ this.$nextTick(() => this.$refs.input?.focus())
194
358
  },
195
359
 
196
360
  selectExtraItem () {
@@ -199,126 +363,155 @@ export default {
199
363
  this.closeMenu()
200
364
  },
201
365
 
202
- setEndOfMenuClick () {
203
- setTimeout(() => (this.menuIsBeingClicked = false), 100)
366
+ onItemClick (item) {
367
+ this.selectItem(item)
368
+ this.$emit('item-click', item)
204
369
  },
205
370
 
206
- onClick () {
207
- if (!this.openOnKeydown) this.openMenu()
208
- this.$refs.input.focus()
371
+ // Called by w-list @item-select. The item is the cleaned item object (uid still present).
372
+ onListItemSelect (item) {
373
+ this.onItemClick(item)
209
374
  },
210
375
 
211
- onFocus () {
212
- if (!this.openOnKeydown) this.openMenu()
376
+ setEndOfMenuClick () {
377
+ setTimeout(() => (this.menuIsBeingClicked = false), 100)
213
378
  },
214
379
 
215
- // Can be triggered by a click outside the autocomplete, or by a tab key.
216
- // It should not be simply triggered by input blur, because when we click a menu item it will
217
- // blur the input for a few ms before we refocus it.
218
- // onBlur () {
219
- // this.closeMenu()
220
- // },
380
+ onWrapClick () {
381
+ if (!this.openOnKeydown) this.openMenu()
382
+ this.$refs.input?.focus()
383
+ },
221
384
 
222
385
  onKeydown (e) {
223
386
  const itemsCount = this.filteredItems.length + (this.$slots['extra-item'] ? 1 : 0)
224
- // `e.key.length === 1`: is all the keyboard keys that generate a character.
387
+
225
388
  if (!this.openOnKeydown || ((this.keywords || e.key.length === 1) && !this.menuOpen)) this.openMenu()
226
389
 
227
- // Tab key.
390
+ // Tab key - close menu.
228
391
  if (e.keyCode === 9) this.closeMenu()
229
392
 
230
- // Delete key.
393
+ // Backspace - unselect last chip when input is empty.
231
394
  else if (e.keyCode === 8 && (!this.keywords || (!e.target.selectionStart && !e.target.selectionEnd))) {
232
395
  this.unselectItem()
233
396
  }
234
397
 
235
398
  // Enter key.
236
399
  else if (e.keyCode === 13) {
237
- e.preventDefault() // Prevent form submissions.
400
+ e.preventDefault()
238
401
  if (this.highlightedItem === 'extra-item') this.selectExtraItem()
239
402
  else if (this.highlightedItemIndex >= 0) this.selectItem(this.filteredItems[this.highlightedItemIndex])
240
403
  }
241
404
 
242
405
  // Up & down arrow keys.
243
406
  else if ([38, 40].includes(e.keyCode)) {
244
- e.preventDefault() // Prevent moving the cursor to the left of the text while selecting item.
407
+ e.preventDefault()
245
408
  let index = this.highlightedItemIndex
246
409
  if (index === -1) index = e.keyCode === 38 ? itemsCount - 1 : 0
247
- else index = (index + (e.keyCode === 38 ? -1 : 1) + itemsCount) % itemsCount // Never out of range.
410
+ else index = (index + (e.keyCode === 38 ? -1 : 1) + itemsCount) % itemsCount
248
411
 
249
412
  if (this.$slots['extra-item'] && index === itemsCount - 1) this.highlightedItem = 'extra-item'
250
- else this.highlightedItem = this.filteredItems[index]?.uid || 0
413
+ else this.highlightedItem = this.filteredItems[index]?.uid ?? 0
251
414
 
252
- // Scroll the container if highlighted item is not in view.
253
- const menuEl = this.$refs.menu
254
- if (menuEl) {
255
- if (this.$slots['extra-item'] && index === itemsCount - 1) menuEl.scrollTop = menuEl.scrollHeight
415
+ // Scroll the menu list if highlighted item is not in view.
416
+ // listWrap is the scroll container; w-list.$el.childNodes are the <li> elements.
417
+ const scrollEl = this.$refs.listWrap
418
+ if (scrollEl) {
419
+ if (this.$slots['extra-item'] && index === itemsCount - 1) scrollEl.scrollTop = scrollEl.scrollHeight
256
420
  else {
257
- const { offsetHeight: itemElHeight, offsetTop: itemElTop } = menuEl.childNodes[index] || {}
258
- if (menuEl.scrollTop + menuEl.offsetHeight - itemElHeight < itemElTop) {
259
- menuEl.scrollTop = itemElTop - menuEl.offsetHeight + itemElHeight
421
+ const liEl = this.$refs.list?.$el?.childNodes[index]
422
+ if (liEl) {
423
+ const { offsetHeight: itemElHeight, offsetTop: itemElTop } = liEl
424
+ if (scrollEl.scrollTop + scrollEl.offsetHeight - itemElHeight < itemElTop) {
425
+ scrollEl.scrollTop = itemElTop - scrollEl.offsetHeight + itemElHeight
426
+ }
427
+ else if (scrollEl.scrollTop > itemElTop) scrollEl.scrollTop = itemElTop
260
428
  }
261
- else if (menuEl.scrollTop > itemElTop) menuEl.scrollTop = itemElTop
262
429
  }
263
430
  }
264
431
  }
265
432
 
266
- // `e.key.length === 1`: allow all control keys but no character creation.
267
- else if (!this.multiple && this.selection.length && (e.key.length === 1)) e.preventDefault()
433
+ // Prevent typing when single selection is already filled.
434
+ else if (!this.multiple && this.selection.length && e.key.length === 1) e.preventDefault()
268
435
  },
269
436
 
270
- // On drag & drop of a text in the input field, don't paste if single selection and already selected.
271
437
  onDrop (e) {
272
438
  if (!this.multiple && this.selection.length) e.preventDefault()
273
439
  },
274
440
 
275
- // When starting a sequence of keys that produces a character.
276
441
  onCompositionStart (e) {
277
- // e.preventDefault() does not work. https://stackoverflow.com/a/77556830/2012407
278
442
  if (!this.multiple && this.selection.length) e.target.setAttribute('readonly', true)
279
443
  },
444
+
280
445
  onCompositionUpdate (e) {
281
446
  if (!this.multiple && this.selection.length) setTimeout(() => e.target.removeAttribute('readonly'), 200)
282
447
  },
283
448
 
449
+ onReset () {
450
+ this.selection = []
451
+ this.keywords = ''
452
+ this.highlightedItem = null
453
+ const emitPayload = this.multiple ? [] : null
454
+ this.$emit('update:modelValue', emitPayload)
455
+ this.$emit('input', emitPayload)
456
+ },
457
+
284
458
  openMenu () {
285
- if (this.menuOpen) return
459
+ if (this.menuOpen || this.isDisabled || this.isReadonly) return
286
460
  this.menuOpen = true
287
- document.addEventListener('click', this.onDocumentClick)
288
461
  },
289
462
 
290
463
  closeMenu () {
464
+ if (!this.menuOpen) return
291
465
  this.menuOpen = false
292
- document.removeEventListener('click', this.onDocumentClick)
466
+ this.highlightedItem = null
293
467
  },
294
468
 
295
- onDocumentClick (e) {
296
- if (!this.$el.contains(e.target) && !this.$el.isSameNode(e.target)) this.closeMenu()
469
+ // Called when the w-menu emits @close (outside click or programmatic close).
470
+ onMenuClose () {
471
+ this.menuOpen = false
472
+ this.highlightedItem = null
297
473
  }
298
474
  },
299
475
 
300
476
  created () {
301
- if (this.modelValue) {
477
+ if (this.modelValue !== null && this.modelValue !== undefined) {
302
478
  const arrayOfValues = Array.isArray(this.modelValue) ? this.modelValue : [this.modelValue]
303
479
  arrayOfValues.forEach(value => {
304
- this.selection.push(this.optimizedItemsForSearch.find(item => item[this.itemValueKey] === +value))
480
+ const item = this.findItemByValue(value)
481
+ if (item) this.selection.push(item)
305
482
  })
306
483
  }
307
484
  },
308
485
 
309
- beforeUnmount () {
310
- document.removeEventListener('click', this.onDocumentClick)
311
- },
312
-
313
486
  watch: {
314
487
  modelValue (value) {
315
488
  this.selection = []
316
- if (value) {
489
+ if (value !== null && value !== undefined) {
317
490
  const arrayOfValues = Array.isArray(value) ? value : [value]
318
- arrayOfValues.forEach(value => {
319
- this.selection.push(this.optimizedItemsForSearch.find(item => item[this.itemValueKey] === +value))
491
+ arrayOfValues.forEach(v => {
492
+ const item = this.findItemByValue(v)
493
+ if (item) this.selection.push(item)
320
494
  })
321
495
  }
496
+ },
497
+
498
+ items () {
499
+ // Re-sync selection when items change (e.g. async loading).
500
+ const currentValues = this.selection.map(item => item[this.itemValueKey])
501
+ this.selection = []
502
+ currentValues.forEach(value => {
503
+ const item = this.findItemByValue(value)
504
+ if (item) this.selection.push(item)
505
+ })
506
+ },
507
+
508
+ // When the number of visible items changes (user is typing), recompute the menu position so
509
+ // that a menu opening above the input stays anchored to the input's top edge instead of
510
+ // drifting upward as the list shrinks.
511
+ filteredItems (newVal, oldVal) {
512
+ if (this.menuOpen && newVal.length !== oldVal.length) {
513
+ this.$nextTick(() => this.$refs.menu?.computeDetachableCoords())
514
+ }
322
515
  }
323
516
  }
324
517
  }
@@ -326,89 +519,330 @@ export default {
326
519
 
327
520
  <style lang="scss">
328
521
  .w-autocomplete {
522
+ position: relative;
329
523
  display: flex;
524
+ flex-grow: 1;
330
525
  flex-wrap: wrap;
331
- gap: 4px;
332
- position: relative;
333
- border-radius: $border-radius;
334
- border: $border;
335
- padding: 2px 4px;
336
- user-select: none;
337
-
338
- &--open {
339
- border-bottom-left-radius: 0;
340
- border-bottom-right-radius: 0;
526
+ align-items: center;
527
+ font-size: var(--w-base-font-size);
528
+
529
+ @include themeable;
530
+
531
+ &--disabled {
532
+ color: var(--w-disabled-color);
533
+ cursor: not-allowed;
534
+ -webkit-tap-highlight-color: transparent;
535
+ }
536
+
537
+ &--fit-to-content {
538
+ display: inline-flex;
539
+ flex-grow: 0;
540
+ }
541
+
542
+ // Input field wrapper.
543
+ // ------------------------------------------------------
544
+ &__input-wrap {
545
+ position: relative;
546
+ display: inline-flex;
547
+ flex: 1 1 auto;
548
+ flex-wrap: wrap;
549
+ align-items: center;
550
+ gap: 4px;
551
+ min-height: var(--w-form-field-height);
552
+ padding: 3px calc(var(--w-base-increment) * 2);
553
+ border-radius: var(--w-border-radius);
554
+ border: var(--w-border);
555
+ transition: border var(--w-transition-duration);
556
+ cursor: text;
557
+
558
+ &--tile { border-radius: initial; }
559
+ &--shadow { box-shadow: var(--w-box-shadow); }
560
+ .w-autocomplete[class^="bdrs"] &, .w-autocomplete[class*=" bdrs"] & { border-radius: inherit; }
561
+
562
+ .w-autocomplete--floating-label & { margin-top: calc(var(--w-base-increment) * 3); }
563
+
564
+ &--underline {
565
+ border-bottom-left-radius: initial;
566
+ border-bottom-right-radius: initial;
567
+ border-width: 0 0 1px;
568
+ }
569
+
570
+ &--round { border-radius: 99em; }
571
+
572
+ .w-autocomplete--focused &,
573
+ .w-autocomplete--open & { border-color: currentColor; }
574
+
575
+ // Focus indicator line (underline style).
576
+ &--underline:after {
577
+ content: '';
578
+ position: absolute;
579
+ bottom: -1px;
580
+ left: 0;
581
+ width: 100%;
582
+ height: 0;
583
+ border-bottom: 2px solid currentColor;
584
+ transition: var(--w-transition-duration);
585
+ transform: scaleX(0);
586
+ pointer-events: none;
587
+ }
588
+
589
+ .w-autocomplete--focused &--underline:after,
590
+ .w-autocomplete--open &--underline:after { transform: scaleX(1); }
591
+
592
+ &--round.w-autocomplete__input-wrap--underline:after {
593
+ border-radius: 99em;
594
+ transition: var(--w-transition-duration), height 0.035s;
595
+ }
596
+
597
+ .w-autocomplete--focused &--round.w-autocomplete__input-wrap--underline:after,
598
+ .w-autocomplete--open &--round.w-autocomplete__input-wrap--underline:after {
599
+ height: 100%;
600
+ transition: var(--w-transition-duration), height 0s calc(var(--w-transition-duration) - 0.035s);
601
+ }
602
+
603
+ &--no-padding {
604
+ padding-left: 0;
605
+ padding-right: 0;
606
+ }
607
+
608
+ // Icon clearance — modifier classes on the wrap itself (no ancestor selector), so these
609
+ // are always 0-1-0 when alone but come after --no-padding in source order (wins the tie).
610
+ // The combined --no-padding + --inner-icon cases are 0-2-0 and beat both single rules.
611
+ &--inner-icon-left { padding-left: 28px; }
612
+ &--inner-icon-right { padding-right: 28px; }
613
+ &--no-padding.w-autocomplete__input-wrap--inner-icon-left { padding-left: 22px; }
614
+ &--no-padding.w-autocomplete__input-wrap--inner-icon-right { padding-right: 22px; }
341
615
  }
342
616
 
617
+ // Selected item chips (multiple mode).
618
+ // ------------------------------------------------------
343
619
  &__selection {
344
- display: flex;
620
+ display: inline-flex;
345
621
  align-items: center;
346
622
  background: color-mix(in srgb, var(--w-contrast-bg-color) 3.5%, transparent);
347
623
  border: 1px solid color-mix(in srgb, var(--w-contrast-bg-color) 5%, transparent);
348
- border-radius: $border-radius;
349
- padding: 0 2px 0 4px;
624
+ border-radius: var(--w-border-radius);
625
+ padding: 0 2px 0 6px;
350
626
  flex-shrink: 0;
627
+ line-height: 1;
628
+
629
+ span {
630
+ font-size: 0.875em;
631
+ margin-top: -1px;
632
+ }
633
+
634
+ .w-button .w-icon:before {
635
+ font-size: 0.8em;
636
+ line-height: 0;
637
+ }
638
+ }
351
639
 
352
- span {margin-top: -1px;line-height: 1;}
353
- .w-button .w-icon:before {font-size: 0.8em;line-height: 0;}
640
+ // Placeholder.
641
+ // ------------------------------------------------------
642
+ &__placeholder {
643
+ color: color-mix(in srgb, var(--w-base-color) 50%, transparent);
644
+ pointer-events: none;
645
+ white-space: nowrap;
646
+ align-self: center;
354
647
  }
355
648
 
649
+ // The text input.
650
+ // ------------------------------------------------------
356
651
  &__input {
652
+ flex: 1 1 60px;
357
653
  min-width: 0;
358
- flex: 1 1 0;
654
+ font: inherit;
359
655
  color: inherit;
656
+ background: none;
360
657
  border: none;
361
- background-color: transparent;
362
- line-height: 18px;
658
+ outline: none;
659
+ padding: 0;
660
+ // Ensure the input takes up a comfortable height in the flex row.
661
+ min-height: calc(var(--w-form-field-height) - 8px);
662
+ align-self: center;
663
+
664
+ .w-autocomplete--disabled & {
665
+ color: var(--w-disabled-color);
666
+ cursor: not-allowed;
667
+ -webkit-tap-highlight-color: transparent;
668
+ }
669
+
670
+ .w-autocomplete--readonly & { pointer-events: none; }
363
671
  }
364
672
 
365
- &__placeholder {
366
- color: color-mix(in srgb, var(--w-base-color) 50%, transparent);
673
+ // Icons inside.
674
+ // ------------------------------------------------------
675
+ &__icon {
676
+ position: absolute;
677
+ font-size: 1.4em;
678
+ cursor: pointer;
679
+ border-radius: 5em;
680
+ @include default-transition;
681
+
682
+ .w-autocomplete--focused &,
683
+ .w-autocomplete--open & { color: currentColor; }
684
+
685
+ .w-autocomplete--disabled &,
686
+ .w-autocomplete--readonly & {
687
+ color: var(--w-disabled-color);
688
+ cursor: not-allowed;
689
+ -webkit-tap-highlight-color: transparent;
690
+ }
691
+
692
+ &--inner-left { left: var(--w-base-increment); }
693
+ &--inner-right { right: var(--w-base-increment); }
694
+
695
+ .w-autocomplete--no-padding &--inner-left { left: 1px; }
696
+ .w-autocomplete--no-padding &--inner-right { right: 1px; }
697
+
698
+ &:hover { background: rgba(0, 0, 0, 0.05); }
699
+
700
+ .w-autocomplete--disabled &:hover,
701
+ .w-autocomplete--readonly &:hover { background-color: transparent; }
702
+ }
703
+
704
+ // Label.
705
+ // ------------------------------------------------------
706
+ &__label {
707
+ display: flex;
708
+ align-items: center;
709
+ transition: color var(--w-transition-duration);
710
+ cursor: pointer;
711
+ user-select: none;
712
+
713
+ &--left { margin-right: calc(var(--w-base-increment) * 2); }
714
+ &--right { margin-left: calc(var(--w-base-increment) * 2); }
715
+
716
+ .w-autocomplete--disabled & {
717
+ color: var(--w-disabled-color);
718
+ cursor: not-allowed;
719
+ -webkit-tap-highlight-color: transparent;
720
+ }
721
+
722
+ .w-autocomplete--readonly.w-autocomplete--empty & {
723
+ opacity: 0.5;
724
+ cursor: auto;
725
+ }
726
+ }
727
+
728
+ // Nesting under __input-wrap gives this block 2-class specificity (0-2-0), which beats
729
+ // .w-form-el-shakable { position: relative } (0-1-0) regardless of stylesheet load order.
730
+ &__input-wrap &__label--inside {
731
+ position: absolute;
732
+ // top: 50% would slide down as chips wrap to more rows; a fixed value always points to the
733
+ // center of the first row, keeping the label anchored and the floating animation correct.
734
+ top: calc(var(--w-form-field-height) / 2);
735
+ left: 0;
736
+ padding-left: calc(var(--w-base-increment) * 2);
737
+ white-space: nowrap;
738
+ transform: translateY(-50%);
367
739
  pointer-events: none;
368
- line-height: 18px;
740
+
741
+ .w-autocomplete--no-padding & { padding-left: 0; }
742
+ .w-autocomplete__input-wrap--round & { padding-left: calc(var(--w-base-increment) * 3); }
743
+ // When an inner-left icon is present, remove padding and align directly with input text start
744
+ // (matches input-wrap's icon clearance padding: 28px regular, 22px no-padding).
745
+ .w-autocomplete--inner-icon-left & { left: 28px; padding-left: 0; }
746
+ .w-autocomplete--no-padding.w-autocomplete--inner-icon-left & { left: 22px; }
747
+ .w-autocomplete--inner-icon-right & { padding-right: 26px; }
748
+
749
+ .w-autocomplete--floating-label & {
750
+ transform-origin: 0 0;
751
+ transition: var(--w-transition-duration) ease;
752
+ will-change: transform;
753
+ }
754
+
755
+ // Float the label up when focused, filled or has placeholder (underline style).
756
+ .w-autocomplete--focused.w-autocomplete--floating-label &,
757
+ .w-autocomplete--filled.w-autocomplete--floating-label &,
758
+ .w-autocomplete--has-placeholder.w-autocomplete--floating-label & {
759
+ transform: translateY(-160%) scale(0.85);
760
+ }
761
+
762
+ // Float higher for box styles (outline / shadow / bg-color).
763
+ .w-autocomplete--focused.w-autocomplete--floating-label .w-autocomplete__input-wrap--box &,
764
+ .w-autocomplete--filled.w-autocomplete--floating-label .w-autocomplete__input-wrap--box &,
765
+ .w-autocomplete--has-placeholder.w-autocomplete--floating-label .w-autocomplete__input-wrap--box & {
766
+ transform: translateY(-180%) scale(0.85);
767
+ }
768
+
769
+ .w-autocomplete--focused.w-autocomplete--floating-label.w-autocomplete--inner-icon-left &,
770
+ .w-autocomplete--filled.w-autocomplete--floating-label.w-autocomplete--inner-icon-left & { left: 0; }
369
771
  }
370
772
 
773
+ // Sizes.
774
+ // ------------------------------------------------------
775
+ &.size--xs { --w-form-field-height: round(nearest, calc(1.43 * var(--w-base-font-size)), 1px); }
776
+ &.size--sm { --w-form-field-height: round(nearest, calc(1.71 * var(--w-base-font-size)), 1px); }
777
+ &.size--lg { --w-form-field-height: round(nearest, calc(2.29 * var(--w-base-font-size)), 1px); }
778
+ &.size--xl { --w-form-field-height: round(nearest, calc(2.71 * var(--w-base-font-size)), 1px); }
779
+
780
+ // Dropdown menu (rendered via w-menu, outside overflow:hidden parents).
781
+ // ------------------------------------------------------
371
782
  &__menu {
372
- position: absolute;
373
- inset: 100% -1px auto;
374
- max-height: clamp(20px, 400px, 80vh);
375
- margin-top: -1px;
376
- margin-left: 0;
377
- background-color: $base-bg-color;
378
- border: 1px solid color-mix(in srgb, var(--w-contrast-bg-color) 20%, transparent);
379
- border-top: none;
380
- border-bottom-left-radius: $border-radius;
381
- border-bottom-right-radius: $border-radius;
382
- overflow: auto;
383
- z-index: 10;
384
-
385
- li {
386
- position: relative;
387
- list-style-type: none;
388
- margin: 0;
389
- padding: 4px 8px;
390
-
391
- &:hover {background-color: rgba($primary, 0.1);}
392
-
393
- &:before, &:after {
394
- content: '';
395
- position: absolute;
396
- inset: 0;
397
- }
783
+ overflow: hidden;
784
+ background-color: var(--w-base-bg-color);
785
+ border: var(--w-border);
786
+ border-radius: var(--w-border-radius);
787
+ }
398
788
 
399
- &.highlighted:before {
400
- border-left: 2px solid transparent;
401
- border-left-color: $primary;
402
- opacity: 0.3;
403
- }
789
+ // Scroll container for the w-list + no-match + extra-item.
790
+ &__list-wrap {
791
+ position: relative;
792
+ max-height: 300px;
793
+ overflow-y: auto;
794
+ flex-grow: 1;
795
+ }
404
796
 
405
- &.highlighted:after {
406
- background-color: $primary;
407
- opacity: 0.1;
408
- }
797
+ // w-list inside the dropdown — let the wrap handle overflow; add padding here.
798
+ &__list.w-list {
799
+ padding: 4px 0;
800
+ width: 100%;
801
+
802
+ // Keyboard-highlighted item: left indicator + tinted background (layered via :after
803
+ // so it doesn't interfere with w-list's own :before hover/active states).
804
+ .w-list__item-label.highlighted:after {
805
+ content: '';
806
+ position: absolute;
807
+ inset: 0;
808
+ border-left: 2px solid currentColor;
809
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
810
+ pointer-events: none;
409
811
  }
410
812
  }
411
- }
412
813
 
413
- li.w-autocomplete__no-match--default:hover {background-color: transparent;}
814
+ &__no-match {
815
+ padding: 6px 12px;
816
+ cursor: default;
817
+ }
818
+
819
+ &__extra-item {
820
+ display: flex;
821
+ align-items: center;
822
+ position: relative;
823
+ padding: 6px 12px;
824
+ cursor: pointer;
825
+ border-top: var(--w-border);
826
+
827
+ &:before {
828
+ content: '';
829
+ position: absolute;
830
+ inset: 0;
831
+ background-color: transparent;
832
+ transition: background-color 0.2s;
833
+ pointer-events: none;
834
+ }
835
+
836
+ &:hover:before { background-color: color-mix(in srgb, currentColor 8%, transparent); }
837
+
838
+ &.highlighted:after {
839
+ content: '';
840
+ position: absolute;
841
+ inset: 0;
842
+ border-left: 2px solid currentColor;
843
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
844
+ pointer-events: none;
845
+ }
846
+ }
847
+ }
414
848
  </style>