Website Structure

This commit is contained in:
supalerk-ar66 2026-01-13 10:46:40 +07:00
parent 62812f2090
commit 71f0676a62
22365 changed files with 4265753 additions and 791 deletions

View file

@ -0,0 +1,65 @@
import { client } from '../platform/Platform.js'
import { noop } from '../../utils/event/event.js'
import getCssVar from '../../utils/css-var/get-css-var.js'
let metaValue
function getProp () {
return client.is.winphone
? 'msapplication-navbutton-color'
: 'theme-color' // Safari, Chrome, ...
}
function getMetaTag (v) {
const els = document.getElementsByTagName('META')
for (const i in els) {
if (els[ i ].name === v) {
return els[ i ]
}
}
}
function setColor (hexColor) {
if (metaValue === void 0) {
// cache it
metaValue = getProp()
}
let metaTag = getMetaTag(metaValue)
const newTag = metaTag === void 0
if (newTag) {
metaTag = document.createElement('meta')
metaTag.setAttribute('name', metaValue)
}
metaTag.setAttribute('content', hexColor)
if (newTag) {
document.head.appendChild(metaTag)
}
}
export default {
set: __QUASAR_SSR_SERVER__ !== true && client.is.mobile === true && (
client.is.nativeMobile === true
|| client.is.winphone === true || client.is.safari === true
|| client.is.webkit === true || client.is.vivaldi === true
)
? hexColor => {
const val = hexColor || getCssVar('primary')
if (client.is.nativeMobile === true && window.StatusBar) {
window.StatusBar.backgroundColorByHexString(val)
}
else {
setColor(val)
}
}
: noop,
install ({ $q }) {
$q.addressbarColor = this
$q.config.addressbarColor && this.set($q.config.addressbarColor)
}
}

View file

@ -0,0 +1,22 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/addressbar-color"
},
"injection": "$q.addressbarColor",
"methods": {
"set": {
"desc": "Sets addressbar color (for browsers that support it)",
"params": {
"hexColor": {
"type": "String",
"desc": "Color in hex format",
"required": true,
"examples": [ "'#ff0000'" ]
}
},
"returns": null
}
}
}

View file

@ -0,0 +1,53 @@
import { describe, test, expect, vi } from 'vitest'
import { mount, config } from '@vue/test-utils'
import AddressbarColor from './AddressbarColor.js'
// We override Quasar install so it installs this plugin
const quasarVuePlugin = config.global.plugins.find(entry => entry.name === 'Quasar')
const { install } = quasarVuePlugin
function mountPlugin (addressbarColor) {
quasarVuePlugin.install = app => install(app, {
config: { addressbarColor },
plugins: { AddressbarColor }
})
return mount({ template: '<div />' })
}
describe('[AddressbarColor API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(AddressbarColor).toBe(wrapper.vm.$q.addressbarColor)
})
})
describe('[Methods]', () => {
describe('[(method)set]', () => {
test('should be callable', () => {
mountPlugin()
expect(
AddressbarColor.set('#ff0000')
).toBeUndefined()
})
test('should be called automatically when $q.config.addressbarColor is set', () => {
const original = AddressbarColor.set
// override original since the real test would be on a
// mobile platform (and we can't test that here, yet)
AddressbarColor.set = vi.fn()
mountPlugin('#aabbcc')
expect.soft(AddressbarColor.set).toHaveBeenCalledTimes(1)
expect.soft(AddressbarColor.set).toHaveBeenCalledWith('#aabbcc')
// restore original
AddressbarColor.set = original
})
})
})
})

View file

@ -0,0 +1,127 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { changeGlobalNodesTarget } from '../../utils/private.config/nodes.js'
const prefixes = {}
function assignFn (fn) {
Object.assign(Plugin, {
request: fn,
exit: fn,
toggle: fn
})
}
function getFullscreenElement () {
return (
document.fullscreenElement
|| document.mozFullScreenElement
|| document.webkitFullscreenElement
|| document.msFullscreenElement
|| null
)
}
function updateEl () {
const newEl = Plugin.activeEl = Plugin.isActive === false
? null
: getFullscreenElement()
changeGlobalNodesTarget(
newEl === null || newEl === document.documentElement
? document.body
: newEl
)
}
function togglePluginState () {
Plugin.isActive = Plugin.isActive === false
updateEl()
}
// needed for consistency across browsers
function promisify (target, fn) {
try {
const res = target[ fn ]()
return res === void 0
? Promise.resolve()
: res
}
catch (err) {
return Promise.reject(err)
}
}
const Plugin = createReactivePlugin({
isActive: false,
activeEl: null
}, {
isCapable: false,
install ({ $q }) {
$q.fullscreen = this
}
})
if (__QUASAR_SSR_SERVER__ === true) {
assignFn(() => Promise.resolve())
}
else {
prefixes.request = [
'requestFullscreen',
'msRequestFullscreen', 'mozRequestFullScreen', 'webkitRequestFullscreen'
].find(request => document.documentElement[ request ] !== void 0)
Plugin.isCapable = prefixes.request !== void 0
if (Plugin.isCapable === false) {
// it means the browser does NOT support it
assignFn(() => Promise.reject('Not capable'))
}
else {
Object.assign(Plugin, {
request (target) {
const el = target || document.documentElement
const { activeEl } = Plugin
if (el === activeEl) {
return Promise.resolve()
}
const queue = activeEl !== null && el.contains(activeEl) === true
? Plugin.exit()
: Promise.resolve()
return queue.finally(() => promisify(el, prefixes.request))
},
exit () {
return Plugin.isActive === true
? promisify(document, prefixes.exit)
: Promise.resolve()
},
toggle (target) {
return Plugin.isActive === true
? Plugin.exit()
: Plugin.request(target)
}
})
prefixes.exit = [
'exitFullscreen',
'msExitFullscreen', 'mozCancelFullScreen', 'webkitExitFullscreen'
].find(exit => document[ exit ])
Plugin.isActive = Boolean(getFullscreenElement())
Plugin.isActive === true && updateEl()
;[
'onfullscreenchange',
'onmsfullscreenchange', 'onwebkitfullscreenchange'
].forEach(evt => {
document[ evt ] = togglePluginState
})
}
}
export default Plugin

View file

@ -0,0 +1,77 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/app-fullscreen"
},
"injection": "$q.fullscreen",
"props": {
"isCapable": {
"type": "Boolean",
"desc": "Does browser support it?"
},
"isActive": {
"type": "Boolean",
"desc": "Is Fullscreen active?",
"reactive": true
},
"activeEl": {
"type": [ "Element", "null" ],
"desc": "The DOM element used as root for fullscreen, otherwise 'null'",
"reactive": true,
"examples": [ "document.fullscreenElement", "null" ]
}
},
"methods": {
"request": {
"desc": "Request going into Fullscreen (with optional target)",
"params": {
"target": {
"type": "Element",
"desc": "Optional Element of target to request Fullscreen on",
"examples": [ "document.getElementById('example')" ]
}
},
"returns": {
"type": "Promise<void>",
"desc": "A Promise which is resolved when transitioned to fullscreen mode. It gets rejected with 'Not capable' if the browser is not capable, and with an Error object if something else went wrong.",
"examples": [
"request().then(response => { ... }).catch(err => { ... })"
]
}
},
"exit": {
"desc": "Request exiting out of Fullscreen mode",
"params": null,
"returns": {
"type": "Promise<void>",
"desc": "A Promise which is resolved when exited out of fullscreen mode. It gets rejected with 'Not capable' if the browser is not capable, and with an Error object if something else went wrong.",
"examples": [
"exit().then(response => { ... }).catch(err => { ... })"
]
}
},
"toggle": {
"desc": "Request toggling Fullscreen mode (with optional target if requesting going into Fullscreen only)",
"params": {
"target": {
"type": "Element",
"desc": "Optional Element of target to request Fullscreen on",
"examples": [ "document.getElementById('example')" ]
}
},
"returns": {
"type": "Promise<void>",
"desc": "A Promise which is resolved when transitioned to / exited out of fullscreen mode. It gets rejected with 'Not capable' if the browser is not capable, and with an Error object if something else went wrong.",
"examples": [
"toggle().then(response => { ... }).catch(err => { ... })"
]
}
}
}
}

View file

@ -0,0 +1,206 @@
import { describe, test, expect, afterEach } from 'vitest'
import { mount, config } from '@vue/test-utils'
// jsdom hack
// this import should always sit before the AppFullscreen one
import { createMockedEl } from './test/mock-fullscreen.js'
import AppFullscreen from './AppFullscreen.js'
const mountPlugin = () => mount({ template: '<div />' })
// We override Quasar install so it installs this plugin
const quasarVuePlugin = config.global.plugins.find(entry => entry.name === 'Quasar')
const { install } = quasarVuePlugin
quasarVuePlugin.install = app => install(app, { plugins: { AppFullscreen } })
afterEach(async () => {
// ensure we don't leave test in fullscreen state
await AppFullscreen.exit()
})
describe('[AppFullscreen API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(AppFullscreen).toMatchObject(wrapper.vm.$q.fullscreen)
})
})
describe('[Props]', () => {
describe('[(prop)isCapable]', () => {
test('is correct type', () => {
mountPlugin()
expect(AppFullscreen.isCapable).toBeTypeOf('boolean')
})
})
describe('[(prop)isActive]', () => {
test('is correct type', () => {
mountPlugin()
expect(AppFullscreen.isActive).toBeTypeOf('boolean')
})
test('is reactive', async () => {
mountPlugin()
expect(AppFullscreen.isActive).toBe(false)
await AppFullscreen.request()
expect(AppFullscreen.isActive).toBe(true)
await AppFullscreen.exit()
expect(AppFullscreen.isActive).toBe(false)
})
})
describe('[(prop)activeEl]', () => {
test('is correct type', () => {
mountPlugin()
expect(AppFullscreen.activeEl).$any([
expect.any(Element),
null
])
})
test('is reactive', async () => {
mountPlugin()
expect(AppFullscreen.activeEl).toBe(null)
await AppFullscreen.request()
expect(AppFullscreen.activeEl).not.toBe(null)
await AppFullscreen.exit()
expect(AppFullscreen.activeEl).toBe(null)
})
})
})
describe('[Methods]', () => {
describe('[(method)request]', () => {
test('request()', async () => {
mountPlugin()
const result = AppFullscreen.request()
expect(
result
).toBeInstanceOf(Promise)
await result
expect(AppFullscreen.isActive).toBe(true)
expect(AppFullscreen.activeEl).not.toBe(null)
await AppFullscreen.exit()
expect(AppFullscreen.isActive).toBe(false)
expect(AppFullscreen.activeEl).toBe(null)
})
test('request(el)', async () => {
mountPlugin()
const el = createMockedEl()
const result = AppFullscreen.request(el)
expect(
result
).toBeInstanceOf(Promise)
await result
expect(el.requestFullscreen).toHaveBeenCalledTimes(1)
expect(AppFullscreen.isActive).toBe(true)
expect(AppFullscreen.activeEl).toBe(el)
await AppFullscreen.exit()
expect(AppFullscreen.isActive).toBe(false)
expect(AppFullscreen.activeEl).toBe(null)
})
test('call request(el) 2 times', async () => {
mountPlugin()
const el = createMockedEl()
const result = AppFullscreen.request(el)
expect(
result
).toBeInstanceOf(Promise)
await result
expect(el.requestFullscreen).toHaveBeenCalledTimes(1)
expect(AppFullscreen.activeEl).toBe(el)
await AppFullscreen.request(el)
expect(el.requestFullscreen).toHaveBeenCalledTimes(1)
expect(AppFullscreen.activeEl).toBe(el)
await AppFullscreen.exit()
expect(AppFullscreen.activeEl).toBe(null)
})
})
describe('[(method)exit]', () => {
test('should be callable', () => {
mountPlugin()
expect(
AppFullscreen.exit()
).toBeInstanceOf(Promise)
})
test('request() + exit()', async () => {
mountPlugin()
const result = AppFullscreen.request()
expect(
result
).toBeInstanceOf(Promise)
await result
expect(AppFullscreen.isActive).toBe(true)
expect(AppFullscreen.activeEl).not.toBe(null)
await AppFullscreen.exit()
expect(AppFullscreen.isActive).toBe(false)
expect(AppFullscreen.activeEl).toBe(null)
})
})
describe('[(method)toggle]', () => {
test('toggle()', async () => {
mountPlugin()
const result = AppFullscreen.toggle()
expect(
result
).toBeInstanceOf(Promise)
await result
expect(AppFullscreen.isActive).toBe(true)
expect(AppFullscreen.activeEl).not.toBe(null)
await AppFullscreen.toggle()
expect(AppFullscreen.isActive).toBe(false)
expect(AppFullscreen.activeEl).toBe(null)
})
test('toggle(el)', async () => {
mountPlugin()
const el = createMockedEl()
const result = AppFullscreen.toggle(el)
expect(
result
).toBeInstanceOf(Promise)
await result
expect(el.requestFullscreen).toHaveBeenCalledTimes(1)
expect(AppFullscreen.isActive).toBe(true)
expect(AppFullscreen.activeEl).toBe(el)
await AppFullscreen.toggle()
expect(AppFullscreen.isActive).toBe(false)
expect(AppFullscreen.activeEl).toBe(null)
})
})
})
})

View file

@ -0,0 +1,43 @@
import { vi, onTestFinished } from 'vitest'
/**
* jsdom does not support Fullscreen API,
* so we mock the functionality
*/
export function mockedRequestFullscreen (el = document.documentElement) {
document.fullscreenElement = el
mockedToggleFullscreen()
}
export function mockedExitFullscreen () {
document.fullscreenElement = null
mockedToggleFullscreen()
}
export function mockedToggleFullscreen () {
document.onfullscreenchange()
}
export function createMockedEl () {
const el = document.createElement('div')
el.setAttribute('tabindex', '0')
document.body.appendChild(el)
el.requestFullscreen = vi.fn(() => {
document.fullscreenElement = el
mockedToggleFullscreen()
})
el.exitFullscreen = mockedExitFullscreen
onTestFinished(() => { el.remove() })
return el
}
document.documentElement.requestFullscreen = mockedRequestFullscreen
document.documentElement.exitFullscreen = mockedExitFullscreen
document.requestFullscreen = mockedRequestFullscreen
document.exitFullscreen = mockedExitFullscreen

View file

@ -0,0 +1,39 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { injectProp } from '../../utils/private.inject-obj-prop/inject-obj-prop.js'
const Plugin = createReactivePlugin({
appVisible: true
}, {
install ({ $q }) {
if (__QUASAR_SSR_SERVER__) {
this.appVisible = $q.appVisible = true
return
}
injectProp($q, 'appVisible', () => this.appVisible)
}
})
if (__QUASAR_SSR_SERVER__ !== true) {
let prop, evt
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
prop = 'hidden'
evt = 'visibilitychange'
}
else if (typeof document.msHidden !== 'undefined') {
prop = 'msHidden'
evt = 'msvisibilitychange'
}
else if (typeof document.webkitHidden !== 'undefined') {
prop = 'webkitHidden'
evt = 'webkitvisibilitychange'
}
if (evt && typeof document[ prop ] !== 'undefined') {
const update = () => { Plugin.appVisible = !document[ prop ] }
document.addEventListener(evt, update, false)
}
}
export default Plugin

View file

@ -0,0 +1,16 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/app-visibility"
},
"injection": "$q.appVisible",
"props": {
"appVisible": {
"tsInjectionPoint": true,
"type": "Boolean",
"desc": "Does the app have user focus? Or the app runs in the background / another tab has the user's attention",
"reactive": true
}
}
}

View file

@ -0,0 +1,45 @@
import { describe, test, expect } from 'vitest'
import { mount, config } from '@vue/test-utils'
import AppVisibility from './AppVisibility.js'
const mountPlugin = () => mount({ template: '<div />' })
// We override Quasar install so it installs this plugin
const quasarVuePlugin = config.global.plugins.find(entry => entry.name === 'Quasar')
const { install } = quasarVuePlugin
quasarVuePlugin.install = app => install(app, { plugins: { AppVisibility } })
describe('[AppVisibility API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(wrapper.vm.$q.appVisible).toBe(AppVisibility.appVisible)
})
})
describe('[Props]', () => {
describe('[(prop)appVisible]', () => {
test('is correct type', () => {
mountPlugin()
expect(AppVisibility.appVisible).toBe(true)
})
test('is reactive', () => {
const wrapper = mountPlugin()
expect(AppVisibility.appVisible).toBe(true)
// jsdom hack
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true
})
document.dispatchEvent(new Event('visibilitychange'))
expect(AppVisibility.appVisible).toBe(false)
expect(wrapper.vm.$q.appVisible).toBe(false)
})
})
})
})

View file

@ -0,0 +1,8 @@
import BottomSheet from './component/BottomSheetComponent.js'
import globalDialog from '../../utils/private.dialog/create-dialog.js'
export default {
install ({ $q, parentApp }) {
$q.bottomSheet = this.create = globalDialog(BottomSheet, false, parentApp)
}
}

View file

@ -0,0 +1,110 @@
{
"mixins": [ "utils/private.dialog/create-dialog" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/bottom-sheet"
},
"injection": "$q.bottomSheet",
"methods": {
"create": {
"tsInjectionPoint": true,
"desc": "Creates an ad-hoc Bottom Sheet; Same as calling $q.bottomSheet(...)",
"params": {
"opts": {
"desc": "Bottom Sheet options",
"definition": {
"title": {
"type": "String",
"desc": "Title",
"examples": [ "'Share'" ]
},
"message": {
"type": "String",
"desc": "Message",
"examples": [ "'Please select how to share'" ]
},
"actions": {
"type": "Array",
"desc": "Array of Objects, each Object defining an action",
"definition": {
"classes": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "CSS classes for this action",
"examples": [ "'my-class'" ]
},
"style": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueStyleProp",
"desc": "Style definitions to be attributed to this action element",
"examples": [ "{ padding: '2px' }" ],
"addedIn": "v2.11.7"
},
"icon": {
"extends": "icon"
},
"img": {
"type": "String",
"desc": "Path to an image for this action",
"examples": [
"# (public folder) 'img/something.png'",
"# (relative path format) :src=\"require('./my_img.jpg')\"",
"# (URL) https://some-site.net/some-img.gif"
]
},
"avatar": {
"type": "String",
"desc": "Path to an avatar image for this action",
"examples": [
"# (public folder) 'img/avatar.png'",
"# (relative path format) :src=\"require('./my_img.jpg')\"",
"# (URL) https://some-site.net/some-img.gif"
]
},
"label": {
"type": [ "String", "Number" ],
"desc": "Action label",
"examples": [ "'Facebook'" ]
},
"...": {
"type": "Any",
"desc": "Any other custom props"
}
}
},
"grid": {
"type": "Boolean",
"desc": "Display actions as a grid instead of as a list"
},
"dark": {
"extends": "dark",
"desc": "Apply dark mode"
},
"seamless": {
"type": "Boolean",
"desc": "Put Bottom Sheet into seamless mode; Does not use a backdrop so user is able to interact with the rest of the page too"
},
"persistent": {
"type": "Boolean",
"desc": "User cannot dismiss Bottom Sheet if clicking outside of it or hitting ESC key; Also, an app route change won't dismiss it"
}
}
}
}
}
}
}

View file

@ -0,0 +1,183 @@
import { h, ref, getCurrentInstance } from 'vue'
import QDialog from '../../../components/dialog/QDialog.js'
import QIcon from '../../../components/icon/QIcon.js'
import QSeparator from '../../../components/separator/QSeparator.js'
import QCard from '../../../components/card/QCard.js'
import QCardSection from '../../../components/card/QCardSection.js'
import QItem from '../../../components/item/QItem.js'
import QItemSection from '../../../components/item/QItemSection.js'
import { createComponent } from '../../../utils/private.create/create.js'
import useDark, { useDarkProps } from '../../../composables/private.use-dark/use-dark.js'
export default createComponent({
name: 'BottomSheetComponent',
props: {
...useDarkProps,
title: String,
message: String,
actions: Array,
grid: Boolean,
cardClass: [ String, Array, Object ],
cardStyle: [ String, Array, Object ]
},
emits: [ 'ok', 'hide' ],
setup (props, { emit }) {
const { proxy } = getCurrentInstance()
const isDark = useDark(props, proxy.$q)
const dialogRef = ref(null)
function show () {
dialogRef.value.show()
}
function hide () {
dialogRef.value.hide()
}
function onOk (action) {
emit('ok', action)
hide()
}
function onHide () {
emit('hide')
}
function getGrid () {
return props.actions.map(action => {
const img = action.avatar || action.img
return action.label === void 0
? h(QSeparator, {
class: 'col-all',
dark: isDark.value
})
: h('div', {
class: [
'q-bottom-sheet__item q-hoverable q-focusable cursor-pointer relative-position',
action.class
],
style: action.style,
tabindex: 0,
role: 'listitem',
onClick () { onOk(action) },
onKeyup (e) { e.keyCode === 13 && onOk(action) }
}, [
h('div', { class: 'q-focus-helper' }),
action.icon
? h(QIcon, { name: action.icon, color: action.color })
: (
img
? h('img', {
class: action.avatar ? 'q-bottom-sheet__avatar' : '',
src: img
})
: h('div', { class: 'q-bottom-sheet__empty-icon' })
),
h('div', action.label)
])
})
}
function getList () {
return props.actions.map(action => {
const img = action.avatar || action.img
return action.label === void 0
? h(QSeparator, { spaced: true, dark: isDark.value })
: h(QItem, {
class: [ 'q-bottom-sheet__item', action.classes ],
style: action.style,
tabindex: 0,
clickable: true,
dark: isDark.value,
onClick () { onOk(action) }
}, () => [
h(
QItemSection,
{ avatar: true },
() => (
action.icon
? h(QIcon, { name: action.icon, color: action.color })
: (
img
? h('img', {
class: action.avatar ? 'q-bottom-sheet__avatar' : '',
src: img
})
: null
)
)
),
h(QItemSection, () => action.label)
])
})
}
function getCardContent () {
const child = []
props.title && child.push(
h(QCardSection, {
class: 'q-dialog__title'
}, () => props.title)
)
props.message && child.push(
h(QCardSection, {
class: 'q-dialog__message'
}, () => props.message)
)
child.push(
props.grid === true
? h('div', {
class: 'row items-stretch justify-start',
role: 'list'
}, getGrid())
: h('div', {
role: 'list'
}, getList())
)
return child
}
function getContent () {
return [
h(QCard, {
class: [
`q-bottom-sheet q-bottom-sheet--${ props.grid === true ? 'grid' : 'list' }`
+ (isDark.value === true ? ' q-bottom-sheet--dark q-dark' : ''),
props.cardClass
],
style: props.cardStyle
}, getCardContent)
]
}
// expose public methods
Object.assign(proxy, { show, hide })
return () => h(QDialog, {
ref: dialogRef,
position: 'bottom',
onHide
}, getContent)
}
})

View file

