Skip to content

Commit

Permalink
feat: HTMLElement.dataset (closes #112)
Browse files Browse the repository at this point in the history
  • Loading branch information
b-fuze committed Oct 26, 2023
1 parent 2c024eb commit da161cf
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 0 deletions.
113 changes: 113 additions & 0 deletions src/dom/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { Node, nodesAndTextNodes, NodeType } from "./node.ts";
import { NodeList, nodeListMutatorSym } from "./node-list.ts";
import { HTMLCollection } from "./html-collection.ts";
import {
getDatasetHtmlAttrName,
getDatasetJavascriptName,
getElementsByClassName,
getOuterOrInnerHtml,
insertBeforeAfter,
lowerCaseCharRe,
upperCaseCharRe,
} from "./utils.ts";
import UtilTypes from "./utils-types.ts";

Expand Down Expand Up @@ -486,6 +490,17 @@ export class NamedNodeMap {
}
}

const XML_NAMESTART_CHAR_RE_SRC = ":A-Za-z_" +
String.raw`\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}` +
String
.raw`\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}` +
String
.raw`\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}`;
const XML_NAME_CHAR_RE_SRC = XML_NAMESTART_CHAR_RE_SRC +
String.raw`\u{B7}\u{0300}-\u{036F}\u{203F}-\u{2040}0-9.-`;
const xmlNamestartCharRe = new RegExp(`[${XML_NAMESTART_CHAR_RE_SRC}]`, "u");
const xmlNameCharRe = new RegExp(`[${XML_NAME_CHAR_RE_SRC}]`, "u");

