Improve compliance of DOMTokenList

1 - Make element.classList settable
2 - On replace, validate in expected order
3 - On replace, fire mutation observer even if new == old
4 - On replace, handle duplicate values
This commit is contained in:
Karl Seguin
2026-02-12 14:07:31 +08:00
parent 5d8739bfb2
commit b139c05960
3 changed files with 110 additions and 13 deletions

View File

@@ -93,6 +93,29 @@
}
</script>
<script id=replace_errors>
{
const div = document.createElement('div');
div.className = 'foo bar';
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('', 'baz'));
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('foo', ''));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo bar', 'baz'));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo', 'bar baz'));
}
</script>
<script id=item>
{
const div = document.createElement('div');
@@ -166,6 +189,29 @@
}
</script>
<script id=classList_assignment>
{
const div = document.createElement('div');
// Direct assignment should work (equivalent to classList.value = ...)
div.classList = 'foo bar baz';
testing.expectEqual('foo bar baz', div.className);
testing.expectEqual(3, div.classList.length);
testing.expectEqual(true, div.classList.contains('foo'));
// Assigning again should replace
div.classList = 'qux';
testing.expectEqual('qux', div.className);
testing.expectEqual(1, div.classList.length);
testing.expectEqual(false, div.classList.contains('foo'));
// Empty assignment
div.classList = '';
testing.expectEqual('', div.className);
testing.expectEqual(0, div.classList.length);
}
</script>
<script id=errors>
{
const div = document.createElement('div');

View File

@@ -674,6 +674,11 @@ pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList {
return gop.value_ptr.*;
}
pub fn setClassList(self: *Element, value: String, page: *Page) !void {
const class_list = try self.getClassList(page);
try class_list.setValue(value, page);
}
pub fn getRelList(self: *Element, page: *Page) !*collections.DOMTokenList {
const gop = try page._element_rel_lists.getOrPut(page.arena, self);
if (!gop.found_existing) {
@@ -1480,7 +1485,7 @@ pub const JsApi = struct {
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, null, .{});
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{});
pub const dataset = bridge.accessor(Element.getDataset, null, .{});
pub const style = bridge.accessor(Element.getStyle, null, .{});
pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{});

View File

@@ -138,21 +138,59 @@ pub fn toggle(self: *DOMTokenList, token: []const u8, force: ?bool, page: *Page)
}
pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8, page: *Page) !bool {
try validateToken(old_token);
try validateToken(new_token);
// Validate in spec order: both empty first, then both whitespace
if (old_token.len == 0 or new_token.len == 0) {
return error.SyntaxError;
}
if (std.mem.indexOfAny(u8, old_token, WHITESPACE) != null) {
return error.InvalidCharacterError;
}
if (std.mem.indexOfAny(u8, new_token, WHITESPACE) != null) {
return error.InvalidCharacterError;
}
var lookup = try self.getTokens(page);
if (lookup.contains(new_token)) {
if (std.mem.eql(u8, new_token, old_token) == false) {
_ = lookup.orderedRemove(old_token);
try self.updateAttribute(lookup, page);
}
// Check if old_token exists
if (!lookup.contains(old_token)) {
return false;
}
// If replacing with the same token, still need to trigger mutation
if (std.mem.eql(u8, new_token, old_token)) {
try self.updateAttribute(lookup, page);
return true;
}
const key_ptr = lookup.getKeyPtr(old_token) orelse return false;
key_ptr.* = new_token;
try self.updateAttribute(lookup, page);
const allocator = page.call_arena;
// Build new token list preserving order but replacing old with new
var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count());
var replaced_old = false;
for (lookup.keys()) |token| {
if (std.mem.eql(u8, token, old_token) and !replaced_old) {
new_tokens.appendAssumeCapacity(new_token);
replaced_old = true;
} else if (std.mem.eql(u8, token, old_token)) {
// Subsequent occurrences of old_token: skip (remove duplicates)
continue;
} else if (std.mem.eql(u8, token, new_token) and replaced_old) {
// Occurrence of new_token AFTER replacement: skip (remove duplicate)
continue;
} else {
// Any other token (including new_token before replacement): keep it
new_tokens.appendAssumeCapacity(token);
}
}
// Rebuild lookup
var new_lookup: Lookup = .empty;
try new_lookup.ensureTotalCapacity(allocator, new_tokens.items.len);
for (new_tokens.items) |token| {
try new_lookup.put(allocator, token, {});
}
try self.updateAttribute(new_lookup, page);
return true;
}
@@ -226,8 +264,16 @@ fn validateToken(token: []const u8) !void {
}
fn updateAttribute(self: *DOMTokenList, tokens: Lookup, page: *Page) !void {
const joined = try std.mem.join(page.call_arena, " ", tokens.keys());
try self._element.setAttribute(self._attribute_name, .wrap(joined), page);
if (tokens.count() > 0) {
const joined = try std.mem.join(page.call_arena, " ", tokens.keys());
return self._element.setAttribute(self._attribute_name, .wrap(joined), page);
}
// Only remove attribute if it didn't exist before (was null)
// If it existed (even as ""), set it to "" to preserve its existence
if (self._element.hasAttributeSafe(self._attribute_name)) {
try self._element.setAttribute(self._attribute_name, .wrap(""), page);
}
}
const Iterator = struct {