@ -0,0 +1,37 @@
.q-bottom-sheet
padding-bottom: 8px
&__avatar
border-radius: 50%
&--list
width: 400px
.q-icon, img
font-size: 24px
width: 24px
height: 24px
&--grid
width: 700px
.q-bottom-sheet__item
padding: 8px
text-align: center
min-width: 100px
.q-icon, img, .q-bottom-sheet__empty-icon
font-size: 48px
width: 48px
height: 48px
margin-bottom: 8px
.q-separator
margin: 12px 0
&__item
flex: 0 0 33.3333%
@media (min-width: $breakpoint-sm-min)
.q-bottom-sheet__item
flex: 0 0 25%

View file

@ -0,0 +1,208 @@
function encode (string) {
return encodeURIComponent(string)
}
function decode (string) {
return decodeURIComponent(string)
}
function stringifyCookieValue (value) {
return encode(value === Object(value) ? JSON.stringify(value) : '' + value)
}
function read (string) {
if (string === '') {
return string
}
if (string.indexOf('"') === 0) {
// This is a quoted cookie as according to RFC2068, unescape...
string = string.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
// Replace server-side written pluses with spaces.
// If we can't decode the cookie, ignore it, it's unusable.
// If we can't parse the cookie, ignore it, it's unusable.
string = decode(string.replace(/\+/g, ' '))
try {
const parsed = JSON.parse(string)
if (parsed === Object(parsed) || Array.isArray(parsed) === true) {
string = parsed
}
}
catch (_) {}
return string
}
function getString (msOffset) {
const time = new Date()
time.setMilliseconds(time.getMilliseconds() + msOffset)
return time.toUTCString()
}
function parseExpireString (str) {
let timestamp = 0
const days = str.match(/(\d+)d/)
const hours = str.match(/(\d+)h/)
const minutes = str.match(/(\d+)m/)
const seconds = str.match(/(\d+)s/)
if (days) { timestamp += days[ 1 ] * 864e+5 }
if (hours) { timestamp += hours[ 1 ] * 36e+5 }
if (minutes) { timestamp += minutes[ 1 ] * 6e+4 }
if (seconds) { timestamp += seconds[ 1 ] * 1000 }
return timestamp === 0
? str
: getString(timestamp)
}
function set (key, val, opts = {}, ssr) {
let expire, expireValue
if (opts.expires !== void 0) {
// if it's a Date Object
if (Object.prototype.toString.call(opts.expires) === '[object Date]') {
expire = opts.expires.toUTCString()
}
// if it's a String (eg. "15m", "1h", "13d", "1d 15m", "31s")
// possible units: d (days), h (hours), m (minutes), s (seconds)
else if (typeof opts.expires === 'string') {
expire = parseExpireString(opts.expires)
}
// otherwise it must be a Number (defined in days)
else {
expireValue = parseFloat(opts.expires)
expire = isNaN(expireValue) === false
? getString(expireValue * 864e+5)
: opts.expires
}
}
const keyValue = `${ encode(key) }=${ stringifyCookieValue(val) }`
const cookie = [
keyValue,
expire !== void 0 ? '; Expires=' + expire : '', // use expires attribute, max-age is not supported by IE
opts.path ? '; Path=' + opts.path : '',
opts.domain ? '; Domain=' + opts.domain : '',
opts.sameSite ? '; SameSite=' + opts.sameSite : '',
opts.httpOnly ? '; HttpOnly' : '',
opts.secure ? '; Secure' : '',
opts.other ? '; ' + opts.other : ''
].join('')
if (ssr) {
if (ssr.req.qCookies) {
ssr.req.qCookies.push(cookie)
}
else {
ssr.req.qCookies = [ cookie ]
}
ssr.res.setHeader('Set-Cookie', ssr.req.qCookies)
// make temporary update so future get()
// within same SSR timeframe would return the set value
let all = ssr.req.headers.cookie || ''
if (expire !== void 0 && expireValue < 0) {
const val = get(key, ssr)
if (val !== undefined) {
all = all
.replace(`${ key }=${ val }; `, '')
.replace(`; ${ key }=${ val }`, '')
.replace(`${ key }=${ val }`, '')
}
}
else {
all = all
? `${ keyValue }; ${ all }`
: cookie
}
ssr.req.headers.cookie = all
}
else {
document.cookie = cookie
}
}
function get (key, ssr) {
const
cookieSource = ssr ? ssr.req.headers : document,
cookies = cookieSource.cookie ? cookieSource.cookie.split('; ') : [],
l = cookies.length
let
result = key ? null : {},
i = 0,
parts,
name,
cookie
for (; i < l; i++) {
parts = cookies[ i ].split('=')
name = decode(parts.shift())
cookie = parts.join('=')
if (!key) {
result[ name ] = cookie
}
else if (key === name) {
result = read(cookie)
break
}
}
return result
}
function remove (key, options, ssr) {
set(
key,
'',
{ expires: -1, ...options },
ssr
)
}
function has (key, ssr) {
return get(key, ssr) !== null
}
export function getObject (ssr) {
return {
get: key => get(key, ssr),
set: (key, val, opts) => set(key, val, opts, ssr),
has: key => has(key, ssr),
remove: (key, options) => remove(key, options, ssr),
getAll: () => get(null, ssr)
}
}
const Plugin = {
install ({ $q, ssrContext }) {
$q.cookies = __QUASAR_SSR_SERVER__
? getObject(ssrContext)
: this
}
}
if (__QUASAR_SSR__) {
Plugin.parseSSR = ssrContext => {
if (ssrContext !== void 0) {
return getObject(ssrContext)
}
}
}
if (__QUASAR_SSR_SERVER__ !== true) {
Object.assign(Plugin, getObject())
}
export default Plugin

View file

@ -0,0 +1,159 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/cookies"
},
"injection": "$q.cookies",
"methods": {
"get": {
"tsType": "CookiesGetMethodType",
"desc": "Get cookie",
"params": {
"name": {
"type": "String",
"desc": "Cookie name",
"required": true,
"examples": [ "'userId'" ]
}
},
"returns": {
"type": [ "String", "null" ],
"desc": "Cookie value; Returns null if cookie not found",
"examples": [ "'john12'" ]
}
},
"getAll": {
"desc": "Get all cookies",
"params": null,
"returns": {
"type": "Object",
"desc": "Object with cookie names (as keys) and their values",
"examples": [ "{ userId: 'john12', XFrame: 'x534' }" ]
}
},
"set": {
"desc": "Set cookie",
"params": {
"name": {
"type": "String",
"desc": "Cookie name",
"required": true,
"examples": [ "'userId'" ]
},
"value": {
"type": "String",
"desc": "Cookie value",
"required": true,
"examples": [ "'john12'" ]
},
"options": {
"type": "Object",
"desc": "Cookie options",
"definition": {
"expires": {
"type": [ "Number", "String", "Date" ],
"desc": "Cookie expires detail; If specified as Number, then the unit is days; If specified as String, it can either be raw stringified date or in Xd Xh Xm Xs format (see examples)",
"examples": [ "30", "'Wed, 13 Jan 2021 22:23:01 GMT'", "'1d'", "'15m'", "'13d'", "'1d 15m'", "'1d 3h 5m 3s'" ]
},
"path": {
"type": "String",
"desc": "Cookie path",
"examples": [ "'/accounts'" ]
},
"domain": {
"type": "String",
"desc": "Cookie domain",
"examples": [ "'.foo.com'" ]
},
"sameSite": {
"type": "String",
"desc": "SameSite cookie option",
"values": [ "'Lax'", "'Strict'", "'None'" ]
},
"httpOnly": {
"type": "Boolean",
"desc": "Is cookie Http Only?"
},
"secure": {
"type": "Boolean",
"desc": "Is cookie secure? (https only)"
},
"other": {
"type": "String",
"desc": "Raw string for other cookie options; To be used as a last resort for possible newer props that are currently not yet implemented in Quasar",
"examples": [ "'SomeNewCookieProp'" ]
}
}
}
},
"returns": null
},
"has": {
"desc": "Check if cookie exists",
"params": {
"name": {
"type": "String",
"desc": "Cookie name",
"required": true,
"examples": [ "'userId'" ]
}
},
"returns": {
"type": "Boolean",
"desc": "Does cookie exists or not?"
}
},
"remove": {
"desc": "Remove a cookie",
"params": {
"name": {
"type": "String",
"desc": "Cookie name",
"required": true,
"examples": [ "'userId'" ]
},
"options": {
"type": "Object",
"desc": "Cookie options",
"definition": {
"path": {
"type": "String",
"desc": "Cookie path",
"examples": [ "'/accounts'" ]
},
"domain": {
"type": "String",
"desc": "Cookie domain",
"examples": [ "'.foo.com'" ]
}
}
}
},
"returns": null
},
"parseSSR": {
"desc": "For SSR usage only, and only on the global import (not on $q.cookies)",
"params": {
"ssrContext": {
"type": "Object",
"desc": "SSR Context Object",
"required": true
}
},
"returns": {
"type": "Object",
"tsType": "Cookies",
"desc": "Cookie object (like $q.cookies) for SSR usage purposes"
}
}
}
}

View file

@ -0,0 +1,75 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
const Plugin = createReactivePlugin({
isActive: false,
mode: false
}, {
__media: void 0,
set (val) {
if (__QUASAR_SSR_SERVER__) return
Plugin.mode = val
if (val === 'auto') {
if (Plugin.__media === void 0) {
Plugin.__media = window.matchMedia('(prefers-color-scheme: dark)')
Plugin.__updateMedia = () => { Plugin.set('auto') }
Plugin.__media.addListener(Plugin.__updateMedia)
}
val = Plugin.__media.matches
}
else if (Plugin.__media !== void 0) {
Plugin.__media.removeListener(Plugin.__updateMedia)
Plugin.__media = void 0
}
Plugin.isActive = val === true
document.body.classList.remove(`body--${ val === true ? 'light' : 'dark' }`)
document.body.classList.add(`body--${ val === true ? 'dark' : 'light' }`)
},
toggle () {
if (__QUASAR_SSR_SERVER__ !== true) {
Plugin.set(Plugin.isActive === false)
}
},
install ({ $q, ssrContext }) {
const dark = __QUASAR_SSR_CLIENT__
? document.body.classList.contains('body--dark')
: $q.config.dark
if (__QUASAR_SSR_SERVER__) {
this.isActive = dark === true
$q.dark = {
isActive: false,
mode: false,
set: val => {
ssrContext._meta.bodyClasses = ssrContext._meta.bodyClasses
.replace(' body--light', '')
.replace(' body--dark', '') + ` body--${ val === true ? 'dark' : 'light' }`
$q.dark.isActive = val === true
$q.dark.mode = val
},
toggle: () => {
$q.dark.set($q.dark.isActive === false)
}
}
$q.dark.set(dark)
return
}
$q.dark = this
if (this.__installed !== true) {
this.set(dark !== void 0 ? dark : false)
}
}
})
export default Plugin

View file

@ -0,0 +1,50 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/dark"
},
"injection": "$q.dark",
"quasarConfOptions": {
"propName": "dark",
"type": [ "Boolean", "String" ],
"desc": "\"'auto'\" uses the OS/browser preference. \"true\" forces dark mode. \"false\" forces light mode.",
"values": [ "'auto'", "true", "false" ]
},
"props": {
"isActive": {
"type": "Boolean",
"desc": "Is Dark mode active?",
"reactive": true
},
"mode": {
"type": [ "Boolean", "String" ],
"desc": "Dark mode configuration (not status)",
"values": [ "'auto'", "true", "false" ],
"reactive": true
}
},
"methods": {
"set": {
"desc": "Set dark mode status",
"params": {
"status": {
"type": [ "Boolean", "String" ],
"desc": "Dark mode status",
"values": [ "true", "false", "'auto'" ],
"required": true
}
},
"returns": null
},
"toggle": {
"desc": "Toggle dark mode status",
"params": null,
"returns": null
}
}
}

View file

@ -0,0 +1,158 @@
import { describe, test, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Dark from './Dark.js'
const mountPlugin = () => mount({ template: '<div />' })
describe('[Dark API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(Dark).toMatchObject(wrapper.vm.$q.dark)
})
})
describe('[Props]', () => {
describe('[(prop)isActive]', () => {
test('is correct type', () => {
mountPlugin()
expect(Dark.isActive).toBeTypeOf('boolean')
})
test('is reactive', () => {
const { vm: { $q } } = mountPlugin()
expect(Dark.isActive).toBe(false)
expect($q.dark.isActive).toBe(false)
expect(
document.body.classList.contains('body--dark')
).toBe(false)
Dark.set(true)
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
})
})
describe('[(prop)mode]', () => {
test('is correct type', () => {
mountPlugin()
expect([ 'auto', true, false ]).toContain(Dark.mode)
})
})
})
describe('[Methods]', () => {
describe('[(method)set]', () => {
test('should be callable', () => {
const { vm: { $q } } = mountPlugin()
expect(
Dark.set(true)
).toBeUndefined()
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
expect(
Dark.set(false)
).toBeUndefined()
expect(Dark.isActive).toBe(false)
expect($q.dark.isActive).toBe(false)
expect(
document.body.classList.contains('body--dark')
).toBe(false)
})
test('should handle auto mode', () => {
const { vm: { $q } } = mountPlugin()
// jsdom hack
const media = {
matches: true,
addListener: vi.fn(),
removeListener: vi.fn()
}
window.matchMedia = vi.fn(() => media)
Dark.set('auto')
expect(Dark.mode).toBe('auto')
expect(media.addListener).toHaveBeenCalledTimes(1)
expect(media.removeListener).not.toHaveBeenCalled()
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
media.matches = false
Dark.__updateMedia()
expect(media.addListener).toHaveBeenCalledTimes(1)
expect(media.removeListener).not.toHaveBeenCalled()
expect(Dark.isActive).toBe(false)
expect($q.dark.isActive).toBe(false)
expect(
document.body.classList.contains('body--dark')
).toBe(false)
Dark.set(true)
expect(Dark.mode).not.toBe('auto')
expect(media.addListener).toHaveBeenCalledTimes(1)
expect(media.removeListener).toHaveBeenCalledTimes(1)
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
})
})
describe('[(method)toggle]', () => {
test('should be callable', () => {
const { vm: { $q } } = mountPlugin()
Dark.set(true)
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
expect(
Dark.toggle()
).toBeUndefined()
expect(Dark.isActive).toBe(false)
expect($q.dark.isActive).toBe(false)
expect(
document.body.classList.contains('body--dark')
).toBe(false)
Dark.toggle()
expect(Dark.isActive).toBe(true)
expect($q.dark.isActive).toBe(true)
expect(
document.body.classList.contains('body--dark')
).toBe(true)
})
})
})
})

View file

@ -0,0 +1,8 @@
import DialogPlugin from './component/DialogPluginComponent.js'
import globalDialog from '../../utils/private.dialog/create-dialog.js'
export default {
install ({ $q, parentApp }) {
$q.dialog = this.create = globalDialog(DialogPlugin, true, parentApp)
}
}

View file

@ -0,0 +1,287 @@
{
"mixins": [ "utils/private.dialog/create-dialog" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/dialog"
},
"injection": "$q.dialog",
"methods": {
"create": {
"tsInjectionPoint": true,
"params": {
"opts": {
"desc": "Dialog options",
"tsType": "QDialogOptions",
"autoDefineTsType": true,
"definition": {
"title": {
"type": "String",
"desc": "A text for the heading title of the dialog",
"examples": [ "'Continue?'" ]
},
"message": {
"type": "String",
"desc": "A text with more information about what needs to be input, selected or confirmed.",
"examples": [ "'Are you certain you want to continue?'" ]
},
"html": {
"type": "Boolean",
"desc": "Render title and message as HTML; This can lead to XSS attacks, so make sure that you sanitize the message first"
},
"position": {
"type": "String",
"desc": "Position of the Dialog on screen. Standard is centered.",
"values": [ "'top'", "'right'", "'bottom'", "'left'", "'standard'" ],
"default": "'standard'"
},
"prompt": {
"type": "Object",
"tsType": "QDialogInputPrompt",
"desc": "An object definition of the input field for the prompting question.",
"examples": [ "{ model: 'initial-value', type: 'number' }" ],
"definition": {
"model": {
"type": "String",
"required": true,
"desc": "The initial value of the input"
},
"type": {
"type": "String",
"desc": "Optional property to determine the input field type",
"default": "'text'",
"examples": [ "'text'", "'number'", "'textarea'" ]
},
"isValid": {
"type": "Function",
"desc": "Is typed content valid?",
"params": {
"val": {
"type": "String",
"required": true,
"desc": "The value of the input"
}
},
"returns": {
"type": "Boolean",
"desc": "The text passed validation or not"
}
},
"...QInputProps": {
"type": "Any",
"desc": "Any QInput props, like color, label, stackLabel, filled, outlined, rounded, prefix etc",
"examples": [
"label: 'My Label'",
"standout: true",
"counter: true",
"maxlength: 12"
]
},
"...nativeAttributes": {
"type": "Object",
"desc": "Any native attributes to pass to the prompt control",
"examples": [ "# autocomplete: 'off'" ]
}
}
},
"options": {
"type": "Object",
"tsType": "QDialogSelectionPrompt",
"desc": "An object definition for creating the selection form content",
"examples": [ "{ model: null, type: 'radio', items: [ /* ...listOfItems */ ] }" ],
"definition": {
"model": {
"type": [ "String", "Array" ],
"required": true,
"desc": "The value of the selection (String if it's of type radio or Array otherwise)",
"examples": [ "[]" ]
},
"type": {
"type": "String",
"desc": "The type of selection",
"default": "'radio'",
"values": [ "'radio'", "'checkbox'", "'toggle'" ]
},
"items": {
"type": "Array",
"desc": "The list of options to interact with; Equivalent to options prop of the QOptionGroup component",
"examples": [
"[ { label: 'Option 1', value: 'op1' }, { label: 'Option 2', value: 'op2' }, { label: 'Option 3', value: 'op3' } ]"
]
},
"isValid": {
"type": "Function",
"desc": "Is the model valid?",
"params": {
"model": {
"type": [ "String", "Array" ],
"required": true,
"desc": "The current model (String if it's of type radio or Array otherwise)",
"examples": [
"'opt2'",
"[ 'opt1' ]",
"[]",
"[ 'opt1', 'opt3' ]"
]
}
},
"returns": {
"type": "Boolean",
"desc": "The selection passed validation or not"
}
},
"...QOptionGroupProps": {
"type": "Any",
"desc": "Any QOptionGroup props",
"examples": [
"color: 'deep-purple-4'",
"inline: true",
"dense: true",
"leftLabel: true"
]
},
"...nativeAttributes": {
"type": "Object",
"desc": "Any native attributes to pass to the inner QOptionGroup"
}
}
},
"progress": {
"type": [ "Boolean", "Object" ],
"desc": "Display a Quasar spinner (if value is true, then the defaults are used); Useful for conveying the idea that something is happening behind the covers; Tip: use along with persistent, ok: false and update() method",
"definition": {
"spinner": {
"type": "Component",
"desc": "One of the QSpinners"
},
"color": {
"extends": "color"
}
}
},
"ok": {
"type": [ "String", "Object", "Boolean" ],
"desc": "Props for an 'OK' button",
"definition": {
"...props": {
"type": "Any",
"desc": "See QBtn for available props"
}
}
},
"cancel": {
"type": [ "String", "Object", "Boolean" ],
"desc": "Props for a 'CANCEL' button",
"definition": {
"...props": {
"type": "Any",
"desc": "See QBtn for available props"
}
}
},
"focus": {
"type": "String",
"desc": "What button to focus, unless you also have 'prompt' or 'options'",
"values": [ "'ok'", "'cancel'", "'none'" ],
"default": "'ok'"
},
"stackButtons": {
"type": "Boolean",
"desc": "Makes buttons be stacked instead of vertically aligned"
},
"color": {
"extends": "color"
},
"dark": {
"extends": "dark",
"desc": "Apply dark mode"
},
"persistent": {
"type": "Boolean",
"desc": "User cannot dismiss Dialog if clicking outside of it or hitting ESC key; Also, an app route change won't dismiss it"
},
"noEscDismiss": {
"type": "Boolean",
"desc": "User cannot dismiss Dialog by hitting ESC key; No need to set it if 'persistent' prop is also set"
},
"noBackdropDismiss": {
"type": "Boolean",
"desc": "User cannot dismiss Dialog by clicking outside of it; No need to set it if 'persistent' prop is also set"
},
"noRouteDismiss": {
"type": "Boolean",
"desc": "Changing route app won't dismiss Dialog; No need to set it if 'persistent' prop is also set"
},
"seamless": {
"type": "Boolean",
"desc": "Put Dialog into seamless mode; Does not use a backdrop so user is able to interact with the rest of the page too"
},
"maximized": {
"type": "Boolean",
"desc": "Put Dialog into maximized mode"
},
"fullWidth": {
"type": "Boolean",
"desc": "Dialog will try to render with same width as the window"
},
"fullHeight": {
"type": "Boolean",
"desc": "Dialog will try to render with same height as the window"
},
"transitionShow": {
"extends": "transition",
"default": "'scale'"
},
"transitionHide": {
"extends": "transition",
"default": "'scale'"
},
"component": {
"type": [ "Component", "String" ],
"desc": "Use custom dialog component; use along with 'componentProps' prop where possible",
"examples": [ "CustomComponent", "'custom-component'" ]
},
"componentProps": {
"type": "Object",
"desc": "User defined props which will be forwarded to underlying custom component if 'component' prop is used; May also include any built-in QDialog option such as 'persistent' or 'seamless'"
}
}
}
}
}
}
}

View file

