Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/calm-dragons-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@remote-dom/polyfill': minor
---

Add `Element.closest()`, `Element.classList`, and `Element.dataset` convenience APIs.
193 changes: 193 additions & 0 deletions packages/polyfill/source/Element.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {
NS,
ATTRIBUTES,
CLASS_LIST,
DATASET,
OWNER_ELEMENT,
VALUE,
HTML_NAMESPACE,
NODE_TYPE_ELEMENT,
type NamespaceURI,
Expand All @@ -12,6 +16,112 @@ import {NamedNodeMap} from './NamedNodeMap.ts';
import {Attr} from './Attr.ts';
import {serializeNode, serializeChildren, parseHtml} from './serialization.ts';
import {getElementsByTagName as findElementsByTagName} from './shared.ts';
import {matchesSelector} from './selectors.ts';

function toDataAttributeName(name: string) {
return 'data-' + name.replace(/[A-Z]/g, '-$&').toLowerCase();
}

function toDataPropertyName(name: string) {
return name
.slice('data-'.length)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}

function isTokenIndex(name: PropertyKey) {
return typeof name === 'string' && name === String(+name);
}

class DOMTokenList {
readonly [index: number]: string;
[OWNER_ELEMENT]: Element;

constructor(element: Element) {
this[OWNER_ELEMENT] = element;
}

get [VALUE]() {
return this[OWNER_ELEMENT].className.trim().split(/\s+/).filter(Boolean);
}

get length() {
return this[VALUE].length;
}

get value() {
return this[OWNER_ELEMENT].className;
}

set value(value: string) {
this[OWNER_ELEMENT].className = String(value);
}

item(index: number) {
return this[VALUE][index] ?? null;
}

contains(token: string) {
return this[VALUE].includes(String(token));
}

add(...tokens: string[]) {
this.value = [...new Set([...this[VALUE], ...tokens.map(String)])].join(
' ',
);
}

remove(...tokens: string[]) {
const removed = new Set(tokens.map(String));
this.value = this[VALUE].filter((token) => !removed.has(token)).join(' ');
}

toggle(token: string, force?: boolean) {
const present = this.contains(token);
const next = force === undefined ? !present : Boolean(force);

if (next !== present) {
if (next) this.add(token);
else this.remove(token);
}

return next;
}

replace(token: string, newToken: string) {
const tokens = this[VALUE];
const index = tokens.indexOf(String(token));
if (index < 0) return false;

tokens[index] = String(newToken);
this.value = [...new Set(tokens)].join(' ');
return true;
}

toString() {
return this.value;
}

[Symbol.iterator]() {
return this[VALUE][Symbol.iterator]();
}
}

Object.setPrototypeOf(
DOMTokenList.prototype,
new Proxy(
{},
{
get(target, name, receiver) {
return isTokenIndex(name)
? (receiver as DOMTokenList)[VALUE][+(name as string)]
: Reflect.get(target, name, receiver);
},
set(target, name, value, receiver) {
return isTokenIndex(name) || Reflect.set(target, name, value, receiver);
},
},
),
);

export class Element extends ParentNode {
static readonly observedAttributes?: string[];
Expand All @@ -27,6 +137,74 @@ export class Element extends ParentNode {
return this.nodeName;
}

get className() {
return this.getAttribute('class') ?? '';
}

set className(value: string) {
this.setAttribute('class', String(value));
}

[CLASS_LIST]?: DOMTokenList;

get classList() {
return (this[CLASS_LIST] ??= new DOMTokenList(this));
}

[DATASET]?: DOMStringMap;

get dataset(): DOMStringMap {
return (this[DATASET] ??= new Proxy({} as DOMStringMap, {
get: (target, name) =>
typeof name !== 'string' || Reflect.has(target, name)
? Reflect.get(target, name)
: (this.getAttribute(toDataAttributeName(name)) ?? undefined),
set: (target, name, value) => {
if (typeof name !== 'string') return Reflect.set(target, name, value);
this.setAttribute(toDataAttributeName(name), String(value));
return true;
},
deleteProperty: (target, name) => {
if (typeof name !== 'string') {
return Reflect.deleteProperty(target, name);
}
this.removeAttribute(toDataAttributeName(name));
return true;
},
defineProperty: (target, name, descriptor) => {
if (typeof name !== 'string') {
return Reflect.defineProperty(target, name, descriptor);
}
if ('get' in descriptor || 'set' in descriptor) return false;
this.setAttribute(toDataAttributeName(name), String(descriptor.value));
return true;
},
preventExtensions: () => false,
has: (target, name) =>
Reflect.has(target, name) ||
(typeof name === 'string' &&
this.hasAttribute(toDataAttributeName(name))),
ownKeys: (target) => [
...this.getAttributeNames()
.filter(
(name) =>
name.startsWith('data-') &&
toDataAttributeName(toDataPropertyName(name)) === name,
)
.map(toDataPropertyName),
...Reflect.ownKeys(target).filter((key) => typeof key !== 'string'),
],
getOwnPropertyDescriptor: (target, name) => {
if (typeof name !== 'string' || Reflect.has(target, name)) {
return Reflect.getOwnPropertyDescriptor(target, name);
}
const value = this.getAttribute(toDataAttributeName(name));
if (value == null) return undefined;
return {value, writable: true, enumerable: true, configurable: true};
},
}));
}

[ATTRIBUTES]!: NamedNodeMap;

[anyProperty: string]: any;
Expand Down Expand Up @@ -128,6 +306,21 @@ export class Element extends ParentNode {
this.attributes.removeNamedItemNS(namespace, name);
}

matches(selector: string) {
return matchesSelector(this, selector);
}

closest(selector: string) {
let element: Element | null = this;

while (element) {
if (element.matches(selector)) return element;
element = element.parentElement as Element | null;
}

return null;
}

get outerHTML() {
return serializeNode(this);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/polyfill/source/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const NS = Symbol('ns');
export const OWNER_ELEMENT = Symbol('owner');
export const OWNER_DOCUMENT = Symbol('owner_document');
export const ATTRIBUTES = Symbol('attributes');
export const CLASS_LIST = Symbol('class_list');
export const DATASET = Symbol('dataset');
export const PREV = Symbol('prev');
export const NEXT = Symbol('next');
export const CHILD = Symbol('child');
Expand Down
44 changes: 2 additions & 42 deletions packages/polyfill/source/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,8 @@ export function parseSelector(selector: string) {
return parts;
}

function matchesSelector(element: Element, selector: string) {
const parsed = parseSelector(selector);
let part: Part | undefined;
while ((part = parsed.pop())) {
if (!matchesSelectorPart(element, part)) return false;
}
return true;
export function matchesSelector(element: Element, selector: string) {
return matchesSelectorRecursive(element, parseSelector(selector));
}

function walkNodesForSelector(
Expand Down Expand Up @@ -212,41 +207,6 @@ function matchesSelectorRecursive(element: Element, parts: Part[]): boolean {
}
}

function matchesSelectorPart(element: Element, {combinator, matchers}: Part) {
if (combinator === COMBINATOR_INNER) {
return matchesSelectorMatcher(element, matchers);
}
const link =
combinator === COMBINATOR_CHILD || combinator === COMBINATOR_DESCENDANT
? PARENT
: PREV;
let ref = element[link];
if (!ref) return false;

// For sibling combinators, skip non-element siblings
if (combinator === COMBINATOR_ADJACENT && !isElementNode(ref)) {
while (ref && !isElementNode(ref)) {
ref = ref[link];
}
if (!ref) return false;
}

if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers)) {
return false;
}

if (
combinator === COMBINATOR_DESCENDANT ||
combinator === COMBINATOR_SIBLING
) {
while ((ref = ref[link])) {
if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers))
return true;
}
}
return true;
}

function matchesSelectorMatcher(
element: Element | null,
matcher: Matcher | Matcher[],
Expand Down
Loading
Loading