@gtkx/components 2.0.0-beta.11 → 2.0.0-beta.12

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 (42) hide show
  1. package/README.md +4 -4
  2. package/dist/column-view.js +1 -1
  3. package/dist/grid-view.js +1 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/cells.d.ts +3 -7
  9. package/dist/internal/cells.d.ts.map +1 -1
  10. package/dist/internal/cells.js +15 -43
  11. package/dist/internal/cells.js.map +1 -1
  12. package/dist/internal/collection-model.d.ts.map +1 -1
  13. package/dist/internal/drop-down.d.ts.map +1 -1
  14. package/dist/internal/drop-down.js +1 -1
  15. package/dist/internal/drop-down.js.map +1 -1
  16. package/dist/internal/keys.d.ts.map +1 -1
  17. package/dist/internal/tree-expansion.d.ts +1 -1
  18. package/dist/internal/tree-expansion.d.ts.map +1 -1
  19. package/dist/internal/tree-expansion.js +1 -1
  20. package/dist/internal/tree-expansion.js.map +1 -1
  21. package/dist/internal/tree-order.d.ts +1 -1
  22. package/dist/internal/tree-order.d.ts.map +1 -1
  23. package/dist/internal/tree-order.js +1 -1
  24. package/dist/internal/tree-order.js.map +1 -1
  25. package/dist/internal/use-widget-ref.d.ts.map +1 -1
  26. package/dist/list-item-factory.js +1 -1
  27. package/dist/list-view.js +1 -1
  28. package/dist/tab-collection.d.ts +9 -0
  29. package/dist/tab-collection.d.ts.map +1 -0
  30. package/dist/tab-collection.js +114 -0
  31. package/dist/tab-collection.js.map +1 -0
  32. package/dist/types.d.ts +35 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/package.json +10 -20
  36. package/src/index.ts +4 -0
  37. package/src/internal/cells.tsx +44 -109
  38. package/src/internal/drop-down.tsx +1 -5
  39. package/src/internal/tree-expansion.ts +1 -9
  40. package/src/internal/tree-order.ts +1 -9
  41. package/src/tab-collection.tsx +124 -0
  42. package/src/types.ts +56 -14
@@ -1,9 +1,10 @@
1
1
  import type * as GObject from "@gtkx/gi/gobject";
2
- import type { ReactElement, ReactNode } from "react";
2
+ import type { ReactElement, ReactNode, RefObject } from "react";
3
3
  import * as Gtk from "@gtkx/gi/gtk";
4
4
  import { GtkBox, GtkSignalListItemFactory, GtkTreeExpander } from "@gtkx/jsx/gtk";
5
5
  import { createPortal, useProperty } from "@gtkx/react";