@ -0,0 +1,325 @@
import { h, ref, computed, watch, toRaw, getCurrentInstance } from 'vue'
import QDialog from '../../../components/dialog/QDialog.js'
import QBtn from '../../../components/btn/QBtn.js'
import QCard from '../../../components/card/QCard.js'
import QCardSection from '../../../components/card/QCardSection.js'
import QCardActions from '../../../components/card/QCardActions.js'
import QSeparator from '../../../components/separator/QSeparator.js'
import QInput from '../../../components/input/QInput.js'
import QOptionGroup from '../../../components/option-group/QOptionGroup.js'
import QSpinner from '../../../components/spinner/QSpinner.js'
import { createComponent } from '../../../utils/private.create/create.js'
import useDark, { useDarkProps } from '../../../composables/private.use-dark/use-dark.js'
import { isKeyCode } from '../../../utils/private.keyboard/key-composition.js'
import { isObject } from '../../../utils/is/is.js'
export default createComponent({
name: 'DialogPluginComponent',
props: {
...useDarkProps,
title: String,
message: String,
prompt: Object,
options: Object,
progress: [ Boolean, Object ],
html: Boolean,
ok: {
type: [ String, Object, Boolean ],
default: true
},
cancel: [ String, Object, Boolean ],
focus: {
type: String,
default: 'ok',
validator: v => [ 'ok', 'cancel', 'none' ].includes(v)
},
stackButtons: Boolean,
color: String,
cardClass: [ String, Array, Object ],
cardStyle: [ String, Array, Object ]
},
emits: [ 'ok', 'hide' ],
setup (props, { emit }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const isDark = useDark(props, $q)
const dialogRef = ref(null)
const model = ref(
props.prompt !== void 0
? props.prompt.model
: (props.options !== void 0 ? props.options.model : void 0)
)
const classes = computed(() =>
'q-dialog-plugin'
+ (isDark.value === true ? ' q-dialog-plugin--dark q-dark' : '')
+ (props.progress !== false ? ' q-dialog-plugin--progress' : '')
)
const vmColor = computed(() =>
props.color || (isDark.value === true ? 'amber' : 'primary')
)
const spinner = computed(() => (
props.progress === false
? null
: (
isObject(props.progress) === true
? {
component: props.progress.spinner || QSpinner,
props: { color: props.progress.color || vmColor.value }
}
: {
component: QSpinner,
props: { color: vmColor.value }
}
)
))
const hasForm = computed(() =>
props.prompt !== void 0 || props.options !== void 0
)
const formProps = computed(() => {
if (hasForm.value !== true) {
return {}
}
const { model, isValid, items, ...formProps } = props.prompt !== void 0
? props.prompt
: props.options
return formProps
})
const okLabel = computed(() => (
isObject(props.ok) === true
? $q.lang.label.ok
: (
props.ok === true
? $q.lang.label.ok
: props.ok
)
))
const cancelLabel = computed(() => (
isObject(props.cancel) === true
? $q.lang.label.cancel
: (
props.cancel === true
? $q.lang.label.cancel
: props.cancel
)
))
const okDisabled = computed(() => {
if (props.prompt !== void 0) {
return props.prompt.isValid !== void 0
&& props.prompt.isValid(model.value) !== true
}
if (props.options !== void 0) {
return props.options.isValid !== void 0
&& props.options.isValid(model.value) !== true
}
return false
})
const okProps = computed(() => ({
color: vmColor.value,
label: okLabel.value,
ripple: false,
disable: okDisabled.value,
...(isObject(props.ok) === true ? props.ok : { flat: true }),
'data-autofocus': (props.focus === 'ok' && hasForm.value !== true) || void 0,
onClick: onOk
}))
const cancelProps = computed(() => ({
color: vmColor.value,
label: cancelLabel.value,
ripple: false,
...(isObject(props.cancel) === true ? props.cancel : { flat: true }),
'data-autofocus': (props.focus === 'cancel' && hasForm.value !== true) || void 0,
onClick: onCancel
}))
watch(() => props.prompt && props.prompt.model, onUpdateModel)
watch(() => props.options && props.options.model, onUpdateModel)
function show () {
dialogRef.value.show()
}
function hide () {
dialogRef.value.hide()
}
function onOk () {
emit('ok', toRaw(model.value))
hide()
}
function onCancel () {
hide()
}
function onDialogHide () {
emit('hide')
}
function onUpdateModel (val) {
model.value = val
}
function onInputKeyup (evt) {
// if ENTER key
if (
okDisabled.value !== true
&& props.prompt.type !== 'textarea'
&& isKeyCode(evt, 13) === true
) {
onOk()
}
}
function getSection (classes, text) {
return props.html === true
? h(QCardSection, {
class: classes,
innerHTML: text
})
: h(QCardSection, { class: classes }, () => text)
}
function getPrompt () {
return [
h(QInput, {
color: vmColor.value,
dense: true,
autofocus: true,
dark: isDark.value,
...formProps.value,
modelValue: model.value,
'onUpdate:modelValue': onUpdateModel,
onKeyup: onInputKeyup
})
]
}
function getOptions () {
return [
h(QOptionGroup, {
color: vmColor.value,
options: props.options.items,
dark: isDark.value,
...formProps.value,
modelValue: model.value,
'onUpdate:modelValue': onUpdateModel
})
]
}
function getButtons () {
const child = []
props.cancel && child.push(
h(QBtn, cancelProps.value)
)
props.ok && child.push(
h(QBtn, okProps.value)
)
return h(QCardActions, {
class: props.stackButtons === true ? 'items-end' : '',
vertical: props.stackButtons,
align: 'right'
}, () => child)
}
function getCardContent () {
const child = []
props.title && child.push(
getSection('q-dialog__title', props.title)
)
props.progress !== false && child.push(
h(
QCardSection,
{ class: 'q-dialog__progress' },
() => h(spinner.value.component, spinner.value.props)
)
)
props.message && child.push(
getSection('q-dialog__message', props.message)
)
if (props.prompt !== void 0) {
child.push(
h(
QCardSection,
{ class: 'scroll q-dialog-plugin__form' },
getPrompt
)
)
}
else if (props.options !== void 0) {
child.push(
h(QSeparator, { dark: isDark.value }),
h(
QCardSection,
{ class: 'scroll q-dialog-plugin__form' },
getOptions
),
h(QSeparator, { dark: isDark.value })
)
}
if (props.ok || props.cancel) {
child.push(getButtons())
}
return child
}
function getContent () {
return [
h(QCard, {
class: [
classes.value,
props.cardClass
],
style: props.cardStyle,
dark: isDark.value
}, getCardContent)
]
}
// expose public methods
Object.assign(proxy, { show, hide })
return () => h(QDialog, {
ref: dialogRef,
onHide: onDialogHide
}, getContent)
}
})

View file

@ -0,0 +1,11 @@
.q-dialog-plugin
width: 400px
&__form
max-height: 50vh
.q-card__section + .q-card__section
padding-top: 0
&--progress
text-align: center

View file

@ -0,0 +1,77 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { injectProp } from '../../utils/private.inject-obj-prop/inject-obj-prop.js'
import materialIcons from '../../../icon-set/material-icons.js'
const Plugin = createReactivePlugin({
iconMapFn: null,
__qIconSet: {}
}, {
// props: object
set (setObject, ssrContext) {
const def = { ...setObject }
if (__QUASAR_SSR_SERVER__) {
if (ssrContext === void 0) {
console.error('SSR ERROR: second param required: IconSet.set(iconSet, ssrContext)')
return
}
def.set = ssrContext.$q.iconSet.set
Object.assign(ssrContext.$q.iconSet, def)
}
else {
def.set = Plugin.set
Object.assign(Plugin.__qIconSet, def)
}
},
install ({ $q, iconSet, ssrContext }) {
if (__QUASAR_SSR_SERVER__) {
const initialSet = iconSet || materialIcons
$q.iconMapFn = ssrContext.$q.config.iconMapFn || this.iconMapFn || null
$q.iconSet = {}
$q.iconSet.set = setObject => {
this.set(setObject, ssrContext)
}
$q.iconSet.set(initialSet)
// one-time SSR server operation
if (
this.props === void 0
|| this.props.name !== initialSet.name
) {
this.props = { ...initialSet }
}
}
else {
if ($q.config.iconMapFn !== void 0) {
this.iconMapFn = $q.config.iconMapFn
}
$q.iconSet = this.__qIconSet
injectProp($q, 'iconMapFn', () => this.iconMapFn, val => { this.iconMapFn = val })
if (this.__installed === true) {
iconSet !== void 0 && this.set(iconSet)
}
else {
this.props = new Proxy(this.__qIconSet, {
get () { return Reflect.get(...arguments) },
ownKeys (target) {
return Reflect.ownKeys(target)
.filter(key => key !== 'set')
}
})
this.set(iconSet || materialIcons)
}
}
}
})
export default Plugin

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,346 @@
import { describe, test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import IconSet from './IconSet.js'
const mountPlugin = () => mount({ template: '<div />' })
describe('[IconSet API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(wrapper.vm.$q.iconSet).toBeDefined()
expect(wrapper.vm.$q.iconSet.name).toBe(IconSet.props.name)
expect(wrapper.vm.$q.iconSet.set).toBe(IconSet.set)
})
})
describe('[Props]', () => {
describe('[(prop)props]', () => {
test('is correct type', () => {
mountPlugin()
expect(IconSet.props).toStrictEqual({
name: expect.any(String),
type: {
positive: expect.any(String),
negative: expect.any(String),
info: expect.any(String),
warning: expect.any(String)
},
arrow: {
up: expect.any(String),
right: expect.any(String),
down: expect.any(String),
left: expect.any(String),
dropdown: expect.any(String)
},
chevron: {
left: expect.any(String),
right: expect.any(String)
},
colorPicker: {
spectrum: expect.any(String),
tune: expect.any(String),
palette: expect.any(String)
},
pullToRefresh: {
icon: expect.any(String)
},
carousel: {
left: expect.any(String),
right: expect.any(String),
up: expect.any(String),
down: expect.any(String),
navigationIcon: expect.any(String)
},
chip: {
remove: expect.any(String),
selected: expect.any(String)
},
datetime: {
arrowLeft: expect.any(String),
arrowRight: expect.any(String),
now: expect.any(String),
today: expect.any(String)
},
editor: {
bold: expect.any(String),
italic: expect.any(String),
strikethrough: expect.any(String),
underline: expect.any(String),
unorderedList: expect.any(String),
orderedList: expect.any(String),
subscript: expect.any(String),
superscript: expect.any(String),
hyperlink: expect.any(String),
toggleFullscreen: expect.any(String),
quote: expect.any(String),
left: expect.any(String),
center: expect.any(String),
right: expect.any(String),
justify: expect.any(String),
print: expect.any(String),
outdent: expect.any(String),
indent: expect.any(String),
removeFormat: expect.any(String),
formatting: expect.any(String),
fontSize: expect.any(String),
align: expect.any(String),
hr: expect.any(String),
undo: expect.any(String),
redo: expect.any(String),
heading: expect.any(String),
code: expect.any(String),
size: expect.any(String),
font: expect.any(String),
viewSource: expect.any(String)
},
expansionItem: {
icon: expect.any(String),
denseIcon: expect.any(String)
},
fab: {
icon: expect.any(String),
activeIcon: expect.any(String)
},
field: {
clear: expect.any(String),
error: expect.any(String)
},
pagination: {
first: expect.any(String),
prev: expect.any(String),
next: expect.any(String),
last: expect.any(String)
},
rating: {
icon: expect.any(String)
},
stepper: {
done: expect.any(String),
active: expect.any(String),
error: expect.any(String)
},
tabs: {
left: expect.any(String),
right: expect.any(String),
up: expect.any(String),
down: expect.any(String)
},
table: {
arrowUp: expect.any(String),
warning: expect.any(String),
firstPage: expect.any(String),
prevPage: expect.any(String),
nextPage: expect.any(String),
lastPage: expect.any(String)
},
tree: {
icon: expect.any(String)
},
uploader: {
done: expect.any(String),
clear: expect.any(String),
add: expect.any(String),
upload: expect.any(String),
removeQueue: expect.any(String),
removeUploaded: expect.any(String)
}
})
})
test('can be set', () => {
const wrapper = mountPlugin()
IconSet.props.name = 'new-icon-set'
expect(IconSet.props.name).toBe('new-icon-set')
expect(wrapper.vm.$q.iconSet.name).toBe('new-icon-set')
wrapper.vm.$q.iconSet.name = 'another-icon-set'
expect(IconSet.props.name).toBe('another-icon-set')
expect(wrapper.vm.$q.iconSet.name).toBe('another-icon-set')
})
})
describe('[(prop)iconMapFn]', () => {
test('is correct type', () => {
mountPlugin()
expect(IconSet.iconMapFn).$any([
expect.any(Function),
null
])
})
})
})
describe('[Methods]', () => {
describe('[(method)set]', () => {
test('should be callable', () => {
const wrapper = mountPlugin()
expect(
IconSet.set({
name: 'new-icon-set',
type: {
positive: 'check_circle',
negative: 'warning',
info: 'info',
warning: 'priority_high'
},
arrow: {
up: 'arrow_upward',
right: 'arrow_forward',
down: 'arrow_downward',
left: 'arrow_back',
dropdown: 'arrow_drop_down'
},
chevron: {
left: 'chevron_left',
right: 'chevron_right'
},
colorPicker: {
spectrum: 'gradient',
tune: 'tune',
palette: 'style'
},
pullToRefresh: {
icon: 'refresh'
},
carousel: {
left: 'chevron_left',
right: 'chevron_right',
up: 'keyboard_arrow_up',
down: 'keyboard_arrow_down',
navigationIcon: 'lens'
},
chip: {
remove: 'cancel',
selected: 'check'
},
datetime: {
arrowLeft: 'chevron_left',
arrowRight: 'chevron_right',
now: 'access_time',
today: 'today'
},
editor: {
bold: 'format_bold',
italic: 'format_italic',
strikethrough: 'strikethrough_s',
underline: 'format_underlined',
unorderedList: 'format_list_bulleted',
orderedList: 'format_list_numbered',
subscript: 'vertical_align_bottom',
superscript: 'vertical_align_top',
hyperlink: 'link',
toggleFullscreen: 'fullscreen',
quote: 'format_quote',
left: 'format_align_left',
center: 'format_align_center',
right: 'format_align_right',
justify: 'format_align_justify',
print: 'print',
outdent: 'format_indent_decrease',
indent: 'format_indent_increase',
removeFormat: 'format_clear',
formatting: 'text_format',
fontSize: 'format_size',
align: 'format_align_left',
hr: 'remove',
undo: 'undo',
redo: 'redo',
heading: 'format_size',
heading1: 'format_size',
heading2: 'format_size',
heading3: 'format_size',
heading4: 'format_size',
heading5: 'format_size',
heading6: 'format_size',
code: 'code',
size: 'format_size',
size1: 'format_size',
size2: 'format_size',
size3: 'format_size',
size4: 'format_size',
size5: 'format_size',
size6: 'format_size',
size7: 'format_size',
font: 'font_download',
viewSource: 'code'
},
expansionItem: {
icon: 'keyboard_arrow_down',
denseIcon: 'arrow_drop_down'
},
fab: {
icon: 'add',
activeIcon: 'close'
},
field: {
clear: 'cancel',
error: 'error'
},
pagination: {
first: 'first_page',
prev: 'keyboard_arrow_left',
next: 'keyboard_arrow_right',
last: 'last_page'
},
rating: {
icon: 'grade'
},
stepper: {
done: 'check',
active: 'edit',
error: 'warning'
},
tabs: {
left: 'chevron_left',
right: 'chevron_right',
up: 'keyboard_arrow_up',
down: 'keyboard_arrow_down'
},
table: {
arrowUp: 'arrow_upward',
warning: 'warning',
firstPage: 'first_page',
prevPage: 'chevron_left',
nextPage: 'chevron_right',
lastPage: 'last_page'
},
tree: {
icon: 'play_arrow'
},
uploader: {
done: 'done',
clear: 'clear',
add: 'add_box',
upload: 'cloud_upload',
removeQueue: 'clear_all',
removeUploaded: 'done_all'
}
})
).toBeUndefined()
expect(IconSet.props.name).toBe('new-icon-set')
expect(wrapper.vm.$q.iconSet.name).toBe('new-icon-set')
})
test('should work with an imported icon set', async () => {
const { vm: { $q } } = mountPlugin()
const { default: newIconSet } = await import('quasar/icon-set/fontawesome-v6.js')
IconSet.set(newIconSet)
expect(IconSet.props.name).toBe(newIconSet.name)
expect($q.iconSet.name).toBe(newIconSet.name)
const { default: anotherIconSet } = await import('quasar/icon-set/ionicons-v4.js')
$q.iconSet.set(anotherIconSet)
expect(IconSet.props.name).toBe(anotherIconSet.name)
expect($q.iconSet.name).toBe(anotherIconSet.name)
})
})
})
})

View file

@ -0,0 +1,117 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
import defaultLang from '../../../lang/en-US.js'
function getLocale () {
if (__QUASAR_SSR_SERVER__) return
const val = Array.isArray(navigator.languages) === true && navigator.languages.length !== 0
? navigator.languages[ 0 ]
: navigator.language
if (typeof val === 'string') {
return val.split(/[-_]/).map((v, i) => (
i === 0
? v.toLowerCase()
: (
i > 1 || v.length < 4
? v.toUpperCase()
: (v[ 0 ].toUpperCase() + v.slice(1).toLowerCase())
)
)).join('-')
}
}
const Plugin = createReactivePlugin({
__qLang: {}
}, {
// props: object
// __langConfig: object
getLocale,
set (langObject = defaultLang, ssrContext) {
const lang = {
...langObject,
rtl: langObject.rtl === true,
getLocale
}
if (__QUASAR_SSR_SERVER__) {
if (ssrContext === void 0) {
console.error('SSR ERROR: second param required: Lang.set(lang, ssrContext)')
return
}
lang.set = ssrContext.$q.lang.set
if (ssrContext.$q.config.lang === void 0 || ssrContext.$q.config.lang.noHtmlAttrs !== true) {
const dir = lang.rtl === true ? 'rtl' : 'ltr'
const attrs = `lang=${ lang.isoName } dir=${ dir }`
ssrContext._meta.htmlAttrs = ssrContext.__qPrevLang !== void 0
? ssrContext._meta.htmlAttrs.replace(ssrContext.__qPrevLang, attrs)
: attrs
ssrContext.__qPrevLang = attrs
}
ssrContext.$q.lang = lang
}
else {
lang.set = Plugin.set
if (Plugin.__langConfig === void 0 || Plugin.__langConfig.noHtmlAttrs !== true) {
const el = document.documentElement
el.setAttribute('dir', lang.rtl === true ? 'rtl' : 'ltr')
el.setAttribute('lang', lang.isoName)
}
Object.assign(Plugin.__qLang, lang)
}
},
install ({ $q, lang, ssrContext }) {
if (__QUASAR_SSR_SERVER__) {
const initialLang = lang || defaultLang
$q.lang = {}
$q.lang.set = langObject => {
this.set(langObject, ssrContext)
}
$q.lang.set(initialLang)
// one-time SSR server operation
if (
this.props === void 0
|| this.props.isoName !== initialLang.isoName
) {
this.props = { ...initialLang }
}
}
else {
$q.lang = Plugin.__qLang
Plugin.__langConfig = $q.config.lang
if (this.__installed === true) {
lang !== void 0 && this.set(lang)
}
else {
this.props = new Proxy(this.__qLang, {
get () { return Reflect.get(...arguments) },
ownKeys (target) {
return Reflect.ownKeys(target)
.filter(key => key !== 'set' && key !== 'getLocale')
}
})
this.set(lang || defaultLang)
}
}
}
})
export default Plugin
export { defaultLang }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,293 @@
import { describe, test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Lang from './Lang.js'
const mountPlugin = () => mount({ template: '<div />' })
describe('[Lang API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(wrapper.vm.$q.lang).toBeDefined()
expect(wrapper.vm.$q.lang.name).toBe(Lang.props.name)
expect(wrapper.vm.$q.lang.set).toBe(Lang.set)
})
})
describe('[Props]', () => {
describe('[(prop)props]', () => {
test('is correct type', () => {
mountPlugin()
expect(Lang.props).toStrictEqual({
isoName: expect.any(String),
nativeName: expect.any(String),
rtl: expect.any(Boolean),
label: {
clear: expect.any(String),
ok: expect.any(String),
cancel: expect.any(String),
close: expect.any(String),
set: expect.any(String),
select: expect.any(String),
reset: expect.any(String),
remove: expect.any(String),
update: expect.any(String),
create: expect.any(String),
search: expect.any(String),
filter: expect.any(String),
refresh: expect.any(String),
expand: expect.any(Function),
collapse: expect.any(Function)
},
date: {
days: expect.any(Array),
daysShort: expect.any(Array),
months: expect.any(Array),
monthsShort: expect.any(Array),
firstDayOfWeek: expect.any(Number),
format24h: expect.any(Boolean),
pluralDay: expect.any(String),
prevMonth: expect.any(String),
nextMonth: expect.any(String),
prevYear: expect.any(String),
nextYear: expect.any(String),
today: expect.any(String),
prevRangeYears: expect.any(Function),
nextRangeYears: expect.any(Function)
},
table: {
noData: expect.any(String),
noResults: expect.any(String),
loading: expect.any(String),
selectedRecords: expect.any(Function),
recordsPerPage: expect.any(String),
allRows: expect.any(String),
pagination: expect.any(Function),
columns: expect.any(String)
},
pagination: {
first: expect.any(String),
last: expect.any(String),
next: expect.any(String),
prev: expect.any(String)
},
editor: {
url: expect.any(String),
bold: expect.any(String),
italic: expect.any(String),
strikethrough: expect.any(String),
underline: expect.any(String),
unorderedList: expect.any(String),
orderedList: expect.any(String),
subscript: expect.any(String),
superscript: expect.any(String),
hyperlink: expect.any(String),
toggleFullscreen: expect.any(String),
quote: expect.any(String),
left: expect.any(String),
center: expect.any(String),
right: expect.any(String),
justify: expect.any(String),
print: expect.any(String),
outdent: expect.any(String),
indent: expect.any(String),
removeFormat: expect.any(String),
formatting: expect.any(String),
fontSize: expect.any(String),
align: expect.any(String),
hr: expect.any(String),
undo: expect.any(String),
redo: expect.any(String),
heading1: expect.any(String),
heading2: expect.any(String),
heading3: expect.any(String),
heading4: expect.any(String),
heading5: expect.any(String),
heading6: expect.any(String),
paragraph: expect.any(String),
code: expect.any(String),
size1: expect.any(String),
size2: expect.any(String),
size3: expect.any(String),
size4: expect.any(String),
size5: expect.any(String),
size6: expect.any(String),
size7: expect.any(String),
defaultFont: expect.any(String),
viewSource: expect.any(String)
},
tree: {
noNodes: expect.any(String),
noResults: expect.any(String)
}
})
})
test('can be set', () => {
const { vm: { $q } } = mountPlugin()
Lang.props.nativeName = 'new-lang'
expect(Lang.props.nativeName).toBe('new-lang')
expect($q.lang.nativeName).toBe('new-lang')
$q.lang.nativeName = 'another-lang'
expect(Lang.props.nativeName).toBe('another-lang')
expect($q.lang.nativeName).toBe('another-lang')
})
})
})
describe('[Methods]', () => {
describe('[(method)set]', () => {
test('should be callable', () => {
const wrapper = mountPlugin()
expect(
Lang.set({
isoName: 'en-US',
nativeName: 'New Language',
rtl: true,
label: {
clear: 'Clear',
ok: 'OK',
cancel: 'Cancel',
close: 'Close',
set: 'Set',
select: 'Select',
reset: 'Reset',
remove: 'Remove',
update: 'Update',
create: 'Create',
search: 'Search',
filter: 'Filter',
refresh: 'Refresh',
expand: label => (label ? `Expand '${ label }'` : 'Expand'),
collapse: label => (label ? `Collapse '${ label }'` : 'Collapse')
},
date: {
days: [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ],
daysShort: [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ],
months: [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ],
monthsShort: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ],
firstDayOfWeek: 0,
format24h: true,
pluralDay: 'days',
prevMonth: expect.any(String),
nextMonth: expect.any(String),
prevYear: expect.any(String),
nextYear: expect.any(String),
today: expect.any(String),
prevRangeYears: expect.any(Function),
nextRangeYears: expect.any(Function)
},
table: {
noData: 'No data available',
noResults: 'No matching records found',
loading: 'Loading...',
selectedRecords: rows => `${ rows } records selected`,
recordsPerPage: 'Records per page:',
allRows: 'All',
pagination: (start, end, total) => start + '-' + end + ' of ' + total,
columns: 'Columns'
},
pagination: {
first: expect.any(String),
last: expect.any(String),
next: expect.any(String),
prev: expect.any(String)
},
editor: {
url: 'URL',
bold: 'Bold',
italic: 'Italic',
strikethrough: 'Strikethrough',
underline: 'Underline',
unorderedList: 'Unordered List',
orderedList: 'Ordered List',
subscript: 'Subscript',
superscript: 'Superscript',
hyperlink: 'Hyperlink',
toggleFullscreen: 'Toggle Fullscreen',
quote: 'Quote',
left: 'Left align',
center: 'Center align',
right: 'Right align',
justify: 'Justify align',
print: 'Print',
outdent: 'Decrease indentation',
indent: 'Increase indentation',
removeFormat: 'Remove formatting',
formatting: 'Formatting',
fontSize: 'Font Size',
align: 'Align',
hr: 'Insert Horizontal Rule',
undo: 'Undo',
redo: 'Redo',
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
heading6: 'Heading 6',
paragraph: 'Paragraph',
code: 'Code',
size1: 'Very small',
size2: 'A bit small',
size3: 'Normal',
size4: 'Medium-large',
size5: 'Big',
size6: 'Very big',
size7: 'Maximum',
defaultFont: 'Default Font',
viewSource: 'View Source'
},
tree: {
noNodes: 'No nodes available',
noResults: 'No matching nodes found'
}
})
).toBeUndefined()
expect(Lang.props.nativeName).toBe('New Language')
expect(wrapper.vm.$q.lang.nativeName).toBe('New Language')
})
test('should work with an imported lang pack', async () => {
const { vm: { $q } } = mountPlugin()
const { default: deLang } = await import('quasar/lang/de-DE.js')
Lang.set(deLang)
expect(Lang.props.nativeName).toBe(deLang.nativeName)
expect($q.lang.nativeName).toBe(deLang.nativeName)
const { default: itLang } = await import('quasar/lang/it.js')
$q.lang.set(itLang)
expect(Lang.props.nativeName).toBe(itLang.nativeName)
expect($q.lang.nativeName).toBe(itLang.nativeName)
})
})
describe('[(method)getLocale]', () => {
test('should be callable', () => {
const wrapper = mountPlugin()
expect(
Lang.getLocale()
).$any([
expect.any(String),
undefined
])
expect(
wrapper.vm.$q.lang.getLocale()
).$any([
expect.any(String),
undefined
])
})
})
})
})

