wave-ui 4.1.0 → 4.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-ui",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "A UI framework for Vue.js 3 (and 2) with only the bright side. :sunny:",
5
5
  "author": "Antoni Andre <antoniandre.web@gmail.com>",
6
6
  "homepage": "https://antoniandre.github.io/wave-ui",
@@ -94,6 +94,7 @@ import { focusElement } from '../../utils/focus'
94
94
 
95
95
  export default {
96
96
  name: 'w-accordion-item',
97
+ focusable: true,
97
98
  expose: ['focus'],
98
99
  mixins: [RippleMixin],
99
100
 
@@ -16,6 +16,7 @@ import { focusElement } from '../../utils/focus'
16
16
 
17
17
  export default {
18
18
  name: 'w-button',
19
+ focusable: true,
19
20
  expose: ['focus'],
20
21
  inheritAttrs: false, // The attrs are only bound to the button-partial, not the root.
21
22
 
@@ -37,6 +37,7 @@ import { guardFocusable } from '../utils/focus'
37
37
 
38
38
  export default {
39
39
  name: 'w-checkboxes',
40
+ focusable: true,
40
41
  expose: ['focus'],
41
42
  mixins: [FormElementMixin],
42
43
 
@@ -49,6 +49,7 @@ import { focusElement } from '../utils/focus'
49
49
 
50
50
  export default {
51
51
  name: 'w-list',
52
+ focusable: true,
52
53
  expose: ['focus'],
53
54
  mixins: [RippleMixin],
54
55
 
@@ -2,7 +2,7 @@
2
2
  slot(name="activator")
3
3
  slot(v-if="!$slots.activator")
4
4
  teleport(v-if="detachableDomReady" :to="teleportTarget" :disabled="!teleportTarget")
5
- transition(:name="transitionName" appear @after-leave="onAfterLeave")
5
+ transition(:name="transitionName" appear @after-enter="onDetachableAfterEnter" @after-leave="onAfterLeave")
6
6
  .w-menu(
7
7
  v-if="custom && detachableVisible"
8
8
  ref="detachable"
@@ -36,6 +36,7 @@ import { guardFocusable } from '../utils/focus'
36
36
 
37
37
  export default {
38
38
  name: 'w-radios',
39
+ focusable: true,
39
40
  expose: ['focus'],
40
41
  mixins: [FormElementMixin],
41
42
 
@@ -34,6 +34,7 @@ import { guardFocusable, focusElement } from '../utils/focus'
34
34
 
35
35
  export default {
36
36
  name: 'w-rating',
37
+ focusable: true,
37
38
  expose: ['focus'],
38
39
  mixins: [FormElementMixin],
39
40
 
@@ -43,7 +43,7 @@
43
43
  import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, useId, useAttrs } from 'vue'
44
44
  import { objectifyClasses } from '../utils/index'
45
45
 
46
- defineOptions({ name: 'WScrollable' })
46
+ defineOptions({ name: 'WScrollable', focusable: true })
47
47
 
48
48
  const props = defineProps({
49
49
  color: { type: String, default: 'primary' },
@@ -80,6 +80,7 @@ import { focusElement } from '../../utils/focus'
80
80
 
81
81
  export default {
82
82
  name: 'w-tabs',
83
+ focusable: true,
83
84
  expose: ['focus'],
84
85
  mixins: [RippleMixin],
85
86
 
@@ -23,6 +23,7 @@ import { focusElement } from '../utils/focus'
23
23
 
24
24
  export default {
25
25
  name: 'w-tag',
26
+ focusable: true,
26
27
  expose: ['focus'],
27
28
  mixins: [RippleMixin],
28
29
 
@@ -2,7 +2,7 @@
2
2
  slot(name="activator")
3
3
  slot(v-if="!$slots.activator")
4
4
  teleport(v-if="detachableDomReady" :to="teleportTarget" :disabled="!teleportTarget")
5
- transition(:name="transitionName" appear @after-leave="onAfterLeave")
5
+ transition(:name="transitionName" appear @after-enter="onDetachableAfterEnter" @after-leave="onAfterLeave")
6
6
  .w-tooltip(
7
7
  v-if="detachableVisible"
8
8
  ref="detachable"
@@ -72,6 +72,7 @@ import { focusElement } from '../utils/focus'
72
72
 
73
73
  export default {
74
74
  name: 'w-tree',
75
+ focusable: true,
75
76
  expose: ['focus'],
76
77
 
77
78
  mixins: [RippleMixin],
@@ -2,10 +2,10 @@ import { reactive, inject } from 'vue'
2
2
  import { mergeConfig } from './utils/config'
3
3
  import { consoleWarn } from './utils/console'
4
4
  import { colorPalette, generateColorShades, flattenColors } from './utils/colors'
5
- import { injectColorsCSSInDOM, injectCSSInDOM } from './utils/dynamic-css'
5
+ import { injectColorsCSSInDOM, injectCSSInDOM, generatePaletteVariables, generateColors } 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
+ import { scheduleFocus, registerVFocus, unregisterVFocus } from './utils/focus'
9
9
  import './scss/index.scss'
10
10
 
11
11
  let mounted = false
@@ -88,9 +88,12 @@ export default class WaveUI {
88
88
  * @param {string} theme - The theme to switch to.
89
89
  */
90
90
  switchTheme (theme) {
91
+ // Only remove the current colors stylesheet when the theme actually changes.
92
+ // This prevents a blink when switchTheme is called on first mount with the same theme
93
+ // that was already set during SSR (via getSSRStyles + useHead) or in the constructor.
94
+ if (this.theme !== theme) document.head.querySelector('#wave-ui-colors')?.remove?.()
91
95
  this.theme = theme
92
96
  document.documentElement.setAttribute('data-theme', theme)
93
- document.head.querySelector('#wave-ui-colors')?.remove?.()
94
97
  const themeColors = this.config.colors[this.theme]
95
98
  injectColorsCSSInDOM(themeColors, colorPalette, this.config.css.colorShadeCssVariables)
96
99
  this.colors = flattenColors(themeColors, colorPalette)
@@ -107,13 +110,99 @@ export default class WaveUI {
107
110
  wApp.className = 'w-app' // First reset the classes.
108
111
  if (classes.length && classes[0]) wApp.classList.add(...classes)
109
112
  }
113
+ },
114
+
115
+ /**
116
+ * Returns the CSS strings for both Wave UI stylesheets so they can be injected
117
+ * server-side (e.g. via Nuxt's useHead) to prevent FOUC.
118
+ * The returned strings map to the `#wave-ui-palette` and `#wave-ui-colors` <style> tags.
119
+ *
120
+ * When called without a `theme` argument (recommended), `colors` contains both themes'
121
+ * custom color variables each scoped to `[data-theme="light"]` / `[data-theme="dark"]`.
122
+ * Combined with `WaveUI.getThemeInitScript()` injected as a blocking <script> in <head>,
123
+ * this guarantees the correct colors are present at first paint — no flash of wrong theme.
124
+ *
125
+ * When called with an explicit `theme`, returns only that theme's variables on `:root`
126
+ * (legacy / single-theme use cases).
127
+ *
128
+ * @param {string} [theme] - Explicit theme ('light'|'dark'). Omit for dual-scoped output.
129
+ * @returns {{ theme: string, palette: string, colors: string }}
130
+ */
131
+ getSSRStyles (theme) {
132
+ const palette = generatePaletteVariables(colorPalette)
133
+ const { colorShadeCssVariables } = this.config.css
134
+
135
+ // No theme specified → emit both themes scoped to [data-theme="X"] so a blocking init
136
+ // script only needs to set the attribute — no color flash is possible.
137
+ if (!theme) {
138
+ return {
139
+ theme: this.theme || this.config?.theme || 'light',
140
+ palette,
141
+ colors:
142
+ generateColors(this.config.colors.light, colorShadeCssVariables, '[data-theme="light"]') +
143
+ generateColors(this.config.colors.dark, colorShadeCssVariables, '[data-theme="dark"]')
144
+ }
145
+ }
146
+
147
+ return {
148
+ theme,
149
+ palette,
150
+ colors: generateColors(this.config.colors[theme], colorShadeCssVariables)
151
+ }
110
152
  }
111
153
  }
112
154
 
155
+ /**
156
+ * Resolves the initial theme from localStorage or OS preference.
157
+ * Use this as the `theme` install option so Wave UI is initialized with the correct
158
+ * theme from the very first render — no flash, no manual localStorage reading needed.
159
+ *
160
+ * app.use(WaveUI, { theme: WaveUI.resolveInitialTheme() })
161
+ *
162
+ * On the server (SSR) where localStorage/window are unavailable, always returns 'light'
163
+ * as the safe fallback — pair with `getSSRStyles()` + `getThemeInitScript()` to handle
164
+ * the server → client handoff without FOUC.
165
+ *
166
+ * @param {string} [storageKey='waveui-theme'] - The localStorage key to read.
167
+ * @returns {'light'|'dark'}
168
+ */
169
+ static resolveInitialTheme (storageKey = 'waveui-theme') {
170
+ if (typeof window === 'undefined') return 'light'
171
+ try {
172
+ const stored = localStorage.getItem(storageKey)
173
+ if (stored === 'light' || stored === 'dark') return stored
174
+ }
175
+ catch { }
176
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
177
+ }
178
+
179
+ /**
180
+ * Returns a minified inline script that sets `data-theme` on `<html>` synchronously,
181
+ * before any CSS is parsed or rendered — eliminating theme FOUC entirely.
182
+ *
183
+ * Inject it as a blocking (no async/defer) <script> in <head> before any CSS:
184
+ *
185
+ * Nuxt — nuxt.config.ts:
186
+ * app: { head: { script: [{ innerHTML: WaveUI.getThemeInitScript() }] } }
187
+ *
188
+ * Plain HTML:
189
+ * <script><%= WaveUI.getThemeInitScript() %></script>
190
+ *
191
+ * @param {string} [storageKey='waveui-theme'] - Must match the key used when saving the theme.
192
+ * @returns {string} Minified inline script string (no <script> tags).
193
+ */
194
+ static getThemeInitScript (storageKey = 'waveui-theme') {
195
+ const key = JSON.stringify(storageKey)
196
+ return `(function(){try{var t=localStorage.getItem(${key});if(t==='dark'||t==='light'){document.documentElement.setAttribute('data-theme',t);return}}catch(e){}if(window.matchMedia('(prefers-color-scheme:dark)').matches)document.documentElement.setAttribute('data-theme','dark')})()`
197
+ }
198
+
113
199
  static install (app, options = {}) {
114
200
  // Register directives.
115
201
  app.directive('focus', {
116
- mounted: (el, binding, vnode) => scheduleFocus(vnode, el)
202
+ mounted: (el) => {
203
+ if (!registerVFocus(el)) scheduleFocus(el)
204
+ },
205
+ unmounted: (el) => unregisterVFocus(el)
117
206
  })
118
207
  app.directive('scroll', {
119
208
  mounted: (el, binding) => {
@@ -156,7 +245,12 @@ export default class WaveUI {
156
245
  }
157
246
 
158
247
  if (config.theme === 'auto') detectOSDarkMode($waveui) // Also switches the theme.
159
- else $waveui.switchTheme(config.theme, true)
248
+ else {
249
+ // Respect any data-theme already set on <html> (e.g. by getThemeInitScript) so a
250
+ // blocking init script is enough — no need to pass `theme` to app.use(WaveUI, ...).
251
+ const docTheme = document.documentElement.getAttribute('data-theme')
252
+ $waveui.switchTheme(docTheme === 'light' || docTheme === 'dark' ? docTheme : config.theme)
253
+ }
160
254
 
161
255
  injectCSSInDOM($waveui)
162
256
  injectNotifManagerInDOM(app)
@@ -200,6 +294,7 @@ export default class WaveUI {
200
294
  app.provide('$waveui', $waveui)
201
295
 
202
296
  if (config.theme !== 'auto') {
297
+ this.$waveui.theme = config.theme
203
298
  this.$waveui.colors = flattenColors(config.colors[config.theme], colorPalette)
204
299
  }
205
300
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { consoleWarn } from '../utils/console'
12
+ import { callFocus } from '../utils/focus'
12
13
 
13
14
  // Minimum space (px) from the viewport edge when flipping / nudging.
14
15
  const VIEWPORT_MARGIN = 4
@@ -65,6 +66,10 @@ export default {
65
66
  // Components use this to bind visibility:hidden, so the element is never visible at the
66
67
  // wrong position before its coordinates are calculated.
67
68
  detachableReady: false,
69
+ // Transition @after-enter has run for the current open cycle.
70
+ _contentEntered: false,
71
+ // v-focus entries waiting for detachableReady + _contentEntered.
72
+ _autofocusTargets: [],
68
73
  // The Vue Teleport target. Stored as data (not computed) so it is resolved lazily at
69
74
  // open()-time — after the DOM is committed — rather than during VNode creation where
70
75
  // document.querySelector() may return null for elements that are part of the same render batch.
@@ -171,6 +176,35 @@ export default {
171
176
  onAfterLeave () {
172
177
  this.detachableReady = false
173
178
  this.detachableEl = null
179
+ this._contentEntered = false
180
+ this._autofocusTargets = []
181
+ },
182
+
183
+ onDetachableAfterEnter () {
184
+ this._contentEntered = true
185
+ this._maybeFlushAutofocus()
186
+ },
187
+
188
+ registerAutofocus (entry) {
189
+ if (!this._autofocusTargets) this._autofocusTargets = []
190
+ this._autofocusTargets.push(entry)
191
+ this._maybeFlushAutofocus()
192
+ },
193
+
194
+ unregisterAutofocus (el) {
195
+ if (!this._autofocusTargets?.length) return
196
+ this._autofocusTargets = this._autofocusTargets.filter(entry => entry.el !== el)
197
+ },
198
+
199
+ _maybeFlushAutofocus () {
200
+ if (this.detachableReady && this._contentEntered) this.flushAutofocus()
201
+ },
202
+
203
+ flushAutofocus () {
204
+ const targets = this._autofocusTargets || []
205
+ this._autofocusTargets = []
206
+ if (!targets.length) return
207
+ this.$nextTick(() => targets.forEach(({ el }) => callFocus(el)))
174
208
  },
175
209
 
176
210
  unbindActivatorDocEvents () {
@@ -253,6 +287,7 @@ export default {
253
287
 
254
288
  // Hide before entering the DOM; handles rapid re-opens where detachableReady is still true.
255
289
  this.detachableReady = false
290
+ this._contentEntered = false
256
291
 
257
292
  // Resolve the teleport target here, at open()-time, so the DOM is fully committed and
258
293
  // detachableDefaultRoot() (for nested menus/tooltips) returns the correct element.
@@ -274,8 +309,11 @@ export default {
274
309
  this.activatorWidth = this.activatorEl.offsetWidth
275
310
  }
276
311
 
277
- if (!this.noPosition) this.computeDetachableCoords()
278
- else this.detachableReady = true
312
+ if (!this.noPosition) await this.computeDetachableCoords()
313
+ else {
314
+ this.detachableReady = true
315
+ this._maybeFlushAutofocus()
316
+ }
279
317
 
280
318
  // In `getActivatorCoordinates` accessing the menu computed styles takes a few ms (less than 10ms),
281
319
  // if we don't postpone the Menu apparition it will start transition from a visible menu and
@@ -412,6 +450,7 @@ export default {
412
450
  // Always await $nextTick so coordinates, placement class, and visibility are flushed atomically.
413
451
  this.detachableReady = true
414
452
  await this.$nextTick()
453
+ this._maybeFlushAutofocus()
415
454
 
416
455
  // Guard against the component being unmounted while awaiting.
417
456
  if (!this.detachableEl) return
@@ -1,13 +1,22 @@
1
1
  import { guardFocusable, focusElement } from '../utils/focus'
2
2
 
3
3
  export default {
4
+ focusable: true,
5
+
4
6
  methods: {
5
7
  focus () {
6
8
  if (!guardFocusable(this)) return
7
9
  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]))
10
+ const tryFocus = () => {
11
+ const target = this.$refs[refName]
12
+ if (target) focusElement(target)
13
+ return !!target
14
+ }
15
+ if (tryFocus()) return
16
+ this.$nextTick(() => {
17
+ if (tryFocus()) return
18
+ this.$nextTick(() => tryFocus())
19
+ })
11
20
  }
12
21
  }
13
22
  }
@@ -9,7 +9,7 @@ const cssVars = {
9
9
  let breakpointsDef = { keys: [], values: [] }
10
10
  let currentBreakpoint = null
11
11
 
12
- const generatePaletteVariables = colorPalette => {
12
+ export const generatePaletteVariables = colorPalette => {
13
13
  let cssVariablesString = ''
14
14
 
15
15
  colorPalette.forEach(({ label, color, shades = [] }) => {
@@ -26,7 +26,7 @@ const generatePaletteVariables = colorPalette => {
26
26
  // :root {[color1-variable], [color2-variable]}
27
27
  // .color1--bg {background-color: [color1-variable]}
28
28
  // .color1 {color: [color1-variable]}
29
- const generateColors = (themeColors, generateShadeCssVariables) => {
29
+ export const generateColors = (themeColors, generateShadeCssVariables, scope = ':root') => {
30
30
  let styles = ''
31
31
  let cssVariablesString = ''
32
32
 
@@ -62,7 +62,7 @@ const generateColors = (themeColors, generateShadeCssVariables) => {
62
62
  for (const colorName in shades) cssVariablesString += `--w-${colorName}-color: ${shades[colorName]};`
63
63
  }
64
64
 
65
- return `:root{${cssVariablesString}}${styles}`
65
+ return `${scope}{${cssVariablesString}}${styles}`
66
66
  }
67
67
 
68
68
  // Generate the layout grid. E.g. xs1, xs2, ..., xl12.
@@ -1,5 +1,29 @@
1
1
  import { nextTick } from 'vue'
2
2
 
3
+ const DETACHABLE_COMPONENTS = new Set(['w-menu', 'w-tooltip'])
4
+
5
+ /** Walk Vue 3 logical parent instances from a directive host element (`__vueParentComponent`). */
6
+ function walkParentInstances (el, predicate) {
7
+ let instance = el?.__vueParentComponent
8
+ while (instance) {
9
+ if (predicate(instance)) return instance
10
+ instance = instance.parent
11
+ }
12
+ return null
13
+ }
14
+
15
+ function componentName (instance) {
16
+ return instance?.type?.name ?? instance?.proxy?.$options?.name
17
+ }
18
+
19
+ function isFocusableInstance (instance) {
20
+ return !!(instance?.type?.focusable || instance?.proxy?.$options?.focusable)
21
+ }
22
+
23
+ function isDetachableInstance (instance) {
24
+ return DETACHABLE_COMPONENTS.has(componentName(instance))
25
+ }
26
+
3
27
  export function focusElement (el) {
4
28
  el?.focus?.()
5
29
  }
@@ -9,23 +33,20 @@ export function guardFocusable (vm) {
9
33
  return true
10
34
  }
11
35
 
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
36
+ /** Nearest ancestor component with `focusable: true` (e.g. w-input inside w-form-element). */
37
+ export function resolveFocusableInstance (el) {
38
+ return walkParentInstances(el, isFocusableInstance)
39
+ }
40
+
41
+ /** w-menu / w-tooltip host for deferred v-focus while floating content opens. */
42
+ export function findDetachableInstance (el) {
43
+ const instance = walkParentInstances(el, isDetachableInstance)
44
+ return instance?.proxy ?? null
25
45
  }
26
46
 
27
- export function callFocus (vnode, el) {
28
- const focusFn = resolveFocusFn(vnode, el)
47
+ export function callFocus (el) {
48
+ const instance = resolveFocusableInstance(el)
49
+ const focusFn = instance?.exposed?.focus ?? instance?.proxy?.focus
29
50
  if (typeof focusFn === 'function') {
30
51
  focusFn()
31
52
  return
@@ -34,6 +55,20 @@ export function callFocus (vnode, el) {
34
55
  }
35
56
 
36
57
  /** Schedule focus after mount so template refs exist on the host component. */
37
- export function scheduleFocus (vnode, el) {
38
- nextTick(() => callFocus(vnode, el))
58
+ export function scheduleFocus (el) {
59
+ nextTick(() => callFocus(el))
60
+ }
61
+
62
+ /** Register v-focus on a detachable host; returns false when not inside w-menu/w-tooltip. */
63
+ export function registerVFocus (el) {
64
+ const detachable = findDetachableInstance(el)
65
+ if (!detachable) return false
66
+ el.__waveUiDetachable = detachable
67
+ detachable.registerAutofocus({ el })
68
+ return true
69
+ }
70
+
71
+ export function unregisterVFocus (el) {
72
+ el.__waveUiDetachable?.unregisterAutofocus(el)
73
+ delete el.__waveUiDetachable
39
74
  }