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 all 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
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);
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
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;
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-';