View file

@ -0,0 +1,77 @@
import { h, ref } from 'vue'
import QAjaxBar from '../../components/ajax-bar/QAjaxBar.js'
import { createChildApp } from '../../install-quasar.js'
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { noop } from '../../utils/event/event.js'
import { createGlobalNode } from '../../utils/private.config/nodes.js'
import { isObject } from '../../utils/is/is.js'
const barRef = ref(null)
const Plugin = createReactivePlugin({
isActive: false
}, {
start: noop,
stop: noop,
increment: noop,
setDefaults: noop,
install ({ $q, parentApp }) {
$q.loadingBar = this
if (__QUASAR_SSR_SERVER__) return
if (this.__installed === true) {
if ($q.config.loadingBar !== void 0) {
this.setDefaults($q.config.loadingBar)
}
return
}
const props = ref(
$q.config.loadingBar !== void 0
? { ...$q.config.loadingBar }
: {}
)
function onStart () {
Plugin.isActive = true
}
function onStop () {
Plugin.isActive = false
}
const el = createGlobalNode('q-loading-bar')
createChildApp({
name: 'LoadingBar',
// hide App from Vue devtools
devtools: { hide: true },
setup: () => () => h(QAjaxBar, { ...props.value, onStart, onStop, ref: barRef })
}, parentApp).mount(el)
Object.assign(this, {
start (speed) {
barRef.value.start(speed)
},
stop () {
barRef.value.stop()
},
increment () {
barRef.value.increment.apply(null, arguments)
},
setDefaults (opts) {
if (isObject(opts) === true) {
Object.assign(props.value, opts)
}
}
})
}
})
export default Plugin

View file

@ -0,0 +1,68 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/loading-bar"
},
"injection": "$q.loadingBar",
"quasarConfOptions": {
"propName": "loadingBar",
"type": "Object",
"tsType": "QLoadingBarOptions",
"desc": "QAjaxBar component props, EXCEPT for 'hijack-filter' in quasar.config file (if using Quasar CLI)",
"examples": [ "{ position: 'bottom', reverse: true }" ]
},
"props": {
"isActive": {
"type": "Boolean",
"desc": "Is LoadingBar active?",
"reactive": true
}
},
"methods": {
"start": {
"desc": "Notify bar you've started a background activity",
"params": {
"speed": {
"type": "Number",
"desc": "Delay (in milliseconds) between bar progress increments",
"default": "300"
}
},
"returns": null
},
"stop": {
"desc": "Notify bar one background activity has finalized",
"params": null,
"returns": null
},
"increment": {
"desc": "Manually trigger a bar progress increment",
"params": {
"amount": {
"type": "Number",
"desc": "Amount (0.0 < x < 1.0) to increment with"
}
},
"returns": null
},
"setDefaults": {
"desc": "Set the inner QAjaxBar's props",
"params": {
"props": {
"type": "Object",
"tsType": "QLoadingBarOptions",
"required": true,
"desc": "QAjaxBar component props",
"examples": [ "{ position: 'bottom', reverse: true }" ]
}
},
"returns": null
}
}
}

View file

@ -0,0 +1,207 @@
import { h, Transition, onMounted } from 'vue'
import QSpinner from '../../components/spinner/QSpinner.js'
import { createChildApp } from '../../install-quasar.js'
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { createGlobalNode, removeGlobalNode } from '../../utils/private.config/nodes.js'
import preventScroll from '../../utils/scroll/prevent-scroll.js'
import { isObject } from '../../utils/is/is.js'
let
app,
vm,
uid = 0,
timeout = null,
props = {},
activeGroups = {}
const originalDefaults = {
group: '__default_quasar_group__',
delay: 0,
message: false,
html: false,
spinnerSize: 80,
spinnerColor: '',
messageColor: '',
backgroundColor: '',
boxClass: '',
spinner: QSpinner,
customClass: ''
}
const defaults = { ...originalDefaults }
function registerProps (opts) {
if (opts?.group !== void 0 && activeGroups[ opts.group ] !== void 0) {
return Object.assign(activeGroups[ opts.group ], opts)
}
const newProps = isObject(opts) === true && opts.ignoreDefaults === true
? { ...originalDefaults, ...opts }
: { ...defaults, ...opts }
activeGroups[ newProps.group ] = newProps
return newProps
}
const Plugin = createReactivePlugin({
isActive: false
}, {
show (opts) {
if (__QUASAR_SSR_SERVER__) return
props = registerProps(opts)
const { group } = props
Plugin.isActive = true
if (app !== void 0) {
props.uid = uid
vm.$forceUpdate()
}
else {
props.uid = ++uid
timeout !== null && clearTimeout(timeout)
timeout = setTimeout(() => {
timeout = null
const el = createGlobalNode('q-loading')
app = createChildApp({
name: 'QLoading',
setup () {
onMounted(() => {
preventScroll(true)
})
function onAfterLeave () {
// might be called to finalize
// previous leave, even if it was cancelled
if (Plugin.isActive !== true && app !== void 0) {
preventScroll(false)
app.unmount(el)
removeGlobalNode(el)
app = void 0
vm = void 0
}
}
function getContent () {
if (Plugin.isActive !== true) {
return null
}
const content = [
h(props.spinner, {
class: 'q-loading__spinner',
color: props.spinnerColor,
size: props.spinnerSize
})
]
props.message && content.push(
h('div', {
class: 'q-loading__message'
+ (props.messageColor ? ` text-${ props.messageColor }` : ''),
[ props.html === true ? 'innerHTML' : 'textContent' ]: props.message
})
)
return h('div', {
class: 'q-loading fullscreen flex flex-center z-max ' + props.customClass.trim(),
key: props.uid
}, [
h('div', {
class: 'q-loading__backdrop'
+ (props.backgroundColor ? ` bg-${ props.backgroundColor }` : '')
}),
h('div', {
class: 'q-loading__box column items-center ' + props.boxClass
}, content)
])
}
return () => h(Transition, {
name: 'q-transition--fade',
appear: true,
onAfterLeave
}, getContent)
}
}, Plugin.__parentApp)
vm = app.mount(el)
}, props.delay)
}
return paramProps => {
// if we don't have params (or not an Object param) then we need to hide this group
if (paramProps === void 0 || Object(paramProps) !== paramProps) {
Plugin.hide(group)
return
}
// else we have params so we need to update this group
Plugin.show({ ...paramProps, group })
}
},
hide (group) {
if (__QUASAR_SSR_SERVER__ !== true && Plugin.isActive === true) {
if (group === void 0) {
// clear out any active groups
activeGroups = {}
}
else if (activeGroups[ group ] === void 0) {
// we've already hidden it so nothing to do
return
}
else {
// remove active group
delete activeGroups[ group ]
const keys = Object.keys(activeGroups)
// if there are other groups registered then
// show last registered one since that one is still active
if (keys.length !== 0) {
// get last registered group
const lastGroup = keys[ keys.length - 1 ]
Plugin.show({ group: lastGroup })
return
}
}
if (timeout !== null) {
clearTimeout(timeout)
timeout = null
}
Plugin.isActive = false
}
},
setDefaults (opts) {
if (__QUASAR_SSR_SERVER__ !== true) {
isObject(opts) === true && Object.assign(defaults, opts)
}
},
install ({ $q, parentApp }) {
$q.loading = this
if (__QUASAR_SSR_SERVER__ !== true) {
Plugin.__parentApp = parentApp
if ($q.config.loading !== void 0) {
this.setDefaults($q.config.loading)
}
}
}
})
export default Plugin

View file

@ -0,0 +1,228 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/loading"
},
"injection": "$q.loading",
"quasarConfOptions": {
"propName": "loading",
"type": "Object",
"definition": {
"delay": {
"type": "Number",
"desc": "Wait a number of millisecond before showing; Not worth showing for 100ms for example then hiding it, so wait until you're sure it's a process that will take some considerable amount of time",
"examples": [ "400" ]
},
"message": {
"type": "String",
"desc": "Message to display",
"examples": [ "'Processing your request'" ]
},
"group": {
"type": "String",
"desc": "Default Loading group name",
"default": "'__default_quasar_group__'",
"examples": [ "'default-group-name'" ],
"addedIn": "v2.8"
},
"html": {
"extends": "html",
"desc": "Force render the message as HTML; This can lead to XSS attacks so make sure that you sanitize the content"
},
"boxClass": {
"type": "String",
"desc": "Content wrapped element custom classes",
"examples": [ "'bg-amber text-black'", "'q-pa-xl'" ]
},
"spinnerSize": {
"type": "Number",
"desc": "Spinner size (in pixels)"
},
"spinnerColor": {
"extends": "color",
"desc": "Color name for spinner from the Quasar Color Palette"
},
"messageColor": {
"extends": "color",
"desc": "Color name for text from the Quasar Color Palette"
},
"backgroundColor": {
"extends": "color",
"desc": "Color name for background from the Quasar Color Palette"
},
"spinner": {
"type": "Component",
"configFileType": "String",
"desc": "One of the QSpinners",
"examples": [ "QSpinnerAudio" ]
},
"customClass": {
"type": "String",
"desc": "Add a CSS class to the container element to easily customize the component",
"examples": [ "'my-class'" ]
}
}
},
"props": {
"isActive": {
"type": "Boolean",
"desc": "Is Loading active?",
"reactive": true
}
},
"methods": {
"show": {
"desc": "Activate and show",
"params": {
"opts": {
"type": "Object",
"tsType": "QLoadingShowOptions",
"autoDefineTsType": true,
"desc": "All props are optional",
"definition": {
"delay": {
"type": "Number",
"desc": "Wait a number of millisecond before showing; Not worth showing for 100ms for example then hiding it, so wait until you're sure it's a process that will take some considerable amount of time"
},
"message": {
"type": "String",
"desc": "Message to display",
"examples": [ "'Processing your request'" ]
},
"group": {
"type": "String",
"desc": "Loading group name",
"examples": [ "'some-api-call'" ],
"addedIn": "v2.8"
},
"html": {
"extends": "html",
"desc": "Render the message as HTML; This can lead to XSS attacks so make sure that you sanitize the message first"
},
"boxClass": {
"type": "String",
"desc": "Content wrapped element custom classes",
"examples": [ "'bg-amber text-black'", "'q-pa-xl'" ]
},
"spinnerSize": {
"type": "Number",
"desc": "Spinner size (in pixels)"
},
"spinnerColor": {
"extends": "color",
"desc": "Color name for spinner from the Quasar Color Palette"
},
"messageColor": {
"extends": "color",
"desc": "Color name for text from the Quasar Color Palette"
},
"backgroundColor": {
"extends": "color",
"desc": "Color name for background from the Quasar Color Palette"
},
"spinner": {
"type": "Component",
"desc": "One of the QSpinners"
},
"customClass": {
"type": "String",
"desc": "Add a CSS class to easily customize the component",
"examples": [ "'my-class'" ]
},
"ignoreDefaults": {
"type": "Boolean",
"desc": "Ignore the default configuration (set by setDefaults()) for this instance only"
}
}
}
},
"returns": {
"type": "Function",
"desc": "Calling this function with no parameters hides the group; When called with one Object parameter then it updates the Loading group (specified properties are shallow merged with the group ones; note that group cannot be changed while updating and it is ignored)",
"params": {
"props": {
"type": "Object",
"tsType": "QLoadingUpdateOptions",
"required": false,
"desc": "Loading properties that will be shallow merged to the group ones; (See 'opts' param of 'show()' for object properties, except 'group')",
"__exemption": [ "definition" ]
}
},
"returns": null,
"addedIn": "v2.8"
}
},
"hide": {
"desc": "Hide it",
"params": {
"group": {
"type": "String",
"desc": "Optional Loading group name to hide instead of hiding all groups",
"required": false,
"examples": [ "'some-api-call'" ],
"addedIn": "v2.8"
}
},
"returns": null
},
"setDefaults": {
"desc": "Merge options into the default ones",
"params": {
"opts": {
"type": "Object",
"desc": "Pick the subprop you want to define",
"required": true,
"definition": {
"delay": {
"type": "Number",
"desc": "Wait a number of millisecond before showing; Not worth showing for 100ms for example then hiding it, so wait until you're sure it's a process that will take some considerable amount of time"
},
"message": {
"type": "String",
"desc": "Message to display",
"examples": [ "'Processing your request'" ]
},
"group": {
"type": "String",
"desc": "Default Loading group name",
"default": "'__default_quasar_group__'",
"examples": [ "'default-group-name'" ],
"addedIn": "v2.8"
},
"spinnerSize": {
"type": "Number",
"desc": "Spinner size (in pixels)"
},
"spinnerColor": {
"extends": "color",
"desc": "Color name for spinner from the Quasar Color Palette"
},
"messageColor": {
"extends": "color",
"desc": "Color name for text from the Quasar Color Palette"
},
"backgroundColor": {
"extends": "color",
"desc": "Color name for background from the Quasar Color Palette"
},
"spinner": {
"type": "Component",
"desc": "One of the QSpinners"
},
"customClass": {
"type": "String",
"desc": "Add a CSS class to easily customize the component",
"examples": [ "'my-class'" ]
}
}
}
},
"returns": null
}
}
}

View file

@ -0,0 +1,26 @@
.q-loading
color: #000
// override q-transition--fade's absolute position
position: fixed !important
&__backdrop
position: fixed
top: 0
right: 0
bottom: 0
left: 0
opacity: .5
z-index: -1
background-color: #000
transition: background-color .28s
&__box
border-radius: $generic-border-radius
padding: 18px
color: #fff
max-width: 450px
&__message
margin: 40px 20px 0
text-align: center

View file

@ -0,0 +1,267 @@
import { isRuntimeSsrPreHydration } from '../platform/Platform.js'
import extend from '../../utils/extend/extend.js'
let updateId = null, currentClientMeta
export const clientList = []
function normalize (meta) {
if (meta.title) {
meta.title = meta.titleTemplate
? meta.titleTemplate(meta.title)
: meta.title
delete meta.titleTemplate
}
;[ [ 'meta', 'content' ], [ 'link', 'href' ] ].forEach(type => {
const
metaType = meta[ type[ 0 ] ],
metaProp = type[ 1 ]
for (const name in metaType) {
const metaLink = metaType[ name ]
if (metaLink.template) {
if (Object.keys(metaLink).length === 1) {
delete metaType[ name ]
}
else {
metaLink[ metaProp ] = metaLink.template(metaLink[ metaProp ] || '')
delete metaLink.template
}
}
}
})
}
function changed (old, def) {
if (Object.keys(old).length !== Object.keys(def).length) {
return true
}
for (const key in old) {
if (old[ key ] !== def[ key ]) {
return true
}
}
}
function bodyFilter (name) {
return [ 'class', 'style' ].includes(name) === false
}
function htmlFilter (name) {
return [ 'lang', 'dir' ].includes(name) === false
}
function diff (meta, other) {
const add = {}, remove = {}
if (meta === void 0) {
return { add: other, remove }
}
if (meta.title !== other.title) {
add.title = other.title
}
;[ 'meta', 'link', 'script', 'htmlAttr', 'bodyAttr' ].forEach(type => {
const old = meta[ type ], cur = other[ type ]
remove[ type ] = []
if (old === void 0 || old === null) {
add[ type ] = cur
return
}
add[ type ] = {}
for (const key in old) {
if (cur.hasOwnProperty(key) === false) {
remove[ type ].push(key)
}
}
for (const key in cur) {
if (old.hasOwnProperty(key) === false) {
add[ type ][ key ] = cur[ key ]
}
else if (changed(old[ key ], cur[ key ]) === true) {
remove[ type ].push(key)
add[ type ][ key ] = cur[ key ]
}
}
})
return { add, remove }
}
function apply ({ add, remove }) {
if (add.title) {
document.title = add.title
}
if (Object.keys(remove).length !== 0) {
[ 'meta', 'link', 'script' ].forEach(type => {
remove[ type ].forEach(name => {
document.head.querySelector(`${ type }[data-qmeta="${ name }"]`).remove()
})
})
remove.htmlAttr.filter(htmlFilter).forEach(name => {
document.documentElement.removeAttribute(name)
})
remove.bodyAttr.filter(bodyFilter).forEach(name => {
document.body.removeAttribute(name)
})
}
;[ 'meta', 'link', 'script' ].forEach(type => {
const metaType = add[ type ]
for (const name in metaType) {
const tag = document.createElement(type)
for (const att in metaType[ name ]) {
if (att !== 'innerHTML') {
tag.setAttribute(att, metaType[ name ][ att ])
}
}
tag.setAttribute('data-qmeta', name)
if (type === 'script') {
tag.innerHTML = metaType[ name ].innerHTML || ''
}
document.head.appendChild(tag)
}
})
Object.keys(add.htmlAttr).filter(htmlFilter).forEach(name => {
document.documentElement.setAttribute(name, add.htmlAttr[ name ] || '')
})
Object.keys(add.bodyAttr).filter(bodyFilter).forEach(name => {
document.body.setAttribute(name, add.bodyAttr[ name ] || '')
})
}
function getAttr (seed) {
return att => {
const val = seed[ att ]
return att + (val !== true && val !== void 0 ? `="${ val }"` : '')
}
}
function getHead (meta) {
let output = ''
if (meta.title) {
output += `<title>${ meta.title }</title>`
}
;[ 'meta', 'link', 'script' ].forEach(type => {
const metaType = meta[ type ]
for (const att in metaType) {
const attrs = Object.keys(metaType[ att ])
.filter(att => att !== 'innerHTML')
.map(getAttr(metaType[ att ]))
output += `<${ type } ${ attrs.join(' ') } data-qmeta="${ att }">`
if (type === 'script') {
output += (metaType[ att ].innerHTML || '') + '</script>'
}
}
})
return output
}
function injectServerMeta (ssrContext) {
const data = {
title: '',
titleTemplate: null,
meta: {},
link: {},
htmlAttr: {},
bodyAttr: {},
noscript: {}
}
const list = ssrContext.__qMetaList
for (let i = 0; i < list.length; i++) {
extend(true, data, list[ i ])
}
normalize(data)
const nonce = ssrContext.nonce !== void 0
? ` nonce="${ ssrContext.nonce }"`
: ''
const ctx = ssrContext._meta
const htmlAttr = Object.keys(data.htmlAttr).filter(htmlFilter)
if (htmlAttr.length !== 0) {
ctx.htmlAttrs += (
(ctx.htmlAttrs.length !== 0 ? ' ' : '')
+ htmlAttr.map(getAttr(data.htmlAttr)).join(' ')
)
}
ctx.headTags += getHead(data)
const bodyAttr = Object.keys(data.bodyAttr).filter(bodyFilter)
if (bodyAttr.length !== 0) {
ctx.bodyAttrs += (
(ctx.bodyAttrs.length !== 0 ? ' ' : '')
+ bodyAttr.map(getAttr(data.bodyAttr)).join(' ')
)
}
ctx.bodyTags += Object.keys(data.noscript)
.map(name => `<noscript data-qmeta="${ name }">${ data.noscript[ name ] }</noscript>`)
.join('')
+ `<script${ nonce } id="qmeta-init">window.__Q_META__=${ delete data.noscript && JSON.stringify(data) }</script>`
}
function updateClientMeta () {
updateId = null
const data = {
title: '',
titleTemplate: null,
meta: {},
link: {},
script: {},
htmlAttr: {},
bodyAttr: {}
}
for (let i = 0; i < clientList.length; i++) {
const { active, val } = clientList[ i ]
if (active === true) {
extend(true, data, val)
}
}
normalize(data)
apply(diff(currentClientMeta, data))
currentClientMeta = data
}
export function planClientUpdate () {
updateId !== null && clearTimeout(updateId)
updateId = setTimeout(updateClientMeta, 50)
}
export default {
install (opts) {
if (__QUASAR_SSR_SERVER__) {
const { ssrContext } = opts
ssrContext.__qMetaList = []
ssrContext.onRendered(() => {
injectServerMeta(ssrContext)
})
}
else if (this.__installed !== true && isRuntimeSsrPreHydration.value === true) {
currentClientMeta = window.__Q_META__
document.getElementById('qmeta-init').remove()
}
}
}

View file

@ -0,0 +1,5 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/meta"
}
}

View file

