wave-ui 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/types/types/components/WAccordion.d.ts +6 -6
  2. package/dist/types/types/components/WTabs.d.ts +6 -5
  3. package/dist/wave-ui.cjs.js +3 -3
  4. package/dist/wave-ui.css +1 -1
  5. package/dist/wave-ui.esm.js +1332 -1165
  6. package/dist/wave-ui.umd.js +3 -3
  7. package/package.json +1 -1
  8. package/src/wave-ui/components/w-accordion/index.vue +15 -2
  9. package/src/wave-ui/components/w-accordion/item.vue +7 -1
  10. package/src/wave-ui/components/w-autocomplete.vue +3 -1
  11. package/src/wave-ui/components/w-button/index.vue +11 -2
  12. package/src/wave-ui/components/w-checkbox.vue +3 -1
  13. package/src/wave-ui/components/w-checkboxes.vue +9 -0
  14. package/src/wave-ui/components/w-dialog.vue +6 -0
  15. package/src/wave-ui/components/w-drawer.vue +15 -2
  16. package/src/wave-ui/components/w-input.vue +3 -1
  17. package/src/wave-ui/components/w-list.vue +10 -1
  18. package/src/wave-ui/components/w-menu.vue +7 -0
  19. package/src/wave-ui/components/w-overlay.vue +44 -8
  20. package/src/wave-ui/components/w-radio.vue +3 -1
  21. package/src/wave-ui/components/w-radios.vue +9 -0
  22. package/src/wave-ui/components/w-rating.vue +7 -0
  23. package/src/wave-ui/components/w-select.vue +10 -1
  24. package/src/wave-ui/components/w-slider.vue +4 -1
  25. package/src/wave-ui/components/w-switch.vue +3 -1
  26. package/src/wave-ui/components/w-tabs/index.vue +77 -19
  27. package/src/wave-ui/components/w-tag.vue +9 -1
  28. package/src/wave-ui/components/w-textarea.vue +4 -1
  29. package/src/wave-ui/components/w-tree.vue +8 -0
  30. package/src/wave-ui/core.js +2 -2
  31. package/src/wave-ui/mixins/focusable.js +13 -0
  32. package/src/wave-ui/scss/_base.scss +4 -0
  33. package/src/wave-ui/utils/focus.js +39 -0
@@ -76,10 +76,11 @@ import { useId } from 'vue'
76
76
  import { objectifyClasses } from '../../utils/index'
77
77
  import RippleMixin from '../../mixins/ripple'
78
78
  import TabContent from './tab-content.vue'
79
+ import { focusElement } from '../../utils/focus'
79
80
 
