From 6edac7b82558faa98ca5e86dd2bd8f55f05bf2b4 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 3 Apr 2024 22:41:02 +0200 Subject: [PATCH] feat(ui): LightRenderMixin (wip, needs test and impl throughout codebase) --- package-lock.json | 2 +- .../components/core/src/LightRenderMixin.js | 379 ++++++++++++++++++ .../core/types/LightRenderMixinTypes.ts | 173 ++++++++ 3 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 packages/ui/components/core/src/LightRenderMixin.js create mode 100644 packages/ui/components/core/types/LightRenderMixinTypes.ts diff --git a/package-lock.json b/package-lock.json index 15298492e0..fcaf083474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21249,7 +21249,7 @@ }, "packages/ui": { "name": "@lion/ui", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@bundled-es-modules/message-format": "^6.2.4", diff --git a/packages/ui/components/core/src/LightRenderMixin.js b/packages/ui/components/core/src/LightRenderMixin.js new file mode 100644 index 0000000000..32c319df82 --- /dev/null +++ b/packages/ui/components/core/src/LightRenderMixin.js @@ -0,0 +1,379 @@ +/* eslint-disable class-methods-use-this */ +import { dedupeMixin } from '@open-wc/dedupe-mixin'; +import { render } from 'lit'; + +/** + * @typedef {{renderBefore:Comment; renderTargetThatRespectsShadowRootScoping: HTMLDivElement}} RenderMetaObj + * @typedef {import('../types/LightRenderMixinTypes.js').LightRenderHost} LightRenderHost + * @typedef {import('../types/LightRenderMixinTypes.js').SlotItem} SlotItem + * @typedef {import('lit').TemplateResult} TemplateResult + * @typedef {import('lit').LitElement} LitElement + */ + +/** + * @param {{slotsProvidedByUser:Set; slots: SlotItem[]}} opts + * @returns {Set} + */ +function extractPrivateSlots({ slots, slotsProvidedByUser }) { + const privateSlots = new Set(); + const slotNames = slots.map(s => s.name); + for (const slotName of slotNames) { + if (slotsProvidedByUser.has(slotName)) { + continue; // eslint-disable-line no-continue + } + // Allow to conditionally return a slot + const slotFunctionResult = slots.find(s => s.name === slotName)?.templateFn(); + if (slotFunctionResult === undefined) { + continue; // eslint-disable-line no-continue + } + privateSlots.add(slotName); + } + return privateSlots; +} + +/** + * Renders to light dom while respecting shadow dom scoping. + * Suitable for rerendering as well. + * + * @private + * @param {object} opts + * @param {Map} opts.renderMetaPerSlot + * @param {import('lit').RenderOptions} opts.renderOptions + * @param {import('lit').TemplateResult} opts.template + * @param {HTMLElement} opts.shadowHost + * @param {string} opts.slotName + * @returns {void} + */ +function renderLightDomInScopedContext({ + renderMetaPerSlot, + renderOptions, + shadowHost, + template, + slotName, +}) { + const isFirstRender = !renderMetaPerSlot.has(slotName); + if (isFirstRender) { + // @ts-expect-error wait for browser support + const supportsScopedRegistry = !!ShadowRoot.prototype.createElement; + const registryRoot = supportsScopedRegistry ? shadowHost.shadowRoot || document : document; + + // @ts-expect-error wait for browser support + const renderTargetThatRespectsShadowRootScoping = registryRoot.createElement('div'); + const startComment = document.createComment(`_start_slot_${slotName}_`); + const endComment = document.createComment(`_end_slot_${slotName}_`); + + renderTargetThatRespectsShadowRootScoping.appendChild(startComment); + renderTargetThatRespectsShadowRootScoping.appendChild(endComment); + + render(template, renderTargetThatRespectsShadowRootScoping, { + ...renderOptions, + renderBefore: endComment, + }); + + renderTargetThatRespectsShadowRootScoping.slot = slotName; + shadowHost.appendChild(renderTargetThatRespectsShadowRootScoping); + + renderMetaPerSlot.set(slotName, { + renderTargetThatRespectsShadowRootScoping, + renderBefore: endComment, + }); + + return; + } + + // Rerender + const { renderBefore } = /** @type {RenderMetaObj} */ (renderMetaPerSlot.get(slotName)); + const rerenderTarget = shadowHost; + render(template, rerenderTarget, { ...renderOptions, renderBefore }); +} + +/** + * @private + * @param {{isInLightDom: () => boolean; slots: SlotItem[]; defaultHost: HTMLElement}} opts + * @returns {void} + */ +function patchRenderFns({ isInLightDom, slots, defaultHost }) { + const slotTemplateFns = []; + for (const { name: slotName, templateFn, host } of slots) { + const hostObj = host || defaultHost; + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const potentialFnName in hostObj) { + if (hostObj[potentialFnName] === templateFn) { + slotTemplateFns.push({ + templateFnName: potentialFnName, + originalFn: templateFn, + slotName, + hostObj, + }); + // eslint-disable-next-line no-continue + continue; + } + } + } + // Patch existing functions + for (const { slotName, templateFnName, originalFn, hostObj } of slotTemplateFns) { + // @ts-ignore + // eslint-disable-next-line consistent-return + hostObj[templateFnName] = (/** @type {any} */ ...args) => { + if (isInLightDom()) { + // @ts-ignore + originalFn(...args); + } else { + const slotNode = document.createElement('slot'); + if (slotName) { + slotNode.name = slotName; + } + return slotNode; + } + }; + } +} + +/** + * LightDomRenderMixin is needed when the author of a component needs to render to both light dom and shadow dom. + * + * Accessibility is extremely high valued in Lion. + * Because aria relations can't cross shadow boundaries today, we introduced LightDomRenderMixin. + * + * Normally, light dom is provided by the consumer of a component and only shadow dom is provided by the author. + * However, in order to deliver the best possible accessible experience, an author sometimes needs to render to light dom. + * Read more about this in the [ARIA in Shadow DOM](https://lion-web.netlify.app/fundamentals/rationales/accessibility/#shadow-roots-and-accessibility) + * The aim of this mixin is to provide abstractions that are almost 100% forward compatible with a future spec for cross-root aria. + * + * ## Common use of light dom + * In below example for instance, a consumer provides options to a combobox via light dom: + * + * ```html + * + * a + * b + * + * ``` + * + * Internally, the author provided a listbox and a textbox in shadow dom. The textbox also has a shadow root. + * + * ```html + * + * a + * b + * #shadow-root + * + * #shadow-root + * + * + *
+ *
+ * ``` + * + * We already see two problems here: aria-controls and aria-activedescendant can't reference ids outside their dom boundaries. + * Now imagine we have a combobox is part of a form group (fieldset) and we want + * to read the fieldset error when the combobox is focused. + * + * ```html + * + * + * + * a + * b + * #shadow-root + * + * #shadow-root + * + * + *
+ *
+ * Combination of residence and country do not match + *
+ * ``` + * + * + * Summarized, without LightDomRenderMixin, the following is not achievable: + * - creating a relation between element outside and an element inside the host (labels, descriptions etc.) + * - using aria-activedescendant, aria-owns, aria-controls (in listboxes, comboboxes, etc.) + * - creating a nested form group (like a fieldset) that lies relations between parent (the group) and children (the fields) + * - leveraging native form registration (today it should be possible to use form association for this) + * - creating a button that allows for implicit form submission + * - as soon as you start to use composition (nested web components), you need to be able to lay relations between the different components + * + * Note that at some point in the future, there will be a spec for cross-root aria relations. By that time, this mixin will be obsolete. + * This mixin is designed in such a way that it can be removed with minimial effort and without breaking changes. + * + * ## How to use + * In order to use the mixin, just render like you would to shadow dom: + * + * ```js + * class MyInput extends LitElement { + * render() { + * return html` + *
+ * ${this.renderInput()} + *
+ * `; + * } + * + * renderInput() { + * return html``; + * } + * } + * + * ``` + * + * This results in: + * ```html + * #shadow-root + * + * ``` + * + * Now, we apply the LightDomRenderMixin on top. + * Below, we just tell which slots we render, using what functions. + * + * ```js + * class MyInput extends LightDomRenderMixin(LitElement) { + * + * slots = { + * input: this.renderInput, + * } + * + * render() { + * return html` + *
+ * ${this.renderInput()} + *
+ * `; + * } + * + * renderInput() { + * return html``; + * } + * } + * + * ``` + * + * This results in: + * ```html + * + * #shadow-root + * + * ``` + * + * ## How it works + * + * By default, the render function is called in LitElement inside the `update` lifecycle method. + * This is done for the shadow root. + * This mixin uses the same render cycle (via the `update` method) to render to light dom as well. + * For this, the collection of functions in the `slots` property is called. The result is appended to the light dom. + * + * The mixin creates a proxy for slot functions (like `renderInput`). When called during the shadow render, + * the slot outlet is added to shadow dom. When called during the light dom render, the slot content is added to light dom. + * + * ## Scoped elements + * + * Per the spec, scoped elements are bound to a shadow root of its host. Since we render to light dom for the possibilities + * it gives us in creating aria relations, we still want to scope elements to the shadow root. LightDomRenderMixin takes care of this. + * + * @param {import('@open-wc/dedupe-mixin').Constructor} superclass + */ +const LightRenderMixinImplementation = superclass => + /** @type {* & LightRenderHost} */ + ( + class LightDomRenderHost extends superclass { + /** + * @type {SlotItem[]} + */ + slots = []; + + constructor() { + super(); + + /** + * The roots that are used to do a rerender in. + * In case of a SlotRerenderObject, this will create a render wrapper in the scoped context (shadow root) + * and connect this under the slot name to the light dom. All (re)renders will happen from here, + * so that all interactions work as intended and no focus issues can arise (which would be the case + * when (cloned) nodes of a render outcome would be moved around) + * @private + * @type { Map } */ + this.__renderMetaPerSlot = new Map(); + + /** + * Those are slots that should be touched by SlotMixin + * The opposite of __slotsProvidedByUserOnFirstConnected, + * also taking into account undefined (a.k.a. conditional) slots + * @private + * @type {Set} + */ + this.__privateSlots = new Set(); + + /** + * @private + * @type {'shadow-dom'|'light-dom'} + */ + this.__slotRenderPhase = 'shadow-dom'; + } + + connectedCallback() { + super.connectedCallback(); + this.__initLightRenderMixin(); + } + + /** + * Here we rerender slots defined with a `SlotRerenderObject` + * @param {import('lit-element').PropertyValues } changedProperties + */ + update(changedProperties) { + // Here we just render the shadow dom + // For this, we tell our patched function to render the slot outlet + this.__slotRenderPhase = 'shadow-dom'; + super.update(changedProperties); + + // Now, we update/render the light dom + // For this, we tell our patched function to render the original function (to light dom) + this.__slotRenderPhase = 'light-dom'; + + // Now we render the light dom in one go... + for (const slotName of this.__privateSlots) { + const slotFunctionResult = this.slots.find(s => s.name === slotName)?.templateFn(); + if (slotFunctionResult === undefined) { + continue; // eslint-disable-line no-continue + } + // Providing all options breaks Safari; keep host and creationScope + const { creationScope, host } = this.renderOptions; + renderLightDomInScopedContext({ + renderMetaPerSlot: this.__renderMetaPerSlot, + renderOptions: { creationScope, host }, + template: slotFunctionResult, + shadowHost: this, + slotName, + }); + } + } + + /** + * Helper function that Subclassers can use to check if a slot is private + * @protected + * @param {string} slotName Name of the slot + * @returns {boolean} true if given slot name been created by SlotMixin + */ + _isPrivateSlot(slotName) { + return this.__privateSlots.has(slotName); + } + + /** + * @private + * @returns {void} + */ + __initLightRenderMixin() { + if (this.__isLightRenderMixinInitialized) return; + + patchRenderFns({ + isInLightDom: () => this.__slotRenderPhase === 'light-dom', + slots: this.slots, + defaultHost: this, + }); + const slotsProvidedByUser = new Set(Array.from(this.children).map(c => c.slot || '')); + this.__privateSlots = extractPrivateSlots({ slotsProvidedByUser, slots: this.slots }); + this.__isLightRenderMixinInitialized = true; + } + } + ); +export const LightRenderMixin = dedupeMixin(LightRenderMixinImplementation); diff --git a/packages/ui/components/core/types/LightRenderMixinTypes.ts b/packages/ui/components/core/types/LightRenderMixinTypes.ts new file mode 100644 index 0000000000..9919df8989 --- /dev/null +++ b/packages/ui/components/core/types/LightRenderMixinTypes.ts @@ -0,0 +1,173 @@ +import { Constructor } from '@open-wc/dedupe-mixin'; +import { TemplateResult, LitElement } from 'lit'; + +export type SlotItem = { name: string; templateFn: () => TemplateResult }; + +export declare class LightRenderHost { + /** + * All slots that should be rendered to light dom instead of shadow dom + */ + public slots: SlotItem[]; + + /** + * Useful to decide if a given slot should be manipulated depending on if it was auto generated + * or not. + * + * @param {string} slotName Name of the slot + * @returns {boolean} true if given slot name been created by SlotMixin + */ + protected _isPrivateSlot(slotName: string): boolean; +} + +/** + * LightRenderMixin is needed when the author of a component needs to render to both light dom and shadow dom. + * + * Accessibility is extremely high valued in Lion. + * Because aria relations can't cross shadow boundaries today, we introduced LightDomRenderMixin. + * + * Normally, light dom is provided by the consumer of a component and only shadow dom is provided by the author. + * However, in order to deliver the best possible accessible experience, an author sometimes needs to render to light dom. + * Read more about this in the [ARIA in Shadow DOM](https://lion-web.netlify.app/fundamentals/rationales/accessibility/#shadow-roots-and-accessibility) + * The aim of this mixin is to provide abstractions that are almost 100% forward compatible with a future spec for cross-root aria. + * + * ## Common use of light dom + * In below example for instance, a consumer provides options to a combobox via light dom: + * + * ```html + * + * a + * b + * + * ``` + * + * Internally, the author provided a listbox and a textbox in shadow dom. The textbox also has a shadow root. + * + * ```html + * + * a + * b + * #shadow-root + * + * #shadow-root + * + * + *
+ *
+ * ``` + * + * We already see two problems here: aria-controls and aria-activedescendant can't reference ids outside their dom boundaries. + * Now imagine we have a combobox is part of a form group (fieldset) and we want + * to read the fieldset error when the combobox is focused. + * + * ```html + * + * + * + * a + * b + * #shadow-root + * + * #shadow-root + * + * + *
+ *
+ * Combination of residence and country do not match + *
+ * ``` + * + * + * Summarized, without LightDomRenderMixin, the following is not achievable: + * - creating a relation between element outside and an element inside the host (labels, descriptions etc.) + * - using aria-activedescendant, aria-owns, aria-controls (in listboxes, comboboxes, etc.) + * - creating a nested form group (like a fieldset) that lies relations between parent (the group) and children (the fields) + * - leveraging native form registration (today it should be possible to use form association for this) + * - creating a button that allows for implicit form submission + * - as soon as you start to use composition (nested web components), you need to be able to lay relations between the different components + * + * Note that at some point in the future, there will be a spec for cross-root aria relations. By that time, this mixin will be obsolete. + * This mixin is designed in such a way that it can be removed with minimial effort and without breaking changes. + * + * ## How to use + * In order to use the mixin, just render like you would to shadow dom: + * + * ```js + * class MyInput extends LitElement { + * render() { + * return html` + *
+ * ${this.renderInput()} + *
+ * `; + * } + * + * renderInput() { + * return html``; + * } + * } + * + * ``` + * + * This results in: + * ```html + * #shadow-root + * + * ``` + * + * Now, we apply the LightDomRenderMixin on top. + * Below, we just tell which slots we render, using what functions. + * + * ```js + * class MyInput extends LightDomRenderMixin(LitElement) { + * + * slots = { + * input: this.renderInput, + * } + * + * render() { + * return html` + *
+ * ${this.renderInput()} + *
+ * `; + * } + * + * renderInput() { + * return html``; + * } + * } + * + * ``` + * + * This results in: + * ```html + * + * #shadow-root + * + * ``` + * + * + * ## How it works + * + * By default, the render function is called in LitElement inside the `update` lifecycle method. + * This is done for the shadow root. + * This mixin uses the same render cycle (via the `update` method) to render to light dom as well. + * For this, the collection of functions in the `slots` property is called. The result is appended to the light dom. + * + * The mixin creates a proxy for slot functions (like `renderInput`). When called during the shadow render, + * the slot outlet is added to shadow dom. When called during the light dom render, the slot content is added to light dom. + * + * ## Scoped elements + * + * Per the spec, scoped elements are bound to a shadow root of its host. Since we render to light dom for the possibilities + * it gives us in creating aria relations, we still want to scope elements to the shadow root. LightDomRenderMixin takes care of this. + * + */ +declare function LightRenderMixinImplementation>( + superclass: T, +): T & + Constructor & + Pick & + Pick; + +export type LightRenderMixin = typeof LightRenderMixinImplementation;