export class Element extends Node {
localName: string;
attributes = new NamedNodeMap(this, (attribute, value) => {
Expand All @@ -503,6 +518,7 @@ export class Element extends Node {
}
}, CTOR_KEY);

#datasetProxy: Record<string, string | undefined> | null = null;
#currentId = "";
#classList = new DOMTokenList(
(className) => {
Expand Down Expand Up @@ -625,6 +641,103 @@ export class Element extends Node {
this.setAttribute("id", this.#currentId = id);
}

get dataset(): Record<string, string | undefined> {
if (this.#datasetProxy) {
return this.#datasetProxy;
}

this.#datasetProxy = new Proxy<Record<string, string | undefined>>({}, {
get: (_target, property, _receiver) => {
if (typeof property === "string") {
const attributeName = getDatasetHtmlAttrName(property);
return this.getAttribute(attributeName) ?? undefined;
}

return undefined;
},

set: (_target, property, value, _receiver) => {
if (typeof property === "string") {
let attributeName = "data-";

let prevChar = "";
for (const char of property) {
// Step 1. https://html.spec.whatwg.org/multipage/dom.html#dom-domstringmap-setitem
if (prevChar === "-" && lowerCaseCharRe.test(char)) {
throw new DOMException(
"An invalid or illegal string was specified",
);
}

// Step 4. https://html.spec.whatwg.org/multipage/dom.html#dom-domstringmap-setitem
if (!xmlNameCharRe.test(char)) {
throw new DOMException("String contains an invalid character");
}

// Step 2. https://html.spec.whatwg.org/multipage/dom.html#dom-domstringmap-setitem
if (upperCaseCharRe.test(char)) {
attributeName += "-";
}

attributeName += char.toLowerCase();
prevChar = char;
}

this.setAttribute(attributeName, String(value));
}

return true;
},

deleteProperty: (_target, property) => {
if (typeof property === "string") {
const attributeName = getDatasetHtmlAttrName(property);
this.removeAttribute(attributeName);
}

return true;
},

ownKeys: (_target) => {
return this
.getAttributeNames()
.flatMap((attributeName) => {
if (attributeName.startsWith?.("data-")) {
return [getDatasetJavascriptName(attributeName)];
} else {
return [];
}
});
},

getOwnPropertyDescriptor: (_target, property) => {
if (typeof property === "string") {
const attributeName = getDatasetHtmlAttrName(property);
if (this.hasAttribute(attributeName)) {
return {
writable: true,
enumerable: true,
configurable: true,
};
}
}

return undefined;
},

has: (_target, property) => {
if (typeof property === "string") {
const attributeName = getDatasetHtmlAttrName(property);
return this.hasAttribute(attributeName);
}

return false;
},
});

return this.#datasetProxy;
}

getAttributeNames(): string[] {
return this.attributes[getNamedNodeMapAttrNamesSym]();
}
Expand Down
35 changes: 35 additions & 0 deletions src/dom/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ import type { Element } from "./element.ts";
import type { HTMLTemplateElement } from "./elements/html-template-element.ts";
import type { DocumentFragment } from "./document-fragment.ts";

export const upperCaseCharRe = /[A-Z]/;
export const lowerCaseCharRe = /[a-z]/;
/**
* Convert JS property name to dataset attribute name without
* validation
*/
export function getDatasetHtmlAttrName(name: string): `data-${string}` {
let attributeName: `data-${string}` = "data-";
for (const char of name) {
if (upperCaseCharRe.test(char)) {
attributeName += "-" + char.toLowerCase();
} else {
attributeName += char;
}
}

return attributeName;
}

export function getDatasetJavascriptName(name: string): string {
let javascriptName = "";
let prevChar = "";
for (const char of name.slice("data-".length)) {
if (prevChar === "-" && lowerCaseCharRe.test(char)) {
javascriptName += char.toUpperCase();
prevChar = "";
} else {
javascriptName += prevChar;
prevChar = char;
}
}

return javascriptName + prevChar;
}

export function getElementsByClassName(
element: any,
className: string,
Expand Down
108 changes: 108 additions & 0 deletions test/units/HTMLElement-dataset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { DOMParser } from "../../deno-dom-wasm.ts";
import {
assertEquals,
assertThrows,
} from "https://deno.land/[email protected]/testing/asserts.ts";

// FIXME: should be HTMLElement but it's not implemented yet
Deno.test("Element#dataset", () => {
const doc = new DOMParser().parseFromString(
`<div bar=nope foo=notdataset data-foo=bar data--foo-bar=baz></div>`,
"text/html",
)!;
const div = doc.querySelector("div")!;

assertEquals(div.dataset.foo, "bar");
assertEquals(div.dataset.FooBar, "baz");
assertEquals(div.dataset.bar, undefined);
assertEquals("foo" in div.dataset, true);
assertEquals("FooBar" in div.dataset, true);
assertEquals("bar" in div.dataset, false);
assertEquals(Object.keys(div.dataset), ["foo", "FooBar"]);
assertEquals(div.hasAttribute("data-foo"), true);
assertEquals(div.hasAttribute("data--foo-bar"), true);

delete div.dataset.FooBar;

assertEquals(div.dataset.foo, "bar");
assertEquals(div.dataset.FooBar, undefined);
assertEquals(div.dataset.bar, undefined);
assertEquals("foo" in div.dataset, true);
assertEquals("FooBar" in div.dataset, false);
assertEquals("bar" in div.dataset, false);
assertEquals(Object.keys(div.dataset), ["foo"]);
assertEquals(div.hasAttribute("data-foo"), true);
assertEquals(div.hasAttribute("data--foo-bar"), false);
assertEquals(
div.outerHTML,
`<div bar="nope" foo="notdataset" data-foo="bar"></div>`,
);

div.dataset.FIZZy = 42 as unknown as string;

assertEquals(div.dataset.foo, "bar");
assertEquals(div.dataset.FooBar, undefined);
assertEquals(div.dataset.bar, undefined);
assertEquals(div.dataset.FIZZy, "42");
assertEquals("foo" in div.dataset, true);
assertEquals("FooBar" in div.dataset, false);
assertEquals("bar" in div.dataset, false);
assertEquals("FIZZy" in div.dataset, true);
assertEquals(Object.keys(div.dataset), ["foo", "FIZZy"]);
assertEquals(div.hasAttribute("data-foo"), true);
assertEquals(div.hasAttribute("data--foo-bar"), false);
assertEquals(div.hasAttribute("data--f-i-z-zy"), true);
assertEquals(
div.outerHTML,
`<div bar="nope" foo="notdataset" data-foo="bar" data--f-i-z-zy="42"></div>`,
);

div.removeAttribute("data-foo");

assertEquals(div.dataset.foo, undefined);
assertEquals(div.dataset.FooBar, undefined);
assertEquals(div.dataset.bar, undefined);
assertEquals(div.dataset.FIZZy, "42");
assertEquals("foo" in div.dataset, false);
assertEquals("FooBar" in div.dataset, false);
assertEquals("bar" in div.dataset, false);
assertEquals("FIZZy" in div.dataset, true);
assertEquals(Object.keys(div.dataset), ["FIZZy"]);
assertEquals(div.hasAttribute("data-foo"), false);
assertEquals(div.hasAttribute("data--foo-bar"), false);
assertEquals(div.hasAttribute("data--f-i-z-zy"), true);
assertEquals(
div.outerHTML,
`<div bar="nope" foo="notdataset" data--f-i-z-zy="42"></div>`,
);

div.setAttribute("data--éclair", true);

assertEquals(div.dataset.foo, undefined);
assertEquals(div.dataset.FooBar, undefined);
assertEquals(div.dataset.bar, undefined);
assertEquals(div.dataset.FIZZy, "42");
assertEquals(div.dataset["-éclair"], "true");
assertEquals("foo" in div.dataset, false);
assertEquals("FooBar" in div.dataset, false);
assertEquals("bar" in div.dataset, false);
assertEquals("FIZZy" in div.dataset, true);
assertEquals("-éclair" in div.dataset, true);
assertEquals(Object.keys(div.dataset), ["FIZZy", "-éclair"]);
assertEquals(div.hasAttribute("data-foo"), false);
assertEquals(div.hasAttribute("data--foo-bar"), false);
assertEquals(div.hasAttribute("data--f-i-z-zy"), true);
assertEquals(div.hasAttribute("data--éclair"), true);
assertEquals(
div.outerHTML,
`<div bar="nope" foo="notdataset" data--f-i-z-zy="42" data--éclair="true"></div>`,
);

assertThrows(() => {
div.dataset["-e"] = "fails";
});

assertThrows(() => {
div.dataset["—"] = "fails";
});
});

0 comments on commit da161cf

Please sign in to comment.