Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
[labs/ssr-dom-shim] Add basic support for element internals (#3677)
Adds basic `ElementInternals` shim to the labs/ssr-dom-shim package, and `HTMLElement` shim now has an `attachInternals()` method that uses it.
`LitElementRenderer` in labs/ssr package will look for any attached internals object and, if so, will reflect any ARIA properties set there to attributes during SSR of the element. These attributes are removed during hydration of the Lit element, to favor the actual `ElementInternals` object.
---------

Co-authored-by: Augustine Kim <augustinekim@google.com>
  • Loading branch information
calebdwilliams and augustjk committed Mar 21, 2023
1 parent a5a584d commit b95c86e
Show file tree
Hide file tree
Showing 15 changed files with 344 additions and 34 deletions.
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
30 changes: 27 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 {
ariaMixinAttributes,
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,23 @@ 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 {
if (this.__internals !== null) {
throw new Error(
`Failed to execute 'attachInternals' on 'HTMLElement': ` +
`ElementInternals for the specified element was already attached.`
);
}
const internals = new ElementInternalsShim(this as unknown as HTMLElement);
this.__internals = internals;
return internals as ElementInternals;
}
getAttribute(name: string) {
const value = attributesForElement(this).get(name);
return value ?? null;
Expand Down
154 changes: 154 additions & 0 deletions packages/labs/ssr-dom-shim/src/lib/element-internals.ts
@@ -0,0 +1,154 @@
/**
* @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;
}
}

type ARIAAttributeMap = {
[K in keyof ARIAMixin]: string;
};

/**
* Map of ARIAMixin properties to attributes
*/
export const ariaMixinAttributes: ARIAAttributeMap = {
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() {
// TODO(augustjk) Consider actually implementing logic.
// See https://github.com/lit/lit/issues/3740
console.warn(
'`ElementInternals.checkValidity()` was called on the server.' +
'This method always returns true.'
);
return true;
}
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-';

0 comments on commit b95c86e

Please sign in to comment.