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

50
Frontend-Learner/node_modules/quasar/src/Brand.json generated vendored Normal file
View file

@ -0,0 +1,50 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/style/color-palette"
},
"internal": true,
"quasarConfOptions": {
"propName": "brand",
"type": "Object",
"definition": {
"primary": {
"type": "String",
"desc": "Main color of your app"
},
"secondary": {
"type": "String",
"desc": "Secondary color of your app"
},
"accent": {
"type": "String",
"desc": "Accent color of your app"
},
"dark": {
"type": "String",
"desc": "Dark color of your app"
},
"positive": {
"type": "String",
"desc": "Positive color of your app"
},
"negative": {
"type": "String",
"desc": "Negative color of your app"
},
"info": {
"type": "String",
"desc": "Info color of your app"
},
"warning": {
"type": "String",
"desc": "Warning color of your app"
},
"...customColors": {
"type": "String",
"desc": "Custom colors of your app, if any"
}
}
}
}

View file

@ -0,0 +1,264 @@
{
"props": {
"readonly": {
"type": "Boolean",
"desc": "Put component in readonly mode",
"category": "state"
},
"disable": {
"type": "Boolean",
"desc": "Put component in disabled mode",
"category": "state"
},
"color": {
"type": "String",
"tsType": "NamedColor",
"desc": "Color name for component from the Quasar Color Palette",
"examples": [ "'primary'", "'teal'", "'teal-10'" ],
"category": "style"
},
"text-color": {
"type": "String",
"tsType": "NamedColor",
"desc": "Overrides text color (if needed); Color name from the Quasar Color Palette",
"examples": [ "'primary'", "'teal'", "'teal-10'" ],
"category": "style"
},
"dense": {
"type": "Boolean",
"desc": "Dense mode; occupies less space",
"category": "style"
},
"size": {
"type": "String",
"desc": "Size in CSS units, including unit name",
"examples": [ "'16px'", "'2rem'" ],
"category": "style"
},
"dark": {
"type": [ "Boolean", "null" ],
"default": "null",
"desc": "Notify the component that the background is a dark color",
"category": "style"
},
"icon": {
"type": "String",
"desc": "Icon name following Quasar convention; Make sure you have the icon library installed unless you are using 'img:' prefix; If 'none' (String) is used as value then no icon is rendered (but screen real estate will still be used for it)",
"examples": [
"'map'",
"'ion-add'",
"'img:https://cdn.quasar.dev/logo-v2/svg/logo.svg'",
"'img:path/to/some_image.png'"
],
"category": "content"
},
"flat": {
"type": "Boolean",
"desc": "Applies a 'flat' design (no default shadow)",
"category": "style"
},
"bordered": {
"type": "Boolean",
"desc": "Applies a default border to the component",
"category": "style"
},
"square": {
"type": "Boolean",
"desc": "Removes border-radius so borders are squared",
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Applies a small standard border-radius for a squared shape of the component",
"category": "style"
},
"tabindex": {
"type": [ "Number", "String" ],
"desc": "Tabindex HTML attribute value",
"examples": [ "100", "'0'" ],
"category": "general"
},
"transition": {
"type": "String",
"desc": "One of Quasar's embedded transitions",
"examples": [ "'fade'", "'slide-down'" ],
"category": "transition"
},
"animation-speed": {
"type": [ "String", "Number" ],
"desc": "Animation speed (in milliseconds, without unit)",
"examples": [ "500", "'1200'" ],
"category": "style"
},
"model-value": {
"desc": "Model of the component; Either use this property (along with a listener for 'update:model-value' event) OR use v-model directive",
"required": true,
"syncable": true,
"category": "model"
},
"html": {
"type": "Boolean",
"desc": "Force use of textContent instead of innerHTML to render text; Use it when the text might be unsafe (from user input)",
"category": "behavior"
},
"tag": {
"type": "String",
"desc": "HTML tag to use",
"category": "content"
},
"scroll-target": {
"type": [ "Element", "String" ],
"desc": "CSS selector or DOM element to be used as a custom scroll container instead of the auto detected one",
"examples": [
".scroll-target-class",
"#scroll-target-id",
"$refs.scrollTarget",
"document.body"
],
"category": "behavior"
},
"ripple": {
"type": [ "Boolean", "Object" ],
"desc": "Configure material ripple (disable it by setting it to 'false' or supply a config object)",
"default": "true",
"examples": [ "false", "{ early: true, center: true, color: 'teal', keyCodes: [] }" ],
"category": "style"
},
"evt": {
"type": "Event",
"desc": "JS event object"
}
},
"slots": {
"default": {
"desc": "Default slot in the devland unslotted content of the component"
}
},
"events": {
"update:model-value": {
"desc": "Emitted when the component needs to change the model; Is also used by v-model",
"params": {
"value": {
"type": "Any",
"desc": "New model value",
"required": true
}
}
},
"show": {
"desc": "Emitted after component has triggered show()",
"params": {
"evt": {
"extends": "evt",
"required": true
}
}
},
"before-show": {
"desc": "Emitted when component triggers show() but before it finishes doing it",
"params": {
"evt": {
"extends": "evt",
"required": true
}
}
},
"after-show": {
"desc": "Emitted when component show animation is finished"
},
"hide": {
"desc": "Emitted after component has triggered hide()",
"params": {
"evt": {
"extends": "evt",
"required": true
}
}
},
"before-hide": {
"desc": "Emitted when component triggers hide() but before it finishes doing it",
"params": {
"evt": {
"extends": "evt",
"required": true
}
}
},
"after-hide": {
"desc": "Emitted when component hide animation is finished"
},
"click": {
"desc": "Emitted when user clicks/taps on the component",
"params": {
"evt": {
"extends": "evt",
"required": true
}
}
}
},
"methods": {
"show": {
"desc": "Triggers component to show",
"params": {
"evt": {
"extends": "evt",
"required": false
}
},
"returns": null
},
"hide": {
"desc": "Triggers component to hide",
"params": {
"evt": {
"extends": "evt",
"required": false
}
},
"returns": null
},
"toggle": {
"desc": "Triggers component to toggle between show/hide",
"params": {
"evt": {
"extends": "evt",
"required": false
}
},
"returns": null
}
}
}

79
Frontend-Learner/node_modules/quasar/src/components.js generated vendored Normal file
View file

@ -0,0 +1,79 @@
export * from './components/ajax-bar/index.js'
export * from './components/avatar/index.js'
export * from './components/badge/index.js'
export * from './components/banner/index.js'
export * from './components/bar/index.js'
export * from './components/breadcrumbs/index.js'
export * from './components/btn/index.js'
export * from './components/btn-dropdown/index.js'
export * from './components/btn-group/index.js'
export * from './components/btn-toggle/index.js'
export * from './components/card/index.js'
export * from './components/carousel/index.js'
export * from './components/chat/index.js'
export * from './components/checkbox/index.js'
export * from './components/chip/index.js'
export * from './components/circular-progress/index.js'
export * from './components/color/index.js'
export * from './components/date/index.js'
export * from './components/dialog/index.js'
export * from './components/drawer/index.js'
export * from './components/editor/index.js'
export * from './components/expansion-item/index.js'
export * from './components/fab/index.js'
export * from './components/field/index.js'
export * from './components/file/index.js'
export * from './components/footer/index.js'
export * from './components/form/index.js'
export * from './components/header/index.js'
export * from './components/icon/index.js'
export * from './components/img/index.js'
export * from './components/infinite-scroll/index.js'
export * from './components/inner-loading/index.js'
export * from './components/input/index.js'
export * from './components/intersection/index.js'
export * from './components/item/index.js'
export * from './components/knob/index.js'
export * from './components/layout/index.js'
export * from './components/markup-table/index.js'
export * from './components/menu/index.js'
export * from './components/no-ssr/index.js'
export * from './components/option-group/index.js'
export * from './components/page/index.js'
export * from './components/page-scroller/index.js'
export * from './components/page-sticky/index.js'
export * from './components/pagination/index.js'
export * from './components/parallax/index.js'
export * from './components/popup-edit/index.js'
export * from './components/popup-proxy/index.js'
export * from './components/linear-progress/index.js'
export * from './components/pull-to-refresh/index.js'
export * from './components/radio/index.js'
export * from './components/range/index.js'
export * from './components/rating/index.js'
export * from './components/resize-observer/index.js'
export * from './components/responsive/index.js'
export * from './components/scroll-area/index.js'
export * from './components/scroll-observer/index.js'
export * from './components/select/index.js'
export * from './components/separator/index.js'
export * from './components/skeleton/index.js'
export * from './components/slide-item/index.js'
export * from './components/slide-transition/index.js'
export * from './components/slider/index.js'
export * from './components/space/index.js'
export * from './components/spinner/index.js'
export * from './components/splitter/index.js'
export * from './components/stepper/index.js'
export * from './components/tab-panels/index.js'
export * from './components/table/index.js'
export * from './components/tabs/index.js'
export * from './components/time/index.js'
export * from './components/timeline/index.js'
export * from './components/toggle/index.js'
export * from './components/toolbar/index.js'
export * from './components/tooltip/index.js'
export * from './components/tree/index.js'
export * from './components/uploader/index.js'
export * from './components/video/index.js'
export * from './components/virtual-scroll/index.js'

View file

@ -0,0 +1,288 @@
import { h, ref, computed, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { between } from '../../utils/format/format.js'
const
xhr = __QUASAR_SSR_SERVER__ ? null : XMLHttpRequest,
open = __QUASAR_SSR_SERVER__ ? null : xhr.prototype.open,
positionValues = [ 'top', 'right', 'bottom', 'left' ]
let stack = []
let highjackCount = 0
function translate ({ p, pos, active, horiz, reverse, dir }) {
let x = 1, y = 1
if (horiz === true) {
if (reverse === true) { x = -1 }
if (pos === 'bottom') { y = -1 }
return { transform: `translate3d(${ x * (p - 100) }%,${ active ? 0 : y * -200 }%,0)` }
}
if (reverse === true) { y = -1 }
if (pos === 'right') { x = -1 }
return { transform: `translate3d(${ active ? 0 : dir * x * -200 }%,${ y * (p - 100) }%,0)` }
}
function inc (p, amount) {
if (typeof amount !== 'number') {
if (p < 25) {
amount = Math.random() * 3 + 3
}
else if (p < 65) {
amount = Math.random() * 3
}
else if (p < 85) {
amount = Math.random() * 2
}
else if (p < 99) {
amount = 0.6
}
else {
amount = 0
}
}
return between(p + amount, 0, 100)
}
function highjackAjax (stackEntry) {
highjackCount++
stack.push(stackEntry)
if (highjackCount > 1) return
xhr.prototype.open = function (_, url) {
const stopStack = []
const loadStart = () => {
stack.forEach(entry => {
if (
entry.hijackFilter.value === null
|| (entry.hijackFilter.value(url) === true)
) {
entry.start()
stopStack.push(entry.stop)
}
})
}
const loadEnd = () => {
stopStack.forEach(stop => { stop() })
}
this.addEventListener('loadstart', loadStart, { once: true })
this.addEventListener('loadend', loadEnd, { once: true })
open.apply(this, arguments)
}
}
function restoreAjax (start) {
stack = stack.filter(entry => entry.start !== start)
highjackCount = Math.max(0, highjackCount - 1)
if (highjackCount === 0) {
xhr.prototype.open = open
}
}
export default createComponent({
name: 'QAjaxBar',
props: {
position: {
type: String,
default: 'top',
validator: val => positionValues.includes(val)
},
size: {
type: String,
default: '2px'
},
color: String,
skipHijack: Boolean,
reverse: Boolean,
hijackFilter: Function
},
emits: [ 'start', 'stop' ],
setup (props, { emit }) {
const { proxy } = getCurrentInstance()
const progress = ref(0)
const onScreen = ref(false)
const animate = ref(true)
let sessions = 0, timer = null, speed
const classes = computed(() =>
`q-loading-bar q-loading-bar--${ props.position }`
+ (props.color !== void 0 ? ` bg-${ props.color }` : '')
+ (animate.value === true ? '' : ' no-transition')
)
const horizontal = computed(() => props.position === 'top' || props.position === 'bottom')
const sizeProp = computed(() => (horizontal.value === true ? 'height' : 'width'))
const style = computed(() => {
const active = onScreen.value
const obj = translate({
p: progress.value,
pos: props.position,
active,
horiz: horizontal.value,
reverse: proxy.$q.lang.rtl === true && [ 'top', 'bottom' ].includes(props.position)
? props.reverse === false
: props.reverse,
dir: proxy.$q.lang.rtl === true ? -1 : 1
})
obj[ sizeProp.value ] = props.size
obj.opacity = active ? 1 : 0
return obj
})
const attributes = computed(() => (
onScreen.value === true
? {
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': progress.value
}
: { 'aria-hidden': 'true' }
))
function start (newSpeed = 300) {
const oldSpeed = speed
speed = Math.max(0, newSpeed) || 0
sessions++
if (sessions > 1) {
if (oldSpeed === 0 && newSpeed > 0) {
planNextStep()
}
else if (timer !== null && oldSpeed > 0 && newSpeed <= 0) {
clearTimeout(timer)
timer = null
}
return sessions
}
timer !== null && clearTimeout(timer)
emit('start')
progress.value = 0
/**
* We're trying to avoid side effects if start() is called inside a watchEffect()
* so we're accessing the _value property directly (under the covers implementation detail of ref())
*
* Otherwise, any refs() accessed here would be marked as deps for the watchEffect()
* -- and we are changing them below, which would cause an infinite loop
*/
timer = setTimeout(() => {
timer = null
animate.value = true
newSpeed > 0 && planNextStep()
// eslint-disable-next-line vue/no-ref-as-operand
}, onScreen._value === true ? 500 : 1)
// eslint-disable-next-line vue/no-ref-as-operand
if (onScreen._value !== true) {
onScreen.value = true
animate.value = false
}
return sessions
}
function increment (amount) {
if (sessions > 0) {
progress.value = inc(progress.value, amount)
}
return sessions
}
function stop () {
sessions = Math.max(0, sessions - 1)
if (sessions > 0) {
return sessions
}
if (timer !== null) {
clearTimeout(timer)
timer = null
}
emit('stop')
const end = () => {
animate.value = true
progress.value = 100
timer = setTimeout(() => {
timer = null
onScreen.value = false
}, 1000)
}
if (progress.value === 0) {
timer = setTimeout(end, 1)
}
else {
end()
}
return sessions
}
function planNextStep () {
if (progress.value < 100) {
timer = setTimeout(() => {
timer = null
increment()
planNextStep()
}, speed)
}
}
let hijacked
onMounted(() => {
if (props.skipHijack !== true) {
hijacked = true
highjackAjax({
start,
stop,
hijackFilter: computed(() => props.hijackFilter || null)
})
}
})
onBeforeUnmount(() => {
timer !== null && clearTimeout(timer)
hijacked === true && restoreAjax(start)
})
// expose public methods
Object.assign(proxy, { start, stop, increment })
return () => h('div', {
class: classes.value,
style: style.value,
...attributes.value
})
}
})

View file

@ -0,0 +1,104 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/ajax-bar"
},
"props": {
"position": {
"type": "String",
"desc": "Position within window of where QAjaxBar should be displayed",
"default": "'top'",
"values": [ "'top'", "'right'", "'bottom'", "'left'" ],
"category": "style"
},
"size": {
"extends": "size",
"default": "'2px'"
},
"color": {
"extends": "color"
},
"reverse": {
"type": "Boolean",
"desc": "Reverse direction of progress",
"category": "behavior"
},
"skip-hijack": {
"type": "Boolean",
"desc": "Skip Ajax hijacking (not a reactive prop)",
"category": "behavior"
},
"hijack-filter": {
"type": "Function",
"desc": "Filter which URL should trigger start() + stop()",
"params": {
"url": {
"type": "String",
"desc": "The URL being triggered",
"examples": [ "'https://some.url/path'" ]
}
},
"returns": {
"type": "Boolean",
"desc": "Should the URL received as param trigger start() + stop()?"
},
"category": "behavior",
"addedIn": "v2.4.5"
}
},
"events": {
"start": {
"desc": "Emitted when bar is triggered to appear"
},
"stop": {
"desc": "Emitted when bar has finished its job"
}
},
"methods": {
"start": {
"desc": "Notify bar you are waiting for a new process to finish",
"params": {
"speed": {
"type": "Number",
"default": "300",
"desc": "Delay (in milliseconds) between progress auto-increments; If delay is 0 then it disables auto-incrementing"
}
},
"returns": {
"type": "Number",
"desc": "Number of active simultaneous sessions"
}
},
"increment": {
"desc": "Manually trigger a bar progress increment",
"params": {
"amount": {
"type": "Number",
"desc": "Amount (0 < x <= 100) to increment with"
}
},
"returns": {
"type": "Number",
"desc": "Number of active simultaneous sessions"
}
},
"stop": {
"desc": "Notify bar that one process you were waiting has finished",
"params": null,
"returns": {
"type": "Number",
"desc": "Number of active simultaneous sessions"
}
}
}
}

View file

@ -0,0 +1,27 @@
.q-loading-bar
position: fixed
z-index: $z-max
transition: transform .5s cubic-bezier(0,0,.2,1), opacity .5s
background: $red
&--top
left: 0 #{"/* rtl:ignore */"}
right: 0 #{"/* rtl:ignore */"}
top: 0
width: 100%
&--bottom
left: 0 #{"/* rtl:ignore */"}
right: 0 #{"/* rtl:ignore */"}
bottom: 0
width: 100%
&--right
top: 0
bottom: 0
right: 0
height: 100%
&--left
top: 0
bottom: 0
left: 0
height: 100%

View file

@ -0,0 +1,5 @@
import QAjaxBar from './QAjaxBar.js'
export {
QAjaxBar
}

View file

@ -0,0 +1,62 @@
import { h, computed } from 'vue'
import QIcon from '../icon/QIcon.js'
import useSize, { useSizeProps } from '../../composables/private.use-size/use-size.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hMergeSlotSafely } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QAvatar',
props: {
...useSizeProps,
fontSize: String,
color: String,
textColor: String,
icon: String,
square: Boolean,
rounded: Boolean
},
setup (props, { slots }) {
const sizeStyle = useSize(props)
const classes = computed(() =>
'q-avatar'
+ (props.color ? ` bg-${ props.color }` : '')
+ (props.textColor ? ` text-${ props.textColor } q-chip--colored` : '')
+ (
props.square === true
? ' q-avatar--square'
: (props.rounded === true ? ' rounded-borders' : '')
)
)
const contentStyle = computed(() => (
props.fontSize
? { fontSize: props.fontSize }
: null
))
return () => {
const icon = props.icon !== void 0
? [ h(QIcon, { name: props.icon }) ]
: void 0
return h('div', {
class: classes.value,
style: sizeStyle.value
}, [
h('div', {
class: 'q-avatar__content row flex-center overflow-hidden',
style: contentStyle.value
}, hMergeSlotSafely(slots.default, icon))
])
}
}
})

View file

@ -0,0 +1,42 @@
{
"mixins": [ "composables/private.use-size/use-size" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/avatar"
},
"props": {
"font-size": {
"type": "String",
"desc": "The size in CSS units, including unit name, of the content (icon, text)",
"examples": [ "'18px'", "'2rem'" ],
"category": "style"
},
"color": {
"extends": "color"
},
"text-color": {
"extends": "text-color"
},
"icon": {
"extends": "icon"
},
"square": {
"extends": "square"
},
"rounded": {
"extends": "rounded"
}
},
"slots": {
"default": {
"desc": "Optional; Suggestions: one character string, <img> tag"
}
}
}

View file

@ -0,0 +1,22 @@
.q-avatar
position: relative
vertical-align: middle
display: inline-block
border-radius: 50%
font-size: $avatar-font-size
height: 1em
width: 1em
&__content
font-size: $avatar-content-font-size
line-height: $avatar-content-line-height
&__content, img:not(.q-icon):not(.q-img__image)
border-radius: inherit
height: inherit
width: inherit
&--square
border-radius: 0

View file

@ -0,0 +1,204 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import QAvatar from './QAvatar.js'
import { useSizeDefaults } from 'quasar/src/composables/private.use-size/use-size.js'
describe('[QAvatar API]', () => {
describe('[Props]', () => {
describe('[(prop)size]', () => {
test('type String has effect', async () => {
const wrapper = mount(QAvatar)
const target = wrapper.get('.q-avatar')
expect(
target.$style('font-size')
).toBe('')
await wrapper.setProps({ size: '100px' })
await flushPromises()
expect(
target.$style('font-size')
).toContain('100px')
await wrapper.setProps({ size: 'sm' })
await flushPromises()
expect(
target.$style('font-size')
).toBe(`${ useSizeDefaults.sm }px`)
})
})
describe('[(prop)font-size]', () => {
test('type String has effect', async () => {
const size = '200px'
const fontSize = '100px'
const wrapper = mount(QAvatar)
expect(
wrapper.get('.q-avatar')
.$style('font-size')
).not.toBe(size)
expect(
wrapper.get('.q-avatar__content')
.$style('font-size')
).not.toBe(fontSize)
await wrapper.setProps({
size,
fontSize
})
await flushPromises()
expect(
wrapper.get('.q-avatar')
.$style('font-size')
).toBe(size)
expect(
wrapper.get('.q-avatar__content')
.$style('font-size')
).toBe(fontSize)
})
})
describe('[(prop)color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(QAvatar)
const target = wrapper.get('.q-avatar')
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
await wrapper.setProps({ color: propVal })
await flushPromises()
expect(
target.classes()
).toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
})
})
describe('[(prop)text-color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(QAvatar)
const target = wrapper.get('.q-avatar')
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
await wrapper.setProps({ textColor: propVal })
await flushPromises()
expect(
target.classes()
).toContain(`text-${ propVal }`)
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
})
})
describe('[(prop)icon]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QAvatar)
expect(
wrapper.get('.q-avatar')
.find('.q-icon')
.exists()
).toBe(false)
await wrapper.setProps({ icon: propVal })
await flushPromises()
expect(
wrapper.get('.q-avatar')
.get('.q-icon')
.text()
).toContain(`${ propVal }`)
})
})
describe('[(prop)square]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QAvatar)
const target = wrapper.get('.q-avatar')
expect(
target.classes()
).not.toContain('q-avatar--square')
await wrapper.setProps({ square: true })
await flushPromises()
expect(
target.classes()
).toContain('q-avatar--square')
expect(
target.$computedStyle('border-radius')
).not.toBe('0px')
})
})
describe('[(prop)rounded]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QAvatar)
const target = wrapper.get('.q-avatar')
expect(
target.classes()
).not.toContain('rounded-borders')
await wrapper.setProps({ rounded: true })
await flushPromises()
expect(
target.$computedStyle('border-radius')
).toBe('4px')
expect(
target.classes()
).toContain('rounded-borders')
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QAvatar, {
slots: {
default: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QAvatar from './QAvatar.js'
export {
QAvatar
}

View file

@ -0,0 +1,60 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { hMergeSlot } from '../../utils/private.render/render.js'
const alignValues = [ 'top', 'middle', 'bottom' ]
export default createComponent({
name: 'QBadge',
props: {
color: String,
textColor: String,
floating: Boolean,
transparent: Boolean,
multiLine: Boolean,
outline: Boolean,
rounded: Boolean,
label: [ Number, String ],
align: {
type: String,
validator: v => alignValues.includes(v)
}
},
setup (props, { slots }) {
const style = computed(() => {
return props.align !== void 0
? { verticalAlign: props.align }
: null
})
const classes = computed(() => {
const text = props.outline === true
? props.color || props.textColor
: props.textColor
return 'q-badge flex inline items-center no-wrap'
+ ` q-badge--${ props.multiLine === true ? 'multi' : 'single' }-line`
+ (props.outline === true
? ' q-badge--outline'
: (props.color !== void 0 ? ` bg-${ props.color }` : '')
)
+ (text !== void 0 ? ` text-${ text }` : '')
+ (props.floating === true ? ' q-badge--floating' : '')
+ (props.rounded === true ? ' q-badge--rounded' : '')
+ (props.transparent === true ? ' q-badge--transparent' : '')
})
return () => h('div', {
class: classes.value,
style: style.value,
role: 'status',
'aria-label': props.label
}, hMergeSlot(slots.default, props.label !== void 0 ? [ props.label ] : []))
}
})

View file

@ -0,0 +1,65 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/badge"
},
"props": {
"color": {
"extends": "color"
},
"text-color": {
"extends": "text-color"
},
"floating": {
"type": "Boolean",
"desc": "Tell QBadge if it should float to the top right side of the relative positioned parent element or not",
"category": "content"
},
"transparent": {
"type": "Boolean",
"desc": "Applies a 0.8 opacity; Useful especially for floating QBadge",
"category": "style"
},
"multi-line": {
"type": "Boolean",
"desc": "Content can wrap to multiple lines",
"category": "content"
},
"label": {
"type": [ "String", "Number" ],
"desc": "Badge's content as string; overrides default slot if specified",
"examples": [ "'John Doe'", "22" ],
"category": "content"
},
"align": {
"type": "String",
"desc": "Sets vertical-align CSS prop",
"values": [ "'top'", "'middle'", "'bottom'" ],
"category": "content"
},
"outline": {
"type": "Boolean",
"desc": "Use 'outline' design (colored text and borders only)",
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Makes a rounded shaped badge",
"category": "style"
}
},
"slots": {
"default": {
"desc": "This is where QBadge content goes, if not using 'label' property"
}
}
}

View file

@ -0,0 +1,32 @@
.q-badge
background-color: var(--q-primary)
color: #fff
padding: 2px 6px
border-radius: $generic-border-radius
font-size: $badge-font-size
line-height: $badge-line-height
min-height: $badge-min-height
font-weight: normal
vertical-align: baseline
&--single-line
white-space: nowrap
&--multi-line
word-break: break-all
word-wrap: break-word
&--floating
position: absolute
top: -4px
right: -3px
cursor: inherit
&--transparent
opacity: .8
&--outline
background-color: transparent
border: 1px solid currentColor
&--rounded
border-radius: 1em

View file

@ -0,0 +1,231 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import QBadge from './QBadge.js'
describe('[QBadge API]', () => {
describe('[Props]', () => {
describe('[(prop)color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
await wrapper.setProps({ color: propVal })
await flushPromises()
expect(
target.classes()
).toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
})
})
describe('[(prop)text-color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
expect(
target.classes()
).not.toContain(`text-${ propVal }`)
await wrapper.setProps({ textColor: propVal })
await flushPromises()
expect(
target.classes()
).toContain(`text-${ propVal }`)
expect(
target.classes()
).not.toContain(`bg-${ propVal }`)
})
})
describe('[(prop)floating]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain('q-badge--floating')
await wrapper.setProps({ floating: true })
await flushPromises()
expect(
wrapper.classes()
).toContain('q-badge--floating')
expect(
wrapper.get('.q-badge')
.$computedStyle('position')
).toBe('absolute')
})
})
describe('[(prop)transparent]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain('q-badge--transparent')
await wrapper.setProps({ transparent: true })
await flushPromises()
expect(
target.classes()
).toContain('q-badge--transparent')
expect(
target.$computedStyle('opacity')
).not.toBe('1')
})
})
describe('[(prop)multi-line]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain('q-badge--multi-line')
await wrapper.setProps({ multiLine: true })
await flushPromises()
expect(
target.classes()
).toContain('q-badge--multi-line')
expect(
target.$computedStyle('word-break')
).toBe('break-all')
})
})
describe('[(prop)label]', () => {
test.each([
[ 'String', 'John Doe' ],
[ 'Number', 22 ]
])('type %s has effect', async (_, propVal) => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.text()
).not.toContain(propVal)
await wrapper.setProps({ label: propVal })
await flushPromises()
expect(
target.text()
).toContain(propVal)
})
})
describe('[(prop)align]', () => {
test.each([
[ 'top' ],
[ 'middle' ],
[ 'bottom' ]
])('value "%s" has effect', async propVal => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.$style('vertical-align')
).not.toBe(propVal)
await wrapper.setProps({ align: propVal })
await flushPromises()
expect(
target.$style('vertical-align')
).toBe(propVal)
})
})
describe('[(prop)outline]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain('q-badge--outline')
await wrapper.setProps({ outline: true })
await flushPromises()
expect(
target.classes()
).toContain('q-badge--outline')
expect(
target.$computedStyle('border')
).toContain('1px solid')
})
})
describe('[(prop)rounded]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBadge)
const target = wrapper.get('.q-badge')
expect(
target.classes()
).not.toContain('q-badge--rounded')
await wrapper.setProps({ rounded: true })
await flushPromises()
expect(
target.classes()
).toContain('q-badge--rounded')
expect(
target.$computedStyle('border-radius')
).toBe('1em')
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBadge, {
slots: {
default: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QBadge from './QBadge.js'
export {
QBadge
}

View file

@ -0,0 +1,58 @@
import { h, computed, getCurrentInstance } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QBanner',
props: {
...useDarkProps,
inlineActions: Boolean,
dense: Boolean,
rounded: Boolean
},
setup (props, { slots }) {
const { proxy: { $q } } = getCurrentInstance()
const isDark = useDark(props, $q)
const classes = computed(() =>
'q-banner row items-center'
+ (props.dense === true ? ' q-banner--dense' : '')
+ (isDark.value === true ? ' q-banner--dark q-dark' : '')
+ (props.rounded === true ? ' rounded-borders' : '')
)
const actionClass = computed(() =>
'q-banner__actions row items-center justify-end'
+ ` col-${ props.inlineActions === true ? 'auto' : 'all' }`
)
return () => {
const child = [
h('div', {
class: 'q-banner__avatar col-auto row items-center self-start'
}, hSlot(slots.avatar)),
h('div', {
class: 'q-banner__content col text-body2'
}, hSlot(slots.default))
]
const actions = hSlot(slots.action)
actions !== void 0 && child.push(
h('div', { class: actionClass.value }, actions)
)
return h('div', {
class: classes.value
+ (props.inlineActions === false && actions !== void 0 ? ' q-banner--top-padding' : ''),
role: 'alert'
}, child)
}
}
})

View file

@ -0,0 +1,39 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/banner"
},
"props": {
"inline-actions": {
"type": "Boolean",
"desc": "Display actions on same row as content",
"category": "content"
},
"dense": {
"extends": "dense"
},
"rounded": {
"extends": "rounded"
},
"dark": {
"extends": "dark"
}
},
"slots": {
"default": {
"desc": "This is where Banner content goes"
},
"avatar": {
"desc": "Slot for displaying an avatar (suggestions: QIcon, QAvatar)"
},
"action": {
"desc": "Slot for Banner action (suggestions: QBtn)"
}
}
}