@ -0,0 +1,544 @@
import { h, ref, markRaw, TransitionGroup } from 'vue'
import QAvatar from '../../components/avatar/QAvatar.js'
import QIcon from '../../components/icon/QIcon.js'
import QBtn from '../../components/btn/QBtn.js'
import QSpinner from '../../components/spinner/QSpinner.js'
import { createChildApp } from '../../install-quasar.js'
import { createComponent } from '../../utils/private.create/create.js'
import { noop } from '../../utils/event/event.js'
import { createGlobalNode } from '../../utils/private.config/nodes.js'
import { isObject } from '../../utils/is/is.js'
let uid = 0
const defaults = {}
const groups = {}
const notificationsList = {}
const positionClass = {}
const emptyRE = /^\s*$/
const notifRefs = []
const invalidTimeoutValues = [ void 0, null, true, false, '' ]
const positionList = [
'top-left', 'top-right',
'bottom-left', 'bottom-right',
'top', 'bottom', 'left', 'right', 'center'
]
const badgePositions = [
'top-left', 'top-right',
'bottom-left', 'bottom-right'
]
const notifTypes = {
positive: {
icon: $q => $q.iconSet.type.positive,
color: 'positive'
},
negative: {
icon: $q => $q.iconSet.type.negative,
color: 'negative'
},
warning: {
icon: $q => $q.iconSet.type.warning,
color: 'warning',
textColor: 'dark'
},
info: {
icon: $q => $q.iconSet.type.info,
color: 'info'
},
ongoing: {
group: false,
timeout: 0,
spinner: true,
color: 'grey-8'
}
}
function addNotification (config, $q, originalApi) {
if (!config) {
return logError('parameter required')
}
let Api
const notif = { textColor: 'white' }
if (config.ignoreDefaults !== true) {
Object.assign(notif, defaults)
}
if (isObject(config) === false) {
if (notif.type) {
Object.assign(notif, notifTypes[ notif.type ])
}
config = { message: config }
}
Object.assign(notif, notifTypes[ config.type || notif.type ], config)
if (typeof notif.icon === 'function') {
notif.icon = notif.icon($q)
}
if (!notif.spinner) {
notif.spinner = false
}
else {
if (notif.spinner === true) {
notif.spinner = QSpinner
}
notif.spinner = markRaw(notif.spinner)
}
notif.meta = {
hasMedia: Boolean(notif.spinner !== false || notif.icon || notif.avatar),
hasText: hasContent(notif.message) || hasContent(notif.caption)
}
if (notif.position) {
if (positionList.includes(notif.position) === false) {
return logError('wrong position', config)
}
}
else {
notif.position = 'bottom'
}
if (invalidTimeoutValues.includes(notif.timeout) === true) {
notif.timeout = 5000
}
else {
const t = Number(notif.timeout) // we catch exponential notation too with Number() casting
if (isNaN(t) || t < 0) {
return logError('wrong timeout', config)
}
notif.timeout = Number.isFinite(t) ? t : 0
}
if (notif.timeout === 0) {
notif.progress = false
}
else if (notif.progress === true) {
notif.meta.progressClass = 'q-notification__progress' + (
notif.progressClass
? ` ${ notif.progressClass }`
: ''
)
notif.meta.progressStyle = {
animationDuration: `${ notif.timeout + 1000 }ms`
}
}
const actions = (
Array.isArray(config.actions) === true
? config.actions
: []
).concat(
config.ignoreDefaults !== true && Array.isArray(defaults.actions) === true
? defaults.actions
: []
).concat(
Array.isArray(notifTypes[ config.type ]?.actions) === true
? notifTypes[ config.type ].actions
: []
)
const { closeBtn } = notif
closeBtn && actions.push({
label: typeof closeBtn === 'string'
? closeBtn
: $q.lang.label.close
})
notif.actions = actions.map(({ handler, noDismiss, ...item }) => ({
flat: true,
...item,
onClick: typeof handler === 'function'
? () => {
handler()
noDismiss !== true && dismiss()
}
: () => { dismiss() }
}))
if (notif.multiLine === void 0) {
notif.multiLine = notif.actions.length > 1
}
Object.assign(notif.meta, {
class: 'q-notification row items-stretch'
+ ` q-notification--${ notif.multiLine === true ? 'multi-line' : 'standard' }`
+ (notif.color !== void 0 ? ` bg-${ notif.color }` : '')
+ (notif.textColor !== void 0 ? ` text-${ notif.textColor }` : '')
+ (notif.classes !== void 0 ? ` ${ notif.classes }` : ''),
wrapperClass: 'q-notification__wrapper col relative-position border-radius-inherit '
+ (notif.multiLine === true ? 'column no-wrap justify-center' : 'row items-center'),
contentClass: 'q-notification__content row items-center'
+ (notif.multiLine === true ? '' : ' col'),
leftClass: notif.meta.hasText === true ? 'additional' : 'single',
attrs: {
role: 'alert',
...notif.attrs
}
})
if (notif.group === false) {
notif.group = void 0
notif.meta.group = void 0
}
else {
if (notif.group === void 0 || notif.group === true) {
// do not replace notifications with different buttons
notif.group = [
notif.message,
notif.caption,
notif.multiline
].concat(
notif.actions.map(props => `${ props.label }*${ props.icon }`)
).join('|')
}
notif.meta.group = notif.group + '|' + notif.position
}
if (notif.actions.length === 0) {
notif.actions = void 0
}
else {
notif.meta.actionsClass = 'q-notification__actions row items-center '
+ (notif.multiLine === true ? 'justify-end' : 'col-auto')
+ (notif.meta.hasMedia === true ? ' q-notification__actions--with-media' : '')
}
if (originalApi !== void 0) {
// reset timeout if any
if (originalApi.notif.meta.timer) {
clearTimeout(originalApi.notif.meta.timer)
originalApi.notif.meta.timer = void 0
}
// retain uid
notif.meta.uid = originalApi.notif.meta.uid
// replace notif
const index = notificationsList[ notif.position ].value.indexOf(originalApi.notif)
notificationsList[ notif.position ].value[ index ] = notif
}
else {
const original = groups[ notif.meta.group ]
// woohoo, it's a new notification
if (original === void 0) {
notif.meta.uid = uid++
notif.meta.badge = 1
if ([ 'left', 'right', 'center' ].indexOf(notif.position) !== -1) {
notificationsList[ notif.position ].value.splice(
Math.floor(notificationsList[ notif.position ].value.length / 2),
0,
notif
)
}
else {
const action = notif.position.indexOf('top') !== -1 ? 'unshift' : 'push'
notificationsList[ notif.position ].value[ action ](notif)
}
if (notif.group !== void 0) {
groups[ notif.meta.group ] = notif
}
}
// ok, so it's NOT a new one
else {
// reset timeout if any
if (original.meta.timer) {
clearTimeout(original.meta.timer)
original.meta.timer = void 0
}
if (notif.badgePosition !== void 0) {
if (badgePositions.includes(notif.badgePosition) === false) {
return logError('wrong badgePosition', config)
}
}
else {
notif.badgePosition = `top-${ notif.position.indexOf('left') !== -1 ? 'right' : 'left' }`
}
notif.meta.uid = original.meta.uid
notif.meta.badge = original.meta.badge + 1
notif.meta.badgeClass = `q-notification__badge q-notification__badge--${ notif.badgePosition }`
+ (notif.badgeColor !== void 0 ? ` bg-${ notif.badgeColor }` : '')
+ (notif.badgeTextColor !== void 0 ? ` text-${ notif.badgeTextColor }` : '')
+ (notif.badgeClass ? ` ${ notif.badgeClass }` : '')
const index = notificationsList[ notif.position ].value.indexOf(original)
notificationsList[ notif.position ].value[ index ] = groups[ notif.meta.group ] = notif
}
}
const dismiss = () => {
removeNotification(notif)
Api = void 0
}
if (notif.timeout > 0) {
notif.meta.timer = setTimeout(() => {
notif.meta.timer = void 0
dismiss()
}, notif.timeout + /* show duration */ 1000)
}
// only non-groupable can be updated
if (notif.group !== void 0) {
return props => {
if (props !== void 0) {
logError('trying to update a grouped one which is forbidden', config)
}
else {
dismiss()
}
}
}
Api = {
dismiss,
config,
notif
}
if (originalApi !== void 0) {
Object.assign(originalApi, Api)
return
}
return props => {
// if notification wasn't previously dismissed
if (Api !== void 0) {
// if no params, then we must dismiss the notification
if (props === void 0) {
Api.dismiss()
}
// otherwise we're updating it
else {
const newNotif = Object.assign({}, Api.config, props, {
group: false,
position: notif.position
})
addNotification(newNotif, $q, Api)
}
}
}
}
function removeNotification (notif) {
if (notif.meta.timer) {
clearTimeout(notif.meta.timer)
notif.meta.timer = void 0
}
const index = notificationsList[ notif.position ].value.indexOf(notif)
if (index !== -1) {
if (notif.group !== void 0) {
delete groups[ notif.meta.group ]
}
const el = notifRefs[ '' + notif.meta.uid ]
if (el) {
const { width, height } = getComputedStyle(el)
el.style.left = `${ el.offsetLeft }px`
el.style.width = width
el.style.height = height
}
notificationsList[ notif.position ].value.splice(index, 1)
if (typeof notif.onDismiss === 'function') {
notif.onDismiss()
}
}
}
function hasContent (str) {
return str !== void 0
&& str !== null
&& emptyRE.test(str) !== true
}
function logError (error, config) {
console.error(`Notify: ${ error }`, config)
return false
}
function getComponent () {
return createComponent({
name: 'QNotifications',
// hide App from Vue devtools
devtools: { hide: true },
setup () {
return () => h('div', { class: 'q-notifications' }, positionList.map(pos => {
return h(TransitionGroup, {
key: pos,
class: positionClass[ pos ],
tag: 'div',
name: `q-notification--${ pos }`
}, () => notificationsList[ pos ].value.map(notif => {
const meta = notif.meta
const mainChild = []
if (meta.hasMedia === true) {
if (notif.spinner !== false) {
mainChild.push(
h(notif.spinner, {
class: 'q-notification__spinner q-notification__spinner--' + meta.leftClass,
color: notif.spinnerColor,
size: notif.spinnerSize
})
)
}
else if (notif.icon) {
mainChild.push(
h(QIcon, {
class: 'q-notification__icon q-notification__icon--' + meta.leftClass,
name: notif.icon,
color: notif.iconColor,
size: notif.iconSize,
role: 'img'
})
)
}
else if (notif.avatar) {
mainChild.push(
h(QAvatar, {
class: 'q-notification__avatar q-notification__avatar--' + meta.leftClass
}, () => h('img', { src: notif.avatar, 'aria-hidden': 'true' }))
)
}
}
if (meta.hasText === true) {
let msgChild
const msgData = { class: 'q-notification__message col' }
if (notif.html === true) {
msgData.innerHTML = notif.caption
? `<div>${ notif.message }</div><div class="q-notification__caption">${ notif.caption }</div>`
: notif.message
}
else {
const msgNode = [ notif.message ]
msgChild = notif.caption
? [
h('div', msgNode),
h('div', { class: 'q-notification__caption' }, [ notif.caption ])
]
: msgNode
}
mainChild.push(
h('div', msgData, msgChild)
)
}
const child = [
h('div', { class: meta.contentClass }, mainChild)
]
notif.progress === true && child.push(
h('div', {
key: `${ meta.uid }|p|${ meta.badge }`,
class: meta.progressClass,
style: meta.progressStyle
})
)
notif.actions !== void 0 && child.push(
h('div', {
class: meta.actionsClass
}, notif.actions.map(props => h(QBtn, props)))
)
meta.badge > 1 && child.push(
h('div', {
key: `${ meta.uid }|${ meta.badge }`,
class: notif.meta.badgeClass,
style: notif.badgeStyle
}, [ meta.badge ])
)
return h('div', {
ref: el => { notifRefs[ '' + meta.uid ] = el },
key: meta.uid,
class: meta.class,
...meta.attrs
}, [
h('div', { class: meta.wrapperClass }, child)
])
}))
}))
}
})
}
export default {
setDefaults (opts) {
if (__QUASAR_SSR_SERVER__ !== true) {
isObject(opts) === true && Object.assign(defaults, opts)
}
},
registerType (typeName, typeOpts) {
if (__QUASAR_SSR_SERVER__ !== true && isObject(typeOpts) === true) {
notifTypes[ typeName ] = typeOpts
}
},
install ({ $q, parentApp }) {
$q.notify = this.create = __QUASAR_SSR_SERVER__
? noop
: opts => addNotification(opts, $q)
$q.notify.setDefaults = this.setDefaults
$q.notify.registerType = this.registerType
if ($q.config.notify !== void 0) {
this.setDefaults($q.config.notify)
}
if (__QUASAR_SSR_SERVER__ !== true && this.__installed !== true) {
positionList.forEach(pos => {
notificationsList[ pos ] = ref([])
const
vert = [ 'left', 'center', 'right' ].includes(pos) === true ? 'center' : (pos.indexOf('top') !== -1 ? 'top' : 'bottom'),
align = pos.indexOf('left') !== -1 ? 'start' : (pos.indexOf('right') !== -1 ? 'end' : 'center'),
classes = [ 'left', 'right' ].includes(pos) ? `items-${ pos === 'left' ? 'start' : 'end' } justify-center` : (pos === 'center' ? 'flex-center' : `items-${ align }`)
positionClass[ pos ] = `q-notifications__list q-notifications__list--${ vert } fixed column no-wrap ${ classes }`
})
const el = createGlobalNode('q-notify')
createChildApp(getComponent(), parentApp).mount(el)
}
}
}

View file

@ -0,0 +1,492 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/notify"
},
"injection": "$q.notify",
"quasarConfOptions": {
"propName": "notify",
"type": "Object",
"definition": {
"type": {
"type": "String",
"desc": "Optional type (that has been previously registered) or one of the out of the box ones ('positive', 'negative', 'warning', 'info', 'ongoing')",
"examples": [ "'negative'", "'custom-type'" ]
},
"color": {
"extends": "color"
},
"textColor": {
"extends": "text-color"
},
"message": {
"type": "String",
"desc": "The content of your message",
"examples": [ "'John Doe pinged you'" ]
},
"caption": {
"type": "String",
"desc": "The content of your optional caption",
"examples": [ "'5 minutes ago'" ]
},
"html": {
"type": "Boolean",
"desc": "Render the message as HTML; This can lead to XSS attacks, so make sure that you sanitize the message first"
},
"icon": {
"extends": "icon"
},
"iconColor": {
"extends": "color",
"addedIn": "v2.5.5"
},
"iconSize": {
"extends": "size",
"addedIn": "v2.5.5"
},
"avatar": {
"type": "String",
"desc": "URL to an avatar/image; Suggestion: use public folder",
"examples": [
"# (public folder) 'img/something.png'",
"# (relative path format) require('./my_img.jpg')",
"# (URL) https://some-site.net/some-img.gif"
]
},
"spinner": {
"type": [ "Boolean", "Component" ],
"configFileType": [ "Boolean", "String" ],
"desc": "Useful for notifications that are updated; Displays a Quasar spinner instead of an avatar or icon; If value is Boolean 'true' then the default QSpinner is shown",
"examples": [ "true", "QSpinnerBars" ]
},
"spinnerColor": {
"extends": "color",
"addedIn": "v2.5.5"
},
"spinnerSize": {
"extends": "size",
"addedIn": "v2.5.5"
},
"position": {
"type": "String",
"desc": "Window side/corner to stick to",
"default": "'bottom'",
"values": [
"'top-left'", "'top-right'",
"'bottom-left'", "'bottom-right'",
"'top'", "'bottom'", "'left'", "'right'", "'center'"
]
},
"group": {
"type": [ "Boolean", "String", "Number" ],
"desc": "Override the auto generated group with custom one; Grouped notifications cannot be updated; String or number value inform this is part of a specific group, regardless of its options; When a new notification is triggered with same group name, it replaces the old one and shows a badge with how many times the notification was triggered",
"default": "# message + caption + multiline + actions labels + position",
"examples": [ "'my-group'" ]
},
"badgeColor": {
"extends": "color",
"desc": "Color name for the badge from the Quasar Color Palette"
},
"badgeTextColor": {
"extends": "color",
"desc": "Color name for the badge text from the Quasar Color Palette"
},
"badgePosition": {
"type": "String",
"desc": "Notification corner to stick badge to; If notification is on the left side then default is top-right otherwise it is top-left",
"default": "# top-left/top-right",
"values": [
"'top-left'", "'top-right'",
"'bottom-left'", "'bottom-right'"
]
},
"badgeStyle": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueStyleProp",
"desc": "Style definitions to be attributed to the badge",
"examples": [
"'background-color: #ff0000'",
"{ backgroundColor: '#ff0000' }"
]
},
"badgeClass": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "Class definitions to be attributed to the badge",
"examples": [
"'my-special-class'",
"{ 'my-special-class': true }"
]
},
"progress": {
"type": "Boolean",
"desc": "Show progress bar to detail when notification will disappear automatically (unless timeout is 0)"
},
"progressClass": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "Class definitions to be attributed to the progress bar",
"examples": [
"'my-special-class'",
"{ 'my-special-class': true }"
]
},
"classes": {
"type": "String",
"desc": "Add CSS class(es) to the notification for easier customization",
"examples": [ "'my-notif-class'" ]
},
"attrs": {
"type": "Object",
"desc": "Key-value for attributes to be set on the notification",
"examples": [ "{ role: 'alertdialog' }" ],
"__exemption": [ "definition" ]
},
"timeout": {
"type": "Number",
"desc": "Amount of time to display (in milliseconds). Set to 0 to never dismiss automatically.",
"default": "5000"
},
"actions": {
"type": "Array",
"tsType": "QNotifyAction",
"desc": "Notification actions (buttons); Unless 'noDismiss' is true, clicking/tapping on the button will close the notification; Also check 'closeBtn' convenience prop",
"definition": {
"handler": {
"type": "Function",
"configFileType": null,
"desc": "Function to be executed when the button is clicked/tapped",
"params": null,
"returns": null,
"examples": [ "() => { console.log('button clicked') }" ]
},
"noDismiss": {
"type": "Boolean",
"desc": "Do not dismiss the notification when the button is clicked/tapped"
},
"...": {
"type": "Any",
"desc": "Any other QBtn prop except 'onClick' (use 'handler' instead, only possible with UI config)",
"examples": [ "label: 'Learn more'", "color: 'primary'" ]
}
},
"examples": [ "[ { label: 'Show', handler: () => {}, 'aria-label': 'Button label' }, { icon: 'map', handler: () => {}, color: 'yellow' }, { label: 'Learn more', noDismiss: true, handler: () => {} } ]" ]
},
"onDismiss": {
"type": "Function",
"configFileType": null,
"desc": "Function to call when notification gets dismissed",
"params": null,
"returns": null,
"examples": [ "() => { console.log('Dismissed') }" ]
},
"closeBtn": {
"type": [ "Boolean", "String" ],
"desc": "Convenient way to add a dismiss button with a specific label, without using the 'actions' prop; If set to true, it uses a label according to the current Quasar language",
"examples": [ "'Close me'" ]
},
"multiLine": {
"type": "Boolean",
"desc": "Put notification into multi-line mode; If this prop isn't used and more than one 'action' is specified then notification goes into multi-line mode by default"
}
}
},
"methods": {
"create": {
"tsInjectionPoint": true,
"desc": "Creates a notification; Same as calling $q.notify(...)",
"params": {
"opts": {
"type": [ "Object", "String" ],
"tsType": "QNotifyCreateOptions",
"autoDefineTsType": true,
"required": true,
"desc": "Notification options",
"definition": {
"type": {
"type": "String",
"desc": "Optional type (that has been previously registered) or one of the out of the box ones ('positive', 'negative', 'warning', 'info', 'ongoing')",
"examples": [ "'negative'", "'custom-type'" ]
},
"color": {
"extends": "color"
},
"textColor": {
"extends": "text-color"
},
"message": {
"type": "String",
"desc": "The content of your message",
"examples": [ "'John Doe pinged you'" ]
},
"caption": {
"type": "String",
"desc": "The content of your optional caption",
"examples": [ "'5 minutes ago'" ]
},
"html": {
"type": "Boolean",
"desc": "Render the message as HTML; This can lead to XSS attacks, so make sure that you sanitize the message first"
},
"icon": {
"extends": "icon"
},
"iconColor": {
"extends": "color",
"addedIn": "v2.5.5"
},
"iconSize": {
"extends": "size",
"addedIn": "v2.5.5"
},
"avatar": {
"type": "String",
"desc": "URL to an avatar/image; Suggestion: use public folder",
"examples": [
"# (public folder) 'img/something.png'",
"# (relative path format) require('./my_img.jpg')",
"# (URL) https://some-site.net/some-img.gif"
]
},
"spinner": {
"type": [ "Boolean", "Component" ],
"desc": "Useful for notifications that are updated; Displays a Quasar spinner instead of an avatar or icon; If value is Boolean 'true' then the default QSpinner is shown",
"examples": [ "true", "QSpinnerBars" ]
},
"spinnerColor": {
"extends": "color",
"addedIn": "v2.5.5"
},
"spinnerSize": {
"extends": "size",
"addedIn": "v2.5.5"
},
"position": {
"type": "String",
"desc": "Window side/corner to stick to",
"default": "'bottom'",
"values": [
"'top-left'", "'top-right'",
"'bottom-left'", "'bottom-right'",
"'top'", "'bottom'", "'left'", "'right'", "'center'"
]
},
"group": {
"type": [ "Boolean", "String", "Number" ],
"desc": "Override the auto generated group with custom one; Grouped notifications cannot be updated; String or number value inform this is part of a specific group, regardless of its options; When a new notification is triggered with same group name, it replaces the old one and shows a badge with how many times the notification was triggered",
"default": "# message + caption + multiline + actions labels + position",
"examples": [ "'my-group'" ]
},
"badgeColor": {
"extends": "color",
"desc": "Color name for the badge from the Quasar Color Palette"
},
"badgeTextColor": {
"extends": "color",
"desc": "Color name for the badge text from the Quasar Color Palette"
},
"badgePosition": {
"type": "String",
"desc": "Notification corner to stick badge to; If notification is on the left side then default is top-right otherwise it is top-left",
"default": "# top-left/top-right",
"values": [
"'top-left'", "'top-right'",
"'bottom-left'", "'bottom-right'"
]
},
"badgeStyle": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueStyleProp",
"desc": "Style definitions to be attributed to the badge",
"examples": [
"'background-color: #ff0000'",
"{ backgroundColor: '#ff0000' }"
]
},
"badgeClass": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "Class definitions to be attributed to the badge",
"examples": [
"'my-special-class'",
"{ 'my-special-class': true }"
]
},
"progress": {
"type": "Boolean",
"desc": "Show progress bar to detail when notification will disappear automatically (unless timeout is 0)"
},
"progressClass": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "Class definitions to be attributed to the progress bar",
"examples": [
"'my-special-class'",
"{ 'my-special-class': true }"
]
},
"classes": {
"type": "String",
"desc": "Add CSS class(es) to the notification for easier customization",
"examples": [ "'my-notif-class'" ]
},
"attrs": {
"type": "Object",
"desc": "Key-value for attributes to be set on the notification",
"examples": [ "{ role: 'alertdialog' }" ],
"__exemption": [ "definition" ]
},
"timeout": {
"type": "Number",
"desc": "Amount of time to display (in milliseconds). Set to 0 to never dismiss automatically.",
"default": "5000",
"examples": [ "2500" ]
},
"actions": {
"type": "Array",
"tsType": "QNotifyAction",
"desc": "Notification actions (buttons); Unless 'noDismiss' is true, clicking/tapping on the button will close the notification; Also check 'closeBtn' convenience prop",
"definition": {
"handler": {
"type": "Function",
"desc": "Function to be executed when the button is clicked/tapped",
"params": null,
"returns": null,
"examples": [ "() => { console.log('button clicked') }" ]
},
"noDismiss": {
"type": "Boolean",
"desc": "Do not dismiss the notification when the button is clicked/tapped"
},
"...": {
"type": "Any",
"desc": "Any other QBtn prop except 'onClick' (use 'handler' instead)",
"examples": [ "label: 'Learn more'", "color: 'primary'" ]
}
},
"examples": [ "[ { label: 'Show', handler: () => {}, 'aria-label': 'Button label' }, { icon: 'map', handler: () => {}, color: 'yellow' }, { label: 'Learn more', noDismiss: true, handler: () => {} } ]" ]
},
"onDismiss": {
"type": "Function",
"desc": "Function to call when notification gets dismissed",
"params": null,
"returns": null,
"examples": [ "() => { console.log('Dismissed') }" ]
},
"closeBtn": {
"type": [ "Boolean", "String" ],
"desc": "Convenient way to add a dismiss button with a specific label, without using the 'actions' prop; If set to true, it uses a label according to the current Quasar language",
"examples": [ "'Close me'" ]
},
"multiLine": {
"type": "Boolean",
"desc": "Put notification into multi-line mode; If this prop isn't used and more than one 'action' is specified then notification goes into multi-line mode by default"
},
"ignoreDefaults": {
"type": "Boolean",
"desc": "Ignore the default configuration (set by setDefaults()) for this instance only"
}
}
}
},
"returns": {
"type": "Function",
"desc": "Calling this function with no parameters hides the notification; When called with one Object parameter (the original notification must NOT be grouped), it updates the notification (specified properties are shallow merged with previous ones; note that group and position cannot be changed while updating and so they are ignored)",
"params": {
"props": {
"type": "Object",
"tsType": "QNotifyUpdateOptions",
"required": false,
"desc": "Notification properties that will be shallow merged to previous ones in order to update the non-grouped notification; (See 'opts' param of 'create()' for object properties, except 'group' and 'position')",
"__exemption": [ "definition" ]
}
},
"returns": null
}
},
"setDefaults": {
"desc": "Merge options into the default ones",
"params": {
"opts": {
"type": "Object",
"tsType": "QNotifyOptions",
"required": true,
"desc": "Notification options except 'ignoreDefaults' (See 'opts' param of 'create()' for object properties)",
"__exemption": [ "definition" ]
}
},
"returns": null
},
"registerType": {
"desc": "Register a new type of notification (or override an existing one)",
"params": {
"typeName": {
"type": "String",
"required": true,
"desc": "Name of the type (to be used as 'type' prop later on)",
"examples": [ "'my-type'" ]
},
"typeOpts": {
"type": "Object",
"tsType": "QNotifyOptions",
"required": true,
"desc": "Notification options except 'ignoreDefaults' (See 'opts' param of 'create()' for object properties)",
"__exemption": [ "definition" ]
}
},
"returns": null
}
}
}

