├── .editorconfig ├── .eslintignore ├── .github ├── FUNDING.yml └── workflows │ └── docs.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.vue │ ├── main.css │ └── main.js ├── tailwind.config.js └── vite.config.js ├── docs ├── .vitepress │ ├── config.mjs │ └── theme │ │ ├── HomeImage.vue │ │ ├── index.js │ │ └── style.css ├── changelog │ ├── v2.1.0.md │ ├── v2.2.0.md │ ├── v2.3.0.md │ ├── v2.3.3.md │ ├── v3.0.0.md │ ├── v3.0.2.md │ ├── v3.0.3.md │ ├── v3.1.0.md │ ├── v3.2.0.md │ └── v4.0.0.md ├── component │ ├── index.md │ ├── props.md │ └── slots.md ├── configure │ ├── dynamic.md │ ├── index.md │ └── presets.md ├── faq.md ├── index.md ├── installation.md ├── migration │ └── from-version-2.md ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ └── images │ │ ├── desktop.png │ │ ├── favicon.png │ │ ├── ipad.png │ │ ├── iphone.png │ │ ├── logo.png │ │ └── macbook.png └── usage.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── component.js ├── composables.js ├── global.d.ts ├── helpers.js ├── index.js ├── plugin.js ├── presets.js ├── store.js └── validation.js ├── tests ├── mock │ ├── MatchMediaMock.js │ └── MediaQueryListMock.js └── unit │ ├── helpers.spec.js │ ├── plugin.spec.js │ ├── store.spec.js │ └── validation.spec.js ├── tsconfig.json └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | demo/* 3 | node_modules 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: craigrileyuk 2 | buymeacoffee: craigrileyuk 3 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress docs to Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 17 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 18 | concurrency: 19 | group: pages 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | # Build job 24 | build: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: pnpm/action-setup@v2 28 | with: 29 | version: 8 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 34 | - name: Setup Node 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version: 20 38 | cache: pnpm # or pnpm / yarn 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v4 41 | - name: Install dependencies 42 | run: pnpm install # or pnpm install / yarn install / bun install 43 | - name: Build with VitePress 44 | run: | 45 | npm run docs:build 46 | touch docs/.vitepress/dist/.nojekyll 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: docs/.vitepress/dist 51 | 52 | # Deployment job 53 | deploy: 54 | environment: 55 | name: github-pages 56 | url: ${{ steps.deployment.outputs.page_url }} 57 | needs: build 58 | runs-on: ubuntu-latest 59 | name: Deploy 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | types 6 | *.local 7 | demo/scss 8 | *.tgz 9 | *.zip 10 | 11 | # VuePress files 12 | docs/.vitepress/.temp/ 13 | docs/.vitepress/cache/ 14 | docs/.vitepress/dist/ 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 4 2 | 3 | - Migrated project to use `type: "module"`. Bumped major as a precaution 4 | - Migrated to ESlint v9 with flat config 5 | - Switched testing library from Jest to Vitest 6 | - Added type builder from JSDoc comments 7 | - **Feature**: New `vuetify3` preset 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2021 Craig Riley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | logo 4 | 5 |

6 | 7 |

8 | Downloads 9 | Version 10 | License 11 |

12 | 13 | Bring fully reactive, responsive design to your Vue 3 project with Vue3-MQ, a plugin which allows your components and pages to adapt to changes in the browser environment. 14 | 15 | - Completely customisable breakpoints 16 | - Includes MQ-Responsive Vue component for unparalleled ease of use 17 | - Access a fully reactive environment analysis object 18 | - Adapt to your users' preference for light or dark themes 19 | - React to changes in screen orientation 20 | - Respect user preference for reduced motion direct in your Vue files 21 | - Choose from a range of breakpoint presets, including Bootstrap 4, Bootstrap 5, Vuetify or Tailwind 22 | - Render on single breakpoints, arrays of breakpoints or ranges 23 | 24 | ## Documentation 25 | 26 | Check out the documentation at [Vue3 MQ: Github Pages](https://craigrileyuk.github.io/vue3-mq/). 27 | 28 | ## License 29 | 30 | [MIT](https://github.com/craigrileyuk/vue3-mq/blob/main/LICENSE) 31 | 32 | ## Sponsor / Donate 33 | 34 | Finding this package useful? Then help keep the coffee flowing. 35 | 36 | [Sponsor Craig Riley on Github](https://github.com/sponsors/craigrileyuk/) 37 | 38 | ## Other Packages by the Author 39 | 40 | - [Tailwind Fluid Typography](https://github.com/craigrileyuk/tailwind-fluid-typography) 41 | - [Laravel Mix Ziggy Watch](https://github.com/craigrileyuk/laravel-mix-ziggy-watch) 42 | - [Vue 3 Snackbar](https://github.com/craigrileyuk/vue3-snackbar) 43 | - [Vue 3 Slide Up Down](https://github.com/craigrileyuk/vue3-slide-up-down) 44 | - [Vue 3 Icon](https://github.com/craigrileyuk/vue3-icon) 45 | 46 | ## Bugs / Support 47 | 48 | Please [open an issue](https://github.com/craigrileyuk/vue3-mq/issues/new) for support. 49 | 50 | ## Thanks 51 | 52 | Thanks to [Alexandre Bonaventure](https://github.com/AlexandreBonaventure/vue-mq) for creating the Vue 2 project that this is based upon. 53 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vue3-MQ: Sandbox 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-mq-demo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "vite", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "vue3-mq": "link:.." 16 | } 17 | } -------------------------------------------------------------------------------- /demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /demo/src/App.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 64 | -------------------------------------------------------------------------------- /demo/src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /demo/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { Vue3Mq } from "vue3-mq"; 3 | 4 | import App from "./App.vue"; 5 | import "./main.css"; 6 | 7 | const app = createApp(App); 8 | app.use(Vue3Mq, { 9 | global: true, 10 | }); 11 | 12 | app.mount("#app"); 13 | -------------------------------------------------------------------------------- /demo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /demo/vite.config.js: -------------------------------------------------------------------------------- 1 | import vue from "@vitejs/plugin-vue"; 2 | 3 | export default { 4 | plugins: [vue()], 5 | }; 6 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | import fg from "fast-glob"; 3 | 4 | const changelogs = fg.sync(["./changelog/*.md"]); 5 | 6 | // https://vitepress.dev/reference/site-config 7 | export default defineConfig({ 8 | title: "Vue3 MQ", 9 | base: "/vue3-mq/", 10 | description: 11 | "Bring fully reactive, responsive design to your Vue 3 project with Vue3-MQ, a plugin which allows your components and pages to adapt and react to changes in the browser environment.", 12 | head: [["link", { rel: "icon", href: "/images/favicon.png" }]], 13 | themeConfig: { 14 | logo: "/images/logo.png", 15 | siteTitle: false, 16 | // https://vitepress.dev/reference/default-theme-config 17 | nav: [ 18 | { 19 | text: "Changelog", 20 | items: changelogs 21 | .map((log) => ({ 22 | text: log 23 | .match( 24 | /(?:\.\/changelog\/)(v[A-Za-z0-9.]+)(?:\.md)/ 25 | ) 26 | .pop(), 27 | link: "/" + log, 28 | })) 29 | .reverse(), 30 | }, 31 | { text: "FAQ", link: "/faq" }, 32 | ], 33 | 34 | sidebar: [ 35 | { 36 | text: "Installation", 37 | link: "/installation", 38 | }, 39 | { 40 | text: "Usage", 41 | link: "/usage", 42 | }, 43 | { 44 | text: "Component", 45 | link: "/component/", 46 | items: [ 47 | { text: "Props", link: "/component/props" }, 48 | { text: "Slots", link: "/component/slots" }, 49 | ], 50 | }, 51 | { 52 | text: "Configure", 53 | link: "/configure/", 54 | items: [ 55 | { text: "Presets", link: "/configure/presets" }, 56 | { text: "Dynamic", link: "/configure/dynamic" }, 57 | ], 58 | }, 59 | { 60 | text: "Frequently Asked Questions", 61 | link: "/faq", 62 | }, 63 | ], 64 | socialLinks: [ 65 | { 66 | icon: "github", 67 | link: "https://github.com/craigrileyuk/vue3-mq", 68 | }, 69 | { 70 | icon: "npm", 71 | link: "https://www.npmjs.com/package/vue3-mq", 72 | }, 73 | ], 74 | footer: { 75 | message: "Released under the MIT License.", 76 | copyright: 77 | "Copyright © 2022-2024 Craig Riley, forked from the vue-mq project by Alexandre Bonaventure", 78 | }, 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/HomeImage.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import { h } from "vue"; 3 | import HomeImage from "./HomeImage.vue"; 4 | import DefaultTheme from "vitepress/theme"; 5 | import "./style.css"; 6 | import { Vue3Mq } from "../../../src"; 7 | 8 | /** @type {import('vitepress').Theme} */ 9 | export default { 10 | extends: DefaultTheme, 11 | Layout: () => { 12 | return h(DefaultTheme.Layout, null, { 13 | // https://vitepress.dev/guide/extending-default-theme#layout-slots 14 | "home-hero-image": () => h(HomeImage), 15 | }); 16 | }, 17 | enhanceApp({ app, router, siteData }) { 18 | app.use(Vue3Mq); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * 9 | * Each colors have exact same color scale system with 3 levels of solid 10 | * colors with different brightness, and 1 soft color. 11 | * 12 | * - `XXX-1`: The most solid color used mainly for colored text. It must 13 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 14 | * 15 | * - `XXX-2`: The color used mainly for hover state of the button. 16 | * 17 | * - `XXX-3`: The color for solid background, such as bg color of the button. 18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 19 | * top of it. 20 | * 21 | * - `XXX-soft`: The color used for subtle background such as custom container 22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 23 | * on top of it. 24 | * 25 | * The soft color must be semi transparent alpha channel. This is crucial 26 | * because it allows adding multiple "soft" colors on top of each other 27 | * to create a accent, such as when having inline code block inside 28 | * custom containers. 29 | * 30 | * - `default`: The color used purely for subtle indication without any 31 | * special meanings attched to it such as bg color for menu hover state. 32 | * 33 | * - `brand`: Used for primary brand colors, such as link text, button with 34 | * brand theme, etc. 35 | * 36 | * - `tip`: Used to indicate useful information. The default theme uses the 37 | * brand color for this by default. 38 | * 39 | * - `warning`: Used to indicate warning to the users. Used in custom 40 | * container, badges, etc. 41 | * 42 | * - `danger`: Used to show error, or dangerous message to the users. Used 43 | * in custom container, badges, etc. 44 | * -------------------------------------------------------------------------- */ 45 | 46 | :root { 47 | --vp-c-default-1: var(--vp-c-gray-1); 48 | --vp-c-default-2: var(--vp-c-gray-2); 49 | --vp-c-default-3: var(--vp-c-gray-3); 50 | --vp-c-default-soft: var(--vp-c-gray-soft); 51 | 52 | --vp-c-brand-1: #065f46; 53 | --vp-c-brand-2: #047857; 54 | --vp-c-brand-3: #10b981; 55 | --vp-c-brand-soft: #a7f3d0; 56 | 57 | --vp-c-tip-1: var(--vp-c-brand-1); 58 | --vp-c-tip-2: var(--vp-c-brand-2); 59 | --vp-c-tip-3: var(--vp-c-brand-3); 60 | --vp-c-tip-soft: var(--vp-c-brand-soft); 61 | 62 | --vp-c-warning-1: var(--vp-c-yellow-1); 63 | --vp-c-warning-2: var(--vp-c-yellow-2); 64 | --vp-c-warning-3: var(--vp-c-yellow-3); 65 | --vp-c-warning-soft: var(--vp-c-yellow-soft); 66 | 67 | --vp-c-danger-1: var(--vp-c-red-1); 68 | --vp-c-danger-2: var(--vp-c-red-2); 69 | --vp-c-danger-3: var(--vp-c-red-3); 70 | --vp-c-danger-soft: var(--vp-c-red-soft); 71 | } 72 | 73 | html.dark { 74 | --vp-c-brand-1: #6ee7b7; 75 | --vp-c-brand-soft: #064e3b; 76 | } 77 | 78 | /** 79 | * Component: Button 80 | * -------------------------------------------------------------------------- */ 81 | 82 | :root { 83 | --vp-button-brand-border: transparent; 84 | --vp-button-brand-text: var(--vp-c-white); 85 | --vp-button-brand-bg: var(--vp-c-brand-3); 86 | --vp-button-brand-hover-border: transparent; 87 | --vp-button-brand-hover-text: var(--vp-c-white); 88 | --vp-button-brand-hover-bg: var(--vp-c-brand-2); 89 | --vp-button-brand-active-border: transparent; 90 | --vp-button-brand-active-text: var(--vp-c-white); 91 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 92 | } 93 | 94 | /** 95 | * Component: Home 96 | * -------------------------------------------------------------------------- */ 97 | 98 | :root { 99 | --vp-home-hero-name-color: transparent; 100 | --vp-home-hero-name-background: -webkit-linear-gradient( 101 | 120deg, 102 | #44c38a 30%, 103 | #047857 104 | ); 105 | 106 | --vp-home-hero-image-background-image: none; 107 | --vp-home-hero-image-filter: blur(44px); 108 | } 109 | 110 | @media (min-width: 640px) { 111 | :root { 112 | --vp-home-hero-image-filter: blur(56px); 113 | } 114 | } 115 | 116 | @media (min-width: 960px) { 117 | :root { 118 | --vp-home-hero-image-filter: blur(68px); 119 | } 120 | } 121 | 122 | /** 123 | * Component: Custom Block 124 | * -------------------------------------------------------------------------- */ 125 | 126 | :root { 127 | --vp-custom-block-tip-border: transparent; 128 | --vp-custom-block-tip-text: var(--vp-c-text-1); 129 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft); 130 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); 131 | } 132 | 133 | /** 134 | * Component: Algolia 135 | * -------------------------------------------------------------------------- */ 136 | 137 | .DocSearch { 138 | --docsearch-primary-color: var(--vp-c-brand-1) !important; 139 | } 140 | 141 | .block { 142 | display: block; 143 | } 144 | -------------------------------------------------------------------------------- /docs/changelog/v2.1.0.md: -------------------------------------------------------------------------------- 1 | # v2.1.0: Update Breakpoints 2 | 3 | An update function is now provided by the plugin which allows components to dynamically change the breakpoints that are responded to. -------------------------------------------------------------------------------- /docs/changelog/v2.2.0.md: -------------------------------------------------------------------------------- 1 | # v2.2.0: New `` Minus and Range Prop Selectors 2 | 3 | When using the `` component within your Vue 3 app, you'll now be able to use 2 additional selector types: 4 | 5 | - Minus: `mq="lg-"` - Will render on lg or below breakpoints... NEW 6 | - Range: `mq="xs-md"` - Will render on any screen sizes between xs and md... NEW 7 | - Plus: `mq="md+"` - Renders on any screen sizes above md 8 | - Single: `mq="lg"` - Only renders on lg screen sizes 9 | - Array: `:mq="['xs','sm','xl']"` - Renders on xs, sm and xl screen sizes 10 | -------------------------------------------------------------------------------- /docs/changelog/v2.3.0.md: -------------------------------------------------------------------------------- 1 | # v2.3.0 Breakpoint slots for the `MqLayout` component 2 | 3 | In addition to the mq="sm" property on the component, you can now also use breakpoint slots which can combine with Transition Groups for more powerful and complex renderings. 4 | 5 | ```vue 6 | 7 | 8 | 9 | 10 | 11 | 12 | ``` -------------------------------------------------------------------------------- /docs/changelog/v2.3.3.md: -------------------------------------------------------------------------------- 1 | # v2.3.3 Bug Fixes 2 | 3 | - Fixed issue with the mq prop when using a minus modifier 4 | - Added tests for store.js for shouldRender 5 | -------------------------------------------------------------------------------- /docs/changelog/v3.0.0.md: -------------------------------------------------------------------------------- 1 | # v3.0.0: The Reimagining 2 | 3 | Vue 3 MQ was initially a fork of [Alexandre Bonaventure's](https://alexandrebonaventure.github.io/vue-mq) Vue MQ project with a few coding changes to help it to work with Vue 3 complete with a couple of extra features sprinkled on top. 4 | 5 | Version 3 is a complete reimagining of the plugin. The code has been re-written from the group up to ensure it takes full advantage of the power of Vue 3 and offers a simple, yet powerful way to respond to vast number of environments your application has to operate in. 6 | 7 | See our [v2-3 migration guide for full details](/migration/from-version-2.md), but key changes are: 8 | 9 | - Global objects and components have been dropped in favour of imports/injections. This helps your app's size and performance by reducing overhead where it's not needed. 10 | - Support for detecting orientation ( landscape / portrait ) and theme ( dark / light) 11 | - The helper is now and the mq prop has become target. 12 | - mq is no longer a responsive value, but a full-blown reactive object. Quickly react to breakpoints, orientation and theme with a wide range of boolean values. Rendering on a mobile could be as simple as `if (mq.mdMinus) { ... }` 13 | - Choose from common presets (Bootstrap, Vuetify, Tailwind) to quickly get Vue3 MQ up and running with your UI of choice. 14 | - Full support for both Options API and Composition API builds of Vue3. 15 | -------------------------------------------------------------------------------- /docs/changelog/v3.0.2.md: -------------------------------------------------------------------------------- 1 | # Version 3.0.2 2 | 3 | Cleaned up production dependencies and fixed some minor typos. -------------------------------------------------------------------------------- /docs/changelog/v3.0.3.md: -------------------------------------------------------------------------------- 1 | # Version 3.0.3 2 | 3 | - Added Wordpress preset for breakpoints, which provides the following settings: 4 | 5 | ```js 6 | app.use(Vue3Mq, { 7 | preset: 'wordpress' 8 | }) 9 | ``` 10 | 11 | | Name | Minimum width | 12 | | ------- | ------------- | 13 | | mobile | 0 | 14 | | small | 600 | 15 | | medium | 782 | 16 | | large | 960 | 17 | | xlarge | 1080 | 18 | | wide | 1280 | 19 | | huge | 1440 | -------------------------------------------------------------------------------- /docs/changelog/v3.1.0.md: -------------------------------------------------------------------------------- 1 | # Version 3.1.0 2 | 3 | ## Prefers Reduced Motion support added 4 | 5 | - Added props of `inert` and `motion` for rendering according to `(prefers-reduced-motion)` @media query. `inert` will render only when preference is `reduce` whereas `motion` will render only when `no-preference` is given or assumed. 6 | - `inert` and `motion` are also available as slot names when used inside a `` element. 7 | - For SSR support, `defaultMotion` can be passed in the Vue3Mq configuration object with a value of either `reduce` or `no-preference` with the latter being default. 8 | - `motionPreference`, `isMotion` and `isInert` properties added to MQ object. 9 | 10 | ```html 11 | 12 | This user prefers reduced motion 13 | 14 | ``` -------------------------------------------------------------------------------- /docs/changelog/v3.2.0.md: -------------------------------------------------------------------------------- 1 | # Version 3.2.0 2 | 3 | ## New `global` option for global installation 4 | 5 | You can now use the `global` config option for the plugin to install the `` component and the `$mq` object globally in your application. 6 | 7 | ```js 8 | import { createApp } from "vue"; 9 | import { Vue3Mq } from "vue3-mq"; 10 | 11 | const app = createApp(); 12 | app.use(Vue3Mq, { 13 | global: true, 14 | }); 15 | 16 | app.mount("#app"); 17 | ``` 18 | 19 | Then in your templates: 20 | 21 | ```vue 22 | 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/changelog/v4.0.0.md: -------------------------------------------------------------------------------- 1 | # Version 4.0.0 2 | 3 | ## New `vuetify3` preset available 4 | 5 | The Vuetify 3 breakpoints are now available as a preset for Vue3 MQ. These feature tweaked large and x-large breakpoints as well as a new xx-large breakpoint. For more information, see [Vuetify 3: Breakpoints and Thresholds](https://vuetifyjs.com/en/features/display-and-platform/#breakpoints-and-thresholds) 6 | 7 | ```js 8 | import { createApp } from "vue"; 9 | import { Vue3Mq } from "vue3-mq"; 10 | 11 | const app = createApp(); 12 | app.use(Vue3Mq, { 13 | preset: "vuetify3", 14 | }); 15 | 16 | app.mount("#app"); 17 | ``` 18 | 19 | ## BugFix: Fix MqResponsive is not referenced error 20 | 21 | Thanks to https://github.com/rfostii for the fix 22 | 23 | ## A Modern Stack 24 | 25 | - The project has been upgrade to ESM-first which meant up bumping to v4 as a precaution 26 | - We're also trying out building some types for the library courtesy of JSDoc 27 | - Jest has been dropped in favour of Vitest 28 | - Eslint 9's flat config is now used 29 | -------------------------------------------------------------------------------- /docs/component/index.md: -------------------------------------------------------------------------------- 1 | # MQ-Responsive Component 2 | 3 | For convenience, an `` component is available for use in your app. You can import it like so: 4 | 5 | ::: code-group 6 | 7 | ```vue [Composition API] 8 | 11 | ``` 12 | 13 | ```vue [Options API] 14 | 23 | ``` 24 | 25 | ::: 26 | -------------------------------------------------------------------------------- /docs/component/props.md: -------------------------------------------------------------------------------- 1 | # MQ Responsive Props 2 | 3 | ## Target 4 | 5 | Content inside the component's slot will render when the current breakpoint matches the target 6 | 7 | ::: tip 8 | This prop accepts either a String or an Array 9 | ::: 10 | 11 | ##### Single breakpoint 12 | ```vue 13 | 18 | ``` 19 | 20 | ##### Breakpoint plus range 21 | ```vue 22 | 27 | ``` 28 | 29 | ##### Breakpoint minus range 30 | ```vue 31 | 36 | ``` 37 | 38 | ##### Breakpoint range 39 | ```vue 40 | 45 | ``` 46 | 47 | ##### Breakpoint array 48 | ```vue 49 | 54 | ``` 55 | 56 | ## Landscape 57 | 58 | Will only render when the screen is in landscape mode (i.e. its width is greater than its height) 59 | 60 | ::: tip 61 | This prop accepts a Boolean 62 | ::: 63 | 64 | ```vue 65 | 70 | ``` 71 | 72 | ## Portrait 73 | 74 | Will render only when the screen is in portrait mode (i.e. its height is greater than its width) 75 | 76 | ::: tip 77 | This prop accepts a Boolean 78 | ::: 79 | 80 | ```vue 81 | 86 | ``` 87 | 88 | ## Dark 89 | 90 | Uses the OS/browser-provided `prefers-color-scheme` media query to render only when dark mode is preferred. 91 | 92 | ::: tip 93 | This prop accepts a Boolean 94 | ::: 95 | 96 | ```vue 97 | 102 | ``` 103 | 104 | ## Light 105 | 106 | Uses the OS/browser-provided `prefers-color-scheme` media query to render only when light mode is preferred. 107 | 108 | ::: tip 109 | This prop accepts a Boolean 110 | ::: 111 | 112 | ```vue 113 | 118 | ``` 119 | 120 | ## Inert 121 | 122 | Uses the OS/browser-provided `prefers-reduced-motion` media query to render only when reduced motion is preferred. 123 | 124 | ::: tip 125 | This prop accepts a Boolean 126 | ::: 127 | 128 | ```vue 129 | 134 | ``` 135 | 136 | ## Motion 137 | 138 | Uses the OS/browser-provided `prefers-reduced-motion` media query to render only when no motion preference is stated. 139 | 140 | ::: tip 141 | This prop accepts a Boolean 142 | ::: 143 | 144 | ```vue 145 | 150 | ``` 151 | 152 | ## Tag 153 | 154 | Sets the HTML tag to be used to wrap the contained content 155 | 156 | ::: tip 157 | This prop accepts a String 158 | 159 | Default: `div` 160 | ::: 161 | 162 | ```vue 163 | 168 | ``` 169 | 170 | ## Group 171 | 172 | Creates a transition group which allows advanced use of list [rendering slots](./slots.md). 173 | 174 | Since this uses Vue's `TransitionGroup`, it will pass through any props to that component. See the [Vue3 API Reference](https://v3.vuejs.org/api/built-in-components.html#component) for more details. 175 | 176 | ::: tip 177 | This prop accepts a Boolean 178 | ::: 179 | 180 | ```vue 181 | 186 | ``` 187 | 188 | ## List Tag 189 | 190 | ::: tip 191 | This prop accepts a String 192 | ::: 193 | 194 | ```vue 195 | 204 | ``` -------------------------------------------------------------------------------- /docs/component/slots.md: -------------------------------------------------------------------------------- 1 | # MqResponsive Group slots 2 | 3 | ::: tip 4 | Group slots become available when `` has the `group` prop applied. 5 | ::: 6 | 7 | When using an MqResponsive group, you can create complex lists which can factor in breakpoints, orientation or theme to decide whether to render the slot's content. 8 | 9 | Slot names are created dynamically based upon your provided breakpoints. 10 | 11 | ```vue 12 | 27 | ``` 28 | 29 | ## Chaining props 30 | 31 | You can chain props onto your slot names by using the `:` separator. 32 | 33 | ```vue 34 | 41 | ``` 42 | 43 | ## Multiple slots on same breakpoint 44 | 45 | Since Vue doesn't allow multiple slots with the same name, we can workaround this by adding a number as a suffix 46 | 47 | ```vue 48 | 55 | ``` 56 | 57 | -------------------------------------------------------------------------------- /docs/configure/dynamic.md: -------------------------------------------------------------------------------- 1 | # Dynamic Breakpoints 2 | 3 | Need to change your breakpoint presets on the go? Then good news everyone, Vue3 Mq supports dynamically updating your application's breakpoints. 4 | 5 | Simply import or inject the provided function and pass your new configuration object. 6 | 7 | ::: code-group 8 | 9 | ```vue [Composition API] 10 | 24 | ``` 25 | 26 | ```vue [Options API] 27 | 41 | ``` 42 | 43 | ::: 44 | 45 | Or you can switch to any of the [breakpoint presets](/configure/presets.md) available. 46 | -------------------------------------------------------------------------------- /docs/configure/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuring the Plugin 3 | --- 4 | # Configuring Vue3-Mq 5 | 6 | When installing Vue3-Mq, you can pass a config object with any of the options below: 7 | 8 | ```js 9 | app.use(Vue3Mq, { 10 | // config options here 11 | }) 12 | ``` 13 | 14 | | Name | Type | Default | Description | 15 | | ------------------ | ------- | ------------ | ----------------------------------------------------------------------------------------------- | 16 | | preset | String | "bootstrap5" | Use breakpoints preset. Options are: bootstrap3, bootstrap4, vuetify, tailwind or devices. | 17 | | breakpoints | Object | null | Custom breakpoints config. Object keys and values = breakpoint name and min-width respectively. | 18 | | defaultBreakpoint | String | null | Screen breakpoint to use before browser window is available (i.e. in SSR) | 19 | | defaultOrientation | String | null | Screen orientation to use before browser window is available. [`landscape` or `portrait`] | 20 | | defaultTheme | String | null | OS / browser theme to use before browser window is available. [`light` or `dark`] | 21 | | defaultMotion | String | null | Motion preference to assume before browser window is available. [`reduce` or `no-preference`] | -------------------------------------------------------------------------------- /docs/configure/presets.md: -------------------------------------------------------------------------------- 1 | # Available presets 2 | 3 | Under the `preset` option, you can pass any of the following: 4 | 5 | ## Bootstrap 5 (default) 6 | 7 | ```js 8 | app.use(Vue3Mq, { 9 | preset: "bootstrap5", 10 | }); 11 | ``` 12 | 13 | | Name | Minimum width | 14 | | ---- | ------------- | 15 | | xs | 0 | 16 | | sm | 576 | 17 | | md | 768 | 18 | | lg | 992 | 19 | | xl | 1200 | 20 | | xxl | 1400 | 21 | 22 | ## Tailwind 23 | 24 | ```js 25 | app.use(Vue3Mq, { 26 | preset: "tailwind", 27 | }); 28 | ``` 29 | 30 | | Name | Minimum width | 31 | | ---- | ------------- | 32 | | xs | 0 | 33 | | sm | 640 | 34 | | md | 768 | 35 | | lg | 1024 | 36 | | xl | 1280 | 37 | | xxl | 1536 | 38 | 39 | ## Vuetify 3 40 | 41 | ```js 42 | app.use(Vue3Mq, { 43 | preset: "vuetify3", 44 | }); 45 | ``` 46 | 47 | | Name | Minimum width | 48 | | ---- | ------------- | 49 | | xs | 0 | 50 | | sm | 600 | 51 | | md | 960 | 52 | | lg | 1280 | 53 | | xl | 1920 | 54 | | xxl | 2560 | 55 | 56 | ## Vuetify 57 | 58 | ```js 59 | app.use(Vue3Mq, { 60 | preset: "vuetify", 61 | }); 62 | ``` 63 | 64 | | Name | Minimum width | 65 | | ---- | ------------- | 66 | | xs | 0 | 67 | | sm | 600 | 68 | | md | 960 | 69 | | lg | 1264 | 70 | | xl | 1904 | 71 | 72 | ## Devices 73 | 74 | ```js 75 | app.use(Vue3Mq, { 76 | preset: "devices", 77 | }); 78 | ``` 79 | 80 | | Name | Minimum width | 81 | | ------- | ------------- | 82 | | phone | 0 | 83 | | tablet | 768 | 84 | | laptop | 1370 | 85 | | desktop | 1906 | 86 | 87 | ## Wordpress 88 | 89 | ```js 90 | app.use(Vue3Mq, { 91 | preset: "wordpress", 92 | }); 93 | ``` 94 | 95 | | Name | Minimum width | 96 | | ------ | ------------- | 97 | | mobile | 0 | 98 | | small | 600 | 99 | | medium | 782 | 100 | | large | 960 | 101 | | xlarge | 1080 | 102 | | wide | 1280 | 103 | | huge | 1440 | 104 | 105 | ## Bootstrap 4 106 | 107 | ```js 108 | app.use(Vue3Mq, { 109 | preset: "bootstrap4", 110 | }); 111 | ``` 112 | 113 | | Name | Minimum width | 114 | | ---- | ------------- | 115 | | xs | 0 | 116 | | sm | 576 | 117 | | md | 768 | 118 | | lg | 992 | 119 | | xl | 1200 | 120 | 121 | ## Bootstrap 3 122 | 123 | ```js 124 | app.use(Vue3Mq, { 125 | preset: "bootstrap3", 126 | }); 127 | ``` 128 | 129 | | Name | Minimum width | 130 | | ---- | ------------- | 131 | | xs | 0 | 132 | | sm | 768 | 133 | | md | 992 | 134 | | lg | 1200 | 135 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Does this package support Vue 2? 4 | 5 | No, and it never will. Vue3-Mq was based on the excellent [Vue-Mq](https://github.com/AlexandreBonaventure/vue-mq) project by [Alexandre Bonaventure](https://alexandrebonaventure.github.io/vue-mq), who's got your Vue 2 needs covered. 6 | 7 | ## Any plans for Typescript support? 8 | 9 | I don't currently work with Typescript (it's on the to-do list, or maybe the to-do list is in Typescript). But if anyone feels compelled to add them, I'll happily accept a pull request on the project's [Github](https://github.com/craigrileyuk/vue3-mq) page. 10 | 11 | ## What can I use for my breakpoint keys? 12 | 13 | Breakpoint keys must start with a letter and contain only alphanumeric characters and underscores `[a-zA-Z0-9_]`. No hyphens are allowed since these are used to denote breakpoint ranges. 14 | 15 | ## I'm getting a hydration warning on SSR... 16 | 17 | It's a known issue when the provided `defaultBreakpoint` differs from the actual breakpoint on the client's device. At the moment, the only workaround is to wait until the app is mounted before initiating the changeover. However, this would result in a flash of alternate content and it only really does what Vue's internal engine handles automatically. 18 | 19 | Awaiting a better solution, if one can be found. 20 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Vue3 MQ" 7 | text: "Plugin for Vue3" 8 | tagline: Bring fully reactive, responsive design to your Vue 3 project with Vue3-MQ, a plugin which allows your components and pages to adapt and react to changes in the browser environment. 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /installation 13 | - theme: alt 14 | text: Sponsor 15 | link: https://github.com/sponsors/craigrileyuk/ 16 | 17 | features: 18 | - title: Reactive Object 19 | details: Respond to changes in browser environment in real-time: be it screen dimensions; dark or light theming; motion preferences; or screen orientation 20 | - title: Component 21 | details: Easy-to-use Vue component included with built in support for transitions and dynamic slots to allow you to switch content out in an instant 22 | - title: Composition & Options API Support 23 | details: Use composables in your setup functions, or simply inject the objects/functions you need if you prefer the Options API 24 | --- 25 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | --- 4 | 5 | # Install Vue3-MQ using a package manager 6 | 7 | To add Vue3-Mq to your Vue 3 app, first install it using your chosen package manager. 8 | 9 | ::: code-group 10 | 11 | ```bash [NPM] 12 | npm install vue3-mq 13 | ``` 14 | 15 | ```bash [YARN] 16 | yarn add vue3-mq 17 | ``` 18 | 19 | ```bash [PNPM] 20 | pnpm add vue3-mq 21 | ``` 22 | 23 | ::: 24 | 25 | ## Standard Installation 26 | 27 | Now add the plugin to your app entry file 28 | 29 | ```js 30 | import { createApp } from "vue"; 31 | import { Vue3Mq } from "vue3-mq"; 32 | const app = createApp({}); 33 | app.use(Vue3Mq); 34 | app.mount("#app"); 35 | ``` 36 | 37 | ## Global Installation 38 | 39 | You can now use the `global` config option for the plugin to install the `` component and the `$mq` object globally in your application. 40 | 41 | ```js 42 | import { createApp } from "vue"; 43 | import { Vue3Mq } from "vue3-mq"; 44 | 45 | const app = createApp(); 46 | app.use(Vue3Mq, { 47 | global: true, 48 | }); 49 | 50 | app.mount("#app"); 51 | ``` 52 | 53 | Then in your templates: 54 | 55 | ```vue 56 | 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/migration/from-version-2.md: -------------------------------------------------------------------------------- 1 | # Migrating from Vue3 Mq version 2 to 3 2 | 3 | Vue3 MQ version 3 has been rewritten from the ground up to provide a plugin that is custom designed for both Options API and Composition API builds of Vue 3 and in line with best practices. 4 | 5 | As such, there've been a fair few changes around here. 6 | 7 | ## Installation Method 8 | 9 | There is no longer a default export from the vue3-mq package. All exports are now named. Therefore, the installation process requires using destructured imports: 10 | 11 | ```js 12 | import { createApp } from "vue"; 13 | import { Vue3Mq } from "vue3-mq"; 14 | 15 | const app = createApp(); 16 | app.use(Vue3Mq); 17 | 18 | app.mount("#app"); 19 | ``` 20 | 21 | ## Now Using Minimum Widths for Breakpoints 22 | 23 | Customising your breakpoints in version 2 required setting your breakpoints using a key and the maximum width (in px) that the screen could be in order to be counted as that breakpoint. This tended to be be pretty confusing given it required the use of something along the lines of a `{ xxl: Infinity }` breakpoint. This was also something of an anti-pattern when compared to setting up common CSS frameworks since it was not "mobile-first". 24 | 25 | Version 3 requires using the **minimum** width you want a breakpoint to become active on. For example, a custom setup might go like so: 26 | 27 | ```js 28 | app.use(Vue3Mq, { 29 | breakpoints: { 30 | xs: 0, 31 | sm: 600, 32 | md: 960, 33 | lg: 1264, 34 | xl: 1904, 35 | } 36 | } 37 | ``` 38 | 39 | This means that from a screen width of 0-599, the `xs` breakpoint is active. From 600-959, the `sm` breakpoint is active and so on. The `xl` breakpoint will be active from 1904 pixels wide and above. 40 | 41 | You should **always** have a breakpoint of `0` when declaring custom breakpoints. 42 | 43 | ## Removal of Global Properties and Functions 44 | 45 | Because of the performance and build-size benefits of tree-shaking, the Vue 3 ecosystem is increasingly embracing the practice of using local imports rather than global functions and properties. As such, $mq has been removed as a global property and must now be injected into components that require it. 46 | 47 | ::: code-group 48 | 49 | ```vue [Composition API] 50 | 57 | ``` 58 | 59 | ```vue [Options API] 60 | 70 | ``` 71 | 72 | ::: 73 | 74 | The same goes for the `updateBreakpoints` function. This should also be injected or imported into your component. 75 | 76 | ::: code-group 77 | 78 | ```vue [Composition API] 79 | 86 | ``` 87 | 88 | ```vue [Options API] 89 | 99 | ``` 100 | 101 | ::: 102 | 103 | ## Component `` Becomes `` and Goes Local 104 | 105 | The Vue3 Mq helper component has changed name from `` to `` to better reflect its function. 106 | 107 | Additionally, the prop `mq` – which designated the breakpoints on which the contents should render – is now called `target` to better fit alongside the new orientation / theme functionality. 108 | 109 | ```vue 110 | 111 | This will render on "md" and above screens 112 | 113 | ``` 114 | 115 | This helper component won't automatically be available globally in version 3. You must either make it a global component yourself, or import it locally when you want to use it inside a Vue component/application. 116 | 117 | ::: code-group 118 | 119 | ```vue [Local Import] 120 | 129 | ``` 130 | 131 | ```js [Global Installation] 132 | import { createApp } from "vue"; 133 | import { Vue3Mq, MqResponsive } from "vue3-mq"; 134 | 135 | const app = createApp(); 136 | app.use(Vue3Mq); 137 | app.component("mq-responsive", MqResponsive); 138 | 139 | app.mount("#app"); 140 | ``` 141 | 142 | ::: 143 | 144 | ## MQ Changes to a Reactive Object 145 | 146 | In version 2, calling `this.$mq` simply told you the current breakpoint. In version 3, injecting `mq` gives you access to a far more powerful reactive object that can instantly tell your app information about the browser environment. New boolean properties allow you to easily make calculations using the likes of `mq.lgPlus` or `mq.mdMinus`. 147 | 148 | This is the current reactive data for your browser window based on the Bootstrap 5 preset: 149 | 150 | 151 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "dev": "vitepress dev", 5 | "build": "vitepress build", 6 | "preview": "vitepress preview" 7 | }, 8 | "devDependencies": { 9 | "fast-glob": "^3.3.2", 10 | "vitepress": "^1.3.4", 11 | "vue": "^3.5.6" 12 | } 13 | } -------------------------------------------------------------------------------- /docs/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | vue: 9 | specifier: ^3.4.15 10 | version: 3.4.15 11 | 12 | packages: 13 | 14 | /@babel/helper-string-parser@7.23.4: 15 | resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} 16 | engines: {node: '>=6.9.0'} 17 | dev: false 18 | 19 | /@babel/helper-validator-identifier@7.22.20: 20 | resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} 21 | engines: {node: '>=6.9.0'} 22 | dev: false 23 | 24 | /@babel/parser@7.23.9: 25 | resolution: {integrity: sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==} 26 | engines: {node: '>=6.0.0'} 27 | hasBin: true 28 | dependencies: 29 | '@babel/types': 7.23.9 30 | dev: false 31 | 32 | /@babel/types@7.23.9: 33 | resolution: {integrity: sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==} 34 | engines: {node: '>=6.9.0'} 35 | dependencies: 36 | '@babel/helper-string-parser': 7.23.4 37 | '@babel/helper-validator-identifier': 7.22.20 38 | to-fast-properties: 2.0.0 39 | dev: false 40 | 41 | /@jridgewell/sourcemap-codec@1.4.15: 42 | resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} 43 | dev: false 44 | 45 | /@vue/compiler-core@3.4.15: 46 | resolution: {integrity: sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==} 47 | dependencies: 48 | '@babel/parser': 7.23.9 49 | '@vue/shared': 3.4.15 50 | entities: 4.5.0 51 | estree-walker: 2.0.2 52 | source-map-js: 1.0.2 53 | dev: false 54 | 55 | /@vue/compiler-dom@3.4.15: 56 | resolution: {integrity: sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==} 57 | dependencies: 58 | '@vue/compiler-core': 3.4.15 59 | '@vue/shared': 3.4.15 60 | dev: false 61 | 62 | /@vue/compiler-sfc@3.4.15: 63 | resolution: {integrity: sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==} 64 | dependencies: 65 | '@babel/parser': 7.23.9 66 | '@vue/compiler-core': 3.4.15 67 | '@vue/compiler-dom': 3.4.15 68 | '@vue/compiler-ssr': 3.4.15 69 | '@vue/shared': 3.4.15 70 | estree-walker: 2.0.2 71 | magic-string: 0.30.7 72 | postcss: 8.4.34 73 | source-map-js: 1.0.2 74 | dev: false 75 | 76 | /@vue/compiler-ssr@3.4.15: 77 | resolution: {integrity: sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==} 78 | dependencies: 79 | '@vue/compiler-dom': 3.4.15 80 | '@vue/shared': 3.4.15 81 | dev: false 82 | 83 | /@vue/reactivity@3.4.15: 84 | resolution: {integrity: sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==} 85 | dependencies: 86 | '@vue/shared': 3.4.15 87 | dev: false 88 | 89 | /@vue/runtime-core@3.4.15: 90 | resolution: {integrity: sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==} 91 | dependencies: 92 | '@vue/reactivity': 3.4.15 93 | '@vue/shared': 3.4.15 94 | dev: false 95 | 96 | /@vue/runtime-dom@3.4.15: 97 | resolution: {integrity: sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==} 98 | dependencies: 99 | '@vue/runtime-core': 3.4.15 100 | '@vue/shared': 3.4.15 101 | csstype: 3.1.3 102 | dev: false 103 | 104 | /@vue/server-renderer@3.4.15(vue@3.4.15): 105 | resolution: {integrity: sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==} 106 | peerDependencies: 107 | vue: 3.4.15 108 | dependencies: 109 | '@vue/compiler-ssr': 3.4.15 110 | '@vue/shared': 3.4.15 111 | vue: 3.4.15 112 | dev: false 113 | 114 | /@vue/shared@3.4.15: 115 | resolution: {integrity: sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==} 116 | dev: false 117 | 118 | /csstype@3.1.3: 119 | resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} 120 | dev: false 121 | 122 | /entities@4.5.0: 123 | resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 124 | engines: {node: '>=0.12'} 125 | dev: false 126 | 127 | /estree-walker@2.0.2: 128 | resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} 129 | dev: false 130 | 131 | /magic-string@0.30.7: 132 | resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} 133 | engines: {node: '>=12'} 134 | dependencies: 135 | '@jridgewell/sourcemap-codec': 1.4.15 136 | dev: false 137 | 138 | /nanoid@3.3.7: 139 | resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 140 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 141 | hasBin: true 142 | dev: false 143 | 144 | /picocolors@1.0.0: 145 | resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} 146 | dev: false 147 | 148 | /postcss@8.4.34: 149 | resolution: {integrity: sha512-4eLTO36woPSocqZ1zIrFD2K1v6wH7pY1uBh0JIM2KKfrVtGvPFiAku6aNOP0W1Wr9qwnaCsF0Z+CrVnryB2A8Q==} 150 | engines: {node: ^10 || ^12 || >=14} 151 | dependencies: 152 | nanoid: 3.3.7 153 | picocolors: 1.0.0 154 | source-map-js: 1.0.2 155 | dev: false 156 | 157 | /source-map-js@1.0.2: 158 | resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} 159 | engines: {node: '>=0.10.0'} 160 | dev: false 161 | 162 | /to-fast-properties@2.0.0: 163 | resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} 164 | engines: {node: '>=4'} 165 | dev: false 166 | 167 | /vue@3.4.15: 168 | resolution: {integrity: sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==} 169 | peerDependencies: 170 | typescript: '*' 171 | peerDependenciesMeta: 172 | typescript: 173 | optional: true 174 | dependencies: 175 | '@vue/compiler-dom': 3.4.15 176 | '@vue/compiler-sfc': 3.4.15 177 | '@vue/runtime-dom': 3.4.15 178 | '@vue/server-renderer': 3.4.15(vue@3.4.15) 179 | '@vue/shared': 3.4.15 180 | dev: false 181 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /docs/public/images/desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/desktop.png -------------------------------------------------------------------------------- /docs/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/favicon.png -------------------------------------------------------------------------------- /docs/public/images/ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/ipad.png -------------------------------------------------------------------------------- /docs/public/images/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/iphone.png -------------------------------------------------------------------------------- /docs/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/logo.png -------------------------------------------------------------------------------- /docs/public/images/macbook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craigrileyuk/vue3-mq/da1a66bf1640e50cc850ba97b7818de6e1a983b0/docs/public/images/macbook.png -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # The MQ Object 7 | 8 | Vue3-Mq provides your app with a fully reactive object detailing the environment that it's operating within. This includes the screen size, orientation and OS/browser theme preference (i.e. dark mode). 9 | 10 | 11 |
12 | {{ mq }}
13 | 
14 |
15 | 16 | Notice that booleans are created for each of your provided breakpoints to allow you to easily respond to different size screens. 17 | 18 | You can access this object inside your Vue 3 application like so: 19 | 20 | ::: code-group 21 | 22 | ```vue [Composition API] 23 | 28 | ``` 29 | 30 | ```vue [Options API] 31 | 36 | ``` 37 | 38 | ::: 39 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginVue from "eslint-plugin-vue"; 2 | import configPrettier from "eslint-config-prettier"; 3 | import jsdoc from "eslint-plugin-jsdoc"; 4 | import js from "@eslint/js"; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | jsdoc.configs["flat/recommended"], 9 | configPrettier, 10 | ...pluginVue.configs["flat/recommended"], 11 | { 12 | plugins: [jsdoc], 13 | }, 14 | { 15 | rules: { 16 | "vue/multi-word-component-names": "off", 17 | "vue/valid-v-slot": "off", 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-mq", 3 | "version": "4.0.2", 4 | "description": "Build responsive design into your Vue 3 app", 5 | "files": [ 6 | "dist/*", 7 | "types/*" 8 | ], 9 | "type": "module", 10 | "main": "./dist/vue3-mq.cjs", 11 | "module": "./dist/vue3-mq.js", 12 | "types": "./types/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "types": "./types/index.d.ts", 16 | "import": "./dist/vue3-mq.js", 17 | "umd": "./dist/vue3-mq.umd.cjs", 18 | "require": "./dist/vue3-mq.cjs" 19 | } 20 | }, 21 | "browserslist": [ 22 | "> 1%", 23 | "last 2 versions", 24 | "not dead" 25 | ], 26 | "author": { 27 | "name": "Craig Riley", 28 | "email": "hello@craigriley.uk", 29 | "url": "https://craigriley.uk" 30 | }, 31 | "funding": [ 32 | { 33 | "type": "github", 34 | "url": "https://github.com/sponsors/craigrileyuk/" 35 | } 36 | ], 37 | "license": "MIT", 38 | "scripts": { 39 | "dev": "vite", 40 | "build": "vitest --run && vite build", 41 | "docs:dev": "pnpm --filter ./docs run dev", 42 | "docs:build": "pnpm --filter ./docs run build", 43 | "docs:preview": "pnpm --filter ./docs run preview", 44 | "demo": "pnpm --filter ./demo run dev", 45 | "test": "vitest", 46 | "build:types": "vue-tsc && cp ./src/global.d.ts ./types/global.d.ts && npm run copy-ref", 47 | "copy-ref": "echo '/// \n' | cat - ./types/index.d.ts > temp && mv temp ./types/index.d.ts", 48 | "prepublishOnly": "npm run build:types && npm run build" 49 | }, 50 | "peerDependencies": { 51 | "vue": "^3.0.5" 52 | }, 53 | "devDependencies": { 54 | "@mdi/js": "^7.4.47", 55 | "@vitejs/plugin-vue": "^5.2.1", 56 | "@vue/compiler-sfc": "^3.5.13", 57 | "@vue/test-utils": "^2.4.6", 58 | "autoprefixer": "^10.4.20", 59 | "css-mediaquery": "^0.1.2", 60 | "eslint": "^9.18.0", 61 | "eslint-config-prettier": "^10.0.1", 62 | "eslint-plugin-jsdoc": "^50.6.2", 63 | "eslint-plugin-vue": "^9.32.0", 64 | "exenv": "^1.2.2", 65 | "lodash-es": "^4.17.21", 66 | "match-media-mock": "^0.1.1", 67 | "postcss": "^8.5.1", 68 | "sass": "^1.83.4", 69 | "tailwindcss": "^3.4.17", 70 | "typescript": "^5.7.3", 71 | "vite": "^6.0.11", 72 | "vitest": "^3.0.4", 73 | "vue": "^3.5.13", 74 | "vue-tsc": "^2.2.0", 75 | "vue3-icon": "^3.0.3" 76 | }, 77 | "bugs": { 78 | "url": "https://github.com/craigrileyuk/vue3-mq/issues" 79 | }, 80 | "homepage": "https://github.com/craigrileyuk/vue3-mq#readme", 81 | "repository": { 82 | "type": "git", 83 | "url": "git+https://github.com/craigrileyuk/vue3-mq.git" 84 | }, 85 | "keywords": [ 86 | "vue", 87 | "vue3", 88 | "media", 89 | "query", 90 | "media queries", 91 | "responsive", 92 | "breakpoints", 93 | "vue-mq" 94 | ], 95 | "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" 96 | } 97 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - docs 3 | - demo 4 | -------------------------------------------------------------------------------- /src/component.js: -------------------------------------------------------------------------------- 1 | import { computed, h, Transition, TransitionGroup } from "vue"; 2 | import { availableBreakpoints, mqState } from "./store"; 3 | import { 4 | calculateBreakpointsToRender, 5 | calculateOrientationsToRender, 6 | calculateThemesToRender, 7 | calculateMotionToRender, 8 | } from "./helpers"; 9 | import { extractSlotNameProperties } from "./validation"; 10 | 11 | const defaultTransitionOptions = { 12 | name: "fade", 13 | mode: "out-in", 14 | }; 15 | 16 | export default { 17 | name: "MqResponsive", 18 | props: { 19 | /** 20 | * Breakpoints to target when rendering component 21 | */ 22 | target: [String, Array], 23 | /** 24 | * Only render in a landscape view 25 | */ 26 | landscape: { 27 | type: Boolean, 28 | default: false, 29 | }, 30 | /** 31 | * Only render in a portrait view 32 | */ 33 | portrait: { 34 | type: Boolean, 35 | default: false, 36 | }, 37 | /** 38 | * Only render when dark mode is preferred 39 | */ 40 | dark: { 41 | type: Boolean, 42 | default: false, 43 | }, 44 | /** 45 | * Only render when light mode is preferred 46 | */ 47 | light: { 48 | type: Boolean, 49 | default: false, 50 | }, 51 | /** 52 | * Only render when reduced motion is preferred 53 | */ 54 | inert: { 55 | type: Boolean, 56 | default: false, 57 | }, 58 | /** 59 | * Only render when normal motion is preferred 60 | */ 61 | motion: { 62 | type: Boolean, 63 | default: false, 64 | }, 65 | /** 66 | * HTML tag to use when rendering 67 | */ 68 | tag: { 69 | type: String, 70 | default: "div", 71 | }, 72 | /** 73 | * When in group mode, the HTML tag to use on list items 74 | */ 75 | listTag: { 76 | type: String, 77 | default: "div", 78 | }, 79 | /** 80 | * Render items as part of a transition group 81 | */ 82 | group: { 83 | type: Boolean, 84 | default: false, 85 | }, 86 | }, 87 | setup(props, { attrs, emit, slots }) { 88 | const breakpointsToRender = computed(() => { 89 | return calculateBreakpointsToRender( 90 | props.target, 91 | availableBreakpoints 92 | ); 93 | }); 94 | const orientationsToRender = computed(() => { 95 | return calculateOrientationsToRender( 96 | props.landscape, 97 | props.portrait 98 | ); 99 | }); 100 | const themesToRender = computed(() => { 101 | return calculateThemesToRender(props.dark, props.light); 102 | }); 103 | const motionToRender = computed(() => { 104 | return calculateMotionToRender(props.inert, props.motion); 105 | }); 106 | const shouldRenderDefault = computed(() => { 107 | return ( 108 | breakpointsToRender.value.includes(mqState.current) && 109 | orientationsToRender.value.includes(mqState.orientation) && 110 | themesToRender.value.includes(mqState.theme) && 111 | motionToRender.value.includes(mqState.motionPreference) 112 | ); 113 | }); 114 | const renderSlots = (tag) => { 115 | // If not using a transition-group and named slots are used, render them all. 116 | if (!props.group && slots.length > 0) return slots; 117 | 118 | const slotsToRender = []; 119 | for (let slot in slots) { 120 | // Extract render options from slot name. 121 | const { slotBp, slotOrientation, slotTheme, slotMotion } = 122 | extractSlotNameProperties(slot); 123 | // Compute an array of breakpoints in which the slot should render 124 | const breakpointsToRenderSlot = computed(() => { 125 | return calculateBreakpointsToRender( 126 | slotBp, 127 | availableBreakpoints 128 | ); 129 | }); 130 | 131 | // Compute an array of orientations on which the slot should render 132 | const orientationsToRenderSlot = computed(() => { 133 | return calculateOrientationsToRender( 134 | slotOrientation === "landscape", 135 | slotOrientation === "portrait" 136 | ); 137 | }); 138 | 139 | // Compute an array of themes under which the slot should render 140 | const themesToRenderSlot = computed(() => { 141 | return calculateThemesToRender( 142 | slotTheme === "dark", 143 | slotTheme === "light" 144 | ); 145 | }); 146 | 147 | // Compute an array of motion preference under which the slot should render 148 | const motionToRenderSlot = computed(() => { 149 | return calculateMotionToRender( 150 | slotMotion === "inert", 151 | slotMotion === "motion" 152 | ); 153 | }); 154 | 155 | // Compute if this slot should be rendered 156 | const shouldRenderSlot = computed(() => { 157 | return ( 158 | breakpointsToRenderSlot.value.includes( 159 | mqState.current 160 | ) && 161 | orientationsToRenderSlot.value.includes( 162 | mqState.orientation 163 | ) && 164 | themesToRenderSlot.value.includes(mqState.theme) && 165 | motionToRenderSlot.value.includes( 166 | mqState.motionPreference 167 | ) 168 | ); 169 | }); 170 | 171 | // If yes, push it onto the rendering array 172 | if (shouldRenderSlot.value === true) { 173 | slotsToRender.push( 174 | h( 175 | tag ? tag : slots[slot], 176 | { key: slot }, 177 | tag ? slots[slot]() : undefined 178 | ) 179 | ); 180 | } 181 | } 182 | // If there is anything to render, return it 183 | return slotsToRender.length > 0 ? slotsToRender : undefined; 184 | }; 185 | 186 | // If the component is using the default slot 187 | if (slots.default) { 188 | return () => 189 | shouldRenderDefault.value 190 | ? h(props.tag, { ...attrs }, slots.default()) 191 | : undefined; 192 | } 193 | // If the component is using named slots 194 | else { 195 | return () => { 196 | const transitionOptions = Object.assign( 197 | {}, 198 | defaultTransitionOptions, 199 | attrs, 200 | { tag: props.tag } 201 | ); 202 | const el = props.group ? TransitionGroup : Transition; 203 | 204 | return h(el, transitionOptions, () => 205 | renderSlots(props.listTag) 206 | ); 207 | }; 208 | } 209 | }, 210 | }; 211 | -------------------------------------------------------------------------------- /src/composables.js: -------------------------------------------------------------------------------- 1 | /* ****************************************** 2 | * MODULE IMPORTS 3 | ****************************************** */ 4 | import { inject, reactive } from "vue"; 5 | 6 | /* ****************************************** 7 | * LOCAL IMPORTS 8 | ****************************************** */ 9 | import { validatePreset, sanitiseBreakpoints } from "./validation"; 10 | import { 11 | removeListeners, 12 | createMediaQueries, 13 | subscribeToMediaQuery, 14 | } from "./helpers"; 15 | 16 | /* ****************************************** 17 | * STATE IMPORTS 18 | ****************************************** */ 19 | import { 20 | setAvailableBreakpoints, 21 | updateState, 22 | updateMotionState, 23 | updateOrientationState, 24 | updateThemeState, 25 | resetState, 26 | } from "./store"; 27 | 28 | /** 29 | * Update the breakpoint presets and assign MediaQuery listeners for each of them 30 | * 31 | * @public 32 | * @param {object} config - Configuration object for updating breakpoints 33 | * @param {object} config.breakpoints - An object of name:min values to set 34 | * @param {object} config.preset - A breakpoint preset to use 35 | */ 36 | export function updateBreakpoints({ breakpoints, preset }) { 37 | const validatedPreset = preset ? validatePreset(preset) : false; 38 | const sanitisedBreakpoints = breakpoints 39 | ? sanitiseBreakpoints(breakpoints) 40 | : false; 41 | 42 | if (validatedPreset === false && !sanitisedBreakpoints) { 43 | throw new TypeError( 44 | "Vue3 Mq: You must provide a valid preset, or valid breakpoint settings." 45 | ); 46 | } else { 47 | setAvailableBreakpoints( 48 | sanitisedBreakpoints 49 | ? sanitisedBreakpoints 50 | : sanitiseBreakpoints(validatedPreset) 51 | ); 52 | } 53 | 54 | removeListeners(); 55 | resetState(); 56 | 57 | // Set matchMedia queries for breakpoints 58 | const mediaQueries = createMediaQueries(); 59 | for (const key in mediaQueries) { 60 | const mediaQuery = mediaQueries[key]; 61 | const callback = () => { 62 | updateState(key); 63 | }; 64 | subscribeToMediaQuery(mediaQuery, callback); 65 | } 66 | 67 | // Set matchMedia queries for orientation 68 | ["portrait", "landscape"].forEach((o) => { 69 | const orientationCallback = () => { 70 | updateOrientationState(o); 71 | }; 72 | 73 | subscribeToMediaQuery(`(orientation: ${o})`, orientationCallback); 74 | }); 75 | 76 | // Set matchMedia queries for OS theme 77 | ["light", "dark"].forEach((t) => { 78 | const themeCallback = () => { 79 | updateThemeState(t); 80 | }; 81 | 82 | subscribeToMediaQuery(`(prefers-color-scheme: ${t})`, themeCallback); 83 | }); 84 | 85 | // Set matchMedia queries for OS theme 86 | ["reduce", "no-preference"].forEach((m) => { 87 | const motionCallback = () => { 88 | updateMotionState(m); 89 | }; 90 | 91 | subscribeToMediaQuery(`(prefers-reduced-motion: ${m})`, motionCallback); 92 | }); 93 | } 94 | 95 | /** 96 | * Composable function which returns the MQ object provided in the ./plugin.js->install method 97 | * 98 | * @public 99 | * @returns {reactive} - The Vue Reactive object 100 | */ 101 | export function useMq() { 102 | const mq = inject("mq"); 103 | if (!mq) { 104 | throw new Error( 105 | "Vue3Mq is not installed in this app. Please follow the installation instructions and try again." 106 | ); 107 | } else return mq; 108 | } 109 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "vue" { 2 | interface ComponentCustomProperties { 3 | /** @type {import("vue").reactive} */ 4 | $mq: reactive; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import { 2 | _listeners as listeners, 3 | availableBreakpoints, 4 | _isMounted as isMounted, 5 | } from "./store"; 6 | import { ref } from "vue"; 7 | 8 | /** 9 | * Remove all MediaMatch listeners from the window object and empties the listeners array 10 | * @return { void } 11 | */ 12 | export function removeListeners() { 13 | while (listeners.length > 0) { 14 | const listener = listeners.shift(); 15 | if (listener && typeof listener === "object") { 16 | const { mql, cb } = listener; 17 | const supportsAEL = 18 | mql.addEventListener && 19 | typeof mql.addEventListener === "function"; 20 | if (supportsAEL) mql.removeEventListener("change", cb); 21 | else mql.removeListener(cb); 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Convert available breakpoints in to media query strings 28 | * @returns {string[]} An array of media query strings 29 | */ 30 | export function createMediaQueries() { 31 | const mediaQueries = availableBreakpoints.value.reduce( 32 | (acc, curr, index, arr) => { 33 | const min = `(min-width: ${curr.min}px)`; 34 | const max = 35 | index < arr.length - 1 36 | ? `(max-width: ${arr[index + 1].min - 1}px)` 37 | : null; 38 | const query = min + (max ? " and " + max : ""); 39 | return Object.assign(acc, { 40 | [curr.name]: query, 41 | }); 42 | }, 43 | {} 44 | ); 45 | 46 | return mediaQueries; 47 | } 48 | 49 | /** 50 | * Subscribe to a given media query and execute a callback when matched 51 | * 52 | * @type {Function} - Adds a listener using the matchMedia method on the window 53 | * @param {string} mediaQuery - The media query to listen for match status 54 | * @param {Function} callback - The callback to execute when the mediaQuery is matched 55 | */ 56 | export function subscribeToMediaQuery(mediaQuery, callback) { 57 | if (typeof window === "undefined" || !window.matchMedia) return false; 58 | else if (typeof window !== "undefined" && !window.matchMedia) { 59 | console.error( 60 | "Vue3 Mq: No MatchMedia support detected in this browser. Responsive breakpoints not available." 61 | ); 62 | return false; 63 | } else { 64 | isMounted.value = true; 65 | 66 | const mql = window.matchMedia(mediaQuery); 67 | const cb = ({ matches }) => { 68 | if (matches) callback(); 69 | }; 70 | listeners.push({ mql, cb }); 71 | 72 | const supportsAEL = 73 | mql.addEventListener && typeof mql.addEventListener === "function"; 74 | 75 | if (supportsAEL) mql.addEventListener("change", cb); 76 | else mql.addListener(cb); 77 | 78 | cb(mql); 79 | } 80 | } 81 | 82 | /** 83 | * Checks that the given breakpoint matches against the given breakpoints in config 84 | * @param {string} bp The breakpoint to validate 85 | * @returns {boolean} The validity of the breakpoint 86 | */ 87 | const validateBreakpoint = (bp) => { 88 | return availableBreakpoints.value.some( 89 | (available) => available.name === bp 90 | ); 91 | }; 92 | 93 | /** 94 | * Calculate which breakpoints are currently active 95 | * @param {string} bp - The breakpoint to validate 96 | * @param {ref} available - A Vue REF holding an array of objects denoting breakpoints registered with the plugin 97 | * @returns {string[]} - An array of breakpoint keys that should be rendered based on the current breakpoint 98 | */ 99 | export const calculateBreakpointsToRender = (bp, available) => { 100 | const allKeys = available.value.map((a) => a.name); 101 | 102 | // No setting 103 | if (!bp) return allKeys; 104 | // Array of breakpoints 105 | else if (Array.isArray(bp)) { 106 | return bp.filter((n) => validateBreakpoint(n)); 107 | } 108 | // Breakpoint plus 109 | else if (typeof bp === "string" && /\w+\+$/.test(bp)) { 110 | bp = bp.replace(/\+$/, ""); 111 | if (validateBreakpoint(bp) === false) { 112 | console.error( 113 | `Vue3 Mq: ${bp} is not a valid breakpoint key. Invalid range.` 114 | ); 115 | return allKeys; 116 | } 117 | const fromIndex = available.value.findIndex((n) => n.name === bp); 118 | return available.value.slice(fromIndex).map((n) => n.name); 119 | } 120 | // Breakpoint minus 121 | else if (typeof bp === "string" && /\w+-$/.test(bp)) { 122 | bp = bp.replace(/-$/, ""); 123 | if (validateBreakpoint(bp) === false) { 124 | console.error( 125 | `Vue3 Mq: ${bp} is not a valid breakpoint key. Invalid range.` 126 | ); 127 | return allKeys; 128 | } 129 | const toIndex = available.value.findIndex((n) => n.name === bp); 130 | return available.value.slice(0, toIndex + 1).map((n) => n.name); 131 | } 132 | // Breakpoint range 133 | else if (typeof bp === "string" && /^\w+-\w+$/.test(bp)) { 134 | const [fromKey, toKey] = bp.split("-"); 135 | if (validateBreakpoint(fromKey) === false) { 136 | console.error( 137 | `Vue3 Mq: ${fromKey} is not a valid breakpoint key. Invalid range.` 138 | ); 139 | return allKeys; 140 | } else if (validateBreakpoint(toKey) === false) { 141 | console.error( 142 | `Vue3 Mq: ${toKey} is not a valid breakpoint key. Invalid range.` 143 | ); 144 | return allKeys; 145 | } 146 | const fromIndex = available.value.findIndex((n) => n.name === fromKey); 147 | const toIndex = available.value.findIndex((n) => n.name === toKey); 148 | return available.value.slice(fromIndex, toIndex + 1).map((n) => n.name); 149 | } 150 | // Single breakpoint 151 | else if (typeof bp === "string" && validateBreakpoint(bp) === true) { 152 | return [bp]; 153 | } 154 | // Fallback 155 | else return allKeys; 156 | }; 157 | 158 | /** 159 | * Creates an array of the orientations that should be rendered 160 | * @param {boolean} landscape Render only in landscape mode 161 | * @param {boolean} portrait Render only in portrait mode 162 | * @returns {string[]} An array of orientations that should be rendered based on the current value 163 | */ 164 | export const calculateOrientationsToRender = (landscape, portrait) => { 165 | const arr = []; 166 | if (!landscape && !portrait) return ["landscape", "portrait"]; 167 | if (landscape) arr.push("landscape"); 168 | if (portrait) arr.push("portrait"); 169 | return arr; 170 | }; 171 | 172 | /** 173 | * Creates an array of the themes that should be rendered on 174 | * @param {boolean} dark Render only in dark mode 175 | * @param {boolean} light Render only in light mode 176 | * @returns {string[]} The array of themes to render on based upon the current value 177 | */ 178 | export const calculateThemesToRender = (dark, light) => { 179 | const arr = []; 180 | if (!light && !dark) return ["light", "dark"]; 181 | if (light) arr.push("light"); 182 | if (dark) arr.push("dark"); 183 | return arr; 184 | }; 185 | 186 | /** 187 | * @constant {Function} calculateMotionToRender - Creates an array of the motion preferences that should be rendered on 188 | * @param {boolean} inert - Render only in reduced motion mode 189 | * @param {boolean} motion - Render only in normal motion mode 190 | * @returns {string[]} - The array of motion preferences to render on based upon the current value 191 | */ 192 | export const calculateMotionToRender = (inert, motion) => { 193 | const arr = []; 194 | if (!inert && !motion) return ["reduce", "no-preference"]; 195 | if (inert) arr.push("reduce"); 196 | if (motion) arr.push("no-preference"); 197 | return arr; 198 | }; 199 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as MqResponsive } from "./component"; 2 | export { default as Vue3Mq } from "./plugin"; 3 | export { useMq, updateBreakpoints } from "./composables"; 4 | export { availableBreakpoints } from "./store"; 5 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import { updateBreakpoints } from "./composables"; 2 | import MqResponsive from "./component"; 3 | import { 4 | setDefaultBreakpoint, 5 | setDefaultMotion, 6 | setDefaultOrientation, 7 | setDefaultTheme, 8 | mqState, 9 | } from "./store"; 10 | import { 11 | validateOrientation, 12 | validateTheme, 13 | validateMotion, 14 | } from "./validation"; 15 | 16 | /** 17 | * Install the Vue3Mq plugin on the Vue app instance 18 | * 19 | * @param {object} app - The Vue3 app instance 20 | * @param {object} config - Plugin installation configuration object 21 | * @param {string} config.preset - A string representing an exported preset from ./presets.js 22 | * @param {object} config.breakpoints - User defined breakpoints comprising a named key with a minimum width value 23 | * @param {string} config.defaultBreakpoint - The screen size to set when the plugin is executed in a non-browser context (e.g. SSR) 24 | * @param {string} config.defaultOrientation - The screen orientation to set when the plugin is executed in a non-browser context (e.g. SSR) 25 | * @param {string} config.defaultMotion - The motion preference to set when the plugin is executed in a non-browser context (e.g. SSR) 26 | * @param {string} config.defaultTheme - The theme to set when the plugin is executed in a non-browser context (e.g. SSR) or for users with no OS preference 27 | * @param {boolean} config.global - Install the MQ Object and component globally in the Vue application 28 | */ 29 | const install = ( 30 | app, 31 | { 32 | preset = "bootstrap5", 33 | breakpoints, 34 | defaultBreakpoint, 35 | defaultOrientation = "landscape", 36 | defaultMotion = "no-preference", 37 | defaultTheme, 38 | global = false, 39 | } = {} 40 | ) => { 41 | try { 42 | const validatedDefaultOrientation = 43 | validateOrientation(defaultOrientation); 44 | const validatedDefaultTheme = validateTheme(defaultTheme); 45 | const validatedDefaultMotion = validateMotion(defaultMotion); 46 | 47 | setDefaultBreakpoint(defaultBreakpoint); 48 | setDefaultOrientation(validatedDefaultOrientation); 49 | setDefaultTheme(validatedDefaultTheme); 50 | setDefaultMotion(validatedDefaultMotion); 51 | 52 | app.provide("mq", mqState); 53 | app.provide("updateBreakpoints", updateBreakpoints); 54 | 55 | if (global === true) { 56 | app.component("MqResponsive", MqResponsive); 57 | app.config.globalProperties.$mq = mqState; 58 | } 59 | 60 | updateBreakpoints({ breakpoints, preset }); 61 | } catch (e) { 62 | console.error(e); 63 | } 64 | }; 65 | 66 | export default { 67 | install, 68 | }; 69 | -------------------------------------------------------------------------------- /src/presets.js: -------------------------------------------------------------------------------- 1 | export const bootstrap5 = { 2 | xs: 0, 3 | sm: 576, 4 | md: 768, 5 | lg: 992, 6 | xl: 1200, 7 | xxl: 1400, 8 | }; 9 | 10 | export const bootstrap4 = { 11 | xs: 0, 12 | sm: 576, 13 | md: 768, 14 | lg: 992, 15 | xl: 1200, 16 | }; 17 | 18 | export const bootstrap3 = { 19 | xs: 0, 20 | sm: 768, 21 | md: 992, 22 | lg: 1200, 23 | }; 24 | 25 | export const vuetify3 = { 26 | xs: 0, 27 | sm: 600, 28 | md: 960, 29 | lg: 1280, 30 | xl: 1920, 31 | xxl: 2560, 32 | }; 33 | 34 | export const vuetify = { 35 | xs: 0, 36 | sm: 600, 37 | md: 960, 38 | lg: 1264, 39 | xl: 1904, 40 | }; 41 | 42 | export const tailwind = { 43 | xs: 0, 44 | sm: 640, 45 | md: 768, 46 | lg: 1024, 47 | xl: 1280, 48 | xxl: 1536, 49 | }; 50 | 51 | export const devices = { 52 | phone: 0, 53 | tablet: 768, 54 | laptop: 1370, 55 | desktop: 1906, 56 | }; 57 | 58 | export const wordpress = { 59 | mobile: 0, 60 | small: 600, 61 | medium: 782, 62 | large: 960, 63 | xlarge: 1080, 64 | wide: 1280, 65 | huge: 1440, 66 | }; 67 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | /* ****************************************** 2 | * IMPORTS 3 | ****************************************** */ 4 | import { reactive, readonly, ref } from "vue"; 5 | 6 | /** 7 | * @typedef {"landscape"|"portrait"} OrientationOptions 8 | */ 9 | 10 | /** 11 | * @typedef {"dark"|"light"} ThemeOptions 12 | */ 13 | 14 | /** 15 | * @typedef {"no-preference"|"reduce"} MotionOptions 16 | */ 17 | 18 | /* ****************************************** 19 | * STATE 20 | ****************************************** */ 21 | const _availableBreakpoints = ref([]); 22 | const _defaultBreakpoint = ref(null); 23 | const _defaultOrientation = ref(null); 24 | const _defaultTheme = ref(null); 25 | const _defaultMotion = ref(null); 26 | const _mqState = reactive({ 27 | current: "", 28 | }); 29 | 30 | export const _listeners = []; 31 | export const _isMounted = ref(false); 32 | 33 | /* ****************************************** 34 | * GETTERS 35 | ****************************************** */ 36 | export const availableBreakpoints = readonly(_availableBreakpoints); 37 | export const defaultBreakpoint = readonly(_defaultBreakpoint); 38 | export const defaultOrientation = readonly(_defaultOrientation); 39 | export const defaultTheme = readonly(_defaultTheme); 40 | export const defaultMotion = readonly(_defaultMotion); 41 | export const mqState = readonly(_mqState); 42 | 43 | /* ****************************************** 44 | * MUTATIONS 45 | ****************************************** */ 46 | export const setAvailableBreakpoints = (v) => { 47 | _availableBreakpoints.value = v; 48 | }; 49 | 50 | /** 51 | * Sets the breakpoint to use when plugin executes in a non-browser context 52 | * @param {string} v Default breakpoint key 53 | */ 54 | export const setDefaultBreakpoint = (v) => { 55 | _defaultBreakpoint.value = v; 56 | }; 57 | 58 | /** 59 | * Sets the orientation to use when plugin executes in a non-browser context 60 | * @param { OrientationOptions } v The default orientation 61 | */ 62 | export const setDefaultOrientation = (v) => { 63 | _defaultOrientation.value = v; 64 | }; 65 | 66 | /** 67 | * Sets the theme to use when plugin executes in a non-browser context 68 | * @param { ThemeOptions } v The default theme 69 | */ 70 | export const setDefaultTheme = (v) => { 71 | _defaultTheme.value = v; 72 | }; 73 | 74 | /** 75 | * Sets the motion preference to use when plugin executes in a non-browser context 76 | * @param { MotionOptions } v Default motion preference 77 | */ 78 | export const setDefaultMotion = (v) => { 79 | _defaultMotion.value = v; 80 | }; 81 | 82 | export const updateState = (v = defaultBreakpoint.value) => { 83 | _mqState.current = v; 84 | const currentIndex = availableBreakpoints.value.findIndex( 85 | (bp) => bp.name === v 86 | ); 87 | const allKeys = availableBreakpoints.value.map((bp) => bp.name); 88 | 89 | for (let i = 0; i < allKeys.length; i++) { 90 | if (i > 0 && i < allKeys.length - 1) { 91 | const mKey = allKeys[i] + "Minus"; 92 | const pKey = allKeys[i] + "Plus"; 93 | 94 | _mqState[mKey] = currentIndex <= i ? true : false; 95 | _mqState[pKey] = currentIndex >= i ? true : false; 96 | } 97 | 98 | _mqState[allKeys[i]] = allKeys[i] === v ? true : false; 99 | } 100 | }; 101 | 102 | /** 103 | * Resets the MQ object to its initial values, using defaultBreakpoint and defaultOrientation 104 | * @returns { void } 105 | */ 106 | export const resetState = () => { 107 | const keys = Object.keys(_mqState); 108 | for (let key of keys) { 109 | delete _mqState[key]; 110 | } 111 | 112 | updateState(); 113 | updateOrientationState(); 114 | updateThemeState(); 115 | updateMotionState(); 116 | }; 117 | 118 | /** 119 | * Update values for the MQ object's orientation properties 120 | * @param { OrientationOptions } v The orientation value to set 121 | * @returns { void } 122 | */ 123 | export const updateOrientationState = (v = defaultOrientation.value) => { 124 | _mqState.orientation = v; 125 | _mqState.isLandscape = v === "landscape"; 126 | _mqState.isPortrait = v === "portrait"; 127 | }; 128 | 129 | /** 130 | * Update values for the MQ object's theme properties 131 | * @param { ThemeOptions } v The theme value to set 132 | * @returns { void } 133 | */ 134 | export const updateThemeState = (v = defaultTheme.value || "light") => { 135 | _mqState.theme = v; 136 | _mqState.isDark = v === "dark"; 137 | _mqState.isLight = v === "light"; 138 | }; 139 | 140 | /** 141 | * Update values for the MQ object's motion preferences 142 | * @param { MotionOptions } v The motion preference to set 143 | * @returns { void } 144 | */ 145 | export const updateMotionState = ( 146 | v = defaultMotion.value || "no-preference" 147 | ) => { 148 | _mqState.motionPreference = v; 149 | _mqState.isMotion = v === "no-preference"; 150 | _mqState.isInert = v === "reduce"; 151 | }; 152 | -------------------------------------------------------------------------------- /src/validation.js: -------------------------------------------------------------------------------- 1 | import * as presets from "./presets"; 2 | 3 | export const validatePreset = (preset) => { 4 | if (typeof preset === "string" && presets[preset]) return presets[preset]; 5 | else { 6 | const availablePresets = Object.keys(presets); 7 | console.error( 8 | `Vue3 Mq: "${preset}" is not a valid preset. Available options are: ${availablePresets.join( 9 | ", " 10 | )}` 11 | ); 12 | return false; 13 | } 14 | }; 15 | 16 | export const validateOrientation = (orientation) => { 17 | const isValid = ["landscape", "portrait"].includes(orientation); 18 | if (isValid === false) { 19 | console.error( 20 | `Vue3 Mq: "${orientation}" is not a valid default orientation. Reverting to unset value.` 21 | ); 22 | return null; 23 | } else return orientation; 24 | }; 25 | 26 | export const validateTheme = (theme = null) => { 27 | const isValid = ["dark", "light"].includes(theme); 28 | if (isValid === false && theme !== null) { 29 | console.error( 30 | `Vue3 Mq: "${theme}" is not a valid default theme. Reverting to unset value.` 31 | ); 32 | return null; 33 | } else return theme; 34 | }; 35 | 36 | export const validateMotion = (motion = null) => { 37 | const isValid = ["no-preference", "reduce"].includes(motion); 38 | if (isValid === false && motion !== null) { 39 | console.error( 40 | `Vue3 Mq: "${motion}" is not a valid default motion preference. Reverting to unset value.` 41 | ); 42 | return null; 43 | } else return motion; 44 | }; 45 | 46 | export const sanitiseBreakpoints = (breakpoints) => { 47 | if (!breakpoints || typeof breakpoints !== "object") return false; 48 | const sanitisedBreakpoints = []; 49 | 50 | for (let key in breakpoints) { 51 | const bp = parseFloat(breakpoints[key]); 52 | if (!key || typeof key !== "string") { 53 | console.warn( 54 | `Vue3 Mq: Invalid or missing breakpoint key (${JSON.stringify( 55 | key 56 | )}). Skipping.` 57 | ); 58 | continue; 59 | } else if (/^[^a-z]/i.test(key) || /[^a-zA-Z0-9_]/.test(key)) { 60 | console.warn( 61 | `Vue3 Mq: "${key}" is an invalid breakpoint key. Breakpoint keys must start with a letter and contain only alphanumeric characters and underscores. Skipping.` 62 | ); 63 | continue; 64 | } else if ((!bp && bp !== 0) || isNaN(bp) || bp < 0) { 65 | console.warn( 66 | `Vue3 Mq: "${key}: ${breakpoints[key]}" is not a valid breakpoint. Breakpoints should be a number of zero or above. Skipping.` 67 | ); 68 | continue; 69 | } 70 | 71 | sanitisedBreakpoints.push({ 72 | name: key, 73 | min: bp, 74 | }); 75 | } 76 | 77 | const hasZero = sanitisedBreakpoints.some( 78 | (breakpoint) => breakpoint.min === 0 79 | ); 80 | if (!hasZero) { 81 | console.warn( 82 | `Vue3 Mq: You have not declared a breakpoint with a minimum value of 0. There may be screen sizes to which Vue3Mq does not respond.` 83 | ); 84 | } 85 | 86 | const uniqueValues = new Set( 87 | sanitisedBreakpoints.map((breakpoint) => breakpoint.min) 88 | ); 89 | if (uniqueValues.size < sanitisedBreakpoints.length) { 90 | console.warn( 91 | `Vue3 Mq: Your breakpoint configuration contains duplicate values. Behaviour may be unpredictable.` 92 | ); 93 | } 94 | 95 | if (sanitisedBreakpoints.length === 0) return false; 96 | else return sanitisedBreakpoints.sort((a, b) => a.min - b.min); 97 | }; 98 | 99 | export const extractSlotNameProperties = (name) => { 100 | const options = name.split(":"); 101 | const optionsObject = {}; 102 | for (let option of options) { 103 | // If the option is a slot number, discard it 104 | if (/\D/.test(option) === false) continue; 105 | // If it's an orientation, apply 106 | else if (["landscape", "portrait"].includes(option)) 107 | optionsObject.slotOrientation = option; 108 | // If it's a theme, apply 109 | else if (["light", "dark"].includes(option)) 110 | optionsObject.slotTheme = option; 111 | // If it's a motion preference, apply 112 | else if (["inert", "motion"].includes(option)) 113 | optionsObject.slotMotion = option; 114 | // Otherwise, assume it's a breakpoint 115 | else optionsObject.slotBp = option; 116 | } 117 | return optionsObject; 118 | }; 119 | -------------------------------------------------------------------------------- /tests/mock/MatchMediaMock.js: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/azazdeaz/match-media-mock 3 | 4 | written by András Polgár 5 | 6 | released under MIT licence 7 | */ 8 | import { clone, mapValues, forOwn } from "lodash-es"; 9 | import MediaQueryListMock from "./MediaQueryListMock"; 10 | 11 | export default { 12 | create() { 13 | var config = {}; 14 | var createdMqls = {}; 15 | 16 | function matchMediaMock(query) { 17 | var mql = createdMqls[query]; 18 | 19 | if (!mql) { 20 | mql = new MediaQueryListMock(query, () => config); 21 | createdMqls[query] = mql; 22 | } 23 | 24 | return mql; 25 | } 26 | 27 | matchMediaMock.setConfig = function (newConfig) { 28 | if (!newConfig) { 29 | return; 30 | } 31 | 32 | var matchBeforeByQuery = mapValues(createdMqls, "matches"); 33 | 34 | config = clone(newConfig) || {}; 35 | 36 | forOwn(createdMqls, function (mql, query) { 37 | if (mql.matches !== matchBeforeByQuery[query]) { 38 | mql.callListeners(); 39 | } 40 | }); 41 | }; 42 | 43 | return matchMediaMock; 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /tests/mock/MediaQueryListMock.js: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/azazdeaz/match-media-mock 3 | 4 | written by András Polgár, 5 | modified by Craig Riley to include the preferred, newer addEventListener and removeEventListener methods 6 | 7 | released under MIT licence 8 | */ 9 | import { includes, pull } from "lodash-es"; 10 | import mediaQuery from "css-mediaquery"; 11 | import ExecutionEnvironment from "exenv"; 12 | 13 | export default class MediaQueryListMock { 14 | constructor(query, getConfig) { 15 | this._getConfig = getConfig; 16 | this._query = query; 17 | this._listeners = []; 18 | } 19 | 20 | get matches() { 21 | return mediaQuery.match(this._query, this._getConfig()); 22 | } 23 | 24 | get media() { 25 | return this._query; 26 | } 27 | 28 | addListener(listener) { 29 | if (!ExecutionEnvironment.canUseDOM) { 30 | return; 31 | } 32 | 33 | if (!includes(this._listeners, listener)) { 34 | this._listeners.push(listener); 35 | } 36 | } 37 | 38 | addEventListener(event, callback) { 39 | return this.addListener(callback); 40 | } 41 | 42 | removeEventListener(event, callback) { 43 | return this.removeListener(callback); 44 | } 45 | 46 | removeListener(listener) { 47 | pull(this._listeners, listener); 48 | } 49 | 50 | callListeners() { 51 | this._listeners.forEach((listener) => listener(this)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/unit/helpers.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, it, expect } from "vitest"; 2 | import * as store from "../../src/store.js"; 3 | import { 4 | calculateBreakpointsToRender, 5 | calculateOrientationsToRender, 6 | calculateThemesToRender, 7 | calculateMotionToRender, 8 | } from "../../src/helpers"; 9 | import { sanitiseBreakpoints } from "../../src/validation"; 10 | import { bootstrap5 } from "../../src/presets"; 11 | 12 | describe("helpers.js", () => { 13 | beforeAll(() => { 14 | const available = sanitiseBreakpoints(bootstrap5); 15 | store.setAvailableBreakpoints(available); 16 | }); 17 | 18 | it("returns the correct breakpoints for a single breakpoint", () => { 19 | const breakpointsToRender = calculateBreakpointsToRender( 20 | "md", 21 | store.availableBreakpoints 22 | ); 23 | const expected = ["md"]; 24 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 25 | }); 26 | 27 | it("returns the correct breakpoints for a plus range", () => { 28 | const breakpointsToRender = calculateBreakpointsToRender( 29 | "xl+", 30 | store.availableBreakpoints 31 | ); 32 | const expected = ["xl", "xxl"]; 33 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 34 | }); 35 | 36 | it("returns the correct breakpoints for a minus range", () => { 37 | const breakpointsToRender = calculateBreakpointsToRender( 38 | "lg-", 39 | store.availableBreakpoints 40 | ); 41 | const expected = ["xs", "sm", "md", "lg"]; 42 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 43 | }); 44 | 45 | it("returns the correct breakpoints for a from-to range", () => { 46 | const breakpointsToRender = calculateBreakpointsToRender( 47 | "sm-lg", 48 | store.availableBreakpoints 49 | ); 50 | const expected = ["sm", "md", "lg"]; 51 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 52 | }); 53 | 54 | it("returns the correct breakpoints for an array of breakpoints", () => { 55 | const breakpointsToRender = calculateBreakpointsToRender( 56 | ["xs", "xxl"], 57 | store.availableBreakpoints 58 | ); 59 | const expected = ["xs", "xxl"]; 60 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 61 | }); 62 | 63 | it("returns all breakpoints for an undefined breakpoint", () => { 64 | const breakpointsToRender = calculateBreakpointsToRender( 65 | undefined, 66 | store.availableBreakpoints 67 | ); 68 | const expected = ["xs", "sm", "md", "lg", "xl", "xxl"]; 69 | expect(breakpointsToRender).toEqual(expect.arrayContaining(expected)); 70 | }); 71 | 72 | /* ****************************************** 73 | * ORIENTATIONS 74 | ****************************************** */ 75 | it("returns all orientations when none specified", () => { 76 | const orientationsToRender = calculateOrientationsToRender(); 77 | const expected = ["landscape", "portrait"]; 78 | expect(orientationsToRender).toEqual(expect.arrayContaining(expected)); 79 | }); 80 | 81 | it("returns both orientations when both specified", () => { 82 | const orientationsToRender = calculateOrientationsToRender(true, true); 83 | const expected = ["landscape", "portrait"]; 84 | expect(orientationsToRender).toEqual(expect.arrayContaining(expected)); 85 | }); 86 | 87 | it("returns portrait correctly", () => { 88 | const orientationsToRender = calculateOrientationsToRender(false, true); 89 | const expected = ["portrait"]; 90 | expect(orientationsToRender).toEqual(expect.arrayContaining(expected)); 91 | }); 92 | 93 | it("returns landscape correctly", () => { 94 | const orientationsToRender = calculateOrientationsToRender(true, false); 95 | const expected = ["landscape"]; 96 | expect(orientationsToRender).toEqual(expect.arrayContaining(expected)); 97 | }); 98 | 99 | /* ****************************************** 100 | * THEME 101 | ****************************************** */ 102 | it("returns all themes when none specified", () => { 103 | const themesToRender = calculateThemesToRender(); 104 | const expected = ["dark", "light"]; 105 | expect(themesToRender).toEqual(expect.arrayContaining(expected)); 106 | }); 107 | 108 | it("returns both themes when both specified", () => { 109 | const themesToRender = calculateThemesToRender(true, true); 110 | const expected = ["dark", "light"]; 111 | expect(themesToRender).toEqual(expect.arrayContaining(expected)); 112 | }); 113 | 114 | it("returns light correctly", () => { 115 | const themesToRender = calculateThemesToRender(false, true); 116 | const expected = ["light"]; 117 | expect(themesToRender).toEqual(expect.arrayContaining(expected)); 118 | }); 119 | 120 | it("returns dark correctly", () => { 121 | const themesToRender = calculateThemesToRender(true, false); 122 | const expected = ["dark"]; 123 | expect(themesToRender).toEqual(expect.arrayContaining(expected)); 124 | }); 125 | 126 | /* ****************************************** 127 | * MOTION 128 | ****************************************** */ 129 | it("returns all motion preferences when none specified", () => { 130 | const motionToRender = calculateMotionToRender(); 131 | const expected = ["reduce", "no-preference"]; 132 | expect(motionToRender).toEqual(expect.arrayContaining(expected)); 133 | }); 134 | 135 | it("returns both motion preferences when both specified", () => { 136 | const motionToRender = calculateMotionToRender(true, true); 137 | const expected = ["reduce", "no-preference"]; 138 | expect(motionToRender).toEqual(expect.arrayContaining(expected)); 139 | }); 140 | 141 | it("returns inert correctly", () => { 142 | const motionToRender = calculateMotionToRender(true, false); 143 | const expected = ["reduce"]; 144 | expect(motionToRender).toEqual(expect.arrayContaining(expected)); 145 | }); 146 | 147 | it("returns motion correctly", () => { 148 | const motionToRender = calculateMotionToRender(false, true); 149 | const expected = ["no-preference"]; 150 | expect(motionToRender).toEqual(expect.arrayContaining(expected)); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /tests/unit/plugin.spec.js: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | 3 | import { describe, it, expect, vi, beforeEach } from "vitest"; 4 | 5 | import plugin from "../../src/plugin.js"; 6 | import MqResponsive from "../../src/component.js"; 7 | import { mount } from "@vue/test-utils"; 8 | import { h } from "vue"; 9 | import MatchMediaMock from "../mock/MatchMediaMock"; 10 | import * as store from "../../src/store.js"; 11 | 12 | describe("plugin.js", () => { 13 | let results; 14 | let matchMediaMock; 15 | beforeEach(() => { 16 | results = new Set(); 17 | matchMediaMock = MatchMediaMock.create(); 18 | matchMediaMock.setConfig({ type: "screen", width: 1200 }); 19 | window.matchMedia = vi.fn((...args) => { 20 | const result = matchMediaMock(...args); 21 | results.add(result); 22 | return result; 23 | }); 24 | }); 25 | 26 | it("should register $mq property", () => { 27 | const wrapper = mount( 28 | { 29 | render() { 30 | return h("div"); 31 | }, 32 | inject: ["mq"], 33 | }, 34 | { global: { plugins: [plugin] }, shallow: true } 35 | ); 36 | expect("mq" in wrapper.vm).toBe(true); 37 | }); 38 | 39 | it("should default to defaultBreakpoint in options", () => { 40 | matchMediaMock.setConfig({}); 41 | const plugins = [ 42 | [ 43 | plugin, 44 | { 45 | defaultBreakpoint: "md", 46 | }, 47 | ], 48 | ]; 49 | const wrapper = mount( 50 | { 51 | template: "
", 52 | inject: ["mq"], 53 | }, 54 | { 55 | global: { 56 | plugins, 57 | }, 58 | shallow: true, 59 | } 60 | ); 61 | expect(wrapper.vm.mq.current).toBe("md"); 62 | }); 63 | 64 | it("should mount with a preset and set the correct breakpoints", () => { 65 | matchMediaMock.setConfig({}); 66 | const plugins = [ 67 | [ 68 | plugin, 69 | { 70 | preset: "devices", 71 | }, 72 | ], 73 | ]; 74 | const wrapper = mount( 75 | { 76 | render() { 77 | return h("div"); 78 | }, 79 | inject: ["mq"], 80 | }, 81 | { 82 | global: { 83 | plugins, 84 | }, 85 | shallow: true, 86 | } 87 | ); 88 | 89 | matchMediaMock.setConfig({ type: "screen", width: 1920 }); 90 | Array.from(results)[1].callListeners(); 91 | expect(wrapper.vm.mq.current).toBe("desktop"); 92 | }); 93 | 94 | it("should subscribe to media queries", () => { 95 | const wrapper = mount( 96 | { 97 | render() { 98 | return h("div"); 99 | }, 100 | inject: ["mq"], 101 | }, 102 | { global: { plugins: [plugin] }, shallow: true } 103 | ); 104 | expect(typeof window).not.toBe("undefined"); 105 | expect(window.matchMedia).toBeCalledWith("(min-width: 1400px)"); 106 | expect(window.matchMedia).toBeCalledWith( 107 | "(min-width: 1200px) and (max-width: 1399px)" 108 | ); 109 | expect(window.matchMedia).toBeCalledWith( 110 | "(min-width: 992px) and (max-width: 1199px)" 111 | ); 112 | expect(window.matchMedia).toBeCalledWith( 113 | "(min-width: 768px) and (max-width: 991px)" 114 | ); 115 | expect(window.matchMedia).toBeCalledWith( 116 | "(min-width: 576px) and (max-width: 767px)" 117 | ); 118 | expect(window.matchMedia).toBeCalledWith( 119 | "(min-width: 0px) and (max-width: 575px)" 120 | ); 121 | }); 122 | 123 | it("should set $mq accordingly when media query change", () => { 124 | const wrapper = mount( 125 | { 126 | render() { 127 | return h("div"); 128 | }, 129 | inject: ["mq"], 130 | }, 131 | { global: { plugins: [plugin] }, shallow: true } 132 | ); 133 | matchMediaMock.setConfig({ type: "screen", width: 700 }); 134 | Array.from(results)[1].callListeners(); 135 | expect(wrapper.vm.mq.current).toBe("sm"); 136 | 137 | matchMediaMock.setConfig({ type: "screen", width: 800 }); 138 | Array.from(results)[1].callListeners(); 139 | expect(wrapper.vm.mq.current).toBe("md"); 140 | 141 | matchMediaMock.setConfig({ type: "screen", width: 1000 }); 142 | Array.from(results)[1].callListeners(); 143 | expect(wrapper.vm.mq.current).toBe("lg"); 144 | 145 | matchMediaMock.setConfig({ type: "screen", width: 1300 }); 146 | Array.from(results)[1].callListeners(); 147 | expect(wrapper.vm.mq.current).toBe("xl"); 148 | 149 | matchMediaMock.setConfig({ type: "screen", width: 1920 }); 150 | Array.from(results)[1].callListeners(); 151 | expect(wrapper.vm.mq.current).toBe("xxl"); 152 | }); 153 | 154 | it("should mount the mq-responsive component", () => { 155 | store.mqAvailableBreakpoints = { 156 | value: { 157 | xs: 0, 158 | sm: 576, 159 | md: 768, 160 | lg: 992, 161 | xl: 1200, 162 | xxl: 1400, 163 | }, 164 | }; 165 | store.updateState("xl"); 166 | const wrapper = mount(MqResponsive, { 167 | shallow: false, 168 | slots: { 169 | default: "

This is a test

", 170 | }, 171 | props: { 172 | target: "xl", 173 | }, 174 | }); 175 | expect(wrapper.html()).toContain("This is a test"); 176 | }); 177 | 178 | it("should mount group list items", () => { 179 | matchMediaMock.setConfig({}); 180 | const plugins = [ 181 | [ 182 | plugin, 183 | { 184 | defaultBreakpoint: "md", 185 | defaultOrientation: "landscape", 186 | defaultTheme: "dark", 187 | }, 188 | ], 189 | ]; 190 | 191 | const wrapper = mount(MqResponsive, { 192 | shallow: false, 193 | global: { 194 | plugins, 195 | }, 196 | slots: { 197 | xs: "This is xs", 198 | sm: "This is sm", 199 | "sm-lg": "This is sm-lg", 200 | "md+": "This is md+", 201 | "md+:2": "This is md+:2", 202 | "lg-": "This is lg-", 203 | lg: "This is lg", 204 | landscape: "This is landscape", 205 | portrait: "This is portrait", 206 | light: "This is light", 207 | dark: "This is dark", 208 | inert: "This is reduced motion", 209 | motion: "This is no motion preference", 210 | "md:landscape:dark": "This is md:landscape:dark", 211 | "md:portrait:dark": "This is md:portrait:dark", 212 | }, 213 | props: { 214 | group: true, 215 | tag: "ul", 216 | listTag: "li", 217 | }, 218 | }); 219 | 220 | expect(wrapper.html()).not.toContain("
  • This is xs
  • "); 221 | expect(wrapper.html()).not.toContain("
  • This is sm
  • "); 222 | expect(wrapper.html()).toContain("
  • This is sm-lg
  • "); 223 | expect(wrapper.html()).toContain("
  • This is md+
  • "); 224 | expect(wrapper.html()).toContain("
  • This is md+:2
  • "); 225 | expect(wrapper.html()).toContain("
  • This is lg-
  • "); 226 | expect(wrapper.html()).not.toContain("
  • This is lg
  • "); 227 | expect(wrapper.html()).toContain("
  • This is landscape
  • "); 228 | expect(wrapper.html()).not.toContain("
  • This is portrait
  • "); 229 | expect(wrapper.html()).not.toContain("
  • This is light
  • "); 230 | expect(wrapper.html()).toContain("
  • This is dark
  • "); 231 | expect(wrapper.html()).not.toContain("
  • This is reduced motion
  • "); 232 | expect(wrapper.html()).toContain( 233 | "
  • This is no motion preference
  • " 234 | ); 235 | expect(wrapper.html()).toContain("
  • This is md:landscape:dark
  • "); 236 | expect(wrapper.html()).not.toContain( 237 | "
  • This is md:portrait:dark
  • " 238 | ); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /tests/unit/store.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, it, expect } from "vitest"; 2 | 3 | import * as store from "../../src/store.js"; 4 | import { sanitiseBreakpoints } from "../../src/validation"; 5 | import { bootstrap5 } from "../../src/presets"; 6 | 7 | describe("store.js", () => { 8 | beforeAll(() => { 9 | const available = sanitiseBreakpoints(bootstrap5); 10 | store.setAvailableBreakpoints(available); 11 | }); 12 | 13 | it("updates the MQ state correctly", () => { 14 | store.updateState("md"); 15 | 16 | expect(store.mqState.current).toBe("md"); 17 | 18 | expect(store.mqState.md).toBeTruthy(); 19 | expect(store.mqState.mdPlus).toBeTruthy(); 20 | expect(store.mqState.mdMinus).toBeTruthy(); 21 | 22 | expect(store.mqState.xs).toBeFalsy(); 23 | 24 | expect(store.mqState.sm).toBeFalsy(); 25 | expect(store.mqState.smPlus).toBeTruthy(); 26 | expect(store.mqState.smMinus).toBeFalsy(); 27 | 28 | expect(store.mqState.lg).toBeFalsy(); 29 | expect(store.mqState.lgPlus).toBeFalsy(); 30 | expect(store.mqState.lgMinus).toBeTruthy(); 31 | 32 | expect(store.mqState.xl).toBeFalsy(); 33 | expect(store.mqState.xlPlus).toBeFalsy(); 34 | expect(store.mqState.xlMinus).toBeTruthy(); 35 | 36 | expect(store.mqState.xxl).toBeFalsy(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/unit/validation.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import * as validation from "../../src/validation"; 3 | import * as presets from "../../src/presets"; 4 | 5 | describe("validation.js", () => { 6 | let spy, warningSpy; 7 | beforeEach(() => { 8 | spy = vi.spyOn(global.console, "error").mockImplementation(() => {}); 9 | warningSpy = vi 10 | .spyOn(global.console, "warn") 11 | .mockImplementation(() => {}); 12 | }); 13 | 14 | afterEach(() => { 15 | spy.mockRestore(); 16 | warningSpy.mockRestore(); 17 | }); 18 | 19 | it("should validate a correct preset", () => { 20 | const validatedPreset = validation.validatePreset("vuetify"); 21 | expect(validatedPreset).toEqual(presets.vuetify); 22 | expect(spy).not.toHaveBeenCalled(); 23 | }); 24 | 25 | it("should fail to validate an incorrect preset", () => { 26 | const validatedPreset = validation.validatePreset("asdfasdf"); 27 | expect(validatedPreset).toBeFalsy(); 28 | expect(spy).toHaveBeenCalledTimes(1); 29 | }); 30 | 31 | it("should validate a correct default orientation", () => { 32 | const validatedOrientation = validation.validateOrientation("portrait"); 33 | expect(validatedOrientation).toEqual("portrait"); 34 | expect(spy).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it("should fail to validate an incorrect default orientation", () => { 38 | const validatedOrientation = 39 | validation.validateOrientation("something"); 40 | expect(validatedOrientation).toBeNull(); 41 | expect(spy).toHaveBeenCalled(); 42 | }); 43 | 44 | it("should validate a correct default theme", () => { 45 | const validatedTheme = validation.validateTheme("dark"); 46 | expect(validatedTheme).toEqual("dark"); 47 | expect(spy).not.toHaveBeenCalled(); 48 | }); 49 | 50 | it("should fail to validate an incorrect default theme", () => { 51 | const validatedTheme = validation.validateTheme("something"); 52 | expect(validatedTheme).toBeNull(); 53 | expect(spy).toHaveBeenCalled(); 54 | }); 55 | 56 | it("should validate a correct default motion preference", () => { 57 | const validatedMotion = validation.validateMotion("reduce"); 58 | expect(validatedMotion).toEqual("reduce"); 59 | expect(spy).not.toHaveBeenCalled(); 60 | }); 61 | 62 | it("should fail to validate an incorrect default motion preference", () => { 63 | const validatedMotion = validation.validateMotion("rollercoaster"); 64 | expect(validatedMotion).toBeNull(); 65 | expect(spy).toHaveBeenCalled(); 66 | }); 67 | 68 | it("sanitises a valid set of breakpoints", () => { 69 | const sanitisedBreakpoints = validation.sanitiseBreakpoints( 70 | presets.tailwind 71 | ); 72 | const expected = [ 73 | { name: "xs", min: 0 }, 74 | { name: "sm", min: 640 }, 75 | { name: "md", min: 768 }, 76 | { name: "lg", min: 1024 }, 77 | { name: "xl", min: 1280 }, 78 | { name: "xxl", min: 1536 }, 79 | ]; 80 | expect(sanitisedBreakpoints).toEqual(expect.arrayContaining(expected)); 81 | }); 82 | 83 | it("shows a warning and skips an invalid breakpoint key", () => { 84 | const sanitisedBreakpoints = validation.sanitiseBreakpoints({ 85 | xs: 0, 86 | sm: 500, 87 | "7md": 1000, 88 | lg: 1920, 89 | }); 90 | 91 | const expected = [ 92 | { name: "xs", min: 0 }, 93 | { name: "sm", min: 500 }, 94 | { name: "lg", min: 1920 }, 95 | ]; 96 | 97 | expect(warningSpy).toHaveBeenCalled(); 98 | expect(sanitisedBreakpoints).toHaveLength(3); 99 | expect(sanitisedBreakpoints).toEqual(expect.arrayContaining(expected)); 100 | expect(sanitisedBreakpoints).toEqual( 101 | expect.not.arrayContaining([{ name: "7md", min: 1000 }]) 102 | ); 103 | }); 104 | 105 | it("shows a warning and skips an invalid breakpoint value", () => { 106 | const sanitisedBreakpoints = validation.sanitiseBreakpoints({ 107 | xs: 0, 108 | sm: 500, 109 | md: -4, 110 | lg: "squirrel", 111 | }); 112 | 113 | const expected = [ 114 | { name: "xs", min: 0 }, 115 | { name: "sm", min: 500 }, 116 | ]; 117 | 118 | expect(warningSpy).toHaveBeenCalledTimes(2); 119 | expect(sanitisedBreakpoints).toHaveLength(2); 120 | expect(sanitisedBreakpoints).toEqual(expect.arrayContaining(expected)); 121 | expect(sanitisedBreakpoints).toEqual( 122 | expect.not.arrayContaining([{ name: "lg", min: "squirrel" }]) 123 | ); 124 | }); 125 | 126 | it("can extract slot names properly", () => { 127 | const slotOptions = validation.extractSlotNameProperties( 128 | "sm-lg:dark:inert:portrait:2" 129 | ); 130 | 131 | expect(slotOptions).toHaveProperty("slotBp", "sm-lg"); 132 | expect(slotOptions).toHaveProperty("slotTheme", "dark"); 133 | expect(slotOptions).toHaveProperty("slotOrientation", "portrait"); 134 | expect(slotOptions).toHaveProperty("slotMotion", "inert"); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.js", "src/**/*.vue"], 3 | "module": "esnext", 4 | "compilerOptions": { 5 | "types": ["./src/global.d.ts"], 6 | "target": "esnext", 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "node", 13 | "emitDeclarationOnly": true, 14 | "allowSyntheticDefaultImports": true, 15 | "outDir": "types", 16 | "declarationMap": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import vue from "@vitejs/plugin-vue"; 2 | import path from "path"; 3 | 4 | export default { 5 | plugins: [vue()], 6 | build: { 7 | lib: { 8 | entry: path.resolve(__dirname, "src/index.js"), 9 | name: "Vue3Mq", 10 | formats: ["es", "umd", "cjs", "iife"], 11 | }, 12 | rollupOptions: { 13 | external: ["vue"], 14 | output: { 15 | globals: { 16 | vue: "Vue", 17 | }, 18 | }, 19 | }, 20 | }, 21 | test: { 22 | include: ["tests/**/*.{test,spec}.?(c|m)[jt]s?(x)"], 23 | }, 24 | }; 25 | --------------------------------------------------------------------------------