View file

@ -0,0 +1,38 @@
.q-banner
min-height: 54px
padding: 8px 16px
background: #fff
&--top-padding
padding-top: 14px
&__avatar
min-width: 1px !important
> .q-avatar
font-size: $banner-avatar-font-size
> .q-icon
font-size: $banner-avatar-icon-font-size
&__avatar:not(:empty) + &__content
padding-left: 16px
&__actions
&.col-auto
padding-left: 16px
&.col-all
.q-btn-item
margin: 4px 0 0 4px
&--dense
min-height: 32px
padding: 8px
&.q-banner--top-padding
padding-top: 12px
.q-banner__avatar
> .q-avatar, > .q-icon
font-size: $banner-avatar-dense-font-size
.q-banner__avatar:not(:empty) + .q-banner__content
padding-left: 8px
.q-banner__actions
&.col-auto
padding-left: 8px

View file

@ -0,0 +1,145 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import QBanner from './QBanner.js'
describe('[QBanner API]', () => {
describe('[Props]', () => {
describe('[(prop)inline-actions]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBanner, {
slots: {
action: () => 'Banner action'
}
})
const target = wrapper
.get('.q-banner')
.get('.q-banner__actions')
expect(
target.classes()
).not.toContain('col-auto')
await wrapper.setProps({ inlineActions: true })
await flushPromises()
expect(
target.classes()
).toContain('col-auto')
})
})
describe('[(prop)dense]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBanner)
const target = wrapper.get('.q-banner')
expect(
target.classes()
).not.toContain('q-banner--dense')
await wrapper.setProps({ dense: true })
await flushPromises()
expect(
target.classes()
).toContain('q-banner--dense')
})
})
describe('[(prop)rounded]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBanner)
const target = wrapper.get('.q-banner')
expect(
target.classes()
).not.toContain('rounded-borders')
await wrapper.setProps({ rounded: true })
await flushPromises()
expect(
target.classes()
).toContain('rounded-borders')
expect(
target.$computedStyle('border-radius')
).toBe('4px')
})
})
describe('[(prop)dark]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBanner)
const target = wrapper.get('.q-banner')
expect(
target.classes()
).not.toContain('q-banner--dark')
await wrapper.setProps({ dark: true })
await flushPromises()
expect(
target.classes()
).toContain('q-banner--dark')
})
test('type null has effect', async () => {
const wrapper = mount(QBanner, {
props: {
dark: null
}
})
expect(
wrapper.get('.q-banner')
.classes()
).not.toContain('q-banner--dark')
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBanner, {
slots: {
default: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
describe('[(slot)avatar]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBanner, {
slots: {
avatar: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
describe('[(slot)action]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBanner, {
slots: {
action: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QBanner from './QBanner.js'
export {
QBanner
}

View file

@ -0,0 +1,31 @@
import { h, computed, getCurrentInstance } from 'vue'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QBar',
props: {
...useDarkProps,
dense: Boolean
},
setup (props, { slots }) {
const { proxy: { $q } } = getCurrentInstance()
const isDark = useDark(props, $q)
const classes = computed(() =>
'q-bar row no-wrap items-center'
+ ` q-bar--${ props.dense === true ? 'dense' : 'standard' } `
+ ` q-bar--${ isDark.value === true ? 'dark' : 'light' }`
)
return () => h('div', {
class: classes.value,
role: 'toolbar'
}, hSlot(slots.default))
}
})

View file

@ -0,0 +1,22 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/bar"
},
"props": {
"dense": {
"extends": "dense"
},
"dark": {
"extends": "dark",
"desc": "The component background color lights up the parent's background (as opposed to default behavior which is to darken it); Works unless you specify a CSS background color for it"
}
},
"slots": {
"default": {
"extends": "default"
}
}
}

View file

@ -0,0 +1,31 @@
.q-bar
background: rgba(0,0,0,.2)
> .q-icon
margin-left: 2px
> div, > div + .q-icon
margin-left: 8px
> .q-btn
margin-left: 2px
> .q-icon:first-child, > .q-btn:first-child, > div:first-child
margin-left: 0
&--standard
padding: 0 12px
height: $bar-height
font-size: 18px
> div
font-size: $bar-inner-font-size
.q-btn
font-size: $bar-button-font-size
&--dense
padding: 0 8px
height: $bar-dense-height
font-size: $bar-dense-font-size
.q-btn
font-size: $bar-dense-button-font-size
&--dark
background: rgba(255,255,255,.15)

View file

@ -0,0 +1,72 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import QBar from './QBar.js'
describe('[QBar API]', () => {
describe('[Props]', () => {
describe('[(prop)dense]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBar)
const target = wrapper.get('.q-bar')
expect(
target.classes()
).not.toContain('q-bar--dense')
await wrapper.setProps({ dense: true })
await flushPromises()
expect(
target.classes()
).toContain('q-bar--dense')
})
})
describe('[(prop)dark]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QBar)
const target = wrapper.get('.q-bar')
expect(
target.classes()
).not.toContain('q-bar--dark')
await wrapper.setProps({ dark: true })
await flushPromises()
expect(
target.classes()
).toContain('q-bar--dark')
})
test('type null has effect', async () => {
const wrapper = mount(QBar, {
props: {
dark: null
}
})
expect(
wrapper.get('.q-bar')
.classes()
).not.toContain('q-bar--dark')
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBar, {
slots: {
default: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QBar from './QBar.js'
export {
QBar
}

View file

@ -0,0 +1,98 @@
import { h, computed } from 'vue'
import useAlign, { useAlignProps } from '../../composables/private.use-align/use-align.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
import { getNormalizedVNodes } from '../../utils/private.vm/vm.js'
const disabledValues = [ '', true ]
export default createComponent({
name: 'QBreadcrumbs',
props: {
...useAlignProps,
separator: {
type: String,
default: '/'
},
separatorColor: String,
activeColor: {
type: String,
default: 'primary'
},
gutter: {
type: String,
validator: v => [ 'none', 'xs', 'sm', 'md', 'lg', 'xl' ].includes(v),
default: 'sm'
}
},
setup (props, { slots }) {
const alignClass = useAlign(props)
const classes = computed(() =>
`flex items-center ${ alignClass.value }${ props.gutter === 'none' ? '' : ` q-gutter-${ props.gutter }` }`
)
const sepClass = computed(() => (props.separatorColor ? ` text-${ props.separatorColor }` : ''))
const activeClass = computed(() => ` text-${ props.activeColor }`)
return () => {
if (slots.default === void 0) return
const vnodes = getNormalizedVNodes(
hSlot(slots.default)
)
if (vnodes.length === 0) return
let els = 1
const
child = [],
len = vnodes.filter(c => c.type?.name === 'QBreadcrumbsEl').length,
separator = slots.separator !== void 0
? slots.separator
: () => props.separator
vnodes.forEach(comp => {
if (comp.type?.name === 'QBreadcrumbsEl') {
const middle = els < len
const disabled = comp.props !== null && disabledValues.includes(comp.props.disable)
const cls = (middle === true ? '' : ' q-breadcrumbs--last')
+ (disabled !== true && middle === true ? activeClass.value : '')
els++
child.push(
h('div', {
class: `flex items-center${ cls }`
}, [ comp ])
)
if (middle === true) {
child.push(
h('div', {
class: 'q-breadcrumbs__separator' + sepClass.value
}, separator())
)
}
}
else {
child.push(comp)
}
})
return h('div', {
class: 'q-breadcrumbs'
}, [
h('div', { class: classes.value }, child)
])
}
}
})

View file

@ -0,0 +1,55 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/breadcrumbs"
},
"props": {
"separator": {
"type": "String",
"desc": "The string used to separate the breadcrumbs",
"default": "'/'",
"examples": [ "'-'", "'|'", "'>'" ],
"category": "content"
},
"active-color": {
"extends": "color",
"desc": "The color of the active breadcrumb, which can be any color from the Quasar Color Palette",
"default": "'primary'",
"category": "style"
},
"gutter": {
"type": "String",
"desc": "The gutter value allows you control over the space between the breadcrumb elements.",
"default": "'sm'",
"values": [ "'none'", "'xs'", "'sm'", "'md'", "'lg'", "'xl'" ],
"category": "content"
},
"separator-color": {
"extends": "color",
"desc": "The color used to color the separator, which can be any color from the Quasar Color Palette",
"category": "style"
},
"align": {
"type": "String",
"desc": "Specify how to align the breadcrumbs horizontally",
"values": [ "'left'", "'center'", "'right'", "'between'", "'around'", "'evenly'" ],
"default": "'left'",
"__runtimeDefault": true,
"category": "content"
}
},
"slots": {
"default": {
"extends": "default"
},
"separator": {
"desc": "HTML or component you can slot in to separate the breadcrumbs"
}
}
}

View file

@ -0,0 +1,13 @@
.q-breadcrumbs
&__el
color: inherit
&__el-icon
font-size: $breadcrumbs-icon-font-size
&--with-label
margin-right: 8px
[dir=rtl] .q-breadcrumbs__separator .q-icon
transform: scaleX(-1) #{"/* rtl:ignore */"}

View file

@ -0,0 +1,178 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
// import QBreadcrumbs from './QBreadcrumbs.js'
import { alignMap } from 'quasar/src/composables/private.use-align/use-align.js'
import BasicBreadcrumbs from './test/BasicBreadcrumbs.vue'
import BreadcrumbWithSeparatorSlot from './test/BreadcrumbWithSeparatorSlot.vue'
describe('[QBreadcrumbs API]', () => {
describe('[Props]', () => {
describe('[(prop)separator]', () => {
test('type String has effect', async () => {
const propVal = '>'
const wrapper = mount(BasicBreadcrumbs)
expect(
wrapper.get('.q-breadcrumbs__separator')
.text()
).not.toContain(propVal)
await wrapper.setProps({ separator: propVal })
await flushPromises()
expect(
wrapper.get('.q-breadcrumbs__separator')
.text()
).toContain(propVal)
})
})
describe('[(prop)active-color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(BasicBreadcrumbs)
expect(
wrapper.get('.q-breadcrumbs > div > .flex.items-center:not(.q-breadcrumbs--last)')
.classes()
).not.toContain('text-red')
await wrapper.setProps({ activeColor: propVal })
await flushPromises()
expect(
wrapper.get('.q-breadcrumbs > div > .flex.items-center:not(.q-breadcrumbs--last)')
.classes()
).toContain('text-red')
})
})
describe('[(prop)gutter]', () => {
test('value "none" has effect', async () => {
const propVal = 'none'
const wrapper = mount(BasicBreadcrumbs)
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).toContain('q-gutter-sm')
await wrapper.setProps({ gutter: propVal })
await flushPromises()
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).toSatisfy(
list => list.every(cls => cls.startsWith('q-gutter') === false)
)
})
test.each([
[ 'xs' ],
[ 'sm' ],
[ 'md' ],
[ 'lg' ],
[ 'xl' ]
])('value %s has effect', async propVal => {
const wrapper = mount(BasicBreadcrumbs)
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).toContain('q-gutter-sm')
await wrapper.setProps({ gutter: propVal })
await flushPromises()
if (propVal !== 'sm') {
// the default value
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).not.toContain('q-gutter-sm')
}
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).toContain(`q-gutter-${ propVal }`)
})
})
describe('[(prop)separator-color]', () => {
test('type String has effect', async () => {
const propVal = 'red'
const wrapper = mount(BasicBreadcrumbs)
wrapper.findAll('.q-breadcrumbs__separator')
.forEach(el => expect(el.classes()).not.toContain('text-red'))
// TODO: write expectations without the prop
// (usually negate the effect of the prop)
await wrapper.setProps({ separatorColor: propVal })
await flushPromises()
wrapper.findAll('.q-breadcrumbs__separator')
.forEach(el => expect(el.classes()).toContain('text-red'))
})
})
describe('[(prop)align]', () => {
test.each([
[ 'left' ],
[ 'center' ],
[ 'right' ],
[ 'between' ],
[ 'around' ],
[ 'evenly' ]
])('value "%s" has effect', async propVal => {
const wrapper = mount(BasicBreadcrumbs)
if (propVal !== 'left') {
// the default value
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).not.toContain(`justify-${ alignMap[ propVal ] }`)
}
await wrapper.setProps({ align: propVal })
await flushPromises()
expect(
wrapper.get('.q-breadcrumbs > div')
.classes()
).toContain(`justify-${ alignMap[ propVal ] }`)
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const wrapper = mount(BasicBreadcrumbs)
expect(
wrapper.get('.q-breadcrumbs > div')
.text()
).toContain('Home')
})
})
describe('[(slot)separator]', () => {
test('renders the content', () => {
const wrapper = mount(BreadcrumbWithSeparatorSlot)
expect(
wrapper.get('.q-breadcrumbs__separator')
.text()
).toContain('arrow_forward')
})
})
})
})

View file

@ -0,0 +1,63 @@
import { h, computed } from 'vue'
import QIcon from '../icon/QIcon.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hMergeSlot } from '../../utils/private.render/render.js'
import useRouterLink, { useRouterLinkProps } from '../../composables/private.use-router-link/use-router-link.js'
export default createComponent({
name: 'QBreadcrumbsEl',
props: {
...useRouterLinkProps,
label: String,
icon: String,
tag: {
type: String,
default: 'span'
}
},
emits: [ 'click' ],
setup (props, { slots }) {
const { linkTag, linkAttrs, linkClass, navigateOnClick } = useRouterLink()
const data = computed(() => {
return {
class: 'q-breadcrumbs__el q-link '
+ 'flex inline items-center relative-position '
+ (props.disable !== true ? 'q-link--focusable' + linkClass.value : 'q-breadcrumbs__el--disable'),
...linkAttrs.value,
onClick: navigateOnClick
}
})
const iconClass = computed(() =>
'q-breadcrumbs__el-icon'
+ (props.label !== void 0 ? ' q-breadcrumbs__el-icon--with-label' : '')
)
return () => {
const child = []
props.icon !== void 0 && child.push(
h(QIcon, {
class: iconClass.value,
name: props.icon
})
)
props.label !== void 0 && child.push(props.label)
return h(
linkTag.value,
{ ...data.value },
hMergeSlot(slots.default, child)
)
}
}
})

View file

@ -0,0 +1,84 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/breadcrumbs"
},
"mixins": [ "composables/private.use-router-link/use-router-link" ],
"props": {
"label": {
"type": "String",
"desc": "The label text for the breadcrumb",
"examples": [ "'Home'", "'Index'" ],
"category": "content"
},
"icon": {
"extends": "icon"
},
"tag": {
"extends": "tag",
"default": "'span'",
"examples": [ "'div'", "'span'" ]
}
},
"slots": {
"default": {
"desc": "This is where custom content goes, unless 'icon' and 'label' props are not enough"
}
},
"events": {
"click": {
"desc": "Emitted when the component is clicked",
"params": {
"evt": {
"extends": "evt",
"desc": "JS event object; If you are using route navigation ('to'/'replace' props) and you want to cancel navigation then call evt.preventDefault() synchronously in your event handler"
},
"go": {
"type": "Function",
"desc": "Available ONLY if you are using route navigation ('to'/'replace' props); When you need to control the time at which the component should trigger the route navigation then call evt.preventDefault() synchronously and then call this function at your convenience; Useful if you have async work to be done before the actual route navigation or if you want to redirect somewhere else",
"required": false,
"addedIn": "v2.9",
"params": {
"opts": {
"type": "Object",
"desc": "Optional options",
"required": false,
"definition": {
"to": {
"type": [ "String", "Object" ],
"desc": "Equivalent to Vue Router <router-link> 'to' property; Specify it explicitly otherwise it will be set with same value as component's 'to' prop",
"required": false,
"examples": [
"'/home/dashboard'",
"{ name: 'my-route-name' }"
]
},
"replace": {
"type": "Boolean",
"desc": "Equivalent to Vue Router <router-link> 'replace' property; Specify it explicitly otherwise it will be set with same value as component's 'replace' prop",
"required": false
},
"returnRouterError": {
"type": "Boolean",
"desc": "Return the router error, if any; Otherwise the returned Promise will always fulfill",
"required": false
}
}
}
},
"returns": {
"type": "Promise<any>",
"desc": "Returns the router's navigation promise"
}
}
}
}
}
}

View file

@ -0,0 +1,513 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect, vi } from 'vitest'
import QBreadcrumbsEl from './QBreadcrumbsEl.js'
import { getRouter } from 'testing/runtime/router.js'
describe('[QBreadcrumbsEl API]', () => {
describe('[Props]', () => {
describe('[(prop)to]', () => {
test('type String has effect', async () => {
const testRoute = '/home/dashboard'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: testRoute })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe(testRoute)
const routerFn = vi.spyOn(router, 'push')
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).toBe(testRoute)
expect(routerFn).toHaveBeenCalledTimes(1)
expect(routerFn).toHaveBeenCalledWith(testRoute)
})
test('type Object has effect', async () => {
const testRoute = '/my-route-name'
const propVal = { path: testRoute }
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: propVal })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe(testRoute)
const routerFn = vi.spyOn(router, 'push')
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).toBe(testRoute)
expect(routerFn).toHaveBeenCalledTimes(1)
expect(routerFn).toHaveBeenCalledWith(propVal)
})
})
describe('[(prop)exact]', () => {
test('type Boolean has effect', async () => {
const activeClass = 'it-is-active'
const exactActiveClass = 'it-is-exact-active'
const router = await getRouter({ '/route': 'subRoute' })
const wrapper = mount(QBreadcrumbsEl, {
props: {
exact: true,
activeClass,
exactActiveClass
},
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: '/route/subRoute' })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe('/route/subRoute')
expect(
wrapper.get('a').classes()
).not.toContain(
expect.$any([ activeClass, exactActiveClass ])
)
await router.push('/route')
expect(
router.currentRoute.value.path
).toBe('/route')
expect(
wrapper.get('a').classes()
).not.toContain(
expect.$any([ activeClass, exactActiveClass ])
)
await router.push('/route/subRoute')
const cls = wrapper.get('a').classes()
expect(cls).toContain(activeClass)
expect(cls).toContain(exactActiveClass)
})
})
describe('[(prop)replace]', () => {
test('type Boolean has effect', async () => {
const testRoute = '/test-route'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
props: {
replace: true
},
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: testRoute })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe(testRoute)
const routerFn = vi.spyOn(router, 'replace')
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).toBe(testRoute)
expect(routerFn).toHaveBeenCalledTimes(1)
expect(routerFn).toHaveBeenCalledWith(testRoute)
})
})
describe('[(prop)active-class]', () => {
test('type String has effect', async () => {
const activeClass = 'it-is-active'
const router = await getRouter({ '/route': 'subRoute' })
const wrapper = mount(QBreadcrumbsEl, {
props: {
activeClass
},
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: '/route' })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe('/route')
expect(
wrapper.get('a').classes()
).not.toContain(activeClass)
await router.push('/route')
expect(
router.currentRoute.value.path
).toBe('/route')
expect(
wrapper.get('a').classes()
).toContain(activeClass)
await router.push('/route/subRoute')
expect(
wrapper.get('a').classes()
).toContain(activeClass)
})
})
describe('[(prop)exact-active-class]', () => {
test('type String has effect', async () => {
const exactActiveClass = 'it-is-exact-active'
const router = await getRouter({
'/route': { subRoute: 'other' }
})
const wrapper = mount(QBreadcrumbsEl, {
props: {
exact: true,
exactActiveClass
},
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ to: '/route/subRoute' })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe('/route/subRoute')
expect(
wrapper.get('a').classes()
).not.toContain(exactActiveClass)
await router.push('/route')
expect(
router.currentRoute.value.path
).toBe('/route')
expect(
wrapper.get('a').classes()
).not.toContain(exactActiveClass)
await router.push('/route/subRoute')
expect(
wrapper.get('a').classes()
).toContain(exactActiveClass)
await router.push('/route/subRoute/other')
expect(
wrapper.get('a').classes()
).not.toContain(exactActiveClass)
})
})
describe('[(prop)href]', () => {
test('type String has effect', async () => {
const propVal = 'https://quasar.dev'
const wrapper = mount(QBreadcrumbsEl)
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ href: propVal })
await flushPromises()
expect(
wrapper.get('a').attributes('href')
).toBe(propVal)
})
})
describe('[(prop)target]', () => {
test('type String has effect', async () => {
const propVal = '_blank'
const href = 'https://quasar.dev'
const wrapper = mount(QBreadcrumbsEl, {
props: {
target: propVal
}
})
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.setProps({ href })
await flushPromises()
const link = wrapper.get('a')
expect(
link.attributes('href')
).toBe(href)
expect(
link.attributes('target')
).toBe(propVal)
})
})
describe('[(prop)disable]', () => {
test('type Boolean has effect', async () => {
const testRoute = '/home'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
props: {
to: testRoute
},
global: {
plugins: [ router ]
}
})
expect(
wrapper.find('a').exists()
).toBe(true)
await wrapper.setProps({ disable: true })
await flushPromises()
expect(
wrapper.find('a').exists()
).toBe(false)
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).not.toBe(testRoute)
})
})
describe('[(prop)label]', () => {
test('type String has effect', async () => {
const propVal = 'Home'
const wrapper = mount(QBreadcrumbsEl)
expect(wrapper.text()).not.toContain(propVal)
await wrapper.setProps({ label: propVal })
await flushPromises()
expect(wrapper.text()).toContain(propVal)
})
})
describe('[(prop)icon]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QBreadcrumbsEl)
expect(
wrapper.find('.q-icon').exists()
).toBe(false)
await wrapper.setProps({ icon: propVal })
await flushPromises()
expect(
wrapper.get('.q-icon').text()
).toContain(propVal)
})
})
describe('[(prop)tag]', () => {
test('type String has effect', async () => {
const propVal = 'div'
const wrapper = mount(QBreadcrumbsEl)
expect(
// default is 'span'
wrapper.element.tagName
).not.toBe(propVal.toUpperCase())
await wrapper.setProps({ tag: propVal })
await flushPromises()
expect(
wrapper.element.tagName
).toBe(propVal.toUpperCase())
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QBreadcrumbsEl, {
slots: {
default: () => slotContent
}
})
expect(wrapper.html()).toContain(slotContent)
})
})
})
describe('[Events]', () => {
describe('[(event)click]', () => {
test('is emitting without router', async () => {
const wrapper = mount(QBreadcrumbsEl)
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('click')
expect(eventList.click).toHaveLength(1)
const [ evt, go ] = eventList.click[ 0 ]
expect(evt).toBeInstanceOf(Event)
expect(go).not.toBeDefined()
})
test('is emitting with router', async () => {
const testRoute = '/home/dashboard'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
props: {
to: testRoute
},
global: {
plugins: [ router ]
}
})
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('click')
expect(eventList.click).toHaveLength(1)
const [ evt, go ] = eventList.click[ 0 ]
expect(evt).toBeInstanceOf(Event)
expect(go).toBeTypeOf('function')
})
test('does not navigates when prevented', async () => {
const testRoute = '/home/dashboard'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
props: {
to: testRoute,
onClick: e => e.preventDefault()
},
global: {
plugins: [ router ]
}
})
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).not.toBe(testRoute)
})
test('can manually navigate by calling go()', async () => {
const testRoute = '/home/dashboard'
const router = await getRouter(testRoute)
const wrapper = mount(QBreadcrumbsEl, {
props: {
to: testRoute,
onClick: (e, go) => {
e.preventDefault()
go()
}
},
global: {
plugins: [ router ]
}
})
await wrapper.trigger('click')
await flushPromises()
expect(
router.currentRoute.value.path
).toBe(testRoute)
})
})
})
})

View file

@ -0,0 +1,7 @@
import QBreadcrumbs from './QBreadcrumbs.js'
import QBreadcrumbsEl from './QBreadcrumbsEl.js'
export {
QBreadcrumbs,
QBreadcrumbsEl
}

View file