View file

@ -0,0 +1,206 @@
.q-notifications__list
z-index: $z-notify
pointer-events: none
left: 0
right: 0
margin-bottom: 10px
position: relative
&--center
top: 0
bottom: 0
&--top
top: 0
&--bottom
bottom: 0
body.q-ios-padding .q-notifications__list
&--center, &--top
top: $ios-statusbar-height
top: env(safe-area-inset-top)
&--center, &--bottom
bottom: env(safe-area-inset-bottom)
.q-notification
box-shadow: $shadow-2
border-radius: $generic-border-radius
pointer-events: all
display: inline-flex
margin: 10px 10px 0
transition: transform 1s, opacity 1s
z-index: $z-notify
flex-shrink: 0
max-width: 95vw
background: #323232
color: #fff
font-size: 14px
&__icon
font-size: 24px
flex: 0 0 1em
&--additional
margin-right: 16px
&__avatar
font-size: 32px
&--additional
margin-right: 8px
&__spinner
font-size: 32px
&--additional
margin-right: 8px
&__message
padding: 8px 0
&__caption
font-size: 0.9em
opacity: 0.7
&__actions
color: var(--q-primary)
&__badge
animation: q-notif-badge .42s
padding: 4px 8px
position: absolute
box-shadow: $shadow-1
background-color: var(--q-negative)
color: #fff
border-radius: $generic-border-radius
font-size: 12px
line-height: 12px
&--top-left,
&--top-right
top: -6px
&--bottom-left,
&--bottom-right
bottom: -6px
&--top-left,
&--bottom-left
left: -22px
&--top-right,
&--bottom-right
right: -22px
&__progress
z-index: -1
position: absolute
height: 3px
bottom: 0
left: -10px
right: -10px
animation: q-notif-progress linear
background: currentColor
opacity: .3
border-radius: $generic-border-radius $generic-border-radius 0 0
transform-origin: 0 50%
transform: scaleX(0)
&--standard
padding: 0 16px
min-height: 48px
.q-notification__actions
padding: 6px 0 6px 8px
margin-right: -8px
&--multi-line
min-height: 68px
padding: 8px 16px
.q-notification__badge
&--top-left,
&--top-right
top: -15px
&--bottom-left,
&--bottom-right
bottom: -15px
.q-notification__progress
bottom: -8px
.q-notification__actions
padding: 0
&--with-media
padding-left: 25px
&--top-left-enter-from, &--top-left-leave-to,
&--top-enter-from, &--top-leave-to,
&--top-right-enter-from, &--top-right-leave-to
opacity: 0
transform: translateY(-50px)
z-index: ($z-notify - 1)
&--left-enter-from, &--left-leave-to,
&--center-enter-from, &--center-leave-to,
&--right-enter-from, &--right-leave-to
opacity: 0
transform: rotateX(90deg)
z-index: ($z-notify - 1)
&--bottom-left-enter-from, &--bottom-left-leave-to,
&--bottom-enter-from, &--bottom-leave-to,
&--bottom-right-enter-from, &--bottom-right-leave-to
opacity: 0
transform: translateY(50px)
z-index: ($z-notify - 1)
&--top-left-leave-active,
&--top-leave-active,
&--top-right-leave-active,
&--left-leave-active,
&--center-leave-active,
&--right-leave-active,
&--bottom-left-leave-active,
&--bottom-leave-active,
&--bottom-right-leave-active
position: absolute
z-index: ($z-notify - 1)
margin-left: 0
margin-right: 0
&--top-leave-active,
&--center-leave-active
top: 0
&--bottom-left-leave-active,
&--bottom-leave-active,
&--bottom-right-leave-active
bottom: 0
@media (min-width: $breakpoint-sm-min)
.q-notification
max-width: 65vw
@keyframes q-notif-badge
15%
transform: translate3d(-25%, 0, 0) rotate3d(0, 0, 1, -5deg)
30%
transform: translate3d(20%, 0, 0) rotate3d(0, 0, 1, 3deg)
45%
transform: translate3d(-15%, 0, 0) rotate3d(0, 0, 1, -3deg)
60%
transform: translate3d(10%, 0, 0) rotate3d(0, 0, 1, 2deg)
75%
transform: translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -1deg)
@keyframes q-notif-progress
0%
transform: scaleX(1)
100%
transform: scaleX(0)

View file

@ -0,0 +1,420 @@
/* eslint-disable no-useless-escape */
import { ref, reactive } from 'vue'
import { injectProp } from '../../utils/private.inject-obj-prop/inject-obj-prop.js'
/**
* __ QUASAR_SSR __ -> runs on SSR on client or server
* __ QUASAR_SSR_SERVER __ -> runs on SSR on server
* __ QUASAR_SSR_CLIENT __ -> runs on SSR on client
* __ QUASAR_SSR_PWA __ -> built with SSR+PWA; may run on SSR on client or on PWA client
* (needs runtime detection)
*/
export const isRuntimeSsrPreHydration = __QUASAR_SSR_SERVER__
? { value: true }
: ref(
__QUASAR_SSR_CLIENT__ && (
__QUASAR_SSR_PWA__ ? document.body.getAttribute('data-server-rendered') !== null : true
)
)
let preHydrationBrowser
function getMatch (userAgent, platformMatch) {
const match = /(edg|edge|edga|edgios)\/([\w.]+)/.exec(userAgent)
|| /(opr)[\/]([\w.]+)/.exec(userAgent)
|| /(vivaldi)[\/]([\w.]+)/.exec(userAgent)
|| /(chrome|crios)[\/]([\w.]+)/.exec(userAgent)
|| /(version)(applewebkit)[\/]([\w.]+).*(safari)[\/]([\w.]+)/.exec(userAgent)
|| /(webkit)[\/]([\w.]+).*(version)[\/]([\w.]+).*(safari)[\/]([\w.]+)/.exec(userAgent)
|| /(firefox|fxios)[\/]([\w.]+)/.exec(userAgent)
|| /(webkit)[\/]([\w.]+)/.exec(userAgent)
|| /(opera)(?:.*version|)[\/]([\w.]+)/.exec(userAgent)
|| []
return {
browser: match[ 5 ] || match[ 3 ] || match[ 1 ] || '',
version: match[ 4 ] || match[ 2 ] || '0',
platform: platformMatch[ 0 ] || ''
}
}
function getPlatformMatch (userAgent) {
return /(ipad)/.exec(userAgent)
|| /(ipod)/.exec(userAgent)
|| /(windows phone)/.exec(userAgent)
|| /(iphone)/.exec(userAgent)
|| /(kindle)/.exec(userAgent)
|| /(silk)/.exec(userAgent)
|| /(android)/.exec(userAgent)
|| /(win)/.exec(userAgent)
|| /(mac)/.exec(userAgent)
|| /(linux)/.exec(userAgent)
|| /(cros)/.exec(userAgent)
// TODO: Remove BlackBerry detection. BlackBerry OS, BlackBerry 10, and BlackBerry PlayBook OS
// is officially dead as of January 4, 2022 (https://www.blackberry.com/us/en/support/devices/end-of-life)
|| /(playbook)/.exec(userAgent)
|| /(bb)/.exec(userAgent)
|| /(blackberry)/.exec(userAgent)
|| []
}
const hasTouch = __QUASAR_SSR_SERVER__
? false
: 'ontouchstart' in window || window.navigator.maxTouchPoints > 0
function getPlatform (UA) {
const userAgent = UA.toLowerCase()
const platformMatch = getPlatformMatch(userAgent)
const matched = getMatch(userAgent, platformMatch)
const browser = {
mobile: false,
desktop: false,
cordova: false,
capacitor: false,
nativeMobile: false,
// nativeMobileWrapper: void 0,
electron: false,
bex: false,
linux: false,
mac: false,
win: false,
cros: false,
chrome: false,
firefox: false,
opera: false,
safari: false,
vivaldi: false,
edge: false,
edgeChromium: false,
ie: false,
webkit: false,
android: false,
ios: false,
ipad: false,
iphone: false,
ipod: false,
kindle: false,
winphone: false,
blackberry: false,
playbook: false,
silk: false
}
if (matched.browser) {
browser[ matched.browser ] = true
browser.version = matched.version
browser.versionNumber = parseInt(matched.version, 10)
}
if (matched.platform) {
browser[ matched.platform ] = true
}
const knownMobiles = browser.android
|| browser.ios
|| browser.bb
|| browser.blackberry
|| browser.ipad
|| browser.iphone
|| browser.ipod
|| browser.kindle
|| browser.playbook
|| browser.silk
|| browser[ 'windows phone' ]
// These are all considered mobile platforms, meaning they run a mobile browser
if (
knownMobiles === true
|| userAgent.indexOf('mobile') !== -1
) {
browser.mobile = true
}
// If it's not mobile we should consider it's desktop platform, meaning it runs a desktop browser
// It's a workaround for anonymized user agents
// (browser.cros || browser.mac || browser.linux || browser.win)
else {
browser.desktop = true
}
if (browser[ 'windows phone' ]) {
browser.winphone = true
delete browser[ 'windows phone' ]
}
if (browser.edga || browser.edgios || browser.edg) {
browser.edge = true
matched.browser = 'edge'
}
else if (browser.crios) {
browser.chrome = true
matched.browser = 'chrome'
}
else if (browser.fxios) {
browser.firefox = true
matched.browser = 'firefox'
}
// Set iOS if on iPod, iPad or iPhone
if (browser.ipod || browser.ipad || browser.iphone) {
browser.ios = true
}
if (browser.vivaldi) {
matched.browser = 'vivaldi'
browser.vivaldi = true
}
// TODO: The assumption about WebKit based browsers below is not completely accurate.
// Google released Blink(a fork of WebKit) engine on April 3, 2013, which is really different than WebKit today.
// Today, one might want to check for WebKit to deal with its bugs, which is used on all browsers on iOS, and Safari browser on all platforms.
if (
// Chrome, Opera 15+, Vivaldi and Safari are webkit based browsers
browser.chrome
|| browser.opr
|| browser.safari
|| browser.vivaldi
// we expect unknown, non iOS mobile browsers to be webkit based
|| (
browser.mobile === true
&& browser.ios !== true
&& knownMobiles !== true
)
) {
browser.webkit = true
}
// Opera 15+ are identified as opr
if (browser.opr) {
matched.browser = 'opera'
browser.opera = true
}
// Some browsers are marked as Safari but are not
if (browser.safari) {
if (browser.blackberry || browser.bb) {
matched.browser = 'blackberry'
browser.blackberry = true
}
else if (browser.playbook) {
matched.browser = 'playbook'
browser.playbook = true
}
else if (browser.android) {
matched.browser = 'android'
browser.android = true
}
else if (browser.kindle) {
matched.browser = 'kindle'
browser.kindle = true
}
else if (browser.silk) {
matched.browser = 'silk'
browser.silk = true
}
}
// Assign the name and platform variable
browser.name = matched.browser
browser.platform = matched.platform
if (__QUASAR_SSR_SERVER__ !== true) {
if (userAgent.indexOf('electron') !== -1) {
browser.electron = true
}
else if (document.location.href.indexOf('-extension://') !== -1) {
browser.bex = true
}
else {
if (window.Capacitor !== void 0) {
browser.capacitor = true
browser.nativeMobile = true
browser.nativeMobileWrapper = 'capacitor'
}
else if (window._cordovaNative !== void 0 || window.cordova !== void 0) {
browser.cordova = true
browser.nativeMobile = true
browser.nativeMobileWrapper = 'cordova'
}
if (isRuntimeSsrPreHydration.value === true) {
/*
* We need to remember the current state as
* everything that follows can only be corrected client-side,
* but we don't want to brake the hydration.
*
* The "client" object is imported throughout the UI and should
* be as accurate as possible given all the knowledge that we posses
* because decisions are required to be made immediately, even
* before the hydration occurs.
*/
preHydrationBrowser = { is: { ...browser } }
}
/*
* All the following should be client-side corrections only
*/
if (
hasTouch === true
&& browser.mac === true
&& (
(browser.desktop === true && browser.safari === true)
|| (
browser.nativeMobile === true
&& browser.android !== true
&& browser.ios !== true
&& browser.ipad !== true
)
)
) {
/*
* Correction needed for iOS since the default
* setting on iPad is to request desktop view; if we have
* touch support and the user agent says it's a
* desktop, we infer that it's an iPhone/iPad with desktop view
* so we must fix the false positives
*/
delete browser.mac
delete browser.desktop
const platform = Math.min(window.innerHeight, window.innerWidth) > 414
? 'ipad'
: 'iphone'
Object.assign(browser, {
mobile: true,
ios: true,
platform,
[ platform ]: true
})
}
if (
browser.mobile !== true
&& window.navigator.userAgentData
&& window.navigator.userAgentData.mobile
) {
/*
* Correction needed on client-side when
* we also have the navigator userAgentData
*/
delete browser.desktop
browser.mobile = true
}
}
}
return browser
}
const userAgent = __QUASAR_SSR_SERVER__
? ''
: navigator.userAgent || navigator.vendor || window.opera
const ssrClient = {
has: {
touch: false,
webStorage: false
},
within: { iframe: false }
}
// We export "client" for hydration error-free parts,
// like touch directives who do not (and must NOT) wait
// for the client takeover;
// Do NOT import this directly in your app, unless you really know
// what you are doing.
export const client = __QUASAR_SSR_SERVER__
? ssrClient
: {
userAgent,
is: getPlatform(userAgent),
has: {
touch: hasTouch
},
within: {
iframe: window.self !== window.top
}
}
const Platform = {
install (opts) {
const { $q } = opts
if (__QUASAR_SSR_SERVER__) {
$q.platform = this.parseSSR(opts.ssrContext)
}
else if (isRuntimeSsrPreHydration.value === true) {
// takeover should increase accuracy for
// the rest of the props; we also avoid
// hydration errors
opts.onSSRHydrated.push(() => {
Object.assign($q.platform, client)
isRuntimeSsrPreHydration.value = false
})
// we need to make platform reactive
// for the takeover phase
$q.platform = reactive(this)
}
else {
$q.platform = this
}
}
}
if (__QUASAR_SSR_SERVER__) {
Platform.parseSSR = (ssrContext) => {
const userAgent = ssrContext.req.headers[ 'user-agent' ] || ssrContext.req.headers[ 'User-Agent' ] || ''
return {
...client,
userAgent,
is: getPlatform(userAgent)
}
}
}
else {
// do not access window.localStorage without
// devland actually using it as this will get
// reported under "Cookies" in Google Chrome
let hasWebStorage
injectProp(client.has, 'webStorage', () => {
if (hasWebStorage !== void 0) {
return hasWebStorage
}
try {
if (window.localStorage) {
hasWebStorage = true
return true
}
}
catch (_) {}
hasWebStorage = false
return false
})
Object.assign(Platform, client)
if (isRuntimeSsrPreHydration.value === true) {
// must match with server-side before
// client taking over in order to prevent
// hydration errors
Object.assign(Platform, preHydrationBrowser, ssrClient)
// free up memory
preHydrationBrowser = null
}
}
export default Platform

View file

@ -0,0 +1,221 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/options/platform-detection"
},
"injection": "$q.platform",
"props": {
"userAgent": {
"type": "String",
"desc": "Client browser User Agent",
"examples": [ "'mozilla/5.0 (macintosh; intel mac os x 10_14_5) applewebkit/537.36 (khtml, like gecko) chrome/75.0.3770.100 safari/537.36'" ]
},
"is": {
"type": "Object",
"desc": "Client browser details (property names depend on browser)",
"definition": {
"name": {
"type": "String",
"desc": "Browser name",
"examples": [ "'chrome'" ]
},
"platform": {
"type": "String",
"desc": "Platform name",
"examples": [ "'mac'" ]
},
"version": {
"type": "String",
"required": false,
"desc": "Detailed browser version",
"examples": [ "'71.0.3578.98'" ]
},
"versionNumber": {
"type": "Number",
"required": false,
"desc": "Major browser version as a number"
},
"mobile": {
"type": "Boolean",
"desc": "Whether the platform is mobile"
},
"desktop": {
"type": "Boolean",
"desc": "Whether the platform is desktop"
},
"cordova": {
"type": "Boolean",
"desc": "Whether the platform is Cordova"
},
"capacitor": {
"type": "Boolean",
"desc": "Whether the platform is Capacitor"
},
"nativeMobile": {
"type": "Boolean",
"desc": "Whether the platform is a native mobile wrapper"
},
"nativeMobileWrapper": {
"type": "String",
"required": false,
"values": [ "'cordova'", "'capacitor'" ],
"desc": "Type of the native mobile wrapper"
},
"electron": {
"type": "Boolean",
"desc": "Whether the platform is Electron"
},
"bex": {
"type": "Boolean",
"desc": "Whether the platform is BEX(Browser Extension)"
},
"linux": {
"type": "Boolean",
"desc": "Whether the operating system is Linux"
},
"mac": {
"type": "Boolean",
"desc": "Whether the operating system is Mac OS"
},
"win": {
"type": "Boolean",
"desc": "Whether the operating system is Windows"
},
"cros": {
"type": "Boolean",
"desc": "Whether the operating system is Chrome OS"
},
"chrome": {
"type": "Boolean",
"desc": "Whether the browser is Google Chrome"
},
"firefox": {
"type": "Boolean",
"desc": "Whether the browser is Firefox"
},
"opera": {
"type": "Boolean",
"desc": "Whether the browser is Opera"
},
"safari": {
"type": "Boolean",
"desc": "Whether the browser is Safari"
},
"vivaldi": {
"type": "Boolean",
"desc": "Whether the browser is Vivaldi"
},
"edge": {
"type": "Boolean",
"desc": "Whether the browser is Microsoft Edge Legacy"
},
"edgeChromium": {
"type": "Boolean",
"desc": "Whether the browser is Microsoft Edge (Chromium)"
},
"ie": {
"type": "Boolean",
"desc": "Whether the browser is Internet Explorer"
},
"webkit": {
"type": "Boolean",
"desc": "Whether the browser is a Webkit or Webkit-based one"
},
"android": {
"type": "Boolean",
"desc": "Whether the operating system is Android"
},
"ios": {
"type": "Boolean",
"desc": "Whether the operating system is iOS"
},
"ipad": {
"type": "Boolean",
"desc": "Whether the device is an iPad"
},
"iphone": {
"type": "Boolean",
"desc": "Whether the device is an iPhone"
},
"ipod": {
"type": "Boolean",
"desc": "Whether the device is an iPod"
},
"kindle": {
"type": "Boolean",
"desc": "Whether the device is a Kindle"
},
"winphone": {
"type": "Boolean",
"desc": "Whether the operating system is Windows Phone"
},
"blackberry": {
"type": "Boolean",
"desc": "Whether the device is a Blackberry"
},
"playbook": {
"type": "Boolean",
"desc": "Whether the device is a Blackberry Playbook"
},
"silk": {
"type": "Boolean",
"desc": "Whether the browser is Amazon Silk"
}
},
"examples": [ "{ chrome: true, version: '71.0.3578.98', versionNumber: 71, mac: true, desktop: true, webkit: true, name: 'chrome', platform: 'mac' }" ]
},
"has": {
"type": "Object",
"desc": "Client browser detectable properties",
"definition": {
"touch": {
"type": "Boolean",
"desc": "Client browser runs on device with touch support"
},
"webStorage": {
"type": "Boolean",
"desc": "Client browser has Web Storage support"
}
},
"examples": [ "{ touch: false, webStorage: true }" ]
},
"within": {
"type": "Object",
"desc": "Client browser environment",
"definition": {
"iframe": {
"type": "Boolean",
"desc": "Does the app run under an iframe?"
}
},
"examples": [ "{ iframe: false }" ]
}
},
"methods": {
"parseSSR": {
"desc": "For SSR usage only, and only on the global import (not on $q.platform)",
"params": {
"ssrContext": {
"type": "Object",
"desc": "SSR Context Object",
"required": true
}
},
"returns": {
"type": "Object",
"tsType": "Platform",
"desc": "Platform object (like $q.platform) for SSR usage purposes"
}
}
}
}

View file

