diff --git a/.changeset/calm-dragons-listen.md b/.changeset/calm-dragons-listen.md new file mode 100644 index 00000000..92032af5 --- /dev/null +++ b/.changeset/calm-dragons-listen.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': minor +--- + +Add `Element.closest()`, `Element.classList`, and `Element.dataset` convenience APIs. diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts index 022fea99..4ea0f9a4 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -1,6 +1,10 @@ import { NS, ATTRIBUTES, + CLASS_LIST, + DATASET, + OWNER_ELEMENT, + VALUE, HTML_NAMESPACE, NODE_TYPE_ELEMENT, type NamespaceURI, @@ -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[]; @@ -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; @@ -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); } diff --git a/packages/polyfill/source/constants.ts b/packages/polyfill/source/constants.ts index 8175a8df..7b0b78cb 100644 --- a/packages/polyfill/source/constants.ts +++ b/packages/polyfill/source/constants.ts @@ -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'); diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index be1d6d2c..4ab419ef 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -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( @@ -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[], diff --git a/packages/polyfill/source/tests/Element.test.ts b/packages/polyfill/source/tests/Element.test.ts new file mode 100644 index 00000000..d193332c --- /dev/null +++ b/packages/polyfill/source/tests/Element.test.ts @@ -0,0 +1,239 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {HOOKS} from '../constants.ts'; +import {Element} from '../Element.ts'; +import {Window} from '../Window.ts'; + +describe('Element convenience APIs', () => { + let window: Window; + let element: Element; + const hooks = { + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + }; + + beforeEach(() => { + window = new Window(); + window[HOOKS] = hooks; + element = window.document.createElement('div'); + hooks.setAttribute.mockClear(); + hooks.removeAttribute.mockClear(); + }); + + describe('closest', () => { + it('finds itself and the nearest matching ancestor', () => { + const outer = window.document.createElement('section'); + const middle = window.document.createElement('div'); + outer.className = 'container'; + middle.className = 'container middle'; + element.className = 'target'; + outer.append(middle); + middle.append(element); + window.document.body.append(outer); + + expect(element.closest('.target')).toBe(element); + expect(element.closest('.container')).toBe(middle); + expect(element.closest('section > .middle')).toBe(middle); + expect(element.closest('body')).toBe(window.document.body); + }); + + it('returns null when neither the element nor its ancestors match', () => { + expect(element.closest('.missing')).toBeNull(); + }); + }); + + describe('classList', () => { + it('stays in sync with className and the class attribute', () => { + const classes = element.classList; + element.setAttribute('class', 'one two'); + + expect(element.classList).toBe(classes); + expect(Array.isArray(classes)).toBe(false); + expect([...classes]).toEqual(['one', 'two']); + expect(classes.length).toBe(2); + expect(classes[0]).toBe('one'); + expect(classes[1]).toBe('two'); + expect(classes[2]).toBeUndefined(); + expect(classes.item(0)).toBe('one'); + expect(classes.item(2)).toBeNull(); + expect(classes.contains('two')).toBe(true); + expect(classes.value).toBe('one two'); + expect(String(classes)).toBe('one two'); + + element.className = 'three'; + expect(element.getAttribute('class')).toBe('three'); + expect(classes[0]).toBe('three'); + expect(classes[1]).toBeUndefined(); + expect([...classes]).toEqual(['three']); + }); + + it('ignores indexed assignment', () => { + element.className = 'one two'; + + (element.classList as any)[1] = 'three'; + + expect(element.classList[1]).toBe('two'); + expect(element.className).toBe('one two'); + }); + + it('adds, removes, and replaces classes through attribute hooks', () => { + element.className = 'one one two'; + hooks.setAttribute.mockClear(); + + element.classList.add('two', 'three'); + expect(element.className).toBe('one two three'); + expect(hooks.setAttribute).toHaveBeenLastCalledWith( + element, + 'class', + 'one two three', + null, + ); + + element.classList.remove('one', 'missing'); + expect(element.className).toBe('two three'); + expect(element.classList.replace('three', 'four')).toBe(true); + expect(element.classList.replace('missing', 'five')).toBe(false); + expect(element.className).toBe('two four'); + }); + + it('toggles classes, including with an explicit force', () => { + expect(element.classList.toggle('active')).toBe(true); + expect(element.classList.contains('active')).toBe(true); + expect(element.classList.toggle('active')).toBe(false); + expect(element.classList.toggle('active', false)).toBe(false); + expect(element.classList.toggle('active', true)).toBe(true); + expect(element.className).toBe('active'); + }); + }); + + describe('dataset', () => { + it('returns the same live object on every access', () => { + const dataset = element.dataset; + expect(element.dataset).toBe(dataset); + + element.setAttribute('data-state', 'ready'); + expect(dataset.state).toBe('ready'); + }); + + it('reads data attributes', () => { + element.setAttribute('data-user-id', '123'); + element.setAttribute('data-state', 'ready'); + + expect(element.dataset.userId).toBe('123'); + expect(element.dataset.state).toBe('ready'); + }); + + it('writes and deletes data attributes through attribute hooks', () => { + (element.dataset as any).itemCount = 2; + + expect(element.getAttribute('data-item-count')).toBe('2'); + expect(hooks.setAttribute).toHaveBeenCalledWith( + element, + 'data-item-count', + '2', + null, + ); + + hooks.removeAttribute.mockClear(); + delete element.dataset.itemCount; + expect(element.hasAttribute('data-item-count')).toBe(false); + expect(hooks.removeAttribute).toHaveBeenCalledWith( + element, + 'data-item-count', + null, + ); + }); + + it('remains live when data attributes change directly', () => { + const dataset = element.dataset; + expect(dataset.status).toBeUndefined(); + + element.setAttribute('data-status', 'pending'); + expect(dataset.status).toBe('pending'); + + element.removeAttribute('data-status'); + expect(dataset.status).toBeUndefined(); + }); + + it('exposes data attributes as enumerable own properties', () => { + element.setAttribute('data-user-id', '123'); + element.setAttribute('data-state', 'ready'); + element.setAttribute('class', 'not-data'); + + expect(Object.keys(element.dataset)).toStrictEqual(['userId', 'state']); + expect({...element.dataset}).toStrictEqual({ + userId: '123', + state: 'ready', + }); + expect('userId' in element.dataset).toBe(true); + expect('missing' in element.dataset).toBe(false); + + const entries: [string, string | undefined][] = []; + for (const key in element.dataset) { + entries.push([key, element.dataset[key]]); + } + expect(entries).toStrictEqual([ + ['userId', '123'], + ['state', 'ready'], + ]); + + element.removeAttribute('data-user-id'); + expect(Object.keys(element.dataset)).toStrictEqual(['state']); + }); + + it('keeps inherited object members visible', () => { + element.setAttribute('data-user-id', '123'); + + expect(typeof element.dataset.toString).toBe('function'); + expect(`${element.dataset}`).toBe('[object Object]'); + expect('toString' in element.dataset).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(element.dataset, 'userId'), + ).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(element.dataset, 'toString'), + ).toBe(false); + expect(Object.keys(element.dataset)).toStrictEqual(['userId']); + }); + + it('routes defineProperty through the data attributes', () => { + Object.defineProperty(element.dataset, 'itemCount', {value: 2}); + + expect(element.getAttribute('data-item-count')).toBe('2'); + expect(Object.keys(element.dataset)).toStrictEqual(['itemCount']); + expect({...element.dataset}).toStrictEqual({itemCount: '2'}); + + expect(() => + Object.defineProperty(element.dataset, 'bad', {get: () => 'x'}), + ).toThrow(TypeError); + expect(Object.keys(element.dataset)).toStrictEqual(['itemCount']); + }); + + it('refuses preventExtensions like a legacy platform object', () => { + element.setAttribute('data-state', 'ready'); + + expect(() => Object.preventExtensions(element.dataset)).toThrow( + TypeError, + ); + expect(() => Object.freeze(element.dataset)).toThrow(TypeError); + expect(Object.isExtensible(element.dataset)).toBe(true); + + element.dataset.after = 'still-works'; + expect(element.getAttribute('data-after')).toBe('still-works'); + expect(Object.keys(element.dataset)).toStrictEqual(['state', 'after']); + }); + + it('hides data attributes whose names do not round-trip to a property', () => { + element.setAttribute('data-fooBar', 'shadowed'); + element.setAttribute('data-foo-bar', 'visible'); + + expect(Object.keys(element.dataset)).toStrictEqual(['fooBar']); + expect({...element.dataset}).toStrictEqual({fooBar: 'visible'}); + expect(element.dataset.fooBar).toBe('visible'); + + element.removeAttribute('data-foo-bar'); + expect(Object.keys(element.dataset)).toStrictEqual([]); + expect(element.dataset.fooBar).toBeUndefined(); + }); + }); +}); diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index 51303b93..2c8fc2cb 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -388,4 +388,44 @@ describe('selector parsing and matching', () => { ).toHaveLength(0); }); }); + + describe('matches and closest', () => { + let container: Element; + let highlight: Element; + + beforeEach(() => { + container = document.createElement('div'); + container.innerHTML = ` +
+
+

Text

+
+
+ `; + highlight = container.querySelector('.highlight')!; + }); + + it('matches descendant combinators through distant ancestors', () => { + expect(highlight.matches('article span')).toBe(true); + expect(highlight.matches('article .content span')).toBe(true); + expect(highlight.matches('.post .text .highlight')).toBe(true); + expect(highlight.matches('footer span')).toBe(false); + expect(highlight.matches('.missing span')).toBe(false); + }); + + it('keeps child combinators strict in matches', () => { + expect(highlight.matches('p > span')).toBe(true); + expect(highlight.matches('.content > span')).toBe(false); + }); + + it('crosses intermediate ancestors in closest', () => { + expect(highlight.closest('article .text')).toBe( + container.querySelector('p.text'), + ); + expect(highlight.closest('article section')).toBe( + container.querySelector('section'), + ); + expect(highlight.closest('.missing')).toBeNull(); + }); + }); });