diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index e13e23cf22d..43d28d0eb8c 100644 --- a/docs/src/assets/menu.js +++ b/docs/src/assets/menu.js @@ -88,6 +88,10 @@ export default [ { name: 'Transitions', path: 'transitions' + }, + { + name: 'Config Root Element', + path: 'root-element' } ] }, diff --git a/docs/src/pages/options/root-element.md b/docs/src/pages/options/root-element.md new file mode 100644 index 00000000000..3b0f96aed71 --- /dev/null +++ b/docs/src/pages/options/root-element.md @@ -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** `` 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. +::: diff --git a/ui/src/components/menu/QMenu.js b/ui/src/components/menu/QMenu.js index 154cb1c089f..0efcdbce3dc 100644 --- a/ui/src/components/menu/QMenu.js +++ b/ui/src/components/menu/QMenu.js @@ -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 @@ -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]' @@ -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) @@ -262,7 +263,7 @@ export default createComponent({ } if (props.noFocus !== true) { - document.activeElement.blur() + getActualActiveElement().blur() } // should removeTick() if this gets removed diff --git a/ui/src/components/tooltip/QTooltip.js b/ui/src/components/tooltip/QTooltip.js index 12c9ed2c5e0..420b3bb8ae1 100644 --- a/ui/src/components/tooltip/QTooltip.js +++ b/ui/src/components/tooltip/QTooltip.js @@ -7,6 +7,7 @@ import { Transition, getCurrentInstance } from 'vue' +import { getRootTarget } from '../../utils/private.dom/root.js' import useAnchor, { useAnchorStaticProps @@ -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( @@ -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) } diff --git a/ui/src/composables/private.use-field/use-field.js b/ui/src/composables/private.use-field/use-field.js index 95e1ac5751b..ffca9a0d4de 100644 --- a/ui/src/composables/private.use-field/use-field.js +++ b/ui/src/composables/private.use-field/use-field.js @@ -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)) { @@ -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() } @@ -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 } diff --git a/ui/src/composables/private.use-fullscreen/use-fullscreen.js b/ui/src/composables/private.use-fullscreen/use-fullscreen.js index ed6e122a8a9..1e659facc00 100644 --- a/ui/src/composables/private.use-fullscreen/use-fullscreen.js +++ b/ui/src/composables/private.use-fullscreen/use-fullscreen.js @@ -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' @@ -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 = { @@ -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(() => { diff --git a/ui/src/css/core/animations.sass b/ui/src/css/core/animations.sass index bc92ad6e681..c03e4482d3e 100644 --- a/ui/src/css/core/animations.sass +++ b/ui/src/css/core/animations.sass @@ -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} diff --git a/ui/src/css/core/colors.sass b/ui/src/css/core/colors.sass index 148685e1972..d76fcbf5e5c 100644 --- a/ui/src/css/core/colors.sass +++ b/ui/src/css/core/colors.sass @@ -1,4 +1,4 @@ -\:root +\:root, :host --q-primary: #{$primary} --q-secondary: #{$secondary} --q-accent: #{$accent} diff --git a/ui/src/css/core/size.sass b/ui/src/css/core/size.sass index 696b0096cea..d2922f1100a 100644 --- a/ui/src/css/core/size.sass +++ b/ui/src/css/core/size.sass @@ -1,6 +1,6 @@ @use 'sass:map' -\:root +\:root, :host @each $name, $size in $sizes #{"--q-size-"}#{$name}: #{$size} diff --git a/ui/src/css/core/transitions.sass b/ui/src/css/core/transitions.sass index 6895ac1eb22..e0d3b0327bd 100644 --- a/ui/src/css/core/transitions.sass +++ b/ui/src/css/core/transitions.sass @@ -1,5 +1,5 @@ // should not need this, but it's good as fallback -\:root +\:root, :host --q-transition-duration: .3s .q-transition diff --git a/ui/src/directives/touch-repeat/TouchRepeat.js b/ui/src/directives/touch-repeat/TouchRepeat.js index 462854016c2..57a16d449e5 100644 --- a/ui/src/directives/touch-repeat/TouchRepeat.js +++ b/ui/src/directives/touch-repeat/TouchRepeat.js @@ -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, @@ -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) { @@ -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 } @@ -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 } diff --git a/ui/src/plugins/dark/Dark.js b/ui/src/plugins/dark/Dark.js index e107edceb28..af1e588095c 100644 --- a/ui/src/plugins/dark/Dark.js +++ b/ui/src/plugins/dark/Dark.js @@ -1,4 +1,5 @@ import { createReactivePlugin } from '../../utils/private.create/create.js' +import { getRootTarget } from '../../utils/private.dom/root.js' const Plugin = createReactivePlugin( { @@ -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() { @@ -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__) { diff --git a/ui/src/utils/css-var/get-css-var.js b/ui/src/utils/css-var/get-css-var.js index 38c48519159..3be684dd031 100644 --- a/ui/src/utils/css-var/get-css-var.js +++ b/ui/src/utils/css-var/get-css-var.js @@ -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 ) } diff --git a/ui/src/utils/css-var/set-css-var.js b/ui/src/utils/css-var/set-css-var.js index d7a8d59c5a4..7ad84c5d40f 100644 --- a/ui/src/utils/css-var/set-css-var.js +++ b/ui/src/utils/css-var/set-css-var.js @@ -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) } diff --git a/ui/src/utils/dom/dom.js b/ui/src/utils/dom/dom.js index 167eb9aadc2..fc7513d8167 100644 --- a/ui/src/utils/dom/dom.js +++ b/ui/src/utils/dom/dom.js @@ -62,9 +62,40 @@ export function getElement(el) { } } +export function getActualActiveElement() { + let el = document.activeElement + while ( + el !== null && + el.shadowRoot !== null && + el.shadowRoot.activeElement !== null + ) { + el = el.shadowRoot.activeElement + } + return el +} + // internal export function childHasFocus(el, focusedEl) { - if (el === void 0 || el === null || el.contains(focusedEl) === true) { + if (el === void 0 || el === null) { + return true + } + + if (focusedEl === void 0 || focusedEl === null) { + focusedEl = getActualActiveElement() + } + + // If focusedEl is the host of the Shadow DOM containing el, + // we must get the ACTUAL focused element inside said Shadow DOM. + let actualFocused = focusedEl + while ( + actualFocused !== null && + actualFocused.shadowRoot !== null && + actualFocused.shadowRoot.activeElement !== null + ) { + actualFocused = actualFocused.shadowRoot.activeElement + } + + if (el.contains(actualFocused) === true) { return true } @@ -73,7 +104,7 @@ export function childHasFocus(el, focusedEl) { next !== null; next = next.nextElementSibling ) { - if (next.contains(focusedEl)) { + if (next.contains(actualFocused)) { return true } } @@ -81,6 +112,10 @@ export function childHasFocus(el, focusedEl) { return false } +export function isShadowRoot(el) { + return typeof ShadowRoot !== 'undefined' && el instanceof ShadowRoot +} + export default { offset, style, diff --git a/ui/src/utils/private.click-outside/click-outside.js b/ui/src/utils/private.click-outside/click-outside.js index 846faca09d9..2ee07b1a958 100644 --- a/ui/src/utils/private.click-outside/click-outside.js +++ b/ui/src/utils/private.click-outside/click-outside.js @@ -46,14 +46,13 @@ function globalHandler(evt) { for (let i = registeredList.length - 1; i >= 0; i--) { const state = registeredList[i] + const path = evt.composedPath !== void 0 ? evt.composedPath() : [] - if ( - (state.anchorEl.value === null || - state.anchorEl.value.contains(target) === false) && - (target === document.body || - (state.innerRef.value !== null && - state.innerRef.value.contains(target) === false)) - ) { + const isInside = + path.includes(state.anchorEl.value) || + (state.innerRef.value !== null && path.includes(state.innerRef.value)) + + if (isInside === false) { // mark the event as being processed by clickOutside // used to prevent refocus after menu close evt.qClickOutside = true diff --git a/ui/src/utils/private.config/nodes.js b/ui/src/utils/private.config/nodes.js index 24e1dea5738..a7c5ee92de5 100644 --- a/ui/src/utils/private.config/nodes.js +++ b/ui/src/utils/private.config/nodes.js @@ -1,4 +1,5 @@ import { globalConfig } from '../private.config/instance-config.js' +import { getRootElement } from '../private.dom/root.js' const nodesList = [] const portalTypeList = [] @@ -19,7 +20,15 @@ export function createGlobalNode(id, portalType) { } } - target.appendChild(el) + if (!__QUASAR_SSR_SERVER__) { + const root = getRootElement() + + if (root !== target && root !== void 0 && root !== null) { + changeGlobalNodesTarget(root) + } + + target.appendChild(el) + } nodesList.push(el) portalTypeList.push(portalType) @@ -42,6 +51,7 @@ export function changeGlobalNodesTarget(newTarget) { if ( target === document.body || + target === getRootElement() || // or we have less than 2 dialogs: portalTypeList.reduce( (acc, type) => (type === 'dialog' ? acc + 1 : acc), diff --git a/ui/src/utils/private.dom/root.js b/ui/src/utils/private.dom/root.js new file mode 100644 index 00000000000..8f30193928e --- /dev/null +++ b/ui/src/utils/private.dom/root.js @@ -0,0 +1,22 @@ +import { isShadowRoot } from '../dom/dom.js' +import { globalConfig } from '../private.config/instance-config.js' + +export function getRootElement() { + if (__QUASAR_SSR_SERVER__) return void 0 + + if (globalConfig.root !== void 0) { + if (typeof globalConfig.root === 'function') { + return globalConfig.root() + } + if (typeof globalConfig.root === 'string') { + return document.querySelector(globalConfig.root) || document.body + } + return globalConfig.root + } + return document.body +} + +export function getRootTarget() { + const root = getRootElement() + return isShadowRoot(root) === true ? root.host : root +}