Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/assets/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export default [
{
name: 'Transitions',
path: 'transitions'
},
{
name: 'Config Root Element',
path: 'root-element'
}
]
},
Expand Down
61 changes: 61 additions & 0 deletions docs/src/pages/options/root-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Config Root Element
desc: Configuring the root element for Quasar Teleported components.
related:
- /quasar-cli-vite/quasar-config-file
- /quasar-cli-webpack/quasar-config-file
---

Quasar defaults to appending teleported components (such as `QDialog`, `QMenu`, `QSelect`, and `QTooltip`) to `document.body`. This behavior can be changed using the `config.root` property.

## Changing the Root Element

You can customize the root element by modifying your `/quasar.config` file. The `root` property accepts either a DOM Element or a function that returns a DOM Element.

```js [highlight=4-6] /quasar.config.js
module.exports = function (ctx) {
return {
framework: {
config: {
root: () => document.getElementById('my-app')
}
}
}
}
```

## Micro Front-ends (Web Components)

A special use case for `config.root` is when building Micro Front-ends using Web Components with Shadow DOM. When a Quasar app is encapsulated inside a Shadow Root, appending globally to `document.body` breaks CSS encapsulation. Teleported elements end up rendered outside the Shadow DOM and therefore miss the applied Quasar styles and CSS variables.

The simplest approach is to pass the `shadowRoot` of your Web Component using a basic DOM selector.

### Example

If you are bootstrapping Quasar inside a Web Component (using `defineCustomElement` in Vue 3), you can configure the App instance to point to your custom element's `shadowRoot`:

```ts [highlight=9] /src/main.ts
import { defineCustomElement } from 'vue'
import { Quasar } from 'quasar'
import RootComponent from './App.ce.vue'

const MyCE = defineCustomElement(RootComponent, {
configureApp(app) {
app.use(Quasar, {
config: {
root: () =>
document.querySelector('my-web-component')?.shadowRoot ||
document.body
}
})
}
})

customElements.define('my-web-component', MyCE)
```

::: warning Side Effect with Multiple Instances
Because the `root` function is evaluated in user-land using `document.querySelector`, it will naturally return the **first** `<my-web-component>` instance found on the page if multiple are present. Consequently, Quasar will append all teleported components (like `QDialog`, `QMenu`, `QSelect`, etc.) to that first instance's Shadow DOM.