@ -0,0 +1,7 @@
<template>
<q-breadcrumbs v-bind="$attrs">
<q-breadcrumbs-el label="Home" icon="home" />
<q-breadcrumbs-el label="Components" icon="widgets" />
<q-breadcrumbs-el label="Toolbar" />
</q-breadcrumbs>
</template>

View file

@ -0,0 +1,11 @@
<template>
<q-breadcrumbs v-bind="$attrs">
<q-breadcrumbs-el label="Home" icon="home" />
<q-breadcrumbs-el label="Components" icon="widgets" />
<q-breadcrumbs-el label="Toolbar" />
<template v-slot:separator>
<q-icon size="1.2em" name="arrow_forward" color="purple" />
</template>
</q-breadcrumbs>
</template>

View file

@ -0,0 +1,251 @@
import { h, ref, computed, watch, onMounted, getCurrentInstance } from 'vue'
import QIcon from '../icon/QIcon.js'
import QBtn from '../btn/QBtn.js'
import QBtnGroup from '../btn-group/QBtnGroup.js'
import QMenu from '../menu/QMenu.js'
import { getBtnDesignAttr, nonRoundBtnProps } from '../btn/use-btn.js'
import useId from '../../composables/use-id/use-id.js'
import { useTransitionProps } from '../../composables/private.use-transition/use-transition.js'
import { createComponent } from '../../utils/private.create/create.js'
import { stop } from '../../utils/event/event.js'
import { hSlot } from '../../utils/private.render/render.js'
const btnPropsList = Object.keys(nonRoundBtnProps)
export function passBtnProps (props) {
return btnPropsList.reduce((acc, key) => {
const val = props[ key ]
if (val !== void 0) {
acc[ key ] = val
}
return acc
}, {})
}
export default createComponent({
name: 'QBtnDropdown',
props: {
...nonRoundBtnProps,
...useTransitionProps,
modelValue: Boolean,
split: Boolean,
dropdownIcon: String,
contentClass: [ Array, String, Object ],
contentStyle: [ Array, String, Object ],
cover: Boolean,
persistent: Boolean,
noEscDismiss: Boolean,
noRouteDismiss: Boolean,
autoClose: Boolean,
noRefocus: Boolean,
noFocus: Boolean,
menuAnchor: {
type: String,
default: 'bottom end'
},
menuSelf: {
type: String,
default: 'top end'
},
menuOffset: Array,
disableMainBtn: Boolean,
disableDropdown: Boolean,
noIconAnimation: Boolean,
toggleAriaLabel: String
},
emits: [ 'update:modelValue', 'click', 'beforeShow', 'show', 'beforeHide', 'hide' ],
setup (props, { slots, emit }) {
const { proxy } = getCurrentInstance()
const showing = ref(props.modelValue)
const menuRef = ref(null)
const targetUid = useId()
const ariaAttrs = computed(() => {
const acc = {
'aria-expanded': showing.value === true ? 'true' : 'false',
'aria-haspopup': 'true',
'aria-controls': targetUid.value,
'aria-label': props.toggleAriaLabel || proxy.$q.lang.label[ showing.value === true ? 'collapse' : 'expand' ](props.label)
}
if (
props.disable === true
|| (
(props.split === false && props.disableMainBtn === true)
|| props.disableDropdown === true
)
) {
acc[ 'aria-disabled' ] = 'true'
}
return acc
})
const iconClass = computed(() =>
'q-btn-dropdown__arrow'
+ (showing.value === true && props.noIconAnimation === false ? ' rotate-180' : '')
+ (props.split === false ? ' q-btn-dropdown__arrow-container' : '')
)
const btnDesignAttr = computed(() => getBtnDesignAttr(props))
const btnProps = computed(() => passBtnProps(props))
watch(() => props.modelValue, val => {
menuRef.value?.[ val ? 'show' : 'hide' ]()
})
watch(() => props.split, hide)
function onBeforeShow (e) {
showing.value = true
emit('beforeShow', e)
}
function onShow (e) {
emit('show', e)
emit('update:modelValue', true)
}
function onBeforeHide (e) {
showing.value = false
emit('beforeHide', e)
}
function onHide (e) {
emit('hide', e)
emit('update:modelValue', false)
}
function onClick (e) {
emit('click', e)
}
function onClickHide (e) {
stop(e)
hide()
emit('click', e)
}
function toggle (evt) {
menuRef.value?.toggle(evt)
}
function show (evt) {
menuRef.value?.show(evt)
}
function hide (evt) {
menuRef.value?.hide(evt)
}
// expose public methods
Object.assign(proxy, {
show, hide, toggle
})
onMounted(() => {
props.modelValue === true && show()
})
return () => {
const Arrow = [
h(QIcon, {
class: iconClass.value,
name: props.dropdownIcon || proxy.$q.iconSet.arrow.dropdown
})
]
props.disableDropdown !== true && Arrow.push(
h(QMenu, {
ref: menuRef,
id: targetUid.value,
class: props.contentClass,
style: props.contentStyle,
cover: props.cover,
fit: true,
persistent: props.persistent,
noEscDismiss: props.noEscDismiss,
noRouteDismiss: props.noRouteDismiss,
autoClose: props.autoClose,
noFocus: props.noFocus,
noRefocus: props.noRefocus,
anchor: props.menuAnchor,
self: props.menuSelf,
offset: props.menuOffset,
separateClosePopup: true,
transitionShow: props.transitionShow,
transitionHide: props.transitionHide,
transitionDuration: props.transitionDuration,
onBeforeShow,
onShow,
onBeforeHide,
onHide
}, slots.default)
)
if (props.split === false) {
return h(QBtn, {
class: 'q-btn-dropdown q-btn-dropdown--simple',
...btnProps.value,
...ariaAttrs.value,
disable: props.disable === true || props.disableMainBtn === true,
noWrap: true,
round: false,
onClick
}, {
default: () => hSlot(slots.label, []).concat(Arrow),
loading: slots.loading
})
}
return h(QBtnGroup, {
class: 'q-btn-dropdown q-btn-dropdown--split no-wrap q-btn-item',
rounded: props.rounded,
square: props.square,
...btnDesignAttr.value,
glossy: props.glossy,
stretch: props.stretch
}, () => [
h(QBtn, {
class: 'q-btn-dropdown--current',
...btnProps.value,
disable: props.disable === true || props.disableMainBtn === true,
noWrap: true,
round: false,
onClick: onClickHide
}, {
default: slots.label,
loading: slots.loading
}),
h(QBtn, {
class: 'q-btn-dropdown__arrow-container q-anchor--skip',
...ariaAttrs.value,
...btnDesignAttr.value,
disable: props.disable === true || props.disableDropdown === true,
rounded: props.rounded,
color: props.color,
textColor: props.textColor,
dense: props.dense,
size: props.size,
padding: props.padding,
ripple: props.ripple
}, () => Arrow)
])
}
}
})

View file

@ -0,0 +1,170 @@
{
"mixins": [ "components/btn/use-btn", "composables/private.use-model-toggle/use-model-toggle", "composables/private.use-transition/use-transition" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/button-dropdown"
},
"props": {
"model-value": {
"type": "Boolean",
"__delete": [ "default" ]
},
"split": {
"type": "Boolean",
"desc": "Split dropdown icon into its own button",
"category": "content|behavior"
},
"dropdown-icon": {
"extends": "icon"
},
"disable-main-btn": {
"type": "Boolean",
"desc": "Disable main button (useful along with 'split' prop)",
"category": "behavior"
},
"disable-dropdown": {
"type": "Boolean",
"desc": "Disables dropdown (dropdown button if using along 'split' prop)",
"category": "behavior"
},
"no-icon-animation": {
"type": "Boolean",
"desc": "Disables the rotation of the dropdown icon when state is toggled",
"category": "style"
},
"content-style": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueStyleProp",
"desc": "Style definitions to be attributed to the menu",
"examples": [
"'background-color: #ff0000'",
"{ backgroundColor: '#ff0000' }"
],
"category": "style"
},
"content-class": {
"type": [ "String", "Array", "Object" ],
"tsType": "VueClassProp",
"desc": "Class definitions to be attributed to the menu",
"examples": [
"'my-special-class'",
"{ 'my-special-class': true }"
],
"category": "style"
},
"cover": {
"type": "Boolean",
"desc": "Allows the menu to cover the button. When used, the 'menu-self' and 'menu-fit' props are no longer effective",
"category": "position"
},
"persistent": {
"type": "Boolean",
"desc": "Allows the menu to not be dismissed by a click/tap outside of the menu or by hitting the ESC key; Also, an app route change won't dismiss it",
"category": "behavior"
},
"no-esc-dismiss": {
"type": "Boolean",
"desc": "User cannot dismiss the popup by hitting ESC key; No need to set it if 'persistent' prop is also set",
"category": "behavior",
"addedIn": "v2.18"
},
"no-route-dismiss": {
"type": "Boolean",
"desc": "Changing route app won't dismiss the popup; No need to set it if 'persistent' prop is also set",
"category": "behavior"
},
"auto-close": {
"type": "Boolean",
"desc": "Allows any click/tap in the menu to close it; Useful instead of attaching events to each menu item that should close the menu on click/tap",
"category": "behavior"
},
"no-refocus": {
"type": "Boolean",
"desc": "(Accessibility) When the dropdown gets hidden, do not refocus on the DOM element that previously had focus",
"category": "behavior",
"addedIn": "v2.18"
},
"no-focus": {
"type": "Boolean",
"desc": "(Accessibility) When the dropdown gets shown, do not switch focus on it",
"category": "behavior",
"addedIn": "v2.18"
},
"menu-anchor": {
"type": "String",
"desc": "Two values setting the starting position or anchor point of the menu relative to its target",
"values": [
"'top left'", "'top middle'", "'top right'", "'top start'", "'top end'",
"'center left'", "'center middle'", "'center right'", "'center start'", "'center end'",
"'bottom left'", "'bottom middle'", "'bottom right'", "'bottom start'", "'bottom end'"
],
"default": "'bottom end'",
"category": "position"
},
"menu-self": {
"type": "String",
"desc": "Two values setting the menu's own position relative to its target",
"values": [
"'top left'", "'top middle'", "'top right'", "'top start'", "'top end'",
"'center left'", "'center middle'", "'center right'", "'center start'", "'center end'",
"'bottom left'", "'bottom middle'", "'bottom right'", "'bottom start'", "'bottom end'"
],
"default": "'top end'",
"category": "position"
},
"menu-offset": {
"type": "Array",
"desc": "An array of two numbers to offset the menu horizontally and vertically in pixels",
"examples": [ "[ 8, 8 ]", "[ 5, 10 ]" ],
"category": "position"
},
"toggle-aria-label": {
"type": "String",
"desc": "aria-label to be used on the dropdown toggle element",
"examples": [ "'Open menu'" ],
"category": "accessibility",
"addedIn": "v2.8.4"
}
},
"slots": {
"default": {
"extends": "default"
},
"label": {
"desc": "Customize main button's content through this slot, unless you're using the 'icon' and 'label' props"
},
"loading": {
"desc": "Override the default QSpinner when in 'loading' state",
"addedIn": "v2.8"
}
},
"events": {
"click": {
"extends": "click",
"desc": "Emitted when user clicks/taps on the main button (not the icon one, if using 'split')"
}
}
}

View file

@ -0,0 +1,15 @@
.q-btn-dropdown
&--split .q-btn-dropdown__arrow-container
padding: 0 4px
&.q-btn--outline
border-left: 1px solid currentColor
&:not(.q-btn--outline)
border-left: 1px solid rgba(255,255,255,.3)
&--simple * + .q-btn-dropdown__arrow
margin-left: 8px
&__arrow
transition: transform .28s
&--current
flex-grow: 1

View file

@ -0,0 +1,5 @@
import QBtnDropdown from './QBtnDropdown.js'
export {
QBtnDropdown
}

View file

@ -0,0 +1,33 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QBtnGroup',
props: {
unelevated: Boolean,
outline: Boolean,
flat: Boolean,
rounded: Boolean,
square: Boolean,
push: Boolean,
stretch: Boolean,
glossy: Boolean,
spread: Boolean
},
setup (props, { slots }) {
const classes = computed(() => {
const cls = [ 'unelevated', 'outline', 'flat', 'rounded', 'square', 'push', 'stretch', 'glossy' ]
.filter(t => props[ t ] === true)
.map(t => `q-btn-group--${ t }`).join(' ')
return `q-btn-group row no-wrap${ cls.length !== 0 ? ' ' + cls : '' }`
+ (props.spread === true ? ' q-btn-group--spread' : ' inline')
})
return () => h('div', { class: classes.value }, hSlot(slots.default))
}
})

View file

@ -0,0 +1,66 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/button-group"
},
"props": {
"spread": {
"type": "Boolean",
"desc": "Spread horizontally to all available space",
"category": "content"
},
"outline": {
"type": "Boolean",
"desc": "Use 'outline' design for buttons",
"category": "style"
},
"flat": {
"type": "Boolean",
"desc": "Use 'flat' design for buttons",
"category": "style"
},
"unelevated": {
"type": "Boolean",
"desc": "Remove shadow on buttons",
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Applies a more prominent border-radius for squared shape buttons",
"category": "style"
},
"square": {
"extends": "square",
"addedIn": "v2.7.6"
},
"push": {
"type": "Boolean",
"desc": "Use 'push' design for buttons",
"category": "style"
},
"stretch": {
"type": "Boolean",
"desc": "When used on flexbox parent, buttons will stretch to parent's height",
"category": "content"
},
"glossy": {
"type": "Boolean",
"desc": "Applies a glossy effect",
"category": "style"
}
},
"slots": {
"default": {
"desc": "Suggestion: QBtn"
}
}
}

View file

@ -0,0 +1,88 @@
.q-btn-group
border-radius: $button-border-radius
box-shadow: $button-shadow
vertical-align: middle
> .q-btn-item
border-radius: inherit
align-self: stretch
&:before
box-shadow: none
.q-badge--floating
right: 0
> .q-btn-group
box-shadow: none
&:first-child
> .q-btn:first-child
border-top-left-radius: inherit
border-bottom-left-radius: inherit
&:last-child
> .q-btn:last-child
border-top-right-radius: inherit
border-bottom-right-radius: inherit
> .q-btn-group:not(:first-child) > .q-btn:first-child:before
border-left: 0
> .q-btn-group:not(:last-child) > .q-btn:last-child:before
border-right: 0
> .q-btn-item:not(:last-child)
border-top-right-radius: 0
border-bottom-right-radius: 0
> .q-btn-item:not(:first-child)
border-top-left-radius: 0
border-bottom-left-radius: 0
> .q-btn-item.q-btn--standard:before
z-index: -1
&--push
border-radius: $button-push-border-radius
> .q-btn--push
&.q-btn--actionable
transform: none
.q-btn__content
transition: margin-top $button-transition, margin-bottom $button-transition
&:active,
&.q-btn--active
.q-btn__content
margin-top: 2px
margin-bottom: -2px
&--rounded
border-radius: $button-rounded-border-radius
&--square
border-radius: 0
&--flat, &--outline, &--unelevated
box-shadow: none
&--outline
> .q-separator
display: none
> .q-btn-item + .q-btn-item:before
border-left: 0
> .q-btn-item:not(:last-child):before
border-right: 0
&--stretch
align-self: stretch
border-radius: 0
&--glossy
> .q-btn-item
background-image: linear-gradient(to bottom, rgba(#fff, .3), rgba(#fff, 0) 50%, rgba(#000, .12) 51%, rgba(#000, .04)) !important
&--spread
> .q-btn-group
display: flex !important
> .q-btn-item, > .q-btn-group > .q-btn-item:not(.q-btn-dropdown__arrow-container)
width: auto
min-width: 0
max-width: 100%
flex: 10000 1 0%

View file

@ -0,0 +1,5 @@
import QBtnGroup from './QBtnGroup.js'
export {
QBtnGroup
}

View file

@ -0,0 +1,169 @@
import { h, computed } from 'vue'
import QBtn from '../btn/QBtn.js'
import QBtnGroup from '../btn-group/QBtnGroup.js'
import { createComponent } from '../../utils/private.create/create.js'
import { useFormInject, useFormProps } from '../../composables/use-form/private.use-form.js'
import { hMergeSlot } from '../../utils/private.render/render.js'
import { getBtnDesignAttr } from '../btn/use-btn.js'
export default createComponent({
name: 'QBtnToggle',
props: {
...useFormProps,
modelValue: {
required: true
},
options: {
type: Array,
required: true,
validator: v => v.every(
opt => ('label' in opt || 'icon' in opt || 'slot' in opt) && 'value' in opt
)
},
// To avoid seeing the active raise shadow through
// the transparent button, give it a color (even white)
color: String,
textColor: String,
toggleColor: {
type: String,
default: 'primary'
},
toggleTextColor: String,
outline: Boolean,
flat: Boolean,
unelevated: Boolean,
rounded: Boolean,
push: Boolean,
glossy: Boolean,
size: String,
padding: String,
noCaps: Boolean,
noWrap: Boolean,
dense: Boolean,
readonly: Boolean,
disable: Boolean,
stack: Boolean,
stretch: Boolean,
spread: Boolean,
clearable: Boolean,
ripple: {
type: [ Boolean, Object ],
default: true
}
},
emits: [ 'update:modelValue', 'clear', 'click' ],
setup (props, { slots, emit }) {
const hasActiveValue = computed(() =>
props.options.find(opt => opt.value === props.modelValue) !== void 0
)
const formAttrs = computed(() => ({
type: 'hidden',
name: props.name,
value: props.modelValue
}))
const injectFormInput = useFormInject(formAttrs)
const btnDesignAttr = computed(() => getBtnDesignAttr(props))
const btnOptionDesign = computed(() => ({
rounded: props.rounded,
dense: props.dense,
...btnDesignAttr.value
}))
const btnOptions = computed(() => props.options.map((item, i) => {
const { attrs, value, slot, ...opt } = item
return {
slot,
props: {
key: i,
'aria-pressed': value === props.modelValue ? 'true' : 'false',
...attrs,
...opt,
...btnOptionDesign.value,
disable: props.disable === true || opt.disable === true,
// Options that come from the button specific options first, then from general props
color: value === props.modelValue
? mergeOpt(opt, 'toggleColor')
: mergeOpt(opt, 'color'),
textColor: value === props.modelValue
? mergeOpt(opt, 'toggleTextColor')
: mergeOpt(opt, 'textColor'),
noCaps: mergeOpt(opt, 'noCaps') === true,
noWrap: mergeOpt(opt, 'noWrap') === true,
size: mergeOpt(opt, 'size'),
padding: mergeOpt(opt, 'padding'),
ripple: mergeOpt(opt, 'ripple'),
stack: mergeOpt(opt, 'stack') === true,
stretch: mergeOpt(opt, 'stretch') === true,
onClick (e) { set(value, item, e) }
}
}
}))
function set (value, opt, e) {
if (props.readonly !== true) {
if (props.modelValue === value) {
if (props.clearable === true) {
emit('update:modelValue', null, null)
emit('clear')
}
}
else {
emit('update:modelValue', value, opt)
}
emit('click', e)
}
}
function mergeOpt (opt, key) {
return opt[ key ] === void 0 ? props[ key ] : opt[ key ]
}
function getContent () {
const child = btnOptions.value.map(opt => {
return h(QBtn, opt.props, opt.slot !== void 0 ? slots[ opt.slot ] : void 0)
})
if (props.name !== void 0 && props.disable !== true && hasActiveValue.value === true) {
injectFormInput(child, 'push')
}
return hMergeSlot(slots.default, child)
}
return () => h(QBtnGroup, {
class: 'q-btn-toggle',
...btnDesignAttr.value,
rounded: props.rounded,
stretch: props.stretch,
glossy: props.glossy,
spread: props.spread
}, getContent)
}
})

View file

@ -0,0 +1,197 @@
{
"mixins": [ "composables/use-form/private.use-form" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/button-toggle"
},
"props": {
"model-value": {
"extends": "model-value",
"type": "Any",
"desc": "Model of the component; Either use this property (along with a listener for 'update:modelValue' event) OR use v-model directive",
"examples": [ "# v-model=\"selected\"" ]
},
"options": {
"type": "Array",
"desc": "Array of Objects defining each option",
"required": true,
"definition": {
"attrs": {
"type": "Object",
"desc": "Key-value for attributes to be set on the button",
"examples": [ "{ 'aria-label': 'Button label' }" ],
"__exemption": [ "definition" ]
},
"label": {
"type": "String",
"desc": "Label of option button; Use this prop and/or 'icon', but at least one is required",
"examples": [ "'Option 1'" ]
},
"icon": {
"extends": "icon",
"desc": "Icon of option button; Use this prop and/or 'label', but at least one is required"
},
"value": {
"type": "Any",
"desc": "Value of the option that will be used by component model",
"required": true
},
"slot": {
"type": "String",
"desc": "Slot name to use for this button content; Useful for customizing content or even add tooltips",
"examples": [ "'mySlot'" ]
},
"...props": {
"type": "Any",
"desc": "Any other QBtn props (including class and style)"
}
},
"examples": [
"[ { label: 'One', value: 'one' }, { label: 'Two', value: 'two' } ]"
],
"category": "model"
},
"color": {
"extends": "color"
},
"text-color": {
"extends": "text-color"
},
"toggle-color": {
"extends": "color",
"default": "'primary'"
},
"toggle-text-color": {
"extends": "text-color"
},
"spread": {
"type": "Boolean",
"desc": "Spread horizontally to all available space",
"category": "content"
},
"outline": {
"type": "Boolean",
"desc": "Use 'outline' design",
"category": "style"
},
"flat": {
"type": "Boolean",
"desc": "Use 'flat' design",
"category": "style"
},
"unelevated": {
"type": "Boolean",
"desc": "Remove shadow",
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Applies a more prominent border-radius for a squared shape button",
"category": "style"
},
"push": {
"type": "Boolean",
"desc": "Use 'push' design",
"category": "style"
},
"glossy": {
"type": "Boolean",
"desc": "Applies a glossy effect",
"category": "style"
},
"size": {
"type": "String",
"desc": "Button size name or a CSS unit including unit name",
"examples": [ "'xs'", "'sm'", "'md'", "'lg'", "'xl'", "'25px'", "'2rem'" ],
"category": "style"
},
"padding": {
"type": "String",
"desc": "Apply custom padding (vertical [horizontal]); Size in CSS units, including unit name or standard size name (none|xs|sm|md|lg|xl); Also removes the min width and height when set",
"examples": [ "'16px'", "'10px 5px'", "'2rem'", "'xs'", "'md lg'", "'2px 2px 5px 7px'" ],
"category": "style"
},
"no-caps": {
"type": "Boolean",
"desc": "Avoid turning label text into caps (which happens by default)",
"category": "content"
},
"no-wrap": {
"type": "Boolean",
"desc": "Avoid label text wrapping",
"category": "content"
},
"ripple": {
"extends": "ripple"
},
"dense": {
"extends": "dense"
},
"readonly": {
"extends": "readonly"
},
"disable": {
"extends": "disable"
},
"stack": {
"type": "Boolean",
"desc": "Stack icon and label vertically instead of on same line (like it is by default)",
"category": "content"
},
"stretch": {
"type": "Boolean",
"desc": "When used on flexbox parent, button will stretch to parent's height",
"category": "content"
},
"clearable": {
"type": "Boolean",
"desc": "Clears model on click of the already selected button",
"category": "model"
}
},
"events": {
"update:model-value": {
"extends": "update:model-value"
},
"clear": {
"desc": "When using the 'clearable' property, this event is emitted when the already selected button is clicked"
},
"click": { "internal": true }
},
"slots": {
"default": {
"desc": "Suggestions: QTooltip, QBadge"
},
"...": {
"desc": "Any other dynamic slots to be used with 'slot' property of the 'options' prop"
}
}
}

View file

@ -0,0 +1,2 @@
.q-btn-toggle
position: relative

View file

@ -0,0 +1,5 @@
import QBtnToggle from './QBtnToggle.js'
export {
QBtnToggle
}

View file

@ -0,0 +1,382 @@
import { h, ref, computed, Transition, onBeforeUnmount, withDirectives, getCurrentInstance } from 'vue'
import QIcon from '../icon/QIcon.js'
import QSpinner from '../spinner/QSpinner.js'
import Ripple from '../../directives/ripple/Ripple.js'
import useBtn, { useBtnProps } from './use-btn.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hMergeSlot } from '../../utils/private.render/render.js'
import { stop, prevent, stopAndPrevent, listenOpts } from '../../utils/event/event.js'
import { isKeyCode } from '../../utils/private.keyboard/key-composition.js'
const { passiveCapture } = listenOpts
let
touchTarget = null,
keyboardTarget = null,
mouseTarget = null
export default createComponent({
name: 'QBtn',
props: {
...useBtnProps,
percentage: Number,
darkPercentage: Boolean,
onTouchstart: [ Function, Array ]
},
emits: [ 'click', 'keydown', 'mousedown', 'keyup' ],
setup (props, { slots, emit }) {
const { proxy } = getCurrentInstance()
const {
classes, style, innerClasses,
attributes,
hasLink, linkTag, navigateOnClick,
isActionable
} = useBtn(props)
const rootRef = ref(null)
const blurTargetRef = ref(null)
let localTouchTargetEl = null, avoidMouseRipple, mouseTimer = null
const hasLabel = computed(() =>
props.label !== void 0 && props.label !== null && props.label !== ''
)
const ripple = computed(() => (
props.disable === true || props.ripple === false
? false
: {
keyCodes: hasLink.value === true ? [ 13, 32 ] : [ 13 ],
...(props.ripple === true ? {} : props.ripple)
}
))
const rippleProps = computed(() => ({ center: props.round }))
const percentageStyle = computed(() => {
const val = Math.max(0, Math.min(100, props.percentage))
return val > 0
? { transition: 'transform 0.6s', transform: `translateX(${ val - 100 }%)` }
: {}
})
const onEvents = computed(() => {
if (props.loading === true) {
return {
onMousedown: onLoadingEvt,
onTouchstart: onLoadingEvt,
onClick: onLoadingEvt,
onKeydown: onLoadingEvt,
onKeyup: onLoadingEvt
}
}
if (isActionable.value === true) {
const acc = {
onClick,
onKeydown,
onMousedown
}
if (proxy.$q.platform.has.touch === true) {
const suffix = props.onTouchstart !== void 0
? ''
: 'Passive'
acc[ `onTouchstart${ suffix }` ] = onTouchstart
}
return acc
}
return {
// needed; especially for disabled <a> tags
onClick: stopAndPrevent
}
})
const nodeProps = computed(() => ({
ref: rootRef,
class: 'q-btn q-btn-item non-selectable no-outline ' + classes.value,
style: style.value,
...attributes.value,
...onEvents.value
}))
function onClick (e) {
// is it already destroyed?
if (rootRef.value === null) return
if (e !== void 0) {
if (e.defaultPrevented === true) return
const el = document.activeElement
// focus button if it came from ENTER on form
// prevent the new submit (already done)
if (
props.type === 'submit'
&& el !== document.body
&& rootRef.value.contains(el) === false
// required for iOS and desktop Safari
&& el.contains(rootRef.value) === false
) {
e.qAvoidFocus !== true && rootRef.value.focus()
const onClickCleanup = () => {
document.removeEventListener('keydown', stopAndPrevent, true)
document.removeEventListener('keyup', onClickCleanup, passiveCapture)
rootRef.value?.removeEventListener('blur', onClickCleanup, passiveCapture)
}
document.addEventListener('keydown', stopAndPrevent, true)
document.addEventListener('keyup', onClickCleanup, passiveCapture)
rootRef.value.addEventListener('blur', onClickCleanup, passiveCapture)
}
}
navigateOnClick(e)
}
function onKeydown (e) {
// is it already destroyed?
if (rootRef.value === null) return
emit('keydown', e)
if (isKeyCode(e, [ 13, 32 ]) === true && keyboardTarget !== rootRef.value) {
keyboardTarget !== null && cleanup()
if (e.defaultPrevented !== true) {
// focus external button if the focus helper was focused before
e.qAvoidFocus !== true && rootRef.value.focus()
keyboardTarget = rootRef.value
rootRef.value.classList.add('q-btn--active')
document.addEventListener('keyup', onPressEnd, true)
rootRef.value.addEventListener('blur', onPressEnd, passiveCapture)
}
stopAndPrevent(e)
}
}
function onTouchstart (e) {
// is it already destroyed?
if (rootRef.value === null) return
emit('touchstart', e)
if (e.defaultPrevented === true) return
if (touchTarget !== rootRef.value) {
touchTarget !== null && cleanup()
touchTarget = rootRef.value
localTouchTargetEl = e.target
localTouchTargetEl.addEventListener('touchcancel', onPressEnd, passiveCapture)
localTouchTargetEl.addEventListener('touchend', onPressEnd, passiveCapture)
}
// avoid duplicated mousedown event
// triggering another early ripple
avoidMouseRipple = true
mouseTimer !== null && clearTimeout(mouseTimer)
mouseTimer = setTimeout(() => {
mouseTimer = null
avoidMouseRipple = false
}, 200)
}
function onMousedown (e) {
// is it already destroyed?
if (rootRef.value === null) return
e.qSkipRipple = avoidMouseRipple === true
emit('mousedown', e)
if (e.defaultPrevented !== true && mouseTarget !== rootRef.value) {
mouseTarget !== null && cleanup()
mouseTarget = rootRef.value
rootRef.value.classList.add('q-btn--active')
document.addEventListener('mouseup', onPressEnd, passiveCapture)
}
}
function onPressEnd (e) {
// is it already destroyed?
if (rootRef.value === null) return
// needed for IE (because it emits blur when focusing button from focus helper)
if (
e?.type === 'blur'
&& document.activeElement === rootRef.value
) return
if (e?.type === 'keyup') {
if (keyboardTarget === rootRef.value && isKeyCode(e, [ 13, 32 ]) === true) {
// for click trigger
const evt = new MouseEvent('click', e)
evt.qKeyEvent = true
e.defaultPrevented === true && prevent(evt)
e.cancelBubble === true && stop(evt)
rootRef.value.dispatchEvent(evt)
stopAndPrevent(e)
// for ripple
e.qKeyEvent = true
}
emit('keyup', e)
}
cleanup()
}
function cleanup (destroying) {
const blurTarget = blurTargetRef.value
if (
destroying !== true
&& (touchTarget === rootRef.value || mouseTarget === rootRef.value)
&& blurTarget !== null
&& blurTarget !== document.activeElement
) {
blurTarget.setAttribute('tabindex', -1)
blurTarget.focus()
}
if (touchTarget === rootRef.value) {
if (localTouchTargetEl !== null) {
localTouchTargetEl.removeEventListener('touchcancel', onPressEnd, passiveCapture)
localTouchTargetEl.removeEventListener('touchend', onPressEnd, passiveCapture)
}
touchTarget = localTouchTargetEl = null
}
if (mouseTarget === rootRef.value) {
document.removeEventListener('mouseup', onPressEnd, passiveCapture)
mouseTarget = null
}
if (keyboardTarget === rootRef.value) {
document.removeEventListener('keyup', onPressEnd, true)
rootRef.value?.removeEventListener('blur', onPressEnd, passiveCapture)
keyboardTarget = null
}
rootRef.value?.classList.remove('q-btn--active')
}
function onLoadingEvt (evt) {
stopAndPrevent(evt)
evt.qSkipRipple = true
}
onBeforeUnmount(() => {
cleanup(true)
})
// expose public methods
Object.assign(proxy, {
click: e => {
if (isActionable.value === true) {
onClick(e)
}
}
})
return () => {
let inner = []
props.icon !== void 0 && inner.push(
h(QIcon, {
name: props.icon,
left: props.stack !== true && hasLabel.value === true,
role: 'img'
})
)
hasLabel.value === true && inner.push(
h('span', { class: 'block' }, [ props.label ])
)
inner = hMergeSlot(slots.default, inner)
if (props.iconRight !== void 0 && props.round === false) {
inner.push(
h(QIcon, {
name: props.iconRight,
right: props.stack !== true && hasLabel.value === true,
role: 'img'
})
)
}
const child = [
h('span', {
class: 'q-focus-helper',
ref: blurTargetRef
})
]
if (props.loading === true && props.percentage !== void 0) {
child.push(
h('span', {
class: 'q-btn__progress absolute-full overflow-hidden' + (props.darkPercentage === true ? ' q-btn__progress--dark' : '')
}, [
h('span', {
class: 'q-btn__progress-indicator fit block',
style: percentageStyle.value
})
])
)
}
child.push(
h('span', {
class: 'q-btn__content text-center col items-center q-anchor--skip ' + innerClasses.value
}, inner)
)
props.loading !== null && child.push(
h(Transition, {
name: 'q-transition--fade'
}, () => (
props.loading === true
? [
h('span', {
key: 'loading',
class: 'absolute-full flex flex-center'
}, slots.loading !== void 0 ? slots.loading() : [ h(QSpinner) ])
]
: null
))
)
return withDirectives(
h(
linkTag.value,
nodeProps.value,
child
),
[ [
Ripple,
ripple.value,
void 0,
rippleProps.value
] ]
)
}
}
})

View file

@ -0,0 +1,106 @@
{
"mixins": [ "components/btn/use-btn" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/button"
},
"props": {
"round": {
"type": "Boolean",
"desc": "Makes a circle shaped button",
"category": "style"
},
"percentage": {
"type": "Number",
"desc": "Percentage (0.0 < x < 100.0); To be used along 'loading' prop; Display a progress bar on the background",
"category": "behavior"
},
"dark-percentage": {
"type": "Boolean",
"desc": "Progress bar on the background should have dark color; To be used along with 'percentage' and 'loading' props",
"category": "behavior"
}
},
"slots": {
"default": {
"desc": "Use for custom content, instead of relying on 'icon' and 'label' props"
},
"loading": {
"desc": "Override the default QSpinner when in 'loading' state"
}
},
"methods": {
"click": {
"desc": "Emulate click on QBtn",
"params": {
"evt": {
"extends": "evt"
}
},
"returns": null
}
},
"events": {
"click": {
"desc": "Emitted when the component is clicked",
"params": {
"evt": {
"extends": "evt",
"desc": "JS event object; If you are using route navigation ('to'/'replace' props) and you want to cancel navigation then call evt.preventDefault() synchronously in your event handler"
},
"go": {
"type": "Function",
"desc": "Available ONLY if you are using route navigation ('to'/'replace' props); When you need to control the time at which the component should trigger the route navigation then call evt.preventDefault() synchronously and then call this function at your convenience; Useful if you have async work to be done before the actual route navigation or if you want to redirect somewhere else",
"required": false,
"addedIn": "v2.9",
"params": {
"opts": {
"type": "Object",
"desc": "Optional options",
"required": false,
"definition": {
"to": {
"type": [ "String", "Object" ],
"desc": "Equivalent to Vue Router <router-link> 'to' property; Specify it explicitly otherwise it will be set with same value as component's 'to' prop",
"required": false,
"examples": [
"'/home/dashboard'",
"{ name: 'my-route-name' }"
]
},
"replace": {
"type": "Boolean",
"desc": "Equivalent to Vue Router <router-link> 'replace' property; Specify it explicitly otherwise it will be set with same value as component's 'replace' prop",
"required": false
},
"returnRouterError": {
"type": "Boolean",
"desc": "Return the router error, if any; Otherwise the returned Promise will always fulfill",
"required": false
}
}
}
},
"returns": {
"type": "Promise<any>",
"desc": "Returns the router's navigation promise"
}
}
}
},
"touchstart": { "internal": true },
"keydown": { "internal": true },
"keyup": { "internal": true },
"mousedown": { "internal": true }
}
}

View file

@ -0,0 +1,159 @@
.q-btn
display: inline-flex
flex-direction: column
align-items: stretch
position: relative
outline: 0
border: 0
vertical-align: middle
font-size: $button-font-size
line-height: $button-line-height
text-decoration: none
color: inherit
background: transparent
font-weight: $button-font-weight
text-transform: uppercase
text-align: center
width: auto
height: auto
cursor: default
padding: $button-padding
min-height: 2.572em
.q-icon, .q-spinner
font-size: $button-line-height
&.disabled
opacity: .7 !important
&:before
content: ''
display: block
position: absolute
left: 0
right: 0
top: 0
bottom: 0
border-radius: inherit
box-shadow: $button-shadow
&--actionable
cursor: pointer
&.q-btn--standard
&:before
// This places the button active raise shadow behind adjacent elements
// Active raise shadow will still be visible under adjacent transparent elements, this is ok and coherent with a desired transparency effect.
// Visible active raise shadow can be removed by specifying a background color to the button
// Visible active raise shadow can be removed by specifying a flat or outline button type
transition: box-shadow $button-transition
&:active,
&.q-btn--active
&:before
box-shadow: $button-shadow-active
&--no-uppercase
text-transform: none
&--rectangle
border-radius: $button-border-radius
&--outline
background: transparent !important
&:before
border: 1px solid currentColor
&--push
border-radius: $button-push-border-radius
&:before
border-bottom: 3px solid rgba(0,0,0,.15)
&.q-btn--actionable
transition: transform $button-transition
&:before
transition: border-width $button-transition
&:active,
&.q-btn--active
transform: translateY(2px)
&:before
border-bottom-width: 0
&--rounded
border-radius: $button-rounded-border-radius
&--round
border-radius: 50%
padding: 0
min-width: 3em
min-height: 3em
&--square
border-radius: 0
&--flat, &--outline, &--unelevated
&:before
box-shadow: none
&--dense
padding: $button-dense-padding
min-height: 2em
&.q-btn--round
padding: 0
min-height: 2.4em
min-width: 2.4em
.on-left
margin-right: 6px
.on-right
margin-left: 6px
&--fab, &--fab-mini
.q-icon
font-size: $button-fab-icon-font-size
&--fab
padding: 16px
min-height: 56px
min-width: 56px
.q-icon
margin: auto
&--fab-mini
padding: 8px
min-height: 40px
min-width: 40px
// workaround for alignment/sizing change when showing loader
&__content
transition: opacity .3s
z-index: 0
&--hidden
opacity: 0
pointer-events: none
&__progress
border-radius: inherit
z-index: 0
&-indicator
z-index: -1
transform: translateX(-100%)
background: rgba(255,255,255,.25)
&--dark
.q-btn__progress-indicator
background: rgba(0,0,0,.2)
&--flat, &--outline
.q-btn__progress-indicator
opacity: 0.2
background: currentColor

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
import QBtn from './QBtn.js'
export {
QBtn
}

View file

@ -0,0 +1,225 @@
import { computed } from 'vue'
import useAlign, { useAlignProps } from '../../composables/private.use-align/use-align.js'
import useSize, { useSizeProps } from '../../composables/private.use-size/use-size.js'
import useRouterLink, { useRouterLinkNonMatchingProps } from '../../composables/private.use-router-link/use-router-link.js'
export const btnPadding = {
none: 0,
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32
}
export const defaultSizes = {
xs: 8,
sm: 10,
md: 14,
lg: 20,
xl: 24
}
const formTypes = [ 'button', 'submit', 'reset' ]
const mediaTypeRE = /[^\s]\/[^\s]/
export const btnDesignOptions = [ 'flat', 'outline', 'push', 'unelevated' ]
export function getBtnDesign (props, defaultValue) {
if (props.flat === true) return 'flat'
if (props.outline === true) return 'outline'
if (props.push === true) return 'push'
if (props.unelevated === true) return 'unelevated'
return defaultValue
}
export function getBtnDesignAttr (props) {
const design = getBtnDesign(props)
return design !== void 0
? { [ design ]: true }
: {}
}
export const nonRoundBtnProps = {
...useSizeProps,
...useRouterLinkNonMatchingProps,
type: {
type: String,
default: 'button'
},
label: [ Number, String ],
icon: String,
iconRight: String,
...btnDesignOptions.reduce(
(acc, val) => (acc[ val ] = Boolean) && acc,
{}
),
square: Boolean,
rounded: Boolean,
glossy: Boolean,
size: String,
fab: Boolean,
fabMini: Boolean,
padding: String,
color: String,
textColor: String,
noCaps: Boolean,
noWrap: Boolean,
dense: Boolean,
tabindex: [ Number, String ],
ripple: {
type: [ Boolean, Object ],
default: true
},
align: {
...useAlignProps.align,
default: 'center'
},
stack: Boolean,
stretch: Boolean,
loading: {
type: Boolean,
default: null
},
disable: Boolean
}
export const useBtnProps = {
...nonRoundBtnProps,
round: Boolean
}
export default function (props) {
const sizeStyle = useSize(props, defaultSizes)
const alignClass = useAlign(props)
const { hasRouterLink, hasLink, linkTag, linkAttrs, navigateOnClick } = useRouterLink({
fallbackTag: 'button'
})
const style = computed(() => {
const obj = props.fab === false && props.fabMini === false
? sizeStyle.value
: {}
return props.padding !== void 0
? Object.assign({}, obj, {
padding: props.padding
.split(/\s+/)
.map(v => (v in btnPadding ? btnPadding[ v ] + 'px' : v))
.join(' '),
minWidth: '0',
minHeight: '0'
})
: obj
})
const isRounded = computed(() =>
props.rounded === true || props.fab === true || props.fabMini === true
)
const isActionable = computed(() =>
props.disable !== true && props.loading !== true
)
const tabIndex = computed(() => (
isActionable.value === true ? props.tabindex || 0 : -1
))
const design = computed(() => getBtnDesign(props, 'standard'))
const attributes = computed(() => {
const acc = { tabindex: tabIndex.value }
if (hasLink.value === true) {
Object.assign(acc, linkAttrs.value)
}
else if (formTypes.includes(props.type) === true) {
acc.type = props.type
}
if (linkTag.value === 'a') {
if (props.disable === true) {
acc[ 'aria-disabled' ] = 'true'
}
else if (acc.href === void 0) {
acc.role = 'button'
}
if (hasRouterLink.value !== true && mediaTypeRE.test(props.type) === true) {
acc.type = props.type
}
}
else if (props.disable === true) {
acc.disabled = ''
acc[ 'aria-disabled' ] = 'true'
}
if (props.loading === true && props.percentage !== void 0) {
Object.assign(acc, {
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': props.percentage
})
}
return acc
})
const classes = computed(() => {
let colors
if (props.color !== void 0) {
if (props.flat === true || props.outline === true) {
colors = `text-${ props.textColor || props.color }`
}
else {
colors = `bg-${ props.color } text-${ props.textColor || 'white' }`
}
}
else if (props.textColor) {
colors = `text-${ props.textColor }`
}
const shape = props.round === true
? 'round'
: `rectangle${ isRounded.value === true ? ' q-btn--rounded' : (props.square === true ? ' q-btn--square' : '') }`
return `q-btn--${ design.value } q-btn--${ shape }`
+ (colors !== void 0 ? ' ' + colors : '')
+ (isActionable.value === true ? ' q-btn--actionable q-focusable q-hoverable' : (props.disable === true ? ' disabled' : ''))
+ (props.fab === true ? ' q-btn--fab' : (props.fabMini === true ? ' q-btn--fab-mini' : ''))
+ (props.noCaps === true ? ' q-btn--no-uppercase' : '')
+ (props.dense === true ? ' q-btn--dense' : '')
+ (props.stretch === true ? ' no-border-radius self-stretch' : '')
+ (props.glossy === true ? ' glossy' : '')
+ (props.square ? ' q-btn--square' : '')
})
const innerClasses = computed(() =>
alignClass.value + (props.stack === true ? ' column' : ' row')
+ (props.noWrap === true ? ' no-wrap text-no-wrap' : '')
+ (props.loading === true ? ' q-btn__content--hidden' : '')
)
return {
classes,
style,
innerClasses,
attributes,
hasLink,
linkTag,
navigateOnClick,
isActionable
}
}

View file

@ -0,0 +1,187 @@
{
"mixins": [ "composables/private.use-size/use-size" ],
"props": {
"type":{
"type": "String",
"desc": "1) Define the button native type attribute (submit, reset, button) or 2) render component with <a> tag so you can access events even if disable or 3) Use 'href' prop and specify 'type' as a media tag",
"default": "'button'",
"examples": [
"'a'", "'submit'", "'button'", "'reset'",
"'image/png'",
"# href=\"https://quasar.dev\" target=\"_blank\""
],
"category": "general"
},
"to": {
"type": [ "String", "Object" ],
"desc": "Equivalent to Vue Router <router-link> 'to' property; Superseded by 'href' prop if used",
"examples": [
"'/home/dashboard'",
"{ name: 'my-route-name' }"
],
"category": "navigation"
},
"replace": {
"type": "Boolean",
"desc": "Equivalent to Vue Router <router-link> 'replace' property; Superseded by 'href' prop if used",
"category": "navigation"
},
"href": {
"type": "String",
"desc": "Native <a> link href attribute; Has priority over the 'to' and 'replace' props",
"examples": [ "'https://quasar.dev'", "# href=\"https://quasar.dev\" target=\"_blank\"" ],
"category": "navigation",
"addedIn": "v2.4"
},
"target": {
"type": "String",
"desc": "Native <a> link target attribute; Use it only with 'to' or 'href' props",
"examples": [ "'_blank'", "'_self'", "'_parent'", "'_top'" ],
"category": "navigation",
"addedIn": "v2.4"
},
"label":{
"type": [ "String", "Number" ],
"desc": "The text that will be shown on the button",
"examples": [ "'Button Label'" ],
"category": "content"
},
"icon": {
"extends": "icon"
},
"icon-right": {
"extends": "icon"
},
"outline": {
"type": "Boolean",
"desc": "Use 'outline' design",
"category": "style"
},
"flat": {
"type": "Boolean",
"desc": "Use 'flat' design",
"category": "style"
},
"unelevated": {
"type": "Boolean",
"desc": "Remove shadow",
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Applies a more prominent border-radius for a squared shape button",
"category": "style"
},
"push": {
"type": "Boolean",
"desc": "Use 'push' design",
"category": "style"
},
"square": {
"extends": "square",
"addedIn": "v2.7.6"
},
"glossy": {
"type": "Boolean",
"desc": "Applies a glossy effect",
"category": "style"
},
"fab": {
"type": "Boolean",
"desc": "Makes button size and shape to fit a Floating Action Button",
"category": "style"
},
"fab-mini": {
"type": "Boolean",
"desc": "Makes button size and shape to fit a small Floating Action Button",
"category": "style"
},
"padding": {
"type": "String",
"desc": "Apply custom padding (vertical [horizontal]); Size in CSS units, including unit name or standard size name (none|xs|sm|md|lg|xl); Also removes the min width and height when set",
"examples": [ "'16px'", "'10px 5px'", "'2rem'", "'xs'", "'md lg'", "'2px 2px 5px 7px'" ],
"category": "style"
},
"color": {
"extends": "color"
},
"text-color": {
"extends": "text-color"
},
"no-caps": {
"type": "Boolean",
"desc": "Avoid turning label text into caps (which happens by default)",
"category": "content"
},
"no-wrap": {
"type": "Boolean",
"desc": "Avoid label text wrapping",
"category": "content"
},
"dense": {
"extends": "dense"
},
"ripple": {
"extends": "ripple"
},
"tabindex": {
"extends": "tabindex"
},
"align": {
"type": "String",
"desc": "Label or content alignment",
"default": "'center'",
"values": [ "'left'", "'right'", "'center'", "'around'", "'between'", "'evenly'" ],
"category": "content"
},
"stack": {
"type": "Boolean",
"desc": "Stack icon and label vertically instead of on same line (like it is by default)",
"category": "content"
},
"stretch": {
"type": "Boolean",
"desc": "When used on flexbox parent, button will stretch to parent's height",
"category": "content"
},
"loading": {
"type": [ "Boolean", "null" ],
"default": "null",
"desc": "Put button into loading state (displays a QSpinner -- can be overridden by using a 'loading' slot)",
"category": "behavior|state"
},
"disable": {
"extends": "disable"
}
}
}

View file

@ -0,0 +1,142 @@
import { mount } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import { defineComponent } from 'vue'
import useBtn, {
btnPadding, defaultSizes, btnDesignOptions,
useBtnProps, nonRoundBtnProps,
getBtnDesign, getBtnDesignAttr
} from './use-btn.js'
describe('[useBtn API]', () => {
describe('[Variables]', () => {
describe('[(variable)btnPadding]', () => {
test('is defined correctly', () => {
expect(btnPadding).toBeTypeOf('object')
expect(Object.keys(btnPadding)).not.toHaveLength(0)
})
})
describe('[(variable)defaultSizes]', () => {
test('is defined correctly', () => {
expect(defaultSizes).toBeTypeOf('object')
expect(Object.keys(defaultSizes)).not.toHaveLength(0)
})
})
describe('[(variable)btnDesignOptions]', () => {
test('is defined correctly', () => {
expect(btnDesignOptions).toBeTypeOf('object')
expect(Object.keys(btnDesignOptions)).not.toHaveLength(0)
})
})
describe('[(variable)useBtnProps]', () => {
test('is defined correctly', () => {
expect(useBtnProps).toBeTypeOf('object')
expect(Object.keys(useBtnProps)).not.toHaveLength(0)
})
})
describe('[(variable)nonRoundBtnProps]', () => {
test('is defined correctly', () => {
expect(nonRoundBtnProps).toBeTypeOf('object')
expect(Object.keys(nonRoundBtnProps)).not.toHaveLength(0)
})
})
})
describe('[Functions]', () => {
describe('[(function)default]', () => {
test('has correct return value', () => {
const wrapper = mount(
defineComponent({
template: '<div />',
setup () {
const result = useBtn({})
return { result }
}
})
)
expect(
wrapper.vm.result
).toStrictEqual({
classes: expect.$ref(expect.any(String)),
style: expect.$ref(expect.any(Object)),
innerClasses: expect.$ref(expect.any(String)),
attributes: expect.$ref(expect.any(Object)),
hasLink: expect.$ref(expect.any(Boolean)),
linkTag: expect.$ref(expect.any(String)),
navigateOnClick: expect.any(Function),
isActionable: expect.$ref(expect.any(Boolean))
})
})
})
describe('[(function)getBtnDesign]', () => {
test('returns correctly with single value', () => {
for (const prop of btnDesignOptions) {
expect(
getBtnDesign({ [ prop ]: true })
).toBe(prop)
expect(
getBtnDesign({ [ prop ]: true }, 'default')
).toBe(prop)
expect(
getBtnDesign({ [ prop ]: false })
).toBeUndefined()
expect(
getBtnDesign({ [ prop ]: false }, 'default')
).toBe('default')
}
})
test('returns correctly with multiple values', () => {
for (const prop of btnDesignOptions) {
const propMap = btnDesignOptions
.reduce((acc, val) => {
if (val !== prop) {
acc[ val ] = true
}
return acc
}, {})
expect(
getBtnDesign(propMap)
).not.toBe(prop)
expect(
getBtnDesign(propMap, 'default')
).not.toBe('default')
}
})
})
describe('[(function)getBtnDesignAttr]', () => {
test('has correct return value', () => {
expect(
getBtnDesignAttr({})
).toStrictEqual({})
expect(
getBtnDesignAttr({ something: true })
).toStrictEqual({})
for (const prop of btnDesignOptions) {
expect(
getBtnDesignAttr({ [ prop ]: true })
).toStrictEqual({ [ prop ]: true })
expect(
getBtnDesignAttr({ [ prop ]: false })
).toStrictEqual({})
}
})
})
})
})

View file

@ -0,0 +1,38 @@
import { h, computed, getCurrentInstance } from 'vue'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QCard',
props: {
...useDarkProps,
tag: {
type: String,
default: 'div'
},
square: Boolean,
flat: Boolean,
bordered: Boolean
},
setup (props, { slots }) {
const { proxy: { $q } } = getCurrentInstance()
const isDark = useDark(props, $q)
const classes = computed(() =>
'q-card'
+ (isDark.value === true ? ' q-card--dark q-dark' : '')
+ (props.bordered === true ? ' q-card--bordered' : '')
+ (props.square === true ? ' q-card--square no-border-radius' : '')
+ (props.flat === true ? ' q-card--flat no-shadow' : '')
)
return () => h(props.tag, { class: classes.value }, hSlot(slots.default))
}
})

View file

@ -0,0 +1,35 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/card"
},
"props": {
"dark": {
"extends": "dark"
},
"square": {
"extends": "square"
},
"flat": {
"extends": "flat"
},
"bordered": {
"extends": "bordered"
},
"tag": {
"extends": "tag",
"default": "'div'",
"examples": [ "'div'", "'form'" ]
}
},
"slots": {
"default": {
"extends": "default"
}
}
}

View file

@ -0,0 +1,90 @@
.q-card
box-shadow: $shadow-2
border-radius: $generic-border-radius
vertical-align: top
background: #fff
position: relative
> div,
> img
&:not(.q--avoid-card-border)
border-top-left-radius: 0
border-top-right-radius: 0
border-bottom-left-radius: 0
border-bottom-right-radius: 0
&:nth-child(1 of :not(.q--avoid-card-border))
border-top: 0
border-top-left-radius: inherit
border-top-right-radius: inherit
&:nth-last-child(1 of :not(.q--avoid-card-border))
border-bottom: 0
border-bottom-left-radius: inherit
border-bottom-right-radius: inherit
> div:not(.q--avoid-card-border)
border-left: 0
border-right: 0
box-shadow: none
&--bordered
border: 1px solid $separator-color
&--dark
border-color: $separator-dark-color
box-shadow: $dark-shadow-2
&__section
position: relative
&--vert
padding: 16px
&--horiz
> div,
> img
&:not(.q--avoid-card-border)
border-top-left-radius: 0
border-bottom-left-radius: 0
border-top-right-radius: 0
border-bottom-right-radius: 0
&:nth-child(1 of :not(.q--avoid-card-border))
border-top-left-radius: inherit
border-bottom-left-radius: inherit
&:nth-last-child(1 of :not(.q--avoid-card-border))
border-top-right-radius: inherit
border-bottom-right-radius: inherit
> div:not(.q--avoid-card-border)
border-top: 0
border-bottom: 0
box-shadow: none
&__actions
padding: 8px
align-items: center
.q-btn--rectangle
padding: 0 8px
&--horiz
> .q-btn-item + .q-btn-item,
> .q-btn-group + .q-btn-item,
> .q-btn-item + .q-btn-group
margin-left: 8px
&--vert
> .q-btn-item.q-btn--round
align-self: center
> .q-btn-item + .q-btn-item,
> .q-btn-group + .q-btn-item,
> .q-btn-item + .q-btn-group
margin-top: 4px
> .q-btn-group > .q-btn-item
flex-grow: 1
> img
display: block
width: 100%
max-width: 100%
border: 0

View file

@ -0,0 +1,26 @@
import { h, computed } from 'vue'
import useAlign, { useAlignProps } from '../../composables/private.use-align/use-align.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QCardActions',
props: {
...useAlignProps,
vertical: Boolean
},
setup (props, { slots }) {
const alignClass = useAlign(props)
const classes = computed(() =>
`q-card__actions ${ alignClass.value }`
+ ` q-card__actions--${ props.vertical === true ? 'vert column' : 'horiz row' }`
)
return () => h('div', { class: classes.value }, hSlot(slots.default))
}
})

View file

@ -0,0 +1,28 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/card"
},
"props": {
"align": {
"type": "String",
"desc": "Specify how to align the actions; For horizontal mode, the default is 'left', while for vertical mode, the default is 'stretch'",
"default": "# 'left'/'stretch'",
"__runtimeDefault": true,
"values": [ "'left'", "'center'", "'right'", "'between'", "'around'", "'evenly'", "'stretch'" ],
"category": "content"
},
"vertical": {
"type": "Boolean",
"desc": "Display actions one below the other",
"category": "content"
}
},
"slots": {
"default": {
"desc": "Suggestions: QBtn"
}
}
}

View file

@ -0,0 +1,26 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QCardSection',
props: {
tag: {
type: String,
default: 'div'
},
horizontal: Boolean
},
setup (props, { slots }) {
const classes = computed(() =>
'q-card__section'
+ ` q-card__section--${ props.horizontal === true ? 'horiz row no-wrap' : 'vert' }`
)
return () => h(props.tag, { class: classes.value }, hSlot(slots.default))
}
})

View file

@ -0,0 +1,25 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/card"
},
"props": {
"horizontal": {
"type": "Boolean",
"desc": "Display a horizontal section (will have no padding and can contain other QCardSection)",
"category": "content"
},
"tag": {
"extends": "tag",
"default": "'div'",
"examples": [ "'div'", "'form'" ]
}
},
"slots": {
"default": {
"extends": "default"
}
}
}

View file

@ -0,0 +1,9 @@
import QCard from './QCard.js'
import QCardSection from './QCardSection.js'
import QCardActions from './QCardActions.js'
export {
QCard,
QCardSection,
QCardActions
}

View file

@ -0,0 +1,283 @@
import { h, computed, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import QBtn from '../btn/QBtn.js'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import usePanel, { usePanelProps, usePanelEmits } from '../../composables/private.use-panel/use-panel.js'
import useFullscreen, { useFullscreenProps, useFullscreenEmits } from '../../composables/private.use-fullscreen/use-fullscreen.js'
import { createComponent } from '../../utils/private.create/create.js'
import { isNumber } from '../../utils/is/is.js'
import { hMergeSlot, hDir } from '../../utils/private.render/render.js'
const navigationPositionOptions = [ 'top', 'right', 'bottom', 'left' ]
const controlTypeOptions = [ 'regular', 'flat', 'outline', 'push', 'unelevated' ]
export default createComponent({
name: 'QCarousel',
props: {
...useDarkProps,
...usePanelProps,
...useFullscreenProps,
transitionPrev: { // usePanelParentProps override
type: String,
default: 'fade'
},
transitionNext: { // usePanelParentProps override
type: String,
default: 'fade'
},
height: String,
padding: Boolean,
controlColor: String,
controlTextColor: String,
controlType: {
type: String,
validator: v => controlTypeOptions.includes(v),
default: 'flat'
},
autoplay: [ Number, Boolean ],
arrows: Boolean,
prevIcon: String,
nextIcon: String,
navigation: Boolean,
navigationPosition: {
type: String,
validator: v => navigationPositionOptions.includes(v)
},
navigationIcon: String,
navigationActiveIcon: String,
thumbnails: Boolean
},
emits: [
...useFullscreenEmits,
...usePanelEmits
],
setup (props, { slots }) {
const { proxy: { $q } } = getCurrentInstance()
const isDark = useDark(props, $q)
let timer = null, panelsLen
const {
updatePanelsList, getPanelContent,
panelDirectives, goToPanel,
previousPanel, nextPanel, getEnabledPanels,
panelIndex
} = usePanel()
const { inFullscreen } = useFullscreen()
const style = computed(() => (
inFullscreen.value !== true && props.height !== void 0
? { height: props.height }
: {}
))
const direction = computed(() => (props.vertical === true ? 'vertical' : 'horizontal'))
const navigationPosition = computed(() => props.navigationPosition
|| (props.vertical === true ? 'right' : 'bottom')
)
const classes = computed(() =>
`q-carousel q-panel-parent q-carousel--with${ props.padding === true ? '' : 'out' }-padding`
+ (inFullscreen.value === true ? ' fullscreen' : '')
+ (isDark.value === true ? ' q-carousel--dark q-dark' : '')
+ (props.arrows === true ? ` q-carousel--arrows-${ direction.value }` : '')
+ (props.navigation === true ? ` q-carousel--navigation-${ navigationPosition.value }` : '')
)
const arrowIcons = computed(() => {
const ico = [
props.prevIcon || $q.iconSet.carousel[ props.vertical === true ? 'up' : 'left' ],
props.nextIcon || $q.iconSet.carousel[ props.vertical === true ? 'down' : 'right' ]
]
return props.vertical === false && $q.lang.rtl === true
? ico.reverse()
: ico
})
const navIcon = computed(() => props.navigationIcon || $q.iconSet.carousel.navigationIcon)
const navActiveIcon = computed(() => props.navigationActiveIcon || navIcon.value)
const controlProps = computed(() => ({
color: props.controlColor,
textColor: props.controlTextColor,
round: true,
[ props.controlType ]: true,
dense: true
}))
watch(() => props.modelValue, () => {
if (props.autoplay) {
startTimer()
}
})
watch(() => props.autoplay, val => {
if (val) {
startTimer()
}
else if (timer !== null) {
clearTimeout(timer)
timer = null
}
})
function startTimer () {
const duration = isNumber(props.autoplay) === true
? Math.abs(props.autoplay)
: 5000
timer !== null && clearTimeout(timer)
timer = setTimeout(() => {
timer = null
if (duration >= 0) {
nextPanel()
}
else {
previousPanel()
}
}, duration)
}
onMounted(() => {
props.autoplay && startTimer()
})
onBeforeUnmount(() => {
timer !== null && clearTimeout(timer)
})
function getNavigationContainer (type, mapping) {
return h('div', {
class: 'q-carousel__control q-carousel__navigation no-wrap absolute flex'
+ ` q-carousel__navigation--${ type } q-carousel__navigation--${ navigationPosition.value }`
+ (props.controlColor !== void 0 ? ` text-${ props.controlColor }` : '')
}, [
h('div', {
class: 'q-carousel__navigation-inner flex flex-center no-wrap'
}, getEnabledPanels().map(mapping))
])
}
function getContent () {
const node = []
if (props.navigation === true) {
const fn = slots[ 'navigation-icon' ] !== void 0
? slots[ 'navigation-icon' ]
: opts => h(QBtn, {
key: 'nav' + opts.name,
class: `q-carousel__navigation-icon q-carousel__navigation-icon--${ opts.active === true ? '' : 'in' }active`,
...opts.btnProps,
onClick: opts.onClick
})
const maxIndex = panelsLen - 1
node.push(
getNavigationContainer('buttons', (panel, index) => {
const name = panel.props.name
const active = panelIndex.value === index
return fn({
index,
maxIndex,
name,
active,
btnProps: {
icon: active === true ? navActiveIcon.value : navIcon.value,
size: 'sm',
...controlProps.value
},
onClick: () => { goToPanel(name) }
})
})
)
}
else if (props.thumbnails === true) {
const color = props.controlColor !== void 0
? ` text-${ props.controlColor }`
: ''
node.push(getNavigationContainer('thumbnails', panel => {
const slide = panel.props
return h('img', {
key: 'tmb#' + slide.name,
class: `q-carousel__thumbnail q-carousel__thumbnail--${ slide.name === props.modelValue ? '' : 'in' }active` + color,
src: slide.imgSrc || slide[ 'img-src' ],
onClick: () => { goToPanel(slide.name) }
})
}))
}
if (props.arrows === true && panelIndex.value >= 0) {
if (props.infinite === true || panelIndex.value > 0) {
node.push(
h('div', {
key: 'prev',
class: `q-carousel__control q-carousel__arrow q-carousel__prev-arrow q-carousel__prev-arrow--${ direction.value } absolute flex flex-center`
}, [
h(QBtn, {
icon: arrowIcons.value[ 0 ],
...controlProps.value,
onClick: previousPanel
})
])
)
}
if (props.infinite === true || panelIndex.value < panelsLen - 1) {
node.push(
h('div', {
key: 'next',
class: 'q-carousel__control q-carousel__arrow q-carousel__next-arrow'
+ ` q-carousel__next-arrow--${ direction.value } absolute flex flex-center`
}, [
h(QBtn, {
icon: arrowIcons.value[ 1 ],
...controlProps.value,
onClick: nextPanel
})
])
)
}
}
return hMergeSlot(slots.control, node)
}
return () => {
panelsLen = updatePanelsList(slots)
return h('div', {
class: classes.value,
style: style.value
}, [
hDir(
'div',
{ class: 'q-carousel__slides-container' },
getPanelContent(),
'sl-cont',
props.swipeable,
() => panelDirectives.value
)
].concat(getContent()))
}
}
})

View file

@ -0,0 +1,154 @@
{
"mixins": [ "composables/private.use-panel/use-panel", "composables/private.use-fullscreen/use-fullscreen" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/carousel"
},
"props": {
"dark": {
"extends": "dark"
},
"height": {
"extends": "size",
"desc": "Height of Carousel in CSS units, including unit name"
},
"padding": {
"type": "Boolean",
"desc": "Applies a default padding to each slide, according to the usage of 'arrows' and 'navigation' props",
"category": "content"
},
"control-color": {
"extends": "color",
"desc": "Color name for QCarousel button controls (arrows, navigation) from the Quasar Color Palette"
},
"control-text-color": {
"extends": "color",
"desc": "Color name for text color of QCarousel button controls (arrows, navigation) from the Quasar Color Palette"
},
"control-type": {
"type": "String",
"desc": "Type of button to use for controls (arrows, navigation)",
"values": [ "'regular'", "'flat'", "'outline'", "'push'", "'unelevated'" ],
"default": "'flat'",
"category": "style"
},
"autoplay": {
"type": [ "Number", "Boolean" ],
"desc": "Jump to next slide (if 'true' or val > 0) or previous slide (if val < 0) at fixed time intervals (in milliseconds); 'false' disables autoplay, 'true' enables it for 5000ms intervals",
"examples": [
"true",
"false",
"2500"
],
"category": "behavior"
},
"arrows": {
"type": "Boolean",
"desc": "Show navigation arrow buttons",
"category": "content"
},
"prev-icon": {
"extends": "icon"
},
"next-icon": {
"extends": "icon"
},
"navigation": {
"type": "Boolean",
"desc": "Show navigation dots",
"category": "content"
},
"navigation-position": {
"type": "String",
"desc": "Side to stick navigation to",
"default": "# 'bottom'/'right'",
"__runtimeDefault": true,
"values": [ "'top'", "'right'", "'bottom'", "'left'" ],
"category": "content"
},
"navigation-icon": {
"extends": "icon"
},
"navigation-active-icon": {
"extends": "icon",
"desc": "Icon name following Quasar convention for the active (current slide) navigation icon; Make sure you have the icon library installed unless you are using 'img:' prefix"
},
"thumbnails": {
"type": "Boolean",
"desc": "Show thumbnails",
"category": "content"
},
"transition-prev": {
"default": "'fade'",
"__delete": [ "__runtimeDefault" ]
},
"transition-next": {
"default": "'fade'",
"__delete": [ "__runtimeDefault" ]
}
},
"slots": {
"default": {
"desc": "Suggestion: QCarouselSlide"
},
"control": {
"desc": "Slot specific for QCarouselControl"
},
"navigation-icon": {
"desc": "Slot for navigation icon/btn; Suggestion: QBtn",
"scope": {
"index": {
"type": "Number",
"desc": "The 0-based index of corresponding slide"
},
"maxIndex": {
"type": "Number",
"desc": "The available number of slides"
},
"name": {
"type": "Any",
"desc": "The name of the corresponding slide"
},
"active": {
"type": "Boolean",
"desc": "Is this the current slide?"
},
"btnProps": {
"type": "Object",
"desc": "Default QBtn props that can be binded to your own QBtn"
},
"onClick": {
"type": "Function",
"desc": "Default trigger when clicked/tapped on",
"params": {
"evt": {
"extends": "evt",
"required": true
}
},
"returns": null
}
}
}
}
}

View file

@ -0,0 +1,126 @@
.q-carousel
background-color: #fff // needed for fullscreen
height: 400px
&__slide
min-height: 100%
background-size: cover
background-position: 50%
&__slide, .q-carousel--padding
padding: 16px
&__slides-container
height: 100%
&__control
color: #fff
&__arrow
pointer-events: none
.q-icon
font-size: $carousel-arrow-icon-font-size
.q-btn
pointer-events: all
&__prev-arrow--horizontal,
&__next-arrow--horizontal
top: 16px
bottom: 16px
&__prev-arrow--horizontal
left: 16px
&__next-arrow--horizontal
right: 16px
&__prev-arrow--vertical,
&__next-arrow--vertical
left: 16px
right: 16px
&__prev-arrow--vertical
top: 16px
&__next-arrow--vertical
bottom: 16px
&__navigation
&--top,
&--bottom
left: 16px
right: 16px
overflow-x: auto
overflow-y: hidden
&--top
top: 16px
&--bottom
bottom: 16px
&--left,
&--right
top: 16px
bottom: 16px
overflow-x: hidden
overflow-y: auto
> .q-carousel__navigation-inner
flex-direction: column
&--left
left: 16px
&--right
right: 16px
&-inner
flex: 1 1 auto
.q-btn
margin: 6px 4px
padding: 5px
&__navigation-icon--inactive
opacity: .7
.q-carousel__thumbnail
margin: 2px
height: 50px
width: auto
display: inline-block
cursor: pointer
border: 1px solid transparent
border-radius: $generic-border-radius
vertical-align: middle
opacity: .7
transition: opacity .3s
.q-carousel__thumbnail:hover,
.q-carousel__thumbnail--active
opacity: 1
.q-carousel__thumbnail--active
border-color: currentColor
cursor: default
&--navigation-top,
&--arrows-vertical
&.q-carousel--with-padding .q-carousel__slide, .q-carousel--padding
padding-top: 60px
&--navigation-bottom,
&--arrows-vertical
&.q-carousel--with-padding .q-carousel__slide, .q-carousel--padding
padding-bottom: 60px
&--navigation-left,
&--arrows-horizontal
&.q-carousel--with-padding .q-carousel__slide, .q-carousel--padding
padding-left: 60px
&--navigation-right,
&--arrows-horizontal
&.q-carousel--with-padding .q-carousel__slide, .q-carousel--padding
padding-right: 60px
&.fullscreen
height: 100%

View file

@ -0,0 +1,37 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QCarouselControl',
props: {
position: {
type: String,
default: 'bottom-right',
validator: v => [
'top-right', 'top-left',
'bottom-right', 'bottom-left',
'top', 'right', 'bottom', 'left'
].includes(v)
},
offset: {
type: Array,
default: () => [ 18, 18 ],
validator: v => v.length === 2
}
},
setup (props, { slots }) {
const classes = computed(() => `q-carousel__control absolute absolute-${ props.position }`)
const style = computed(() => ({
margin: `${ props.offset[ 1 ] }px ${ props.offset[ 0 ] }px`
}))
return () => h('div', {
class: classes.value,
style: style.value
}, hSlot(slots.default))
}
})

View file

@ -0,0 +1,33 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/carousel"
},
"props": {
"position": {
"type": "String",
"desc": "Side/corner to stick to",
"default": "'bottom-right'",
"values": [
"'top-right'", "'top-left'",
"'bottom-right'", "'bottom-left'",
"'top'", "'right'", "'bottom'", "'left'"
],
"category": "position"
},
"offset": {
"type": "Array",
"desc": "An array of two numbers to offset the component horizontally and vertically (in pixels)",
"default": "[ 18, 18 ]",
"examples": [ "[ 8, 8 ]", "[ 5, 10 ]" ],
"category": "position"
}
},
"slots": {
"default": {
"extends": "default"
}
}
}

View file

@ -0,0 +1,28 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { usePanelChildProps } from '../../composables/private.use-panel/use-panel.js'
import { hSlot } from '../../utils/private.render/render.js'
export default createComponent({
name: 'QCarouselSlide',
props: {
...usePanelChildProps,
imgSrc: String
},
setup (props, { slots }) {
const style = computed(() => (
props.imgSrc
? { backgroundImage: `url("${ props.imgSrc }")` }
: {}
))
return () => h('div', {
class: 'q-carousel__slide',
style: style.value
}, hSlot(slots.default))
}
})

View file

@ -0,0 +1,33 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/carousel"
},
"mixins": [ "composables/private.use-panel/use-panel.child" ],
"props": {
"name": {
"desc": "Slide name",
"category": "model"
},
"img-src": {
"type": "String",
"desc": "URL pointing to a slide background image (use public folder)",
"transformAssetUrls": true,
"examples": [
"# (public folder) src=\"img/my-bg.png\"",
"# (assets folder) src=\"~assets/my-img.png\"",
"# (relative path format) :src=\"require('./my_img.jpg')\"",
"# (URL) src=\"https://picsum.photos/500/300\""
],
"category": "model"
}
},
"slots": {
"default": {
"extends": "default"
}
}
}

View file

@ -0,0 +1,9 @@
import QCarousel from './QCarousel.js'
import QCarouselSlide from './QCarouselSlide.js'
import QCarouselControl from './QCarouselControl.js'
export {
QCarousel,
QCarouselSlide,
QCarouselControl
}

View file

@ -0,0 +1,156 @@
import { h, computed } from 'vue'
import { createComponent } from '../../utils/private.create/create.js'
import { getNormalizedVNodes } from '../../utils/private.vm/vm.js'
export default createComponent({
name: 'QChatMessage',
props: {
sent: Boolean,
label: String,
bgColor: String,
textColor: String,
name: String,
avatar: String,
text: Array,
stamp: String,
size: String,
labelHtml: Boolean,
nameHtml: Boolean,
textHtml: Boolean,
stampHtml: Boolean
},
setup (props, { slots }) {
const op = computed(() => (props.sent === true ? 'sent' : 'received'))
const textClass = computed(() =>
`q-message-text-content q-message-text-content--${ op.value }`
+ (props.textColor !== void 0 ? ` text-${ props.textColor }` : '')
)
const messageClass = computed(() =>
`q-message-text q-message-text--${ op.value }`
+ (props.bgColor !== void 0 ? ` text-${ props.bgColor }` : '')
)
const containerClass = computed(() =>
'q-message-container row items-end no-wrap'
+ (props.sent === true ? ' reverse' : '')
)
const sizeClass = computed(() => (props.size !== void 0 ? `col-${ props.size }` : ''))
const domProps = computed(() => ({
msg: props.textHtml === true ? 'innerHTML' : 'textContent',
stamp: props.stampHtml === true ? 'innerHTML' : 'textContent',
name: props.nameHtml === true ? 'innerHTML' : 'textContent',
label: props.labelHtml === true ? 'innerHTML' : 'textContent'
}))
function wrapStamp (node) {
if (slots.stamp !== void 0) {
return [ node, h('div', { class: 'q-message-stamp' }, slots.stamp()) ]
}
if (props.stamp) {
return [
node,
h('div', {
class: 'q-message-stamp',
[ domProps.value.stamp ]: props.stamp
})
]
}
return [ node ]
}
function getText (contentList, withSlots) {
const content = withSlots === true
? (contentList.length > 1 ? text => text : text => h('div', [ text ]))
: text => h('div', { [ domProps.value.msg ]: text })
return contentList.map((msg, index) => h('div', {
key: index,
class: messageClass.value
}, [
h('div', { class: textClass.value }, wrapStamp(content(msg)))
]))
}
return () => {
const container = []
if (slots.avatar !== void 0) {
container.push(slots.avatar())
}
else if (props.avatar !== void 0) {
container.push(
h('img', {
class: `q-message-avatar q-message-avatar--${ op.value }`,
src: props.avatar,
'aria-hidden': 'true'
})
)
}
const msg = []
if (slots.name !== void 0) {
msg.push(
h('div', { class: `q-message-name q-message-name--${ op.value }` }, slots.name())
)
}
else if (props.name !== void 0) {
msg.push(
h('div', {
class: `q-message-name q-message-name--${ op.value }`,
[ domProps.value.name ]: props.name
})
)
}
if (slots.default !== void 0) {
msg.push(
getText(
getNormalizedVNodes(slots.default()),
true
)
)
}
else if (props.text !== void 0) {
msg.push(getText(props.text))
}
container.push(
h('div', { class: sizeClass.value }, msg)
)
const child = []
if (slots.label !== void 0) {
child.push(
h('div', { class: 'q-message-label' }, slots.label())
)
}
else if (props.label !== void 0) {
child.push(
h('div', {
class: 'q-message-label',
[ domProps.value.label ]: props.label
})
)
}
child.push(
h('div', { class: containerClass.value }, container)
)
return h('div', {
class: `q-message q-message-${ op.value }`
}, child)
}
}
})

View file

@ -0,0 +1,115 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/chat"
},
"props": {
"sent": {
"type": "Boolean",
"desc": "Render as a sent message (so from current user)",
"category": "content"
},
"label": {
"type": "String",
"desc": "Renders a label header/section only",
"examples": [ "'Friday, 18th'" ],
"category": "content"
},
"bg-color": {
"extends": "color",
"desc": "Color name (from the Quasar Color Palette) for chat bubble background",
"category": "style"
},
"text-color": {
"extends": "text-color",
"desc": "Color name (from the Quasar Color Palette) for chat bubble text",
"category": "style"
},
"name": {
"type": "String",
"desc": "Author's name",
"examples": [ "'John Doe'" ],
"category": "content"
},
"avatar": {
"type": "String",
"desc": "URL to the avatar image of the author",
"transformAssetUrls": true,
"examples": [
"# (public folder) src=\"boy-avatar.png\"",
"# (assets folder) src=\"~assets/boy-avatar.png\"",
"# (relative path format) :src=\"require('./my_img.jpg')\"",
"# (URL) src=\"https://picsum.photos/500/300\""
],
"category": "content"
},
"text": {
"type": "Array",
"desc": "Array of strings that are the message body. Strings are not sanitized (see details in docs)",
"examples": [ "[ 'How are you?' ]", "[ 'I\\'m good, thank you!', 'And you?' ]" ],
"category": "content"
},
"stamp": {
"type": "String",
"desc": "Creation timestamp",
"examples": [ "'13:55'", "'Yesterday at 13:51'" ],
"category": "content"
},
"size": {
"type": "String",
"desc": "1-12 out of 12 (same as col-*)",
"examples": [ "'4'", "'6'", "'12'" ],
"category": "style"
},
"label-html": {
"extends": "html",
"desc": "Render the label as HTML; This can lead to XSS attacks so make sure that you sanitize the message first"
},
"name-html": {
"extends": "html",
"desc": "Render the name as HTML; This can lead to XSS attacks so make sure that you sanitize the message first"
},
"text-html": {
"extends": "html",
"desc": "Render the text as HTML; This can lead to XSS attacks so make sure that you sanitize the message first"
},
"stamp-html": {
"extends": "html",
"desc": "Render the stamp as HTML; This can lead to XSS attacks so make sure that you sanitize the message first"
}
},
"slots": {
"default": {
"desc": "You can use this slot to define a custom message (overrides props)"
},
"avatar": {
"desc": "Slot for avatar; Suggestion: QAvatar, img"
},
"name": {
"desc": "Slot for name; Overrides the 'name' prop"
},
"stamp": {
"desc": "Slot for stamp; Overrides the 'stamp' prop"
},
"label": {
"desc": "Slot for label; Overrides the 'label' prop"
}
}
}

View file

@ -0,0 +1,77 @@
.q-message-name
font-size: $chat-message-name-font-size
.q-message-label
margin: (3 * $chat-message-distance) 0
text-align: center
font-size: $chat-message-label-font-size
.q-message-stamp
color: inherit
margin-top: 4px
opacity: .6
display: none
font-size: $chat-message-stamp-font-size
.q-message-avatar
border-radius: 50%
width: $chat-message-avatar-size
height: $chat-message-avatar-size
min-width: $chat-message-avatar-size
.q-message
margin-bottom: $chat-message-distance
&:first-child .q-message-label
margin-top: 0
.q-message-avatar--received
margin-right: 8px
.q-message-text--received
color: $chat-message-received-bg
border-radius: $chat-message-border-radius $chat-message-border-radius $chat-message-border-radius 0
&:last-child:before
right: 100%
border-right: 0 solid transparent
border-left: 8px solid transparent
border-bottom: 8px solid currentColor
.q-message-text-content--received
color: $chat-message-received-color
.q-message-name--sent
text-align: right
.q-message-avatar--sent
margin-left: 8px
.q-message-container--sent
flex-direction: row-reverse
.q-message-text--sent
color: $chat-message-sent-bg
border-radius: $chat-message-border-radius $chat-message-border-radius 0 $chat-message-border-radius
&:last-child:before
left: 100%
border-left: 0 solid transparent
border-right: 8px solid transparent
border-bottom: 8px solid currentColor
.q-message-text-content--sent
color: $chat-message-sent-color
.q-message-text
background: currentColor
padding: $chat-message-text-padding
line-height: max(1.2, $min-line-height)
word-break: break-word
position: relative
& + &
margin-top: 3px
&:last-child
min-height: $chat-message-avatar-size
.q-message-stamp
display: block
&:before
content: ''
position: absolute
bottom: 0
width: 0
height: 0

View file

@ -0,0 +1,5 @@
import QChatMessage from './QChatMessage.js'
export {
QChatMessage
}

View file

@ -0,0 +1,68 @@
import { h, computed } from 'vue'
import QIcon from '../icon/QIcon.js'
import { createComponent } from '../../utils/private.create/create.js'
import useCheckbox, { useCheckboxProps, useCheckboxEmits } from './use-checkbox.js'
const createBgNode = () => h('div', {
key: 'svg',
class: 'q-checkbox__bg absolute'
}, [
h('svg', {
class: 'q-checkbox__svg fit absolute-full',
viewBox: '0 0 24 24'
}, [
h('path', {
class: 'q-checkbox__truthy',
fill: 'none',
d: 'M1.73,12.91 8.1,19.28 22.79,4.59'
}),
h('path', {
class: 'q-checkbox__indet',
d: 'M4,14H20V10H4'
})
])
])
export default createComponent({
name: 'QCheckbox',
props: useCheckboxProps,
emits: useCheckboxEmits,
setup (props) {
const bgNode = createBgNode()
function getInner (isTrue, isIndeterminate) {
const icon = computed(() =>
(isTrue.value === true
? props.checkedIcon
: (isIndeterminate.value === true
? props.indeterminateIcon
: props.uncheckedIcon
)
) || null
)
return () => (
icon.value !== null
? [
h('div', {
key: 'icon',
class: 'q-checkbox__icon-container absolute-full flex flex-center no-wrap'
}, [
h(QIcon, {
class: 'q-checkbox__icon',
name: icon.value
})
])
]
: [ bgNode ]
)
}
return useCheckbox('checkbox', getInner)
}
})

