@ankhorage/zora 21.0.8 → 21.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts +4 -5
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts.map +1 -1
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js +171 -85
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js.map +1 -1
  7. package/dist/features/tabs/adapters/inbound/TabList.js +3 -3
  8. package/dist/features/tabs/adapters/inbound/TabList.js.map +1 -1
  9. package/dist/features/tabs/tabListMeta.d.ts +8 -1
  10. package/dist/features/tabs/tabListMeta.d.ts.map +1 -1
  11. package/dist/features/tabs/tabListMeta.js +3 -1
  12. package/dist/features/tabs/tabListMeta.js.map +1 -1
  13. package/dist/types/tabs.d.ts +3 -7
  14. package/dist/types/tabs.d.ts.map +1 -1
  15. package/dist/types/tabs.js.map +1 -1
  16. package/package.json +2 -2
  17. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.test.ts +60 -25
  18. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts +243 -93
  19. package/src/features/tabs/adapters/inbound/TabList.tsx +3 -4
  20. package/src/features/tabs/adapters/inbound/Tabs.layout.test.tsx +33 -0
  21. package/src/features/tabs/tabListMeta.ts +3 -1
  22. package/src/types/tabs.ts +5 -7
  23. package/web-dist/chunks/{Accordion-aj6fsjhv.js → Accordion-j4d58grp.js} +62 -37
  24. package/web-dist/components/graph-view/GraphView.js +189 -72
  25. package/web-dist/components/tab/Tab.js +1 -1
  26. package/web-dist/components/tab-list/TabList.js +1 -1
  27. package/web-dist/components/tab-panel/TabPanel.js +1 -1
  28. package/web-dist/components/tabs/Tabs.js +1 -1
  29. package/web-dist/manifest.json +4 -4
@@ -1,59 +1,66 @@
1
1
  import { expect, test } from 'bun:test';
2
- import cytoscape from 'cytoscape';
2
+ import cytoscape, { type NodeSingular } from 'cytoscape';
3
3
 
4
4
  import { compactGraphSpacing } from './compactGraphSpacing';
5
5
 
