diff --git a/packages/vuetify/package.json b/packages/vuetify/package.json index 0ee3a601a8f..a3bb2985041 100755 --- a/packages/vuetify/package.json +++ b/packages/vuetify/package.json @@ -147,7 +147,7 @@ "lint:fix": "concurrently -n \"tsc,eslint\" \"tsgo -p tsconfig.checks.json --noEmit --pretty\" \"eslint --fix src\"" }, "dependencies": { - "@vuetify/v0": "^1.2.1" + "@vuetify/v0": "^1.2.3" }, "devDependencies": { "@date-io/core": "catalog:", diff --git a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts index 74dfe164486..9855e4c6dcb 100644 --- a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts +++ b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts @@ -1,9 +1,9 @@ // Composables -import { createDisplay } from '../display' +import { createDisplay, DisplaySymbol, useDisplay } from '../display' // Utilities -import { page } from '@test' -import { effectScope } from 'vue' +import { page, render } from '@test' +import { defineComponent, effectScope, h, inject, nextTick } from 'vue' const breakpoints = [ 'xs', @@ -287,6 +287,72 @@ describe('display', () => { await expect.poll(() => name.value).toBe('xs') }) + it('should remove the ssr resize listener when the scope stops', async () => { + const add = vi.spyOn(window, 'addEventListener') + const remove = vi.spyOn(window, 'removeEventListener') + const scope = effectScope() + + try { + const update = scope.run(() => { + return createDisplay(undefined, { clientWidth: 1024, clientHeight: 768 }).update + })! + + update() + await nextTick() + + expect(add).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }) + + scope.stop() + + expect(remove).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }) + } finally { + scope.stop() + add.mockRestore() + remove.mockRestore() + } + }) + + it('should use the same media query for a component mobileBreakpoint as the global mobile flag', async () => { + await page.viewport(1600, 900) + + const original = window.matchMedia.bind(window) + const spy = vi.spyOn(window, 'matchMedia').mockImplementation(query => { + const list = original(query) + + if (query !== '(min-width: 1145px)') return list + + return new Proxy(list, { + get (target, prop, receiver) { + if (prop === 'matches') return false + + const value = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + + try { + let local = false + let global = false + + render(defineComponent({ + setup () { + const display = inject(DisplaySymbol)! + const { mobile } = useDisplay({ mobile: null, mobileBreakpoint: 'lg' }) + local = mobile.value + global = display.mobile.value + + return () => h('div') + }, + })) + + expect(global).toBe(true) + expect(local).toBe(true) + } finally { + spy.mockRestore() + } + }) + it('should allow breakpoint strings for mobileBreakpoint', async () => { const scope = effectScope() const { mobile } = scope.run(() => createDisplay({ mobileBreakpoint: 'lg' }))! diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index 6854832b34d..421c83e7a53 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -1,7 +1,7 @@ // Utilities -import { computed, inject, onScopeDispose, reactive, shallowRef, toRef, toRefs, watchEffect } from 'vue' -import { getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' -import { IN_BROWSER, SUPPORTS_TOUCH } from '@/util/globals' +import { computed, effectScope, inject, shallowRef, toRef } from 'vue' +import { createBreakpoints, getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' +import { IN_BROWSER, SUPPORTS_MATCH_MEDIA, SUPPORTS_TOUCH } from '@/util/globals' // Types import type { InjectionKey, PropType, Ref } from 'vue' @@ -99,18 +99,6 @@ const parseDisplayOptions = (options: DisplayOptions = defaultDisplayOptions) => return mergeDeep(defaultDisplayOptions, options) as InternalDisplayOptions } -function getClientWidth (ssr?: SSROptions) { - return IN_BROWSER && !ssr - ? window.innerWidth - : (isObject(ssr) && ssr.clientWidth) || 0 -} - -function getClientHeight (ssr?: SSROptions) { - return IN_BROWSER && !ssr - ? window.innerHeight - : (isObject(ssr) && ssr.clientHeight) || 0 -} - function getPlatform (ssr?: SSROptions): DisplayPlatform { const userAgent = IN_BROWSER && !ssr ? window.navigator.userAgent @@ -152,70 +140,57 @@ function getPlatform (ssr?: SSROptions): DisplayPlatform { export function createDisplay (options?: DisplayOptions, ssr?: SSROptions): DisplayInstance { const { thresholds, mobileBreakpoint } = parseDisplayOptions(options) - const height = shallowRef(getClientHeight(ssr)) + // `ssr: true` is the boolean form; createBreakpoints only accepts a size. + const ssrSize = isObject(ssr) + ? ssr + : ssr + ? { clientWidth: 0, clientHeight: 0 } + : undefined + + const screen = createBreakpoints({ + breakpoints: thresholds, + mobileBreakpoint, + ssr: ssrSize, + }) + const platform = shallowRef(getPlatform(ssr)) - const state = reactive({} as DisplayInstance) - const width = shallowRef(getClientWidth(ssr)) - function updateSize () { - height.value = getClientHeight() - width.value = getClientWidth() - } + // ssr defers the listener until update(), and that call happens after + // createVuetify's scope has returned. The child scope is still stopped + // with it, so unmount removes the listener. + const resizeScope = ssrSize ? effectScope() : undefined + function update () { - updateSize() + if (resizeScope) resizeScope.run(() => screen.update()) + else screen.update() platform.value = getPlatform() } - // eslint-disable-next-line max-statements - watchEffect(() => { - const xs = width.value < thresholds.sm - const sm = width.value < thresholds.md && !xs - const md = width.value < thresholds.lg && !(sm || xs) - const lg = width.value < thresholds.xl && !(md || sm || xs) - const xl = width.value < thresholds.xxl && !(lg || md || sm || xs) - const xxl = width.value >= thresholds.xxl - const name = - xs ? 'xs' - : sm ? 'sm' - : md ? 'md' - : lg ? 'lg' - : xl ? 'xl' - : 'xxl' - const breakpointValue = isNumber(mobileBreakpoint) ? mobileBreakpoint : thresholds[mobileBreakpoint] - const mobile = width.value < breakpointValue - - state.xs = xs - state.sm = sm - state.md = md - state.lg = lg - state.xl = xl - state.xxl = xxl - state.smAndUp = !xs - state.mdAndUp = !(xs || sm) - state.lgAndUp = !(xs || sm || md) - state.xlAndUp = !(xs || sm || md || lg) - state.smAndDown = !(md || lg || xl || xxl) - state.mdAndDown = !(lg || xl || xxl) - state.lgAndDown = !(xl || xxl) - state.xlAndDown = !xxl - state.name = name - state.height = height.value - state.width = width.value - state.mobile = mobile - state.mobileBreakpoint = mobileBreakpoint - state.platform = platform.value - state.thresholds = thresholds - }) - - if (IN_BROWSER) { - window.addEventListener('resize', updateSize, { passive: true }) - - onScopeDispose(() => { - window.removeEventListener('resize', updateSize) - }, true) - } - - return { ...toRefs(state), update, ssr: !!ssr } + return { + xs: screen.xs, + sm: screen.sm, + md: screen.md, + lg: screen.lg, + xl: screen.xl, + xxl: screen.xxl, + smAndUp: screen.smAndUp, + mdAndUp: screen.mdAndUp, + lgAndUp: screen.lgAndUp, + xlAndUp: screen.xlAndUp, + smAndDown: screen.smAndDown, + mdAndDown: screen.mdAndDown, + lgAndDown: screen.lgAndDown, + xlAndDown: screen.xlAndDown, + name: screen.name, + height: screen.height, + width: screen.width, + mobile: screen.isMobile, + mobileBreakpoint: shallowRef(mobileBreakpoint), + platform, + thresholds: shallowRef(thresholds), + ssr: !!ssr, + update, + } as DisplayInstance } export const makeDisplayProps = propsFactory({ @@ -234,13 +209,24 @@ export function useDisplay ( if (!display) throw new Error('Could not find Vuetify display injection') + function isBelow (px: number, width: number) { + if (SUPPORTS_MATCH_MEDIA) { + return !window.matchMedia(`(min-width: ${px}px)`).matches + } + + return width < px + } + const mobile = computed(() => { + // Reactivity trigger, do not remove. matchMedia() does not subscribe. + const width = display.width.value + if (props.mobile) { return true } else if (isNumber(props.mobileBreakpoint)) { - return display.width.value < props.mobileBreakpoint + return isBelow(props.mobileBreakpoint, width) } else if (props.mobileBreakpoint) { - return display.width.value < display.thresholds.value[props.mobileBreakpoint] + return isBelow(display.thresholds.value[props.mobileBreakpoint], width) } else if (isNull(props.mobile)) { return display.mobile.value } else { diff --git a/packages/vuetify/src/util/v0.ts b/packages/vuetify/src/util/v0.ts index 37da90bea03..7086a9b7380 100644 --- a/packages/vuetify/src/util/v0.ts +++ b/packages/vuetify/src/util/v0.ts @@ -21,7 +21,7 @@ export { export type { IgnoreAccents } from '@vuetify/v0/utilities' -export { createLocale as createV0Locale, createRtl as createV0Rtl } from '@vuetify/v0/composables' +export { createBreakpoints, createLocale as createV0Locale, createRtl as createV0Rtl } from '@vuetify/v0/composables' // Kept under core's existing name — `range` shadows local variables in several // components (VPagination, VRating, VSlider). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4c674d8cf4..03c1b223ffd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,8 +1000,8 @@ importers: packages/vuetify: dependencies: '@vuetify/v0': - specifier: ^1.2.1 - version: 1.2.1(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2)) + specifier: ^1.2.3 + version: 1.2.3(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2)) devDependencies: '@date-io/core': specifier: 'catalog:' @@ -4264,8 +4264,8 @@ packages: resolution: {integrity: sha512-EHJDH+O7eov7yJ3WPR52qELyHq8beQ/GmletheroKRvp4SxTL9IgOEeXqBfw4bOyd49VNH5coyoqvIIlnhavUQ==} engines: {node: '>=18'} - '@vuetify/v0@1.2.1': - resolution: {integrity: sha512-lFBUeMmQiKbFR7JXC1Z74Z2dvvGLCqt/Wd9JQqg4falICTvHtlkulYqaHbtmByGV9eR7cbG1ohN1+7OQLLSPfg==} + '@vuetify/v0@1.2.3': + resolution: {integrity: sha512-8qpYa88KxKrvi4jQcz+Zg4WH04un48Lt7Tj0Qq+LqvbUvHi+H177vWqBn9tQWgpxBVs1RXrTz5zw0Cy6txJHlw==} peerDependencies: '@adobe/leonardo-contrast-colors': '>=1.0.0' '@ant-design/colors': '>=7.0.0' @@ -12704,7 +12704,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@vuetify/v0@1.2.1(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2))': + '@vuetify/v0@1.2.3(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2))': dependencies: vue: 3.5.25(typescript@6.0.2) optionalDependencies: