@djangocfg/ui-core 2.1.393 → 2.1.395
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/README.md +14 -0
- package/package.json +4 -4
- package/src/components/effects/GlowBackground.tsx +1 -1
- package/src/components/navigation/sidebar/sidebar.tsx +4 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/tabs/activeTabStore.ts +167 -0
- package/src/hooks/tabs/index.ts +17 -0
- package/src/hooks/tabs/tabId.ts +44 -0
- package/src/hooks/tabs/useActiveTab.ts +64 -0
- package/src/styles/README.md +149 -123
- package/src/styles/base.css +3 -3
- package/src/styles/presets/presets.ts +0 -9
- package/src/styles/presets/themes/dense.ts +10 -10
- package/src/styles/presets/themes/django-cfg.ts +13 -13
- package/src/styles/presets/themes/high-contrast.ts +11 -11
- package/src/styles/presets/themes/index.ts +0 -18
- package/src/styles/presets/themes/ios.ts +64 -64
- package/src/styles/presets/themes/macos.ts +64 -64
- package/src/styles/presets/themes/soft.ts +25 -25
- package/src/styles/presets/themes/windows.ts +64 -64
- package/src/styles/presets/types.ts +26 -12
- package/src/styles/theme/dark.css +47 -53
- package/src/styles/theme/light.css +47 -53
- package/src/styles/theme/tokens.css +128 -113
- package/src/styles/utilities.css +44 -6
- package/src/styles/presets/themes/catppuccin.ts +0 -38
- package/src/styles/presets/themes/dracula.ts +0 -38
- package/src/styles/presets/themes/github.ts +0 -38
- package/src/styles/presets/themes/gruvbox.ts +0 -38
- package/src/styles/presets/themes/material.ts +0 -38
- package/src/styles/presets/themes/nord.ts +0 -38
- package/src/styles/presets/themes/one-dark.ts +0 -38
- package/src/styles/presets/themes/solarized.ts +0 -38
- package/src/styles/presets/themes/tokyo-night.ts +0 -38
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
|
|
|
76
76
|
| **Theme** | `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
|
|
77
77
|
| **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
|
|
78
78
|
| **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` — Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
|
|
79
|
+
| **Tabs** | `useActiveTab`, `useIsTabActive`, `useIsTabLeader` — cross-tab focus + leader election via BroadcastChannel |
|
|
79
80
|
| **Feedback** | `useToast`, `toast` (Sonner) |
|
|
80
81
|
| **Debug** | `useDebugTools` |
|
|
81
82
|
|
|
@@ -170,6 +171,19 @@ Lower level: `createSoundBus<E>({ sounds, getMuted, getVolume, isEnabled })` exp
|
|
|
170
171
|
|
|
171
172
|
For an opinionated, fully-wired example with bundled audio assets see `useChatAudio` in [`@djangocfg/ui-tools`](../../ui-tools/src/tools/Chat/README.md#audio) — it inlines six notification mp3s into the lazy chat chunk as `data:`-URLs so consumers need no asset setup.
|
|
172
173
|
|
|
174
|
+
## Cross-tab coordination
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { useActiveTab, useIsTabLeader } from '@djangocfg/ui-core/hooks';
|
|
178
|
+
|
|
179
|
+
const { isActive, isLeader, tabId } = useActiveTab();
|
|
180
|
+
// isActive — this tab has user focus (visibilitychange + focus/blur)
|
|
181
|
+
// isLeader — elected leader among open tabs (stable; oldest tab wins)
|
|
182
|
+
// tabId — stable per-tab id (sessionStorage, survives in-tab reload)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Single shared coordinator over `BroadcastChannel` — leader election runs locally on every tab from the same peer-set view, no server. Use the leader flag to dedupe side-effects across tabs: only the leader mutates `document.title` / favicon, holds the websocket, plays a sound; followers stay silent but still read state. Zustand-backed, SSR-safe, single-tab fallback when `BroadcastChannel` is unavailable.
|
|
186
|
+
|
|
173
187
|
## Schema-driven configurators
|
|
174
188
|
|
|
175
189
|
`@djangocfg/ui-core/lib` exports a portable JSON Schema 7 subset (`CustomJsonSchema7`, `CustomJsonUiSchema7`, `CustomJsonUiGroup`, `CustomJsonUiDisabledWhenRule`) for packages that ship configurator schemas without taking a runtime dependency on RJSF.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.395",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/i18n": "^2.1.
|
|
93
|
+
"@djangocfg/i18n": "^2.1.395",
|
|
94
94
|
"consola": "^3.4.2",
|
|
95
95
|
"lucide-react": "^0.545.0",
|
|
96
96
|
"moment": "^2.30.1",
|
|
@@ -160,8 +160,8 @@
|
|
|
160
160
|
"vaul": "1.1.2"
|
|
161
161
|
},
|
|
162
162
|
"devDependencies": {
|
|
163
|
-
"@djangocfg/i18n": "^2.1.
|
|
164
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
163
|
+
"@djangocfg/i18n": "^2.1.395",
|
|
164
|
+
"@djangocfg/typescript-config": "^2.1.395",
|
|
165
165
|
"@types/node": "^24.7.2",
|
|
166
166
|
"@types/react": "^19.1.0",
|
|
167
167
|
"@types/react-dom": "^19.1.0",
|
|
@@ -40,7 +40,7 @@ export const GlowBackground = React.memo(({ className }: GlowBackgroundProps) =>
|
|
|
40
40
|
width: 700,
|
|
41
41
|
height: 700,
|
|
42
42
|
borderRadius: '50%',
|
|
43
|
-
background: 'radial-gradient(circle,
|
|
43
|
+
background: 'radial-gradient(circle, color-mix(in oklab, var(--primary) 45%, transparent) 0%, transparent 70%)',
|
|
44
44
|
filter: 'blur(80px)',
|
|
45
45
|
animation: 'blob 10s ease-in-out infinite',
|
|
46
46
|
}} />
|
|
@@ -201,7 +201,7 @@ const Sidebar = React.forwardRef<
|
|
|
201
201
|
)}
|
|
202
202
|
style={
|
|
203
203
|
{
|
|
204
|
-
backgroundColor: "
|
|
204
|
+
backgroundColor: "var(--sidebar-background)",
|
|
205
205
|
} as React.CSSProperties
|
|
206
206
|
}
|
|
207
207
|
>
|
|
@@ -289,8 +289,8 @@ const Sidebar = React.forwardRef<
|
|
|
289
289
|
aria-hidden
|
|
290
290
|
className={cn(
|
|
291
291
|
"pointer-events-none absolute inset-y-0 right-0 z-[1] w-px",
|
|
292
|
-
"bg-[linear-gradient(180deg,
|
|
293
|
-
"dark:bg-[linear-gradient(180deg,
|
|
292
|
+
"bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_2%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_22%,transparent)_18%,color-mix(in_oklab,var(--sidebar-border)_40%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_20%,transparent)_82%,color-mix(in_oklab,var(--sidebar-border)_3%,transparent)_100%)]",
|
|
293
|
+
"dark:bg-[linear-gradient(180deg,color-mix(in_oklab,var(--sidebar-border)_8%,transparent)_0%,color-mix(in_oklab,var(--sidebar-border)_34%,transparent)_22%,color-mix(in_oklab,var(--sidebar-border)_55%,transparent)_50%,color-mix(in_oklab,var(--sidebar-border)_28%,transparent)_78%,color-mix(in_oklab,var(--sidebar-border)_6%,transparent)_100%)]"
|
|
294
294
|
)}
|
|
295
295
|
/>
|
|
296
296
|
)}
|
|
@@ -610,7 +610,7 @@ const sidebarMenuButtonVariants = cva(
|
|
|
610
610
|
variant: {
|
|
611
611
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
612
612
|
outline:
|
|
613
|
-
"bg-background shadow-[
|
|
613
|
+
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
|
614
614
|
},
|
|
615
615
|
size: {
|
|
616
616
|
default: "h-8 text-sm",
|
package/src/hooks/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from './events';
|
|
|
21
21
|
export * from './hotkey';
|
|
22
22
|
export * from './audio';
|
|
23
23
|
export * from './debug';
|
|
24
|
+
export * from './tabs';
|
|
24
25
|
|
|
25
26
|
// ----------------------------------------------------------------------------
|
|
26
27
|
// Router — framework-agnostic navigation primitives.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
import { getTabId } from './tabId';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-tab coordination store. Singleton, lazily initialised on the
|
|
9
|
+
* first hook subscription.
|
|
10
|
+
*
|
|
11
|
+
* Two orthogonal concepts:
|
|
12
|
+
* - `isActive` — this tab has user focus right now (visibilitychange
|
|
13
|
+
* + focus/blur).
|
|
14
|
+
* - `isLeader` — among open tabs of this app, this tab won the
|
|
15
|
+
* election. Leadership is stable; it only changes when the current
|
|
16
|
+
* leader closes/refreshes.
|
|
17
|
+
*
|
|
18
|
+
* Election protocol over a single BroadcastChannel:
|
|
19
|
+
* on mount:
|
|
20
|
+
* - announce HELLO with our tabId
|
|
21
|
+
* - listen for HELLO/CLAIM/BYE
|
|
22
|
+
* on HELLO from a peer:
|
|
23
|
+
* - if we think we're leader, reply CLAIM (so the newcomer learns
|
|
24
|
+
* the current leader without forcing a re-election)
|
|
25
|
+
* on CLAIM from a peer:
|
|
26
|
+
* - record their tabId in the peer set; the smallest tabId wins
|
|
27
|
+
* (tabIds are time-prefixed so smallest == oldest)
|
|
28
|
+
* on BYE (beforeunload):
|
|
29
|
+
* - remove peer; if it was the leader, re-elect locally
|
|
30
|
+
*
|
|
31
|
+
* Leader election runs locally on each tab from the same peer-set view,
|
|
32
|
+
* so all tabs converge on the same answer without a coordinator.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type ActiveTabMessage =
|
|
36
|
+
| { type: 'hello'; tabId: string }
|
|
37
|
+
| { type: 'claim'; tabId: string }
|
|
38
|
+
| { type: 'bye'; tabId: string };
|
|
39
|
+
|
|
40
|
+
interface ActiveTabState {
|
|
41
|
+
/** Stable id for the current tab. */
|
|
42
|
+
tabId: string;
|
|
43
|
+
/** This tab has user focus. */
|
|
44
|
+
isActive: boolean;
|
|
45
|
+
/** This tab is the elected leader among open tabs. */
|
|
46
|
+
isLeader: boolean;
|
|
47
|
+
/** Currently elected leader id (may be this tab or another). */
|
|
48
|
+
leaderId: string;
|
|
49
|
+
/** All tabs we've seen, including ourselves. */
|
|
50
|
+
peers: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CHANNEL_NAME = 'djangocfg:active-tab';
|
|
54
|
+
|
|
55
|
+
const initialId = getTabId();
|
|
56
|
+
|
|
57
|
+
export const useActiveTabStore = create<ActiveTabState>(() => ({
|
|
58
|
+
tabId: initialId,
|
|
59
|
+
isActive: typeof document !== 'undefined' ? !document.hidden : true,
|
|
60
|
+
isLeader: true, // Optimistic — corrected by election on first peer HELLO.
|
|
61
|
+
leaderId: initialId,
|
|
62
|
+
peers: [initialId],
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
let initialised = false;
|
|
66
|
+
let channel: BroadcastChannel | null = null;
|
|
67
|
+
let visibilityTeardown: (() => void) | null = null;
|
|
68
|
+
|
|
69
|
+
function elect(peers: string[]): string {
|
|
70
|
+
// Smallest tabId wins. tabIds are time-prefixed; smallest == oldest.
|
|
71
|
+
return peers.slice().sort()[0]!;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function applyPeers(nextPeers: string[]) {
|
|
75
|
+
const me = useActiveTabStore.getState().tabId;
|
|
76
|
+
const peers = Array.from(new Set([me, ...nextPeers])).sort();
|
|
77
|
+
const leaderId = elect(peers);
|
|
78
|
+
useActiveTabStore.setState({
|
|
79
|
+
peers,
|
|
80
|
+
leaderId,
|
|
81
|
+
isLeader: leaderId === me,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function post(msg: ActiveTabMessage) {
|
|
86
|
+
channel?.postMessage(msg);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setupVisibility(): () => void {
|
|
90
|
+
if (typeof document === 'undefined') return () => {};
|
|
91
|
+
|
|
92
|
+
const sync = () => {
|
|
93
|
+
const hidden = document.hidden || (
|
|
94
|
+
typeof document.hasFocus === 'function' && !document.hasFocus()
|
|
95
|
+
);
|
|
96
|
+
useActiveTabStore.setState({ isActive: !hidden });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
sync();
|
|
100
|
+
document.addEventListener('visibilitychange', sync);
|
|
101
|
+
window.addEventListener('focus', sync);
|
|
102
|
+
window.addEventListener('blur', sync);
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
document.removeEventListener('visibilitychange', sync);
|
|
106
|
+
window.removeEventListener('focus', sync);
|
|
107
|
+
window.removeEventListener('blur', sync);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Idempotent — first call wires up the channel and listeners, later
|
|
113
|
+
* calls are no-ops. Called automatically by `useActiveTab` on mount.
|
|
114
|
+
* Exposed for hosts that want to warm the coordinator before any hook
|
|
115
|
+
* subscribes (e.g. inside an app shell).
|
|
116
|
+
*/
|
|
117
|
+
export function ensureActiveTabCoordinator(): void {
|
|
118
|
+
if (initialised) return;
|
|
119
|
+
initialised = true;
|
|
120
|
+
|
|
121
|
+
visibilityTeardown = setupVisibility();
|
|
122
|
+
|
|
123
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
124
|
+
// Single-tab fallback: we are trivially the leader.
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
129
|
+
const me = useActiveTabStore.getState().tabId;
|
|
130
|
+
|
|
131
|
+
channel.addEventListener('message', (e) => {
|
|
132
|
+
const msg = e.data as ActiveTabMessage | undefined;
|
|
133
|
+
if (!msg || typeof msg !== 'object') return;
|
|
134
|
+
|
|
135
|
+
const { peers: currentPeers } = useActiveTabStore.getState();
|
|
136
|
+
|
|
137
|
+
if (msg.type === 'hello') {
|
|
138
|
+
// Acknowledge the newcomer with a CLAIM so they learn about us.
|
|
139
|
+
applyPeers([...currentPeers, msg.tabId]);
|
|
140
|
+
post({ type: 'claim', tabId: me });
|
|
141
|
+
} else if (msg.type === 'claim') {
|
|
142
|
+
applyPeers([...currentPeers, msg.tabId]);
|
|
143
|
+
} else if (msg.type === 'bye') {
|
|
144
|
+
applyPeers(currentPeers.filter((id) => id !== msg.tabId));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Announce ourselves.
|
|
149
|
+
post({ type: 'hello', tabId: me });
|
|
150
|
+
|
|
151
|
+
const onUnload = () => post({ type: 'bye', tabId: me });
|
|
152
|
+
window.addEventListener('beforeunload', onUnload);
|
|
153
|
+
window.addEventListener('pagehide', onUnload);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Tear down everything (channel, listeners). Tests only — production
|
|
158
|
+
* tabs live and die with the page.
|
|
159
|
+
*/
|
|
160
|
+
export function disposeActiveTabCoordinator(): void {
|
|
161
|
+
if (!initialised) return;
|
|
162
|
+
initialised = false;
|
|
163
|
+
channel?.close();
|
|
164
|
+
channel = null;
|
|
165
|
+
visibilityTeardown?.();
|
|
166
|
+
visibilityTeardown = null;
|
|
167
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
useActiveTab,
|
|
5
|
+
useIsTabActive,
|
|
6
|
+
useIsTabLeader,
|
|
7
|
+
type UseActiveTabReturn,
|
|
8
|
+
} from './useActiveTab';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
useActiveTabStore,
|
|
12
|
+
ensureActiveTabCoordinator,
|
|
13
|
+
disposeActiveTabCoordinator,
|
|
14
|
+
type ActiveTabMessage,
|
|
15
|
+
} from './activeTabStore';
|
|
16
|
+
|
|
17
|
+
export { getTabId } from './tabId';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-tab identifier. Persisted in sessionStorage so a single
|
|
3
|
+
* tab keeps its id across in-tab reloads (Cmd-R) but a new tab gets a
|
|
4
|
+
* fresh one. Falls back to an in-memory id when sessionStorage is
|
|
5
|
+
* unavailable (private mode, sandboxed iframes, SSR).
|
|
6
|
+
*
|
|
7
|
+
* The id includes `performance.timeOrigin` so leader election can
|
|
8
|
+
* prefer the longest-lived tab without an extra "born_at" field —
|
|
9
|
+
* lexicographic ordering of the id matches age ordering closely enough
|
|
10
|
+
* for our purposes (ties broken by random suffix).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const KEY = 'djangocfg:tab-id';
|
|
14
|
+
|
|
15
|
+
let cached: string | null = null;
|
|
16
|
+
|
|
17
|
+
function makeId(): string {
|
|
18
|
+
const origin = typeof performance !== 'undefined' ? performance.timeOrigin : Date.now();
|
|
19
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
20
|
+
// Zero-padded so string compare matches numeric compare.
|
|
21
|
+
return `${Math.floor(origin).toString(10).padStart(16, '0')}-${rand}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getTabId(): string {
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
if (typeof sessionStorage === 'undefined') {
|
|
27
|
+
cached = makeId();
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const existing = sessionStorage.getItem(KEY);
|
|
32
|
+
if (existing) {
|
|
33
|
+
cached = existing;
|
|
34
|
+
return existing;
|
|
35
|
+
}
|
|
36
|
+
const next = makeId();
|
|
37
|
+
sessionStorage.setItem(KEY, next);
|
|
38
|
+
cached = next;
|
|
39
|
+
return next;
|
|
40
|
+
} catch {
|
|
41
|
+
cached = makeId();
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureActiveTabCoordinator,
|
|
8
|
+
useActiveTabStore,
|
|
9
|
+
} from './activeTabStore';
|
|
10
|
+
|
|
11
|
+
export interface UseActiveTabReturn {
|
|
12
|
+
/** Stable id for this tab. Survives in-tab reload, dies with the tab. */
|
|
13
|
+
tabId: string;
|
|
14
|
+
/** True while the user has focus on this tab. */
|
|
15
|
+
isActive: boolean;
|
|
16
|
+
/** True when this tab is the elected leader across all open app tabs. */
|
|
17
|
+
isLeader: boolean;
|
|
18
|
+
/** Current leader id (may be this tab or another). */
|
|
19
|
+
leaderId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Subscribe to cross-tab state: focus + leader election.
|
|
24
|
+
*
|
|
25
|
+
* Use cases:
|
|
26
|
+
* - Only the leader mutates `document.title` / favicon to avoid all
|
|
27
|
+
* open tabs blinking in unison.
|
|
28
|
+
* - Only the leader holds a websocket; followers consume snapshots
|
|
29
|
+
* via broadcast.
|
|
30
|
+
* - `isActive` for "the user is watching this exact tab" gating.
|
|
31
|
+
*
|
|
32
|
+
* The coordinator is shared across every consumer in the app — calling
|
|
33
|
+
* the hook in multiple components costs only a store subscription.
|
|
34
|
+
*/
|
|
35
|
+
export function useActiveTab(): UseActiveTabReturn {
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
ensureActiveTabCoordinator();
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return useActiveTabStore(
|
|
41
|
+
useShallow((s) => ({
|
|
42
|
+
tabId: s.tabId,
|
|
43
|
+
isActive: s.isActive,
|
|
44
|
+
isLeader: s.isLeader,
|
|
45
|
+
leaderId: s.leaderId,
|
|
46
|
+
})),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Subscribe to just the `isActive` flag — no leader-election reads. */
|
|
51
|
+
export function useIsTabActive(): boolean {
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
ensureActiveTabCoordinator();
|
|
54
|
+
}, []);
|
|
55
|
+
return useActiveTabStore((s) => s.isActive);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Subscribe to just the `isLeader` flag. */
|
|
59
|
+
export function useIsTabLeader(): boolean {
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
ensureActiveTabCoordinator();
|
|
62
|
+
}, []);
|
|
63
|
+
return useActiveTabStore((s) => s.isLeader);
|
|
64
|
+
}
|