@bitvea/feedback-toolbar 0.0.1
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 +262 -0
- package/dist/FeedbackPanel.d.ts +117 -0
- package/dist/FeedbackPanel.d.ts.map +1 -0
- package/dist/FeedbackPanel.js +622 -0
- package/dist/FeedbackPanel.js.map +1 -0
- package/dist/FeedbackToolbar.d.ts +54 -0
- package/dist/FeedbackToolbar.d.ts.map +1 -0
- package/dist/FeedbackToolbar.js +1149 -0
- package/dist/FeedbackToolbar.js.map +1 -0
- package/dist/InboxSheet.d.ts +80 -0
- package/dist/InboxSheet.d.ts.map +1 -0
- package/dist/InboxSheet.js +580 -0
- package/dist/InboxSheet.js.map +1 -0
- package/dist/PinLayer.d.ts +59 -0
- package/dist/PinLayer.d.ts.map +1 -0
- package/dist/PinLayer.js +1702 -0
- package/dist/PinLayer.js.map +1 -0
- package/dist/RichText.d.ts +6 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/RichText.js +135 -0
- package/dist/RichText.js.map +1 -0
- package/dist/browser/entry.d.ts +3 -0
- package/dist/browser/entry.d.ts.map +1 -0
- package/dist/browser/entry.js +13 -0
- package/dist/browser/entry.js.map +1 -0
- package/dist/browser/mount.d.ts +37 -0
- package/dist/browser/mount.d.ts.map +1 -0
- package/dist/browser/mount.js +150 -0
- package/dist/browser/mount.js.map +1 -0
- package/dist/browser/toolbar.js +38 -0
- package/dist/browser/toolbar.js.map +7 -0
- package/dist/capture.d.ts +58 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +98 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +196 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/fs.d.ts +16 -0
- package/dist/cli/fs.d.ts.map +1 -0
- package/dist/cli/fs.js +33 -0
- package/dist/cli/fs.js.map +1 -0
- package/dist/cli/init.d.ts +75 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +266 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/register.d.ts +55 -0
- package/dist/cli/register.d.ts.map +1 -0
- package/dist/cli/register.js +84 -0
- package/dist/cli/register.js.map +1 -0
- package/dist/cli/registration.d.ts +37 -0
- package/dist/cli/registration.d.ts.map +1 -0
- package/dist/cli/registration.js +69 -0
- package/dist/cli/registration.js.map +1 -0
- package/dist/cli/strategies.d.ts +98 -0
- package/dist/cli/strategies.d.ts.map +1 -0
- package/dist/cli/strategies.js +198 -0
- package/dist/cli/strategies.js.map +1 -0
- package/dist/client.d.ts +59 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +113 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +92 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/deepLink.d.ts +86 -0
- package/dist/deepLink.d.ts.map +1 -0
- package/dist/deepLink.js +185 -0
- package/dist/deepLink.js.map +1 -0
- package/dist/fontFace.d.ts +70 -0
- package/dist/fontFace.d.ts.map +1 -0
- package/dist/fontFace.js +88 -0
- package/dist/fontFace.js.map +1 -0
- package/dist/gating.d.ts +32 -0
- package/dist/gating.d.ts.map +1 -0
- package/dist/gating.js +49 -0
- package/dist/gating.js.map +1 -0
- package/dist/icons.d.ts +68 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +97 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +59 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +130 -0
- package/dist/markdown.js.map +1 -0
- package/dist/motion.d.ts +178 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +302 -0
- package/dist/motion.js.map +1 -0
- package/dist/preferences.d.ts +71 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +195 -0
- package/dist/preferences.js.map +1 -0
- package/dist/rasterise.d.ts +45 -0
- package/dist/rasterise.d.ts.map +1 -0
- package/dist/rasterise.js +490 -0
- package/dist/rasterise.js.map +1 -0
- package/dist/selector.d.ts +19 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +142 -0
- package/dist/selector.js.map +1 -0
- package/dist/session.d.ts +19 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +36 -0
- package/dist/session.js.map +1 -0
- package/dist/shortcut.d.ts +88 -0
- package/dist/shortcut.d.ts.map +1 -0
- package/dist/shortcut.js +149 -0
- package/dist/shortcut.js.map +1 -0
- package/dist/tokens.d.ts +709 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +749 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vendor/types/account.d.ts +26 -0
- package/dist/vendor/types/account.d.ts.map +1 -0
- package/dist/vendor/types/account.js +23 -0
- package/dist/vendor/types/account.js.map +1 -0
- package/dist/vendor/types/apiKey.d.ts +38 -0
- package/dist/vendor/types/apiKey.d.ts.map +1 -0
- package/dist/vendor/types/apiKey.js +36 -0
- package/dist/vendor/types/apiKey.js.map +1 -0
- package/dist/vendor/types/auth.d.ts +10 -0
- package/dist/vendor/types/auth.d.ts.map +1 -0
- package/dist/vendor/types/auth.js +15 -0
- package/dist/vendor/types/auth.js.map +1 -0
- package/dist/vendor/types/entity.d.ts +74 -0
- package/dist/vendor/types/entity.d.ts.map +1 -0
- package/dist/vendor/types/entity.js +111 -0
- package/dist/vendor/types/entity.js.map +1 -0
- package/dist/vendor/types/feedback.d.ts +285 -0
- package/dist/vendor/types/feedback.d.ts.map +1 -0
- package/dist/vendor/types/feedback.js +286 -0
- package/dist/vendor/types/feedback.js.map +1 -0
- package/dist/vendor/types/index.d.ts +14 -0
- package/dist/vendor/types/index.d.ts.map +1 -0
- package/dist/vendor/types/index.js +16 -0
- package/dist/vendor/types/index.js.map +1 -0
- package/dist/vendor/types/invite.d.ts +42 -0
- package/dist/vendor/types/invite.d.ts.map +1 -0
- package/dist/vendor/types/invite.js +30 -0
- package/dist/vendor/types/invite.js.map +1 -0
- package/dist/vendor/types/mcpToken.d.ts +38 -0
- package/dist/vendor/types/mcpToken.d.ts.map +1 -0
- package/dist/vendor/types/mcpToken.js +39 -0
- package/dist/vendor/types/mcpToken.js.map +1 -0
- package/dist/vendor/types/organization.d.ts +13 -0
- package/dist/vendor/types/organization.d.ts.map +1 -0
- package/dist/vendor/types/organization.js +12 -0
- package/dist/vendor/types/organization.js.map +1 -0
- package/dist/vendor/types/project.d.ts +73 -0
- package/dist/vendor/types/project.d.ts.map +1 -0
- package/dist/vendor/types/project.js +117 -0
- package/dist/vendor/types/project.js.map +1 -0
- package/dist/vendor/types/role.d.ts +12 -0
- package/dist/vendor/types/role.d.ts.map +1 -0
- package/dist/vendor/types/role.js +20 -0
- package/dist/vendor/types/role.js.map +1 -0
- package/dist/vendor/types/roleDefinition.d.ts +61 -0
- package/dist/vendor/types/roleDefinition.d.ts.map +1 -0
- package/dist/vendor/types/roleDefinition.js +45 -0
- package/dist/vendor/types/roleDefinition.js.map +1 -0
- package/dist/vendor/types/team.d.ts +25 -0
- package/dist/vendor/types/team.d.ts.map +1 -0
- package/dist/vendor/types/team.js +17 -0
- package/dist/vendor/types/team.js.map +1 -0
- package/dist/vendor/types/util.d.ts +39 -0
- package/dist/vendor/types/util.d.ts.map +1 -0
- package/dist/vendor/types/util.js +31 -0
- package/dist/vendor/types/util.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The toolbar shell: a floating bar at the bottom centre of the host page, and
|
|
4
|
+
// the popovers its buttons open.
|
|
5
|
+
//
|
|
6
|
+
// The shell owns the gate, which tool is active, and the theme. What matters
|
|
7
|
+
// most here is the gate: when the toolbar is disabled this component returns
|
|
8
|
+
// null before anything else runs, so a production build renders no markup and
|
|
9
|
+
// issues no request. That is a hard requirement rather than hidden UI, and it
|
|
10
|
+
// is why the early return is the very first statement and why nothing in this
|
|
11
|
+
// file fetches on mount.
|
|
12
|
+
//
|
|
13
|
+
// There is no panel any more. A fixed 360px panel sits badly over an arbitrary
|
|
14
|
+
// host app, and the surfaces it held want different shapes: the note composer
|
|
15
|
+
// is small, the account surface is smaller still, and anything list-shaped
|
|
16
|
+
// belongs in the inbox sheet. Each bar button therefore opens a popover
|
|
17
|
+
// anchored to itself.
|
|
18
|
+
//
|
|
19
|
+
// The bar has TWO FORMS, and issue #22 is why: one full pill is permanently in
|
|
20
|
+
// the way of the very page it exists to review - on a narrow viewport, or for
|
|
21
|
+
// a reviewer who is reading rather than annotating - and the only alternative
|
|
22
|
+
// used to be not having the toolbar at all.
|
|
23
|
+
//
|
|
24
|
+
// The collapsed form is ONE CIRCULAR DOT: the product mark, the unread
|
|
25
|
+
// indicator on its edge, and a click anywhere on it to bring the bar back. It
|
|
26
|
+
// was a two-control rectangle - the note action beside a hamburger - and the
|
|
27
|
+
// argument for that was that a horizontal strip is the only orientation a
|
|
28
|
+
// bottom-edge dock can ask for. Issue #21 removed that argument by making the
|
|
29
|
+
// dock movable, and a circle is the one shape that reads the same at all eight
|
|
30
|
+
// of them: it has no orientation to get wrong, and its diameter is the
|
|
31
|
+
// expanded bar's own height, so a collapse reads as the bar's ends closing in
|
|
32
|
+
// rather than as the bar also changing thickness. The cost is stated where it
|
|
33
|
+
// falls, in `setCollapsed`: with no note button in this form, the composer can
|
|
34
|
+
// no longer survive a collapse.
|
|
35
|
+
//
|
|
36
|
+
// AND THE BAR MOVES, which is issue #21. Eight discrete docks, dragged by the
|
|
37
|
+
// bar's own body and chosen from a 3x3 grid in the account surface for anybody
|
|
38
|
+
// not using a mouse, remembered in the same preference record as the collapsed
|
|
39
|
+
// flag. What is NOT here is a stored coordinate: see `ToolbarDock` in
|
|
40
|
+
// tokens.ts for why that makes "never leaves the bar off-screen" and "a resize
|
|
41
|
+
// re-docks it" structural rather than a listener.
|
|
42
|
+
//
|
|
43
|
+
// THE ONE RULE THIS FILE CANNOT BREAK, and the reason the drag is where it is:
|
|
44
|
+
// the toolbar ROOT is the sole ancestor of every `position: fixed` pin marker
|
|
45
|
+
// on the page, because `PinLayer` renders a bare fragment. A `transform`
|
|
46
|
+
// anywhere above those markers makes that box their containing block and moves
|
|
47
|
+
// every pin on the host page. So the drag's transform is on the PILL - a leaf
|
|
48
|
+
// that holds no marker and is the pin layer's nephew, not its ancestor - the
|
|
49
|
+
// dock moves by LAYOUT only, and the root carries neither.
|
|
50
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
51
|
+
import { createPortal } from "react-dom";
|
|
52
|
+
import { resolveConfig, } from "./config.js";
|
|
53
|
+
import { captureDeepLink, clearDeepLinkHash } from "./deepLink.js";
|
|
54
|
+
import { AccountSurface, FeedbackPanel } from "./FeedbackPanel.js";
|
|
55
|
+
// Every glyph in the product, drawn once - see the header of icons.tsx for why
|
|
56
|
+
// the size lives in `style` rather than in a `width` attribute.
|
|
57
|
+
import { AccountIcon, CollapseIcon, InboxIcon, NoteIcon, PinIcon, } from "./icons.js";
|
|
58
|
+
import { INBOX_OPEN_STORE, InboxSheet, SHEET_WIDTH, writeInboxOpen, } from "./InboxSheet.js";
|
|
59
|
+
import { surfaceMotion, useEntered, useJustChanged, usePrefersReducedMotion, useSwapEntered, } from "./motion.js";
|
|
60
|
+
import { PinLayer } from "./PinLayer.js";
|
|
61
|
+
import { formatChord, isApplePlatform } from "./shortcut.js";
|
|
62
|
+
import { PREFERENCES_STORE, writePreferences } from "./preferences.js";
|
|
63
|
+
import { createToolbarClient } from "./client.js";
|
|
64
|
+
import { useToolbarSession } from "./session.js";
|
|
65
|
+
import { bar, DARK_SCHEME_QUERY, DEFAULT_DOCK, dockLayout, DRAG_THRESHOLD, fonts, isThemeMode, isToolbarDock, layers, motion, motionTransform, radii, resetBox, sizes, spacing, themeFor, THEME_STORAGE_KEY, transition, typeScale, Z_INDEX, } from "./tokens.js";
|
|
66
|
+
import { injectFontFaces } from "./fontFace.js";
|
|
67
|
+
/** The default inbox: this package's own sheet. */
|
|
68
|
+
const defaultRenderInbox = (props) => (_jsx(InboxSheet, { ...props }));
|
|
69
|
+
export function FeedbackToolbar({ env, children, renderInbox = defaultRenderInbox, ...overrides }) {
|
|
70
|
+
const config = useMemo(() => resolveConfig(env ?? readProcessEnv(), overrides),
|
|
71
|
+
// The env bag and the overrides are static for the life of a page: they
|
|
72
|
+
// come from the bundle, not from state.
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
[env]);
|
|
75
|
+
// Disabled: render nothing at all, and make no request. The gate is a
|
|
76
|
+
// separate component from the toolbar itself precisely so that the hooks
|
|
77
|
+
// below it - including the one that resolves the session and the one that
|
|
78
|
+
// reads the host's colour scheme - never run on a production build. A
|
|
79
|
+
// conditional hook would be the usual way to get this wrong; a conditional
|
|
80
|
+
// component cannot be.
|
|
81
|
+
if (!config.enabled)
|
|
82
|
+
return null;
|
|
83
|
+
return (_jsx(EnabledToolbar, { config: config, renderInbox: renderInbox, children: children }));
|
|
84
|
+
}
|
|
85
|
+
function EnabledToolbar({ config, children, renderInbox, }) {
|
|
86
|
+
const [tool, setTool] = useState(null);
|
|
87
|
+
const [picking, setPicking] = useState(false);
|
|
88
|
+
// Read from storage, because a reviewer working through a round of feedback
|
|
89
|
+
// navigates between pages and a sheet that closed on every navigation would
|
|
90
|
+
// be useless. Through a store with a server snapshot rather than a
|
|
91
|
+
// `useState` initialiser: the initialiser answered false on the server and
|
|
92
|
+
// true on the client, and the value reaches the DOM as `aria-expanded`,
|
|
93
|
+
// `aria-pressed` and an inline background on the inbox button - so a
|
|
94
|
+
// reviewer who left the sheet open got a React hydration error in the host
|
|
95
|
+
// app's console on every page load. See INBOX_OPEN_STORE.
|
|
96
|
+
const inboxOpen = useSyncExternalStore(INBOX_OPEN_STORE.subscribe, INBOX_OPEN_STORE.getSnapshot, INBOX_OPEN_STORE.getServerSnapshot);
|
|
97
|
+
const [showResolved, setShowResolved] = useState(false);
|
|
98
|
+
const [focusPinId, setFocusPinId] = useState(null);
|
|
99
|
+
const [openCount, setOpenCount] = useState(0);
|
|
100
|
+
const { mode, theme, setMode } = useToolbarTheme();
|
|
101
|
+
// Which form the bar is in, from the reviewer's persisted preferences and
|
|
102
|
+
// through the same store shape the theme and the inbox flag use. Issue #22
|
|
103
|
+
// asks for it to survive a reload, and on a host MPA every link is a fresh
|
|
104
|
+
// page load, so "I collapsed the bar" cannot live in React state.
|
|
105
|
+
//
|
|
106
|
+
// Emphatically not a `useState` initialiser that reads storage: this flag
|
|
107
|
+
// decides which CONTROLS EXIST, so the server would render one set of
|
|
108
|
+
// buttons and the hydrating client another - a harder version of the
|
|
109
|
+
// hydration error `INBOX_OPEN_STORE` was rewritten to fix, and it lands in
|
|
110
|
+
// the CUSTOMER's console. See PREFERENCES_STORE.
|
|
111
|
+
//
|
|
112
|
+
// ONE subscription for both fields, not two. They come out of one record
|
|
113
|
+
// (see preferences.ts) and they have to come out of one SNAPSHOT: a second
|
|
114
|
+
// `useSyncExternalStore` on the same store would be a second server snapshot
|
|
115
|
+
// to keep in step, and the form of the bar and the edge it is docked to are
|
|
116
|
+
// exactly the pair issue #22 says must not be able to disagree.
|
|
117
|
+
const { collapsed, dock } = useSyncExternalStore(PREFERENCES_STORE.subscribe, PREFERENCES_STORE.getSnapshot, PREFERENCES_STORE.getServerSnapshot);
|
|
118
|
+
// Every per-edge answer in this component - the dock's flex triple, the
|
|
119
|
+
// popover's origin and gap, the unread dot's corner, the chevron's angle -
|
|
120
|
+
// derived once from that one value. See `dockLayout`.
|
|
121
|
+
const edge = dockLayout(dock);
|
|
122
|
+
// The drag, on the pill and nowhere above it. See `useBarDrag`.
|
|
123
|
+
const drag = useBarDrag();
|
|
124
|
+
// The URL fragment, read exactly once. It has to be read in the shell's
|
|
125
|
+
// first render pass: a host framework that owns the hash rewrites it during
|
|
126
|
+
// hydration, and the pin layer renders after this line.
|
|
127
|
+
//
|
|
128
|
+
// Read through a store for the same reason the theme is. `captureDeepLink`
|
|
129
|
+
// is a memoised one-shot, so `getSnapshot` is stable and the subscription
|
|
130
|
+
// has nothing to publish - what the store buys is the SERVER snapshot: null
|
|
131
|
+
// during hydration, adopted afterwards, instead of a value that differs
|
|
132
|
+
// between the two renders. The read itself still happens in this first
|
|
133
|
+
// render pass, because React calls `getSnapshot` on the client even while it
|
|
134
|
+
// is rendering the server's value.
|
|
135
|
+
const deepLinkPinId = useSyncExternalStore(subscribeDeepLink, captureDeepLink, serverDeepLink);
|
|
136
|
+
// The address bar, tidied. Twice, and from an effect both times.
|
|
137
|
+
//
|
|
138
|
+
// From an effect because a `history.replaceState` is a side effect on the
|
|
139
|
+
// host's document, and a render pass is not where those belong: React is
|
|
140
|
+
// entitled to run one twice and to throw one away. Twice because the first
|
|
141
|
+
// clear is not the last word - Next's App Router restores the address it
|
|
142
|
+
// considered canonical at load, our fragment included, from a commit-phase
|
|
143
|
+
// effect. A parent's effects run after its children's, and the router is an
|
|
144
|
+
// ancestor of every host app, so there is no effect this component can
|
|
145
|
+
// register that is guaranteed to run later. A timeout is: it is scheduled
|
|
146
|
+
// from inside the commit and runs after all of it. `clearDeepLinkHash`
|
|
147
|
+
// re-checks the prefix, so neither call can eat a fragment that is not ours.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (deepLinkPinId === null)
|
|
150
|
+
return;
|
|
151
|
+
clearDeepLinkHash();
|
|
152
|
+
const timer = window.setTimeout(clearDeepLinkHash, 0);
|
|
153
|
+
return () => window.clearTimeout(timer);
|
|
154
|
+
}, [deepLinkPinId]);
|
|
155
|
+
// DESIGN.md's two faces, put into the REVIEWED page. Naming them in a font
|
|
156
|
+
// stack is not enough on a site that has never heard of Geist - see the
|
|
157
|
+
// note at the head of `fontFace.ts` for the two separate ways that missed.
|
|
158
|
+
//
|
|
159
|
+
// Deliberately NOT cleaned up on unmount. The `<style>` defines two
|
|
160
|
+
// namespaced families and no selectors, so it cannot affect anything the
|
|
161
|
+
// host renders; removing it would instead evict the font from the document
|
|
162
|
+
// between an unmount and the next mount and flash the fallback back in.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
injectFontFaces(document, config.assetOrigin);
|
|
165
|
+
}, [config.assetOrigin]);
|
|
166
|
+
const client = useMemo(() => createToolbarClient(config.apiUrl), [config.apiUrl]);
|
|
167
|
+
// Resolved here rather than in a popover: the pin layer needs the answer
|
|
168
|
+
// before anybody opens anything, or existing pins are invisible until a
|
|
169
|
+
// reviewer opens the toolbar - by which point they may already be writing a
|
|
170
|
+
// duplicate of the report the pin was.
|
|
171
|
+
const session = useToolbarSession(client, config.projectUrl);
|
|
172
|
+
const startPicking = useCallback(() => {
|
|
173
|
+
setPicking(true);
|
|
174
|
+
// Out of the way: the reviewer is about to click the page underneath.
|
|
175
|
+
setTool(null);
|
|
176
|
+
}, []);
|
|
177
|
+
const toggleTool = useCallback((next) => {
|
|
178
|
+
setTool((current) => (current === next ? null : next));
|
|
179
|
+
setPicking(false);
|
|
180
|
+
}, []);
|
|
181
|
+
const focusPin = useCallback((id) => {
|
|
182
|
+
setFocusPinId(id);
|
|
183
|
+
// The sheet stays open - the reviewer is working down a list - but a
|
|
184
|
+
// popover over the bar would sit on top of the pin they just asked to see.
|
|
185
|
+
setTool(null);
|
|
186
|
+
}, []);
|
|
187
|
+
// Written to storage, which notifies the store, which re-renders this. There
|
|
188
|
+
// is no component copy of the flag to keep in step with it.
|
|
189
|
+
const setInbox = useCallback((open) => {
|
|
190
|
+
writeInboxOpen(open);
|
|
191
|
+
}, []);
|
|
192
|
+
// Same shape, and the same reason there is no component copy of the flag.
|
|
193
|
+
const setCollapsed = useCallback((next) => {
|
|
194
|
+
writePreferences({ collapsed: next });
|
|
195
|
+
// Collapsing only. Expanding takes no control away, and closing the
|
|
196
|
+
// composer on the way back to the full bar would throw away a note the
|
|
197
|
+
// reviewer was in the middle of writing.
|
|
198
|
+
if (!next)
|
|
199
|
+
return;
|
|
200
|
+
// EVERY popover, the composer included, and that carve-out inverted with
|
|
201
|
+
// the collapsed form. `layers` in tokens.ts writes the rule down: every
|
|
202
|
+
// surface here is something the bar opened and the bar has to be able to
|
|
203
|
+
// close. The note popover used to survive a collapse because its button
|
|
204
|
+
// did; the single dot issue #22 asks for has no note button, so a surviving
|
|
205
|
+
// composer would be a `role="dialog"` hanging over a 46px circle with
|
|
206
|
+
// nothing left in the DOM to close it. Losing a half-written note is the
|
|
207
|
+
// stated cost of the one-control dot, and it is the smaller failure.
|
|
208
|
+
setTool(null);
|
|
209
|
+
// Picking is worse than an orphaned popover: the crosshair wash stays over
|
|
210
|
+
// the host page with the toggle that cancels it gone from the DOM, so the
|
|
211
|
+
// only way out is a key nobody told the reviewer about.
|
|
212
|
+
setPicking(false);
|
|
213
|
+
}, []);
|
|
214
|
+
// Escape closes whichever popover is open. The container declares
|
|
215
|
+
// `role="dialog"`, and Escape is that role's documented dismissal - there
|
|
216
|
+
// was no key handling in this package at all.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (tool === null)
|
|
219
|
+
return;
|
|
220
|
+
const onKeyDown = (event) => {
|
|
221
|
+
if (event.key !== "Escape")
|
|
222
|
+
return;
|
|
223
|
+
setTool(null);
|
|
224
|
+
// Only when something was open, so a host app's own Escape handling is
|
|
225
|
+
// untouched the rest of the time.
|
|
226
|
+
event.stopPropagation();
|
|
227
|
+
};
|
|
228
|
+
window.addEventListener("keydown", onKeyDown);
|
|
229
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
230
|
+
}, [tool]);
|
|
231
|
+
// And a click on the host page closes it too. A 320px composer parked over
|
|
232
|
+
// the very element under review, dismissable only by finding the bar button
|
|
233
|
+
// again, is the review tool getting in the way of the review. Clicks inside
|
|
234
|
+
// the toolbar's own root are not "outside": that includes the bar, whose
|
|
235
|
+
// buttons do their own toggling.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (tool === null)
|
|
238
|
+
return;
|
|
239
|
+
const onPointerDown = (event) => {
|
|
240
|
+
const target = event.target;
|
|
241
|
+
if (target instanceof Element &&
|
|
242
|
+
target.closest("[data-bitvea-feedback-toolbar]") !== null) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
setTool(null);
|
|
246
|
+
};
|
|
247
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
248
|
+
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
|
249
|
+
}, [tool]);
|
|
250
|
+
// Whether this render may portal into `document.body`. False on the server
|
|
251
|
+
// and false for the first client render, then true - the same
|
|
252
|
+
// `useSyncExternalStore` shape the theme and the deep link above use, and
|
|
253
|
+
// for the same reason: a value that differs between the server render and
|
|
254
|
+
// the hydrating one is a React error in the CUSTOMER's console. There is
|
|
255
|
+
// nothing to subscribe to; what this buys is the server snapshot.
|
|
256
|
+
const canPortal = useSyncExternalStore(subscribeNothing, browserCanPortal, serverCanPortal);
|
|
257
|
+
// The chord as this reviewer's platform writes it, or null where the host
|
|
258
|
+
// switched it off. Computed here rather than in the button because it is a
|
|
259
|
+
// read of `navigator`, and every such read in this file goes through a
|
|
260
|
+
// deterministic first render - a hint that differed between the server's
|
|
261
|
+
// markup and the client's is a hydration error in somebody else's console.
|
|
262
|
+
const pinShortcutHint = useSyncExternalStore(subscribeNothing, () => config.pinShortcut === null
|
|
263
|
+
? null
|
|
264
|
+
: formatChord(config.pinShortcut, isApplePlatform(navigator)), () => null);
|
|
265
|
+
const branchLabel = config.gitBranch ?? config.environment;
|
|
266
|
+
const commitLabel = config.gitCommit?.slice(0, 7) ?? null;
|
|
267
|
+
const ready = session.readyProjectId;
|
|
268
|
+
// Whether the sheet is actually on screen, which is what the dock has to
|
|
269
|
+
// make room for. Not the same as `inboxOpen`: the sheet only mounts once a
|
|
270
|
+
// project has resolved, and a host may have replaced or removed it.
|
|
271
|
+
const sheetOpen = renderInbox != null && inboxOpen && ready !== null;
|
|
272
|
+
// The unread signal, read by both forms of the bar from one place. It is the
|
|
273
|
+
// INBOX's signal: a host that suppressed the inbox with `renderInbox={null}`
|
|
274
|
+
// gets no count on the expanded bar, so the collapsed form must not invent
|
|
275
|
+
// one for the control that would have opened it.
|
|
276
|
+
const unread = renderInbox ? openCount : 0;
|
|
277
|
+
// The note action. It used to be the one control BOTH forms carried, and
|
|
278
|
+
// declaring it once was how "the note action survives a collapse" stayed the
|
|
279
|
+
// same element rather than two spellings of it. The collapsed form is a
|
|
280
|
+
// single dot now, so it is the expanded bar's alone - but it keeps its own
|
|
281
|
+
// binding, because its test id is what four e2e specs seed feedback through
|
|
282
|
+
// and a control worth naming once is worth naming where a reader finds it.
|
|
283
|
+
const noteButton = (_jsx(BarButton, { theme: theme,
|
|
284
|
+
// Named for the control it replaces: every e2e spec that seeds feedback
|
|
285
|
+
// opens the composer through this id, and renaming it would break four
|
|
286
|
+
// of them for no gain.
|
|
287
|
+
testId: "feedback-toggle", label: tool === "note" ? "Close feedback" : "Leave a note", expanded: tool === "note", active: tool === "note", onClick: () => toggleTool("note"), children: _jsx(NoteIcon, {}) }));
|
|
288
|
+
const tree = (_jsxs("div", { "data-bitvea-feedback-toolbar": "",
|
|
289
|
+
// The root is a zero-sized, UNTRANSFORMED anchor, and both facts are
|
|
290
|
+
// load-bearing. A transform on it would make it the containing block for
|
|
291
|
+
// every `position: fixed` descendant, which is what the pin markers are:
|
|
292
|
+
// they would then be offset by the bar's own position instead of sitting
|
|
293
|
+
// on the elements they are anchored to. Zero-sized so it can never
|
|
294
|
+
// swallow a click meant for the host page.
|
|
295
|
+
style: {
|
|
296
|
+
// A host `div { padding: 8px }` would otherwise inflate a box this
|
|
297
|
+
// file went to some trouble to make zero-sized.
|
|
298
|
+
...resetBox,
|
|
299
|
+
position: "fixed",
|
|
300
|
+
left: 0,
|
|
301
|
+
top: 0,
|
|
302
|
+
width: 0,
|
|
303
|
+
height: 0,
|
|
304
|
+
zIndex: Z_INDEX,
|
|
305
|
+
fontFamily: fonts.sans,
|
|
306
|
+
// Every inherited text property the host could have set on `html`,
|
|
307
|
+
// `body` or `*`, stated once at the top of the toolbar's own tree.
|
|
308
|
+
// `fontFamily` alone was never enough: a host that letter-spaces its
|
|
309
|
+
// body copy, uppercases with `text-transform`, italicises, right-
|
|
310
|
+
// aligns for an RTL locale or sets `line-height: 2` was re-typesetting
|
|
311
|
+
// every surface below this line, and none of it is visible in the
|
|
312
|
+
// preview harness because apps/web sets none of it.
|
|
313
|
+
letterSpacing: "normal",
|
|
314
|
+
wordSpacing: "normal",
|
|
315
|
+
textTransform: "none",
|
|
316
|
+
fontStyle: "normal",
|
|
317
|
+
fontVariant: "normal",
|
|
318
|
+
textAlign: "left",
|
|
319
|
+
textIndent: 0,
|
|
320
|
+
lineHeight: 1.5,
|
|
321
|
+
textDecoration: "none",
|
|
322
|
+
whiteSpace: "normal",
|
|
323
|
+
// Follows the reviewer's resolved theme rather than pinning one, so
|
|
324
|
+
// the browser's own form-control chrome inside the popovers matches
|
|
325
|
+
// the surface they sit on.
|
|
326
|
+
colorScheme: theme.resolved,
|
|
327
|
+
}, children: [_jsxs("div", { "data-testid": "feedback-dock", style: {
|
|
328
|
+
position: "fixed",
|
|
329
|
+
// Four longhands rather than the `inset` shorthand: jsdom does not
|
|
330
|
+
// expand `inset` into `style.left`/`style.right`, and two tests read
|
|
331
|
+
// those to prove the dock is a box rather than a centred transform.
|
|
332
|
+
top: 0,
|
|
333
|
+
right: 0,
|
|
334
|
+
bottom: 0,
|
|
335
|
+
left: 0,
|
|
336
|
+
display: "flex",
|
|
337
|
+
flexDirection: edge.flexDirection,
|
|
338
|
+
// Always the bar's own end of the column, so a popover grows away
|
|
339
|
+
// from the pill instead of pushing it. See the note above.
|
|
340
|
+
justifyContent: "flex-end",
|
|
341
|
+
alignItems: edge.alignItems,
|
|
342
|
+
boxSizing: "border-box",
|
|
343
|
+
// Issue #21's "standard viewport inset", now on all four sides
|
|
344
|
+
// because all four are dockable. See `bar.inset`.
|
|
345
|
+
padding: bar.inset,
|
|
346
|
+
// ...except the bottom, which is also how a mid-edge dock centres
|
|
347
|
+
// the bar. See `dockLayout`.
|
|
348
|
+
paddingBottom: edge.paddingBottom,
|
|
349
|
+
// The sheet is `right: 0`, so the bar centres in what is left of the
|
|
350
|
+
// viewport. `clamp` rather than a flat 340px because on a narrow
|
|
351
|
+
// screen the sheet is itself clamped, and subtracting its full width
|
|
352
|
+
// there would squeeze the bar to nothing: below ~420px the offset
|
|
353
|
+
// reaches zero and the bar centres on the whole viewport again,
|
|
354
|
+
// under the sheet's own edge.
|
|
355
|
+
//
|
|
356
|
+
// Unconditional across the eight docks, which is the whole reason it
|
|
357
|
+
// is a padding and not a centring offset: for a left dock it changes
|
|
358
|
+
// nothing, for a centred one it re-centres in what is left, and for a
|
|
359
|
+
// RIGHT dock - including the mid-edge, where a 46px dot would
|
|
360
|
+
// otherwise sit squarely under a full-height sheet - it holds the bar
|
|
361
|
+
// clear of the sheet's edge. That is issue #21's "a bar docked right
|
|
362
|
+
// should not sit under the sheet", answered once.
|
|
363
|
+
paddingRight: sheetOpen
|
|
364
|
+
? `clamp(${spacing.xs}, calc(100vw - 420px), ${SHEET_WIDTH}px)`
|
|
365
|
+
: bar.inset,
|
|
366
|
+
// The box must never swallow a click meant for the host page. Each
|
|
367
|
+
// thing inside it turns pointer events back on for itself.
|
|
368
|
+
pointerEvents: "none",
|
|
369
|
+
// Above every surface the toolbar opens. The dock used to carry no
|
|
370
|
+
// z-index at all, which in this stacking context means it lost to
|
|
371
|
+
// the picking overlay, the pin markers, the threads and the inbox
|
|
372
|
+
// sheet alike - and losing to the picking overlay meant the pin tool
|
|
373
|
+
// could not be turned off. See `layers`.
|
|
374
|
+
zIndex: layers.bar,
|
|
375
|
+
}, children: [children === undefined ? null : (_jsx("div", { style: { pointerEvents: "auto" }, children: children })), tool !== null ? (_jsx(BarPopover, { theme: theme, tool: tool, edge: edge, children: tool === "note" ? (_jsx(FeedbackPanel, { config: config, phase: session.phase, theme: theme, onReloadSession: session.reload, onDone: () => setTool(null) })) : (_jsx(AccountSurface, { config: config, phase: session.phase, theme: theme, mode: mode, onModeChange: setMode, onReloadSession: session.reload })) })) : null, _jsx("div", { "aria-label": "BitVea Feedback toolbar", "data-testid": "feedback-bar", "data-toolbar-state": collapsed ? "collapsed" : "expanded", ...drag.handlers, style: {
|
|
376
|
+
display: "flex",
|
|
377
|
+
alignItems: "center",
|
|
378
|
+
padding: bar.padding,
|
|
379
|
+
// Clamped to the dock, which is the viewport less its inset.
|
|
380
|
+
// Without this the 402px pill overhung both edges of a 390px
|
|
381
|
+
// phone and both 32px rounded ends were cut off.
|
|
382
|
+
maxWidth: "100%",
|
|
383
|
+
boxSizing: "border-box",
|
|
384
|
+
pointerEvents: "auto",
|
|
385
|
+
background: theme.colors.barSurface,
|
|
386
|
+
border: `1px solid ${theme.colors.barBorder}`,
|
|
387
|
+
// A CIRCLE when collapsed, and it needs no width or height of its
|
|
388
|
+
// own to be one: 4px of `bar.padding` around a single 36px button
|
|
389
|
+
// is a 44px content box, so a pill radius on it is a circle - the
|
|
390
|
+
// same 46px across as the expanded bar is tall. That is why the
|
|
391
|
+
// collapse reads as the bar's ends closing in rather than as the
|
|
392
|
+
// bar also getting thinner, and it clears DESIGN.md's 44x44 touch
|
|
393
|
+
// floor (Responsive -> Touch Targets), which a bare 36px dot does
|
|
394
|
+
// not.
|
|
395
|
+
borderRadius: collapsed ? radii.pill : bar.radius,
|
|
396
|
+
boxShadow: theme.shadows.bar,
|
|
397
|
+
// The drag affordance, issue #21. A grab cursor on the bar's body
|
|
398
|
+
// is the whole discovery mechanism - there is no grip handle,
|
|
399
|
+
// because a 36px square in a bar already fighting for width on a
|
|
400
|
+
// phone is a worse trade than a cursor. The buttons inside declare
|
|
401
|
+
// `pointer` and win over their own boxes.
|
|
402
|
+
cursor: "grab",
|
|
403
|
+
// Stated permanently rather than while dragging, because a browser
|
|
404
|
+
// decides whether a touch gesture scrolls the HOST page at the
|
|
405
|
+
// moment the finger lands: by the time a `touchAction` applied
|
|
406
|
+
// mid-drag reached the compositor the page would already be
|
|
407
|
+
// moving. The bar is a draggable object, so a touch that starts on
|
|
408
|
+
// it belongs to the bar.
|
|
409
|
+
touchAction: "none",
|
|
410
|
+
...drag.style,
|
|
411
|
+
}, children: _jsx(BarContents, { form: collapsed, children: collapsed ? (_jsxs(BarButton, { theme: theme, testId: "toolbar-expand",
|
|
412
|
+
// The count is in the name as well as on the edge, because the
|
|
413
|
+
// indicator is now a bare dot and this control is the only
|
|
414
|
+
// thing a screen-reader user has left to hear the number from.
|
|
415
|
+
label: unread > 0
|
|
416
|
+
? `Expand toolbar, ${unread} open`
|
|
417
|
+
: "Expand toolbar", expanded: false, onClick: () => setCollapsed(false), children: [_jsx(BitveaMark, { theme: theme, size: 18, inline: true }), unread > 0 ? _jsx(UnreadDot, { theme: theme, at: edge.badge }) : null] })) : (_jsxs(_Fragment, { children: [_jsx(BitveaMark, { theme: theme }), _jsx(BarButton, { theme: theme, testId: "toolbar-pin",
|
|
418
|
+
// The label is what the e2e suite finds this control by, and
|
|
419
|
+
// it survived the panel it used to live in on purpose. The
|
|
420
|
+
// shortcut goes in `hint` and not here for exactly that
|
|
421
|
+
// reason - see the prop's note on BarButton.
|
|
422
|
+
label: "Pin to an element",
|
|
423
|
+
// Discoverability at the one moment it lands: hovering the
|
|
424
|
+
// button you were about to click. Derived from the config'd
|
|
425
|
+
// chord rather than written out, so a host that remaps it
|
|
426
|
+
// does not leave this teaching a keystroke that does
|
|
427
|
+
// nothing, and a host that switches it off shows no hint.
|
|
428
|
+
hint: pinShortcutHint ?? undefined, active: picking, onClick: () => (picking ? setPicking(false) : startPicking()), children: _jsx(PinIcon, {}) }), noteButton, renderInbox ? (_jsx(BarButton, { theme: theme, testId: "toolbar-inbox", label: unread > 0 ? `Inbox, ${unread} open` : "Inbox", expanded: inboxOpen, active: inboxOpen, onClick: () => setInbox(!inboxOpen), badge: unread, children: _jsx(InboxIcon, {}) })) : null, _jsx(BarButton, { theme: theme, testId: "toolbar-account", label: "Account", expanded: tool === "account", active: tool === "account", onClick: () => toggleTool("account"), children: _jsx(AccountIcon, {}) }), _jsxs("span", { style: {
|
|
429
|
+
display: "inline-flex",
|
|
430
|
+
alignItems: "center",
|
|
431
|
+
gap: spacing.xs,
|
|
432
|
+
height: bar.button,
|
|
433
|
+
padding: `0 ${spacing.sm}`,
|
|
434
|
+
maxWidth: "220px",
|
|
435
|
+
// The one thing in the bar allowed to give up width, and
|
|
436
|
+
// the reason the pill can be clamped at all: the buttons
|
|
437
|
+
// are fixed 36px squares, so a narrow viewport has to come
|
|
438
|
+
// out of here. It has one more square to make room for
|
|
439
|
+
// since issue #22, which is the same viewport the collapse
|
|
440
|
+
// control exists to rescue.
|
|
441
|
+
flex: "0 1 auto",
|
|
442
|
+
minWidth: 0,
|
|
443
|
+
boxSizing: "border-box",
|
|
444
|
+
borderRadius: radii.pill,
|
|
445
|
+
background: theme.colors.barHover,
|
|
446
|
+
...typeScale.captionMono,
|
|
447
|
+
}, children: [_jsx("span", { "data-testid": "toolbar-branch", title: branchLabel, style: {
|
|
448
|
+
minWidth: 0,
|
|
449
|
+
overflow: "hidden",
|
|
450
|
+
whiteSpace: "nowrap",
|
|
451
|
+
textOverflow: "ellipsis",
|
|
452
|
+
// `body`, not `barInkMute`: mute on the hover wash is
|
|
453
|
+
// 3.2:1 at 12px, and this is the one label the bar exists
|
|
454
|
+
// to make readable at a glance.
|
|
455
|
+
color: theme.colors.body,
|
|
456
|
+
}, children: branchLabel }), commitLabel ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", style: { color: theme.colors.body }, children: "\u00B7" }), _jsx("span", { "data-testid": "toolbar-commit", title: config.gitCommit, style: {
|
|
457
|
+
// `0 0 auto`, not `0 1 auto`. Both labels shrinking
|
|
458
|
+
// meant the 220px clamp took width off the commit
|
|
459
|
+
// too, and with `overflow: hidden` and no ellipsis it
|
|
460
|
+
// clipped mid-glyph - a seven-character SHA rendered
|
|
461
|
+
// as six and a sliver, which is worse than absent
|
|
462
|
+
// because it still looks like a value. The short SHA
|
|
463
|
+
// is a fixed width and is the identifier that pins a
|
|
464
|
+
// report to a build, so it is the one thing in this
|
|
465
|
+
// pill that never gives up room; the branch above
|
|
466
|
+
// already ellipsises and is the label that can afford
|
|
467
|
+
// to.
|
|
468
|
+
flex: "0 0 auto",
|
|
469
|
+
whiteSpace: "nowrap",
|
|
470
|
+
color: theme.colors.body,
|
|
471
|
+
}, children: commitLabel })] })) : null] }), _jsx(BarButton, { theme: theme, testId: "toolbar-collapse", label: "Collapse toolbar", expanded: true, onClick: () => setCollapsed(true), children: _jsx(CollapseIcon, { turn: edge.chevron }) })] })) }) })] }), ready ? (_jsxs(_Fragment, { children: [_jsx(PinLayer, { client: client, config: config, organizationId: ready, theme: theme, picking: picking, onPickingChange: setPicking, showResolved: showResolved, focusPinId: focusPinId, deepLinkPinId: deepLinkPinId, onFocusHandled: () => setFocusPinId(null), onOpenCountChange: setOpenCount }), renderInbox?.({
|
|
472
|
+
client,
|
|
473
|
+
config,
|
|
474
|
+
organizationId: ready,
|
|
475
|
+
theme,
|
|
476
|
+
open: inboxOpen,
|
|
477
|
+
onClose: () => setInbox(false),
|
|
478
|
+
showResolved,
|
|
479
|
+
onShowResolvedChange: setShowResolved,
|
|
480
|
+
onFocusPin: focusPin,
|
|
481
|
+
})] })) : null] }));
|
|
482
|
+
// Portalled to `document.body`, which is a correctness fix rather than a
|
|
483
|
+
// tidy-up.
|
|
484
|
+
//
|
|
485
|
+
// Every surface this package draws is `position: fixed`, and a fixed box is
|
|
486
|
+
// positioned against the VIEWPORT only while no ancestor establishes a
|
|
487
|
+
// containing block. Any ancestor carrying `transform`, `filter`,
|
|
488
|
+
// `perspective`, `backdrop-filter`, `will-change` or `contain` does exactly
|
|
489
|
+
// that - and on the npm install path the toolbar is mounted wherever the
|
|
490
|
+
// host's root layout puts it, commonly inside a page-transition wrapper or
|
|
491
|
+
// an animated shell. The whole toolbar - bar, popovers, inbox sheet and
|
|
492
|
+
// every pin marker on the page - then renders offset into that ancestor's
|
|
493
|
+
// box, with the pins pointing at nothing. The script-tag path never showed
|
|
494
|
+
// it because mount.tsx appends its container to `document.body` already.
|
|
495
|
+
//
|
|
496
|
+
// `data-bitvea-feedback-toolbar` travels with the portalled root, so the
|
|
497
|
+
// outside-click check above and `elementUnder`'s "never anchor to our own
|
|
498
|
+
// UI" exclusion in PinLayer still match: both walk `closest()` from the
|
|
499
|
+
// event target through the real DOM tree, which is where the portal puts
|
|
500
|
+
// this.
|
|
501
|
+
return canPortal ? createPortal(tree, document.body) : null;
|
|
502
|
+
}
|
|
503
|
+
/* ── The deep link, as a store ─────────────────────────────────────────── */
|
|
504
|
+
/**
|
|
505
|
+
* `captureDeepLink` is a module-scope one-shot: it reads the hash the first
|
|
506
|
+
* time it is called on the client and answers every later caller from memory.
|
|
507
|
+
* So there is nothing to subscribe to - the value cannot change - and this
|
|
508
|
+
* exists only to satisfy `useSyncExternalStore`, which is used here for its
|
|
509
|
+
* server snapshot rather than for its subscription.
|
|
510
|
+
*/
|
|
511
|
+
function subscribeDeepLink() {
|
|
512
|
+
return () => { };
|
|
513
|
+
}
|
|
514
|
+
/* ── The portal target, as a store ────────────────────────────────────── */
|
|
515
|
+
/** Neither of the two stores below can change, so neither publishes. */
|
|
516
|
+
function subscribeNothing() {
|
|
517
|
+
return () => { };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A browser with a `<body>` to portal into. Both halves are checked because
|
|
521
|
+
* this also runs from a `<script>` in `<head>`, where `document` exists and
|
|
522
|
+
* `document.body` does not yet.
|
|
523
|
+
*/
|
|
524
|
+
function browserCanPortal() {
|
|
525
|
+
return typeof document !== "undefined" && document.body !== null;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* The server's answer, and the hydrating render's. False: `createPortal` is
|
|
529
|
+
* not supported by the server renderer at all, and a client render that
|
|
530
|
+
* portalled while the server rendered inline would be a hydration mismatch in
|
|
531
|
+
* the host app. So the toolbar is client-only, which it effectively already
|
|
532
|
+
* was - every surface here reads `window`, `localStorage` or `location`.
|
|
533
|
+
*/
|
|
534
|
+
function serverCanPortal() {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* No `location` on the server, and none during hydration either. React renders
|
|
539
|
+
* this value first and adopts `captureDeepLink`'s answer once hydration is
|
|
540
|
+
* finished, which is what keeps the two renders identical.
|
|
541
|
+
*/
|
|
542
|
+
function serverDeepLink() {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/* ── Theme ────────────────────────────────────────────────────────────── */
|
|
546
|
+
/**
|
|
547
|
+
* The reviewer's manual override, held at module scope so the media query and
|
|
548
|
+
* the stored preference are one external store rather than component state.
|
|
549
|
+
*
|
|
550
|
+
* `useSyncExternalStore` rather than an effect that calls `setState`: the
|
|
551
|
+
* server render has no `window`, and the two snapshots below give React a
|
|
552
|
+
* deterministic first paint (`system`, light) that it then reconciles, instead
|
|
553
|
+
* of a hydration mismatch inside somebody else's app.
|
|
554
|
+
*/
|
|
555
|
+
// Only used when `localStorage` is unreachable - private mode, a blocked
|
|
556
|
+
// origin, a host that disabled storage. The toolbar still honours the choice
|
|
557
|
+
// for this page; it just cannot survive a reload, which beats the toggle
|
|
558
|
+
// silently doing nothing.
|
|
559
|
+
let memoryMode = null;
|
|
560
|
+
const modeListeners = new Set();
|
|
561
|
+
function readStoredMode() {
|
|
562
|
+
try {
|
|
563
|
+
const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
564
|
+
if (isThemeMode(raw))
|
|
565
|
+
return raw;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// Fall through to the in-memory value.
|
|
569
|
+
}
|
|
570
|
+
return memoryMode ?? "system";
|
|
571
|
+
}
|
|
572
|
+
function writeStoredMode(mode) {
|
|
573
|
+
try {
|
|
574
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
|
575
|
+
memoryMode = null;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
memoryMode = mode;
|
|
579
|
+
}
|
|
580
|
+
modeListeners.forEach((listener) => listener());
|
|
581
|
+
}
|
|
582
|
+
function subscribeMode(listener) {
|
|
583
|
+
modeListeners.add(listener);
|
|
584
|
+
return () => modeListeners.delete(listener);
|
|
585
|
+
}
|
|
586
|
+
function subscribeSystemScheme(listener) {
|
|
587
|
+
if (typeof window.matchMedia !== "function")
|
|
588
|
+
return () => { };
|
|
589
|
+
const query = window.matchMedia(DARK_SCHEME_QUERY);
|
|
590
|
+
query.addEventListener("change", listener);
|
|
591
|
+
return () => query.removeEventListener("change", listener);
|
|
592
|
+
}
|
|
593
|
+
function systemPrefersDark() {
|
|
594
|
+
if (typeof window === "undefined" ||
|
|
595
|
+
typeof window.matchMedia !== "function") {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
return window.matchMedia(DARK_SCHEME_QUERY).matches;
|
|
599
|
+
}
|
|
600
|
+
function serverMode() {
|
|
601
|
+
return "system";
|
|
602
|
+
}
|
|
603
|
+
function useToolbarTheme() {
|
|
604
|
+
// The server snapshot: no `window`, so no stored choice and no media query.
|
|
605
|
+
// Both halves therefore render "follow the host, and assume light" first,
|
|
606
|
+
// which is a deterministic first paint rather than a hydration mismatch
|
|
607
|
+
// inside somebody else's app.
|
|
608
|
+
const mode = useSyncExternalStore(subscribeMode, readStoredMode, serverMode);
|
|
609
|
+
const prefersDark = useSyncExternalStore(subscribeSystemScheme, systemPrefersDark, () => false);
|
|
610
|
+
const resolved = mode === "system" ? (prefersDark ? "dark" : "light") : mode;
|
|
611
|
+
return {
|
|
612
|
+
mode,
|
|
613
|
+
theme: useMemo(() => themeFor(resolved), [resolved]),
|
|
614
|
+
setMode: writeStoredMode,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/** Keep the pill's box inside the viewport, on one axis. */
|
|
618
|
+
function clampDelta(delta, start, length, viewport) {
|
|
619
|
+
// `Math.min` last, so a viewport narrower than the bar answers the low bound
|
|
620
|
+
// rather than an impossible one - the bar hangs off the far edge instead of
|
|
621
|
+
// off the near one, which is the edge a reviewer is reaching from.
|
|
622
|
+
return Math.max(-start, Math.min(delta, viewport - start - length));
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Which of the eight docks a point belongs to: the viewport in thirds on each
|
|
626
|
+
* axis, which is the whole snap rule.
|
|
627
|
+
*
|
|
628
|
+
* Thirds rather than "nearest of eight centres", because the eight positions
|
|
629
|
+
* are not points - a bottom-centre bar is a 400px object and a bottom-right
|
|
630
|
+
* one is the same object 200px along - so a distance to a centre would depend
|
|
631
|
+
* on the bar's own width and would move the boundaries as it collapsed.
|
|
632
|
+
*
|
|
633
|
+
* `middle-center` is not a dock, so a release in the dead centre of the
|
|
634
|
+
* viewport resolves to the nearer of top-centre and bottom-centre. Validated
|
|
635
|
+
* through `isToolbarDock` rather than cast: the composed string is exactly the
|
|
636
|
+
* shape that guard exists to check, and it means a ninth combination cannot
|
|
637
|
+
* reach `dockLayout` as a key it has no row for.
|
|
638
|
+
*/
|
|
639
|
+
function nearestDock(x, y) {
|
|
640
|
+
const width = window.innerWidth || 1;
|
|
641
|
+
const height = window.innerHeight || 1;
|
|
642
|
+
const horizontal = x < width / 3 ? "left" : x > (width * 2) / 3 ? "right" : "center";
|
|
643
|
+
const band = y < height / 3 ? "top" : y > (height * 2) / 3 ? "bottom" : "middle";
|
|
644
|
+
const vertical = band === "middle" && horizontal === "center"
|
|
645
|
+
? y < height / 2
|
|
646
|
+
? "top"
|
|
647
|
+
: "bottom"
|
|
648
|
+
: band;
|
|
649
|
+
const candidate = `${vertical}-${horizontal}`;
|
|
650
|
+
return isToolbarDock(candidate) ? candidate : DEFAULT_DOCK;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The bar's drag, and every line of it is about where the transform is allowed
|
|
654
|
+
* to live.
|
|
655
|
+
*
|
|
656
|
+
* The pill carries `translate3d` while the pointer is down and NOTHING at
|
|
657
|
+
* rest - an omitted property rather than `transform: none`, because a resting
|
|
658
|
+
* transform of any spelling is still a stacking context and two tests read
|
|
659
|
+
* that declaration to prove the collapse motion is not on this box. The dock
|
|
660
|
+
* above it and the root above that stay transform-free permanently: the root
|
|
661
|
+
* is the sole ancestor of every `position: fixed` pin marker on the page, and
|
|
662
|
+
* a transform there moves all of them. Issue #21 names the pill as the one
|
|
663
|
+
* sanctioned place, and this is it.
|
|
664
|
+
*
|
|
665
|
+
* `window` listeners rather than `setPointerCapture`, and that is not a
|
|
666
|
+
* preference either. Pointer capture RETARGETS every later event at the
|
|
667
|
+
* capturing element, so the bar button under the pointer would never see its
|
|
668
|
+
* own `pointerup` and would stay visibly pressed for the rest of the session.
|
|
669
|
+
* Listening on the window keeps the button's own events intact and picks up a
|
|
670
|
+
* pointer that left the viewport, which capture does too but at that price.
|
|
671
|
+
* What capture WOULD have given for free is the guarantee that a gesture ends,
|
|
672
|
+
* so this one has to notice on its own that it did: see the `buttons === 0`
|
|
673
|
+
* line in `onMove`, without which a release over another application left the
|
|
674
|
+
* bar following the cursor around the host page for the rest of the session.
|
|
675
|
+
*
|
|
676
|
+
* Reduced motion is deliberately not consulted. `prefers-reduced-motion` is
|
|
677
|
+
* about animation a reviewer did not ask for; a box following their own
|
|
678
|
+
* pointer 1:1 is direct manipulation, and refusing to move it would be
|
|
679
|
+
* refusing the interaction rather than calming it. There is no `transition` on
|
|
680
|
+
* this transform for the same reason - it has to track the pointer exactly.
|
|
681
|
+
*
|
|
682
|
+
* AND THE SNAP ITSELF IS INSTANT, which is a decision rather than an omission.
|
|
683
|
+
* The dock changes by flex alignment, and DESIGN.md's Motion section names
|
|
684
|
+
* `top`/`left`/`right`/`bottom`/`margin`/`padding` as the properties this
|
|
685
|
+
* package may never transition over a host page - a reflow per frame on a
|
|
686
|
+
* document nobody here wrote. The only legal way to animate a layout move is
|
|
687
|
+
* to invert it into a transform and play that back, which needs the new box
|
|
688
|
+
* measured after the commit; the bytes and the frame of extra work buy an
|
|
689
|
+
* acknowledgement the reviewer's own hand has already given them. The drag IS
|
|
690
|
+
* the motion, and it ends when they let go. The transform and the new dock
|
|
691
|
+
* land in one commit, so there is no frame of the bar in the wrong place.
|
|
692
|
+
*/
|
|
693
|
+
function useBarDrag() {
|
|
694
|
+
const [origin, setOrigin] = useState(null);
|
|
695
|
+
/** The live delta, set only once the threshold has been crossed. */
|
|
696
|
+
const [offset, setOffset] = useState(null);
|
|
697
|
+
/**
|
|
698
|
+
* Whether the press that is ending turned out to be a drag.
|
|
699
|
+
*
|
|
700
|
+
* A ref rather than state, and the reason is ordering rather than renders.
|
|
701
|
+
* Nothing about the bar LOOKS different because a drag just finished; what
|
|
702
|
+
* this flag does is swallow exactly one click, and it is written by
|
|
703
|
+
* `pointerup` and read by the `click` that follows it a moment later. State
|
|
704
|
+
* makes that a question about when React has re-rendered - and the answer
|
|
705
|
+
* differs between a browser, which flushes a discrete event synchronously,
|
|
706
|
+
* and a test that dispatches the pair inside one `act`. A ref is true the
|
|
707
|
+
* instant it is set, in both.
|
|
708
|
+
*/
|
|
709
|
+
const dragged = useRef(false);
|
|
710
|
+
const moved = offset !== null;
|
|
711
|
+
// `origin` and `moved` are the only deps, and both change at most twice per
|
|
712
|
+
// drag - so this subscribes once when the pointer goes down and re-subscribes
|
|
713
|
+
// once when the threshold is crossed, rather than on every pointermove.
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
if (origin === null)
|
|
716
|
+
return;
|
|
717
|
+
/** The clamped delta and whether this event is past the threshold. */
|
|
718
|
+
const measure = (event) => {
|
|
719
|
+
const rawX = event.clientX - origin.x;
|
|
720
|
+
const rawY = event.clientY - origin.y;
|
|
721
|
+
return {
|
|
722
|
+
x: clampDelta(rawX, origin.left, origin.width, window.innerWidth),
|
|
723
|
+
y: clampDelta(rawY, origin.top, origin.height, window.innerHeight),
|
|
724
|
+
// Latched: a drag that wanders out and comes back to the pixel it
|
|
725
|
+
// started on is still a drag, and must not also fire the click.
|
|
726
|
+
past: moved || Math.hypot(rawX, rawY) >= DRAG_THRESHOLD,
|
|
727
|
+
};
|
|
728
|
+
};
|
|
729
|
+
const onMove = (event) => {
|
|
730
|
+
// A MOVE WITH NO BUTTON HELD IS A RELEASE THIS WINDOW NEVER SAW, and
|
|
731
|
+
// without this line the bar follows the pointer around the host page
|
|
732
|
+
// for the rest of the session. It is what `setPointerCapture` would
|
|
733
|
+
// have bought, at the price named above: a release over another
|
|
734
|
+
// application, a native menu or a second monitor does not always come
|
|
735
|
+
// back as a `pointerup`, and the next thing the page hears is a plain
|
|
736
|
+
// move. Reverting rather than docking where the pointer reappeared,
|
|
737
|
+
// because the reviewer did not choose that spot - they let go somewhere
|
|
738
|
+
// this document was never told about.
|
|
739
|
+
if (event.buttons === 0) {
|
|
740
|
+
setOrigin(null);
|
|
741
|
+
setOffset(null);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const { x, y, past } = measure(event);
|
|
745
|
+
if (past)
|
|
746
|
+
setOffset({ x, y });
|
|
747
|
+
};
|
|
748
|
+
const onUp = (event) => {
|
|
749
|
+
const { x, y, past } = measure(event);
|
|
750
|
+
if (past) {
|
|
751
|
+
// The pill's centre where the reviewer let go. Written straight to the
|
|
752
|
+
// shared preference record, which notifies the store, which re-renders
|
|
753
|
+
// the dock - there is no component copy of the dock to keep in step.
|
|
754
|
+
writePreferences({
|
|
755
|
+
dock: nearestDock(origin.left + x + origin.width / 2, origin.top + y + origin.height / 2),
|
|
756
|
+
});
|
|
757
|
+
dragged.current = true;
|
|
758
|
+
}
|
|
759
|
+
setOrigin(null);
|
|
760
|
+
setOffset(null);
|
|
761
|
+
};
|
|
762
|
+
// A cancelled pointer - the OS took the gesture, the touch became a
|
|
763
|
+
// scroll - is not a choice, so it reverts rather than docking somewhere
|
|
764
|
+
// the reviewer never released.
|
|
765
|
+
const onCancel = () => {
|
|
766
|
+
setOrigin(null);
|
|
767
|
+
setOffset(null);
|
|
768
|
+
};
|
|
769
|
+
window.addEventListener("pointermove", onMove);
|
|
770
|
+
window.addEventListener("pointerup", onUp);
|
|
771
|
+
window.addEventListener("pointercancel", onCancel);
|
|
772
|
+
return () => {
|
|
773
|
+
window.removeEventListener("pointermove", onMove);
|
|
774
|
+
window.removeEventListener("pointerup", onUp);
|
|
775
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
776
|
+
};
|
|
777
|
+
}, [origin, moved]);
|
|
778
|
+
return {
|
|
779
|
+
style: {
|
|
780
|
+
// Only while it is actually moving, so the property is absent at rest.
|
|
781
|
+
...(offset === null
|
|
782
|
+
? {}
|
|
783
|
+
: {
|
|
784
|
+
transform: `translate3d(${offset.x}px, ${offset.y}px, 0)`,
|
|
785
|
+
cursor: "grabbing",
|
|
786
|
+
}),
|
|
787
|
+
// From the pointer going DOWN rather than from the threshold, because by
|
|
788
|
+
// the time 6px have been travelled the browser has already started
|
|
789
|
+
// selecting the deployment chip's text under the cursor.
|
|
790
|
+
...(origin === null ? {} : { userSelect: "none" }),
|
|
791
|
+
},
|
|
792
|
+
handlers: {
|
|
793
|
+
onPointerDown: (event) => {
|
|
794
|
+
// Secondary buttons open host context menus; they are not drags.
|
|
795
|
+
if (event.button > 0)
|
|
796
|
+
return;
|
|
797
|
+
// FIRST, AND BEFORE THE CONTROL CHECK BELOW RETURNS. A press is the
|
|
798
|
+
// start of a new gesture, so nothing a previous one left behind may
|
|
799
|
+
// survive into it - and the swallow below is armed by a `pointerup`
|
|
800
|
+
// and disarmed by a `click` that is not guaranteed to arrive. A drag
|
|
801
|
+
// whose pointer was clamped inside the viewport while the pointer ran
|
|
802
|
+
// past it ends with the two events on different elements, and the
|
|
803
|
+
// browser then fires the click on their common ancestor, which is the
|
|
804
|
+
// host page's own tree rather than this pill. Clearing it only where
|
|
805
|
+
// a drag can be armed left that stale `true` to eat the reviewer's
|
|
806
|
+
// next click on a bar button - the account popover simply did not
|
|
807
|
+
// open, once, after every drag into a corner.
|
|
808
|
+
dragged.current = false;
|
|
809
|
+
const target = event.target;
|
|
810
|
+
// The bar's BODY is the handle - the mark, the deployment chip, the
|
|
811
|
+
// padding between controls - and a press that lands on a control is
|
|
812
|
+
// that control's. Without this every click on a bar button would arm a
|
|
813
|
+
// drag and flash its 3% press squash on the way.
|
|
814
|
+
//
|
|
815
|
+
// The collapsed dot is the exception, and the reason it needs one is
|
|
816
|
+
// that it has no body: it is a single button filling the whole circle,
|
|
817
|
+
// so "drag by the body" would leave it un-draggable. Its whole surface
|
|
818
|
+
// is the handle, and the click still fires because a press under the
|
|
819
|
+
// threshold is not a drag - which is what `onClickCapture` below
|
|
820
|
+
// decides.
|
|
821
|
+
const onControl = target instanceof Element &&
|
|
822
|
+
target.closest("button, a, input, textarea, select") !== null;
|
|
823
|
+
const wholeSurface = event.currentTarget.dataset.toolbarState === "collapsed";
|
|
824
|
+
if (onControl && !wholeSurface)
|
|
825
|
+
return;
|
|
826
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
827
|
+
setOrigin({
|
|
828
|
+
x: event.clientX,
|
|
829
|
+
y: event.clientY,
|
|
830
|
+
left: box.left,
|
|
831
|
+
top: box.top,
|
|
832
|
+
width: box.width,
|
|
833
|
+
height: box.height,
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
// A drag that ended over the collapsed dot would otherwise also expand
|
|
837
|
+
// the bar, since the pointer went down and came up on the same button.
|
|
838
|
+
// Capture phase, so the button's own `onClick` never runs.
|
|
839
|
+
onClickCapture: (event) => {
|
|
840
|
+
if (!dragged.current)
|
|
841
|
+
return;
|
|
842
|
+
event.stopPropagation();
|
|
843
|
+
event.preventDefault();
|
|
844
|
+
dragged.current = false;
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/* ── Bar chrome ───────────────────────────────────────────────────────── */
|
|
850
|
+
/**
|
|
851
|
+
* The bar's contents, and the only thing that moves when a reviewer collapses
|
|
852
|
+
* or expands it.
|
|
853
|
+
*
|
|
854
|
+
* A wrapper INSIDE the pill rather than the pill itself, and that is the whole
|
|
855
|
+
* design of this component. Two rules meet here and both of them have been
|
|
856
|
+
* broken in this package before:
|
|
857
|
+
*
|
|
858
|
+
* DESIGN.md's Motion section allows `transform` and `opacity` and forbids
|
|
859
|
+
* `width`. The pill's width is exactly what a collapse changes, so the box
|
|
860
|
+
* snaps - instantly, in the frame the cross-fade starts, which is that
|
|
861
|
+
* section's own rule for a layout property that has to change alongside an
|
|
862
|
+
* animation. Animating it instead would reflow the host document once per
|
|
863
|
+
* frame, and the host gets blamed for that jank.
|
|
864
|
+
*
|
|
865
|
+
* And the opacity and the transform go HERE, never on the pill, the dock or
|
|
866
|
+
* the toolbar root. Both create a stacking context for every frame they are
|
|
867
|
+
* mid-flight, and issue #23 was that exact defect one surface over: a wrapper
|
|
868
|
+
* dimmed to 45% painted the thread it contained straight through to the host
|
|
869
|
+
* page and swallowed the `layers` rung the thread had set on itself. This
|
|
870
|
+
* element is safe for it because of what is INSIDE it - bar buttons, the mark
|
|
871
|
+
* and the deployment chip, none of them `position: fixed` and none of them
|
|
872
|
+
* declaring a z-index. Everything that does stack against `layers` is a
|
|
873
|
+
* sibling of the pill or lives outside the dock: the popovers, the inbox sheet
|
|
874
|
+
* and every pin marker on the page. Adding anything to the bar that has to
|
|
875
|
+
* escape this box is the change that breaks the rule.
|
|
876
|
+
*
|
|
877
|
+
* `settle` rather than `fast`, and `motion.duration.settle` exists in
|
|
878
|
+
* tokens.ts for this surface. It is the one transition in the package whose
|
|
879
|
+
* box changes shape underneath it: 120ms of cross-fade lands before the eye
|
|
880
|
+
* has followed the pill's snap from ~400px to 46px, which reads as the
|
|
881
|
+
* contents having been swapped rather than as one bar changing form. 180ms is
|
|
882
|
+
* DESIGN.md's ceiling and this is what the ceiling is for.
|
|
883
|
+
*/
|
|
884
|
+
function BarContents({ form, children, }) {
|
|
885
|
+
const entered = useSwapEntered(form);
|
|
886
|
+
const reduced = usePrefersReducedMotion();
|
|
887
|
+
return (_jsx("div", { style: {
|
|
888
|
+
display: "flex",
|
|
889
|
+
alignItems: "center",
|
|
890
|
+
gap: bar.gap,
|
|
891
|
+
// The flex item that gives the pill its width, so it is what has to be
|
|
892
|
+
// allowed to shrink: a flex item's default `min-width: auto` is the
|
|
893
|
+
// width of its content, and without this the deployment chip's own
|
|
894
|
+
// `flex: 0 1 auto` had nothing to shrink inside and the pill overhung a
|
|
895
|
+
// narrow viewport again.
|
|
896
|
+
minWidth: 0,
|
|
897
|
+
...surfaceMotion({
|
|
898
|
+
visible: entered,
|
|
899
|
+
reduced,
|
|
900
|
+
duration: motion.duration.settle,
|
|
901
|
+
}),
|
|
902
|
+
}, children: children }, String(form)));
|
|
903
|
+
}
|
|
904
|
+
function BarButton({ theme, testId, label, hint, active, expanded, badge, onClick, children, }) {
|
|
905
|
+
const [hovered, setHovered] = useState(false);
|
|
906
|
+
// Focus is tracked in state because an inline style cannot express
|
|
907
|
+
// `:focus-visible`, and there is no stylesheet here to put it in. Every
|
|
908
|
+
// control in this package needs it: a host shipping `*:focus { outline:
|
|
909
|
+
// none }` - which plenty do - erased keyboard focus from the entire review
|
|
910
|
+
// tool, and the package declared no focus style of its own to survive it.
|
|
911
|
+
const [focused, setFocused] = useState(false);
|
|
912
|
+
const [pressed, setPressed] = useState(false);
|
|
913
|
+
const reduced = usePrefersReducedMotion();
|
|
914
|
+
return (_jsxs("button", { type: "button", "data-testid": testId, "aria-label": label, title: hint === undefined ? label : `${label} (${hint})`, ...(expanded === undefined ? {} : { "aria-expanded": expanded }), ...(active === undefined ? {} : { "aria-pressed": active }), onClick: onClick, onPointerEnter: () => setHovered(true), onPointerLeave: () => {
|
|
915
|
+
setHovered(false);
|
|
916
|
+
// Dragging off cancels the press, the way a native control does.
|
|
917
|
+
setPressed(false);
|
|
918
|
+
}, onPointerDown: () => setPressed(true), onPointerUp: () => setPressed(false), onPointerCancel: () => setPressed(false), onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: {
|
|
919
|
+
position: "relative",
|
|
920
|
+
display: "inline-flex",
|
|
921
|
+
alignItems: "center",
|
|
922
|
+
justifyContent: "center",
|
|
923
|
+
width: bar.button,
|
|
924
|
+
height: bar.button,
|
|
925
|
+
flex: "0 0 auto",
|
|
926
|
+
padding: 0,
|
|
927
|
+
border: "none",
|
|
928
|
+
borderRadius: radii.pill,
|
|
929
|
+
// Ink, not the accent. "Which mode am I in" is a selected state, and
|
|
930
|
+
// DESIGN.md gives selection to the ink polarity flip; the accent was
|
|
931
|
+
// carrying chrome, which is the one job it is not for. The bar's own
|
|
932
|
+
// ink flips with the palette, so this is a black pill with a white
|
|
933
|
+
// glyph on a light host and the reverse on a dark one.
|
|
934
|
+
background: active
|
|
935
|
+
? theme.colors.ink
|
|
936
|
+
: hovered
|
|
937
|
+
? theme.colors.barHover
|
|
938
|
+
: "transparent",
|
|
939
|
+
color: active ? theme.colors.onInk : theme.colors.barInk,
|
|
940
|
+
cursor: "pointer",
|
|
941
|
+
// A 3% squash under the pointer. Safe on this button and on nothing
|
|
942
|
+
// above it: it is a leaf with no `position: fixed` descendant, whereas
|
|
943
|
+
// a transform on the toolbar ROOT would make it the containing block
|
|
944
|
+
// for every pin marker on the page - `PinLayer` is a child of it - and
|
|
945
|
+
// every pin would then be offset by the bar's own position. The dock
|
|
946
|
+
// is a sibling of the pin layer and holds no marker, so its own rule
|
|
947
|
+
// is the narrower one: it is the ancestor of the pill and the
|
|
948
|
+
// popovers, and their motion belongs on them. See the note beside
|
|
949
|
+
// `createPortal` below, and the two tests that assert both stay
|
|
950
|
+
// transform-free.
|
|
951
|
+
transform: motionTransform(pressed ? `scale(${motion.pressScale})` : "none", reduced),
|
|
952
|
+
// The one inline duration this package shipped - `background 120ms
|
|
953
|
+
// ease`, written before there were tokens - now spelled from them, and
|
|
954
|
+
// at `instant` rather than `fast`: DESIGN.md's Motion section gives
|
|
955
|
+
// 80ms to feedback on a control the pointer is already on, and this is
|
|
956
|
+
// the definition of that. `background-color` rather than `background`,
|
|
957
|
+
// because that is the animatable longhand and the shorthand quietly
|
|
958
|
+
// included properties nobody chose to animate.
|
|
959
|
+
transition: transition(["background-color", "color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
|
|
960
|
+
// `revert`, not `none`: if the host has no rule at all the UA ring is
|
|
961
|
+
// a perfectly good second ring, and this only replaces it where a host
|
|
962
|
+
// suppressed it.
|
|
963
|
+
outline: "revert",
|
|
964
|
+
boxShadow: focused ? theme.shadows.focusRing : "none",
|
|
965
|
+
}, children: [children, badge !== undefined && badge > 0 ? (_jsx(CountBubble, { theme: theme, testId: testId, active: active ?? false, count: badge, children: badge > 99 ? "99+" : badge })) : null] }));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* The panel a bar button opens, above the bar and anchored to it.
|
|
969
|
+
*
|
|
970
|
+
* Enter only, and that is a decision rather than an omission. This element
|
|
971
|
+
* carries `role="dialog"`, so deferring its unmount would leave a dialog in
|
|
972
|
+
* the accessibility tree after the reviewer closed it, and several e2e specs
|
|
973
|
+
* assert `feedback-panel` reaches zero elements. The acknowledgement on the
|
|
974
|
+
* way out is the button's own fill cross-fading back off, which is on the
|
|
975
|
+
* control the reviewer just clicked and is where they are looking.
|
|
976
|
+
*/
|
|
977
|
+
function BarPopover({ theme, tool, edge, children, }) {
|
|
978
|
+
const entered = useEntered();
|
|
979
|
+
const reduced = usePrefersReducedMotion();
|
|
980
|
+
return (_jsx("div", { role: "dialog", "aria-label": "BitVea Feedback", "data-testid": "feedback-panel", "data-toolbar-surface": tool, style: {
|
|
981
|
+
width: tool === "note" ? "320px" : "280px",
|
|
982
|
+
maxWidth: "100%",
|
|
983
|
+
// Every fixed-width surface in this package states this rather than
|
|
984
|
+
// inheriting it. The toolbar mounts in a host page that may ship no
|
|
985
|
+
// CSS reset at all, and under the default `content-box` this popover
|
|
986
|
+
// renders 320px + 24px of padding + 2px of border, 26px wider than it
|
|
987
|
+
// was designed. It is invisible on the preview route only because
|
|
988
|
+
// apps/web loads Tailwind's preflight.
|
|
989
|
+
boxSizing: "border-box",
|
|
990
|
+
pointerEvents: "auto",
|
|
991
|
+
// The gap goes on the edge FACING THE BAR, which flips with the dock -
|
|
992
|
+
// `marginBottom` for a bottom or mid-edge dock, `marginTop` for a top
|
|
993
|
+
// one, where `column-reverse` has put this popover underneath.
|
|
994
|
+
//
|
|
995
|
+
// And the horizontal `auto` that used to centre it is gone, which was
|
|
996
|
+
// a latent bug rather than a tidy-up: an `auto` margin on a flex item
|
|
997
|
+
// absorbs the free space on its axis and OVERRIDES `align-items`, so a
|
|
998
|
+
// 320px composer would have stayed pinned to the viewport's centre
|
|
999
|
+
// while the bar it belongs to sat in a corner. The dock's
|
|
1000
|
+
// `alignItems` is what places it now.
|
|
1001
|
+
[edge.popoverGap]: spacing.xs,
|
|
1002
|
+
padding: spacing.sm,
|
|
1003
|
+
background: theme.colors.canvas,
|
|
1004
|
+
// The border STAYS and the shadow's ring is inset. Every popover here
|
|
1005
|
+
// used to draw two edges - this 1px border plus an outset `0 0 0 1px`
|
|
1006
|
+
// baked into the shadow - which on a light host read as a double
|
|
1007
|
+
// outline. The border is the theme-aware one; the ring is DESIGN.md's
|
|
1008
|
+
// Level 1 inset hairline, drawn inside.
|
|
1009
|
+
border: `1px solid ${theme.colors.hairline}`,
|
|
1010
|
+
borderRadius: radii.lg,
|
|
1011
|
+
boxShadow: theme.shadows.popover,
|
|
1012
|
+
color: theme.colors.body,
|
|
1013
|
+
...typeScale.bodySm,
|
|
1014
|
+
// Grows out of the bar, because that is what it is anchored to - see
|
|
1015
|
+
// the comment at the call site. The origin is the edge it is ATTACHED
|
|
1016
|
+
// to, which is DESIGN.md's rule for a surface anchored to a control
|
|
1017
|
+
// rather than dropped at a clicked pixel - and since issue #21 that
|
|
1018
|
+
// edge is whichever one the bar is docked to, so it comes from
|
|
1019
|
+
// `dockLayout` instead of being the hard-coded `bottom center` it was.
|
|
1020
|
+
//
|
|
1021
|
+
// The transform is on THIS element and never on the dock above it or
|
|
1022
|
+
// the root above that. The ROOT is the ancestor every `position:
|
|
1023
|
+
// fixed` pin marker resolves against - `PinLayer` is its child - so a
|
|
1024
|
+
// transform there would become their containing block and offset every
|
|
1025
|
+
// pin on the host page by the bar's own position. The dock holds no
|
|
1026
|
+
// marker, being the pin layer's sibling; what it holds is this popover
|
|
1027
|
+
// and the pill, so the motion those two need belongs on them and not
|
|
1028
|
+
// on the box that positions both. Two tests assert the dock and the
|
|
1029
|
+
// root stay transform-free.
|
|
1030
|
+
...surfaceMotion({
|
|
1031
|
+
visible: entered,
|
|
1032
|
+
reduced,
|
|
1033
|
+
origin: edge.popoverOrigin,
|
|
1034
|
+
}),
|
|
1035
|
+
}, children: children }));
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The unread count on a bar button.
|
|
1039
|
+
*
|
|
1040
|
+
* Split out of `BarButton` because acknowledging a number that moved needs a
|
|
1041
|
+
* hook, and `useJustChanged` has to be keyed on the count rather than on the
|
|
1042
|
+
* button. Everything about this bubble is identical before and after an
|
|
1043
|
+
* increment - same ink, same position, same 18px box - so a count going 3 → 4
|
|
1044
|
+
* with no motion is a glyph swap nobody notices, which is the whole reason the
|
|
1045
|
+
* issue names it.
|
|
1046
|
+
*/
|
|
1047
|
+
function CountBubble({ theme, testId, active, count, children, }) {
|
|
1048
|
+
const reduced = usePrefersReducedMotion();
|
|
1049
|
+
// The COUNT, not `children`. `useJustChanged` compares with `!==`, and a
|
|
1050
|
+
// `ReactNode` is only a primitive here by luck of the current markup - the
|
|
1051
|
+
// day somebody wraps the number in a `<span>` or a fragment, every render
|
|
1052
|
+
// produces a new object, `changed` is true forever and the badge sits
|
|
1053
|
+
// permanently squashed. Keyed on the number, that cannot happen.
|
|
1054
|
+
const changed = useJustChanged(count);
|
|
1055
|
+
return (_jsx("span", { "data-testid": `${testId}-count`, style: {
|
|
1056
|
+
position: "absolute",
|
|
1057
|
+
top: 0,
|
|
1058
|
+
right: 0,
|
|
1059
|
+
// 18px, because 12px mono is the smallest type in this system and
|
|
1060
|
+
// the bubble has to hold it. It was a 16px circle carrying 10px
|
|
1061
|
+
// sans - a size on no step in DESIGN.md's table and the smallest
|
|
1062
|
+
// type anywhere in the product.
|
|
1063
|
+
minWidth: sizes.badge,
|
|
1064
|
+
height: sizes.badge,
|
|
1065
|
+
padding: `0 ${spacing.xxs}`,
|
|
1066
|
+
boxSizing: "border-box",
|
|
1067
|
+
display: "inline-flex",
|
|
1068
|
+
alignItems: "center",
|
|
1069
|
+
justifyContent: "center",
|
|
1070
|
+
borderRadius: radii.pill,
|
|
1071
|
+
// Ink rather than the accent it used to wear - and inverted while
|
|
1072
|
+
// the button is active, because the active pill is itself an ink
|
|
1073
|
+
// fill and an ink bubble on it would vanish.
|
|
1074
|
+
background: active ? theme.colors.onInk : theme.colors.ink,
|
|
1075
|
+
color: active ? theme.colors.ink : theme.colors.onInk,
|
|
1076
|
+
...typeScale.captionMono,
|
|
1077
|
+
border: `1px solid ${active ? theme.colors.ink : theme.colors.barSurface}`,
|
|
1078
|
+
// The bubble's own colours invert when the button goes active, and
|
|
1079
|
+
// both of them are palette extremes - an ink bubble becoming an onInk
|
|
1080
|
+
// one is black to white. Transitioned so opening the inbox does not
|
|
1081
|
+
// strobe the badge beside the glyph that is already cross-fading.
|
|
1082
|
+
//
|
|
1083
|
+
// The dip is `enterScale` used in reverse: the bubble is already on
|
|
1084
|
+
// screen, so it acknowledges a new number by settling back into place
|
|
1085
|
+
// rather than by arriving. Not an overshoot - DESIGN.md's Motion
|
|
1086
|
+
// section rules those out, and one scale step is all this system has.
|
|
1087
|
+
transform: motionTransform(changed ? `scale(${motion.enterScale})` : "none", reduced),
|
|
1088
|
+
transition: transition(["background-color", "color", "border-color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
|
|
1089
|
+
}, children: children }));
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The unread signal on the collapsed dot: `sizes.dot`, on the corner facing
|
|
1093
|
+
* away from the docked edge.
|
|
1094
|
+
*
|
|
1095
|
+
* The same object as the pin marker's own unread dot - 8px of ink inside a 2px
|
|
1096
|
+
* ring of whatever surface it sits on - because they mean the same thing and
|
|
1097
|
+
* `sizes.dot` is the token they now share. It replaces an 18px `CountBubble`
|
|
1098
|
+
* carrying the number, which on a 46px circle was a badge nearly half the
|
|
1099
|
+
* control's diameter; the number itself did not go anywhere, it moved into the
|
|
1100
|
+
* button's accessible name, which already carried it.
|
|
1101
|
+
*/
|
|
1102
|
+
function UnreadDot({ theme, at, }) {
|
|
1103
|
+
return (_jsx("span", { "data-testid": "toolbar-expand-count", "aria-hidden": "true", style: {
|
|
1104
|
+
position: "absolute",
|
|
1105
|
+
...at,
|
|
1106
|
+
width: sizes.dot,
|
|
1107
|
+
height: sizes.dot,
|
|
1108
|
+
borderRadius: radii.pill,
|
|
1109
|
+
// Ink, like every other indicator here since the accent was demoted to
|
|
1110
|
+
// identity only - and the ring is the bar's own surface, so the dot
|
|
1111
|
+
// separates from the mark behind it whichever palette is in play.
|
|
1112
|
+
background: theme.colors.ink,
|
|
1113
|
+
border: `2px solid ${theme.colors.barSurface}`,
|
|
1114
|
+
boxSizing: "content-box",
|
|
1115
|
+
} }));
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The BitVea mark, inlined from `BitveaLogo.tsx` in the website project. Its
|
|
1119
|
+
* paths are `fill="currentColor"`, so it tints itself from the bar's ink and
|
|
1120
|
+
* needs neither an image dependency nor a network fetch - both of which would
|
|
1121
|
+
* be visible in a host app's waterfall, and the second of which would breach
|
|
1122
|
+
* the "a disabled build makes no request" rule the moment it was cached wrong.
|
|
1123
|
+
*/
|
|
1124
|
+
export function BitveaMark({ theme, size = 18, inline = false, }) {
|
|
1125
|
+
return (_jsx("span", { "aria-hidden": "true", style: {
|
|
1126
|
+
display: "inline-flex",
|
|
1127
|
+
alignItems: "center",
|
|
1128
|
+
justifyContent: "center",
|
|
1129
|
+
...(inline ? {} : { width: bar.button, height: bar.button }),
|
|
1130
|
+
flex: "0 0 auto",
|
|
1131
|
+
color: theme?.colors.barInk,
|
|
1132
|
+
}, children: _jsxs("svg", { viewBox: "0 0 435 439", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "BitVea",
|
|
1133
|
+
// Size in the style object, not in `width`/`height` attributes. Author
|
|
1134
|
+
// CSS beats a presentation attribute, so a host rule as ordinary as
|
|
1135
|
+
// `svg { width: 100% }` resized every icon in the toolbar - invisible
|
|
1136
|
+
// in the preview harness, visible in the wild.
|
|
1137
|
+
style: { display: "block", width: `${size}px`, height: `${size}px` }, children: [_jsx("path", { d: "M347.997 88.1161H87.207L159.233 0.373047H333.067L347.997 88.1161Z", fill: "currentColor" }), _jsx("path", { d: "M434.913 160.795V336.203L347.996 351.268V88.1167L434.913 160.795Z", fill: "currentColor" }), _jsx("path", { d: "M347.997 351.267L275.971 438.971H102.098L87.207 351.267H347.997Z", fill: "currentColor" }), _jsx("path", { d: "M87.2056 88.1167V351.268L0.25 278.59V103.143L87.2056 88.1167Z", fill: "currentColor" })] }) }));
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* `process.env` where a bundler has provided one, and an empty bag otherwise.
|
|
1141
|
+
* Guarded because this component also runs in a browser where `process` may
|
|
1142
|
+
* not exist at all, and a ReferenceError here would take the host app down.
|
|
1143
|
+
*/
|
|
1144
|
+
function readProcessEnv() {
|
|
1145
|
+
if (typeof process === "undefined" || !process.env)
|
|
1146
|
+
return {};
|
|
1147
|
+
return process.env;
|
|
1148
|
+
}
|
|
1149
|
+
//# sourceMappingURL=FeedbackToolbar.js.map
|