@intentface/ui 0.0.0 → 0.7.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 +21 -0
- package/README.md +145 -2
- package/dist/ask-user/ask-user.d.ts +104 -0
- package/dist/ask-user/ask-user.js +245 -0
- package/dist/ask-user/index.d.ts +3 -0
- package/dist/ask-user/index.js +3 -0
- package/dist/ask-user/index.parts.d.ts +1 -0
- package/dist/ask-user/index.parts.js +5 -0
- package/dist/attachments/attachments.d.ts +73 -0
- package/dist/attachments/attachments.js +121 -0
- package/dist/attachments/index.d.ts +3 -0
- package/dist/attachments/index.js +3 -0
- package/dist/attachments/index.parts.d.ts +1 -0
- package/dist/attachments/index.parts.js +5 -0
- package/dist/chip/chip.d.ts +45 -0
- package/dist/chip/chip.js +43 -0
- package/dist/chip/index.d.ts +2 -0
- package/dist/chip/index.js +2 -0
- package/dist/chip/index.parts.d.ts +1 -0
- package/dist/chip/index.parts.js +5 -0
- package/dist/chip-markdown.d.ts +25 -0
- package/dist/chip-markdown.js +54 -0
- package/dist/composer/actions.d.ts +35 -0
- package/dist/composer/actions.js +76 -0
- package/dist/composer/ask-user-machine.d.ts +52 -0
- package/dist/composer/ask-user-machine.js +164 -0
- package/dist/composer/attachments-machine.d.ts +34 -0
- package/dist/composer/attachments-machine.js +91 -0
- package/dist/composer/attachments.d.ts +16 -0
- package/dist/composer/attachments.js +32 -0
- package/dist/composer/command-list.d.ts +49 -0
- package/dist/composer/command-list.js +360 -0
- package/dist/composer/commands.d.ts +28 -0
- package/dist/composer/commands.js +56 -0
- package/dist/composer/container.d.ts +3 -0
- package/dist/composer/container.js +32 -0
- package/dist/composer/controller.d.ts +3 -0
- package/dist/composer/controller.js +6 -0
- package/dist/composer/editor-dom.d.ts +57 -0
- package/dist/composer/editor-dom.js +517 -0
- package/dist/composer/fuzzy.d.ts +4 -0
- package/dist/composer/fuzzy.js +49 -0
- package/dist/composer/index.d.ts +25 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.parts.d.ts +11 -0
- package/dist/composer/index.parts.js +15 -0
- package/dist/composer/internals.d.ts +30 -0
- package/dist/composer/internals.js +102 -0
- package/dist/composer/keyboard.d.ts +61 -0
- package/dist/composer/keyboard.js +75 -0
- package/dist/composer/panel.d.ts +28 -0
- package/dist/composer/panel.js +76 -0
- package/dist/composer/placeholder.d.ts +10 -0
- package/dist/composer/placeholder.js +8 -0
- package/dist/composer/popover.d.ts +12 -0
- package/dist/composer/popover.js +62 -0
- package/dist/composer/prefix-detection.d.ts +23 -0
- package/dist/composer/prefix-detection.js +67 -0
- package/dist/composer/root.d.ts +21 -0
- package/dist/composer/root.js +124 -0
- package/dist/composer/segments.d.ts +108 -0
- package/dist/composer/segments.js +366 -0
- package/dist/composer/store.d.ts +98 -0
- package/dist/composer/store.js +457 -0
- package/dist/composer/textarea.d.ts +39 -0
- package/dist/composer/textarea.js +64 -0
- package/dist/composer/trigger-tracker.d.ts +12 -0
- package/dist/composer/trigger-tracker.js +90 -0
- package/dist/composer/types.d.ts +127 -0
- package/dist/composer/types.js +4 -0
- package/dist/composer/use-composer-editor.d.ts +43 -0
- package/dist/composer/use-composer-editor.js +653 -0
- package/dist/generative/catalog.d.ts +177 -0
- package/dist/generative/catalog.js +20 -0
- package/dist/generative/index.d.ts +26 -0
- package/dist/generative/index.js +20 -0
- package/dist/generative/json-schema.d.ts +45 -0
- package/dist/generative/json-schema.js +216 -0
- package/dist/generative/markdown.d.ts +21 -0
- package/dist/generative/markdown.js +76 -0
- package/dist/generative/tree.d.ts +56 -0
- package/dist/generative/tree.js +365 -0
- package/dist/generative/use-tree.d.ts +12 -0
- package/dist/generative/use-tree.js +64 -0
- package/dist/internal/change-event-details.d.ts +12 -0
- package/dist/internal/change-event-details.js +16 -0
- package/dist/internal/collapsible.d.ts +22 -0
- package/dist/internal/collapsible.js +94 -0
- package/dist/internal/collection.d.ts +32 -0
- package/dist/internal/collection.js +72 -0
- package/dist/internal/iso-layout-effect.d.ts +2 -0
- package/dist/internal/iso-layout-effect.js +6 -0
- package/dist/internal/portal.d.ts +6 -0
- package/dist/internal/portal.js +18 -0
- package/dist/internal/primitive-props.d.ts +4 -0
- package/dist/internal/primitive-props.js +3 -0
- package/dist/internal/render/baseTypes.d.ts +18 -0
- package/dist/internal/render/baseTypes.js +4 -0
- package/dist/internal/render/empty.d.ts +3 -0
- package/dist/internal/render/empty.js +5 -0
- package/dist/internal/render/getReactElementRef.d.ts +5 -0
- package/dist/internal/render/getReactElementRef.js +15 -0
- package/dist/internal/render/getStateAttributesProps.d.ts +4 -0
- package/dist/internal/render/getStateAttributesProps.js +23 -0
- package/dist/internal/render/mergeObjects.d.ts +1 -0
- package/dist/internal/render/mergeObjects.js +14 -0
- package/dist/internal/render/mergeProps.d.ts +55 -0
- package/dist/internal/render/mergeProps.js +183 -0
- package/dist/internal/render/positioning.d.ts +33 -0
- package/dist/internal/render/positioning.js +112 -0
- package/dist/internal/render/reactVersion.d.ts +3 -0
- package/dist/internal/render/reactVersion.js +7 -0
- package/dist/internal/render/resolveClassName.d.ts +8 -0
- package/dist/internal/render/resolveClassName.js +12 -0
- package/dist/internal/render/resolveStyle.d.ts +8 -0
- package/dist/internal/render/resolveStyle.js +12 -0
- package/dist/internal/render/transition.d.ts +47 -0
- package/dist/internal/render/transition.js +259 -0
- package/dist/internal/render/types.d.ts +74 -0
- package/dist/internal/render/types.js +3 -0
- package/dist/internal/render/useMergedRefs.d.ts +21 -0
- package/dist/internal/render/useMergedRefs.js +102 -0
- package/dist/internal/render/useRefWithInit.d.ts +10 -0
- package/dist/internal/render/useRefWithInit.js +12 -0
- package/dist/internal/render/useRender.d.ts +71 -0
- package/dist/internal/render/useRender.js +12 -0
- package/dist/internal/render/useRenderElement.d.ts +62 -0
- package/dist/internal/render/useRenderElement.js +157 -0
- package/dist/internal/render/warn.d.ts +1 -0
- package/dist/internal/render/warn.js +15 -0
- package/dist/internal/safe-polygon.d.ts +21 -0
- package/dist/internal/safe-polygon.js +179 -0
- package/dist/internal/scroll-into-view-within.d.ts +1 -0
- package/dist/internal/scroll-into-view-within.js +26 -0
- package/dist/internal/standard-schema.d.ts +38 -0
- package/dist/internal/standard-schema.js +4 -0
- package/dist/internal/state-mappings.d.ts +8 -0
- package/dist/internal/state-mappings.js +17 -0
- package/dist/internal/use-controlled.d.ts +19 -0
- package/dist/internal/use-controlled.js +33 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.js +3 -0
- package/dist/message/index.parts.d.ts +1 -0
- package/dist/message/index.parts.js +5 -0
- package/dist/message/message.d.ts +58 -0
- package/dist/message/message.js +93 -0
- package/dist/message-utils.d.ts +55 -0
- package/dist/message-utils.js +79 -0
- package/dist/nav/index.d.ts +4 -0
- package/dist/nav/index.js +5 -0
- package/dist/nav/index.parts.d.ts +3 -0
- package/dist/nav/index.parts.js +7 -0
- package/dist/nav/nav.d.ts +152 -0
- package/dist/nav/nav.js +660 -0
- package/dist/nav/store.d.ts +50 -0
- package/dist/nav/store.js +95 -0
- package/dist/reasoning/index.d.ts +3 -0
- package/dist/reasoning/index.js +3 -0
- package/dist/reasoning/index.parts.d.ts +1 -0
- package/dist/reasoning/index.parts.js +5 -0
- package/dist/reasoning/reasoning.d.ts +38 -0
- package/dist/reasoning/reasoning.js +67 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +5 -0
- package/dist/shell/index.parts.d.ts +3 -0
- package/dist/shell/index.parts.js +7 -0
- package/dist/shell/shell.d.ts +108 -0
- package/dist/shell/shell.js +390 -0
- package/dist/shell/store.d.ts +112 -0
- package/dist/shell/store.js +134 -0
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/dist/steps/index.parts.d.ts +1 -0
- package/dist/steps/index.parts.js +5 -0
- package/dist/steps/steps.d.ts +90 -0
- package/dist/steps/steps.js +116 -0
- package/dist/tabs/anchored.d.ts +59 -0
- package/dist/tabs/anchored.js +109 -0
- package/dist/tabs/hover.d.ts +33 -0
- package/dist/tabs/hover.js +169 -0
- package/dist/tabs/index.d.ts +5 -0
- package/dist/tabs/index.js +4 -0
- package/dist/tabs/index.parts.d.ts +4 -0
- package/dist/tabs/index.parts.js +8 -0
- package/dist/tabs/store.d.ts +131 -0
- package/dist/tabs/store.js +248 -0
- package/dist/tabs/tabs.d.ts +217 -0
- package/dist/tabs/tabs.js +458 -0
- package/dist/thread/geometry.d.ts +23 -0
- package/dist/thread/geometry.js +48 -0
- package/dist/thread/index.d.ts +4 -0
- package/dist/thread/index.js +3 -0
- package/dist/thread/index.parts.d.ts +1 -0
- package/dist/thread/index.parts.js +5 -0
- package/dist/thread/stores.d.ts +21 -0
- package/dist/thread/stores.js +63 -0
- package/dist/thread/thread.d.ts +57 -0
- package/dist/thread/thread.js +663 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +17 -0
- package/package.json +120 -4
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, Fragment, use, useCallback, useId, useMemo, useState } from "react";
|
|
4
|
+
import { createChangeEventDetails } from "../internal/change-event-details.js";
|
|
5
|
+
import { step } from "../internal/collection.js";
|
|
6
|
+
import { useIsomorphicLayoutEffect } from "../internal/iso-layout-effect.js";
|
|
7
|
+
import { useRenderElement } from "../internal/render/useRenderElement.js";
|
|
8
|
+
import { createTabsStore, TabsStoreContext, useTabsContextStore, useTabsStore } from "./store.js";
|
|
9
|
+
/**
|
|
10
|
+
* `direction` would otherwise emit `data-direction` — the default derivation
|
|
11
|
+
* lowercases rather than kebab-cases — and `none` is not worth an attribute.
|
|
12
|
+
*/
|
|
13
|
+
const PART_ATTRIBUTES = {
|
|
14
|
+
direction: (value) => value === "none" ? null : { "data-activation-direction": value },
|
|
15
|
+
};
|
|
16
|
+
const selectValue = (tabs) => tabs.value;
|
|
17
|
+
const selectDirection = (tabs) => tabs.direction;
|
|
18
|
+
const selectHasViewport = (tabs) => tabs.hasViewport;
|
|
19
|
+
const selectItems = (tabs) => tabs.items;
|
|
20
|
+
const TabsConfigContext = createContext(null);
|
|
21
|
+
const useTabsConfig = () => {
|
|
22
|
+
const config = use(TabsConfigContext);
|
|
23
|
+
if (!config)
|
|
24
|
+
throw new Error("Tabs parts must be used within <Tabs.Root>");
|
|
25
|
+
return config;
|
|
26
|
+
};
|
|
27
|
+
const TabsListContext = createContext(null);
|
|
28
|
+
const TabsItemContext = createContext(null);
|
|
29
|
+
/**
|
|
30
|
+
* Disabled cascades: the Root disables everything, an Item disables its own
|
|
31
|
+
* entry, and a part can disable just itself.
|
|
32
|
+
*/
|
|
33
|
+
const useDisabled = (own = false) => {
|
|
34
|
+
const { disabled: rootDisabled } = useTabsConfig();
|
|
35
|
+
const item = use(TabsItemContext);
|
|
36
|
+
return rootDisabled || (item?.disabled ?? false) || own;
|
|
37
|
+
};
|
|
38
|
+
/** The enclosing item's value, if there is one. Does not insist on it. */
|
|
39
|
+
const useOptionalItemValue = (explicit) => explicit ?? use(TabsItemContext)?.value ?? null;
|
|
40
|
+
const useItemValue = (explicit) => {
|
|
41
|
+
const inherited = use(TabsItemContext)?.value ?? null;
|
|
42
|
+
const value = explicit ?? inherited;
|
|
43
|
+
if (value === null || value === undefined) {
|
|
44
|
+
throw new Error("Tabs parts need a `value`, either their own or from <Tabs.Trigger>");
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
const usePartState = (store, orientation, disabled = false) => ({
|
|
49
|
+
orientation,
|
|
50
|
+
disabled,
|
|
51
|
+
empty: useTabsStore(store, selectValue) === null,
|
|
52
|
+
direction: useTabsStore(store, selectDirection),
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* True while a text field should keep an arrow key for itself: there is a
|
|
56
|
+
* selection, Shift is held, or the caret has not yet reached the end it is
|
|
57
|
+
* travelling towards.
|
|
58
|
+
*/
|
|
59
|
+
const ownsTheArrowKeys = (event, forward, backward) => {
|
|
60
|
+
const target = event.target;
|
|
61
|
+
if (!target || !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (target.disabled)
|
|
65
|
+
return false;
|
|
66
|
+
const { selectionStart, selectionEnd, value } = target;
|
|
67
|
+
if (selectionStart === null || event.shiftKey || selectionStart !== selectionEnd)
|
|
68
|
+
return true;
|
|
69
|
+
if (event.key !== backward && selectionStart < value.length)
|
|
70
|
+
return true;
|
|
71
|
+
if (event.key !== forward && selectionStart > 0)
|
|
72
|
+
return true;
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
const tabId = (store, value) => `${store.baseId}-tab-${value}`;
|
|
76
|
+
const viewportId = (store) => `${store.baseId}-viewport`;
|
|
77
|
+
/**
|
|
78
|
+
* The provider and container. Owns the item order, the selection and the
|
|
79
|
+
* roving focus, so every other part reads rather than holds state.
|
|
80
|
+
* Renders a `<div>` element.
|
|
81
|
+
*/
|
|
82
|
+
export const TabsRoot = ({ defaultValue, value, onValueChange, defaultItems, items, onItemsChange, selectOnClose, store: storeProp, orientation = "horizontal", loop = true, activateOnFocus = false, disabled = false, dismissOnEscape = true, className, render, style, ...elementProps }) => {
|
|
83
|
+
const baseId = useId();
|
|
84
|
+
const [store] = useState(() => {
|
|
85
|
+
const created = storeProp ?? createTabsStore();
|
|
86
|
+
// Before anything has subscribed, so no notify and no write-back.
|
|
87
|
+
created.hydrate({ items: items ?? defaultItems, value: value ?? defaultValue });
|
|
88
|
+
created.baseId = baseId;
|
|
89
|
+
return created;
|
|
90
|
+
});
|
|
91
|
+
// Latest-ref bridges, written during render: idempotent, and current before
|
|
92
|
+
// any event handler can read them.
|
|
93
|
+
store.selectOnCloseRef.current = selectOnClose;
|
|
94
|
+
store.orientationRef.current = orientation;
|
|
95
|
+
store.valueControlledRef.current = value !== undefined;
|
|
96
|
+
store.itemsControlledRef.current = items !== undefined;
|
|
97
|
+
store.onValueChangeRef.current = onValueChange;
|
|
98
|
+
store.onItemsChangeRef.current = onItemsChange;
|
|
99
|
+
// Before paint, so a controlled change never shows the old state first.
|
|
100
|
+
useIsomorphicLayoutEffect(() => {
|
|
101
|
+
if (value !== undefined)
|
|
102
|
+
store.commitValue(value);
|
|
103
|
+
}, [value, store]);
|
|
104
|
+
useIsomorphicLayoutEffect(() => {
|
|
105
|
+
if (items !== undefined)
|
|
106
|
+
store.commitItems(items);
|
|
107
|
+
}, [items, store]);
|
|
108
|
+
useIsomorphicLayoutEffect(() => {
|
|
109
|
+
if (!dismissOnEscape)
|
|
110
|
+
return;
|
|
111
|
+
const handleKeyDown = (event) => {
|
|
112
|
+
const { value } = store.getSnapshot();
|
|
113
|
+
// The window hears Escape last, so one handled inside (a composer stopping) is spent.
|
|
114
|
+
if (event.key !== "Escape" || event.defaultPrevented || value === null)
|
|
115
|
+
return;
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
// From inside the popup, focus would drop to the body as it closes; it goes back to the tab.
|
|
118
|
+
const fromPopup = store.popupRef.current?.contains(document.activeElement) ?? false;
|
|
119
|
+
store.selectWithDetails(null, createChangeEventDetails("escape-key", event));
|
|
120
|
+
if (fromPopup)
|
|
121
|
+
store.elements.get(value)?.focus();
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
124
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
125
|
+
}, [dismissOnEscape, store]);
|
|
126
|
+
const config = useMemo(() => ({ orientation, loop, activateOnFocus, disabled }), [orientation, loop, activateOnFocus, disabled]);
|
|
127
|
+
// Nothing hover started outlives the strip.
|
|
128
|
+
const releaseHoverOnUnmount = useCallback(() => () => store.hover.release(), [store]);
|
|
129
|
+
const element = useRenderElement("div", { className, render, style }, {
|
|
130
|
+
state: usePartState(store, orientation),
|
|
131
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
132
|
+
ref: releaseHoverOnUnmount,
|
|
133
|
+
props: [{ "data-tabs": "" }, elementProps],
|
|
134
|
+
});
|
|
135
|
+
return (_jsx(TabsStoreContext, { value: store, children: _jsx(TabsConfigContext, { value: config, children: element }) }));
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* The strip. A `toolbar` of disclosures rather than a `tablist`, because a
|
|
139
|
+
* tablist may hold only tabs and must always have one selected.
|
|
140
|
+
* Renders a `<div>` element.
|
|
141
|
+
*/
|
|
142
|
+
export const TabsList = ({ children, className, render, style, ...elementProps }) => {
|
|
143
|
+
const store = useTabsContextStore();
|
|
144
|
+
const { orientation, loop, activateOnFocus } = useTabsConfig();
|
|
145
|
+
const items = useTabsStore(store, selectItems);
|
|
146
|
+
const value = useTabsStore(store, selectValue);
|
|
147
|
+
// Which tab the roving tabindex sits on. Follows the open tab until the
|
|
148
|
+
// arrows move it somewhere else, which is what manual activation means.
|
|
149
|
+
const [highlighted, setHighlighted] = useState(null);
|
|
150
|
+
// Validated against `items`, not trusted: closing the focused tab with no
|
|
151
|
+
// `selectOnClose` policy leaves nothing to focus, so no `onFocus` fires to
|
|
152
|
+
// re-seed this. A stale id would leave every trigger at `tabIndex: -1` and
|
|
153
|
+
// the strip unreachable from the keyboard.
|
|
154
|
+
const live = highlighted !== null && items.includes(highlighted) ? highlighted : null;
|
|
155
|
+
const current = live ?? value ?? items[0] ?? null;
|
|
156
|
+
const listContext = useMemo(() => ({ highlighted: current, setHighlighted }), [current]);
|
|
157
|
+
const handleKeyDown = (event) => {
|
|
158
|
+
const forward = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
159
|
+
const backward = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
160
|
+
// A text field inside the strip — renaming a tab in place — owns its own
|
|
161
|
+
// arrow keys until the caret reaches the end it is heading for. Without
|
|
162
|
+
// this the roving focus steals them and the caret never moves.
|
|
163
|
+
if (ownsTheArrowKeys(event, forward, backward))
|
|
164
|
+
return;
|
|
165
|
+
const next = event.key === forward
|
|
166
|
+
? step(items, current, 1, { loop })
|
|
167
|
+
: event.key === backward
|
|
168
|
+
? step(items, current, -1, { loop })
|
|
169
|
+
: event.key === "Home"
|
|
170
|
+
? (items[0] ?? null)
|
|
171
|
+
: event.key === "End"
|
|
172
|
+
? (items[items.length - 1] ?? null)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (next === undefined)
|
|
175
|
+
return;
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
if (next === null)
|
|
178
|
+
return;
|
|
179
|
+
setHighlighted(next);
|
|
180
|
+
const element = store.elements.get(next);
|
|
181
|
+
element?.focus();
|
|
182
|
+
if (!activateOnFocus)
|
|
183
|
+
return;
|
|
184
|
+
store.selectWithDetails(next, createChangeEventDetails("list-navigation", event.nativeEvent, element));
|
|
185
|
+
};
|
|
186
|
+
const element = useRenderElement("div", { className, render, style }, {
|
|
187
|
+
state: usePartState(store, orientation),
|
|
188
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
189
|
+
props: [
|
|
190
|
+
{
|
|
191
|
+
"data-tabs-list": "",
|
|
192
|
+
role: "toolbar",
|
|
193
|
+
"aria-orientation": orientation,
|
|
194
|
+
onKeyDown: handleKeyDown,
|
|
195
|
+
},
|
|
196
|
+
elementProps,
|
|
197
|
+
{
|
|
198
|
+
children: typeof children === "function"
|
|
199
|
+
? items.map((value, index) => (_jsx(Fragment, { children: children(value, index) }, value)))
|
|
200
|
+
: children,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
return _jsx(TabsListContext, { value: listContext, children: element });
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* A tab: the whole pill, and the thing you press.
|
|
208
|
+
*
|
|
209
|
+
* It is a `div` with `role="button"` rather than a `<button>`, so that a close
|
|
210
|
+
* button can live inside it. That nesting is what Linear does and it is what
|
|
211
|
+
* makes the entire pill a click target, but it is not free: `role="button"`
|
|
212
|
+
* has children-presentational in ARIA, so `axe` reports `nested-interactive`
|
|
213
|
+
* and the suite suppresses that one rule with a note. A real `<button>` would
|
|
214
|
+
* be worse — the HTML parser closes an outer button when it meets an inner
|
|
215
|
+
* one, so server-rendered markup would come back reshaped and hydration would
|
|
216
|
+
* mismatch.
|
|
217
|
+
*
|
|
218
|
+
* The price of not being a native button is that activation is ours to
|
|
219
|
+
* implement: Enter and Space, by hand.
|
|
220
|
+
*
|
|
221
|
+
* One part, two situations, decided by where it sits rather than by a prop:
|
|
222
|
+
*
|
|
223
|
+
* - **Inside a `Tabs.List`** it is one of the collection, joins the roving
|
|
224
|
+
* focus, and selects. `Delete` closes it.
|
|
225
|
+
* - **Outside one** it takes an explicit value, keeps its own tab stop, and
|
|
226
|
+
* toggles — a panel with no tab behind it, opened anchored to the button
|
|
227
|
+
* that owns it and promoted to a real tab only when you call `open()`.
|
|
228
|
+
*
|
|
229
|
+
* With `openOnHover`, leaving closes the tab again unless the mouse heads into
|
|
230
|
+
* the popup. A press on the tab, or a press or focus inside the popup, keeps it.
|
|
231
|
+
*/
|
|
232
|
+
export const TabsTrigger = ({ value: explicitValue, disabled: ownDisabled, openOnHover = false, openDelay = 50, closeDelay = 50, className, render, style, ...elementProps }) => {
|
|
233
|
+
const store = useTabsContextStore();
|
|
234
|
+
const { orientation, disabled: rootDisabled } = useTabsConfig();
|
|
235
|
+
const list = use(TabsListContext);
|
|
236
|
+
const inList = list !== null;
|
|
237
|
+
const value = useItemValue(explicitValue);
|
|
238
|
+
const disabled = rootDisabled || (ownDisabled ?? false);
|
|
239
|
+
const selected = useTabsStore(store, selectValue) === value;
|
|
240
|
+
const hasViewport = useTabsStore(store, selectHasViewport);
|
|
241
|
+
const context = useMemo(() => ({ value, disabled }), [value, disabled]);
|
|
242
|
+
const hovers = openOnHover && !disabled;
|
|
243
|
+
const ref = useCallback((element) => {
|
|
244
|
+
// The same element is both the focus target and what a floating surface
|
|
245
|
+
// lines up with, now that the pill and the button are one thing.
|
|
246
|
+
store.registerElement(value, element);
|
|
247
|
+
store.registerAnchor(value, element);
|
|
248
|
+
const unregisterHover = element && hovers ? store.hover.register(element) : undefined;
|
|
249
|
+
return () => {
|
|
250
|
+
store.registerElement(value, null);
|
|
251
|
+
store.registerAnchor(value, null);
|
|
252
|
+
unregisterHover?.();
|
|
253
|
+
};
|
|
254
|
+
}, [store, value, hovers]);
|
|
255
|
+
useIsomorphicLayoutEffect(() => {
|
|
256
|
+
store.registerDisabled(value, disabled);
|
|
257
|
+
return () => store.registerDisabled(value, false);
|
|
258
|
+
}, [store, value, disabled]);
|
|
259
|
+
const activate = (event) => {
|
|
260
|
+
if (disabled)
|
|
261
|
+
return;
|
|
262
|
+
// A press on a tab the mouse opened keeps it open, rather than toggling it shut.
|
|
263
|
+
const hoverOpened = store.hover.owns(value);
|
|
264
|
+
store.hover.claim();
|
|
265
|
+
store.selectWithDetails(!inList && selected && !hoverOpened ? null : value, createChangeEventDetails("trigger-press", event.nativeEvent, event.currentTarget));
|
|
266
|
+
};
|
|
267
|
+
const handleKeyDown = (event) => {
|
|
268
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
269
|
+
// A div does not do this for us.
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
activate(event);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Delete closes a tab — the keyboard equivalent of the × beside it.
|
|
275
|
+
if (!inList || disabled)
|
|
276
|
+
return;
|
|
277
|
+
if (event.key !== "Delete" && event.key !== "Backspace")
|
|
278
|
+
return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
closeAndRefocus(store, value, createChangeEventDetails("keyboard", event.nativeEvent, event.currentTarget));
|
|
281
|
+
};
|
|
282
|
+
const element = useRenderElement("div", { className, render, style }, {
|
|
283
|
+
state: { ...usePartState(store, orientation, disabled), selected },
|
|
284
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
285
|
+
ref,
|
|
286
|
+
props: [
|
|
287
|
+
{
|
|
288
|
+
"data-tabs-trigger": "",
|
|
289
|
+
role: "button",
|
|
290
|
+
id: tabId(store, value),
|
|
291
|
+
"aria-expanded": selected,
|
|
292
|
+
// Only claim a viewport that is really in the document.
|
|
293
|
+
"aria-controls": hasViewport ? viewportId(store) : undefined,
|
|
294
|
+
"aria-disabled": disabled || undefined,
|
|
295
|
+
tabIndex: inList ? (list.highlighted === value ? 0 : -1) : 0,
|
|
296
|
+
onClick: activate,
|
|
297
|
+
onFocus: inList ? () => list.setHighlighted(value) : undefined,
|
|
298
|
+
onKeyDown: handleKeyDown,
|
|
299
|
+
...(hovers && {
|
|
300
|
+
onPointerEnter: (event) => store.hover.enterTab({ value, openDelay, closeDelay }, event),
|
|
301
|
+
onPointerMove: store.hover.moveOnTab,
|
|
302
|
+
onPointerLeave: store.hover.leaveTab,
|
|
303
|
+
}),
|
|
304
|
+
},
|
|
305
|
+
elementProps,
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
return _jsx(TabsItemContext, { value: context, children: element });
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* Close, then put focus somewhere deliberate.
|
|
312
|
+
*
|
|
313
|
+
* The store commits synchronously, so the tab that takes over is already known
|
|
314
|
+
* by the time this reads it back — and focus moves there before React unmounts
|
|
315
|
+
* the tab that was closed. Left alone, focus would fall to the body.
|
|
316
|
+
*/
|
|
317
|
+
const closeAndRefocus = (store, value, eventDetails) => {
|
|
318
|
+
store.closeWithDetails(value, eventDetails);
|
|
319
|
+
if (eventDetails.isCanceled)
|
|
320
|
+
return;
|
|
321
|
+
const successor = store.getSnapshot().value;
|
|
322
|
+
if (successor)
|
|
323
|
+
store.elements.get(successor)?.focus();
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* A decorative mark inside a trigger.
|
|
327
|
+
*
|
|
328
|
+
* `aria-hidden`, deliberately: the trigger already has an accessible name, and
|
|
329
|
+
* an icon that repeats it only makes the announcement longer. It carries the
|
|
330
|
+
* part state too, so the mark can respond to its tab being open without the
|
|
331
|
+
* consumer reaching for a group selector.
|
|
332
|
+
*/
|
|
333
|
+
export const TabsIcon = ({ value: explicitValue, className, render, style, ...elementProps }) => {
|
|
334
|
+
const store = useTabsContextStore();
|
|
335
|
+
const { orientation } = useTabsConfig();
|
|
336
|
+
const value = useOptionalItemValue(explicitValue);
|
|
337
|
+
const disabled = useDisabled();
|
|
338
|
+
const selected = useTabsStore(store, selectValue) === value && value !== null;
|
|
339
|
+
return useRenderElement("span", { className, render, style }, {
|
|
340
|
+
state: { ...usePartState(store, orientation, disabled), selected },
|
|
341
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
342
|
+
props: [{ "data-tabs-icon": "", "aria-hidden": true }, elementProps],
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* The trailing slot inside a tab — where the close button lives.
|
|
347
|
+
*
|
|
348
|
+
* Its own part rather than a div of yours for one reason: a tab narrow enough
|
|
349
|
+
* to truncate has nowhere to put a control. Give this `position: absolute` and
|
|
350
|
+
* a background that fades to the tab's own colour, and the label runs the full
|
|
351
|
+
* width and disappears under it instead of colliding with it. A flex box with
|
|
352
|
+
* padding then centres whatever is inside.
|
|
353
|
+
*
|
|
354
|
+
* All of that is your CSS. What the part supplies is the element and the
|
|
355
|
+
* trigger's state on it — `data-selected` above all, since the fade has to end
|
|
356
|
+
* in whatever colour the tab currently is.
|
|
357
|
+
*/
|
|
358
|
+
export const TabsAction = ({ value: explicitValue, className, render, style, ...elementProps }) => {
|
|
359
|
+
const store = useTabsContextStore();
|
|
360
|
+
const { orientation } = useTabsConfig();
|
|
361
|
+
const value = useOptionalItemValue(explicitValue);
|
|
362
|
+
const disabled = useDisabled();
|
|
363
|
+
const selected = useTabsStore(store, selectValue) === value && value !== null;
|
|
364
|
+
return useRenderElement("div", { className, render, style }, {
|
|
365
|
+
state: { ...usePartState(store, orientation, disabled), selected },
|
|
366
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
367
|
+
props: [{ "data-tabs-action": "" }, elementProps],
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
/**
|
|
371
|
+
* The × inside a tab.
|
|
372
|
+
*
|
|
373
|
+
* Also a `div` with `role="button"`, for the same reason its parent is. It
|
|
374
|
+
* keeps out of the roving order — arrowing along a strip should walk tabs, not
|
|
375
|
+
* alternate between each tab and its close button — so the keyboard route to
|
|
376
|
+
* closing is `Delete` on the tab itself. Every event it handles stops there:
|
|
377
|
+
* nested inside the trigger, anything that escaped would open the tab on its
|
|
378
|
+
* way out of closing it.
|
|
379
|
+
*/
|
|
380
|
+
export const TabsClose = ({ value: explicitValue, disabled: ownDisabled, className, render, style, ...elementProps }) => {
|
|
381
|
+
const store = useTabsContextStore();
|
|
382
|
+
const { orientation } = useTabsConfig();
|
|
383
|
+
const disabled = useDisabled(ownDisabled);
|
|
384
|
+
const value = useItemValue(explicitValue);
|
|
385
|
+
const selected = useTabsStore(store, selectValue) === value;
|
|
386
|
+
const close = (event) => {
|
|
387
|
+
event.stopPropagation();
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
if (disabled)
|
|
390
|
+
return;
|
|
391
|
+
closeAndRefocus(store, value, createChangeEventDetails("close-press", event.nativeEvent, event.currentTarget));
|
|
392
|
+
};
|
|
393
|
+
return useRenderElement("div", { className, render, style }, {
|
|
394
|
+
state: { ...usePartState(store, orientation, disabled), selected },
|
|
395
|
+
stateAttributesMapping: PART_ATTRIBUTES,
|
|
396
|
+
props: [
|
|
397
|
+
{
|
|
398
|
+
"data-tabs-close": "",
|
|
399
|
+
role: "button",
|
|
400
|
+
"aria-disabled": disabled || undefined,
|
|
401
|
+
tabIndex: -1,
|
|
402
|
+
onClick: close,
|
|
403
|
+
onKeyDown: (event) => {
|
|
404
|
+
if (event.key !== "Enter" && event.key !== " ")
|
|
405
|
+
return;
|
|
406
|
+
close(event);
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
elementProps,
|
|
410
|
+
],
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* The one box that shows a tab's content.
|
|
415
|
+
*
|
|
416
|
+
* There is no per-tab element and nothing mounts or unmounts as you switch —
|
|
417
|
+
* the same box stays put and re-renders its children for whichever value is
|
|
418
|
+
* open. That is what makes a floating surface simply move rather than tear
|
|
419
|
+
* itself down, and it is why nothing here needs overlaying or a mount flag.
|
|
420
|
+
*
|
|
421
|
+
* The corollary, and it is deliberate: nothing off-screen exists. A tab you
|
|
422
|
+
* are not looking at has no component, so anything that must keep running
|
|
423
|
+
* while you are elsewhere — a reply still streaming — belongs in a store
|
|
424
|
+
* rather than in the panel's own state.
|
|
425
|
+
*/
|
|
426
|
+
export const TabsViewport = ({ children, className, render, style, ...elementProps }) => {
|
|
427
|
+
const store = useTabsContextStore();
|
|
428
|
+
const { orientation } = useTabsConfig();
|
|
429
|
+
const value = useTabsStore(store, selectValue);
|
|
430
|
+
// A trigger only claims a viewport that is really in the document.
|
|
431
|
+
useIsomorphicLayoutEffect(() => {
|
|
432
|
+
store.registerViewport(true);
|
|
433
|
+
return () => store.registerViewport(false);
|
|
434
|
+
}, [store]);
|
|
435
|
+
return useRenderElement("div", { className, render, style }, {
|
|
436
|
+
state: { ...usePartState(store, orientation), value },
|
|
437
|
+
stateAttributesMapping: {
|
|
438
|
+
...PART_ATTRIBUTES,
|
|
439
|
+
// The open tab's id is not a styling hook; keep it out of the DOM.
|
|
440
|
+
value: () => null,
|
|
441
|
+
},
|
|
442
|
+
props: [
|
|
443
|
+
{
|
|
444
|
+
"data-tabs-viewport": "",
|
|
445
|
+
id: viewportId(store),
|
|
446
|
+
// A bare div maps to the `generic` role, whose name is prohibited, so
|
|
447
|
+
// `aria-labelledby` on one is discarded. `group` is the lightest role
|
|
448
|
+
// that accepts a name without claiming the tabpanel semantics this
|
|
449
|
+
// widget deliberately does not have.
|
|
450
|
+
role: value === null ? undefined : "group",
|
|
451
|
+
// Named by whichever tab is open, so the box is not anonymous.
|
|
452
|
+
"aria-labelledby": value === null ? undefined : tabId(store, value),
|
|
453
|
+
},
|
|
454
|
+
elementProps,
|
|
455
|
+
{ children: value === null ? null : children?.(value) },
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const prefersReducedMotion: () => boolean;
|
|
2
|
+
export declare const resolveScrollBehavior: (behavior: ScrollBehavior) => ScrollBehavior;
|
|
3
|
+
export declare const scrollContainerTo: (el: HTMLElement, top: number, behavior: ScrollBehavior) => void;
|
|
4
|
+
export type ViewportBox = {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const readViewportBox: (el: HTMLElement | null) => ViewportBox;
|
|
9
|
+
export declare const sameViewportBox: (a: ViewportBox, b: ViewportBox) => boolean;
|
|
10
|
+
export declare const wasPrepended: (mutations: MutationRecord[], previousFirst: Element | null, content: HTMLElement) => boolean;
|
|
11
|
+
export declare const DEFAULT_BOTTOM_OFFSET = 128;
|
|
12
|
+
export declare const DOCK_SELECTOR = "[data-thread-composer]";
|
|
13
|
+
export declare const queryDock: (root: HTMLElement) => HTMLElement | null;
|
|
14
|
+
/**
|
|
15
|
+
* Height (px) to reserve at the bottom for the dock: its top edge down to the
|
|
16
|
+
* root's bottom, plus the content gap. Measured against the root's bottom
|
|
17
|
+
* rather than the slot's own height, so a dock that floats above the bottom
|
|
18
|
+
* edge still reserves the space beneath it. Parts that must NOT reserve space
|
|
19
|
+
* — the command-list / ask-user panel, the scroll button — are positioned out
|
|
20
|
+
* of the slot's flow. Returns null when the slot isn't mounted yet.
|
|
21
|
+
*/
|
|
22
|
+
export declare const measureDockInset: (root: HTMLElement) => number | null;
|
|
23
|
+
export declare const measureTopInset: (root: HTMLElement) => number;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure DOM geometry + measurement for the thread scroll subsystem. No React —
|
|
2
|
+
// unit-testable with stubbed elements/rects.
|
|
3
|
+
// Queried per call, not cached — the OS setting can change mid-session and
|
|
4
|
+
// none of these scrolls are hot paths. Guarded for non-DOM (pure-test) callers.
|
|
5
|
+
export const prefersReducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6
|
+
// "smooth" downgrades to "auto" under prefers-reduced-motion — deliberate
|
|
7
|
+
// movements still happen, they just don't animate.
|
|
8
|
+
export const resolveScrollBehavior = (behavior) => behavior === "smooth" && prefersReducedMotion() ? "auto" : behavior;
|
|
9
|
+
// Single place that performs the scroll, so callers just choose the behavior:
|
|
10
|
+
// 'instant' for jumps that must not animate, 'smooth' for deliberate movements.
|
|
11
|
+
export const scrollContainerTo = (el, top, behavior) => {
|
|
12
|
+
el.scrollTo({ top, behavior: resolveScrollBehavior(behavior) });
|
|
13
|
+
};
|
|
14
|
+
export const readViewportBox = (el) => el ? { width: el.clientWidth, height: el.clientHeight } : { width: 0, height: 0 };
|
|
15
|
+
export const sameViewportBox = (a, b) => a.width === b.width && a.height === b.height;
|
|
16
|
+
// A prepend = rows were added, nothing removed, and the previously-first row
|
|
17
|
+
// is still connected but no longer first (older history loading in above).
|
|
18
|
+
export const wasPrepended = (mutations, previousFirst, content) => previousFirst?.isConnected === true &&
|
|
19
|
+
content.firstElementChild !== previousFirst &&
|
|
20
|
+
mutations.some((m) => m.addedNodes.length > 0) &&
|
|
21
|
+
mutations.every((m) => m.removedNodes.length === 0);
|
|
22
|
+
// Fallback (px) until the composer is measured.
|
|
23
|
+
export const DEFAULT_BOTTOM_OFFSET = 128;
|
|
24
|
+
// Breathing room between the last line of content and the composer dock. The
|
|
25
|
+
// bottom overlay spans it in the styled layer.
|
|
26
|
+
const COMPOSER_GAP = 32;
|
|
27
|
+
// The dock is the Thread.Composer slot — the one element the thread reserves
|
|
28
|
+
// space for. Null until it mounts.
|
|
29
|
+
export const DOCK_SELECTOR = "[data-thread-composer]";
|
|
30
|
+
export const queryDock = (root) => root.querySelector(DOCK_SELECTOR);
|
|
31
|
+
/**
|
|
32
|
+
* Height (px) to reserve at the bottom for the dock: its top edge down to the
|
|
33
|
+
* root's bottom, plus the content gap. Measured against the root's bottom
|
|
34
|
+
* rather than the slot's own height, so a dock that floats above the bottom
|
|
35
|
+
* edge still reserves the space beneath it. Parts that must NOT reserve space
|
|
36
|
+
* — the command-list / ask-user panel, the scroll button — are positioned out
|
|
37
|
+
* of the slot's flow. Returns null when the slot isn't mounted yet.
|
|
38
|
+
*/
|
|
39
|
+
export const measureDockInset = (root) => {
|
|
40
|
+
const dock = queryDock(root);
|
|
41
|
+
if (!dock)
|
|
42
|
+
return null;
|
|
43
|
+
const dockTop = dock.getBoundingClientRect().top;
|
|
44
|
+
return Math.round(root.getBoundingClientRect().bottom - dockTop + COMPOSER_GAP);
|
|
45
|
+
};
|
|
46
|
+
// Top inset reserved by the top overlay, measured straight off the rendered
|
|
47
|
+
// element (px) — no getComputedStyle / rem→px conversion. 0 if no top overlay.
|
|
48
|
+
export const measureTopInset = (root) => root.querySelector('[data-thread-overlay="top"]')?.getBoundingClientRect().height ?? 0;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export * as Thread from "./index.parts.js";
|
|
2
|
+
export type { ThreadVisibilityState } from "./stores.js";
|
|
3
|
+
export type { ThreadAutoScrollMode, ThreadComposerProps, ThreadContentProps, ThreadOverlayProps, ThreadOverlayState, ThreadPlaceholderProps, ThreadRootProps, ThreadScrollToMessageOptions, ThreadViewportProps, } from "./thread.js";
|
|
4
|
+
export { useThread, useThreadVisibility } from "./thread.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThreadComposer as Composer, ThreadContent as Content, ThreadOverlay as Overlay, ThreadPlaceholder as Placeholder, ThreadRoot as Root, ThreadViewport as Viewport, } from "./thread.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Part names for the `Thread` namespace. Deliberately carries no
|
|
2
|
+
// "use client" directive: this module and index.ts must stay server-resolvable
|
|
3
|
+
// so a React Server Component can reach Thread.Root and its siblings
|
|
4
|
+
// through them. The directive lives one level down, on thread.tsx.
|
|
5
|
+
export { ThreadComposer as Composer, ThreadContent as Content, ThreadOverlay as Overlay, ThreadPlaceholder as Placeholder, ThreadRoot as Root, ThreadViewport as Viewport, } from "./thread.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const createEdgeStore: () => {
|
|
2
|
+
getSnapshot: () => boolean;
|
|
3
|
+
setSnapshot: (next: boolean) => void;
|
|
4
|
+
subscribe: (listener: () => void) => () => void;
|
|
5
|
+
};
|
|
6
|
+
export type EdgeStore = ReturnType<typeof createEdgeStore>;
|
|
7
|
+
export type ThreadVisibilityState = {
|
|
8
|
+
/** data-message-id values intersecting the viewport, in document order. */
|
|
9
|
+
visibleMessageIds: string[];
|
|
10
|
+
/** The topmost visible row — the one being read. */
|
|
11
|
+
currentMessageId: string | null;
|
|
12
|
+
};
|
|
13
|
+
export declare const EMPTY_VISIBILITY: ThreadVisibilityState;
|
|
14
|
+
export declare const visibilityStatesEqual: (a: ThreadVisibilityState, b: ThreadVisibilityState) => boolean;
|
|
15
|
+
export declare const createVisibilityStore: () => {
|
|
16
|
+
getSnapshot: () => ThreadVisibilityState;
|
|
17
|
+
hasListeners: () => boolean;
|
|
18
|
+
setSnapshot: (next: ThreadVisibilityState) => void;
|
|
19
|
+
subscribe: (listener: () => void, onFirstSubscribe: () => void, onLastUnsubscribe: () => void) => () => void;
|
|
20
|
+
};
|
|
21
|
+
export type VisibilityStore = ReturnType<typeof createVisibilityStore>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Scroll-edge + visibility stores for the thread. External (hand-rolled
|
|
2
|
+
// useSyncExternalStore stores) so a change re-renders only the components that
|
|
3
|
+
// read it, never the Thread tree — the context value stays referentially stable
|
|
4
|
+
// for the thread's lifetime. Pure; no React, no DOM.
|
|
5
|
+
// One boolean store per edge (top / bottom). Two independent stores rather than
|
|
6
|
+
// a single { atTop, atBottom } snapshot, so a flip at one edge never notifies
|
|
7
|
+
// the other's readers.
|
|
8
|
+
export const createEdgeStore = () => {
|
|
9
|
+
let snapshot = true;
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
return {
|
|
12
|
+
getSnapshot: () => snapshot,
|
|
13
|
+
setSnapshot: (next) => {
|
|
14
|
+
if (snapshot === next)
|
|
15
|
+
return;
|
|
16
|
+
snapshot = next;
|
|
17
|
+
for (const listener of listeners)
|
|
18
|
+
listener();
|
|
19
|
+
},
|
|
20
|
+
subscribe: (listener) => {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => {
|
|
23
|
+
listeners.delete(listener);
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export const EMPTY_VISIBILITY = {
|
|
29
|
+
visibleMessageIds: [],
|
|
30
|
+
currentMessageId: null,
|
|
31
|
+
};
|
|
32
|
+
export const visibilityStatesEqual = (a, b) => a.currentMessageId === b.currentMessageId &&
|
|
33
|
+
a.visibleMessageIds.length === b.visibleMessageIds.length &&
|
|
34
|
+
a.visibleMessageIds.every((id, index) => id === b.visibleMessageIds[index]);
|
|
35
|
+
// Which data-message-id rows intersect the viewport. Lazy + ref-counted: the
|
|
36
|
+
// observers behind it exist only while at least one useThreadVisibility
|
|
37
|
+
// subscriber is mounted, so unsubscribed threads pay nothing.
|
|
38
|
+
export const createVisibilityStore = () => {
|
|
39
|
+
let snapshot = EMPTY_VISIBILITY;
|
|
40
|
+
const listeners = new Set();
|
|
41
|
+
return {
|
|
42
|
+
getSnapshot: () => snapshot,
|
|
43
|
+
hasListeners: () => listeners.size > 0,
|
|
44
|
+
setSnapshot: (next) => {
|
|
45
|
+
if (visibilityStatesEqual(snapshot, next))
|
|
46
|
+
return;
|
|
47
|
+
snapshot = next;
|
|
48
|
+
for (const listener of listeners)
|
|
49
|
+
listener();
|
|
50
|
+
},
|
|
51
|
+
subscribe: (listener, onFirstSubscribe, onLastUnsubscribe) => {
|
|
52
|
+
const wasEmpty = listeners.size === 0;
|
|
53
|
+
listeners.add(listener);
|
|
54
|
+
if (wasEmpty)
|
|
55
|
+
onFirstSubscribe();
|
|
56
|
+
return () => {
|
|
57
|
+
listeners.delete(listener);
|
|
58
|
+
if (listeners.size === 0)
|
|
59
|
+
onLastUnsubscribe();
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|