80
81
  export default {
81
82
  name: 'w-tabs',
82
-
83
+ expose: ['focus'],
83
84
  mixins: [RippleMixin],
84
85
 
85
86
  setup () {
@@ -183,6 +184,46 @@ export default {
183
184
  },
184
185
 
185
186
  methods: {
187
+ focus () {
188
+ const bar = this.$refs['tabs-bar']
189
+ if (!bar) return
190
+ const tab = bar.querySelector('[role="tab"][aria-selected="true"]:not([tabindex="-1"])')
191
+ || bar.querySelector('[role="tab"][tabindex="0"]')
192
+ focusElement(tab)
193
+ },
194
+
195
+ resolveTabUid (value) {
196
+ if (!this.tabs.length) return null
197
+ if (value === undefined || value === null || value === '') return this.tabs[0]._uid
198
+
199
+ if (typeof value === 'string') {
200
+ if (this.tabsByUid[value]?._uid) return value
201
+ const parsed = Number.parseInt(value, 10)
202
+ if (!Number.isNaN(parsed) && `${parsed}` === value.trim()) return this.tabs[parsed]?._uid || null
203
+ return null
204
+ }
205
+
206
+ if (typeof value === 'number' && value >= 0) return this.tabs[value]?._uid || null
207
+ return null
208
+ },
209
+
210
+ syncActiveTabFromModelValue (value = this.modelValue) {
211
+ const uid = this.resolveTabUid(value) || this.tabs[0]?._uid || null
212
+ const tab = uid ? this.tabsByUid[uid] : null
213
+ this.activeTabUid = tab?._uid || null
214
+ this.activeTabIndex = tab?._index || 0
215
+ },
216
+
217
+ shouldEmitUidModelValue () {
218
+ if (typeof this.modelValue !== 'string') return false
219
+ return !/^\d+$/.test(this.modelValue.trim())
220
+ },
221
+
222
+ getModelValueForTab (tab) {
223
+ if (this.shouldEmitUidModelValue()) return tab[this.itemIdKey] ?? tab._uid
224
+ return tab._index
225
+ },
226
+
186
227
  // Adding a tab in the list.
187
228
  addTab (item) {
188
229
  // If there is no unique ID provided, inject one in each tab.
@@ -200,6 +241,7 @@ export default {
200
241
  refreshTabs () {
201
242
  let items = this.items
202
243
  if (typeof items === 'number') items = Array(items).fill().map((_, i) => this.tabs[i] || {})
244
+ else items = items || []
203
245
 
204
246
  this.tabs = items.map((item, _index) => {
205
247
  // If there is no unique ID provided, inject one in each tab.
@@ -258,19 +300,23 @@ export default {
258
300
  openTab (uid) {
259
301
  this.prevTabIndex = this.activeTabIndex // To resolve the transition direction.
260
302
  const tab = this.tabsByUid[uid]
303
+ if (!tab) return
261
304
  this.activeTabIndex = tab._index
262
305
  this.activeTabUid = tab._uid
263
- this.$emit('update:modelValue', tab._index)
264
- this.$emit('input', tab._index)
306
+ const modelValue = this.getModelValueForTab(tab)
307
+ this.$emit('update:modelValue', modelValue)
308
+ this.$emit('input', modelValue)
265
309
 
266
310
  if (!this.noSlider) this.$nextTick(this.updateSlider)
267
311
  },
268
312
 
269
313
  // Updates the slider position.
270
314
  updateSlider (domLookup = true) {
271
- if (domLookup) {
272
- const ref = this.$refs['tabs-bar']
273
- this.activeTabEl = ref?.querySelector('.w-tabs__bar-item--active')
315
+ const ref = this.$refs['tabs-bar']
316
+ if (domLookup || !this.activeTabEl) {
317
+ this.activeTabEl =
318
+ ref?.querySelector('.w-tabs__bar-item--active')
319
+ || ref?.querySelector(`.w-tabs__bar-item:nth-child(${this.activeTabIndex + 1})`)
274
320
  }
275
321
 
276
322
  if (!this.fillBar && this.activeTabEl) {
@@ -281,6 +327,10 @@ export default {
281
327
  this.slider.left = `${left - parentLeft - parseInt(borderLeftWidth) + tabsBar.scrollLeft}px`
282
328
  this.slider.width = `${width}px`
283
329
  }
330
+ else if (!this.fillBar && domLookup && this.tabs.length) {
331
+ // Hydration/layout timing can briefly hide active title lookup; retry once on next tick.
332
+ this.$nextTick(() => this.updateSlider(false))
333
+ }
284
334
  else {
285
335
  this.slider.left = `${this.activeTab._index * 100 / this.tabs.length}%`
286
336
  this.slider.width = `${100 / this.tabs.length}%`
@@ -288,17 +338,17 @@ export default {
288
338
  },
289
339
 
290
340
  updateActiveTab (index) {
291
- if (typeof index === 'string') index = ~~index
292
- else if (isNaN(index) || index < 0) index = 0
341
+ const uid = this.resolveTabUid(index)
342
+ const tab = uid ? this.tabsByUid[uid] : null
293
343
 
294
344
  // Only open the tab if it is found.
295
- if (this.tabs[index]?._uid) {
296
- this.openTab(this.tabs[index]?._uid)
345
+ if (tab?._uid) {
346
+ this.openTab(tab._uid)
297
347
 
298
348
  // Scroll the new active tab item title into view if needed.
299
349
  this.$nextTick(() => {
300
350
  const ref = this.$refs['tabs-bar']
301
- this.activeTabEl = ref?.querySelector(`.w-tabs__bar-item:nth-child(${index + 1})`)
351
+ this.activeTabEl = ref?.querySelector(`.w-tabs__bar-item:nth-child(${tab._index + 1})`)
302
352
  if (this.activeTabEl) {
303
353
  this.activeTabEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
304
354
  }
@@ -312,13 +362,14 @@ export default {
312
362
  }
313
363
  },
314
364
 
315
- beforeMount () {
365
+ created () {
316
366
  this.tabs = [] // Reset for hot-reloading.
317
- const items = typeof this.items === 'number' ? Array(this.items).fill().map(Object) : this.items
367
+ const items = typeof this.items === 'number' ? Array(this.items).fill().map(Object) : this.items || []
318
368
  items.forEach(this.addTab)
369
+ this.syncActiveTabFromModelValue(this.modelValue)
370
+ },
319
371
 
320
- if (this.modelValue ?? false) this.updateActiveTab(this.modelValue)
321
-
372
+ beforeMount () {
322
373
  this.$nextTick(() => {
323
374
  this.updateSlider()
324
375
  // Disable the slider transition while loading.
@@ -333,14 +384,15 @@ export default {
333
384
  },
334
385
 
335
386
  watch: {
336
- modelValue (index) {
337
- if (index !== this.activeTabIndex) this.updateActiveTab(index)
387
+ modelValue (value) {
388
+ const uid = this.resolveTabUid(value)
389
+ if (uid && uid !== this.activeTabUid) this.updateActiveTab(value)
338
390
  },
339
391
  items: {
340
392
  handler () {
341
393
  this.refreshTabs()
342
-
343
- if (this.tabs.length) this.reopenTheActiveTab()
394
+ this.syncActiveTabFromModelValue(this.modelValue)
395
+ if (!this.activeTabUid && this.tabs.length) this.reopenTheActiveTab()
344
396
 
345
397
  if (!this.noSlider) this.$nextTick(this.updateSlider)
346
398
  },
@@ -412,6 +464,12 @@ export default {
412
464
  margin-right: -1px;
413
465
  }
414
466
  .w-tabs--card &--active {border-bottom-color: transparent;}
467
+ .w-tabs--pill-slider & {
468
+ padding: calc(var(--w-base-increment) * 1) calc(var(--w-base-increment) * 2);
469
+ margin: calc(var(--w-base-increment) * 1) calc(var(--w-base-increment) * 1);
470
+ border-radius: 99em;
471
+ font-size: round(nearest, calc(1.1 * var(--w-base-font-size)), 1px);
472
+ }
415
473
 
416
474
  &--disabled {
417
475
  cursor: not-allowed;
@@ -19,10 +19,11 @@ span.w-tag(
19
19
 
20
20
  <script>
21
21
  import RippleMixin from '../mixins/ripple'
22
+ import { focusElement } from '../utils/focus'
22
23
 
23
24
  export default {
24
25
  name: 'w-tag',
25
-
26
+ expose: ['focus'],
26
27
  mixins: [RippleMixin],
27
28
 
28
29
  props: {
@@ -48,6 +49,13 @@ export default {
48
49
 
49
50
  emits: ['input', 'update:modelValue'],
50
51
 
52
+ methods: {
53
+ focus () {
54
+ if (this.modelValue === -1) return
55
+ focusElement(this.$el)
56
+ }
57
+ },
58
+
51
59
  computed: {
52
60
  presetSize () {
53
61
  return (
@@ -68,10 +68,13 @@ component(
68
68
  **/
69
69
 
70
70
  import FormElementMixin, { useWaveUiFormIds } from '../mixins/form-elements'
71
+ import FocusableMixin from '../mixins/focusable'
71
72
 
72
73
  export default {
73
74
  name: 'w-textarea',
74
- mixins: [FormElementMixin],
75
+ expose: ['focus'],
76
+ focusTargetRef: 'textarea',
77
+ mixins: [FormElementMixin, FocusableMixin],
75
78
  inheritAttrs: false, // The attrs should only be added to the textarea not the wrapper.
76
79
 
77
80
  setup () {
@@ -68,8 +68,11 @@ import { consoleWarn } from '../utils/console'
68
68
  * - option to add a left border.
69
69
  **/
70
70
 
71
+ import { focusElement } from '../utils/focus'
72
+
71
73
  export default {
72
74
  name: 'w-tree',
75
+ expose: ['focus'],
73
76
 
74
77
  mixins: [RippleMixin],
75
78
 
@@ -122,6 +125,11 @@ export default {
122
125
  },
123
126
 
124
127
  methods: {
128
+ focus () {
129
+ if (this.disabled) return
130
+ focusElement(this.$el.querySelector('.w-tree__item-label[tabindex="0"]'))
131
+ },
132
+
125
133
  // From data watcher, retain the oldItems open state.
126
134
  updateCurrentDepthTree (items, oldItems = []) {
127
135
  this.currentDepthItems = []
@@ -5,6 +5,7 @@ import { colorPalette, generateColorShades, flattenColors } from './utils/colors
5
5
  import { injectColorsCSSInDOM, injectCSSInDOM } from './utils/dynamic-css'
6
6
  import { injectNotifManagerInDOM, NotificationManager } from './utils/notification-manager'
7
7
  import { waveRippleDirective } from './utils/wave-ripple-directive'
8
+ import { scheduleFocus } from './utils/focus'
8
9
  import './scss/index.scss'
9
10
 
10
11
  let mounted = false
@@ -112,8 +113,7 @@ export default class WaveUI {
112
113
  static install (app, options = {}) {
113
114
  // Register directives.
114
115
  app.directive('focus', {
115
- // Wait for the next tick to focus the newly mounted element.
116
- mounted: el => setTimeout(() => el.focus(), 0)
116
+ mounted: (el, binding, vnode) => scheduleFocus(vnode, el)
117
117
  })
118
118
  app.directive('scroll', {
119
119
  mounted: (el, binding) => {
@@ -0,0 +1,13 @@
1
+ import { guardFocusable, focusElement } from '../utils/focus'
2
+
3
+ export default {
4
+ methods: {
5
+ focus () {
6
+ if (!guardFocusable(this)) return
7
+ const refName = this.$options.focusTargetRef || 'input'
8
+ const target = this.$refs[refName]
9
+ if (target) focusElement(target)
10
+ else this.$nextTick(() => focusElement(this.$refs[refName]))
11
+ }
12
+ }
13
+ }
@@ -88,6 +88,8 @@
88
88
  }
89
89
 
90
90
  :root[data-theme="light"] {
91
+ color-scheme: light;
92
+
91
93
  --w-base-bg-color: #fff;
92
94
  --w-base-color: #000;
93
95
  --w-contrast-bg-color: #000;
@@ -97,6 +99,8 @@
97
99
  }
98
100
 
99
101
  :root[data-theme="dark"] {
102
+ color-scheme: dark;
103
+
100
104
  --w-base-bg-color: #222;
101
105
  --w-base-color: #fff;
102
106
  --w-contrast-bg-color: #fff;
@@ -0,0 +1,39 @@
1
+ import { nextTick } from 'vue'
2
+
3
+ export function focusElement (el) {
4
+ el?.focus?.()
5
+ }
6
+
7
+ export function guardFocusable (vm) {
8
+ if (vm.isDisabled || vm.isReadonly) return false
9
+ return true
10
+ }
11
+
12
+ /**
13
+ * Resolve focus() from a directive host. On components, `vnode` is the root element
14
+ * VNode (no `vnode.component`); the DOM owner is on `el.__vueParentComponent`.
15
+ * Walk up when the root is a wrapper (e.g. `w-form-element` around `w-input`).
16
+ */
17
+ export function resolveFocusFn (vnode, el) {
18
+ let instance = vnode?.component ?? el?.__vueParentComponent
19
+ while (instance) {
20
+ const focusFn = instance.exposed?.focus ?? instance.proxy?.focus
21
+ if (typeof focusFn === 'function') return focusFn
22
+ instance = instance.parent
23
+ }
24
+ return null
25
+ }
26
+
27
+ export function callFocus (vnode, el) {
28
+ const focusFn = resolveFocusFn(vnode, el)
29
+ if (typeof focusFn === 'function') {
30
+ focusFn()
31
+ return
32
+ }
33
+ focusElement(el)
34
+ }
35
+
36
+ /** Schedule focus after mount so template refs exist on the host component. */
37
+ export function scheduleFocus (vnode, el) {
38
+ nextTick(() => callFocus(vnode, el))
39
+ }