Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[labs/ssr-dom-shim] Add basic support for element internals #3677

Merged
merged 16 commits into from Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/late-hairs-flash.md
@@ -0,0 +1,5 @@
---
'@lit-labs/ssr-dom-shim': minor
---

Add rough support for HTMLElement.prototype.attachInternals
7 changes: 7 additions & 0 deletions .changeset/rich-toes-jam.md
@@ -0,0 +1,7 @@
---
'lit': minor
'lit-element': minor
'@lit-labs/ssr': minor
---

Reflect ARIA attributes onto server rendered Lit elements with attached internals during SSR and remove them upon hydration.
1 change: 1 addition & 0 deletions .eslintignore
Expand Up @@ -278,6 +278,7 @@ packages/labs/ssr-client/node_modules/
packages/labs/ssr-client/index.*

packages/labs/ssr-dom-shim/index.*
packages/labs/ssr-dom-shim/lib/

packages/labs/ssr-react/node/
packages/labs/ssr-react/lib/
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Expand Up @@ -265,6 +265,7 @@ packages/labs/ssr-client/node_modules/
packages/labs/ssr-client/index.*

packages/labs/ssr-dom-shim/index.*
packages/labs/ssr-dom-shim/lib/

packages/labs/ssr-react/node/
packages/labs/ssr-react/lib/
Expand Down
48 changes: 19 additions & 29 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/labs/ssr-dom-shim/.gitignore
@@ -1 +1,2 @@
/index.*
/lib/
4 changes: 3 additions & 1 deletion packages/labs/ssr-dom-shim/package.json
Expand Up @@ -23,7 +23,8 @@
}
},
"files": [
"index.{d.ts,d.ts.map,js,js.map}"
"index.{d.ts,d.ts.map,js,js.map}",
"lib/"
],
"scripts": {
"build": "wireit",
Expand All @@ -43,6 +44,7 @@
"tsconfig.json"
],
"output": [
"lib/",
"index.{d.ts,d.ts.map,js,js.map}",
"tsconfig.tsbuildinfo"
]
Expand Down
24 changes: 21 additions & 3 deletions packages/labs/ssr-dom-shim/src/index.ts
Expand Up @@ -3,6 +3,13 @@
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import {ElementInternalsShim} from './lib/element-internals.js';

export {
ariaMixinEnum,
ElementInternals,
HYDRATE_INTERNALS_ATTR_PREFIX,
} from './lib/element-internals.js';