View file

@ -0,0 +1,24 @@
{
"mixins": [ "components/checkbox/use-checkbox" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/checkbox"
},
"props": {
"checked-icon": {
"desc": "The icon to be used when the model is truthy (instead of the default design)",
"addedIn": "v2.5"
},
"unchecked-icon": {
"desc": "The icon to be used when the toggle is falsy (instead of the default design)",
"addedIn": "v2.5"
},
"indeterminate-icon": {
"desc": "The icon to be used when the model is indeterminate (instead of the default design)",
"addedIn": "v2.5"
}
}
}

View file

@ -0,0 +1,122 @@
$checkbox-transition: .22s cubic-bezier(0,0,.2,1) 0ms
.q-checkbox
vertical-align: middle
&__native
width: 1px
height: 1px
&__bg,
&__icon-container
user-select: none
&__bg
top: 25%
left: 25%
width: 50%
height: 50%
border: 2px solid currentColor
border-radius: 2px
transition: background $checkbox-transition
-webkit-print-color-adjust: exact
&__icon
color: currentColor
font-size: .5em
&__svg
color: #fff
&__truthy
stroke: currentColor
stroke-width: 3.12px
stroke-dashoffset: 29.78334
stroke-dasharray: 29.78334
&__indet
fill: currentColor
transform-origin: 50% 50%
transform: rotate(-280deg) scale(0)
&__inner
font-size: $checkbox-inner-font-size
width: 1em
min-width: 1em
height: 1em
outline: 0
border-radius: 50%
color: rgba(0,0,0,.54)
&--truthy, &--indet
color: var(--q-primary)
.q-checkbox__bg
background: currentColor
&--truthy
path
stroke-dashoffset: 0
transition: stroke-dashoffset .18s cubic-bezier(.4,0,.6,1) 0ms
&--indet
.q-checkbox__indet
transform: rotate(0) scale(1)
transition: transform $checkbox-transition
&.disabled
opacity: .75 !important
&--dark
.q-checkbox__inner
color: rgba(255,255,255,.7)
&:before
opacity: .32 !important
&--truthy, &--indet
color: var(--q-primary)
&--dense
.q-checkbox__inner
width: .5em
min-width: .5em
height: .5em
.q-checkbox__bg
left: 5%
top: 5%
width: 90%
height: 90%
.q-checkbox__label
padding-left: .5em
&.reverse .q-checkbox__label
padding-left: 0
padding-right: .5em
body.desktop
.q-checkbox:not(.disabled)
.q-checkbox__inner:before
content: ''
position: absolute
top: 0
right: 0
bottom: 0
left: 0
border-radius: 50%
background: currentColor
opacity: .12
transform: scale3d(0, 0, 1)
transition: transform $option-focus-transition
&:focus,
&:hover
.q-checkbox__inner:before
transform: scale3d(1, 1, 1)
.q-checkbox--dense:not(.disabled)
&:focus,
&:hover
.q-checkbox__inner:before
transform: scale3d(1.4, 1.4, 1)

View file

@ -0,0 +1,5 @@
import QCheckbox from './QCheckbox.js'
export {
QCheckbox
}

View file

@ -0,0 +1,244 @@
import { h, ref, computed, getCurrentInstance, toRaw } from 'vue'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import useSize, { useSizeProps } from '../../composables/private.use-size/use-size.js'
import useRefocusTarget from '../../composables/private.use-refocus-target/use-refocus-target.js'
import { useFormInject, useFormProps } from '../../composables/use-form/private.use-form.js'
import optionSizes from '../../utils/private.option-sizes/option-sizes.js'
import { stopAndPrevent } from '../../utils/event/event.js'
import { hSlot, hMergeSlot } from '../../utils/private.render/render.js'
export const useCheckboxProps = {
...useDarkProps,
...useSizeProps,
...useFormProps,
modelValue: {
required: true,
default: null
},
val: {},
trueValue: { default: true },
falseValue: { default: false },
indeterminateValue: { default: null },
checkedIcon: String,
uncheckedIcon: String,
indeterminateIcon: String,
toggleOrder: {
type: String,
validator: v => v === 'tf' || v === 'ft'
},
toggleIndeterminate: Boolean,
label: String,
leftLabel: Boolean,
color: String,
keepColor: Boolean,
dense: Boolean,
disable: Boolean,
tabindex: [ String, Number ]
}
export const useCheckboxEmits = [ 'update:modelValue' ]
export default function (type, getInner) {
const { props, slots, emit, proxy } = getCurrentInstance()
const { $q } = proxy
const isDark = useDark(props, $q)
const rootRef = ref(null)
const { refocusTargetEl, refocusTarget } = useRefocusTarget(props, rootRef)
const sizeStyle = useSize(props, optionSizes)
const modelIsArray = computed(() =>
props.val !== void 0 && Array.isArray(props.modelValue)
)
const index = computed(() => {
const val = toRaw(props.val)
return modelIsArray.value === true
? props.modelValue.findIndex(opt => toRaw(opt) === val)
: -1
})
const isTrue = computed(() => (
modelIsArray.value === true
? index.value !== -1
: toRaw(props.modelValue) === toRaw(props.trueValue)
))
const isFalse = computed(() => (
modelIsArray.value === true
? index.value === -1
: toRaw(props.modelValue) === toRaw(props.falseValue)
))
const isIndeterminate = computed(() =>
isTrue.value === false && isFalse.value === false
)
const tabindex = computed(() => (
props.disable === true ? -1 : props.tabindex || 0
))
const classes = computed(() =>
`q-${ type } cursor-pointer no-outline row inline no-wrap items-center`
+ (props.disable === true ? ' disabled' : '')
+ (isDark.value === true ? ` q-${ type }--dark` : '')
+ (props.dense === true ? ` q-${ type }--dense` : '')
+ (props.leftLabel === true ? ' reverse' : '')
)
const innerClass = computed(() => {
const state = isTrue.value === true ? 'truthy' : (isFalse.value === true ? 'falsy' : 'indet')
const color = props.color !== void 0 && (
props.keepColor === true
|| (type === 'toggle' ? isTrue.value === true : isFalse.value !== true)
)
? ` text-${ props.color }`
: ''
return `q-${ type }__inner relative-position non-selectable q-${ type }__inner--${ state }${ color }`
})
const formAttrs = computed(() => {
const prop = { type: 'checkbox' }
props.name !== void 0 && Object.assign(prop, {
// see https://vuejs.org/guide/extras/render-function.html#creating-vnodes (.prop)
'.checked': isTrue.value,
'^checked': isTrue.value === true ? 'checked' : void 0,
name: props.name,
value: modelIsArray.value === true
? props.val
: props.trueValue
})
return prop
})
const injectFormInput = useFormInject(formAttrs)
const attributes = computed(() => {
const attrs = {
tabindex: tabindex.value,
role: type === 'toggle' ? 'switch' : 'checkbox',
'aria-label': props.label,
'aria-checked': isIndeterminate.value === true
? 'mixed'
: (isTrue.value === true ? 'true' : 'false')
}
if (props.disable === true) {
attrs[ 'aria-disabled' ] = 'true'
}
return attrs
})
function onClick (e) {
if (e !== void 0) {
stopAndPrevent(e)
refocusTarget(e)
}
if (props.disable !== true) {
emit('update:modelValue', getNextValue(), e)
}
}
function getNextValue () {
if (modelIsArray.value === true) {
if (isTrue.value === true) {
const val = props.modelValue.slice()
val.splice(index.value, 1)
return val
}
return props.modelValue.concat([ props.val ])
}
if (isTrue.value === true) {
if (props.toggleOrder !== 'ft' || props.toggleIndeterminate === false) {
return props.falseValue
}
}
else if (isFalse.value === true) {
if (props.toggleOrder === 'ft' || props.toggleIndeterminate === false) {
return props.trueValue
}
}
else {
return props.toggleOrder !== 'ft'
? props.trueValue
: props.falseValue
}
return props.indeterminateValue
}
function onKeydown (e) {
if (e.keyCode === 13 || e.keyCode === 32) {
stopAndPrevent(e)
}
}
function onKeyup (e) {
if (e.keyCode === 13 || e.keyCode === 32) {
onClick(e)
}
}
const getInnerContent = getInner(isTrue, isIndeterminate)
// expose public methods
Object.assign(proxy, { toggle: onClick })
return () => {
const inner = getInnerContent()
props.disable !== true && injectFormInput(
inner,
'unshift',
` q-${ type }__native absolute q-ma-none q-pa-none`
)
const child = [
h('div', {
class: innerClass.value,
style: sizeStyle.value,
'aria-hidden': 'true'
}, inner)
]
if (refocusTargetEl.value !== null) {
child.push(refocusTargetEl.value)
}
const label = props.label !== void 0
? hMergeSlot(slots.default, [ props.label ])
: hSlot(slots.default)
label !== void 0 && child.push(
h('div', {
class: `q-${ type }__label q-anchor--skip`
}, label)
)
return h('div', {
ref: rootRef,
class: classes.value,
...attributes.value,
onClick,
onKeydown,
onKeyup
}, child)
}
}

View file

@ -0,0 +1,144 @@
{
"mixins": [ "composables/private.use-size/use-size", "composables/use-form/private.use-form" ],
"props": {
"model-value": {
"extends": "model-value",
"type": [ "Any", "Array" ],
"default": "null",
"examples": [ "false", "[ 'car', 'building' ]" ]
},
"val": {
"type": "Any",
"desc": "Works when model ('value') is Array. It tells the component which value should add/remove when ticked/unticked",
"examples": [ "'car'" ],
"category": "model"
},
"true-value": {
"type": "Any",
"desc": "What model value should be considered as checked/ticked/on?",
"default": "true",
"examples": [ "'Agreed'" ],
"category": "model"
},
"false-value": {
"type": "Any",
"desc": "What model value should be considered as unchecked/unticked/off?",
"default": "false",
"examples": [ "'Disagree'" ],
"category": "model"
},
"indeterminate-value": {
"type": "Any",
"desc": "What model value should be considered as 'indeterminate'?",
"default": "null",
"examples": [ "0", "'not_answered'" ],
"category": "model"
},
"toggle-order": {
"type": "String",
"desc": "Determines toggle order of the two states ('t' stands for state of true, 'f' for state of false); If 'toggle-indeterminate' is true, then the order is: indet -> first state -> second state -> indet (and repeat), otherwise: indet -> first state -> second state -> first state -> second state -> ...",
"values": [ "'tf'", "'ft'" ],
"category": "behavior"
},
"toggle-indeterminate": {
"type": "Boolean",
"desc": "When user clicks/taps on the component, should we toggle through the indeterminate state too?",
"category": "behavior"
},
"label": {
"type": "String",
"desc": "Label to display along the component (or use the default slot instead of this prop)",
"examples": [ "'I agree with the Terms and Conditions'" ],
"category": "label"
},
"left-label": {
"type": "Boolean",
"desc": "Label (if any specified) should be displayed on the left side of the component",
"category": "label"
},
"checked-icon": {
"type": "String",
"examples": [ "'visibility'" ],
"category": "icons"
},
"unchecked-icon": {
"type": "String",
"examples": [ "'visibility_off'" ],
"category": "icons"
},
"indeterminate-icon": {
"type": "String",
"examples": [ "'help'" ],
"category": "icons"
},
"color": {
"extends": "color"
},
"keep-color": {
"type": "Boolean",
"desc": "Should the color (if specified any) be kept when the component is unticked/ off?",
"category": "behavior"
},
"dark": {
"extends": "dark"
},
"dense": {
"extends": "dense"
},
"disable": {
"extends": "disable"
},
"tabindex": {
"extends": "tabindex"
}
},
"events": {
"update:model-value": {
"desc": "Emitted when the component needs to change the model; Is also used by v-model",
"params": {
"value": {
"type": "Any",
"desc": "New model value",
"required": true
},
"evt": {
"extends": "evt",
"required": true
}
}
}
},
"methods": {
"toggle": {
"desc": "Toggle the state (of the model)",
"params": null,
"returns": null
}
},
"slots": {
"default": {
"desc": "Default slot can be used as label, unless 'label' prop is specified; Suggestion: string"
}
}
}

View file

@ -0,0 +1,209 @@
import { h, computed, getCurrentInstance } from 'vue'
import QIcon from '../icon/QIcon.js'
import Ripple from '../../directives/ripple/Ripple.js'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import useSize, { useSizeProps } from '../../composables/private.use-size/use-size.js'
import { createComponent } from '../../utils/private.create/create.js'
import { stopAndPrevent } from '../../utils/event/event.js'
import { hMergeSlotSafely, hDir } from '../../utils/private.render/render.js'
export const defaultSizes = {
xs: 8,
sm: 10,
md: 14,
lg: 20,
xl: 24
}
export default createComponent({
name: 'QChip',
props: {
...useDarkProps,
...useSizeProps,
dense: Boolean,
icon: String,
iconRight: String,
iconRemove: String,
iconSelected: String,
label: [ String, Number ],
color: String,
textColor: String,
modelValue: {
type: Boolean,
default: true
},
selected: {
type: Boolean,
default: null
},
square: Boolean,
outline: Boolean,
clickable: Boolean,
removable: Boolean,
removeAriaLabel: String,
tabindex: [ String, Number ],
disable: Boolean,
ripple: {
type: [ Boolean, Object ],
default: true
}
},
emits: [ 'update:modelValue', 'update:selected', 'remove', 'click' ],
setup (props, { slots, emit }) {
const { proxy: { $q } } = getCurrentInstance()
const isDark = useDark(props, $q)
const sizeStyle = useSize(props, defaultSizes)
const hasLeftIcon = computed(() => props.selected === true || props.icon !== void 0)
const leftIcon = computed(() => (
props.selected === true
? props.iconSelected || $q.iconSet.chip.selected
: props.icon
))
const removeIcon = computed(() => props.iconRemove || $q.iconSet.chip.remove)
const isClickable = computed(() =>
props.disable === false
&& (props.clickable === true || props.selected !== null)
)
const classes = computed(() => {
const text = props.outline === true
? props.color || props.textColor
: props.textColor
return 'q-chip row inline no-wrap items-center'
+ (props.outline === false && props.color !== void 0 ? ` bg-${ props.color }` : '')
+ (text ? ` text-${ text } q-chip--colored` : '')
+ (props.disable === true ? ' disabled' : '')
+ (props.dense === true ? ' q-chip--dense' : '')
+ (props.outline === true ? ' q-chip--outline' : '')
+ (props.selected === true ? ' q-chip--selected' : '')
+ (isClickable.value === true ? ' q-chip--clickable cursor-pointer non-selectable q-hoverable' : '')
+ (props.square === true ? ' q-chip--square' : '')
+ (isDark.value === true ? ' q-chip--dark q-dark' : '')
})
const attributes = computed(() => {
const chip = props.disable === true
? { tabindex: -1, 'aria-disabled': 'true' }
: { tabindex: props.tabindex || 0 }
const remove = {
...chip,
role: 'button',
'aria-hidden': 'false',
'aria-label': props.removeAriaLabel || $q.lang.label.remove
}
return { chip, remove }
})
function onKeyup (e) {
e.keyCode === 13 /* ENTER */ && onClick(e)
}
function onClick (e) {
if (!props.disable) {
emit('update:selected', !props.selected)
emit('click', e)
}
}
function onRemove (e) {
if (e.keyCode === void 0 || e.keyCode === 13) {
stopAndPrevent(e)
if (props.disable === false) {
emit('update:modelValue', false)
emit('remove')
}
}
}
function getContent () {
const child = []
isClickable.value === true && child.push(
h('div', { class: 'q-focus-helper' })
)
hasLeftIcon.value === true && child.push(
h(QIcon, {
class: 'q-chip__icon q-chip__icon--left',
name: leftIcon.value
})
)
const label = props.label !== void 0
? [ h('div', { class: 'ellipsis' }, [ props.label ]) ]
: void 0
child.push(
h('div', {
class: 'q-chip__content col row no-wrap items-center q-anchor--skip'
}, hMergeSlotSafely(slots.default, label))
)
props.iconRight && child.push(
h(QIcon, {
class: 'q-chip__icon q-chip__icon--right',
name: props.iconRight
})
)
props.removable === true && child.push(
h(QIcon, {
class: 'q-chip__icon q-chip__icon--remove cursor-pointer',
name: removeIcon.value,
...attributes.value.remove,
onClick: onRemove,
onKeyup: onRemove
})
)
return child
}
return () => {
if (props.modelValue === false) return
const data = {
class: classes.value,
style: sizeStyle.value
}
isClickable.value === true && Object.assign(
data,
attributes.value.chip,
{ onClick, onKeyup }
)
return hDir(
'div',
data,
getContent(),
'ripple',
props.ripple !== false && props.disable !== true,
() => [ [ Ripple, props.ripple ] ]
)
}
}
})

View file

@ -0,0 +1,154 @@
{
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/chip"
},
"props": {
"dense": {
"extends": "dense"
},
"size": {
"type": "String",
"desc": "QChip size name or a CSS unit including unit name",
"examples": [ "'xs'", "'sm'", "'md'", "'lg'", "'xl'", "'25px'", "'2rem'" ],
"category": "style"
},
"dark": {
"extends": "dark"
},
"icon": {
"extends": "icon"
},
"icon-right": {
"extends": "icon"
},
"icon-remove": {
"extends": "icon"
},
"icon-selected": {
"extends": "icon"
},
"label": {
"type": [ "String", "Number" ],
"desc": "Chip's content as string; overrides default slot if specified",
"examples": [ "'John Doe'", "'Book'" ],
"category": "content"
},
"color": {
"extends": "color"
},
"text-color": {
"extends": "text-color"
},
"model-value": {
"extends": "model-value",
"type": "Boolean",
"desc": "Model of the component determining if QChip should be rendered or not",
"required": false,
"default": "true"
},
"selected": {
"type": [ "Boolean", "null" ],
"default": "null",
"sync": true,
"desc": "Model for QChip if it's selected or not",
"examples": [ "# v-model:selected=\"myState\"" ],
"category": "model"
},
"square": {
"extends": "square",
"desc": "Sets a low value for border-radius instead of the default one, making it close to a square"
},
"outline": {
"type": "Boolean",
"desc": "Display using the 'outline' design",
"category": "style"
},
"clickable": {
"type": "Boolean",
"desc": "Is QChip clickable? If it's the case, then it will add hover effects and emit 'click' events",
"category": "state"
},
"removable": {
"type": "Boolean",
"desc": "If set, then it displays a 'remove' icon that when clicked the QChip emits 'remove' event",
"category": "state"
},
"ripple": {
"extends": "ripple"
},
"remove-aria-label": {
"type": "String",
"desc": "aria-label to be used on the remove icon",
"examples": [ "'Remove item'" ],
"category": "accessibility",
"addedIn": "v2.8.4"
},
"tabindex": {
"extends": "tabindex"
},
"disable": {
"extends": "disable"
}
},
"slots": {
"default": {
"desc": "This is where QChip content goes, if not using 'label' property"
}
},
"events": {
"click": {
"desc": "Emitted on QChip click if 'clickable' property is set",
"params": {
"evt": {
"extends": "evt"
}
}
},
"update:model-value": {
"extends": "update:model-value"
},
"update:selected": {
"desc": "Used by Vue on 'v-model:selected' for updating its value",
"params": {
"state": {
"type": "Boolean",
"desc": "Selected state"
}
}
},
"remove": {
"desc": "Works along with 'value' and 'removable' prop. Emitted when toggling rendering state of the QChip",
"params": {
"state": {
"type": "Boolean",
"desc": "Render state (render or not)"
}
}
}
}
}

View file

@ -0,0 +1,89 @@
.q-chip
vertical-align: middle
border-radius: 16px
outline: 0
position: relative
height: $chip-height
max-width: 100%
margin: 4px
background: #e0e0e0
color: rgba(0,0,0,.87)
font-size: $chip-font-size
padding: .5em .9em
&--colored, &--dark
.q-chip__icon
color: inherit
.q-avatar
font-size: $chip-avatar-font-size
margin-left: -.45em
margin-right: .2em
border-radius: 16px
&--outline
background: transparent !important
border: 1px solid currentColor
.q-avatar
// half padding and border width:
margin-left: calc(-.45em - 1px)
&--selected
.q-avatar
display: none
&__icon
color: rgba(0,0,0,.54)
font-size: 1.5em
margin: -.2em
&--left
margin-right: .2em
&--right
margin-left: .2em
&--remove
margin-left: .1em
margin-right: -.5em
opacity: .6
outline: 0
&:hover,
&:focus
opacity: 1
&__content
white-space: nowrap
&--dense
border-radius: 12px
padding: 0 .4em
height: $chip-dense-height
.q-avatar
font-size: $chip-dense-avatar-font-size
margin-left: -.27em
margin-right: .1em
border-radius: 12px
.q-chip__icon
font-size: 1.25em
&--left
margin-right: .195em
&--remove
margin-right: -.25em
&--square
border-radius: $generic-border-radius
.q-avatar
border-radius: ($generic-border-radius - 1px) 0 0 ($generic-border-radius - 1px)
body.desktop
.q-chip--clickable:focus
box-shadow: $shadow-1
&.body--dark
.q-chip--clickable:focus
box-shadow: $dark-shadow-1

View file

@ -0,0 +1,794 @@
import { mount, flushPromises } from '@vue/test-utils'
import { describe, test, expect } from 'vitest'
import QChip, { defaultSizes } from './QChip.js'
describe('[QChip API]', () => {
describe('[Props]', () => {
describe('[(prop)dense]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--dense')
await wrapper.setProps({ dense: true })
await flushPromises()
expect(
target.classes()
).toContain('q-chip--dense')
})
})
describe('[(prop)size]', () => {
test('type String has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.$style('font-size')
).not.toBe('100px')
await wrapper.setProps({ size: '100px' })
await flushPromises()
expect(
target.$style('font-size')
).toBe('100px')
await wrapper.setProps({ size: 'sm' })
expect(
target.$style('font-size')
).toBe(`${ defaultSizes.sm }px`)
})
})
describe('[(prop)dark]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--dark')
await wrapper.setProps({ dark: true })
await flushPromises()
expect(
target.classes()
).toContain('q-chip--dark')
})
test('type null has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--dense')
await wrapper.setProps({ dark: null })
await flushPromises()
expect(
target.classes()
).not.toContain('q-chip--dense')
})
})
describe('[(prop)icon]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QChip)
expect(
wrapper.find('.q-icon').exists()
).toBe(false)
await wrapper.setProps({ icon: propVal })
await flushPromises()
expect(
wrapper.get('.q-icon').text()
).toContain(propVal)
})
})
describe('[(prop)icon-right]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QChip)
expect(
wrapper.find('.q-icon').exists()
).toBe(false)
await wrapper.setProps({ iconRight: propVal })
await flushPromises()
expect(
wrapper.get('.q-icon').text()
).toContain(propVal)
})
})
describe('[(prop)icon-remove]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QChip)
expect(
wrapper.find('.q-icon').exists()
).toBe(false)
await wrapper.setProps({
removable: true,
iconRemove: propVal
})
await flushPromises()
expect(
wrapper.get('.q-icon.q-chip__icon--remove')
.text()
).toBe(propVal)
})
})
describe('[(prop)icon-selected]', () => {
test('type String has effect', async () => {
const propVal = 'map'
const wrapper = mount(QChip)
expect(
wrapper.find('.q-icon').exists()
).toBe(false)
await wrapper.setProps({
selected: true,
iconSelected: propVal
})
await flushPromises()
expect(
wrapper.get('.q-chip.q-chip--selected')
.get('.q-icon')
.text()
).toBe(propVal)
})
})
describe('[(prop)label]', () => {
test.each([
[ 'String', 'John Doe' ],
[ 'Number', 22 ]
])('type %s has effect', async (_, propVal) => {
const wrapper = mount(QChip)
expect(
wrapper.get('.q-chip__content')
.text()
).not.toBe('' + propVal)
await wrapper.setProps({ label: propVal })
await flushPromises()
expect(
wrapper.get('.q-chip__content')
.text()
).toBe('' + propVal)
})
})
describe('[(prop)color]', () => {
test('with default design', async () => {
const propVal = 'red'
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
await wrapper.setProps({ color: propVal })
await flushPromises()
cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).toContain('bg-red')
})
test('with outline design', async () => {
const propVal = 'red'
const wrapper = mount(QChip, {
props: {
outline: true
}
})
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
await wrapper.setProps({ color: propVal })
await flushPromises()
cls = target.classes()
expect(cls).toContain('text-red')
expect(cls).not.toContain('bg-red')
})
})
describe('[(prop)text-color]', () => {
test('with default design', async () => {
const propVal = 'red'
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).not.toContain('q-chip--colored')
await wrapper.setProps({ textColor: propVal })
await flushPromises()
cls = target.classes()
expect(cls).toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).toContain('q-chip--colored')
})
test('with default design + color', async () => {
const propVal = 'red'
const wrapper = mount(QChip, {
props: {
color: 'blue'
}
})
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-blue')
expect(cls).toContain('bg-blue')
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).not.toContain('q-chip--colored')
await wrapper.setProps({ textColor: propVal })
await flushPromises()
cls = target.classes()
expect(cls).not.toContain('text-blue')
expect(cls).toContain('bg-blue')
expect(cls).toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).toContain('q-chip--colored')
})
test('with outline design', async () => {
const propVal = 'red'
const wrapper = mount(QChip, {
props: {
outline: true
}
})
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).not.toContain('q-chip--colored')
await wrapper.setProps({ textColor: propVal })
await flushPromises()
cls = target.classes()
expect(cls).toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).toContain('q-chip--colored')
})
test('with outline design + color', async () => {
const propVal = 'red'
const wrapper = mount(QChip, {
props: {
color: 'blue',
outline: true
}
})
const target = wrapper.get('.q-chip')
let cls = target.classes()
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).toContain('text-blue')
expect(cls).not.toContain('bg-blue')
expect(cls).toContain('q-chip--colored')
await wrapper.setProps({ textColor: propVal })
await flushPromises()
cls = target.classes()
expect(cls).toContain('text-blue')
expect(cls).not.toContain('bg-blue')
expect(cls).not.toContain('text-red')
expect(cls).not.toContain('bg-red')
expect(cls).toContain('q-chip--colored')
})
})
describe('[(prop)model-value]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip, {
props: {
modelValue: true
}
})
expect(
wrapper.find('.q-chip').exists()
).toBe(true)
await wrapper.setProps({ modelValue: false })
await flushPromises()
expect(
wrapper.find('.q-chip')
.exists()
).toBe(false)
})
})
describe('[(prop)selected]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--selected')
await wrapper.setProps({
selected: true,
'onUpdate:selected': val => { wrapper.setProps({ selected: val }) }
})
await flushPromises()
expect(
target.classes()
).toContain('q-chip--selected')
})
test('type null has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--selected')
await wrapper.setProps({
selected: null,
'onUpdate:selected': val => { wrapper.setProps({ selected: val }) }
})
await flushPromises()
expect(
target.classes()
).not.toContain('q-chip--selected')
})
})
describe('[(prop)square]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--square')
await wrapper.setProps({ square: true })
await flushPromises()
expect(
target.classes()
).toContain('q-chip--square')
expect(
target.$computedStyle('border-radius')
).toBe('4px')
})
})
describe('[(prop)outline]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.classes()
).not.toContain('q-chip--outline')
await wrapper.setProps({ outline: true })
await flushPromises()
expect(
target.classes()
).toContain('q-chip--outline')
})
})
describe('[(prop)clickable]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
const target = wrapper.get('.q-chip')
expect(
target.attributes('tabindex')
).toBeUndefined()
await wrapper.setProps({ clickable: true })
await flushPromises()
expect(
target.attributes('tabindex')
).toBe('0')
expect(
target.$computedStyle('cursor')
).toBe('pointer')
})
})
describe('[(prop)removable]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
expect(
wrapper.find('.q-icon.q-chip__icon--remove')
.exists()
).not.toBe(true)
await wrapper.setProps({ removable: true })
await flushPromises()
expect(
wrapper.find('.q-icon.q-chip__icon--remove')
.exists()
).toBe(true)
})
})
describe('[(prop)ripple]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
expect(
wrapper.find('.q-ripple')
.exists()
).toBe(false)
await wrapper.setProps({ ripple: true })
await flushPromises()
await wrapper.trigger('click')
expect(
wrapper.find('.q-ripple')
.exists()
).toBe(true)
})
test('type Object has effect', async () => {
const propVal = { center: true, color: 'teal', keyCodes: [] }
const wrapper = mount(QChip)
expect(
wrapper.find('.q-ripple')
.exists()
).toBe(false)
await wrapper.setProps({ ripple: propVal })
await flushPromises()
await wrapper.trigger('click')
expect(
wrapper.find('.q-ripple')
.exists()
).toBe(true)
})
})
describe('[(prop)remove-aria-label]', () => {
test('type String has effect', async () => {
const propVal = 'Remove item'
const wrapper = mount(QChip, {
props: {
removable: true
}
})
const removeIcon = wrapper.get('.q-chip__icon--remove')
expect(
removeIcon.attributes('aria-label')
).not.toBe(propVal)
await wrapper.setProps({ removeAriaLabel: propVal })
await flushPromises()
expect(
removeIcon.attributes('aria-label')
).toBe(propVal)
expect(
removeIcon.attributes('tabindex')
).toBe('0')
})
})
describe('[(prop)tabindex]', () => {
test.each([
[ 'Number', 100 ],
[ 'String', '100' ]
])('type %s has effect', async (_, propVal) => {
const wrapper = mount(QChip, {
props: {
clickable: true,
tabindex: propVal
}
})
expect(
wrapper.attributes('tabindex')
).toBe('' + propVal)
// we'll test clickable + disable
await wrapper.setProps({ disable: true })
await flushPromises()
expect(
wrapper.attributes('tabindex')
).toBeUndefined()
expect(
wrapper.attributes('aria-disabled')
).toBeUndefined()
// we'll now test removable + disable
await wrapper.setProps({
clickable: false,
removable: true
})
await flushPromises()
let removeIcon = wrapper.get('.q-chip__icon--remove')
expect(
removeIcon.attributes('tabindex')
).toBe('-1')
expect(
removeIcon.attributes('aria-disabled')
).toBe('true')
// we'll now test removable
await wrapper.setProps({ disable: false })
await flushPromises()
removeIcon = wrapper.get('.q-chip__icon--remove')
expect(
removeIcon.attributes('tabindex')
).toBe('' + propVal)
expect(
removeIcon.attributes('aria-disabled')
).toBeUndefined()
})
})
describe('[(prop)disable]', () => {
test('type Boolean has effect', async () => {
const wrapper = mount(QChip)
expect(
wrapper.get('.q-chip').classes()
).not.toContain('disabled')
await wrapper.setProps({ disable: true })
await flushPromises()
expect(
wrapper.get('.q-chip').classes()
).toContain('disabled')
})
})
})
describe('[Slots]', () => {
describe('[(slot)default]', () => {
test('renders the content', () => {
const slotContent = 'some-slot-content'
const wrapper = mount(QChip, {
slots: {
default: () => slotContent
}
})
expect(wrapper.text()).toContain(slotContent)
})
})
})
describe('[Events]', () => {
describe('[(event)click]', () => {
test('is emitting when clickable', async () => {
const wrapper = mount(QChip, {
props: {
clickable: true
}
})
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('click')
expect(eventList.click).toHaveLength(1)
const [ evt ] = eventList.click[ 0 ]
expect(evt).toBeInstanceOf(Event)
})
test('is emitting when selected', async () => {
const wrapper = mount(QChip, {
props: {
selected: true
}
})
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('click')
expect(eventList.click).toHaveLength(1)
const [ evt ] = eventList.click[ 0 ]
expect(evt).toBeInstanceOf(Event)
})
test('is NOT emitting when not clickable or removable', async () => {
const wrapper = mount(QChip)
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).not.toHaveProperty('click')
})
test('is NOT emitting when disable + clickable', async () => {
const wrapper = mount(QChip, {
props: {
clickable: true,
disable: true
}
})
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).not.toHaveProperty('click')
})
test('is NOT emitting when disable + selected', async () => {
const wrapper = mount(QChip, {
props: {
selected: true,
disable: true
}
})
await wrapper.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).not.toHaveProperty('click')
})
})
describe('[(event)update:selected]', () => {
test('is emitting', async () => {
const wrapper = mount(QChip, {
props: {
selected: false,
'onUpdate:selected': val => {
wrapper.setProps({ selected: val })
}
}
})
await wrapper.trigger('click')
await flushPromises()
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('update:selected')
expect(eventList[ 'update:selected' ]).toHaveLength(1)
const [ state ] = eventList[ 'update:selected' ][ 0 ]
expect(state).toBeTypeOf('boolean')
})
test('is NOT emitting when disable', async () => {
const wrapper = mount(QChip, {
props: {
disable: true,
selected: false,
'onUpdate:selected': val => {
wrapper.setProps({ selected: val })
}
}
})
await wrapper.trigger('click')
await flushPromises()
const eventList = wrapper.emitted()
expect(eventList).not.toHaveProperty('update:selected')
})
})
describe('[(event)remove]', () => {
test('is emitting', async () => {
const wrapper = mount(QChip, {
props: {
removable: true
}
})
await wrapper.get('.q-chip__icon--remove')
.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('remove')
expect(eventList.remove).toHaveLength(1)
expect(eventList.remove[ 0 ]).toHaveLength(0)
})
})
describe('[(event)update:model-value]', () => {
test('is emitting', async () => {
const wrapper = mount(QChip, {
props: {
removable: true,
modelValue: true,
'onUpdate:modelValue': val => {
wrapper.setProps({ modelValue: val })
}
}
})
await wrapper.get('.q-chip__icon--remove')
.trigger('click')
const eventList = wrapper.emitted()
expect(eventList).toHaveProperty('update:modelValue')
expect(eventList[ 'update:modelValue' ]).toHaveLength(1)
const [ value ] = eventList[ 'update:modelValue' ][ 0 ]
expect(value).toBe(false)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QChip from './QChip.js'
export {
QChip
}

View file

@ -0,0 +1,148 @@
import { h, computed, getCurrentInstance } from 'vue'
import useSize from '../../composables/private.use-size/use-size.js'
import { useCircularCommonProps } from './circular-progress.js'
import { createComponent } from '../../utils/private.create/create.js'
import { hMergeSlotSafely } from '../../utils/private.render/render.js'
import { between } from '../../utils/format/format.js'
const
radius = 50,
diameter = 2 * radius,
circumference = diameter * Math.PI,
strokeDashArray = Math.round(circumference * 1000) / 1000
export default createComponent({
name: 'QCircularProgress',
props: {
...useCircularCommonProps,
value: {
type: Number,
default: 0
},
animationSpeed: {
type: [ String, Number ],
default: 600
},
indeterminate: Boolean
},
setup (props, { slots }) {
const { proxy: { $q } } = getCurrentInstance()
const sizeStyle = useSize(props)
const svgStyle = computed(() => {
const angle = ($q.lang.rtl === true ? -1 : 1) * props.angle
return {
transform: props.reverse !== ($q.lang.rtl === true)
? `scale3d(-1, 1, 1) rotate3d(0, 0, 1, ${ -90 - angle }deg)`
: `rotate3d(0, 0, 1, ${ angle - 90 }deg)`
}
})
const circleStyle = computed(() => (
props.instantFeedback !== true && props.indeterminate !== true
? { transition: `stroke-dashoffset ${ props.animationSpeed }ms ease 0s, stroke ${ props.animationSpeed }ms ease` }
: ''
))
const viewBox = computed(() => diameter / (1 - props.thickness / 2))
const viewBoxAttr = computed(() =>
`${ viewBox.value / 2 } ${ viewBox.value / 2 } ${ viewBox.value } ${ viewBox.value }`
)
const normalized = computed(() => between(props.value, props.min, props.max))
const range = computed(() => props.max - props.min)
const strokeWidth = computed(() => props.thickness / 2 * viewBox.value)
const strokeDashOffset = computed(() => {
const dashRatio = (props.max - normalized.value) / range.value
const dashGap = props.rounded === true && normalized.value < props.max && dashRatio < 0.25
? strokeWidth.value / 2 * (1 - dashRatio / 0.25)
: 0
return circumference * dashRatio + dashGap
})
function getCircle ({ thickness, offset, color, cls, rounded }) {
return h('circle', {
class: 'q-circular-progress__' + cls + (color !== void 0 ? ` text-${ color }` : ''),
style: circleStyle.value,
fill: 'transparent',
stroke: 'currentColor',
'stroke-width': thickness,
'stroke-dasharray': strokeDashArray,
'stroke-dashoffset': offset,
'stroke-linecap': rounded,
cx: viewBox.value,
cy: viewBox.value,
r: radius
})
}
return () => {
const svgChild = []
props.centerColor !== void 0 && props.centerColor !== 'transparent' && svgChild.push(
h('circle', {
class: `q-circular-progress__center text-${ props.centerColor }`,
fill: 'currentColor',
r: radius - strokeWidth.value / 2,
cx: viewBox.value,
cy: viewBox.value
})
)
props.trackColor !== void 0 && props.trackColor !== 'transparent' && svgChild.push(
getCircle({
cls: 'track',
thickness: strokeWidth.value,
offset: 0,
color: props.trackColor
})
)
svgChild.push(
getCircle({
cls: 'circle',
thickness: strokeWidth.value,
offset: strokeDashOffset.value,
color: props.color,
rounded: props.rounded === true ? 'round' : void 0
})
)
const child = [
h('svg', {
class: 'q-circular-progress__svg',
style: svgStyle.value,
viewBox: viewBoxAttr.value,
'aria-hidden': 'true'
}, svgChild)
]
props.showValue === true && child.push(
h('div', {
class: 'q-circular-progress__text absolute-full row flex-center content-center',
style: { fontSize: props.fontSize }
}, slots.default !== void 0 ? slots.default() : [ h('div', normalized.value) ])
)
return h('div', {
class: `q-circular-progress q-circular-progress--${ props.indeterminate === true ? 'in' : '' }determinate`,
style: sizeStyle.value,
role: 'progressbar',
'aria-valuemin': props.min,
'aria-valuemax': props.max,
'aria-valuenow': props.indeterminate === true ? void 0 : normalized.value
}, hMergeSlotSafely(slots.internal, child)) // "internal" is used by QKnob
}
}
})

View file

@ -0,0 +1,114 @@
{
"mixins": [ "composables/private.use-size/use-size" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/circular-progress"
},
"props": {
"value": {
"type": "Number",
"default": "0",
"desc": "Current progress (must be between min/max)",
"category": "model"
},
"min": {
"type": "Number",
"default": "0",
"desc": "Minimum value defining 'no progress' (must be lower than 'max')",
"category": "model"
},
"max": {
"type": "Number",
"default": "100",
"desc": "Maximum value defining 100% progress made (must be higher than 'min')",
"category": "model"
},
"color": {
"extends": "color",
"desc": "Color name for the arc progress from the Quasar Color Palette"
},
"center-color": {
"extends": "color",
"desc": "Color name for the center part of the component from the Quasar Color Palette"
},
"track-color": {
"extends": "color",
"desc": "Color name for the track of the component from the Quasar Color Palette"
},
"font-size": {
"type": "String",
"desc": "Size of text in CSS units, including unit name. Suggestion: use 'em' units to sync with component size",
"examples": [ "'1em'", "'16px'", "'2rem'" ],
"category": "style"
},
"rounded": {
"type": "Boolean",
"desc": "Rounding the arc of progress",
"category": "style",
"addedIn": "v2.8.4"
},
"thickness": {
"type": "Number",
"default": "0.2",
"desc": "Thickness of progress arc as a ratio (0.0 < x < 1.0) of component size",
"category": "style"
},
"angle": {
"type": "Number",
"desc": "Angle to rotate progress arc by",
"default": "0",
"category": "content"
},
"indeterminate": {
"type": "Boolean",
"desc": "Put component into 'indeterminate' state; Ignores 'value' prop",
"category": "behavior"
},
"show-value": {
"type": "Boolean",
"desc": "Enables the default slot and uses it (if available), otherwise it displays the 'value' prop as text; Make sure the text has enough space to be displayed inside the component",
"category": "content|behavior"
},
"reverse": {
"type": "Boolean",
"desc": "Reverses the direction of progress; Only for determined state",
"category": "behavior"
},
"instant-feedback": {
"type": "Boolean",
"desc": "No animation when model changes",
"category": "behavior"
},
"animation-speed": {
"extends": "animation-speed",
"default": "600",
"addedIn": "v2.3"
}
},
"slots": {
"default": {
"desc": "Used for component content only if 'show-value' prop is set; Make sure the content has enough space to be displayed inside the component"
},
"internal": {
"desc": "Used by QKnob internally",
"internal": true
}
}
}

View file

@ -0,0 +1,38 @@
.q-circular-progress
display: inline-block
position: relative
vertical-align: middle
width: 1em
height: 1em
line-height: 1
&.q-focusable
border-radius: 50%
&__svg
width: 100%
height: 100%
&__text
font-size: .25em
&--indeterminate
.q-circular-progress__svg
transform-origin: 50% 50%
animation: q-spin 2s linear infinite #{"/* rtl:ignore */"}
.q-circular-progress__circle
stroke-dasharray: 1 400
stroke-dashoffset: 0
animation: q-circular-progress-circle 1.5s ease-in-out infinite #{"/* rtl:ignore */"}
@keyframes q-circular-progress-circle
0%
stroke-dasharray: 1, 400
stroke-dashoffset: 0
50%
stroke-dasharray: 400, 400
stroke-dashoffset: -100
100%
stroke-dasharray: 400, 400
stroke-dashoffset: -300

View file

@ -0,0 +1,39 @@
import { useSizeProps } from '../../composables/private.use-size/use-size.js'
// also used by QKnob
export const useCircularCommonProps = {
...useSizeProps,
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
color: String,
centerColor: String,
trackColor: String,
fontSize: String,
rounded: Boolean,
// ratio
thickness: {
type: Number,
default: 0.2,
validator: v => v >= 0 && v <= 1
},
angle: {
type: Number,
default: 0
},
showValue: Boolean,
reverse: Boolean,
instantFeedback: Boolean
}

View file

@ -0,0 +1,14 @@
import { describe, test, expect } from 'vitest'
import { useCircularCommonProps } from './circular-progress.js'
describe('[circularProgress API]', () => {
describe('[Variables]', () => {
describe('[(variable)useCircularCommonProps]', () => {
test('is defined correctly', () => {
expect(useCircularCommonProps).toBeTypeOf('object')
expect(Object.keys(useCircularCommonProps)).not.toHaveLength(0)
})
})
})
})

View file

@ -0,0 +1,5 @@
import QCircularProgress from './QCircularProgress.js'
export {
QCircularProgress
}

View file

@ -0,0 +1,851 @@
import { h, ref, computed, watch, nextTick, getCurrentInstance } from 'vue'
import TouchPan from '../../directives/touch-pan/TouchPan.js'
import QSlider from '../slider/QSlider.js'
import QIcon from '../icon/QIcon.js'
import QTabs from '../tabs/QTabs.js'
import QTab from '../tabs/QTab.js'
import QTabPanels from '../tab-panels/QTabPanels.js'
import QTabPanel from '../tab-panels/QTabPanel.js'
import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js'
import useRenderCache from '../../composables/use-render-cache/use-render-cache.js'
import { useFormInject, useFormProps } from '../../composables/use-form/private.use-form.js'
import { createComponent } from '../../utils/private.create/create.js'
import { testPattern } from '../../utils/patterns/patterns.js'
import throttle from '../../utils/throttle/throttle.js'
import { stop } from '../../utils/event/event.js'
import { hexToRgb, rgbToHex, rgbToString, textToRgb, rgbToHsv, hsvToRgb, luminosity } from '../../utils/colors/colors.js'
import { hDir } from '../../utils/private.render/render.js'
const palette = [
'rgb(255,204,204)', 'rgb(255,230,204)', 'rgb(255,255,204)', 'rgb(204,255,204)', 'rgb(204,255,230)', 'rgb(204,255,255)', 'rgb(204,230,255)', 'rgb(204,204,255)', 'rgb(230,204,255)', 'rgb(255,204,255)',
'rgb(255,153,153)', 'rgb(255,204,153)', 'rgb(255,255,153)', 'rgb(153,255,153)', 'rgb(153,255,204)', 'rgb(153,255,255)', 'rgb(153,204,255)', 'rgb(153,153,255)', 'rgb(204,153,255)', 'rgb(255,153,255)',
'rgb(255,102,102)', 'rgb(255,179,102)', 'rgb(255,255,102)', 'rgb(102,255,102)', 'rgb(102,255,179)', 'rgb(102,255,255)', 'rgb(102,179,255)', 'rgb(102,102,255)', 'rgb(179,102,255)', 'rgb(255,102,255)',
'rgb(255,51,51)', 'rgb(255,153,51)', 'rgb(255,255,51)', 'rgb(51,255,51)', 'rgb(51,255,153)', 'rgb(51,255,255)', 'rgb(51,153,255)', 'rgb(51,51,255)', 'rgb(153,51,255)', 'rgb(255,51,255)',
'rgb(255,0,0)', 'rgb(255,128,0)', 'rgb(255,255,0)', 'rgb(0,255,0)', 'rgb(0,255,128)', 'rgb(0,255,255)', 'rgb(0,128,255)', 'rgb(0,0,255)', 'rgb(128,0,255)', 'rgb(255,0,255)',
'rgb(245,0,0)', 'rgb(245,123,0)', 'rgb(245,245,0)', 'rgb(0,245,0)', 'rgb(0,245,123)', 'rgb(0,245,245)', 'rgb(0,123,245)', 'rgb(0,0,245)', 'rgb(123,0,245)', 'rgb(245,0,245)',
'rgb(214,0,0)', 'rgb(214,108,0)', 'rgb(214,214,0)', 'rgb(0,214,0)', 'rgb(0,214,108)', 'rgb(0,214,214)', 'rgb(0,108,214)', 'rgb(0,0,214)', 'rgb(108,0,214)', 'rgb(214,0,214)',
'rgb(163,0,0)', 'rgb(163,82,0)', 'rgb(163,163,0)', 'rgb(0,163,0)', 'rgb(0,163,82)', 'rgb(0,163,163)', 'rgb(0,82,163)', 'rgb(0,0,163)', 'rgb(82,0,163)', 'rgb(163,0,163)',
'rgb(92,0,0)', 'rgb(92,46,0)', 'rgb(92,92,0)', 'rgb(0,92,0)', 'rgb(0,92,46)', 'rgb(0,92,92)', 'rgb(0,46,92)', 'rgb(0,0,92)', 'rgb(46,0,92)', 'rgb(92,0,92)',
'rgb(255,255,255)', 'rgb(205,205,205)', 'rgb(178,178,178)', 'rgb(153,153,153)', 'rgb(127,127,127)', 'rgb(102,102,102)', 'rgb(76,76,76)', 'rgb(51,51,51)', 'rgb(25,25,25)', 'rgb(0,0,0)'
]
const thumbPath = 'M5 5 h10 v10 h-10 v-10 z'
const alphaTrackImg = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAH0lEQVQoU2NkYGAwZkAFZ5G5jPRRgOYEVDeB3EBjBQBOZwTVugIGyAAAAABJRU5ErkJggg=='
export default createComponent({
name: 'QColor',
props: {
...useDarkProps,
...useFormProps,
modelValue: String,
defaultValue: String,
defaultView: {
type: String,
default: 'spectrum',
validator: v => [ 'spectrum', 'tune', 'palette' ].includes(v)
},
formatModel: {
type: String,
default: 'auto',
validator: v => [ 'auto', 'hex', 'rgb', 'hexa', 'rgba' ].includes(v)
},
palette: Array,
noHeader: Boolean,
noHeaderTabs: Boolean,
noFooter: Boolean,
square: Boolean,
flat: Boolean,
bordered: Boolean,
disable: Boolean,
readonly: Boolean
},
emits: [ 'update:modelValue', 'change' ],
setup (props, { emit }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const isDark = useDark(props, $q)
const { getCache } = useRenderCache()
const spectrumRef = ref(null)
const errorIconRef = ref(null)
const forceHex = computed(() => (
props.formatModel === 'auto'
? null
: props.formatModel.indexOf('hex') !== -1
))
const forceAlpha = computed(() => (
props.formatModel === 'auto'
? null
: props.formatModel.indexOf('a') !== -1
))
const topView = ref(
props.formatModel === 'auto'
? (
(props.modelValue === void 0 || props.modelValue === null || props.modelValue === '' || props.modelValue.startsWith('#'))
? 'hex'
: 'rgb'
)
: (props.formatModel.startsWith('hex') ? 'hex' : 'rgb')
)
const view = ref(props.defaultView)
const model = ref(parseModel(props.modelValue || props.defaultValue))
const editable = computed(() => props.disable !== true && props.readonly !== true)
const isHex = computed(() =>
props.modelValue === void 0
|| props.modelValue === null
|| props.modelValue === ''
|| props.modelValue.startsWith('#')
)
const isOutputHex = computed(() => (
forceHex.value !== null
? forceHex.value
: isHex.value
))
const formAttrs = computed(() => ({
type: 'hidden',
name: props.name,
value: model.value[ isOutputHex.value === true ? 'hex' : 'rgb' ]
}))
const injectFormInput = useFormInject(formAttrs)
const hasAlpha = computed(() => (
forceAlpha.value !== null
? forceAlpha.value
: model.value.a !== void 0
))
const currentBgColor = computed(() => ({
backgroundColor: model.value.rgb || '#000'
}))
const headerClass = computed(() => {
const light = model.value.a !== void 0 && model.value.a < 65
? true
: luminosity(model.value) > 0.4
return 'q-color-picker__header-content'
+ ` q-color-picker__header-content--${ light ? 'light' : 'dark' }`
})
const spectrumStyle = computed(() => ({
background: `hsl(${ model.value.h },100%,50%)`
}))
const spectrumPointerStyle = computed(() => ({
top: `${ 100 - model.value.v }%`,
[ $q.lang.rtl === true ? 'right' : 'left' ]: `${ model.value.s }%`
}))
const computedPalette = computed(() => (
props.palette !== void 0 && props.palette.length !== 0
? props.palette
: palette
))
const classes = computed(() =>
'q-color-picker'
+ (props.bordered === true ? ' q-color-picker--bordered' : '')
+ (props.square === true ? ' q-color-picker--square no-border-radius' : '')
+ (props.flat === true ? ' q-color-picker--flat no-shadow' : '')
+ (props.disable === true ? ' disabled' : '')
+ (isDark.value === true ? ' q-color-picker--dark q-dark' : '')
)
const attributes = computed(() => (
props.disable === true
? { 'aria-disabled': 'true' }
: {}
))
const spectrumDirective = computed(() => {
// if editable.value === true
return [ [
TouchPan,
onSpectrumPan,
void 0,
{ prevent: true, stop: true, mouse: true }
] ]
})
watch(() => props.modelValue, v => {
const localModel = parseModel(v || props.defaultValue)
if (localModel.hex !== model.value.hex) {
model.value = localModel
}
})
watch(() => props.defaultValue, v => {
if (!props.modelValue && v) {
const localModel = parseModel(v)
if (localModel.hex !== model.value.hex) {
model.value = localModel
}
}
})
function updateModel (rgb, change) {
// update internally
model.value.hex = rgbToHex(rgb)
model.value.rgb = rgbToString(rgb)
model.value.r = rgb.r
model.value.g = rgb.g
model.value.b = rgb.b
model.value.a = rgb.a
const value = model.value[ isOutputHex.value === true ? 'hex' : 'rgb' ]
// emit new value
emit('update:modelValue', value)
change === true && emit('change', value)
}
function parseModel (v) {
const alpha = forceAlpha.value !== void 0
? forceAlpha.value
: (
props.formatModel === 'auto'
? null
: props.formatModel.indexOf('a') !== -1
)
if (typeof v !== 'string' || v.length === 0 || testPattern.anyColor(v.replace(/ /g, '')) !== true) {
return {
h: 0,
s: 0,
v: 0,
r: 0,
g: 0,
b: 0,
a: alpha === true ? 100 : void 0,
hex: void 0,
rgb: void 0
}
}
const model = textToRgb(v)
if (alpha === true && model.a === void 0) {
model.a = 100
}
model.hex = rgbToHex(model)
model.rgb = rgbToString(model)
return Object.assign(model, rgbToHsv(model))
}
function changeSpectrum (left, top, change) {
const panel = spectrumRef.value
if (panel === null) return
const
width = panel.clientWidth,
height = panel.clientHeight,
rect = panel.getBoundingClientRect()
let x = Math.min(width, Math.max(0, left - rect.left))
if ($q.lang.rtl === true) {
x = width - x
}
const
y = Math.min(height, Math.max(0, top - rect.top)),
s = Math.round(100 * x / width),
v = Math.round(100 * Math.max(0, Math.min(1, -(y / height) + 1))),
rgb = hsvToRgb({
h: model.value.h,
s,
v,
a: hasAlpha.value === true ? model.value.a : void 0
})
model.value.s = s
model.value.v = v
updateModel(rgb, change)
}
function onHue (val, change) {
const h = Math.round(val)
const rgb = hsvToRgb({
h,
s: model.value.s,
v: model.value.v,
a: hasAlpha.value === true ? model.value.a : void 0
})
model.value.h = h
updateModel(rgb, change)
}
function onHueChange (val) {
onHue(val, true)
}
function onNumericChange (value, formatModel, max, evt, change) {
evt !== void 0 && stop(evt)
if (!/^[0-9]+$/.test(value)) {
change === true && proxy.$forceUpdate()
return
}
const val = Math.floor(Number(value))
if (val < 0 || val > max) {
change === true && proxy.$forceUpdate()
return
}
const rgb = {
r: formatModel === 'r' ? val : model.value.r,
g: formatModel === 'g' ? val : model.value.g,
b: formatModel === 'b' ? val : model.value.b,
a: hasAlpha.value === true
? (formatModel === 'a' ? val : model.value.a)
: void 0
}
if (formatModel !== 'a') {
const hsv = rgbToHsv(rgb)
model.value.h = hsv.h
model.value.s = hsv.s
model.value.v = hsv.v
}
updateModel(rgb, change)
if (change !== true && evt?.target.selectionEnd !== void 0) {
const index = evt.target.selectionEnd
nextTick(() => {
evt.target.setSelectionRange(index, index)
})
}
}
function onEditorChange (evt, change) {
let rgb
const inp = evt.target.value
stop(evt)
if (topView.value === 'hex') {
if (
inp.length !== (hasAlpha.value === true ? 9 : 7)
|| !/^#[0-9A-Fa-f]+$/.test(inp)
) {
return true
}
rgb = hexToRgb(inp)
}
else {
let model
if (!inp.endsWith(')')) {
return true
}
else if (hasAlpha.value !== true && inp.startsWith('rgb(')) {
model = inp.substring(4, inp.length - 1).split(',').map(n => parseInt(n, 10))
if (
model.length !== 3
|| !/^rgb\([0-9]{1,3},[0-9]{1,3},[0-9]{1,3}\)$/.test(inp)
) {
return true
}
}
else if (hasAlpha.value === true && inp.startsWith('rgba(')) {
model = inp.substring(5, inp.length - 1).split(',')
if (
model.length !== 4
|| !/^rgba\([0-9]{1,3},[0-9]{1,3},[0-9]{1,3},(0|0\.[0-9]+[1-9]|0\.[1-9]+|1)\)$/.test(inp)
) {
return true
}
for (let i = 0; i < 3; i++) {
const v = parseInt(model[ i ], 10)
if (v < 0 || v > 255) {
return true
}
model[ i ] = v
}
const v = parseFloat(model[ 3 ])
if (v < 0 || v > 1) {
return true
}
model[ 3 ] = v
}
else {
return true
}
if (
model[ 0 ] < 0 || model[ 0 ] > 255
|| model[ 1 ] < 0 || model[ 1 ] > 255
|| model[ 2 ] < 0 || model[ 2 ] > 255
|| (hasAlpha.value === true && (model[ 3 ] < 0 || model[ 3 ] > 1))
) {
return true
}
rgb = {
r: model[ 0 ],
g: model[ 1 ],
b: model[ 2 ],
a: hasAlpha.value === true
? model[ 3 ] * 100
: void 0
}
}
const hsv = rgbToHsv(rgb)
model.value.h = hsv.h
model.value.s = hsv.s
model.value.v = hsv.v
updateModel(rgb, change)
if (change !== true) {
const index = evt.target.selectionEnd
nextTick(() => {
evt.target.setSelectionRange(index, index)
})
}
}
function onPalettePick (color) {
const def = parseModel(color)
const rgb = { r: def.r, g: def.g, b: def.b, a: def.a }
if (rgb.a === void 0) {
rgb.a = model.value.a
}
model.value.h = def.h
model.value.s = def.s
model.value.v = def.v
updateModel(rgb, true)
}
function onSpectrumPan (evt) {
if (evt.isFinal) {
changeSpectrum(
evt.position.left,
evt.position.top,
true
)
}
else {
onSpectrumChange(evt)
}
}
const onSpectrumChange = throttle(
evt => { changeSpectrum(evt.position.left, evt.position.top) },
20
)
function onSpectrumClick (evt) {
changeSpectrum(
evt.pageX - window.pageXOffset,
evt.pageY - window.pageYOffset,
true
)
}
function onActivate (evt) {
changeSpectrum(
evt.pageX - window.pageXOffset,
evt.pageY - window.pageYOffset
)
}
function updateErrorIcon (val) {
// we MUST avoid vue triggering a render,
// so manually changing this
if (errorIconRef.value !== null) {
errorIconRef.value.$el.style.opacity = val ? 1 : 0
}
}
function setTopView (val) {
topView.value = val
}
function getHeader () {
const child = []
props.noHeaderTabs !== true && child.push(
h(QTabs, {
class: 'q-color-picker__header-tabs',
modelValue: topView.value,
dense: true,
align: 'justify',
'onUpdate:modelValue': setTopView
}, () => [
h(QTab, {
label: 'HEX' + (hasAlpha.value === true ? 'A' : ''),
name: 'hex',
ripple: false
}),
h(QTab, {
label: 'RGB' + (hasAlpha.value === true ? 'A' : ''),
name: 'rgb',
ripple: false
})
])
)
child.push(
h('div', {
class: 'q-color-picker__header-banner row flex-center no-wrap'
}, [
h('input', {
class: 'fit',
value: model.value[ topView.value ],
...(editable.value !== true
? { readonly: true }
: {}
),
...getCache('topIn', {
onInput: evt => {
updateErrorIcon(onEditorChange(evt) === true)
},
onChange: stop,
onBlur: evt => {
onEditorChange(evt, true) === true && proxy.$forceUpdate()
updateErrorIcon(false)
}
})
}),
h(QIcon, {
ref: errorIconRef,
class: 'q-color-picker__error-icon absolute no-pointer-events',
name: $q.iconSet.type.negative
})
])
)
return h('div', {
class: 'q-color-picker__header relative-position overflow-hidden'
}, [
h('div', { class: 'q-color-picker__header-bg absolute-full' }),
h('div', {
class: headerClass.value,
style: currentBgColor.value
}, child)
])
}
function getContent () {
return h(QTabPanels, {
modelValue: view.value,
animated: true
}, () => [
h(QTabPanel, {
class: 'q-color-picker__spectrum-tab overflow-hidden',
name: 'spectrum'
}, getSpectrumTab),
h(QTabPanel, {
class: 'q-pa-md q-color-picker__tune-tab',
name: 'tune'
}, getTuneTab),
h(QTabPanel, {
class: 'q-color-picker__palette-tab',
name: 'palette'
}, getPaletteTab)
])
}
function setView (val) {
view.value = val
}
function getFooter () {
return h('div', {
class: 'q-color-picker__footer relative-position overflow-hidden'
}, [
h(QTabs, {
class: 'absolute-full',
modelValue: view.value,
dense: true,
align: 'justify',
'onUpdate:modelValue': setView
}, () => [
h(QTab, {
icon: $q.iconSet.colorPicker.spectrum,
name: 'spectrum',
ripple: false
}),
h(QTab, {
icon: $q.iconSet.colorPicker.tune,
name: 'tune',
ripple: false
}),
h(QTab, {
icon: $q.iconSet.colorPicker.palette,
name: 'palette',
ripple: false
})
])
])
}
function getSpectrumTab () {
const data = {
ref: spectrumRef,
class: 'q-color-picker__spectrum non-selectable relative-position cursor-pointer'
+ (editable.value !== true ? ' readonly' : ''),
style: spectrumStyle.value,
...(editable.value === true
? {
onClick: onSpectrumClick,
onMousedown: onActivate
}
: {}
)
}
const child = [
h('div', { style: { paddingBottom: '100%' } }),
h('div', { class: 'q-color-picker__spectrum-white absolute-full' }),
h('div', { class: 'q-color-picker__spectrum-black absolute-full' }),
h('div', {
class: 'absolute',
style: spectrumPointerStyle.value
}, [
model.value.hex !== void 0
? h('div', { class: 'q-color-picker__spectrum-circle' })
: null
])
]
const sliders = [
h(QSlider, {
class: 'q-color-picker__hue non-selectable',
modelValue: model.value.h,
min: 0,
max: 360,
trackSize: '8px',
innerTrackColor: 'transparent',
selectionColor: 'transparent',
readonly: editable.value !== true,
thumbPath,
'onUpdate:modelValue': onHue,
onChange: onHueChange
})
]
hasAlpha.value === true && sliders.push(
h(QSlider, {
class: 'q-color-picker__alpha non-selectable',
modelValue: model.value.a,
min: 0,
max: 100,
trackSize: '8px',
trackColor: 'white',
innerTrackColor: 'transparent',
selectionColor: 'transparent',
trackImg: alphaTrackImg,
readonly: editable.value !== true,
hideSelection: true,
thumbPath,
...getCache('alphaSlide', {
'onUpdate:modelValue': value => onNumericChange(value, 'a', 100),
onChange: value => onNumericChange(value, 'a', 100, void 0, true)
})
})
)
return [
hDir('div', data, child, 'spec', editable.value, () => spectrumDirective.value),
h('div', { class: 'q-color-picker__sliders' }, sliders)
]
}
function getTuneTab () {
return [
h('div', { class: 'row items-center no-wrap' }, [
h('div', 'R'),
h(QSlider, {
modelValue: model.value.r,
min: 0,
max: 255,
color: 'red',
dark: isDark.value,
readonly: editable.value !== true,
...getCache('rSlide', {
'onUpdate:modelValue': value => onNumericChange(value, 'r', 255),
onChange: value => onNumericChange(value, 'r', 255, void 0, true)
})
}),
h('input', {
value: model.value.r,
maxlength: 3,
readonly: editable.value !== true,
onChange: stop,
...getCache('rIn', {
onInput: evt => onNumericChange(evt.target.value, 'r', 255, evt),
onBlur: evt => onNumericChange(evt.target.value, 'r', 255, evt, true)
})
})
]),
h('div', { class: 'row items-center no-wrap' }, [
h('div', 'G'),
h(QSlider, {
modelValue: model.value.g,
min: 0,
max: 255,
color: 'green',
dark: isDark.value,
readonly: editable.value !== true,
...getCache('gSlide', {
'onUpdate:modelValue': value => onNumericChange(value, 'g', 255),
onChange: value => onNumericChange(value, 'g', 255, void 0, true)
})
}),
h('input', {
value: model.value.g,
maxlength: 3,
readonly: editable.value !== true,
onChange: stop,
...getCache('gIn', {
onInput: evt => onNumericChange(evt.target.value, 'g', 255, evt),
onBlur: evt => onNumericChange(evt.target.value, 'g', 255, evt, true)
})
})
]),
h('div', { class: 'row items-center no-wrap' }, [
h('div', 'B'),
h(QSlider, {
modelValue: model.value.b,
min: 0,
max: 255,
color: 'blue',
readonly: editable.value !== true,
dark: isDark.value,
...getCache('bSlide', {
'onUpdate:modelValue': value => onNumericChange(value, 'b', 255),
onChange: value => onNumericChange(value, 'b', 255, void 0, true)
})
}),
h('input', {
value: model.value.b,
maxlength: 3,
readonly: editable.value !== true,
onChange: stop,
...getCache('bIn', {
onInput: evt => onNumericChange(evt.target.value, 'b', 255, evt),
onBlur: evt => onNumericChange(evt.target.value, 'b', 255, evt, true)
})
})
]),
hasAlpha.value === true ? h('div', { class: 'row items-center no-wrap' }, [
h('div', 'A'),
h(QSlider, {
modelValue: model.value.a,
color: 'grey',
readonly: editable.value !== true,
dark: isDark.value,
...getCache('aSlide', {
'onUpdate:modelValue': value => onNumericChange(value, 'a', 100),
onChange: value => onNumericChange(value, 'a', 100, void 0, true)
})
}),
h('input', {
value: model.value.a,
maxlength: 3,
readonly: editable.value !== true,
onChange: stop,
...getCache('aIn', {
onInput: evt => onNumericChange(evt.target.value, 'a', 100, evt),
onBlur: evt => onNumericChange(evt.target.value, 'a', 100, evt, true)
})
})
]) : null
]
}
function getPaletteTab () {
const fn = color => h('div', {
class: 'q-color-picker__cube col-auto',
style: { backgroundColor: color },
...(
editable.value === true
? getCache('palette#' + color, {
onClick: () => { onPalettePick(color) }
})
: {}
)
})
return [
h('div', {
class: 'row items-center q-color-picker__palette-rows'
+ (editable.value === true ? ' q-color-picker__palette-rows--editable' : '')
}, computedPalette.value.map(fn))
]
}
return () => {
const child = [ getContent() ]
if (props.name !== void 0 && props.disable !== true) {
injectFormInput(child, 'push')
}
props.noHeader !== true && child.unshift(
getHeader()
)
props.noFooter !== true && child.push(
getFooter()
)
return h('div', {
class: classes.value,
...attributes.value
}, child)
}
}
})

View file

@ -0,0 +1,106 @@
{
"mixins": [ "composables/use-form/private.use-form" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/color-picker"
},
"props": {
"model-value": {
"extends": "model-value",
"type": [ "String", "null", "undefined" ],
"examples": [ "# v-model=\"myColor\"" ]
},
"default-value": {
"type": "String",
"desc": "The default value to show when the model doesn't have one",
"examples": [ "'#c0c0c0'" ],
"category": "model"
},
"default-view": {
"type": "String",
"desc": "The default view of the picker",
"default": "'spectrum'",
"values": [ "'spectrum'", "'tune'", "'palette'" ],
"category": "behavior"
},
"format-model": {
"type": "String",
"desc": "Forces a certain model format upon the model",
"default": "'auto'",
"values": [ "'auto'", "'hex'", "'rgb'", "'hexa'", "'rgba'" ],
"category": "model"
},
"palette": {
"type": "Array",
"desc": "Use a custom palette of colors for the palette tab",
"default": "# hard-coded palette",
"__runtimeDefault": true,
"examples": [ "[ '#019A9D', '#D9B801', 'rgb(23,120,0)', '#B2028A' ]" ],
"category": "content"
},
"square": {
"extends": "square"
},
"flat": {
"extends": "flat"
},
"bordered": {
"extends": "bordered"
},
"no-header": {
"type": "Boolean",
"desc": "Do not render header",
"category": "content"
},
"no-header-tabs": {
"type": "Boolean",
"desc": "Do not render header tabs (only the input)",
"category": "content",
"addedIn": "v2.2"
},
"no-footer": {
"type": "Boolean",
"desc": "Do not render footer; Useful when you want a specific view ('default-view' prop) and don't want the user to be able to switch it",
"category": "content"
},
"disable": {
"extends": "disable"
},
"readonly": {
"extends": "readonly"
},
"dark": {
"extends": "dark"
}
},
"events": {
"update:model-value": {
"extends": "update:model-value",
"params": {
"value": {
"type": [ "String", "null" ]
}
}
},
"change": {
"extends": "update:model-value",
"desc": "Emitted on lazy model value change (after user finishes selecting a color)"
}
}
}

View file

@ -0,0 +1,180 @@
.q-color-picker
overflow: hidden
background: #fff
max-width: 350px
vertical-align: top
min-width: 180px
border-radius: $generic-border-radius
box-shadow: $shadow-2
.q-tab
padding: 0 !important
&--bordered
border: 1px solid $separator-color
&__header
&-tabs
height: 32px
&-banner
height: 36px
input
line-height: 24px
border: 0
.q-tab
min-height: 32px !important
height: 32px !important
&--inactive
background: linear-gradient(to top, rgba(0,0,0,.3) 0%, rgba(0,0,0,.15) 25%, rgba(0,0,0,.1))
&__error-icon
bottom: 2px
right: 2px
font-size: 24px
opacity: 0
transition: opacity .3s ease-in
&__header-content
position: relative
background: #fff
&--light
color: #000
&--dark
color: #fff
.q-tab--inactive
&:before
content: ''
position: absolute
top: 0
right: 0
bottom: 0
left: 0
background: rgba(255,255,255,.2)
&__header-banner
height: 36px
&__header-bg
background: #fff
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAH0lEQVQoU2NkYGAwZkAFZ5G5jPRRgOYEVDeB3EBjBQBOZwTVugIGyAAAAABJRU5ErkJggg==') !important
&__footer
height: 36px
.q-tab
min-height: 36px !important
height: 36px !important
&--inactive
background: linear-gradient(to bottom, rgba(0,0,0,.3) 0%, rgba(0,0,0,.15) 25%, rgba(0,0,0,.1))
/* Saturation Tab */
&__spectrum
width: 100%
height: 100%
&__spectrum-tab
padding: 0 !important
&__spectrum-white
background: linear-gradient(to right, #fff, rgba(255,255,255,0))
&__spectrum-black
background: linear-gradient(to top, #000, rgba(0,0,0,0))
&__spectrum-circle
width: 10px
height: 10px
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0,0,0,.3), 0 0 1px 2px rgba(0,0,0,.4)
border-radius: 50%
transform: translate(-5px, -5px)
&__hue .q-slider__track
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%) !important
opacity: 1
&__alpha
.q-slider__track-container
padding-top: 0
.q-slider__track:before
content: ''
position: absolute
top: 0
right: 0
bottom: 0
left: 0
border-radius: inherit
background: linear-gradient(90deg, rgba(255,255,255,0), $grey-7)
&__sliders
padding: 0 16px
.q-slider__thumb
color: $grey-9
path
stroke-width: 2px
fill: transparent
.q-slider--active path
stroke-width: 3px
/* Tune Tab */
&__tune-tab
.q-slider
margin-left: 18px
margin-right: 18px
input
font-size: $color-picker-tune-tab-input-font-size
border: 1px solid $grey-4
border-radius: $generic-border-radius
width: 3.5em
/* Palette Tab */
&__palette-tab
padding: 0 !important
&__palette-rows
&--editable .q-color-picker__cube
cursor: pointer
&__cube
padding-bottom: 10%
width: 10% !important
/* Generic */
input
color: inherit
background: transparent
outline: 0
text-align: center
.q-tabs
overflow: hidden
.q-tab--active
box-shadow: 0 0 14px 3px rgba(0,0,0,.2)
.q-focus-helper
display: none
.q-tab__indicator
display: none
.q-tab-panels
background: inherit
&--dark
box-shadow: $dark-shadow-2
.q-color-picker__tune-tab input
border: 1px solid rgba(#fff, .3)
.q-slider__thumb
color: $grey-1

View file

@ -0,0 +1,5 @@
import QColor from './QColor.js'
export {
QColor
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,477 @@
{
"mixins": [ "components/date/use-datetime" ],
"meta": {
"docsUrl": "https://v2.quasar.dev/vue-components/date"
},
"props": {
"model-value": {
"extends": "model-value",
"type": [ "String", "Array", "Object", "null", "undefined" ],
"desc": "Date(s) of the component; Must be Array if using 'multiple' prop; Either use this property (along with a listener for 'update:model-value' event) OR use v-model directive",
"examples": [
"# v-model=\"myDate\"",
"# v-model=\"[myDate1, myDate2]\"",
"# v-model=\"[{ from: myDateFrom, to: myDateTo }]\"",
"# v-model=\"[myDate1, { from: myDateFrom, to: myDateTo }, myDate2]\""
]
},
"title": {
"type": "String",
"desc": "When specified, it overrides the default header title; Makes sense when not in 'minimal' mode",
"examples": [ "'Birthday'" ],
"category": "content"
},
"subtitle": {
"type": "String",
"desc": "When specified, it overrides the default header subtitle; Makes sense when not in 'minimal' mode",
"examples": [ "'John Doe'" ],
"category": "content"
},
"default-year-month": {
"type": "String",
"desc": "The default year and month to display (in YYYY/MM format) when model is unfilled (undefined or null); Please ensure it is within the navigation min/max year-month (if using them)",
"examples": [ "'1986/02'" ],
"category": "model"
},
"mask": {
"default": "'YYYY/MM/DD'",
"examples": [ "'YYYY-MM-DD'", "'MMMM Do, YYYY'", "'YYYY-MM-DD HH:mm:ss'" ]
},
"default-view": {
"type": "String",
"desc": "The view which will be displayed by default",
"default": "'Calendar'",
"values": [ "'Calendar'", "'Months'", "'Years'" ],
"category": "model"
},
"years-in-month-view": {
"type": "Boolean",
"desc": "Show the years selector in months view",
"category": "behavior"
},
"events": {
"type": [ "Array", "Function" ],
"desc": "A list of events to highlight on the calendar; If using a function, it receives the date as a String and must return a Boolean (matches or not); If using a function then for best performance, reference it from your scope and do not define it inline",
"params": {
"date": {
"type": "String",
"desc": "The current date being processed.",
"examples": [ "'2018/11/05'", "'2021/10/25'" ]
}
},
"returns": {
"type": "Boolean",
"desc": "If true, the current date will be highlighted"
},
"examples": [
"[ '2018/11/05', '2018/11/06', '2018/11/09', '2018/11/23' ]",
"date => (date[ 9 ] % 3 === 0)"
],
"category": "model"
},
"event-color": {
"type": [ "String", "Function" ],
"desc": "Color name (from the Quasar Color Palette); If using a function, it receives the date as a String and must return a String (color for the received date); If using a function then for best performance, reference it from your scope and do not define it inline",
"params": {
"date": {
"type": "String",
"desc": "The current date being processed.",
"examples": [ "'2018/11/05'", "'2021/10/25'" ]
}
},
"returns": {
"type": "String",
"desc": "Color for the current date.",
"examples": [ "'teal'", "'orange'" ]
},
"examples": [
"'teal-10'",
"date => (date[ 9 ] % 2 === 0 ? 'teal' : 'orange')"
],
"category": "style"
},
"options": {
"type": [ "Array", "Function" ],
"desc": "Optionally configure the days that are selectable; If using a function, it receives the date as a String and must return a Boolean (is date acceptable or not); If using a function then for best performance, reference it from your scope and do not define it inline; Incompatible with 'range' prop",
"params": {
"date": {
"type": "String",
"desc": "The current date being processed.",
"examples": [ "'2018/11/05'", "'2021/10/25'" ]
}
},
"returns": {
"type": "Boolean",
"desc": "If true, the current date will be made available for selection"
},
"examples": [
"[ '2018/11/05', '2018/11/12', '2018/11/19', '2018/11/26' ]",
"date => (date[ 9 ] % 3 === 0)",
"date => (date >= '2018/11/03' && date <= '2018/11/15')"
],
"category": "model"
},
"navigation-min-year-month": {
"type": "String",
"desc": "Lock user from navigating below a specific year+month (in YYYY/MM format); This prop is not used to correct the model; You might want to also use 'default-year-month' prop",
"examples": [ "'2020/07'" ],
"category": "selection"
},
"navigation-max-year-month": {
"type": "String",
"desc": "Lock user from navigating above a specific year+month (in YYYY/MM format); This prop is not used to correct the model; You might want to also use 'default-year-month' prop",
"examples": [ "'2020/10'" ],
"category": "selection"
},
"no-unset": {
"type": "Boolean",
"desc": "Remove ability to unselect a date; It does not apply to selecting a range over already selected dates",
"category": "selection"
},
"first-day-of-week": {
"type": [ "String", "Number" ],
"desc": "Sets the day of the week that is considered the first day (0 - Sunday, 1 - Monday, ...); This day will show in the left-most column of the calendar",
"default": "# based on configured Quasar lang language",
"__runtimeDefault": true,
"examples": [
"1",
"# first-day-of-week=\"1\"",
"# :first-day-of-week=\"selectedFirstDayOfTheWeek\""
],
"category": "model"
},
"today-btn": {
"type": "Boolean",
"desc": "Display a button that selects the current day",
"category": "content"
},
"minimal": {
"type": "Boolean",
"desc": "Dont display the header",
"category": "content"
},
"multiple": {
"type": "Boolean",
"desc": "Allow multiple selection; Model must be Array",
"category": "model|selection"
},
"range": {
"type": "Boolean",
"desc": "Allow range selection; Partial compatibility with 'options' prop: selected ranges might also include 'unselectable' days",
"category": "model|selection"
},
"emit-immediately": {
"type": "Boolean",
"desc": "Emit model when user browses month and year too; ONLY for single selection (non-multiple, non-range)",
"category": "model"
}
},
"slots": {
"default": {
"desc": "This is where additional buttons can go"
}
},
"events": {
"update:model-value": {
"extends": "update:model-value",
"params": {
"value": {
"type": [ "String", "Array", "Object", "null" ]
},
"reason": {
"type": "String",
"desc": "Reason of the user interaction (what was picked)",
"values": [ "'add-day'", "'remove-day'", "'add-range'", "'remove-range'", "'mask'", "'locale'", "'year'", "'month'" ]
},
"details": {
"type": "Object",
"desc": "Object of properties on the new model",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year of the date that the user has clicked/tapped on"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month of the date that the user has clicked/tapped on"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of the month that the user has clicked/tapped on"
},
"from": {
"type": "Object",
"required": false,
"desc": "Object of properties of the range starting point (only if range)",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of month"
}
}
},
"to": {
"type": "Object",
"required": false,
"desc": "Object of properties of the range ending point (only if range)",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of month"
}
}
}
}
}
}
},
"navigation": {
"desc": "Emitted when user navigates to a different month or year (and even when the model changes from an outside source)",
"params": {
"view": {
"type": "Object",
"desc": "Definition of the current view (year, month)",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
}
}
}
}
},
"range-start": {
"desc": "User has started a range selection",
"params": {
"from": {
"type": "Object",
"desc": "Definition of date from where the range begins",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of month"
}
}
}
}
},
"range-end": {
"desc": "User has ended a range selection",
"params": {
"range": {
"type": "Object",
"desc": "Definition of the range",
"definition": {
"from": {
"type": "Object",
"required": true,
"desc": "Definition of date from where the range begins",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of month"
}
}
},
"to": {
"type": "Object",
"required": true,
"desc": "Definition of date to where the range ends",
"definition": {
"year": {
"type": "Number",
"required": true,
"desc": "The year"
},
"month": {
"type": "Number",
"required": true,
"desc": "The month"
},
"day": {
"type": "Number",
"required": true,
"desc": "The day of month"
}
}
}
}
}
}
}
},
"methods": {
"setToday": {
"desc": "Change model to today",
"params": null,
"returns": null
},
"setView": {
"desc": "Change current view",
"params": {
"view": {
"type": "String",
"required": true,
"desc": "QDate view name",
"values": [ "'Calendar'", "'Months'", "'Years'" ]
}
},
"returns": null
},
"offsetCalendar": {
"desc": "Increment or decrement calendar view's month or year",
"params": {
"type": {
"type": "String",
"required": true,
"desc": "What to increment/decrement",
"values": [ "'month'", "'year'" ]
},
"descending": {
"type": "Boolean",
"desc": "Decrement?"
}
},
"returns": null
},
"setCalendarTo": {
"desc": "Change current year and month of the Calendar view; It gets corrected if using navigation-min/max-year-month and sets the current view to Calendar",
"params": {
"year": {
"type": "Number",
"desc": "The year"
},
"month": {
"type": "Number",
"desc": "The month"
}
},
"returns": null
},
"setEditingRange": {
"desc": "Configure the current editing range",
"params": {
"from": {
"type": "Object",
"desc": "Definition of date from where the range begins",
"definition": {
"year": {
"type": "Number",
"desc": "The year"
},
"month": {
"type": "Number",
"desc": "The month"
},
"day": {
"type": "Number",
"desc": "The day of month"
}
}
},
"to": {
"type": "Object",
"desc": "Definition of date to where the range ends",
"definition": {
"year": {
"type": "Number",
"desc": "The year"
},
"month": {
"type": "Number",
"desc": "The month"
},
"day": {
"type": "Number",
"desc": "The day of month"
}
}
}
},
"returns": null
}
}
}

Some files were not shown because too many files have changed in this diff Show more