chromium-tabs 0.1.0
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.
- package/LICENSE +31 -0
- package/README.md +164 -0
- package/dist/chunk-2DTGNBUT.js +1667 -0
- package/dist/chunk-2DTGNBUT.js.map +1 -0
- package/dist/core/index.cjs +1699 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +761 -0
- package/dist/core/index.d.ts +761 -0
- package/dist/core/index.js +19 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index.cjs +2084 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +128 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +394 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +165 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Wearing
|
|
4
|
+
Portions derived from Chromium (Copyright The Chromium Authors), used under
|
|
5
|
+
a BSD-style license: https://chromium.googlesource.com/chromium/src/+/main/LICENSE
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
11
|
+
this list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
18
|
+
may be used to endorse or promote products derived from this software
|
|
19
|
+
without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
24
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
25
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
26
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
27
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
28
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
29
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
30
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
31
|
+
POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# chromium-tabs
|
|
2
|
+
|
|
3
|
+
Chrome's tab strip logic, ported from the Chromium C++ source to TypeScript, with React bindings.
|
|
4
|
+
|
|
5
|
+
This is not "a tabs component". It's the actual behavioral model Chrome uses (`TabStripModel`, `chrome/browser/ui/tabs/`), so you get the details users expect from a real browser for free:
|
|
6
|
+
|
|
7
|
+
- **Pinned tabs** locked to the left, with all of Chrome's index-clamping rules
|
|
8
|
+
- **Tab groups** with colors, titles, collapse, and contiguity enforcement
|
|
9
|
+
- **Opener tracking**: close a tab opened from another and activation jumps back the way Chrome's does (opened-children first, then siblings, then the opener)
|
|
10
|
+
- **Smart insertion**: link-style opens insert next to their opener; typed-style opens append and behave like Chrome's "quick look-up" tabs
|
|
11
|
+
- **Multi-select** with anchor/extend semantics (ctrl-click, shift-click)
|
|
12
|
+
- **Drag to reorder**, with group membership adjusting at boundaries the way Chrome's `MoveTabRelative` does
|
|
13
|
+
- **Stateful tabs without the memory bill**: background tabs keep their live component state like Chrome keeps background pages alive, and a port of Chrome's Memory Saver discards the least-recently-used tabs past a budget, reloading them on activation
|
|
14
|
+
|
|
15
|
+
The core is headless and framework-agnostic. The React layer is optional.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install chromium-tabs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## React quick start
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { Tabs, useTabStripModel } from 'chromium-tabs'
|
|
27
|
+
import 'chromium-tabs/styles.css'
|
|
28
|
+
|
|
29
|
+
interface Page {
|
|
30
|
+
title: string
|
|
31
|
+
url: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function App() {
|
|
35
|
+
const model = useTabStripModel<Page>((m) => {
|
|
36
|
+
m.appendTab({ title: 'Home', url: '/' })
|
|
37
|
+
m.appendTab({ title: 'Docs', url: '/docs' }, false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Tabs
|
|
42
|
+
model={model}
|
|
43
|
+
renderTab={(tab) => tab.data.title}
|
|
44
|
+
onNewTab={() => model.addTab({ title: 'New tab', url: '/new' }, { cause: 'typed', flags: 1 })}
|
|
45
|
+
>
|
|
46
|
+
{(tab) => <PageView page={tab.data} />}
|
|
47
|
+
</Tabs>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`<Tabs>` is the strip plus the keep-alive content host wired together. Tab content stays mounted while in the background, so component state (filters, scroll, drafts) survives switching by construction. Need a custom layout (strip in a sidebar, panels elsewhere)? Compose `<TabStrip>` and `<TabPanels>` directly, but keep content inside `<TabPanels>` unless you specifically want state-losing remounts on every switch.
|
|
53
|
+
|
|
54
|
+
Interactions out of the box: click activates, ctrl/cmd-click multi-selects, shift-click extends, middle-click closes, drag reorders, arrow keys switch tabs, ctrl/cmd+arrows move the active tab (hopping group boundaries like Chrome).
|
|
55
|
+
|
|
56
|
+
## Stateful tab content (and keeping memory bounded)
|
|
57
|
+
|
|
58
|
+
`<TabPanels>` is the content host. Every tab's content stays mounted while hidden, so scroll positions, form drafts, and in-flight work all survive switching tabs, the same way Chrome keeps background pages alive:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { TabPanels, TabStrip, TabLifecycleManager, useTabVisibility, useTabStripModel } from 'chromium-tabs'
|
|
62
|
+
import { useEffect } from 'react'
|
|
63
|
+
|
|
64
|
+
function App() {
|
|
65
|
+
const model = useTabStripModel<Page>(/* ... */)
|
|
66
|
+
|
|
67
|
+
// Chrome's Memory Saver, ported: past 8 loaded tabs, the least recently
|
|
68
|
+
// used tab's content is dropped. The tab stays in the strip and remounts
|
|
69
|
+
// fresh when activated, exactly like Chrome's discard + reload-on-focus.
|
|
70
|
+
useEffect(() => new TabLifecycleManager(model, { maxLoadedTabs: 8 }).start(), [model])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<TabStrip model={model} renderTab={(tab) => tab.data.title} />
|
|
75
|
+
<TabPanels model={model}>{(tab) => <PageView page={tab.data} />}</TabPanels>
|
|
76
|
+
</>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function PageView({ page }: { page: Page }) {
|
|
81
|
+
// 'hidden' while the tab is in the background: pause polling, video, etc.
|
|
82
|
+
const visibility = useTabVisibility()
|
|
83
|
+
// ...
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The discard policy is Chrome's, ported from `resource_coordinator` / `performance_manager`:
|
|
88
|
+
|
|
89
|
+
- the active tab and opted-out tabs (`tab.autoDiscardable = false`) are never discarded
|
|
90
|
+
- your app can veto per tab (`canDiscardTab`), the equivalent of Chrome protecting tabs that play audio or hold form input
|
|
91
|
+
- pinned and recently-active tabs (10 min, configurable) are protected: discarded only under `'urgent'` pressure, never proactively
|
|
92
|
+
- otherwise: least recently used goes first
|
|
93
|
+
- `onBeforeDiscard` lets you snapshot restorable state (scroll offset, draft text) into `tab.data` before the content unmounts
|
|
94
|
+
|
|
95
|
+
One policy with no Chrome equivalent, for apps whose tab content shares global state per content type (a singleton store per route/scene): `exclusiveContentKey: (tab) => string | null` keeps at most one loaded tab per distinct key. When two loaded tabs would share a key, the background one is discarded immediately and re-derives its state from its own `data` on the next activation, so shared state can never bleed between duplicates. Return `null` to exempt content that isolates correctly.
|
|
96
|
+
|
|
97
|
+
## Syncing with an external source of truth
|
|
98
|
+
|
|
99
|
+
If your app's canonical tab state lives elsewhere (a router, a store, another window), mirror it into the model with `reconcile` — minimal mutations, tab identity (and therefore mounted content and discard state) preserved:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
model.reconcile(
|
|
103
|
+
tabs.map((t) => ({ id: t.id, data: t, pinned: t.pinned })),
|
|
104
|
+
{ activeId: activeTabId, dataEquals: (a, b) => a.url === b.url },
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Tabs absent from the list are removed (bypassing `canCloseTab` — the external state already decided), missing tabs are inserted at their position, data/pinned/order/activation converge. A second identical call fires no observer events, so it is safe to run on every store change. Reconcile-driven activations carry selection reason `'none'`, letting observers distinguish them from user gestures when writing model changes back to the store.
|
|
109
|
+
|
|
110
|
+
## Headless usage
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { TabStripModel, AddTabFlags } from 'chromium-tabs/core'
|
|
114
|
+
|
|
115
|
+
const model = new TabStripModel<string>()
|
|
116
|
+
|
|
117
|
+
// A tab opened from a link inserts next to its opener and inherits its group.
|
|
118
|
+
model.appendTab('docs.example.com')
|
|
119
|
+
const child = model.addTab('linked page', { cause: 'link', flags: AddTabFlags.ACTIVE })
|
|
120
|
+
|
|
121
|
+
// Closing it returns to the opener, exactly like Chrome.
|
|
122
|
+
model.closeTabAt(model.indexOfTab(child))
|
|
123
|
+
|
|
124
|
+
// Pinning moves the tab to the pinned block and unpins clamp moves around it.
|
|
125
|
+
model.setTabPinned(1, true)
|
|
126
|
+
|
|
127
|
+
// Groups stay contiguous; Chrome's exit rules apply when tabs leave.
|
|
128
|
+
const group = model.addToNewGroup([1, 2])
|
|
129
|
+
model.setGroupCollapsed(group, true)
|
|
130
|
+
|
|
131
|
+
model.addObserver({
|
|
132
|
+
onTabStripModelChanged(change, selection) {
|
|
133
|
+
// 'inserted' | 'removed' | 'moved' | 'replaced' | 'selectionOnly'
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## What's ported and from where
|
|
139
|
+
|
|
140
|
+
The port tracks Chromium `main` (see `PORTING_NOTES.md` for the algorithm-by-algorithm mapping with C++ line references):
|
|
141
|
+
|
|
142
|
+
| This package | Chromium |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `TabStripModel` | `chrome/browser/ui/tabs/tab_strip_model.{h,cc}` |
|
|
145
|
+
| `ListSelectionModel` | `ui/base/models/list_selection_model.{h,cc}` |
|
|
146
|
+
| observer events | `chrome/browser/ui/tabs/tab_strip_model_observer.h` |
|
|
147
|
+
| `AddTabFlags` | `chrome/browser/ui/tabs/tab_enums.h` |
|
|
148
|
+
| `TabLifecycleManager` (discarding) | `chrome/browser/resource_coordinator/tab_lifecycle_unit.cc`, `chrome/browser/performance_manager/policies/discard_eligibility_policy.h` |
|
|
149
|
+
| `<TabPanels>` keep-alive + `useTabVisibility` | behavioral equivalent of Chrome keeping background pages alive + visibility signals |
|
|
150
|
+
|
|
151
|
+
Not ported: split tabs, async unload handlers (closes are synchronous; veto with `canCloseTab`).
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
bun install
|
|
157
|
+
bun run test # vitest, 91 tests
|
|
158
|
+
bun run typecheck
|
|
159
|
+
bun run build # tsup -> dist/
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
BSD-3-Clause, matching the Chromium code this derives from.
|