const attributes: WeakMap<
InstanceType<typeof HTMLElementShim>,
Expand All @@ -29,19 +36,24 @@ const attributesForElement = (
// `const ElementShimWithRealType = ElementShim as object as typeof Element;`.
// 4. We want the exported names to match the real ones, hence e.g.
// `export {ElementShimWithRealType as Element}`.

const ElementShim = class Element {
get attributes() {
return Array.from(attributesForElement(this)).map(([name, value]) => ({
name,
value,
}));
}
private __shadowRoot: null | ShadowRoot = null;
private __shadowRootMode: null | ShadowRootMode = null;
protected __shadowRoot: null | ShadowRoot = null;
protected __internals: null | ElementInternals = null;

get shadowRoot() {
if (this.__shadowRootMode === 'closed') {
return null;
}
return this.__shadowRoot;
}
setAttribute(name: string, value: unknown) {
setAttribute(name: string, value: unknown): void {
// Emulate browser behavior that silently casts all values to string. E.g.
// `42` becomes `"42"` and `{}` becomes `"[object Object]""`.
attributesForElement(this).set(name, String(value));
Expand All @@ -54,11 +66,17 @@ const ElementShim = class Element {
}
attachShadow(init: ShadowRootInit): ShadowRoot {
const shadowRoot = {host: this} as object as ShadowRoot;
this.__shadowRootMode = init.mode;
if (init && init.mode === 'open') {
this.__shadowRoot = shadowRoot;
}
return shadowRoot;
}
attachInternals(): ElementInternals {
const internals = new ElementInternalsShim(this as unknown as HTMLElement);
augustjk marked this conversation as resolved.
Show resolved Hide resolved
this.__internals = internals;
return internals as ElementInternals;
augustjk marked this conversation as resolved.
Show resolved Hide resolved
}
getAttribute(name: string) {
const value = attributesForElement(this).get(name);
return value ?? null;
Expand Down
141 changes: 141 additions & 0 deletions packages/labs/ssr-dom-shim/src/lib/element-internals.ts
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

// As of TypeScript 4.7.4, `ARIAMixin` is missing the following properties
// https://w3c.github.io/aria/#state_prop_def
declare global {
interface ARIAMixin {
ariaBraileLabel: string | null;
ariaBraileRoleDescription: string | null;
ariaDescription: string | null;
ariaInvalid: string | null;
role: string | null;
}
}

export const ariaMixinEnum: Record<keyof ARIAMixin, string> = {
augustjk marked this conversation as resolved.
Show resolved Hide resolved
augustjk marked this conversation as resolved.
Show resolved Hide resolved
ariaAtomic: 'aria-atomic',
ariaAutoComplete: 'aria-autocomplete',
ariaBraileLabel: 'aria-brailelabel',
ariaBraileRoleDescription: 'aria-braileroledescription',
ariaBusy: 'aria-busy',
ariaChecked: 'aria-checked',
ariaColCount: 'aria-colcount',
ariaColIndex: 'aria-colindex',
ariaColSpan: 'aria-colspan',
ariaCurrent: 'aria-current',
ariaDescription: 'aria-description',
ariaDisabled: 'aria-disabled',
ariaExpanded: 'aria-expanded',
ariaHasPopup: 'aria-haspopup',
ariaHidden: 'aria-hidden',
ariaInvalid: 'aria-invalid',
ariaKeyShortcuts: 'aria-keyshortcuts',
ariaLabel: 'aria-label',
ariaLevel: 'aria-level',
ariaLive: 'aria-live',
ariaModal: 'aria-modal',
ariaMultiLine: 'aria-multiline',
ariaMultiSelectable: 'aria-multiselectable',
ariaOrientation: 'aria-orientation',
ariaPlaceholder: 'aria-placeholder',
ariaPosInSet: 'aria-posinset',
ariaPressed: 'aria-pressed',
ariaReadOnly: 'aria-readonly',
ariaRequired: 'aria-required',
ariaRoleDescription: 'aria-roledescription',
ariaRowCount: 'aria-rowcount',
ariaRowIndex: 'aria-rowindex',
ariaRowSpan: 'aria-rowspan',
ariaSelected: 'aria-selected',
ariaSetSize: 'aria-setsize',
ariaSort: 'aria-sort',
ariaValueMax: 'aria-valuemax',
ariaValueMin: 'aria-valuemin',
ariaValueNow: 'aria-valuenow',
ariaValueText: 'aria-valuetext',
role: 'role',
};

// Shim the global element internals object
// Methods should be fine as noops and properties can generally
// be while on the server.
export const ElementInternalsShim = class ElementInternals
implements ARIAMixin
{
ariaAtomic = '';
ariaAutoComplete = '';
ariaBraileLabel = '';
ariaBraileRoleDescription = '';
ariaBusy = '';
ariaChecked = '';
ariaColCount = '';
ariaColIndex = '';
ariaColSpan = '';
ariaCurrent = '';
ariaDescription = '';
ariaDisabled = '';
ariaExpanded = '';
ariaHasPopup = '';
ariaHidden = '';
ariaInvalid = '';
ariaKeyShortcuts = '';
ariaLabel = '';
ariaLevel = '';
ariaLive = '';
ariaModal = '';
ariaMultiLine = '';
ariaMultiSelectable = '';
ariaOrientation = '';
ariaPlaceholder = '';
ariaPosInSet = '';
ariaPressed = '';
ariaReadOnly = '';
ariaRequired = '';
ariaRoleDescription = '';
ariaRowCount = '';
ariaRowIndex = '';
ariaRowSpan = '';
ariaSelected = '';
ariaSetSize = '';
ariaSort = '';
ariaValueMax = '';
ariaValueMin = '';
ariaValueNow = '';
ariaValueText = '';
role = '';
__host: HTMLElement;
get shadowRoot() {
// Grab the shadow root instance from the Element shim
// to ensure that the shadow root is always available
// to the internals instance even if the mode is 'closed'
return (this.__host as HTMLElement & {__shadowRoot: ShadowRoot})
.__shadowRoot;
}
constructor(_host: HTMLElement) {
this.__host = _host;
}
checkValidity() {
return true;
augustjk marked this conversation as resolved.
Show resolved Hide resolved
}
form = null;
labels = [] as unknown as NodeListOf<HTMLLabelElement>;
reportValidity() {
return true;
}
setFormValue(): void {}
setValidity(): void {}
states = new Set();
validationMessage = '';
validity = {} as ValidityState;
willValidate = true;
};

const ElementInternalsShimWithRealType =
ElementInternalsShim as object as typeof ElementInternals;
export {ElementInternalsShimWithRealType as ElementInternals};

export const HYDRATE_INTERNALS_ATTR_PREFIX = 'hydrate-internals-';
29 changes: 29 additions & 0 deletions packages/labs/ssr/src/lib/lit-element-renderer.ts
Expand Up @@ -7,6 +7,10 @@
import {ElementRenderer} from './element-renderer.js';
import {LitElement, CSSResult, ReactiveElement} from 'lit';
import {_$LE} from 'lit-element/private-ssr-support.js';
import {
ariaMixinEnum,
HYDRATE_INTERNALS_ATTR_PREFIX,
} from '@lit-labs/ssr-dom-shim';
import {renderValue} from './render-value.js';
import type {RenderInfo} from './render-value.js';
import type {RenderResult} from './render-result.js';
Expand All @@ -29,6 +33,31 @@ export class LitElementRenderer extends ElementRenderer {
constructor(tagName: string) {
super(tagName);
this.element = new (customElements.get(this.tagName)!)() as LitElement;

// Reflect internals AOM attributes back to the DOM prior to hydration to
// ensure search bots can accurately parse element semantics prior to
// hydration. This is called whenever an instance of ElementInternals is
// created on an element to wire up the getters/setters for the ARIAMixin
// properties.
const internals = (
this.element as object as {__internals: ElementInternals}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this only work when we're using our shim. We need to think about what happens if someone loads jsdom. We may need adapters, like a getInternals() callback.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if there's anything to be done right now. Neither jsdom nor happy-dom have attachInternals() implemented and a canonical way of getting the internals object from an element doesn't exist.

Do you mean a getInternals() on the element renderer? This would be more of a DOM shim dependent thing rather than an element renderer thing.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have an issue somewhere for using another DOM shim, and this can go on the list of things we'll need to consider there. What I mean by getInternals() is that since there's no standard DOM API for getting at that from outside a component, we'll need a callback per DOM emulation library to get at it. Future work, just noting it.

).__internals;
if (internals) {
for (const [key, value] of Object.entries(internals)) {
augustjk marked this conversation as resolved.
Show resolved Hide resolved
const ariaAttribute = ariaMixinEnum[key as keyof ARIAMixin];
if (
ariaAttribute &&
value &&
!this.element.hasAttribute(ariaAttribute)
) {
this.element.setAttribute(ariaAttribute, value);
this.element.setAttribute(
`${HYDRATE_INTERNALS_ATTR_PREFIX}${ariaAttribute}`,
value
);
}
}
}
}

override get shadowRootOptions() {
Expand Down