-
Notifications
You must be signed in to change notification settings - Fork 294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add convenient, ergonomic, performant API for sorting siblings #586
Comments
@domenic and I were discussing this issue this evening; one suggestion, inspired by Roma’s post, was to have an API that accepts the name of an attribute and sorts by its value (with an optional comparison function if the standard string-sorting algo wasn't sufficient). @domenic also had another, more generic approach of passing a function or method, the details of which I've now forgotten. :( |
One question: on what interface would this method exist? Would it be on
You might want to sort by data that isn't stored in attributes (like the text content, or sorting by whether the checkboxes are currently checked). And the things you're sorting on might be properties of child elements (e.g., when sorting table rows by the data in cells). Finally, you may have a list of sort values (checked vs unchecked, then alphabetically by content). The simplest initial API would take a generic comparison function that is passed two element objects, inside the function, the author could then query child elements, attributes, or properties, and compare them in order. Example: container.reorderChildren((el1, el2) => {
/* sort checked items before unchecked,
then alphabetically by description */
return (el1.querySelector(".ToDoList__done").checked
- el2.querySelector(".ToDoList__done").checked) ||
(el1.querySelector(".ToDoList__description").textContent
- el2.querySelector(".ToDoList__description").textContent );
}); A more complex (but easier to optimize) API would take two arrays of functions:
The optimization benefit of this approach, compared to doing it all in a single sort function, is that you'd only run each accessor function (which might include things like Example of this second API: const sortingKeys = {
dueDate: (el)=>(new Date( el.querySelector(".ToDoList__dueDate").textContent)),
label: (el)=>(el.querySelector(".ToDoList_description").textContent),
done: (el)=>(el.querySelector(".ToDoList_done").checked),
}
const sortingComparators = {
dueDate: (a, b)=>( (isFinite(a)-isFinite(b)) || (a - b) ),
//valid dates before invalid, then sort by Date
/* label would sort by default comparator */
done: (a,b)=>(a - b)
//this would be default comparator if we don't convert boolean to string
}
let sortBy = [done, dueDate, label]; // this would change based on user selections
container.reorderChildrenBy( sortBy.map((key)=>sortingKeys[key]) ,
sortBy.map((key)=>sortingComparators[key] ) ); (One thing missing from that example is a way to reverse the sort order. Not sure if that should also be part of the API, or if it makes sense to require the author to adjust the comparator functions.) |
It would probably be good to look at various JavaScript sorting libraries and see what they have on offer. (HTML tried to offer sortable tables before, but perhaps that was too high-level and an API is the right answer here.) |
The trick here is creating something that's simple enough to be attractive and woo folks away from CSS ordering, but isn't too restrictive. My thought was something like this strikes the balance: parentEl.reorderChildren(el => el.dataset.order);
// Perhaps even this, as an overload?? Then we don't cross back into JS at all.
parentEl.reorderChildren("data-order"); where it converts the return value to a number and does sorting that way. The alternative is going full-on comparator like @AmeliaBR's version. But I think that tips the feature into the territory of losing out to CSS ordering on ease of use. It also hurts potential performance benefits; instead of calling into JS for the comparator n times, you call in at least O(n log n) times. On the other hand, requiring the sorting key to be numeric as I have done hurts the cases where you want to do string comparison :-/. It's really a question of how big the scope here should be, or stated another way, how much we want to tradeoff authors doing setup work to prepare for a simple reorder call vs. authors doing complex reorder calls. |
To clarify, @domenic, you're suggesting that the accessor function/attribute would need to return an integer index? Or at least numeric? (That seems more useful than string comparison, which is what I was thinking was meant by using an attribute value.) With a numeric key, an author could map the child elements to an array, sort that with custom JS comparators, and then save the resulting array indices on the elements. Which is no worse, from an authoring perspective, than any existing options for sorting elements. Although it does mean that the sorting is happening twice: once in JS, to generate the index values, and then once in the DOM method, to sort the actual elements by the indices. But for a table-sorting use case, where data in the table isn't changing, you would only need to calculate the sort order for each key once. Could the method be overloaded, so that if the sort key parameter is a function, it gets called for each element, otherwise it's used as the name of an attribute to parse? Or is that too much of a JS thing for a DOM API? What about the idea of taking a list of different keys, for tie-breaker values (so that, for static content like a data table, you could calculate out the sort order for each key separately, and store in different element attributes/properties)? And/or having an easy built-in way to reverse the sort order? Per @annevk's question, What Would JS Libraries Do?
|
Yeah, that was the idea.
Right, that overload was what I was implying with my "Perhaps even this" sample.
I'm hesitant about this kind of scope creep, but it might be doable. However, note that it's easy to do on top of the simpler API:
Again I'm unsure on the scope creep. For example JS's Great survey of existing libraries. I also forgot to mention that your concern about non-element children is a real one and I'm not sure what the right answer is. I do think for most use cases there won't be any, but does that justify making an API that does something strange like shuffling them to the end? |
Also I should note I'm not wedded to the idea of a numeric key-based method. A comparator function like JS's array.sort() seems like a better fit with the existing platform, and a bit more powerful, but it definitely brings extra complexity. I'm just unsure. |
I don’t think any of that would woo me away from using CSS to move a navigation bar to a different spot for mobile. What would be better would be a CSS property/value to indicate that it was OK for AT and tab order to be affected by that visual order change. |
If we define this as
we could make it a primitive of sorts that generates a single mutation record. That would also ensure script doesn't have much chance of changing the world view too much. |
There's two reasons people use Having a sorting method that takes a comparator function which accepts two elements would be convenient, and might be worth having, but it alone wouldn't solve the problem here. As @domenic says, we need something that doesn't call back into JS for the simple cases, and as @annevk says, it should be an atomic operation from the DOM's perspective. Otherwise we give up most of the optimization opportunities that would allow for comparative performance. Sorting by passing in an attribute name would get you parity with Roma's CSS-based solution, both in simplicity and (insofar as possible) performance. If it's not too hard, I'd suggest accepting numberish things rather than just numbers, so that you can get dates and dimensions and currency sorts (on normalized values) for free. Folks wanting string sorting or other fancy operations client-side would need to either generate numeric keys, or use a method that takes a comparator-function. (They can't use @bradkemper That's off-topic. Whatever we end up with here, please file an issue back against Flexbox to add some good examples, to steer people towards it once it's available. :) |
For N elements there are at least N! distinct possible renderings of the content without duplicate orderings of the sets. A map of the total possible permutations of the input data (HTML content as string) could be created corresponding each set to a distinct logical key. When the predefined conditions are met, the value for the single logical key can be set at |
Wouldn't using Anne's idea, of describing the operation in terms of grouped transactions, allow the JS comparator approach at least pass the second measure? The only question is whether you'd run the comparator function (on all elements, to determine the abstract sort order) before touching the DOM, or if you extract all the elements first, and sort them in a detached state. The first approach makes it more "atomic", and allows you to use comparator functions that only work on attached elements, but any DOM-altering side effects in the comparator could make the sort order undefined.
That seems to open up a lot of ambiguity. What determines the parsing rules for converting from string attribute values to the correct numeric type? |
@AmeliaBR The index of an element within a collection of N possibly infinite sets of values is sufficient to get that specific set within the total possible lexicographic arrangements of individual values within a set of a collection. If the elements of any set are unique the values themselves can be ignored. The question, from perspective here, would be is it more expensive to create a map of the total dataset with corresponding logical key to get an index of a pre-sorted and stored set which retrieves that unique value, or to sort the dataset dynamically at each call to the sort function without storing the accrued results of the sort function calls, to prevent for example, having to iterate to 51 out of 100 or 49 in reverse to get a single sort order that will not change from the initial call to the compare function to the next call to the compare function. |
Agenda+ to discuss this proposal at an upcoming triage meeting. There is now a ED CSS specification for display order: https://drafts.csswg.org/css-display-4/#display-order And as referenced here, it would be good to also have a DOM sorting API for some use cases. |
Full proposal:
And:
|
This is close to React keyed nodes so it allows somehow DOM diffing, a feature I’ve asked for years ago already, except is limited to always same list of nodes. Why not doing an extra step to handle also removed nodes and eventual new nodes, at this point? That way all diffing libraries can be gone and performance would win. udomdiff among others can help suggesting the implementation details too, happy to help as I can as I wrote 5 different algo for diffing already. |
As couple of examples/ideas around this:
Maybe |
I'd like to understand the exact use case you're describing. Is it this: Replace children of a node with a given list of nodes, in the specified order And expect the UA to do so in the most efficient way possible, such as by avoiding re-creation of nodes in the list that are already children of the given node? Is an algorithm for this use case deployed in a diffing algorithm for React or some other common library? |
I am surprised react keys are not known today, but this is the documentation about it. Ported to this <ul id="list">
<li data-order="2">b</li>
<li data-order="1">a</li>
<li data-order="3">c</li>
</ul> If I understood correctly what's being proposed, a <ul id="list">
<li data-order="1">a</li>
<li data-order="2">b</li>
<li data-order="3">c</li>
</ul> and every single Let's see a swap nodes, using the keys as extra approach: <ul id="list">
<li data-order="1" key="a-key">a</li>
<li data-order="2" key="b-key">b</li>
<li data-order="3" key="c-key">c</li>
</ul> Now swapping 1 with 3 would preserve the node identity and the JS would be like: list.firstElementChild.dataset.order = 3;
list.lastElementChild.dataset.order = 1;
list.reorderChildren("data-order"); Now we have preserved identity and a way to diff a previous state with the current one, where some node got prioritized or de-prioritized but all of them have kept their references. This is an extremely limited way to deal with the potential of a diffing previous-next API that could not only order by all means, not just numerically for attributes that also are always strings on the DOM, but it could take into account new nodes either inserted or removed at a specific index of the list, so that such API would cover every single possible use case out there, instead of requiring effort to cover only numerical sort out of strings, avoiding possible growing and shrinking lists to benefit from such API (a TODO list is already a good example of that, as mutable state to handle). I hope this explains better why this API would have 1% of the potential it could have if it would instead just diff the passed along list of nodes as iterable that could benefit from any sorting method, not just numeric, and avoid tons of boilerplates for libraries authors to deal with just the number, as that requires a "keep in sync" attributes with diffing for an API that, as presented, already takes care of diffing previous nodes with the new ordered one. |
@chrishtr as per #891 (comment) I think solving the interoperability issues around #808 is a pre-requisite here. |
Please first focus on lower level API (ability to set new/reordered array of DOM children), as sorting by attribute value could be easily implemented on top of it. Also same feature should be working in SVG context. |
The diffing use case sounds interesting, but still not clear to me how in practice an API like is being proposed here solves that use case. (And there is also the replaceChildren API...) In any case, I would like to focus this issue instead on solving the use case of a visual order sorting of DOM siblings that is easy enough to use that developers will, like @domenic said, use it instead of CSS order when CSS is not the right tool for the job. The original comment in this issue is really about ergonomics of sorting children, not performance. I'd like the triage meeting discussion this week to focus on this use case, whether it is important enough to support directly in a DOM API, and how best to meet it if so (assuming it's needed, that's how I arrived at this proposal). |
Speaking of CSS |
That's a good question. Right now it does not affect accessibility or tab index order, but there are discussions to change that so as to improve accessibility when CSS |
The reason I ask is because if it's handled that way, you could do sorting and similar reordering patterns as follows, deferring most of the real work to the layout engine and other things that consume styled DOMs:
This of course is pretty limited and only works for lists with a single shared ancestor, and custom elements that manage their own elements separately from their shadow roots and such would still have to implement it manually. I wonder if this would work? Or would it be too hackish? Obviously it's not the most elegant, but it does use the CSS Edit: SVG would also need an attribute added with similar functionality. Just wanted to call that out as a potential concern. |
As that name suggests, With this argument, we don't need any sorting API neither: parent.replaceChildren(
[...parent.children].sort(
(a, b) => (
parseFloat(a.dataset.order) -
parseFloat(b.dataset.order)
)
)
); If the sorting is smarter at not removing children that don't need to be moved (less mutations on the tree) then it is a missed opportunity to confine that ability to same list length and same nodes only and via attributes, that is why I've raised my suggestion we could do a little more and solve diffing forever on the DOM. |
@chrishtr it's worth mentioning that abusing the API already allows diffing (at least per elements, not per mixed elements and text nodes (or comments) ... example: const diff = (parent, prev, curr) => {
const {length: plength} = prev;
const {length: clength} = curr;
const childs = [];
let i = 0, length = i;
// order old nodes after
while (i < plength)
prev[i++].dataset.i = (clength + i);
i = 0;
// order new nodes before
while (i < clength) {
const child = curr[i++];
child.dataset.i = i;
if (child.parentNode !== parent)
length = childs.push(child);
}
if (length)
parent.append(...childs);
// reorder all by attribute
parent.reorderChildren('data-i');
// drop nodes still there
length += plength;
if (i < length) {
const {children} = parent;
const range = new Range;
range.setStartBefore(children[i]);
range.setEndAfter(children[length - 1]);
range.deleteContents();
}
return curr;
}; Assuming for now the polyfill would be like: Element.prototype.reorderChildren = function (attributeName) {
this.replaceChildren(
...[...this.children].sort(
(a, b) => (
parseInt(a.getAttribute(attributeName)) -
parseInt(b.getAttribute(attributeName))
)
)
);
}; Given the following operations would move, insert once, and drop all at once on every needed situation: document.body.innerHTML = `<ul><li>a</li><li>b</li><li>c</li></ul>`;
const ul = document.body.firstElementChild;
let children = [...ul.children];
// nothing happens
children = diff(ul, children, children);
// a swaps with c
children = diff(ul, children, [children[2], children[1], children[0]]);
let li = document.createElement('li');
li.textContent = 'd';
// result into c, b, a, d
children = diff(ul, children, children.concat(li));
// b, a remain
children = diff(ul, children, [children[1], children[2]]);
// d, b
children = diff(ul, children, [li, children[0]]);
// z
children = diff(ul, children, [document.createElement('li')]);
children[0].textContent = 'z'; After all this already helps diffing nodes in a keyed like way so maybe it's OK to have at least this helper and be creative around its full potentials. |
Chiming in here since I rewrote Mithril's keyed diffing algo. There are quite a few things to have in mind if you want to solve node sorting holistically:
Here's an example that showcases some these scenarios (no comments here since Mithril does not use them). Note that when we move the nodes around in a keyed list, the DOM node identity is preserved. For the details of the operation, it would be ideal from an app developer perspective to make the reordering
With these concerns in mind, IMO an optimal API would be something like this: parent.replaceRangeAtomically(
previousSibling: Node|null,
nextSibling: Node|null,
newNodes: Array<Node|null>
)
Regarding the implementation, since moving nodes is expensive, we minimize the number of operations by computing the longest subsequence of keys that have the same order in the old and the new list, and only move the other nodes. So if we move from nodes with keys |
@pygy you just rewrote my initial comment around making this API more generic, and all your algorithms are already part of udomdiff, domdiff, majinbu, or speedy Mayern approach I’ve explored, but like I’ve demoed already, those are implementation details and this API already provides a way to diff natively. The only point I fully agree is the “node not leaving the DOM while sorted” so I’m plus 1 on iframes and focused elements, to name a few, not re-initializing themselves, same way diffing already works in every modern library, but I don’t know full constraints behind this expectation |
@WebReflection This comment was addressed to the WhatWG members, not you personally.
|
@pygy im with you, I just don’t think the generic API is being considered, so something is better than nothing. I already proposed a generic API alternative that could work with other child nodes too, and by no mean I meant to appropriate to myself algorithms used here and there. Majinbu is out of levenshtein distance as example, only udomdiff is from scratch yet based on LOS in a branch of the logic. Again, thanks for your input, I hope it’ll be considered, but I also think current proposal might address already most use cases. |
It does not address this case (and variations thereof): const GimmeAKeyedList() {
return someArray.map(x => <div key={x}>x</div>)
}
render(root, <div>
<hr />
<GimmeAKeyedList />
<hr />
</div>
) Suppose that
In order to handle that case efficiently, frameworks need an API that has the conceptual shape I suggested. I could also be parent.replaceChildren(nodes: Array<Nodes|null>, options?: {previousSibling?,nextSibling?}) Which would make it more ergonomic when you want to replace/reorder all the children of an element. |
In that regard doesn’t address “pinned changes” neither, those confined within a parent subset of nodes, common with UI when changes should be confined, addressed by my libs or lit via comments to pin-point before what node changes should happen, or within comments nodes as delimiter of a virtual fragment. |
Could you give an example? Edit: do you mean that the original proposal doesn't work, or my suggestion? |
@pygy your suggestion is an example ... uhtml/lit or others would use a |
P.S. I like the If this proposal wants to replace the need for CSS ordering, where none of this happens (including iframes, just moved around but they never leave the DOM) we can't use |
Re. not detaching nodes, agreed (as per my first comment), it was more of a suggestion for the API shape. It would also be nice to be able to move nodes diagonally (changing their parents) within the same document while preserving state. The same method could be used. @WebReflection so you have nodes that are pinned within lists of nodes that can move? Did I get this right? I've dabbed with a finely reactive framework and also use pairs of comments, but to delineate the dynamic bits. So The only missing piece would be delayed node removal for exit animations. The |
Could you please take this conversation to https://whatwg.org/chat or equivalent? There's over a 150 people watching this repository and the lengthier and less clear issues become, the less likely they are to get fixed. |
Based in the discussion at the last triage meeting, I am interested in trying to satisfy both #586 and #891 with one new method: Element.reorderChildren(). Element.reorderChildren will take an array of nodes which are already children of the element and then reorder its children to match the provided list. If any nodes are in the given array but not in the child list, they are ignored. Similarly if any elements of the provided array aren’t Nodes, they are ignored. If any nodes are in the child list but not the array, they preserve their relative DOM order to each other but are placed at the end of the new child list. The purpose of all of these conditions is to ensure that No synchronous Mutation Events are fired. A MutationObserver “childList” observation will be emitted. This might need a new MutationRecord type such as DOM Ranges which include a current child at the beginning or end of the Range will retain that same child after the reorder. Images, iframes, input elements, embedded objects and other elements which maintain internal state retain that state and cannot observe the change internally to the element.
I’m happy to help with interop issues here but I believe that it’s pretty straightforward: no script can run synchronously during a call to reorderChildren(). Since iframes and script elements can’t be inserted or removed, and no synchronous mutation events can be fired, this shouldn’t be possible.
If we want to insert or remove nodes, then we have to not only resolve #808 but also Ryosuke’s concerns about iframes and scripts. I’d like to help out with DOM diffing but I don’t think that is within scope of this method to simply reorder nodes. However, with the new (performant) |
@josepharhar I strongly support the proposal of a basic reordering primitive, which'll allow people to invent their own sort/etc operations on top of it. That's a great idea, and your description of the behavior sounds ideal. However, that still leaves the base case ("I want to sort these table rows by the value of some cell" or "I want to sort this list by the value of some attribute") relatively unergonomic to do without a library. (At minimum it's Luckily having the base primitive means we don't need to get complicated with the sugar; we can focus on the simplest case and let libraries handle all the in-between. I suggest having a second reordering method (suggested name So total suggested API: partial interface ParentNode {
Element reorderChildren(sequence<Node> children);
Element sortChildElements((DOMString or UnaryElementKeyer) keyFn);
};
callback UnaryElementKeyer = any (Element); (I'm assuming that these return the parent node. They could also return (
ParentNode.prototype.sortChildElements = function(key) {
if(typeof key == "string") key = x=>+x.getAttribute(key);
const keyedChildren = Array.from(this.childElements, el=>[key(el), el]);
keyedChildren.sort((a,b)=>a[0] < b[0] ? -1 : a[0]==b[0] ? 0 : 1);
return this.reorderChildren(keyedChildren.map(x=>x[1]);
} |
It can be implemented in linear time with the following algo (I suppose this is what you had in mind).
This lets the child list in the state you described.
Having the possibility to specify the I still would like to have a way to re-parent nodes without reinitializing them (assuming there is implementer interest too), but this would require a different API. I'm therefore mapping and compiling the state of affairs re. #808 and side effects timelines. There are many inconsistencies but I think UAs could be brought in line with a consistent model without breaking WebCompat™ (relying on the fact that, since the timeline is inconsistent, relying on it right now is not viable). Edit: thanks to Anne and Tab for bouncing ideas about this in Edit again, the algo is even simpler than I thought. |
What does cruft mean?
Any reason to prepend instead of append like my last comment suggests? |
Cruft is either not a Node, or a Node that isn't a child of If the method is meant to evolve in the future to accept non-child nodes, we could also throw TypeErrors for now when "cruft" is encountered, leaving room for the API update if we crack external nodes at some point. By prepending in reverse order you automatically leave the children that were not in the I had not understood you meant to append. |
Responding to just the reordering primitive use case here: Preserving focus and not reloading resources is absolutely great, however we get there.
Something like Because
Sorting child nodes as in the other use case is something I've had to deal with less often, and usually via the looping mechanisms we have for the reorder use case, but I have seen it with tables. I think there it'd be very useful to be able to sort a sublist too, via before and after reference nodes. |
The reason I want to build on top of #808 is because that will make it clear what kind of behavior elements have for removal and insertion. Some of that behavior we may want to preserve when moving them, even though we would not want to preserve all behavior (such as the behavior that results in script execution). Therefore I think we should solve that issue so we know exactly what the removal and insertion semantics are and can then much more confidently discuss move semantics. |
What if instead of fixing this as a sorting problem, we treat it as a batched-update problem, and present an API that does specifically that? e.g. (strawman) a function that takes an array of remove/insertAdjacentElement calls document.executeBatchUpdates([
{ remove: node },
{ insert: [parent, child, 'afterbegin'] }
]) Side note: at Wix we had to jump through crazy hoops because of the state-reset-at-reparent-or-reorder issue, this is far from being an exotic problem in my experience. |
[Could've sworn I filed this already, but maybe we only talked about it.]
Back in 2015 Bo Campbell from IBM reported a major accessibility problem with websites using Flexbox: they were using the
order
property to to perform semantic re-ordering of elements, because theorder
property was much easier and more performant than re-ordering with JavaScript.The problem with this is that the re-ordering isn't reflected in the accessibility tree or in the navigation order: CSS deliberately forbids this, since the point of this property was to create a divergence between the visual positioning and logical ordering of the elements.* Our continued position is that semantic ordering belongs in the source.
* The reason CSS allows this divergence is because visual perception is 2D and is affected by things like the gestalt principles. Size differences, color contrast, spatial grouping, and other techniques are regularly used by designers to guide the eye around the page in patterns that don't follow a simple top-left->bottom-right coordinate scan; allowing the spatial order to diverge from the source order lets designers map the source order to the intended visual perception order. (This is all out-of-scope for the issue, just wanted to head off any off-topic comments like “why does CSS define
order
this way”.)However, because reordering elements in the DOM is so unwieldy, authors are sorely tempted to use
order
for it. Roma Kamarov’s sorting solution with CSS variables, for example, is so much more straightforward than a JS approach. It would be helpful if the DOM offered an API for sorting siblings that was as simple and powerful as sorting withorder
in CSS; even if it can't be quite as performant as a CSS-only solution (since it has to do strictly more work by altering the DOM tree as well), it would hopefully be useful enough to head off some of the more misguided uses of CSS, and provide a useful convenience to people using JS regardless.The text was updated successfully, but these errors were encountered: