(root.querySelector(`#${id}`), 'DisclosureGroup');
+}
+
+describe('a group of disclosures', () => {
+ it('collects its members in document order', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a')}
+ ${disclosureMarkup('b')}
+
+ `);
+
+ expect(group(root, 'grp').members).toEqual([disclosure(root, 'a'), disclosure(root, 'b')]);
+ expect(disclosure(root, 'a').group).toBe(group(root, 'grp').api);
+ });
+
+ it('keeps one open at a time', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a')}
+ ${disclosureMarkup('b')}
+
+ `);
+ const a = disclosure(root, 'a');
+ const b = disclosure(root, 'b');
+
+ a.$refs.trigger.click();
+ expect([a.isOpen, b.isOpen]).toEqual([true, false]);
+ b.$refs.trigger.click();
+ expect([a.isOpen, b.isOpen]).toEqual([false, true]);
+ });
+
+ it('works with no group above it', async () => {
+ const root = await render(disclosureMarkup('lonely'));
+ const lonely = disclosure(root, 'lonely');
+
+ expect(lonely.group).toBeUndefined();
+ lonely.$refs.trigger.click();
+ expect(lonely.isOpen).toBe(true);
+ });
+
+ it('lets an open peer that mounts later lose to the one before it in the DOM', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a', true)}
+
+ `);
+ const a = disclosure(root, 'a');
+ expect(a.isOpen).toBe(true);
+
+ // The late peer follows `a` in the DOM, so `a` keeps the open state.
+ root.querySelector('#grp')?.insertAdjacentHTML('beforeend', disclosureMarkup('b', true));
+ await settle();
+ const b = disclosure(root, 'b');
+
+ expect(group(root, 'grp').members).toEqual([a, b]);
+ expect([a.isOpen, b.isOpen]).toEqual([true, false]);
+ });
+
+ it('lets an open peer that mounts later win when it precedes the others', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('b', true)}
+
+ `);
+ const b = disclosure(root, 'b');
+ expect(b.isOpen).toBe(true);
+
+ // Same late mount, opposite document position: the newcomer wins instead.
+ root.querySelector('#grp')?.insertAdjacentHTML('afterbegin', disclosureMarkup('a', true));
+ await settle();
+ const a = disclosure(root, 'a');
+
+ expect(group(root, 'grp').members).toEqual([a, b]);
+ expect([a.isOpen, b.isOpen]).toEqual([true, false]);
+ });
+
+ it('joins a group that mounts after its members', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a')}
+ ${disclosureMarkup('b')}
+
+ `);
+ const a = disclosure(root, 'a');
+ expect(a.group).toBeUndefined();
+
+ root.querySelector('#grp')?.setAttribute('data-component', 'DisclosureGroup');
+ await settle();
+
+ expect(a.group).toBe(group(root, 'grp').api);
+ expect(group(root, 'grp').members).toEqual([a, disclosure(root, 'b')]);
+ });
+
+ it('gives a nested group its own members', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('o', true)}
+
+ ${disclosureMarkup('i')}
+
+
+ `);
+ const outer = group(root, 'outer');
+ const inner = group(root, 'inner');
+ const o = disclosure(root, 'o');
+ const i = disclosure(root, 'i');
+
+ expect(outer.members).toEqual([o]);
+ expect(inner.members).toEqual([i]);
+
+ // The nested member's open state is the inner group's business only.
+ i.$refs.trigger.click();
+ expect([o.isOpen, i.isOpen]).toEqual([true, true]);
+ });
+
+ it('hands a member over to a nearer group inserted later', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a')}
+ ${disclosureMarkup('b')}
+
+ `);
+ const outer = group(root, 'outer');
+ const b = disclosure(root, 'b');
+ expect(outer.members).toHaveLength(2);
+
+ const inner = document.createElement('div');
+ inner.id = 'inner';
+ inner.setAttribute('data-component', 'DisclosureGroup');
+ root.querySelector('#outer')?.append(inner);
+ inner.append(b.$el);
+ await settle();
+
+ // The same instance changed hands: the subscription was re-answered, the
+ // member was not destroyed and rebuilt.
+ expect(disclosure(root, 'b')).toBe(b);
+ expect(outer.members).toEqual([disclosure(root, 'a')]);
+ expect(group(root, 'inner').members).toEqual([disclosure(root, 'b')]);
+ });
+
+ it('drops a member whose element leaves the DOM', async () => {
+ const root = await render(`
+
+ ${disclosureMarkup('a')}
+ ${disclosureMarkup('b')}
+
+ `);
+ const a = disclosure(root, 'a');
+
+ disclosure(root, 'b').$el.remove();
+ await settle();
+
+ expect(group(root, 'grp').members).toEqual([a]);
+ });
+});
diff --git a/packages/v4/src/group.ts b/packages/v4/src/group.ts
new file mode 100644
index 000000000..6843c913c
--- /dev/null
+++ b/packages/v4/src/group.ts
@@ -0,0 +1,93 @@
+import { signal, type Signal } from './context.js';
+
+/**
+ * What a group needs of a member: the element that decides its rank.
+ *
+ * Structural on purpose — this module never imports `Base`, so a group orders
+ * anything anchored to the DOM and costs a consumer no component graph.
+ */
+export interface GroupMember {
+ readonly $el: Element;
+}
+
+/**
+ * A set of peers that know about each other, published as one reactive value.
+ *
+ * The membership _is_ the state. v3's `withGroup` handed out a bare `Set` with no
+ * value cell, so a coordinator could read its peers but never learn that one
+ * arrived, and every consumer built a change channel beside it. Here a peer
+ * joining or leaving is an observable write, which is what makes an invariant
+ * over the whole set — one open at a time, one selected item — enforceable
+ * when v4 mounts on DOM insertion with no ordering guarantee.
+ *
+ * A group is not a registry: nothing joins implicitly, and the coordinator
+ * that created the group decides who can reach it — usually by providing it as
+ * a context value, so membership is scoped by the DOM, nearest provider first.
+ */
+export interface Group {
+ /**
+ * The members in document order, replaced by a new array on every change so
+ * subscribers observe changes by identity and never share a mutable set.
+ */
+ readonly members: Signal;
+ /**
+ * Join, and get the leave function back.
+ *
+ * Returning the teardown is what makes membership survive an unknown mount
+ * order: a member hands this straight back from a `subscribeContext()`
+ * callback, so it joins the nearest group whenever that group appears, and
+ * leaves again before a nearer one takes over.
+ *
+ * Membership is a set — joining twice changes nothing, and either leave
+ * function removes the member once.
+ */
+ join(member: T): () => void;
+}
+
+/**
+ * Document order, the only order DOM-anchored peers can agree on without
+ * either the coordinator or the mount sequence arbitrating it.
+ */
+function inDocumentOrder(a: GroupMember, b: GroupMember): number {
+ const position = a.$el.compareDocumentPosition(b.$el);
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
+ return -1;
+ }
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
+ return 1;
+ }
+ return 0;
+}
+
+/**
+ * Create an empty group.
+ *
+ * A member leaves through the function `join()` returned; nothing sweeps
+ * disconnected elements, because v4 destroys a component when its element
+ * leaves the DOM and the member's own teardown is what removes it.
+ */
+export function createGroup(): Group {
+ const joined = new Set();
+ const members = signal([]);
+
+ // Sorting on write keeps the published value ready to read and makes the
+ // new array identity the notification.
+ const publish = () => {
+ members.value = [...joined].sort(inDocumentOrder);
+ };
+
+ return {
+ members,
+ join(member: T): () => void {
+ if (!joined.has(member)) {
+ joined.add(member);
+ publish();
+ }
+ return () => {
+ if (joined.delete(member)) {
+ publish();
+ }
+ };
+ },
+ };
+}
diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts
index 6ccd6e85b..1d5a70d8b 100644
--- a/packages/v4/src/index.ts
+++ b/packages/v4/src/index.ts
@@ -41,6 +41,7 @@ export {
type AttributeWatcher,
} from './dom-mutations.js';
export { EVENTS } from './events.js';
+export { createGroup, type Group, type GroupMember } from './group.js';
export { getInstances } from './instances.js';
export {
defineManifest,
diff --git a/packages/v4/src/subpaths/createGroup.ts b/packages/v4/src/subpaths/createGroup.ts
new file mode 100644
index 000000000..03cfa83a5
--- /dev/null
+++ b/packages/v4/src/subpaths/createGroup.ts
@@ -0,0 +1 @@
+export { createGroup, createGroup as default } from '../group.js';
diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js
index 09af867c2..a1b46806c 100644
--- a/packages/v4/test/package-node-consumer.js
+++ b/packages/v4/test/package-node-consumer.js
@@ -12,6 +12,7 @@ import useMutationDefault, { useMutation } from '@studiometa/js-toolkit-v4/useMu
import useRafDefault, { useRaf } from '@studiometa/js-toolkit-v4/useRaf';
import withMutationDefault, { withMutation } from '@studiometa/js-toolkit-v4/withMutation';
import watchAttributesDefault, { watchAttributes } from '@studiometa/js-toolkit-v4/watchAttributes';
+import createGroupDefault, { createGroup } from '@studiometa/js-toolkit-v4/createGroup';
import createStorageDefault, { createStorage } from '@studiometa/js-toolkit-v4/createStorage';
import createMemoryStorageProviderDefault, {
createMemoryStorageProvider,
@@ -53,7 +54,9 @@ assert.equal(createStorage, toolkit.createStorage);
assert.equal(createStorageDefault, createStorage);
assert.equal(createMemoryStorageProvider, toolkit.createMemoryStorageProvider);
assert.equal(createMemoryStorageProviderDefault, createMemoryStorageProvider);
-assert.equal(Object.keys(toolkit).length, 81);
+assert.equal(createGroup, toolkit.createGroup);
+assert.equal(createGroupDefault, createGroup);
+assert.equal(Object.keys(toolkit).length, 82);
assert.equal(toolkit.ToolkitErrorDetail, undefined);
assert.equal(toolkit.ToolkitErrorStage, undefined);
@@ -68,6 +71,16 @@ storage.set('theme', 'light');
unsubscribe();
storage.set('theme', 'dark');
assert.deepEqual(seen, ['light']);
+// A group needs no DOM to hold its members: it only reads `$el` to order peers.
+const group = createGroup();
+const peer = { $el: {} };
+const published = [];
+group.members.subscribe((members) => published.push(members));
+const leave = group.join(peer);
+assert.deepEqual(group.members.value, [peer]);
+leave();
+assert.deepEqual(published, [[peer], []]);
+
assert.equal(clampDefault, clamp);
assert.equal(clamp(12, 0, 10), 10);