6
- test('compacts the settled layout up to measured clearance without changing node sizes or edges', () => {
6
+ test('compacts around measured labels while allowing node backgrounds to overlap', () => {
7
7
  const cy = cytoscape({
8
8
  headless: true,
9
9
  styleEnabled: true,
10
10
  layout: { name: 'preset' },
11
11
  elements: [
12
12
  { data: { id: 'a' }, position: { x: 0, y: 0 } },
13
- { data: { id: 'b' }, position: { x: 500, y: 0 } },
13
+ { data: { id: 'b' }, position: { x: 200, y: 0 } },
14
14
  { data: { id: 'e', source: 'a', target: 'b', weight: 7 } },
15
15
  ],
16
16
  style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
17
17
  });
18
18
  try {
19
+ const a = cy.getElementById('a');
20
+ const b = cy.getElementById('b');
21
+ installMeasuredLabelBox(a, 40, 20);
22
+ installMeasuredLabelBox(b, 40, 20);
23
+
19
24
  const factor = compactGraphSpacing(cy, 1);
20
- expect(factor).toBeLessThan(0.3);
21
- expect(factor).toBeGreaterThan(0.2);
22
- const a = cy.getElementById('a').boundingBox();
23
- const b = cy.getElementById('b').boundingBox();
24
- expect(b.x1 - a.x2).toBeGreaterThanOrEqual(8);
25
- expect(cy.edges().length).toBe(1);
25
+ expect(factor).toBeGreaterThan(0.17);
26
+ expect(factor).toBeLessThan(0.19);
27
+ expect(b.boundingBox().x1 - a.boundingBox().x2).toBeLessThan(0);
28
+
29
+ const labelA = readLabelBox(a);
30
+ const labelB = readLabelBox(b);
31
+ const overlap = Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1);
32
+ expect(overlap).toBeGreaterThan(0);
33
+ expect(overlap).toBeLessThanOrEqual(40 * 0.12 + 0.2);
26
34
  expect(cy.edges()[0].data('weight')).toBe(7);
27
- expect(cy.getElementById('a').width()).toBe(100);
28
- // A second bounded search may refine the first interval, but cannot cross the clearance.
29
- const refined = compactGraphSpacing(cy, factor);
30
- expect(factor - refined).toBeLessThan(1 / 128);
31
- expect(
32
- cy.getElementById('b').boundingBox().x1 - cy.getElementById('a').boundingBox().x2,
33
- ).toBeGreaterThanOrEqual(8);
35
+ expect(a.width()).toBe(100);
34
36
  } finally {
35
37
  cy.destroy();
36
38
  }
37
39
  });
38
40
 
39
- test('expands a uniformly cramped layout to measured clearance', () => {
41
+ test('expands a uniformly cramped layout only until measured labels are readable', () => {
40
42
  const cy = cytoscape({
41
43
  headless: true,
42
44
  styleEnabled: true,
43
45
  layout: { name: 'preset' },
44
46
  elements: [
45
47
  { data: { id: 'a' }, position: { x: 0, y: 0 } },
46
- { data: { id: 'b' }, position: { x: 50, y: 0 } },
48
+ { data: { id: 'b' }, position: { x: 20, y: 0 } },
47
49
  ],
48
50
  style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
49
51
  });
50
52
  try {
53
+ installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
54
+ installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
55
+
51
56
  const factor = compactGraphSpacing(cy, 0.1);
52
- expect(factor).toBeGreaterThan(0.2);
53
- expect(factor).toBeLessThan(0.3);
54
- const a = cy.getElementById('a').boundingBox();
55
- const b = cy.getElementById('b').boundingBox();
56
- expect(b.x1 - a.x2).toBeGreaterThanOrEqual(8);
57
+ expect(factor).toBeGreaterThan(0.17);
58
+ expect(factor).toBeLessThan(0.19);
59
+ const labelA = readLabelBox(cy.getElementById('a'));
60
+ const labelB = readLabelBox(cy.getElementById('b'));
61
+ expect(Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1)).toBeLessThanOrEqual(
62
+ 40 * 0.12 + 0.2,
63
+ );
57
64
  } finally {
58
65
  cy.destroy();
59
66
  }
@@ -78,7 +85,7 @@ test('preserves locked or non-uniformly overlapping views', () => {
78
85
  }
79
86
  });
80
87
 
81
- test('allows parent-child containment but preserves separation between compound groups', () => {
88
+ test('allows parent-child containment but keeps compound peer groups separated', () => {
82
89
  const cy = cytoscape({
83
90
  headless: true,
84
91
  styleEnabled: true,
@@ -94,9 +101,37 @@ test('allows parent-child containment but preserves separation between compound
94
101
  expect(compactGraphSpacing(cy, 1)).toBeLessThan(1);
95
102
  expect(
96
103
  cy.getElementById('q').boundingBox().x1 - cy.getElementById('p').boundingBox().x2,
97
- ).toBeGreaterThanOrEqual(8);
104
+ ).toBeGreaterThanOrEqual(0);
98
105
  expect(cy.getElementById('p.a').data('parent')).toBe('p');
99
106
  } finally {
100
107
  cy.destroy();
101
108
  }
102
109
  });
110
+
111
+ /*** Install a dynamic renderer-style label box for headless Cytoscape tests. */
112
+ function installMeasuredLabelBox(node: NodeSingular, width: number, height: number): void {
113
+ const originalBoundingBox = node.boundingBox.bind(node);
114
+ node.boundingBox = (options) => {
115
+ if (options?.includeNodes !== false) return originalBoundingBox(options);
116
+ const position = node.position();
117
+ return {
118
+ x1: position.x - width / 2,
119
+ y1: position.y - height / 2,
120
+ x2: position.x + width / 2,
121
+ y2: position.y + height / 2,
122
+ w: width,
123
+ h: height,
124
+ };
125
+ };
126
+ }
127
+
128
+ /*** Read only the renderer-measured label box from one test node. */
129
+ function readLabelBox(node: NodeSingular) {
130
+ return node.boundingBox({
131
+ includeNodes: false,
132
+ includeEdges: false,
133
+ includeLabels: true,
134
+ includeOverlays: false,
135
+ includeUnderlays: false,
136
+ });
137
+ }
@@ -1,86 +1,264 @@
1
- import type { Core } from 'cytoscape';
1
+ import { findMinimumAcceptedNumber } from '@ankhorage/utility/algorithms';
2
+ import type { Core, NodeSingular } from 'cytoscape';
3
+
4
+ const LABEL_OVERLAP_RATIO = 0.12;
5
+ const MAX_EXPANSION_ATTEMPTS = 8;
6
+ const MAX_NODE_COUNT = 2000;
7
+ const MAX_PAIR_CHECKS = 200000;
8
+ const SEARCH_ITERATIONS = 8;
2
9
 
3
10
  /***
4
- * Optimize settled layout spacing uniformly until measured node and label bounds clear.
5
- * Compound containment is intentional; sibling and unrelated compound intersections are not.
6
- * @performance At most eight synchronous trials and 200,000 pair checks per trial. No layout
7
- * algorithm reruns, no work on zoom, and no intermediate frame is painted. Large, locked, or
8
- * non-uniformly overlapping views retain their original positions.
11
+ * Optimize settled layout spacing around readable label bounds instead of full node clearance.
12
+ * Compound containment stays intentional while sibling groups retain non-overlapping boundaries.
13
+ * @performance Cytoscape geometry is measured once, then bounded pure numeric trials reuse the
14
+ * immutable snapshot. No layout reruns, zoom work, or intermediate frames are painted.
9
15
  */
10
16
  export function compactGraphSpacing(cy: Core, spacingFactor: number): number {
11
17
  const leaves = cy.nodes(':childless');
12
- if (leaves.length < 2 || cy.nodes().length > 2000 || leaves.filter(':locked').length > 0)
18
+ if (leaves.length < 2 || cy.nodes().length > MAX_NODE_COUNT || cy.nodes(':locked').length > 0)
13
19
  return spacingFactor;
14
- const ancestors = new Map(
15
- cy.nodes().map((node) => [node.id(), new Set(node.ancestors().map((parent) => parent.id()))]),
16
- );
17
- const positions = new Map(leaves.map((node) => [node.id(), { ...node.position() }]));
20
+
18
21
  const bounds = leaves.boundingBox();
19
22
  const center = { x: (bounds.x1 + bounds.x2) / 2, y: (bounds.y1 + bounds.y2) / 2 };
20
- if (hasCollisions(cy, ancestors))
21
- return expandGraphSpacing(cy, ancestors, center, positions, spacingFactor);
22
- return compressGraphSpacing(cy, ancestors, center, positions, spacingFactor);
23
+ const leafGeometry = leaves.map(readLeafGeometry);
24
+ const groups = cy.nodes(':parent').map((node) =>
25
+ readGroupGeometry(
26
+ node.id(),
27
+ node.ancestors().map((parent) => parent.id()),
28
+ node.boundingBox(),
29
+ leafGeometry,
30
+ ),
31
+ );
32
+ const accepts = (factor: number) =>
33
+ !hasUnsafeCollisions(resolveCollisionBoxes(leafGeometry, groups, center, factor));
34
+ const currentAccepted = accepts(1);
35
+ const minimum = currentAccepted ? Math.min(1, 0.1 / spacingFactor) : 1;
36
+ const maximum = currentAccepted ? 1 : findExpansionMaximum(accepts);
37
+ if (maximum === undefined) return spacingFactor;
38
+
39
+ const factor = findMinimumAcceptedNumber({
40
+ minimum,
41
+ maximum,
42
+ iterations: SEARCH_ITERATIONS,
43
+ accepts,
44
+ });
45
+ if (factor === undefined) return spacingFactor;
46
+
47
+ applyScale(cy, leafGeometry, center, factor);
48
+ return spacingFactor * factor;
23
49
  }
24
50
 
25
- /*** Compress a collision-free settled layout to its smallest measured clearance. */
26
- function compressGraphSpacing(
27
- cy: Core,
28
- ancestors: ReadonlyMap<string, ReadonlySet<string>>,
29
- center: { x: number; y: number },
30
- positions: ReadonlyMap<string, { x: number; y: number }>,
31
- spacingFactor: number,
32
- ): number {
33
- const search = { low: Math.min(1, 0.1 / spacingFactor), high: 1 };
34
- for (const attempt of Array.from({ length: 8 }, (_, index) => index)) {
35
- const factor = attempt === 0 ? search.low : (search.low + search.high) / 2;
36
- applyScale(cy, positions, center, factor);
37
- if (hasCollisions(cy, ancestors)) search.low = factor;
38
- else search.high = factor;
39
- if (search.high === search.low) break;
40
- }
41
- applyScale(cy, positions, center, search.high);
42
- return spacingFactor * search.high;
51
+ interface Point {
52
+ readonly x: number;
53
+ readonly y: number;
43
54
  }
44
55
 
45
- /*** Expand a uniformly cramped layout until measured node and label bounds clear. */
46
- function expandGraphSpacing(
47
- cy: Core,
48
- ancestors: ReadonlyMap<string, ReadonlySet<string>>,
49
- center: { x: number; y: number },
50
- positions: ReadonlyMap<string, { x: number; y: number }>,
51
- spacingFactor: number,
52
- ): number {
53
- let low = 1;
54
- let high = 1;
55
- let attempts = 0;
56
- while (attempts < 8) {
57
- high *= 2;
58
- attempts += 1;
59
- applyScale(cy, positions, center, high);
60
- if (!hasCollisions(cy, ancestors)) break;
61
- low = high;
62
- }
63
- if (hasCollisions(cy, ancestors)) {
64
- applyScale(cy, positions, center, 1);
65
- return spacingFactor;
66
- }
67
- for (const _attempt of Array.from({ length: 8 - attempts }, (_, index) => index)) {
68
- const factor = (low + high) / 2;
69
- applyScale(cy, positions, center, factor);
70
- if (hasCollisions(cy, ancestors)) low = factor;
71
- else high = factor;
56
+ interface Bounds {
57
+ readonly x1: number;
58
+ readonly y1: number;
59
+ readonly x2: number;
60
+ readonly y2: number;
61
+ readonly w: number;
62
+ readonly h: number;
63
+ }
64
+
65
+ interface Insets {
66
+ readonly left: number;
67
+ readonly right: number;
68
+ readonly top: number;
69
+ readonly bottom: number;
70
+ }
71
+
72
+ interface LeafGeometry {
73
+ readonly id: string;
74
+ readonly ancestors: ReadonlySet<string>;
75
+ readonly position: Point;
76
+ readonly labelBox: Bounds;
77
+ readonly nodeBox: Bounds;
78
+ }
79
+
80
+ interface GroupGeometry {
81
+ readonly id: string;
82
+ readonly ancestors: ReadonlySet<string>;
83
+ readonly descendants: readonly LeafGeometry[];
84
+ readonly insets: Insets;
85
+ }
86
+
87
+ interface CollisionBox {
88
+ readonly id: string;
89
+ readonly ancestors: ReadonlySet<string>;
90
+ readonly kind: 'label' | 'group';
91
+ readonly box: Bounds;
92
+ }
93
+
94
+ /*** Read one leaf's immutable position, hierarchy and renderer-measured label geometry. */
95
+ function readLeafGeometry(node: NodeSingular): LeafGeometry {
96
+ const nodeBox = node.boundingBox({
97
+ includeLabels: true,
98
+ includeOverlays: false,
99
+ includeUnderlays: false,
100
+ });
101
+ const labelBox = node.boundingBox({
102
+ includeNodes: false,
103
+ includeEdges: false,
104
+ includeLabels: true,
105
+ includeOverlays: false,
106
+ includeUnderlays: false,
107
+ });
108
+ return {
109
+ id: node.id(),
110
+ ancestors: new Set(node.ancestors().map((parent) => parent.id())),
111
+ position: { ...node.position() },
112
+ labelBox: isUsableBox(labelBox) ? labelBox : nodeBox,
113
+ nodeBox,
114
+ };
115
+ }
116
+
117
+ /*** Capture one compound group's descendant geometry and constant renderer-owned outer insets. */
118
+ function readGroupGeometry(
119
+ id: string,
120
+ ancestorIds: readonly string[],
121
+ parentBox: Bounds,
122
+ leaves: readonly LeafGeometry[],
123
+ ): GroupGeometry {
124
+ const descendants = leaves.filter((leaf) => leaf.ancestors.has(id));
125
+ const descendantUnion = unionBounds(descendants.map((leaf) => leaf.nodeBox)) ?? parentBox;
126
+ return {
127
+ id,
128
+ ancestors: new Set(ancestorIds),
129
+ descendants,
130
+ insets: {
131
+ left: Math.max(0, descendantUnion.x1 - parentBox.x1),
132
+ right: Math.max(0, parentBox.x2 - descendantUnion.x2),
133
+ top: Math.max(0, descendantUnion.y1 - parentBox.y1),
134
+ bottom: Math.max(0, parentBox.y2 - descendantUnion.y2),
135
+ },
136
+ };
137
+ }
138
+
139
+ /*** Resolve immutable label and compound boxes for one candidate uniform spacing factor. */
140
+ function resolveCollisionBoxes(
141
+ leaves: readonly LeafGeometry[],
142
+ groups: readonly GroupGeometry[],
143
+ center: Point,
144
+ factor: number,
145
+ ): readonly CollisionBox[] {
146
+ const labels = leaves.map((leaf) => ({
147
+ id: leaf.id,
148
+ ancestors: leaf.ancestors,
149
+ kind: 'label' as const,
150
+ box: shiftBounds(leaf.labelBox, leaf.position, center, factor),
151
+ }));
152
+ const compoundBoxes = groups.flatMap((group) => {
153
+ const descendants = group.descendants.map((leaf) =>
154
+ shiftBounds(leaf.nodeBox, leaf.position, center, factor),
155
+ );
156
+ const union = unionBounds(descendants);
157
+ if (!union) return [];
158
+ return [
159
+ {
160
+ id: group.id,
161
+ ancestors: group.ancestors,
162
+ kind: 'group' as const,
163
+ box: expandBounds(union, group.insets),
164
+ },
165
+ ];
166
+ });
167
+ return [...labels, ...compoundBoxes];
168
+ }
169
+
170
+ /*** Find the first bounded expansion factor that makes the pure geometry snapshot acceptable. */
171
+ function findExpansionMaximum(accepts: (factor: number) => boolean): number | undefined {
172
+ return Array.from({ length: MAX_EXPANSION_ATTEMPTS }, (_, index) => 2 ** (index + 1)).find(
173
+ accepts,
174
+ );
175
+ }
176
+
177
+ /***
178
+ * Sweep resolved boxes by x extent and stop conservatively if the pair-check budget is exhausted.
179
+ * Local loop counters are intentionally mutable on this performance-critical boundary.
180
+ */
181
+ function hasUnsafeCollisions(boxes: readonly CollisionBox[]): boolean {
182
+ const sorted = [...boxes].sort((first, second) => first.box.x1 - second.box.x1);
183
+ const budget = { remaining: MAX_PAIR_CHECKS };
184
+ for (const [index, first] of sorted.entries()) {
185
+ for (let next = index + 1; next < sorted.length; next += 1) {
186
+ const second = sorted.at(next);
187
+ if (!second) break;
188
+ if (second.box.x1 >= first.box.x2) break;
189
+ budget.remaining -= 1;
190
+ if (budget.remaining < 0) return true;
191
+ if (isIntentionalContainment(first, second)) continue;
192
+ if (isUnsafeOverlap(first, second)) return true;
193
+ }
72
194
  }
73
- applyScale(cy, positions, center, high);
74
- return spacingFactor * high;
195
+ return false;
196
+ }
197
+
198
+ /*** Ignore intentional compound containment while retaining sibling and unrelated separation. */
199
+ function isIntentionalContainment(first: CollisionBox, second: CollisionBox): boolean {
200
+ return first.ancestors.has(second.id) || second.ancestors.has(first.id);
201
+ }
202
+
203
+ /*** Permit only a small bounded label-label overlap; every other intersection remains unsafe. */
204
+ function isUnsafeOverlap(first: CollisionBox, second: CollisionBox): boolean {
205
+ const overlapWidth =
206
+ Math.min(first.box.x2, second.box.x2) - Math.max(first.box.x1, second.box.x1);
207
+ const overlapHeight =
208
+ Math.min(first.box.y2, second.box.y2) - Math.max(first.box.y1, second.box.y1);
209
+ if (overlapWidth <= 0 || overlapHeight <= 0) return false;
210
+ if (first.kind !== 'label' || second.kind !== 'label') return true;
211
+ const widthRatio = overlapWidth / Math.min(first.box.w, second.box.w);
212
+ const heightRatio = overlapHeight / Math.min(first.box.h, second.box.h);
213
+ return widthRatio > LABEL_OVERLAP_RATIO && heightRatio > LABEL_OVERLAP_RATIO;
75
214
  }
76
215
 
77
- /*** Transform leaf centers, preserving ordering, dimensions, compound membership and edge data. */
216
+ /*** Shift one fixed renderer box with its leaf center under uniform radial scaling. */
217
+ function shiftBounds(box: Bounds, position: Point, center: Point, factor: number): Bounds {
218
+ const dx = (position.x - center.x) * (factor - 1);
219
+ const dy = (position.y - center.y) * (factor - 1);
220
+ return {
221
+ x1: box.x1 + dx,
222
+ y1: box.y1 + dy,
223
+ x2: box.x2 + dx,
224
+ y2: box.y2 + dy,
225
+ w: box.w,
226
+ h: box.h,
227
+ };
228
+ }
229
+
230
+ /*** Expand a descendant union by the compound renderer's measured outer insets. */
231
+ function expandBounds(box: Bounds, insets: Insets): Bounds {
232
+ const x1 = box.x1 - insets.left;
233
+ const y1 = box.y1 - insets.top;
234
+ const x2 = box.x2 + insets.right;
235
+ const y2 = box.y2 + insets.bottom;
236
+ return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
237
+ }
238
+
239
+ /*** Return the immutable union of measured boxes, or undefined for an empty collection. */
240
+ function unionBounds(boxes: readonly Bounds[]): Bounds | undefined {
241
+ if (boxes.length === 0) return undefined;
242
+ const x1 = Math.min(...boxes.map((box) => box.x1));
243
+ const y1 = Math.min(...boxes.map((box) => box.y1));
244
+ const x2 = Math.max(...boxes.map((box) => box.x2));
245
+ const y2 = Math.max(...boxes.map((box) => box.y2));
246
+ return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
247
+ }
248
+
249
+ /*** Return whether renderer measurement produced a non-empty label box. */
250
+ function isUsableBox(box: Bounds): boolean {
251
+ return box.w > 0 && box.h > 0;
252
+ }
253
+
254
+ /*** Apply the accepted factor once, preserving node dimensions, hierarchy, ordering and edge data. */
78
255
  function applyScale(
79
256
  cy: Core,
80
- positions: ReadonlyMap<string, { x: number; y: number }>,
81
- center: { x: number; y: number },
257
+ leaves: readonly LeafGeometry[],
258
+ center: Point,
82
259
  factor: number,
83
- ) {
260
+ ): void {
261
+ const positions = new Map(leaves.map((leaf) => [leaf.id, leaf.position]));
84
262
  cy.batch(() => {
85
263
  cy.nodes()
86
264
  .not(':parent')
@@ -93,31 +271,3 @@ function applyScale(
93
271
  });
94
272
  });
95
273
  }
96
-
97
- /*** Sweep measured bounds, ignoring intentional containment and stopping conservatively at budget. */
98
- function hasCollisions(cy: Core, ancestors: ReadonlyMap<string, ReadonlySet<string>>): boolean {
99
- const boxes = cy
100
- .nodes()
101
- .map((node) => ({
102
- id: node.id(),
103
- box: node.boundingBox({
104
- includeLabels: true,
105
- includeOverlays: false,
106
- includeUnderlays: false,
107
- }),
108
- }))
109
- .sort((a, b) => a.box.x1 - b.box.x1);
110
- const budget = { remaining: 200000 };
111
- for (const [index, first] of boxes.entries()) {
112
- for (let next = index + 1; next < boxes.length; next += 1) {
113
- const second = boxes.at(next);
114
- if (!second) break;
115
- if (second.box.x1 >= first.box.x2 + 8) break;
116
- if (--budget.remaining < 0) return true;
117
- if (ancestors.get(first.id)?.has(second.id) || ancestors.get(second.id)?.has(first.id))
118
- continue;
119
- if (first.box.y1 < second.box.y2 + 8 && second.box.y1 < first.box.y2 + 8) return true;
120
- }
121
- }
122
- return false;
123
- }
@@ -6,13 +6,12 @@ import { withZoraThemeScope } from '../../../theme/adapters/inbound/withZoraThem
6
6
  /*** Renders the accessible list of tabs for a Tabs composition. */
7
7
  export const TabList = withZoraThemeScope(TabListInner);
8
8
 
9
- /*** Delegates tablist semantics and keyboard navigation to Surface. */
9
+ /*** Delegates tablist semantics, keyboard navigation, and fill distribution to Surface. */
10
10
  function TabListInner({
11
11
  themeId: _themeId,
12
12
  mode: _mode,
13
13
  interactionPolicy: _interactionPolicy,
14
- children,
15
- testID,
14
+ ...props
16
15
  }: TabListProps) {
17
- return <SurfaceTabList testID={testID}>{children}</SurfaceTabList>;
16
+ return <SurfaceTabList {...props} />;
18
17
  }
@@ -8,6 +8,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
8
8
 
9
9
  import type { ScrollView as ScrollViewComponent } from '../../../layout/adapters/inbound/ScrollView';
10
10
  import type { ZoraProvider as ZoraProviderComponent } from '../../../theme/adapters/inbound/ZoraProvider';
11
+ import type { Tab as TabComponent } from './Tab';
12
+ import type { TabList as TabListComponent } from './TabList';
11
13
  import type { TabPanel as TabPanelComponent } from './TabPanel';
12
14
  import type { Tabs as TabsComponent } from './Tabs';
13
15
 
@@ -55,3 +57,34 @@ test('renders a bounded generated Tabs panel containing the canonical ScrollView
55
57
  expect(browserWindow.document.body.textContent).not.toContain('Hidden rules');
56
58
  browserWindow.close();
57
59
  });
60
+
61
+ test('forwards filled tab distribution and trailing tab content through the generated artifact', async () => {
62
+ const { ZoraProvider } = (await load('runtime/ZoraProvider.js')) as {
63
+ ZoraProvider: typeof ZoraProviderComponent;
64
+ };
65
+ const { Tab, TabList, Tabs } = (await load('components/tabs/index.js')) as {
66
+ Tab: typeof TabComponent;
67
+ TabList: typeof TabListComponent;
68
+ Tabs: typeof TabsComponent;
69
+ };
70
+ const markup = renderToStaticMarkup(
71
+ <ZoraProvider mode="light">
72
+ <Tabs defaultValue="tree">
73
+ <TabList fill testID="tools">
74
+ <Tab label="Tree" trailing={<span data-testid="tree-count">2</span>} value="tree" />
75
+ <Tab label="Rules" value="rules" />
76
+ </TabList>
77
+ </Tabs>
78
+ </ZoraProvider>,
79
+ );
80
+ const browserWindow = new Window();
81
+ browserWindow.document.body.innerHTML = markup;
82
+ const tabList = browserWindow.document.querySelector('[data-testid="tools"]');
83
+ const wrappers = Array.from(tabList?.children ?? []);
84
+
85
+ expect(tabList?.getAttribute('role')).toBe('tablist');
86
+ expect(wrappers).toHaveLength(2);
87
+ expect(wrappers.every((wrapper) => wrapper.getAttribute('style')?.includes('flex:1'))).toBe(true);
88
+ expect(browserWindow.document.querySelector('[data-testid="tree-count"]')?.textContent).toBe('2');
89
+ browserWindow.close();
90
+ });
@@ -6,5 +6,7 @@ export const tabListMeta = {
6
6
  directManifestNode: true,
7
7
  allowedChildren: ['Tab'],
8
8
  description: 'Accessible list of selectable tabs.',
9
- props: {},
9
+ props: {
10
+ fill: { type: 'boolean', category: 'Layout', label: 'Fill', default: false },
11
+ },
10
12
  } as const satisfies ZoraComponentMeta;
package/src/types/tabs.ts CHANGED
@@ -1,21 +1,19 @@
1
1
  import type {
2
+ TabListProps as SurfaceTabListProps,
2
3
  TabPanelProps as SurfaceTabPanelProps,
4
+ TabProps as SurfaceTabProps,
3
5
  TabsProps as SurfaceTabsProps,
4
6
  } from '@ankhorage/surface';
5
- import type React from 'react';
6
7
 
7
8
  import type { ZoraBaseProps } from './base';
8
9
 
9
10
  export interface TabsProps extends ZoraBaseProps, Omit<SurfaceTabsProps, 'testID'> {}
10
11
 
11
- export interface TabListProps extends ZoraBaseProps {
12
- children?: React.ReactNode;
13
- }
12
+ export interface TabListProps extends ZoraBaseProps, Omit<SurfaceTabListProps, 'testID'> {}
14
13
 
15
- export interface TabProps extends ZoraBaseProps {
16
- value: string;
14
+ export interface TabProps
15
+ extends ZoraBaseProps, Omit<SurfaceTabProps, 'children' | 'interactionPolicy' | 'testID'> {
17
16
  label: string;
18
- disabled?: boolean;
19
17
  }
20
18
 
21
19
  export interface TabPanelProps extends ZoraBaseProps, Omit<SurfaceTabPanelProps, 'testID'> {}