However, if your Micro Front-ends share the same CSS (Quasar styling is present on all instances), this behavior works smoothly and the overlays will still render correctly inside the first Web Component.
:::
9 changes: 5 additions & 4 deletions ui/src/components/menu/QMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
addFocusout,
removeFocusout
} from '../../utils/private.focus/focusout.js'
import { childHasFocus } from '../../utils/dom/dom.js'
import { childHasFocus, getActualActiveElement } from '../../utils/dom/dom.js'
import {
addClickOutside,
removeClickOutside
Expand Down Expand Up @@ -211,7 +211,7 @@ export default createComponent({
addFocusFn(() => {
let node = innerRef.value

if (node && node.contains(document.activeElement) !== true) {
if (node && node.contains(getActualActiveElement()) !== true) {
node =
node.querySelector(
'[autofocus][tabindex], [data-autofocus][tabindex]'
Expand All @@ -227,7 +227,8 @@ export default createComponent({
}

function handleShow(evt) {
refocusTarget = props.noRefocus === false ? document.activeElement : null
refocusTarget =
props.noRefocus === false ? getActualActiveElement() : null

addFocusout(onFocusout)

Expand Down Expand Up @@ -262,7 +263,7 @@ export default createComponent({
}

if (props.noFocus !== true) {
document.activeElement.blur()
getActualActiveElement().blur()
}

// should removeTick() if this gets removed
Expand Down
9 changes: 7 additions & 2 deletions ui/src/components/tooltip/QTooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Transition,
getCurrentInstance
} from 'vue'
import { getRootTarget } from '../../utils/private.dom/root.js'

import useAnchor, {
useAnchorStaticProps
Expand Down Expand Up @@ -266,7 +267,8 @@ export default createComponent({
function delayShow(evt) {
if ($q.platform.is.mobile === true) {
clearSelection()
document.body.classList.add('non-selectable')
const rootTarget = getRootTarget()
if (rootTarget?.classList) rootTarget.classList.add('non-selectable')

const target = anchorEl.value
const evts = ['touchmove', 'touchcancel', 'touchend', 'click'].map(
Expand All @@ -287,7 +289,10 @@ export default createComponent({
clearSelection()
// delay needed otherwise selection still occurs
setTimeout(() => {
document.body.classList.remove('non-selectable')
const rootTarget = getRootTarget()
if (rootTarget?.classList) {
rootTarget.classList.remove('non-selectable')
}
}, 10)
}

Expand Down
11 changes: 8 additions & 3 deletions ui/src/composables/private.use-field/use-field.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,13 @@ export default function useField(state) {
return acc
})

function getActiveEl() {
const root = state.rootRef?.value?.getRootNode?.()
return (root && root.activeElement) || document.activeElement
}

function focusHandler() {
const el = document.activeElement
const el = getActiveEl()
let target = state.targetRef?.value

if (target && (el === null || el.id !== state.targetUid.value)) {
Expand All @@ -331,7 +336,7 @@ export default function useField(state) {

function blur() {
removeFocusFn(focusHandler)
const el = document.activeElement
const el = getActiveEl()
if (el !== null && state.rootRef.value.contains(el)) {
el.blur()
}
Expand Down Expand Up @@ -359,7 +364,7 @@ export default function useField(state) {
(state.hasPopupOpen === true ||
state.controlRef === void 0 ||
state.controlRef.value === null ||
state.controlRef.value.contains(document.activeElement) !== false)
state.controlRef.value.contains(getActiveEl()) !== false)
) {
return
}
Expand Down
10 changes: 7 additions & 3 deletions ui/src/composables/private.use-fullscreen/use-fullscreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
onBeforeUnmount,
getCurrentInstance
} from 'vue'
import { getRootElement, getRootTarget } from '../../utils/private.dom/root.js'

import History from '../../plugins/private.history/History.js'
import { vmHasRouter } from '../../utils/private.vm/vm.js'
Expand Down Expand Up @@ -65,11 +66,13 @@ export default function useFullscreen() {
inFullscreen.value = true
container = proxy.$el.parentNode
container.replaceChild(fullscreenFillerNode, proxy.$el)
document.body.appendChild(proxy.$el)
let root = getRootElement()
root.appendChild(proxy.$el)

counter++
if (counter === 1) {
document.body.classList.add('q-body--fullscreen-mixin')
const target = getRootTarget()
if (target?.classList) target.classList.add('q-body--fullscreen-mixin')
}

historyEntry = {
Expand All @@ -92,7 +95,8 @@ export default function useFullscreen() {
counter = Math.max(0, counter - 1)

if (counter === 0) {
document.body.classList.remove('q-body--fullscreen-mixin')
const target = getRootTarget()
if (target?.classList) target.classList.remove('q-body--fullscreen-mixin')

if (proxy.$el.scrollIntoView !== void 0) {
setTimeout(() => {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/css/core/animations.sass
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Adapted from: https://github.com/animate-css/animate.css/blob/6828621a01e145119db6194dc9b4d37325b48aa5/source/_base.css
*/

\:root
\:root, :host
--animate-duration: #{$animate-duration}
--animate-delay: #{$animate-delay}
--animate-repeat: #{$animate-repeat}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/css/core/colors.sass
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
\:root
\:root, :host
--q-primary: #{$primary}
--q-secondary: #{$secondary}
--q-accent: #{$accent}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/css/core/size.sass
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@use 'sass:map'

\:root
\:root, :host
@each $name, $size in $sizes
#{"--q-size-"}#{$name}: #{$size}

Expand Down
2 changes: 1 addition & 1 deletion ui/src/css/core/transitions.sass
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// should not need this, but it's good as fallback
\:root
\:root, :host
--q-transition-duration: .3s

.q-transition
Expand Down
14 changes: 11 additions & 3 deletions ui/src/directives/touch-repeat/TouchRepeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { clearSelection } from '../../utils/private.selection/selection.js'
import { isKeyCode } from '../../utils/private.keyboard/key-composition.js'
import getSSRProps from '../../utils/private.noop-ssr-directive-transform/noop-ssr-directive-transform.js'
import { getRootTarget } from '../../utils/private.dom/root.js'

const keyCodes = {
esc: 27,
Expand Down Expand Up @@ -128,7 +129,10 @@ export default createDirective(
document.documentElement.style.cursor = ''

const remove = () => {
document.body.classList.remove('non-selectable')
const target = getRootTarget()
if (target?.classList) {
target.classList.remove('non-selectable')
}
}

if (withDelay === true) {
Expand All @@ -140,7 +144,8 @@ export default createDirective(
}

if (client.is.mobile === true) {
document.body.classList.add('non-selectable')
const target = getRootTarget()
if (target?.classList) target.classList.add('non-selectable')
clearSelection()
ctx.styleCleanup = styleCleanup
}
Expand Down Expand Up @@ -169,7 +174,10 @@ export default createDirective(

if (client.is.mobile !== true) {
document.documentElement.style.cursor = 'pointer'
document.body.classList.add('non-selectable')
const target = getRootTarget()
if (target?.classList) {
target.classList.add('non-selectable')
}
clearSelection()
ctx.styleCleanup = styleCleanup
}
Expand Down
12 changes: 9 additions & 3 deletions ui/src/plugins/dark/Dark.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createReactivePlugin } from '../../utils/private.create/create.js'
import { getRootTarget } from '../../utils/private.dom/root.js'

const Plugin = createReactivePlugin(
{
Expand Down Expand Up @@ -29,8 +30,12 @@ const Plugin = createReactivePlugin(
}

Plugin.isActive = val === true
document.body.classList.remove(`body--${val === true ? 'light' : 'dark'}`)
document.body.classList.add(`body--${val === true ? 'dark' : 'light'}`)

const target = getRootTarget()
if (target?.classList) {
target.classList.remove(`body--${val === true ? 'light' : 'dark'}`)
target.classList.add(`body--${val === true ? 'dark' : 'light'}`)
}
},

toggle() {
Expand All @@ -40,8 +45,9 @@ const Plugin = createReactivePlugin(
},

install({ $q, ssrContext }) {
const target = __QUASAR_SSR_CLIENT__ ? getRootTarget() : null
const dark = __QUASAR_SSR_CLIENT__
? document.body.classList.contains('body--dark')
? target?.classList?.contains('body--dark')
: $q.config.dark

if (__QUASAR_SSR_SERVER__) {
Expand Down
11 changes: 8 additions & 3 deletions ui/src/utils/css-var/get-css-var.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
export default function getCssVar(propName, element = document.body) {
import { getRootTarget } from '../private.dom/root.js'

export default function getCssVar(propName, element) {
if (typeof propName !== 'string') {
throw new TypeError('Expected a string as propName')
}
if (!(element instanceof Element)) {

const target = element || getRootTarget()

if (!(target instanceof Element)) {
throw new TypeError('Expected a DOM element')
}

return (
getComputedStyle(element).getPropertyValue(`--q-${propName}`).trim() || null
getComputedStyle(target).getPropertyValue(`--q-${propName}`).trim() || null
)
}
11 changes: 5 additions & 6 deletions ui/src/utils/css-var/set-css-var.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
export default function setCssVar(propName, value, element = document.body) {
import { getRootTarget } from '../private.dom/root.js'

export default function setCssVar(propName, value, element) {
if (typeof propName !== 'string') {
throw new TypeError('Expected a string as propName')
}
if (typeof value !== 'string') {
throw new TypeError('Expected a string as value')
}
if (!(element instanceof Element)) {
throw new TypeError('Expected a DOM element')
}

element.style.setProperty(`--q-${propName}`, value)
const target = element || getRootTarget()
target.style.setProperty(`--q-${propName}`, value)
}
Loading
Loading