Skip to content
3 changes: 3 additions & 0 deletions web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
- Added `URL.toDart` and `Uri.toJS` extension methods.
- Added missing `Document` and `Window` pointer event getters: `onDrag*`,
`onTouch*`, `onMouse*`.
- Added `JSLiveNodeListWrapper` to support mutable operations on nodes lists.
- Added `childNodesAsList` to `Node` and `childrenAsList` to `Element` via
extensions.

## 1.1.1

Expand Down
12 changes: 12 additions & 0 deletions web/lib/src/helpers/extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import 'dart:convert';
import 'dart:js_interop';

import '../dom.dart';
import 'lists.dart';

export 'cross_origin.dart'
show CrossOriginContentWindowExtension, CrossOriginWindowExtension;
Expand Down Expand Up @@ -103,3 +104,14 @@ extension UriToURL on Uri {
}
}
}

extension NodeExtension on Node {
/// Returns [childNodes] ad modifiable [List]
List<Node> get childNodesAsList => JSLiveNodeListWrapper(this, childNodes);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be nodesAsList, because that was a special helper that dart:html provided in addition to childNodes (which was an immutable list).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I cant see nodesAsList but nodes in dart:html. I think this is less confusing 'nativeMethod+AsList' but up to you of course.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry I meant nodes exists in dart:html. I think you bring up a good point though, I like the nativeMethod+AsList syntax even if it's not consistent with dart:html.

}

extension ElementExtension on Element {
/// Returns [children] ad modifiable [List]
List<Element> get childrenAsList =>
JSLiveNodeListWrapper<Element, HTMLCollection, Element>(this, children);
}
33 changes: 33 additions & 0 deletions web/lib/src/helpers/lists.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:collection';
import 'dart:js_interop';
import '../dom/dom.dart';

/// `_JSList` acts as a wrapper around a JS list object providing an interface to
/// access the list items and list length while also allowing us to specify the
Expand Down Expand Up @@ -69,3 +70,35 @@ class JSImmutableListWrapper<T extends JSObject, U extends JSObject>
@override
U elementAt(int index) => this[index];
}

/// A wrapper for live node lists. `NodeList` and `HTMLCollection` that are
/// [live](https://developer.mozilla.org/en-US/docs/Web/API/NodeList#live_vs._static_nodelists)
/// can be safely modified at runtime. This requires an instance of `P`, a
/// container that elements would be added to or removed from.
class JSLiveNodeListWrapper<P extends Node, T extends JSObject, U extends Node>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dart:html has overrides for first and last for children that use firstElementChild and lastElementChild, respectively.

I think this is the same as item(0) and item(len - 1) for children, but may be worth double-checking.

remove as well is a bit tricky because we should likely use removeChild instead of the gap-closing that ListMixin does.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is realiable source:
https://stackoverflow.com/questions/43324751/is-there-a-difference-between-children0-and-firstelementchild
seems difference is only in returned value when list is empty but we throw exceptions in both cases.

I've added remove. It was indeed tricky as type of parameter in List interface is Object?, please take a look if type checking i used makes sense.

extends JSImmutableListWrapper<T, U> {
P parentNode;

JSLiveNodeListWrapper(this.parentNode, super.original);

@override
set length(int value) {
if (value > length) {
throw UnsupportedError('Cannot add null to live node List.');
}
for (var i = length - 1; i >= value; i--) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is new compared to the dart:html version. I think that's okay though since we were throwing in the dart:html version anyways.

Copy link
Contributor Author

@fsw fsw Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it is here to reuse JSImmutableListWrapper logic without overriding many methods

parentNode.removeChild(_jsList.item(i));
}
}

@override
void operator []=(int index, Node value) {
parentNode.replaceChild(value, _jsList.item(index));
}

@override
void add(Node element) {
// `ListMixin` implementation only works for lists that allow `null`.
parentNode.appendChild(element);
}
}
49 changes: 49 additions & 0 deletions web/test/helpers_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,55 @@ void main() {
expect(() => dartList[0], returnsNormally);
});

test('modify child nodes using JSLiveNodeListWrapper', () {
final div = (document.createElement('div'))
..append(document.createElement('div')..textContent = '1')
..append(document.createElement('div')..textContent = '2')
..append(document.createElement('div')..textContent = '3');

final childNodesList = div.childNodesAsList;
final childrenList = div.childrenAsList;

// Ensure initial list length is correct.
expect(childNodesList.length, 3);
expect(childrenList.length, 3);

childrenList.removeWhere((node) => node.textContent == '2');

// Ensure both list were updated.
expect(childNodesList.length, 2);
expect(childrenList.length, 2);

// add node via children
childrenList.add(document.createElement('div')..textContent = '4');
// add node via childNodes
childNodesList.add(document.createElement('div')..textContent = '5');
// add node directly to parent
div.appendChild(document.createElement('div')..textContent = '6');

// Ensure 3 elements were added to both lists
expect(childNodesList.length, 5);
expect(childrenList.length, 5);

// add only text nodes
childNodesList.addAll(
[document.createTextNode('txt1'), document.createTextNode('txt2')]);

// Ensure only childNodes list changed
expect(childNodesList.length, 7);
expect(childrenList.length, 5);

// replace element with text node
childNodesList[2] = document.createTextNode('txt3');

// test retainWhere, keep Elements only
childNodesList.retainWhere((e) => e.isA<Element>());

// Ensure only text nodes were removed
expect(childNodesList.length, 4);
expect(childrenList.length, 4);
});

test('responseHeaders transforms headers into a map', () async {
final request = XMLHttpRequest()
..open('GET', 'www.google.com')
Expand Down