6
- import { Fragment, memo, useInsertionEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
6
+ import { useLatestRef } from "@gtkx/react/internal";
7
+ import { createRef, Fragment, memo, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
7
8
  import type {
8
9
  ExpanderDescriptions,
9
10
  ListItem,
@@ -34,7 +35,7 @@ type CellHost = FactoryHost & {
34
35
  type CellEntry<H extends FactoryHost> = {
35
36
  key: string;
36
37
  host: H;
37
- identity: ItemIdentityStore;
38
+ identity: RefObject<ItemIdentity | null>;
38
39
  };
39
40
 
40
41
  type FactoryHandlers = {
@@ -95,11 +96,6 @@ type CollectionState = {
95
96
  expandedIds: string[] | null | undefined;
96
97
  };
97
98
 
98
- type CollectionStateStore = {
99
- read: () => CollectionState;
100
- write: (state: CollectionState) => void;
101
- };
102
-
103
99
  type ItemIdentity = {
104
100
  itemKey: string;
105
101
  item: ListItem | undefined;
@@ -107,15 +103,10 @@ type ItemIdentity = {
107
103
  isExpanded: boolean;
108
104
  };
109
105
 
110
- type ItemIdentityStore = {
111
- read: () => ItemIdentity | null;
112
- write: (identity: ItemIdentity) => void;
113
- };
114
-
115
106
  type ItemCellProps = {
116
107
  entry: CellEntry<Gtk.ListItem>;
117
108
  render: ListItemRenderer<never>;
118
- state: CollectionStateStore;
109
+ state: RefObject<CollectionState>;
119
110
  hasExpander: boolean;
120
111
  expanderDescriptions: ExpanderDescriptions | null | undefined;
121
112
  } & ItemIdentity;
@@ -132,7 +123,7 @@ type ItemPortalsProps = {
132
123
  type ItemRowProps = {
133
124
  entry: CellEntry<Gtk.ColumnViewRow>;
134
125
  rowProps: ListRowPropsResolver<never>;
135
- state: CollectionStateStore;
126
+ state: RefObject<CollectionState>;
136
127
  } & ItemIdentity;
137
128
 
138
129
  type RowPortalsProps = {
@@ -231,7 +222,7 @@ function notifyRegistry<H extends FactoryHost>(state: CellRegistryState<H>): voi
231
222
  function createEntry<H extends FactoryHost>(state: CellRegistryState<H>, host: H): CellEntry<H> {
232
223
  state.serial += 1;
233
224
 
234
- return { key: `gtkx-cell-${String(state.serial)}`, host, identity: createItemIdentityStore() };
225
+ return { key: `gtkx-cell-${String(state.serial)}`, host, identity: createRef<ItemIdentity>() };
235
226
  }
236
227
 
237
228
  function setupHost<H extends FactoryHost>(state: CellRegistryState<H>, host: H): void {
@@ -386,7 +377,7 @@ function slotFor(options: ItemSlotOptions): ItemSlot | null {
386
377
 
387
378
  function useItemSlot(
388
379
  entry: CellEntry<PositionedHost>,
389
- state: CollectionStateStore,
380
+ state: RefObject<CollectionState>,
390
381
  identity: ItemIdentity,
391
382
  ): ItemSlot | null {
392
383
  const position = useProperty(entry.host, "position");
@@ -397,14 +388,14 @@ function useItemSlot(
397
388
  return identity.item === undefined
398
389
  ? null
399
390
  : {
400
- itemKey: identity.itemKey,
401
- item: identity.item,
402
- row,
403
- args: itemArgs(identity.item, position, row, identity.isExpanded),
404
- };
391
+ itemKey: identity.itemKey,
392
+ item: identity.item,
393
+ row,
394
+ args: itemArgs(identity.item, position, row, identity.isExpanded),
395
+ };
405
396
  }
406
397
 
407
- const { collection, expandedIds } = state.read();
398
+ const { collection, expandedIds } = state.current;
408
399
 
409
400
  return slotFor({ item, position, row, collection, expandedIds });
410
401
  }
@@ -435,7 +426,7 @@ function isSameItemIdentity(previous: ItemIdentity, next: ItemIdentity): boolean
435
426
  }
436
427
 
437
428
  function isSameItemCell(previous: ItemCellProps, next: ItemCellProps): boolean {
438
- const identity = previous.entry.identity.read() ?? previous;
429
+ const identity = previous.entry.identity.current ?? previous;
439
430
 
440
431
  return (
441
432
  previous.entry === next.entry &&
@@ -447,22 +438,11 @@ function isSameItemCell(previous: ItemCellProps, next: ItemCellProps): boolean {
447
438
  }
448
439
 
449
440
  function isSameItemRow(previous: ItemRowProps, next: ItemRowProps): boolean {
450
- const identity = previous.entry.identity.read() ?? previous;
441
+ const identity = previous.entry.identity.current ?? previous;
451
442
 
452
443
  return previous.entry === next.entry && previous.rowProps === next.rowProps && isSameItemIdentity(identity, next);
453
444
  }
454
445
 
455
- function createItemIdentityStore(): ItemIdentityStore {
456
- let current: ItemIdentity | null = null;
457
-
458
- return {
459
- read: () => current,
460
- write: (identity) => {
461
- current = identity;
462
- },
463
- };
464
- }
465
-
466
446
  function createNativeItemKeyStore(): NativeItemKeyStore {
467
447
  const keys: WeakMap<GObject.Object, string> = new WeakMap();
468
448
  let serial = 0;
@@ -494,35 +474,12 @@ function renderedIdentity(entry: CellEntry<PositionedHost>, slot: ItemSlot | nul
494
474
  }
495
475
 
496
476
  function useRememberIdentity(entry: CellEntry<PositionedHost>, slot: ItemSlot | null): void {
477
+ const identityRef = entry.identity;
497
478
  useLayoutEffect(() => {
498
- entry.identity.write(renderedIdentity(entry, slot));
479
+ identityRef.current = renderedIdentity(entry, slot);
499
480
  });
500
481
  }
501
482
 
502
- function createCollectionStateStore(initial: CollectionState): CollectionStateStore {
503
- let current = initial;
504
-
505
- return {
506
- read: () => current,
507
- write: (state) => {
508
- current = state;
509
- },
510
- };
511
- }
512
-
513
- function useCollectionState(
514
- collection: Collection,
515
- expandedIds: string[] | null | undefined,
516
- ): CollectionStateStore {
517
- const [state] = useState(() => createCollectionStateStore({ collection, expandedIds }));
518
-
519
- useInsertionEffect(() => {
520
- state.write({ collection, expandedIds });
521
- }, [state, collection, expandedIds]);
522
-
523
- return state;
524
- }
525
-
526
483
  function expanderDescriptionFor(
527
484
  slot: ItemSlot,
528
485
  descriptions: ExpanderDescriptions | null | undefined,
@@ -544,21 +501,19 @@ function wrapExpander(
544
501
  const { item, row } = slot;
545
502
 
546
503
  const expander: ReactNode =
547
- row === null
548
- ? (
549
- content
550
- )
551
- : (
552
- <GtkTreeExpander
553
- listRow={row}
554
- hideExpander={item.shouldHideExpander ?? false}
555
- indentForDepth={item.shouldIndentForDepth ?? true}
556
- indentForIcon={item.shouldIndentForIcon ?? true}
557
- accessibleDescription={expanderDescriptionFor(slot, descriptions)}
558
- >
559
- {content}
560
- </GtkTreeExpander>
561
- );
504
+ row === null ? (
505
+ content
506
+ ) : (
507
+ <GtkTreeExpander
508
+ listRow={row}
509
+ hideExpander={item.shouldHideExpander ?? false}
510
+ indentForDepth={item.shouldIndentForDepth ?? true}
511
+ indentForIcon={item.shouldIndentForIcon ?? true}
512
+ accessibleDescription={expanderDescriptionFor(slot, descriptions)}
513
+ >
514
+ {content}
515
+ </GtkTreeExpander>
516
+ );
562
517
 
563
518
  return expander;
564
519
  }
@@ -576,16 +531,13 @@ function itemBody(
576
531
  const renderItem = render as ListItemRenderer<unknown>;
577
532
  const content = renderItem(slot.args);
578
533
 
579
- return (
580
- <Fragment key={slot.itemKey}>{hasExpander ? wrapExpander(slot, content, descriptions) : content}</Fragment>
581
- );
534
+ return <Fragment key={slot.itemKey}>{hasExpander ? wrapExpander(slot, content, descriptions) : content}</Fragment>;
582
535
  }
583
536
 
584
537
  function rowText(value: string | undefined): string | null {
585
538
  return value ?? null;
586
539
  }
587
540
 
588
- /* eslint-disable-next-line unicorn/consistent-boolean-name -- reads the flag off a row, paired with rowText */
589
541
  function rowFlag(value: boolean | undefined): boolean {
590
542
  return value ?? true;
591
543
  }
@@ -603,11 +555,7 @@ function rowPropsFor(slot: ItemSlot | null, rowProps: ListRowPropsResolver<never
603
555
  };
604
556
  }
605
557
 
606
- function applyRowProps(
607
- host: Gtk.ColumnViewRow,
608
- props: ResolvedRowProps,
609
- previous: ResolvedRowProps | null,
610
- ): void {
558
+ function applyRowProps(host: Gtk.ColumnViewRow, props: ResolvedRowProps, previous: ResolvedRowProps | null): void {
611
559
  if (previous?.accessibleLabel !== props.accessibleLabel) {
612
560
  host.accessibleLabel = props.accessibleLabel;
613
561
  }
@@ -664,32 +612,26 @@ function HeaderCellImpl({ entry, render, collection }: HeaderCellProps): ReactNo
664
612
  return createPortal(headerBody(slotRefFor(item), render, collection), entry.host, entry.key);
665
613
  }
666
614
 
667
- function NativeItemCell<T extends GObject.Object>({
668
- entry,
669
- render,
670
- keys,
671
- size,
672
- }: NativeItemCellProps<T>): ReactNode {
615
+ function NativeItemCell<T extends GObject.Object>({ entry, render, keys, size }: NativeItemCellProps<T>): ReactNode {
673
616
  const position = useProperty(entry.host, "position");
674
617
  const item = useProperty(entry.host, "item") ?? null;
675
618
  let body: ReactNode = null;
676
619
 
677
620
  if (item !== null && position !== undefined) {
678
621
  const content = render({ item: item as T, index: position });
679
- const child = content == null || typeof content === "boolean"
680
- ? <GtkBox widthRequest={size.width} heightRequest={size.height} />
681
- : content;
622
+ const child =
623
+ content == null || typeof content === "boolean" ? (
624
+ <GtkBox widthRequest={size.width} heightRequest={size.height} />
625
+ ) : (
626
+ content
627
+ );
682
628
  body = <Fragment key={keys.keyFor(item)}>{child}</Fragment>;
683
629
  }
684
630
 
685
631
  return createPortal(body, entry.host, entry.key);
686
632
  }
687
633
 
688
- function headerBody(
689
- ref: SlotRef | null,
690
- render: ListSectionRenderer<never>,
691
- collection: Collection,
692
- ): ReactNode {
634
+ function headerBody(ref: SlotRef | null, render: ListSectionRenderer<never>, collection: Collection): ReactNode {
693
635
  if (ref === null) {
694
636
  return null;
695
637
  }
@@ -719,7 +661,7 @@ const ItemPortals = ({
719
661
  expanderDescriptions,
720
662
  }: ItemPortalsProps): ReactNode => {
721
663
  const entries = usePortalEntries(registry);
722
- const state = useCollectionState(collection, expandedIds);
664
+ const state = useLatestRef({ collection, expandedIds });
723
665
 
724
666
  return entries.map((entry) => (
725
667
  <ItemCell
@@ -736,7 +678,7 @@ const ItemPortals = ({
736
678
 
737
679
  const RowPortals = ({ registry, rowProps, collection, expandedIds }: RowPortalsProps): ReactNode => {
738
680
  const entries = usePortalEntries(registry);
739
- const state = useCollectionState(collection, expandedIds);
681
+ const state = useLatestRef({ collection, expandedIds });
740
682
 
741
683
  return entries.map((entry) => (
742
684
  <ItemRow
@@ -799,11 +741,4 @@ const useRowProps = (
799
741
  };
800
742
  };
801
743
 
802
- export {
803
- useItemCells,
804
- useRowProps,
805
- useSectionHeader,
806
- ItemPortals,
807
- NativeItemPortals,
808
- type CellSize,
809
- };
744
+ export { useItemCells, useRowProps, useSectionHeader, ItemPortals, NativeItemPortals, type CellSize };
@@ -59,11 +59,7 @@ const defaultRenderItem = ({ item }: ListItemRenderArgs<Primitive>): ReactNode =
59
59
  item == null ? null : <GtkLabel>{String(item)}</GtkLabel>;
60
60
  const faceRenderer = (props: DropDownBaseProps): ListItemRenderer<never> => props.renderItem ?? defaultRenderItem;
61
61
 
62
- const resolvePosition = (
63
- widget: SelectableWidget,
64
- collection: Collection,
65
- selectedId: string | undefined,
66
- ): number => {
62
+ const resolvePosition = (widget: SelectableWidget, collection: Collection, selectedId: string | undefined): number => {
67
63
  if (selectedId === undefined) {
68
64
  return widget.getSelected();
69
65
  }
@@ -92,12 +92,4 @@ function adoptIndex(expansion: TreeExpansion, index: CollectionIndex): void {
92
92
  expansion.order = null;
93
93
  }
94
94
 
95
- export {
96
- adoptIndex,
97
- adoptOrder,
98
- createTreeExpansion,
99
- markExpanded,
100
- orderFor,
101
- pruneSlots,
102
- type TreeExpansion,
103
- };
95
+ export { adoptIndex, adoptOrder, createTreeExpansion, markExpanded, orderFor, pruneSlots, type TreeExpansion };
@@ -133,12 +133,4 @@ function findIds(index: CollectionIndex, slots: SlotMap, positions: Set<number>)
133
133
  return found;
134
134
  }
135
135
 
136
- export {
137
- expandedPathsFor,
138
- findIds,
139
- findRows,
140
- walkVisible,
141
- type MatchedRows,
142
- type VisibleOrder,
143
- type WalkOptions,
144
- };
136
+ export { expandedPathsFor, findIds, findRows, walkVisible, type MatchedRows, type VisibleOrder, type WalkOptions };
@@ -0,0 +1,124 @@
1
+ import * as Adw from "@gtkx/gi/adw";
2
+ import { AdwTabPage } from "@gtkx/jsx/adw";
3
+ import { createRoot, flushSync, type Root } from "@gtkx/react";
4
+ import { getClassType } from "@gtkx/runtime";
5
+ import { createRef, type RefObject, useEffect } from "react";
6
+ import type { TabCollection, TabCollectionOptions } from "./types.js";
7
+
8
+ type TabHandle<T> = {
9
+ root: Root;
10
+ page: RefObject<Adw.TabPage | null>;
11
+ getPage: () => Adw.TabPage | null;
12
+ parent: Adw.TabPage | undefined;
13
+ item: T;
14
+ };
15
+
16
+ type ManagedTabProps<T> = {
17
+ handle: TabHandle<T>;
18
+ item: T;
19
+ options: TabCollectionOptions<T>;
20
+ forget: (handle: TabHandle<T>) => void;
21
+ };
22
+
23
+ const ManagedTab = <T,>({ handle, item, options, forget }: ManagedTabProps<T>) => {
24
+ const { page: pageRef, parent, getPage } = handle;
25
+ useEffect(() => () => forget(handle), [handle, forget]);
26
+ return <AdwTabPage {...options.pageProps(item, getPage)} ref={pageRef} parent={parent} />;
27
+ };
28
+
29
+ const ownerOf = (page: Adw.TabPage): Adw.TabView => {
30
+ const owner = page.getChild().getAncestor(getClassType(Adw.TabView));
31
+ if (!(owner instanceof Adw.TabView)) throw new Error("The tab has no native view");
32
+ return owner;
33
+ };
34
+
35
+ /**
36
+ * Creates a collection of JSX pages whose React state survives native transfers and source-window
37
+ * closure. Call append from an event handler because it commits synchronously. Page content lives in
38
+ * independent roots: pass context-dependent values through the item or wrap the content in providers.
39
+ */
40
+ const createTabCollection = <T,>(options: TabCollectionOptions<T>): TabCollection<T> => {
41
+ const handles = new Set<TabHandle<T>>();
42
+ const forget = (handle: TabHandle<T>) => {
43
+ handles.delete(handle);
44
+ };
45
+ const find = (page: Adw.TabPage) => [...handles].find((handle) => handle.page.current === page);
46
+ const render = (handle: TabHandle<T>) => {
47
+ handle.root.render(<ManagedTab handle={handle} item={handle.item} options={options} forget={forget} />);
48
+ };
49
+ const remove = (handle: TabHandle<T>) => {
50
+ handles.delete(handle);
51
+ handle.root.unmount();
52
+ };
53
+
54
+ return {
55
+ append: (view, item, appendOptions = {}) => {
56
+ if (appendOptions.parent && ownerOf(appendOptions.parent) !== view) {
57
+ throw new RangeError("The parent page belongs to another tab view");
58
+ }
59
+ const pageRef = createRef<Adw.TabPage>();
60
+ const handle: TabHandle<T> = {
61
+ root: createRoot(view),
62
+ page: pageRef,
63
+ getPage: () => pageRef.current,
64
+ parent: appendOptions.parent,
65
+ item,
66
+ };
67
+ handles.add(handle);
68
+ try {
69
+ flushSync(() => render(handle));
70
+ } catch (error) {
71
+ remove(handle);
72
+ throw error;
73
+ }
74
+ const page = handle.page.current;
75
+ if (page === null) {
76
+ remove(handle);
77
+ throw new Error("The tab page did not render");
78
+ }
79
+ if (appendOptions.parent === undefined) {
80
+ view.reorderPage(page, page.getPinned() ? view.getNPinnedPages() - 1 : view.getNPages() - 1);
81
+ }
82
+ if (appendOptions.select !== false) view.setSelectedPage(page);
83
+ return page;
84
+ },
85
+ get: (page) => find(page)?.item,
86
+ update: (page, item) => {
87
+ const handle = find(page);
88
+ if (handle === undefined) throw new Error("The tab is not managed by this collection");
89
+ handle.item = item;
90
+ render(handle);
91
+ },
92
+ remove: (page) => {
93
+ const handle = find(page);
94
+ if (handle) remove(handle);
95
+ },
96
+ removeView: (view) => {
97
+ const pages = Array.from({ length: view.getNPages() }, (_, index) => view.getNthPage(index));
98
+ for (const page of pages) {
99
+ const handle = find(page);
100
+ if (handle) remove(handle);
101
+ }
102
+ },
103
+ transfer: (page, target, position) => {
104
+ if (find(page) === undefined) throw new Error("The tab is not managed by this collection");
105
+ const source = ownerOf(page);
106
+ const minimum = page.getPinned() ? 0 : target.getNPinnedPages();
107
+ const maximum = page.getPinned() ? target.getNPinnedPages() : target.getNPages();
108
+ const index = position ?? maximum;
109
+ if (!Number.isInteger(index) || index < minimum || index > maximum) {
110
+ throw new RangeError("The position is outside the tab's pinned or unpinned group");
111
+ }
112
+ if (source === target) {
113
+ target.reorderPage(page, Math.min(index, maximum - 1));
114
+ } else {
115
+ source.transferPage(page, target, index);
116
+ }
117
+ },
118
+ dispose: () => {
119
+ for (const handle of [...handles]) remove(handle);
120
+ },
121
+ };
122
+ };
123
+
124
+ export { createTabCollection };
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type * as Adw from "@gtkx/gi/adw";
2
2
  import type * as GObject from "@gtkx/gi/gobject";
3
3
  import type * as Gtk from "@gtkx/gi/gtk";
4
- import type { AdwComboRowProps, AdwToastProps } from "@gtkx/jsx/adw";
4
+ import type { AdwComboRowProps, AdwTabPageProps, AdwToastProps } from "@gtkx/jsx/adw";
5
5
  import type {
6
6
  GtkColumnViewColumnProps,
7
7
  GtkColumnViewProps,
@@ -159,15 +159,18 @@ type SortProps = {
159
159
  };
160
160
 
161
161
  /** The data a collection view renders, either as a plain item list or grouped into sections. */
162
- type SourceProps<T, S> = ({
163
- /** Items to render, nesting through `ListItem.children` for a tree. */
164
- items?: ListItem<T>[] | undefined;
165
- } & Partial<Record<"sections", undefined>> & Partial<Record<"renderHeader", null | undefined>>) | ({
166
- /** Items grouped under section headers. */
167
- sections: ListSection<S, T>[];
168
- /** Renders the header shown above each section. */
169
- renderHeader?: ListSectionRenderer<S> | null | undefined;
170
- } & Partial<Record<"items", undefined>>);
162
+ type SourceProps<T, S> =
163
+ | ({
164
+ /** Items to render, nesting through `ListItem.children` for a tree. */
165
+ items?: ListItem<T>[] | undefined;
166
+ } & Partial<Record<"sections", undefined>> &
167
+ Partial<Record<"renderHeader", null | undefined>>)
168
+ | ({
169
+ /** Items grouped under section headers. */
170
+ sections: ListSection<S, T>[];
171
+ /** Renders the header shown above each section. */
172
+ renderHeader?: ListSectionRenderer<S> | null | undefined;
173
+ } & Partial<Record<"items", undefined>>);
171
174
 
172
175
  /** One column of a {@link ColumnView}, pairing Gtk.ColumnViewColumn props with a cell renderer. */
173
176
  type ColumnViewColumn<T = unknown> = Omit<GtkColumnViewColumnProps, "factory" | "sorter" | "id" | "title"> & {
@@ -203,7 +206,7 @@ type ColumnViewProps<T = unknown, S = unknown> = Omit<
203
206
  GtkColumnViewProps,
204
207
  "children" | "columns" | "model" | "headerFactory" | "rowFactory" | keyof ColumnViewOwnProps<T, S>
205
208
  > &
206
- ColumnViewOwnProps<T, S>;
209
+ ColumnViewOwnProps<T, S>;
207
210
 
208
211
  /** The declarative collection props {@link DropDown} and `ComboRow` add on top of their widget's own. */
209
212
  type DropDownOwnProps<T, S> = SourceProps<T, S> & {
@@ -222,8 +225,8 @@ type DropDownWidgetProps<Widget, T, S> = Omit<
222
225
  Widget,
223
226
  "model" | "factory" | "listFactory" | "headerFactory" | keyof DropDownOwnProps<T, S>
224
227
  > &
225
- DropDownOwnProps<T, S> &
226
- (SourceProps<T & Primitive, S> | Record<"renderItem", ListItemRenderer<T>>);
228
+ DropDownOwnProps<T, S> &
229
+ (SourceProps<T & Primitive, S> | Record<"renderItem", ListItemRenderer<T>>);
227
230
 
228
231
  /**
229
232
  * Native `Gtk.DropDown` props with declarative collections, controlled selection, and display/popup renderers.
@@ -263,7 +266,7 @@ type ListViewProps<T = unknown, S = unknown> = Omit<
263
266
  GtkListViewProps,
264
267
  "model" | "factory" | "headerFactory" | keyof ListViewOwnProps<T, S>
265
268
  > &
266
- ListViewOwnProps<T, S>;
269
+ ListViewOwnProps<T, S>;
267
270
 
268
271
  /**
269
272
  * Native `Adw.ComboRow` props with declarative collections, controlled selection, and display/popup renderers.
@@ -291,6 +294,42 @@ type ToastProviderProps = {
291
294
  children?: ReactNode | undefined;
292
295
  };
293
296
 
297
+ /** Describes the JSX page rendered for each value in a {@link TabCollection}. */
298
+ type TabCollectionOptions<T> = {
299
+ /** Native page props and JSX content. Call getPage in event handlers to access the mounted page. The collection owns its ref and parent relationship. */
300
+ pageProps: (item: T, getPage: () => Adw.TabPage | null) => Omit<AdwTabPageProps, "ref" | "parent">;
301
+ };
302
+
303
+ /** Options for adding a page to a {@link TabCollection}. */
304
+ type TabAppendOptions = {
305
+ /** Inserts a related page after this parent and its descendants, using native tab ancestry. */
306
+ parent?: Adw.TabPage | undefined;
307
+ /** Selects the new page by default. */
308
+ select?: boolean | undefined;
309
+ };
310
+
311
+ /**
312
+ * Owns independent JSX tab roots across native view transfers. Its lifetime can span several windows.
313
+ * Remove a page after accepting its native close request, remove a view's pages before destroying that
314
+ * view, and dispose the collection when its owner is finished.
315
+ */
316
+ type TabCollection<T> = {
317
+ /** Renders synchronously and returns the native page, including from an overview's create-tab handler. */
318
+ append: (view: Adw.TabView, item: T, options?: TabAppendOptions) => Adw.TabPage;
319
+ /** Returns a managed page's current value, or undefined for an unmanaged page. */
320
+ get: (page: Adw.TabPage) => T | undefined;
321
+ /** Updates the value and rerenders the existing JSX root, preserving component state and native identity. */
322
+ update: (page: Adw.TabPage, item: T) => void;
323
+ /** Unmounts a managed page from its current native owner; already removed pages are ignored. */
324
+ remove: (page: Adw.TabPage) => void;
325
+ /** Unmounts this collection's pages currently belonging to the given view, including transferred pages. */
326
+ removeView: (view: Adw.TabView) => void;
327
+ /** Moves a managed page to another view, appending within its pinned or unpinned group by default. */
328
+ transfer: (page: Adw.TabPage, target: Adw.TabView, position?: number) => void;
329
+ /** Unmounts every managed page. The collection may subsequently be reused. */
330
+ dispose: () => void;
331
+ };
332
+
294
333
  export {
295
334
  type SelectionProps,
296
335
  type ExpanderDescriptions,
@@ -315,4 +354,7 @@ export {
315
354
  type ToastOptions,
316
355
  type ToastController,
317
356
  type ToastProviderProps,
357
+ type TabAppendOptions,
358
+ type TabCollection,
359
+ type TabCollectionOptions,
318
360
  };