@ -0,0 +1,112 @@
/**
* Ignored specs:
* [(method)parseSSR]
*/
import { describe, test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Platform from './Platform.js'
const mountPlugin = () => mount({ template: '<div />' })
describe('[Platform API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(Platform).toBe(wrapper.vm.$q.platform)
})
})
describe('[Props]', () => {
describe('[(prop)userAgent]', () => {
test('is correct type', () => {
mountPlugin()
expect(Platform.userAgent).toBeTypeOf('string')
})
})
describe('[(prop)is]', () => {
test('is correct type', () => {
mountPlugin()
const expected = {
name: expect.any(String),
platform: expect.any(String),
version: expect.any(String),
versionNumber: expect.any(Number),
mobile: expect.any(Boolean),
desktop: expect.any(Boolean),
cordova: expect.any(Boolean),
capacitor: expect.any(Boolean),
nativeMobile: expect.any(Boolean),
nativeMobileWrapper: expect.$any([
'cordova',
'capacitor'
]),
electron: expect.any(Boolean),
bex: expect.any(Boolean),
linux: expect.any(Boolean),
mac: expect.any(Boolean),
win: expect.any(Boolean),
cros: expect.any(Boolean),
chrome: expect.any(Boolean),
firefox: expect.any(Boolean),
opera: expect.any(Boolean),
safari: expect.any(Boolean),
vivaldi: expect.any(Boolean),
edge: expect.any(Boolean),
edgeChromium: expect.any(Boolean),
ie: expect.any(Boolean),
webkit: expect.any(Boolean),
android: expect.any(Boolean),
ios: expect.any(Boolean),
ipad: expect.any(Boolean),
iphone: expect.any(Boolean),
ipod: expect.any(Boolean),
kindle: expect.any(Boolean),
winphone: expect.any(Boolean),
blackberry: expect.any(Boolean),
playbook: expect.any(Boolean),
silk: expect.any(Boolean)
}
const actualKeys = Object.keys(Platform.is)
expect(actualKeys).not.toHaveLength(0)
expect(actualKeys).toSatisfy(
keys => keys.every(key => expected[ key ] !== void 0)
)
actualKeys.forEach(key => {
expect(Platform.is[ key ]).toStrictEqual(expected[ key ])
})
})
})
describe('[(prop)has]', () => {
test('is correct type', () => {
mountPlugin()
expect(Platform.has).toStrictEqual({
touch: expect.any(Boolean),
webStorage: expect.any(Boolean)
})
})
})
describe('[(prop)within]', () => {
test('is correct type', () => {
mountPlugin()
expect(Platform.within).toStrictEqual({
iframe: expect.any(Boolean)
})
})
})
})
})

View file

@ -0,0 +1,142 @@
import setCssVar from '../../utils/css-var/set-css-var.js'
import { noop } from '../../utils/event/event.js'
import { onKeyDownComposition } from '../../utils/private.keyboard/key-composition.js'
import { isRuntimeSsrPreHydration, client } from '../platform/Platform.js'
function getMobilePlatform (is) {
if (is.ios === true) return 'ios'
if (is.android === true) return 'android'
}
function getBodyClasses ({ is, has, within }, cfg) {
const cls = [
is.desktop === true ? 'desktop' : 'mobile',
`${ has.touch === false ? 'no-' : '' }touch`
]
if (is.mobile === true) {
const mobile = getMobilePlatform(is)
mobile !== void 0 && cls.push('platform-' + mobile)
}
if (is.nativeMobile === true) {
const type = is.nativeMobileWrapper
cls.push(type)
cls.push('native-mobile')
if (
is.ios === true
&& (cfg[ type ] === void 0 || cfg[ type ].iosStatusBarPadding !== false)
) {
cls.push('q-ios-padding')
}
}
else if (is.electron === true) {
cls.push('electron')
}
else if (is.bex === true) {
cls.push('bex')
}
within.iframe === true && cls.push('within-iframe')
return cls
}
function applyClientSsrCorrections () {
const { is } = client
const classes = document.body.className
const classList = new Set(classes.replace(/ {2}/g, ' ').split(' '))
if (is.nativeMobile !== true && is.electron !== true && is.bex !== true) {
if (is.desktop === true) {
classList.delete('mobile')
classList.delete('platform-ios')
classList.delete('platform-android')
classList.add('desktop')
}
else if (is.mobile === true) {
classList.delete('desktop')
classList.add('mobile')
classList.delete('platform-ios')
classList.delete('platform-android')
const mobile = getMobilePlatform(is)
if (mobile !== void 0) {
classList.add(`platform-${ mobile }`)
}
}
}
if (client.has.touch === true) {
classList.delete('no-touch')
classList.add('touch')
}
if (client.within.iframe === true) {
classList.add('within-iframe')
}
const newCls = Array.from(classList).join(' ')
if (classes !== newCls) {
document.body.className = newCls
}
}
function setColors (brand) {
for (const color in brand) {
setCssVar(color, brand[ color ])
}
}
export default {
install (opts) {
if (__QUASAR_SSR_SERVER__) {
const { $q, ssrContext } = opts
const cls = getBodyClasses($q.platform, $q.config)
if ($q.config.screen?.bodyClass === true) {
cls.push('screen--xs')
}
ssrContext._meta.bodyClasses += cls.join(' ')
const brand = $q.config.brand
if (brand !== void 0) {
const vars = Object.keys(brand)
.map(key => `--q-${ key }:${ brand[ key ] };`)
.join('')
ssrContext._meta.endingHeadTags += `<style>:root{${ vars }}</style>`
}
return
}
if (this.__installed === true) return
if (isRuntimeSsrPreHydration.value === true) {
applyClientSsrCorrections()
}
else {
const { $q } = opts
$q.config.brand !== void 0 && setColors($q.config.brand)
const cls = getBodyClasses(client, $q.config)
document.body.classList.add.apply(document.body.classList, cls)
}
if (client.is.ios === true) {
// needed for iOS button active state
document.body.addEventListener('touchstart', noop)
}
window.addEventListener('keydown', onKeyDownComposition, true)
}
}

View file

@ -0,0 +1,28 @@
import { describe, test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Body from './Body.js'
const mountPlugin = () => mount({ template: '<div />' })
describe('[Body API]', () => {
describe('[Functions]', () => {
describe('[(function)install]', () => {
test('should be defined correctly', () => {
expect(Body).toBeTypeOf('object')
expect(
Body.install
).toBeTypeOf('function')
})
test('sets body classes', () => {
mountPlugin()
expect(
document.body.getAttribute('class')
).toBe('desktop touch body--light')
})
})
})
})

View file

@ -0,0 +1,112 @@
import { client } from '../platform/Platform.js'
import { noop } from '../../utils/event/event.js'
const getTrue = () => true
function filterInvalidPath (path) {
return typeof path === 'string'
&& path !== ''
&& path !== '/'
&& path !== '#/'
}
function normalizeExitPath (path) {
path.startsWith('#') === true && (path = path.substring(1))
path.startsWith('/') === false && (path = '/' + path)
path.endsWith('/') === true && (path = path.substring(0, path.length - 1))
return '#' + path
}
function getShouldExitFn (cfg) {
if (cfg.backButtonExit === false) {
return () => false
}
if (cfg.backButtonExit === '*') {
return getTrue
}
// Add default root path
const exitPaths = [ '#/' ]
// Add custom exit paths
Array.isArray(cfg.backButtonExit) === true && exitPaths.push(
...cfg.backButtonExit.filter(filterInvalidPath).map(normalizeExitPath)
)
return () => exitPaths.includes(window.location.hash)
}
export default {
__history: [],
add: noop,
remove: noop,
install ({ $q }) {
if (__QUASAR_SSR_SERVER__ || this.__installed === true) return
const { cordova, capacitor } = client.is
if (cordova !== true && capacitor !== true) return
const qConf = $q.config[ cordova === true ? 'cordova' : 'capacitor' ]
if (qConf?.backButton === false) return
// if the '@capacitor/app' plugin is not installed
// then we got nothing to do
if (
// if we're on Capacitor mode
capacitor === true
// and it's also not in Capacitor's main instance
&& (window.Capacitor === void 0 || window.Capacitor.Plugins.App === void 0)
) return
this.add = entry => {
if (entry.condition === void 0) {
entry.condition = getTrue
}
this.__history.push(entry)
}
this.remove = entry => {
const index = this.__history.indexOf(entry)
if (index >= 0) {
this.__history.splice(index, 1)
}
}
const shouldExit = getShouldExitFn(
Object.assign(
{ backButtonExit: true },
qConf
)
)
const backHandler = () => {
if (this.__history.length) {
const entry = this.__history[ this.__history.length - 1 ]
if (entry.condition() === true) {
this.__history.pop()
entry.handler()
}
}
else if (shouldExit() === true) {
navigator.app.exitApp()
}
else {
window.history.back()
}
}
if (cordova === true) {
document.addEventListener('deviceready', () => {
document.addEventListener('backbutton', backHandler, false)
})
}
else {
window.Capacitor.Plugins.App.addListener('backButton', backHandler)
}
}
}

View file

@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest'
import History from './History.js'
/**
* Can't really fully test it since it handles
* Capacitor and Cordova platforms
*/
describe('[History API]', () => {
describe('[Variables]', () => {
describe('[(variable)__history]', () => {
test('is defined correctly', () => {
expect(Array.isArray(History.__history)).toBe(true)
})
})
describe('[(variable)add]', () => {
test('is defined correctly', () => {
expect(History.add).toBeTypeOf('function')
})
})
describe('[(variable)remove]', () => {
test('is defined correctly', () => {
expect(History.remove).toBeTypeOf('function')
})
})
})
describe('[Functions]', () => {
describe('[(function)install]', () => {
test('is defined correctly', () => {
expect(History.install).toBeTypeOf('function')
})
})
})
})

View file

@ -0,0 +1,183 @@
import { isRuntimeSsrPreHydration, client } from '../platform/Platform.js'
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { listenOpts, noop } from '../../utils/event/event.js'
import debounce from '../../utils/debounce/debounce.js'
const SIZE_LIST = [ 'sm', 'md', 'lg', 'xl' ]
const { passive } = listenOpts
export default createReactivePlugin({
width: 0,
height: 0,
name: 'xs',
sizes: {
sm: 600,
md: 1024,
lg: 1440,
xl: 1920
},
lt: {
sm: true,
md: true,
lg: true,
xl: true
},
gt: {
xs: false,
sm: false,
md: false,
lg: false
},
xs: true,
sm: false,
md: false,
lg: false,
xl: false
}, {
setSizes: noop,
setDebounce: noop,
install ({ $q, onSSRHydrated }) {
$q.screen = this
if (__QUASAR_SSR_SERVER__) return
if (this.__installed === true) {
if ($q.config.screen !== void 0) {
if ($q.config.screen.bodyClasses === false) {
document.body.classList.remove(`screen--${ this.name }`)
}
else {
this.__update(true)
}
}
return
}
const { visualViewport } = window
const target = visualViewport || window
const scrollingElement = document.scrollingElement || document.documentElement
const getSize = visualViewport === void 0 || client.is.mobile === true
? () => [
Math.max(window.innerWidth, scrollingElement.clientWidth),
Math.max(window.innerHeight, scrollingElement.clientHeight)
]
: () => [
visualViewport.width * visualViewport.scale + window.innerWidth - scrollingElement.clientWidth,
visualViewport.height * visualViewport.scale + window.innerHeight - scrollingElement.clientHeight
]
const classes = $q.config.screen?.bodyClasses === true
this.__update = force => {
const [ w, h ] = getSize()
if (h !== this.height) {
this.height = h
}
if (w !== this.width) {
this.width = w
}
else if (force !== true) {
return
}
let s = this.sizes
this.gt.xs = w >= s.sm
this.gt.sm = w >= s.md
this.gt.md = w >= s.lg
this.gt.lg = w >= s.xl
this.lt.sm = w < s.sm
this.lt.md = w < s.md
this.lt.lg = w < s.lg
this.lt.xl = w < s.xl
this.xs = this.lt.sm
this.sm = this.gt.xs === true && this.lt.md === true
this.md = this.gt.sm === true && this.lt.lg === true
this.lg = this.gt.md === true && this.lt.xl === true
this.xl = this.gt.lg
s = (this.xs === true && 'xs')
|| (this.sm === true && 'sm')
|| (this.md === true && 'md')
|| (this.lg === true && 'lg')
|| 'xl'
if (s !== this.name) {
if (classes === true) {
document.body.classList.remove(`screen--${ this.name }`)
document.body.classList.add(`screen--${ s }`)
}
this.name = s
}
}
let updateEvt, updateSizes = {}, updateDebounce = 16
this.setSizes = sizes => {
SIZE_LIST.forEach(name => {
if (sizes[ name ] !== void 0) {
updateSizes[ name ] = sizes[ name ]
}
})
}
this.setDebounce = deb => {
updateDebounce = deb
}
const start = () => {
const style = getComputedStyle(document.body)
// if css props available
if (style.getPropertyValue('--q-size-sm')) {
SIZE_LIST.forEach(name => {
this.sizes[ name ] = parseInt(style.getPropertyValue(`--q-size-${ name }`), 10)
})
}
this.setSizes = sizes => {
SIZE_LIST.forEach(name => {
if (sizes[ name ]) {
this.sizes[ name ] = sizes[ name ]
}
})
this.__update(true)
}
this.setDebounce = delay => {
updateEvt !== void 0 && target.removeEventListener('resize', updateEvt, passive)
updateEvt = delay > 0
? debounce(this.__update, delay)
: this.__update
target.addEventListener('resize', updateEvt, passive)
}
this.setDebounce(updateDebounce)
if (Object.keys(updateSizes).length !== 0) {
this.setSizes(updateSizes)
updateSizes = void 0 // free up memory
}
else {
this.__update()
}
// due to optimizations, this would be left out otherwise
classes === true && this.name === 'xs'
&& document.body.classList.add('screen--xs')
}
if (isRuntimeSsrPreHydration.value === true) {
onSSRHydrated.push(start)
}
else {
start()
}
}
})

View file

@ -0,0 +1,190 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/options/screen-plugin"
},
"injection": "$q.screen",
"quasarConfOptions": {
"propName": "screen",
"type": "Object",
"definition": {
"bodyClasses": {
"type": "Boolean",
"desc": "Whether to apply CSS classes for the current window breakpoint to the body element"
}
}
},
"props": {
"width": {
"type": "Number",
"desc": "Screen width (in pixels)",
"reactive": true,
"examples": [ "452" ]
},
"height": {
"type": "Number",
"desc": "Screen height (in pixels)",
"reactive": true,
"examples": [ "721" ]
},
"name": {
"type": "String",
"desc": "Tells current window breakpoint",
"values": [ "'xs'", "'sm'", "'md'", "'lg'", "'xl'" ],
"reactive": true
},
"sizes": {
"type": "Object",
"desc": "Breakpoints (in pixels)",
"definition": {
"sm": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"md": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"lg": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"xl": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
}
},
"reactive": true,
"examples": [ "{ sm: 600, md: 1024, lg: 1440, xl: 1920 }" ]
},
"lt": {
"type": "Object",
"desc": "Tells if current screen width is lower than breakpoint-name",
"reactive": true,
"definition": {
"sm": {
"type": "Boolean",
"desc": "Is current screen width lower than this breakpoint's lowest limit?"
},
"md": {
"type": "Boolean",
"desc": "Is current screen width lower than this breakpoint's lowest limit?"
},
"lg": {
"type": "Boolean",
"desc": "Is current screen width lower than this breakpoint's lowest limit?"
},
"xl": {
"type": "Boolean",
"desc": "Is current screen width lower than this breakpoint's lowest limit?"
}
},
"examples": [ "{ sm: false, md: true, lg: true, xl: true }" ]
},
"gt": {
"type": "Object",
"desc": "Tells if current screen width is greater than breakpoint-name",
"reactive": true,
"definition": {
"xs": {
"type": "Boolean",
"desc": "Is current screen width greater than this breakpoint's max limit?"
},
"sm": {
"type": "Boolean",
"desc": "Is current screen width greater than this breakpoint's max limit?"
},
"md": {
"type": "Boolean",
"desc": "Is current screen width greater than this breakpoint's max limit?"
},
"lg": {
"type": "Boolean",
"desc": "Is current screen width greater than this breakpoint's max limit?"
}
},
"examples": [ "{ xs: true, sm: true, md: false, lg: false, xl: false }" ]
},
"xs": {
"type": "Boolean",
"desc": "Current screen width fits exactly 'xs' breakpoint",
"reactive": true
},
"sm": {
"type": "Boolean",
"desc": "Current screen width fits exactly 'sm' breakpoint",
"reactive": true
},
"md": {
"type": "Boolean",
"desc": "Current screen width fits exactly 'md' breakpoint",
"reactive": true
},
"lg": {
"type": "Boolean",
"desc": "Current screen width fits exactly 'lg' breakpoint",
"reactive": true
},
"xl": {
"type": "Boolean",
"desc": "Current screen width fits exactly 'xl' breakpoint",
"reactive": true
}
},
"methods": {
"setSizes": {
"desc": "Override default breakpoint sizes",
"params": {
"breakpoints": {
"type": "Object",
"desc": "Pick what you want to override",
"definition": {
"sm": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"md": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"lg": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
},
"xl": {
"type": "Number",
"desc": "Breakpoint width size (minimum size)"
}
},
"required": true
}
},
"returns": null
},
"setDebounce": {
"desc": "Debounce update of all props when screen width/height changes",
"params": {
"amount": {
"type": "Number",
"desc": "Amount in milliseconds",
"required": true
}
},
"returns": null
}
}
}

View file

@ -0,0 +1,453 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Screen from './Screen.js'
const mountPlugin = () => mount({ template: '<div />' })
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.restoreAllMocks()
})
function setWidth (width) {
window.innerWidth = width
window.dispatchEvent(new Event('resize'))
vi.runAllTimers()
}
function setHeight (height) {
window.innerHeight = height
window.dispatchEvent(new Event('resize'))
vi.runAllTimers()
}
describe('[Screen API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const wrapper = mountPlugin()
expect(Screen).toBe(wrapper.vm.$q.screen)
})
})
describe('[Props]', () => {
describe('[(prop)width]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.width).toBeTypeOf('number')
})
test('is reactive', () => {
mountPlugin()
expect(Screen.width).not.toBe(100)
setWidth(100)
vi.runAllTimers()
expect(Screen.width).toBe(100)
})
})
describe('[(prop)height]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.height).toBeTypeOf('number')
})
test('is reactive', () => {
mountPlugin()
expect(Screen.height).not.toBe(100)
setHeight(100)
expect(Screen.height).toBe(100)
})
})
describe('[(prop)name]', () => {
test('is correct type', () => {
mountPlugin()
expect([ 'xs', 'sm', 'md', 'lg', 'xl' ]).toContain(Screen.name)
})
test('is reactive', () => {
mountPlugin()
setWidth(500)
expect(Screen.name).toBe('xs')
setWidth(800)
expect(Screen.name).toBe('sm')
setWidth(1200)
expect(Screen.name).toBe('md')
setWidth(1600)
expect(Screen.name).toBe('lg')
setWidth(2000)
expect(Screen.name).toBe('xl')
})
})
describe('[(prop)sizes]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.sizes).toStrictEqual({
sm: expect.any(Number),
md: expect.any(Number),
lg: expect.any(Number),
xl: expect.any(Number)
})
})
})
describe('[(prop)lt]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.lt).toStrictEqual({
sm: expect.any(Boolean),
md: expect.any(Boolean),
lg: expect.any(Boolean),
xl: expect.any(Boolean)
})
})
test('is reactive', () => {
mountPlugin()
setWidth(500) // xs
expect(Screen.lt).toStrictEqual({
sm: true,
md: true,
lg: true,
xl: true
})
setWidth(800) // sm
expect(Screen.lt).toStrictEqual({
sm: false,
md: true,
lg: true,
xl: true
})
setWidth(1200) // md
expect(Screen.lt).toStrictEqual({
sm: false,
md: false,
lg: true,
xl: true
})
setWidth(1600) // lg
expect(Screen.lt).toStrictEqual({
sm: false,
md: false,
lg: false,
xl: true
})
setWidth(2000) // xl
expect(Screen.lt).toStrictEqual({
sm: false,
md: false,
lg: false,
xl: false
})
})
})
describe('[(prop)gt]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.gt).toStrictEqual({
xs: expect.any(Boolean),
sm: expect.any(Boolean),
md: expect.any(Boolean),
lg: expect.any(Boolean)
})
})
test('is reactive', () => {
mountPlugin()
setWidth(500) // xs
expect(Screen.gt).toStrictEqual({
xs: false,
sm: false,
md: false,
lg: false
})
setWidth(800) // sm
expect(Screen.gt).toStrictEqual({
xs: true,
sm: false,
md: false,
lg: false
})
setWidth(1200) // md
expect(Screen.gt).toStrictEqual({
xs: true,
sm: true,
md: false,
lg: false
})
setWidth(1600) // lg
expect(Screen.gt).toStrictEqual({
xs: true,
sm: true,
md: true,
lg: false
})
setWidth(2000) // xl
expect(Screen.gt).toStrictEqual({
xs: true,
sm: true,
md: true,
lg: true
})
})
})
describe('[(prop)xs]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.xs).toBeTypeOf('boolean')
})
test('is reactive', () => {
mountPlugin()
setWidth(500) // xs
expect(Screen.xs).toBe(true)
setWidth(800) // sm
expect(Screen.xs).toBe(false)
})
})
describe('[(prop)sm]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.sm).toBeTypeOf('boolean')
})
test('is reactive', () => {
mountPlugin()
setWidth(500) // xs
expect(Screen.sm).toBe(false)
setWidth(800) // sm
expect(Screen.sm).toBe(true)
})
})
describe('[(prop)md]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.md).toBeTypeOf('boolean')
})
test('is reactive', () => {
mountPlugin()
setWidth(800) // sm
expect(Screen.md).toBe(false)
setWidth(1200) // md
expect(Screen.md).toBe(true)
})
})
describe('[(prop)lg]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.lg).toBeTypeOf('boolean')
})
test('is reactive', () => {
mountPlugin()
setWidth(1200) // md
expect(Screen.lg).toBe(false)
setWidth(1600) // lg
expect(Screen.lg).toBe(true)
})
})
describe('[(prop)xl]', () => {
test('is correct type', () => {
mountPlugin()
expect(Screen.xl).toBeTypeOf('boolean')
})
test('is reactive', () => {
mountPlugin()
setWidth(1600) // lg
expect(Screen.xl).toBe(false)
setWidth(2000) // xl
expect(Screen.xl).toBe(true)
})
})
})
describe('[Methods]', () => {
describe('[(method)setSizes]', () => {
test('should be callable', () => {
mountPlugin()
const newSizes = {
sm: 10,
md: 15,
lg: 20,
xl: 25
}
expect(
Screen.setSizes(newSizes)
).toBeUndefined()
expect(
Screen.sizes
).toStrictEqual(newSizes)
setWidth(5)
expect(Screen).toMatchObject({
name: 'xs',
xs: true,
sm: false,
md: false,
lg: false,
xl: false,
lt: {
sm: true,
md: true,
lg: true,
xl: true
},
gt: {
xs: false,
sm: false,
md: false,
lg: false
}
})
setWidth(11)
expect(Screen).toMatchObject({
name: 'sm',
xs: false,
sm: true,
md: false,
lg: false,
xl: false,
lt: {
sm: false,
md: true,
lg: true,
xl: true
},
gt: {
xs: true,
sm: false,
md: false,
lg: false
}
})
setWidth(16)
expect(Screen).toMatchObject({
name: 'md',
xs: false,
sm: false,
md: true,
lg: false,
xl: false,
lt: {
sm: false,
md: false,
lg: true,
xl: true
},
gt: {
xs: true,
sm: true,
md: false,
lg: false
}
})
setWidth(21)
expect(Screen).toMatchObject({
name: 'lg',
xs: false,
sm: false,
md: false,
lg: true,
xl: false,
lt: {
sm: false,
md: false,
lg: false,
xl: true
},
gt: {
xs: true,
sm: true,
md: true,
lg: false
}
})
setWidth(26)
expect(Screen).toMatchObject({
name: 'xl',
xs: false,
sm: false,
md: false,
lg: false,
xl: true,
lt: {
sm: false,
md: false,
lg: false,
xl: false
},
gt: {
xs: true,
sm: true,
md: true,
lg: true
}
})
})
})
describe('[(method)setDebounce]', () => {
test('should be callable', () => {
mountPlugin()
expect(
Screen.setDebounce(1000)
).toBeUndefined()
window.innerWidth = 100
window.dispatchEvent(new Event('resize'))
expect(Screen.width).not.toBe(100)
vi.advanceTimersByTime(999)
expect(Screen.width).not.toBe(100)
vi.advanceTimersByTime(1)
expect(Screen.width).toBe(100)
})
})
})
})

View file

@ -0,0 +1,16 @@
import { client } from '../platform/Platform.js'
import { getEmptyStorage, getStorage } from './engine/web-storage.js'
const storage = __QUASAR_SSR_SERVER__ || client.has.webStorage === false
? getEmptyStorage()
: getStorage('local')
const Plugin = {
install ({ $q }) {
$q.localStorage = storage
}
}
Object.assign(Plugin, storage)
export default Plugin

View file

@ -0,0 +1,5 @@
{
"mixins": [ "plugins/storage/engine/web-storage" ],
"injection": "$q.localStorage"
}

View file

@ -0,0 +1,323 @@
import { describe, test, expect } from 'vitest'
import { mount, config } from '@vue/test-utils'
import LocalStorage from './LocalStorage.js'
const mountPlugin = () => mount({ template: '<div />' })
// We override Quasar install so it installs this plugin
const quasarVuePlugin = config.global.plugins.find(entry => entry.name === 'Quasar')
const { install } = quasarVuePlugin
quasarVuePlugin.install = app => install(app, { plugins: { LocalStorage } })
describe('[LocalStorage API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage).toBeDefined()
expect($q.localStorage).toBeTypeOf('object')
expect(Object.keys($q.localStorage)).not.toHaveLength(0)
expect(LocalStorage).toMatchObject($q.localStorage)
})
})
describe('[Methods]', () => {
describe('[(method)hasItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(LocalStorage.hasItem('has')).toBe(false)
LocalStorage.setItem('has', 'rstoenescu')
expect(LocalStorage.hasItem('has')).toBe(true)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.hasItem).toBe(LocalStorage.hasItem)
})
})
describe('[(method)getLength]', () => {
test('should be callable', () => {
mountPlugin()
const len = LocalStorage.getLength()
expect(len).toBeTypeOf('number')
LocalStorage.setItem('getLength', 0)
expect(LocalStorage.getLength()).toBe(len + 1)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getLength).toBe(LocalStorage.getLength)
})
})
describe('[(method)getItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(
LocalStorage.getItem('getItem')
).toBeNull()
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getItem).toBe(LocalStorage.getItem)
})
})
describe('[(method)getIndex]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
LocalStorage.setItem('getIndex', 'rstoenescu')
expect(
LocalStorage.getIndex(0)
).$any([
expect.any(Number),
expect.any(Boolean),
expect.any(Date),
expect.any(RegExp),
expect.any(Function),
expect.any(Object),
expect.any(Array),
expect.any(String)
])
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getIndex).toBe(LocalStorage.getIndex)
})
})
describe('[(method)getKey]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
LocalStorage.setItem('getKey', 'rstoenescu')
expect(
LocalStorage.getKey(0)
).toBeTypeOf('string')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getKey).toBe(LocalStorage.getKey)
})
})
describe('[(method)getAll]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
LocalStorage.setItem('getAll', 'rstoenescu')
const result = LocalStorage.getAll()
expect(result).toBeTypeOf('object')
expect(Object.keys(result)).not.toHaveLength(0)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getAll).toBe(LocalStorage.getAll)
})
})
describe('[(method)getAllKeys]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
LocalStorage.setItem('getAllKeys', 'rstoenescu')
expect(
Array.isArray(LocalStorage.getAllKeys())
).toBe(true)
expect(
LocalStorage.getAllKeys()
).toContain('getAllKeys')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.getAllKeys).toBe(LocalStorage.getAllKeys)
})
})
describe('[(method)setItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(
LocalStorage.setItem('set', 'rstoenescu')
).toBeUndefined()
expect(
LocalStorage.getItem('set')
).toBe('rstoenescu')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.setItem).toBe(LocalStorage.setItem)
})
test('can override value', () => {
mountPlugin()
expect(LocalStorage.setItem('set2', 'rstoenescu'))
expect(LocalStorage.getItem('set2')).toBe('rstoenescu')
expect(LocalStorage.setItem('set2', 'rstoenescu2'))
expect(LocalStorage.getItem('set2')).toBe('rstoenescu2')
})
test('can encode + decode a Number', () => {
mountPlugin()
LocalStorage.setItem('Number', 123)
expect(LocalStorage.getItem('Number')).toBe(123)
})
test('can encode + decode a Boolean', () => {
mountPlugin()
LocalStorage.setItem('Boolean', true)
expect(LocalStorage.getItem('Boolean')).toBe(true)
})
test('can encode + decode a Date', () => {
mountPlugin()
const date = new Date()
LocalStorage.setItem('Date', date)
expect(
LocalStorage.getItem('Date')
).toStrictEqual(date)
})
test('can encode + decode a String', () => {
mountPlugin()
LocalStorage.setItem('String', 'rstoenescu')
expect(
LocalStorage.getItem('String')
).toBe('rstoenescu')
})
test('can encode + decode a RegExp', () => {
mountPlugin()
LocalStorage.setItem('RegExp', /abc/)
expect(
LocalStorage.getItem('RegExp')
).toStrictEqual(/abc/)
})
test('can encode + decode a Function', () => {
mountPlugin()
const fn = () => 5
LocalStorage.setItem('Function', fn)
expect(
LocalStorage.getItem('Function')
).toBe(fn.toString())
})
test('can encode + decode an Object', () => {
mountPlugin()
const obj = { a: 1 }
LocalStorage.setItem('Object', obj)
expect(
LocalStorage.getItem('Object')
).toStrictEqual(obj)
})
test('can encode + decode an Array', () => {
mountPlugin()
const arr = [ 1, 2, 3 ]
LocalStorage.setItem('Array', arr)
expect(
LocalStorage.getItem('Array')
).toStrictEqual(arr)
})
})
describe('[(method)removeItem]', () => {
test('should be callable', () => {
mountPlugin()
LocalStorage.setItem('remove', 5)
expect(
LocalStorage.getItem('remove')
).toBe(5)
expect(
LocalStorage.removeItem('remove')
).toBeUndefined()
expect(
LocalStorage.getItem('remove')
).toBeNull()
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.removeItem).toBe(LocalStorage.removeItem)
})
})
describe('[(method)clear]', () => {
test('should be callable', () => {
mountPlugin()
LocalStorage.setItem('clear', 5)
expect(LocalStorage.getItem('clear')).toBe(5)
expect(LocalStorage.clear()).toBeUndefined()
expect(LocalStorage.getItem('clear')).toBeNull()
expect(LocalStorage.getLength()).toBe(0)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.clear).toBe(LocalStorage.clear)
})
})
describe('[(method)isEmpty]', () => {
test('should be callable', () => {
mountPlugin()
LocalStorage.setItem('isEmpty', 5)
expect(LocalStorage.getItem('isEmpty')).toBe(5)
expect(LocalStorage.isEmpty()).toBe(false)
LocalStorage.clear()
expect(LocalStorage.isEmpty()).toBe(true)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.localStorage.isEmpty).toBe(LocalStorage.isEmpty)
})
})
})
})

View file

@ -0,0 +1,16 @@
import { client } from '../platform/Platform.js'
import { getEmptyStorage, getStorage } from './engine/web-storage.js'
const storage = __QUASAR_SSR_SERVER__ || client.has.webStorage === false
? getEmptyStorage()
: getStorage('session')
const Plugin = {
install ({ $q }) {
$q.sessionStorage = storage
}
}
Object.assign(Plugin, storage)
export default Plugin

View file

@ -0,0 +1,5 @@
{
"mixins": [ "plugins/storage/engine/web-storage" ],
"injection": "$q.sessionStorage"
}

View file

@ -0,0 +1,323 @@
import { describe, test, expect } from 'vitest'
import { mount, config } from '@vue/test-utils'
import SessionStorage from './SessionStorage.js'
const mountPlugin = () => mount({ template: '<div />' })
// We override Quasar install so it installs this plugin
const quasarVuePlugin = config.global.plugins.find(entry => entry.name === 'Quasar')
const { install } = quasarVuePlugin
quasarVuePlugin.install = app => install(app, { plugins: { SessionStorage } })
describe('[SessionStorage API]', () => {
describe('[Injection]', () => {
test('is injected into $q', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage).toBeDefined()
expect($q.sessionStorage).toBeTypeOf('object')
expect(Object.keys($q.sessionStorage)).not.toHaveLength(0)
expect(SessionStorage).toMatchObject($q.sessionStorage)
})
})
describe('[Methods]', () => {
describe('[(method)hasItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(SessionStorage.hasItem('has')).toBe(false)
SessionStorage.setItem('has', 'rstoenescu')
expect(SessionStorage.hasItem('has')).toBe(true)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.hasItem).toBe(SessionStorage.hasItem)
})
})
describe('[(method)getLength]', () => {
test('should be callable', () => {
mountPlugin()
const len = SessionStorage.getLength()
expect(len).toBeTypeOf('number')
SessionStorage.setItem('getLength', 0)
expect(SessionStorage.getLength()).toBe(len + 1)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getLength).toBe(SessionStorage.getLength)
})
})
describe('[(method)getItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(
SessionStorage.getItem('getItem')
).toBeNull()
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getItem).toBe(SessionStorage.getItem)
})
})
describe('[(method)getIndex]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
SessionStorage.setItem('getIndex', 'rstoenescu')
expect(
SessionStorage.getIndex(0)
).$any([
expect.any(Number),
expect.any(Boolean),
expect.any(Date),
expect.any(RegExp),
expect.any(Function),
expect.any(Object),
expect.any(Array),
expect.any(String)
])
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getIndex).toBe(SessionStorage.getIndex)
})
})
describe('[(method)getKey]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
SessionStorage.setItem('getKey', 'rstoenescu')
expect(
SessionStorage.getKey(0)
).toBeTypeOf('string')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getKey).toBe(SessionStorage.getKey)
})
})
describe('[(method)getAll]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
SessionStorage.setItem('getAll', 'rstoenescu')
const result = SessionStorage.getAll()
expect(result).toBeTypeOf('object')
expect(Object.keys(result)).not.toHaveLength(0)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getAll).toBe(SessionStorage.getAll)
})
})
describe('[(method)getAllKeys]', () => {
test('should be callable', () => {
mountPlugin()
// ensure at least one element is defined
SessionStorage.setItem('getAllKeys', 'rstoenescu')
expect(
Array.isArray(SessionStorage.getAllKeys())
).toBe(true)
expect(
SessionStorage.getAllKeys()
).toContain('getAllKeys')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.getAllKeys).toBe(SessionStorage.getAllKeys)
})
})
describe('[(method)setItem]', () => {
test('should be callable', () => {
mountPlugin()
expect(
SessionStorage.setItem('set', 'rstoenescu')
).toBeUndefined()
expect(
SessionStorage.getItem('set')
).toBe('rstoenescu')
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.setItem).toBe(SessionStorage.setItem)
})
test('can override value', () => {
mountPlugin()
expect(SessionStorage.setItem('set2', 'rstoenescu'))
expect(SessionStorage.getItem('set2')).toBe('rstoenescu')
expect(SessionStorage.setItem('set2', 'rstoenescu2'))
expect(SessionStorage.getItem('set2')).toBe('rstoenescu2')
})
test('can encode + decode a Number', () => {
mountPlugin()
SessionStorage.setItem('Number', 123)
expect(SessionStorage.getItem('Number')).toBe(123)
})
test('can encode + decode a Boolean', () => {
mountPlugin()
SessionStorage.setItem('Boolean', true)
expect(SessionStorage.getItem('Boolean')).toBe(true)
})
test('can encode + decode a Date', () => {
mountPlugin()
const date = new Date()
SessionStorage.setItem('Date', date)
expect(
SessionStorage.getItem('Date')
).toStrictEqual(date)
})
test('can encode + decode a String', () => {
mountPlugin()
SessionStorage.setItem('String', 'rstoenescu')
expect(
SessionStorage.getItem('String')
).toBe('rstoenescu')
})
test('can encode + decode a RegExp', () => {
mountPlugin()
SessionStorage.setItem('RegExp', /abc/)
expect(
SessionStorage.getItem('RegExp')
).toStrictEqual(/abc/)
})
test('can encode + decode a Function', () => {
mountPlugin()
const fn = () => 5
SessionStorage.setItem('Function', fn)
expect(
SessionStorage.getItem('Function')
).toBe(fn.toString())
})
test('can encode + decode an Object', () => {
mountPlugin()
const obj = { a: 1 }
SessionStorage.setItem('Object', obj)
expect(
SessionStorage.getItem('Object')
).toStrictEqual(obj)
})
test('can encode + decode an Array', () => {
mountPlugin()
const arr = [ 1, 2, 3 ]
SessionStorage.setItem('Array', arr)
expect(
SessionStorage.getItem('Array')
).toStrictEqual(arr)
})
})
describe('[(method)removeItem]', () => {
test('should be callable', () => {
mountPlugin()
SessionStorage.setItem('remove', 5)
expect(
SessionStorage.getItem('remove')
).toBe(5)
expect(
SessionStorage.removeItem('remove')
).toBeUndefined()
expect(
SessionStorage.getItem('remove')
).toBeNull()
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.removeItem).toBe(SessionStorage.removeItem)
})
})
describe('[(method)clear]', () => {
test('should be callable', () => {
mountPlugin()
SessionStorage.setItem('clear', 5)
expect(SessionStorage.getItem('clear')).toBe(5)
expect(SessionStorage.clear()).toBeUndefined()
expect(SessionStorage.getItem('clear')).toBeNull()
expect(SessionStorage.getLength()).toBe(0)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.clear).toBe(SessionStorage.clear)
})
})
describe('[(method)isEmpty]', () => {
test('should be callable', () => {
mountPlugin()
SessionStorage.setItem('isEmpty', 5)
expect(SessionStorage.getItem('isEmpty')).toBe(5)
expect(SessionStorage.isEmpty()).toBe(false)
SessionStorage.clear()
expect(SessionStorage.isEmpty()).toBe(true)
})
test('matches $q API', () => {
const { vm: { $q } } = mountPlugin()
expect($q.sessionStorage.isEmpty).toBe(SessionStorage.isEmpty)
})
})
})
})

View file

@ -0,0 +1,147 @@
import { noop } from '../../../utils/event/event.js'
import { isDate, isRegexp } from '../../../utils/is/is.js'
function encode (value) {
if (isDate(value) === true) {
return '__q_date|' + value.getTime()
}
if (isRegexp(value) === true) {
return '__q_expr|' + value.source
}
if (typeof value === 'number') {
return '__q_numb|' + value
}
if (typeof value === 'boolean') {
return '__q_bool|' + (value ? '1' : '0')
}
if (typeof value === 'string') {
return '__q_strn|' + value
}
if (typeof value === 'function') {
return '__q_strn|' + value.toString()
}
if (value === Object(value)) {
return '__q_objt|' + JSON.stringify(value)
}
// hmm, we don't know what to do with it,
// so just return it as is
return value
}
function decode (value) {
const length = value.length
if (length < 9) {
// then it wasn't encoded by us
return value
}
const type = value.substring(0, 8)
const source = value.substring(9)
switch (type) {
case '__q_date':
const number = Number(source)
return new Date(Number.isNaN(number) === true ? source : number)
case '__q_expr':
return new RegExp(source)
case '__q_numb':
return Number(source)
case '__q_bool':
return Boolean(source === '1')
case '__q_strn':
return '' + source
case '__q_objt':
return JSON.parse(source)
default:
// hmm, we reached here, we don't know the type,
// then it means it wasn't encoded by us, so just
// return whatever value it is
return value
}
}
export function getEmptyStorage () {
const getVal = () => null
return {
has: () => false, // alias for hasItem; TODO: remove in Qv3
hasItem: () => false,
getLength: () => 0,
getItem: getVal,
getIndex: getVal,
getKey: getVal,
getAll: () => {},
getAllKeys: () => [],
set: noop, // alias for setItem; TODO: remove in Qv3
setItem: noop,
remove: noop, // alias for removeItem; TODO: remove in Qv3
removeItem: noop,
clear: noop,
isEmpty: () => true
}
}
export function getStorage (type) {
const
webStorage = window[ type + 'Storage' ],
get = key => {
const item = webStorage.getItem(key)
return item
? decode(item)
: null
}
const hasItem = key => webStorage.getItem(key) !== null
const setItem = (key, value) => { webStorage.setItem(key, encode(value)) }
const removeItem = key => { webStorage.removeItem(key) }
return {
has: hasItem, // TODO: remove in Qv3
hasItem,
getLength: () => webStorage.length,
getItem: get,
getIndex: index => {
return index < webStorage.length
? get(webStorage.key(index))
: null
},
getKey: index => {
return index < webStorage.length
? webStorage.key(index)
: null
},
getAll: () => {
let key
const result = {}, len = webStorage.length
for (let i = 0; i < len; i++) {
key = webStorage.key(i)
result[ key ] = get(key)
}
return result
},
getAllKeys: () => {
const result = [], len = webStorage.length
for (let i = 0; i < len; i++) {
result.push(webStorage.key(i))
}
return result
},
set: setItem, // TODO: remove in Qv3
setItem,
remove: removeItem, // TODO: remove in Qv3
removeItem,
clear: () => { webStorage.clear() },
isEmpty: () => webStorage.length === 0
}
}

View file

@ -0,0 +1,165 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/quasar-plugins/web-storage"
},
"methods": {
"hasItem": {
"desc": "Check if storage item exists",
"alias": "has",
"params": {
"key": {
"type": "String",
"desc": "Entry key",
"required": true,
"examples": [ "'userId'" ]
}
},
"returns": {
"type": "Boolean",
"desc": "Does the item exists or not?"
}
},
"getLength": {
"desc": "Get storage number of entries",
"params": null,
"returns": {
"type": "Number",
"desc": "Number of entries"
}
},
"getItem": {
"tsType": "WebStorageGetItemMethodType",
"desc": "Get a storage item value",
"params": {
"key": {
"type": "String",
"desc": "Entry key",
"required": true,
"examples": [ "'userId'" ]
}
},
"returns": {
"type": [ "Number", "Boolean", "Date", "RegExp", "Function", "Object", "Array", "String", "null" ],
"desc": "Storage item value",
"examples": [ "'john12'", "702" ]
}
},
"getIndex": {
"tsType": "WebStorageGetIndexMethodType",
"desc": "Get the storage item value at specific index",
"params": {
"index": {
"type": "Number",
"desc": "Entry index",
"required": true
}
},
"returns": {
"type": [ "Number", "Boolean", "Date", "RegExp", "Function", "Object", "Array", "String", "null" ],
"desc": "Storage item index"
}
},
"getKey": {
"tsType": "WebStorageGetKeyMethodType",
"desc": "Get the storage key at specific index",
"params": {
"index": {
"type": "Number",
"desc": "Entry index",
"required": true
}
},
"returns": {
"type": [ "String", "null" ],
"desc": "Storage key",
"examples": [ "'userId'" ]
}
},
"getAll": {
"desc": "Retrieve all items in storage",
"params": null,
"returns": {
"type": "Object",
"desc": "Object syntax: item name as Object key and its value",
"examples": [ "{ userId: 'rstoenescu', timesLoggedIn: 14 }" ]
}
},
"getAllKeys": {
"tsType": "WebStorageGetAllKeysMethodType",
"desc": "Retrieve all keys in storage",
"params": null,
"returns": {
"type": "Array",
"desc": "Storage keys (Array of Strings)",
"examples": [ "[ 'userId', 'password' ]" ]
}
},
"setItem": {
"desc": "Set item in storage",
"alias": "set",
"params": {
"key": {
"type": "String",
"desc": "Entry key",
"required": true,
"examples": [ "'userId'" ]
},
"value": {
"type": [ "Number", "Boolean", "Date", "RegExp", "Function", "Object", "Array", "String", "null" ],
"desc": "Entry value",
"required": true,
"params": {
"...params": {
"type": "Any",
"__exemption": [ "desc" ]
}
},
"returns": {
"type": "Any",
"__exemption": [ "desc" ]
},
"examples": [ "'john12'" ]
}
},
"returns": null
},
"removeItem": {
"desc": "Remove a storage item",
"alias": "remove",
"params": {
"key": {
"type": "String",
"desc": "Storage key",
"required": true,
"examples": [ "'userId'" ]
}
},
"returns": null
},
"clear": {
"desc": "Remove everything from the storage",
"params": null,
"returns": null
},
"isEmpty": {
"desc": "Determine if storage has any items",
"params": null,
"returns": {
"type": "Boolean",
"desc": "Tells if storage is empty or not"
}
}
}
}

View file

@ -0,0 +1,43 @@
import { describe, test, expect } from 'vitest'
import { getEmptyStorage, getStorage } from './web-storage.js'
const objectDefinition = {
has: expect.any(Function), // alias of has
hasItem: expect.any(Function),
getLength: expect.any(Function),
getItem: expect.any(Function),
getIndex: expect.any(Function),
getKey: expect.any(Function),
getAll: expect.any(Function),
getAllKeys: expect.any(Function),
set: expect.any(Function), // alias of setItem
setItem: expect.any(Function),
remove: expect.any(Function), // alias of removeItem
removeItem: expect.any(Function),
clear: expect.any(Function),
isEmpty: expect.any(Function)
}
describe('[webStorage API]', () => {
describe('[Functions]', () => {
describe('[(function)getEmptyStorage]', () => {
test('has correct return value', () => {
const result = getEmptyStorage()
expect(result).toStrictEqual(objectDefinition)
})
})
describe('[(function)getStorage]', () => {
test('has correct return value for local', () => {
const local = getStorage('local')
expect(local).toStrictEqual(objectDefinition)
})
test('has correct return value for session', () => {
const session = getStorage('session')
expect(session).toStrictEqual(objectDefinition)
})
})
})
})