@lovett/ui 0.0.11 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5402 -71
- package/dist/index.js +21992 -11618
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +296 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DetailSurface — one item, three modes, one DOM.
|
|
3
|
+
*
|
|
4
|
+
* `docs/design-briefs/tasks-item-detail-full.md` §1:
|
|
5
|
+
*
|
|
6
|
+
* full default; near-full-screen with a margin, so the board stays
|
|
7
|
+
* faintly visible behind it. NOT a takeover.
|
|
8
|
+
* modal a smaller centred window.
|
|
9
|
+
* panel a right side panel.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is not `Modal` for two of them and `FloatingDrawer` for the third
|
|
12
|
+
*
|
|
13
|
+
* That was the first shape tried, and the brief kills it in one line:
|
|
14
|
+
* "Switching modes must not lose the open item, the active tab, scroll
|
|
15
|
+
* position, or an in-progress edit." Swapping which PRIMITIVE holds the
|
|
16
|
+
* content unmounts the content — React reconciles by position under a parent,
|
|
17
|
+
* and `<Modal>` and `<FloatingDrawer>` are different parents. Everything
|
|
18
|
+
* living below the swap dies with it: the active tab, both scrollers, and
|
|
19
|
+
* every half-typed field editor. The predecessor
|
|
20
|
+
* (`lenses/tasks/components/detail/item-detail-surface.tsx`) paid for this by
|
|
21
|
+
* hoisting the title and description drafts into the frame chooser and
|
|
22
|
+
* re-applying `scrollTop` in a layout effect after every reframe — and that
|
|
23
|
+
* still only covered the two edits it knew the names of. With five field
|
|
24
|
+
* editors, an activity tab and two scrollers, that approach does not scale;
|
|
25
|
+
* it just moves the loss somewhere less obvious.
|
|
26
|
+
*
|
|
27
|
+
* So the frame is ONE element tree in every mode, and the mode changes only
|
|
28
|
+
* positioning, size and flex direction. Nothing unmounts, so nothing is
|
|
29
|
+
* hoisted, so nothing is lost — the requirement is satisfied structurally
|
|
30
|
+
* rather than by a list of state to remember.
|
|
31
|
+
*
|
|
32
|
+
* That does mean this file owns dialog mechanics `Modal` already owns, so it
|
|
33
|
+
* borrows the same pieces rather than reimplementing them: `useLayer` for
|
|
34
|
+
* topmost-wins Escape, `tabbablesWithin` for the trap, `.ds-card-surface` for
|
|
35
|
+
* the chrome. What is genuinely new here is the framing, and the framing is
|
|
36
|
+
* the part neither primitive could express.
|
|
37
|
+
*
|
|
38
|
+
* ## Orientation follows the mode
|
|
39
|
+
*
|
|
40
|
+
* `detailPaneSide()` — activity at the inline end in `full` and `modal`, below
|
|
41
|
+
* in `panel`, with the divider turning with it. There is no second setting to
|
|
42
|
+
* disagree with the mode.
|
|
43
|
+
*
|
|
44
|
+
* ## Modality
|
|
45
|
+
*
|
|
46
|
+
* `full` and `modal` are modal: a backdrop, a focus trap. `panel` is not —
|
|
47
|
+
* the board stays legible and operable beside it, which is the whole point of
|
|
48
|
+
* the shape — so it has no backdrop and no trap, and answers Escape through
|
|
49
|
+
* the shared layer stack so a select opened inside it closes first.
|
|
50
|
+
*
|
|
51
|
+
* The dialog is ALWAYS MOUNTED and driven by `open` (§8). Closed it is
|
|
52
|
+
* `inert`, so an off-screen panel is neither a stray dialog in the
|
|
53
|
+
* accessibility tree nor a set of stray tab stops.
|
|
54
|
+
*
|
|
55
|
+
* ## The split's bounds are the CONTAINER's, not a constant
|
|
56
|
+
*
|
|
57
|
+
* The first version expressed the constraint on one pane only: the activity
|
|
58
|
+
* pane had a `min` and a `max` in absolute px, and the fields pane was
|
|
59
|
+
* `flex-1 min-w-0` — which is to say it had no minimum at all and would shrink
|
|
60
|
+
* to nothing. In `modal` mode (840px at most) an activity pane at its legal
|
|
61
|
+
* 720 left the fields ~116px, and the label column alone is 132px; the value
|
|
62
|
+
* column went negative and every value wrapped to about four characters
|
|
63
|
+
* ("Aug 22, 202", "Set star date"). An absolute maximum cannot know that.
|
|
64
|
+
*
|
|
65
|
+
* So the bounds are RECOMPUTED from the measured split — the aside may grow to
|
|
66
|
+
* `container − FIELDS_MIN − the divider`, and no further, in every mode and at
|
|
67
|
+
* every viewport. A `ResizeObserver` is what makes that hold: `full` / `modal`
|
|
68
|
+
* / `panel` and the viewport all change the container, and none of them is a
|
|
69
|
+
* React render this component can hook. The divider is handed the DERIVED
|
|
70
|
+
* bounds, so it cannot be dragged into a state the layout will not honour —
|
|
71
|
+
* the constraint is enforced where the gesture happens, not repaired after it.
|
|
72
|
+
*
|
|
73
|
+
* The fields pane still degrades below that floor rather than colliding: the
|
|
74
|
+
* field row stacks its label above its value under a container query, and
|
|
75
|
+
* every value truncates. See `field-row.tsx` and the `ds-field-list` block in
|
|
76
|
+
* styles.css.
|
|
77
|
+
*
|
|
78
|
+
* ## The PANEL has an outer edge, and it obeys the same rule
|
|
79
|
+
*
|
|
80
|
+
* `panel` mode's width was a hardcoded `max-inline-size: 560px`. That is fine
|
|
81
|
+
* at 1512 and wrong on a laptop — 560 of side panel plus a 308px rail leaves a
|
|
82
|
+
* 1024px screen 144px of board, which is half a lane. So the panel's leading
|
|
83
|
+
* edge is a `ResizableHandle` too, and its ceiling is derived the same way the
|
|
84
|
+
* split's is: MEASURED wrapper less a reserve the host declares, recomputed by
|
|
85
|
+
* a `ResizeObserver` on the wrapper, so the board keeps a whole lane beside it
|
|
86
|
+
* at every viewport. Its floor is the panel's own content minimum (`PANEL_MIN`
|
|
87
|
+
* — the header's non-shrinking cluster), never an arbitrary number.
|
|
88
|
+
*
|
|
89
|
+
* The two resizes are INDEPENDENT and both live: the outer one moves the
|
|
90
|
+
* panel's leading edge, the inner one moves the seam between the fields and
|
|
91
|
+
* the activity pane. Only `panel` mode draws the outer one — `full` and
|
|
92
|
+
* `modal` are centred overlays already sized against the viewport.
|
|
93
|
+
*
|
|
94
|
+
* Note which axis each governs, because it is the thing that looks like a bug
|
|
95
|
+
* and is not: in `panel` mode the split is a COLUMN, so the inner divider's
|
|
96
|
+
* bounds are derived from the container's HEIGHT and the outer drag does not
|
|
97
|
+
* move them. What the outer drag does move is the fields pane's INLINE size,
|
|
98
|
+
* and the collision that lives there — the value column falling under its
|
|
99
|
+
* 256px floor — is held off by `PANEL_MIN`, not by the split's clamp. The
|
|
100
|
+
* split's observer still watches the same box and re-derives from whatever it
|
|
101
|
+
* reports, so neither clamp is ever computed once and left stale.
|
|
102
|
+
*
|
|
103
|
+
* ## Collapsing a pane must not unmount it
|
|
104
|
+
*
|
|
105
|
+
* The whole reason this is one element tree is that state survives. A collapse
|
|
106
|
+
* that swapped a pane for `null` would throw away exactly what a mode switch
|
|
107
|
+
* is careful to keep — the scroll, the drafts, the active tab — so it is a
|
|
108
|
+
* SIZE change, not a conditional render.
|
|
109
|
+
*
|
|
110
|
+
* It is two boxes per pane, and that is load-bearing rather than decorative.
|
|
111
|
+
* The OUTER box is what collapses, to zero, with `overflow: hidden`. The INNER
|
|
112
|
+
* box keeps an explicit pixel size, so the collapsed pane's content is laid out
|
|
113
|
+
* at exactly the width it had a moment ago — which is what preserves
|
|
114
|
+
* `scrollTop`. Collapse the pane by shrinking the scroller itself and its
|
|
115
|
+
* content reflows to zero width, its `scrollHeight` collapses with it, and the
|
|
116
|
+
* browser clamps the scroll position to nothing; restoring then lands the
|
|
117
|
+
* operator at the top of a conversation they had scrolled halfway down.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
import {
|
|
121
|
+
useCallback,
|
|
122
|
+
useEffect,
|
|
123
|
+
useLayoutEffect,
|
|
124
|
+
useMemo,
|
|
125
|
+
useRef,
|
|
126
|
+
useState,
|
|
127
|
+
type CSSProperties,
|
|
128
|
+
type ReactNode,
|
|
129
|
+
type RefObject,
|
|
130
|
+
} from 'react'
|
|
131
|
+
import { ChevronsDown, ChevronsLeft, ChevronsRight, ChevronsUp } from 'lucide-react'
|
|
132
|
+
import { cn } from '../lib/utils'
|
|
133
|
+
import { tabbablesWithin } from '../lib/focus'
|
|
134
|
+
import { useLayer } from '../lib/layer-stack'
|
|
135
|
+
import { ResizableHandle } from '../resizable'
|
|
136
|
+
import { DetailDivider } from './detail-divider'
|
|
137
|
+
import {
|
|
138
|
+
DETAIL_PANEL_DEFAULT_SIZE,
|
|
139
|
+
detailPaneSide,
|
|
140
|
+
type DetailCollapse,
|
|
141
|
+
type DetailMode,
|
|
142
|
+
type DetailPaneSide,
|
|
143
|
+
} from './types'
|
|
144
|
+
|
|
145
|
+
/** Per-orientation defaults for the activity pane, in px. */
|
|
146
|
+
const DEFAULT_ASIDE_SIZE: Readonly<Record<DetailPaneSide, number>> = {
|
|
147
|
+
side: 400,
|
|
148
|
+
bottom: 260,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const DEFAULT_ASIDE_MIN: Readonly<Record<DetailPaneSide, number>> = {
|
|
152
|
+
side: 280,
|
|
153
|
+
bottom: 160,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The ceiling the OPERATOR is offered, before the container has its say. The
|
|
158
|
+
* effective maximum is the smaller of this and what the fields pane can spare;
|
|
159
|
+
* see `boundsFor`.
|
|
160
|
+
*/
|
|
161
|
+
const DEFAULT_ASIDE_MAX: Readonly<Record<DetailPaneSide, number>> = {
|
|
162
|
+
side: 720,
|
|
163
|
+
bottom: 560,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What the FIELDS pane may never go below, per orientation. The number the
|
|
168
|
+
* whole clamp is built on, so it is derived from the row rather than picked:
|
|
169
|
+
*
|
|
170
|
+
* side 288px. The field list pads `--space-4` each side (32), leaving
|
|
171
|
+
* 256px of content. Below 400px the row STACKS — label above value,
|
|
172
|
+
* full width — so 256px is the value column outright, which is about
|
|
173
|
+
* 30 characters at the row's 13px. Side by side above 400px the same
|
|
174
|
+
* 256px is what is left after the tightened 104px label and the 8px
|
|
175
|
+
* gap, so the value column lands on 256 from both directions and
|
|
176
|
+
* never goes under it at any divider position. That is the fix: not
|
|
177
|
+
* "a bit more room", a floor the value column cannot fall through.
|
|
178
|
+
* bottom 200px. A height, and a different question: the list's toolbar is
|
|
179
|
+
* ~48px, so this is the toolbar plus roughly four rows — enough for
|
|
180
|
+
* the pane to read as a list rather than as a sliver.
|
|
181
|
+
*/
|
|
182
|
+
const FIELDS_MIN: Readonly<Record<DetailPaneSide, number>> = {
|
|
183
|
+
side: 288,
|
|
184
|
+
bottom: 200,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The divider strip, px — `w-1` / `h-1` in `detail-divider.tsx`. It sits
|
|
189
|
+
* BETWEEN the panes and therefore comes out of the container before either of
|
|
190
|
+
* them is measured.
|
|
191
|
+
*/
|
|
192
|
+
const DIVIDER_SIZE = 4
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The rail a collapsed pane leaves behind, px — `--space-6`, and the same 24
|
|
196
|
+
* the thread's collapse rail uses for the same reason: it is the interface
|
|
197
|
+
* standards' minimum target, and a rail thinner than that is a hairline
|
|
198
|
+
* pretending to be a control.
|
|
199
|
+
*
|
|
200
|
+
* It is IN FLOW rather than floating. The first pass pinned a round pill to
|
|
201
|
+
* the split's top corner and it landed on top of the field list's search and
|
|
202
|
+
* filter row — chrome overlapping chrome, which is the layout-stability rule
|
|
203
|
+
* from the other side: reserve the space, do not park something over content.
|
|
204
|
+
*/
|
|
205
|
+
const RESTORE_RAIL_SIZE = 24
|
|
206
|
+
|
|
207
|
+
function clamp(value: number, low: number, high: number): number {
|
|
208
|
+
return Math.max(low, Math.min(high, value))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The bounds the divider actually gets.
|
|
213
|
+
*
|
|
214
|
+
* `container` is null until the observer has measured — before first layout,
|
|
215
|
+
* and in any environment without a `ResizeObserver`. There the absolute bounds
|
|
216
|
+
* stand, which is the behaviour this component already had.
|
|
217
|
+
*
|
|
218
|
+
* The order matters when the container is too small to satisfy both panes: the
|
|
219
|
+
* FIELDS floor wins and the aside takes what is left, down to nothing, rather
|
|
220
|
+
* than the two of them overflowing a panel that clips. `min` is pulled down to
|
|
221
|
+
* `max` in that case, because a separator whose minimum exceeds its maximum
|
|
222
|
+
* describes a range that does not exist.
|
|
223
|
+
*/
|
|
224
|
+
function boundsFor(
|
|
225
|
+
container: number | null,
|
|
226
|
+
side: DetailPaneSide,
|
|
227
|
+
min: number,
|
|
228
|
+
max: number,
|
|
229
|
+
): { readonly min: number; readonly max: number } {
|
|
230
|
+
if (container === null) return { min, max }
|
|
231
|
+
// Floored: `contentRect` is fractional on a scaled display, and a separator
|
|
232
|
+
// reporting `aria-valuemax="252.90625"` is both unreadable to a screen
|
|
233
|
+
// reader and a subpixel over the floor it is supposed to protect.
|
|
234
|
+
const room = Math.floor(container - FIELDS_MIN[side] - DIVIDER_SIZE)
|
|
235
|
+
const effectiveMax = Math.max(Math.min(max, room), 0)
|
|
236
|
+
return { min: Math.min(min, effectiveMax), max: effectiveMax }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* What the PANEL may never be dragged below in `panel` mode, px — its own
|
|
241
|
+
* content minimum, derived from the widest thing it holds rather than picked.
|
|
242
|
+
*
|
|
243
|
+
* That thing is the HEADER, not the field list. Measured on the running lens,
|
|
244
|
+
* its non-shrinking cluster is 365px: mark-complete (110) + "Updated ‹when›"
|
|
245
|
+
* (75) + prev / next (68) + star, ellipsis and close (32 each), six
|
|
246
|
+
* `--space-2` gaps (48), and `--space-4` of inline padding either side (32).
|
|
247
|
+
* The title is the only flexible child, so under 365 the header stops fitting
|
|
248
|
+
* and the panel's `overflow: hidden` eats controls from the end — the close
|
|
249
|
+
* button first, which is a trap rather than a tight layout.
|
|
250
|
+
*
|
|
251
|
+
* 400 is that floor plus enough for the title to still read as a field, and it
|
|
252
|
+
* is also the width at which `FieldRow` stacks its label above its value: at
|
|
253
|
+
* and below it the value column is the full content width (~368px), well over
|
|
254
|
+
* the 256px floor `FIELDS_MIN` protects on the other axis. So neither
|
|
255
|
+
* collision is reachable by dragging the edge.
|
|
256
|
+
*/
|
|
257
|
+
const PANEL_MIN = 400
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* How much inline space must stay clear beside the panel when the host does
|
|
261
|
+
* not say, px. One board lane at its floor (`grid-auto-columns: minmax(288px,
|
|
262
|
+
* 1fr)`) plus a `--space-4` gutter either side.
|
|
263
|
+
*
|
|
264
|
+
* It is a RESERVE rather than a constant maximum because 560px is fine at
|
|
265
|
+
* 1512 and wrong on a laptop: the ceiling has to be the viewport's answer, not
|
|
266
|
+
* a number. A host whose content sits behind a fixed rail passes its own
|
|
267
|
+
* `panelReserve` — this component measures the viewport, not what is under it,
|
|
268
|
+
* and 320px of viewport is not 320px of board when a 308px rail holds the left
|
|
269
|
+
* edge.
|
|
270
|
+
*/
|
|
271
|
+
const DEFAULT_PANEL_RESERVE = 320
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `ResizableHandle`'s own cross-axis hit padding, px — it widens the 4px line
|
|
275
|
+
* to an 8px target and pulls itself back by the same amount so the line stays
|
|
276
|
+
* centred on the edge it decorates.
|
|
277
|
+
*
|
|
278
|
+
* The panel's leading edge is the one place that pull-back would be thrown
|
|
279
|
+
* away: half the target would sit outside `overflow: hidden` and be clipped,
|
|
280
|
+
* leaving 6px to grab. So the strip is offset by it, and the whole 8px lands
|
|
281
|
+
* inside the panel. Mirrored rather than imported because it is the handle's
|
|
282
|
+
* internal geometry, not a shared measure.
|
|
283
|
+
*/
|
|
284
|
+
const HANDLE_HIT_PAD = 2
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The bounds the panel's leading edge actually gets.
|
|
288
|
+
*
|
|
289
|
+
* `available` is null until the wrapper has been measured — before first
|
|
290
|
+
* layout, and in any environment without a `ResizeObserver`. There the panel
|
|
291
|
+
* keeps the fixed geometry it has always had.
|
|
292
|
+
*
|
|
293
|
+
* The order matches `boundsFor`, and for the same reason: when the viewport
|
|
294
|
+
* cannot afford both the panel's floor and the reserve, the reserve wins and
|
|
295
|
+
* the minimum is pulled down to the maximum, because a separator whose minimum
|
|
296
|
+
* exceeds its maximum describes a range that does not exist.
|
|
297
|
+
*/
|
|
298
|
+
function panelBoundsFor(
|
|
299
|
+
available: number | null,
|
|
300
|
+
reserve: number,
|
|
301
|
+
): { readonly min: number; readonly max: number } {
|
|
302
|
+
if (available === null) {
|
|
303
|
+
return { min: PANEL_MIN, max: Math.max(PANEL_MIN, DETAIL_PANEL_DEFAULT_SIZE) }
|
|
304
|
+
}
|
|
305
|
+
// Floored for the same reason `boundsFor` floors: `contentRect` is
|
|
306
|
+
// fractional on a scaled display, and `aria-valuemax="879.328125"` is
|
|
307
|
+
// unreadable to a screen reader.
|
|
308
|
+
const max = Math.max(Math.floor(available - reserve), 0)
|
|
309
|
+
return { min: Math.min(PANEL_MIN, max), max }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The margin that keeps `full` from being a takeover, and the geometry of the
|
|
314
|
+
* other two. Sizes are `min()`-clamped against the viewport so the panel can
|
|
315
|
+
* never exceed the space its margin leaves.
|
|
316
|
+
*/
|
|
317
|
+
const PANEL_GEOMETRY: Readonly<Record<DetailMode, CSSProperties>> = {
|
|
318
|
+
full: {
|
|
319
|
+
inlineSize: '100%',
|
|
320
|
+
maxInlineSize: '1280px',
|
|
321
|
+
blockSize: '100%',
|
|
322
|
+
},
|
|
323
|
+
modal: {
|
|
324
|
+
inlineSize: '100%',
|
|
325
|
+
maxInlineSize: '840px',
|
|
326
|
+
blockSize: 'min(720px, 100%)',
|
|
327
|
+
},
|
|
328
|
+
// `inlineSize` is REPLACED by the measured width below; what is here is the
|
|
329
|
+
// pre-measurement fallback, and it is the size the mode had before its
|
|
330
|
+
// leading edge became draggable. `maxInlineSize` stays as the last-resort
|
|
331
|
+
// guard: a stored width from a wider monitor can never overflow the wrapper
|
|
332
|
+
// even for the frame before the observer reports.
|
|
333
|
+
panel: {
|
|
334
|
+
inlineSize: `${DETAIL_PANEL_DEFAULT_SIZE}px`,
|
|
335
|
+
maxInlineSize: '100%',
|
|
336
|
+
blockSize: '100%',
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const WRAPPER_PADDING: Readonly<Record<DetailMode, string>> = {
|
|
341
|
+
full: 'var(--space-6)',
|
|
342
|
+
modal: 'var(--space-6)',
|
|
343
|
+
panel: 'var(--space-3)',
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface DetailSurfaceProps {
|
|
347
|
+
/** Drive the dialog with this. NEVER conditionally render the surface (§8). */
|
|
348
|
+
open: boolean
|
|
349
|
+
mode: DetailMode
|
|
350
|
+
onClose: () => void
|
|
351
|
+
/** Names the dialog. There is no `<h2>` of our own to point at. */
|
|
352
|
+
ariaLabel: string
|
|
353
|
+
/** The header row — a `<DetailHeader>`. Spans the full width above the split. */
|
|
354
|
+
header?: ReactNode | undefined
|
|
355
|
+
/** The fields pane. */
|
|
356
|
+
children: ReactNode
|
|
357
|
+
/** The activity pane. Omitted, the surface renders no divider and no pane. */
|
|
358
|
+
aside?: ReactNode | undefined
|
|
359
|
+
/**
|
|
360
|
+
* Size of the activity pane along the split axis, px. Controlled when
|
|
361
|
+
* supplied — and it is per-ORIENTATION, which is why the change handler is
|
|
362
|
+
* told which side it is reporting: 400px of width is not 400px of height.
|
|
363
|
+
*/
|
|
364
|
+
asideSize?: number | undefined
|
|
365
|
+
onAsideSizeChange?: ((next: number, side: DetailPaneSide) => void) | undefined
|
|
366
|
+
/** Fires once on release / keypress. Persist from here, not the change. */
|
|
367
|
+
onAsideSizeCommit?: ((next: number, side: DetailPaneSide) => void) | undefined
|
|
368
|
+
/**
|
|
369
|
+
* The bounds the operator is OFFERED. Both are narrowed further against the
|
|
370
|
+
* measured container so the fields pane keeps its floor — passing a large
|
|
371
|
+
* `asideMax` cannot squeeze the fields to nothing.
|
|
372
|
+
*/
|
|
373
|
+
asideMin?: number | undefined
|
|
374
|
+
asideMax?: number | undefined
|
|
375
|
+
/**
|
|
376
|
+
* Which pane is collapsed. Controlled when supplied, and per-ORIENTATION for
|
|
377
|
+
* the same reason the size is — the handler is told which side it reports.
|
|
378
|
+
*/
|
|
379
|
+
collapsed?: DetailCollapse | undefined
|
|
380
|
+
onCollapsedChange?: ((next: DetailCollapse, side: DetailPaneSide) => void) | undefined
|
|
381
|
+
/** Names what the divider resizes. Default "Resize the activity pane". */
|
|
382
|
+
dividerLabel?: string | undefined
|
|
383
|
+
/**
|
|
384
|
+
* `panel` MODE ONLY: the panel's own width along the inline axis, px.
|
|
385
|
+
* Controlled when supplied. `full` and `modal` ignore every `panel*` prop —
|
|
386
|
+
* they are centred overlays whose size is already viewport-relative, and
|
|
387
|
+
* they render no edge handle at all.
|
|
388
|
+
*/
|
|
389
|
+
panelSize?: number | undefined
|
|
390
|
+
onPanelSizeChange?: ((next: number) => void) | undefined
|
|
391
|
+
/** Fires once on release / keypress. Persist from here, not the change. */
|
|
392
|
+
onPanelSizeCommit?: ((next: number) => void) | undefined
|
|
393
|
+
/**
|
|
394
|
+
* How much inline space must stay clear beside the panel, px — the panel's
|
|
395
|
+
* effective maximum is the measured wrapper less this. Default
|
|
396
|
+
* `DEFAULT_PANEL_RESERVE`; a host whose content sits behind a fixed rail
|
|
397
|
+
* should pass the rail's width plus whatever its own content needs.
|
|
398
|
+
*
|
|
399
|
+
* Measured INSIDE this component's padding box, so a reserve derived from a
|
|
400
|
+
* viewport-relative offset is conservative by one gutter — which errs toward
|
|
401
|
+
* leaving the host MORE room, never less.
|
|
402
|
+
*/
|
|
403
|
+
panelReserve?: number | undefined
|
|
404
|
+
/** Names the panel's own edge handle. Default "Resize the panel". */
|
|
405
|
+
panelResizeLabel?: string | undefined
|
|
406
|
+
/** On the dialog panel. */
|
|
407
|
+
className?: string | undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function DetailSurface({
|
|
411
|
+
open,
|
|
412
|
+
mode,
|
|
413
|
+
onClose,
|
|
414
|
+
ariaLabel,
|
|
415
|
+
header,
|
|
416
|
+
children,
|
|
417
|
+
aside,
|
|
418
|
+
asideSize,
|
|
419
|
+
onAsideSizeChange,
|
|
420
|
+
onAsideSizeCommit,
|
|
421
|
+
asideMin,
|
|
422
|
+
asideMax,
|
|
423
|
+
collapsed,
|
|
424
|
+
onCollapsedChange,
|
|
425
|
+
dividerLabel = 'Resize the activity pane',
|
|
426
|
+
panelSize,
|
|
427
|
+
onPanelSizeChange,
|
|
428
|
+
onPanelSizeCommit,
|
|
429
|
+
panelReserve,
|
|
430
|
+
panelResizeLabel = 'Resize the panel',
|
|
431
|
+
className,
|
|
432
|
+
}: DetailSurfaceProps) {
|
|
433
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
|
434
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
435
|
+
const splitRef = useRef<HTMLDivElement | null>(null)
|
|
436
|
+
const side = detailPaneSide(mode)
|
|
437
|
+
const isModal = mode !== 'panel'
|
|
438
|
+
const isPanel = !isModal
|
|
439
|
+
const vertical = side === 'side'
|
|
440
|
+
|
|
441
|
+
/* ---- the activity pane's size, per orientation ------------------ */
|
|
442
|
+
|
|
443
|
+
// Uncontrolled state is a RECORD, not a number: the two orientations are
|
|
444
|
+
// different measurements and carrying one value across the switch would
|
|
445
|
+
// hand a 400px-wide pane's width to a pane that is now a height.
|
|
446
|
+
const [internalSize, setInternalSize] =
|
|
447
|
+
useState<Record<DetailPaneSide, number>>(DEFAULT_ASIDE_SIZE)
|
|
448
|
+
const controlled = asideSize !== undefined
|
|
449
|
+
const requested = controlled ? asideSize : internalSize[side]
|
|
450
|
+
const min = asideMin ?? DEFAULT_ASIDE_MIN[side]
|
|
451
|
+
const max = asideMax ?? DEFAULT_ASIDE_MAX[side]
|
|
452
|
+
|
|
453
|
+
/* ---- what the container will actually allow ---------------------- */
|
|
454
|
+
|
|
455
|
+
// Measured rather than derived: `full` / `modal` / `panel` and the viewport
|
|
456
|
+
// all change this box, and none of them is a render this component sees.
|
|
457
|
+
// A zero is ignored — that is a box with no layout yet, not a real
|
|
458
|
+
// measurement, and treating it as one would clamp the split to nothing on
|
|
459
|
+
// the frame before first paint.
|
|
460
|
+
const [containerSize, setContainerSize] = useState<number | null>(null)
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
const node = splitRef.current
|
|
463
|
+
if (node === null) return
|
|
464
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
465
|
+
// The reading in hand measured the OTHER axis if the orientation just
|
|
466
|
+
// changed. Dropped here rather than in an effect of its own, because two
|
|
467
|
+
// effects run in declaration order and the second would undo the first's
|
|
468
|
+
// fresh measurement on every re-run.
|
|
469
|
+
setContainerSize(null)
|
|
470
|
+
const observer = new ResizeObserver((entries) => {
|
|
471
|
+
const entry = entries[0]
|
|
472
|
+
if (entry === undefined) return
|
|
473
|
+
const measured = vertical ? entry.contentRect.width : entry.contentRect.height
|
|
474
|
+
if (measured > 0) setContainerSize(measured)
|
|
475
|
+
})
|
|
476
|
+
observer.observe(node)
|
|
477
|
+
return () => observer.disconnect()
|
|
478
|
+
}, [vertical])
|
|
479
|
+
|
|
480
|
+
const bounds = useMemo(
|
|
481
|
+
() => boundsFor(containerSize, side, min, max),
|
|
482
|
+
[containerSize, side, min, max],
|
|
483
|
+
)
|
|
484
|
+
const size = clamp(requested, bounds.min, bounds.max)
|
|
485
|
+
|
|
486
|
+
const setSize = useCallback(
|
|
487
|
+
(next: number) => {
|
|
488
|
+
if (!controlled) setInternalSize((current) => ({ ...current, [side]: next }))
|
|
489
|
+
onAsideSizeChange?.(next, side)
|
|
490
|
+
},
|
|
491
|
+
[controlled, onAsideSizeChange, side],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
const commitSize = useCallback(
|
|
495
|
+
(next: number) => {
|
|
496
|
+
onAsideSizeCommit?.(next, side)
|
|
497
|
+
},
|
|
498
|
+
[onAsideSizeCommit, side],
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
/* ---- the panel's own width, `panel` mode only -------------------- */
|
|
502
|
+
|
|
503
|
+
// Uncontrolled state is a single number rather than a record: unlike the
|
|
504
|
+
// split, there is only one panel geometry — `full` and `modal` are centred
|
|
505
|
+
// overlays and never read it.
|
|
506
|
+
const [internalPanelSize, setInternalPanelSize] = useState(DETAIL_PANEL_DEFAULT_SIZE)
|
|
507
|
+
const panelControlled = panelSize !== undefined
|
|
508
|
+
const requestedPanel = panelControlled ? panelSize : internalPanelSize
|
|
509
|
+
|
|
510
|
+
// The wrapper is `inset: 0`, so its content box IS the viewport less this
|
|
511
|
+
// mode's padding. MEASURED, not read off `window`: the same observer then
|
|
512
|
+
// answers a viewport resize, a mode change and a zoom step, and there is no
|
|
513
|
+
// second copy of "how wide is it" for the two to disagree about.
|
|
514
|
+
//
|
|
515
|
+
// Cleared when the mode leaves `panel` so the reading in hand can never be
|
|
516
|
+
// the wrong layout's — the same discipline the split's observer applies on
|
|
517
|
+
// an orientation change.
|
|
518
|
+
const [availableSize, setAvailableSize] = useState<number | null>(null)
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (!isPanel) {
|
|
521
|
+
setAvailableSize(null)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
const node = wrapperRef.current
|
|
525
|
+
if (node === null) return
|
|
526
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
527
|
+
const observer = new ResizeObserver((entries) => {
|
|
528
|
+
const entry = entries[0]
|
|
529
|
+
if (entry === undefined) return
|
|
530
|
+
const measured = entry.contentRect.width
|
|
531
|
+
// A zero is a box with no layout yet, not a measurement.
|
|
532
|
+
if (measured > 0) setAvailableSize(measured)
|
|
533
|
+
})
|
|
534
|
+
observer.observe(node)
|
|
535
|
+
return () => observer.disconnect()
|
|
536
|
+
}, [isPanel])
|
|
537
|
+
|
|
538
|
+
const panelBounds = useMemo(
|
|
539
|
+
() => panelBoundsFor(availableSize, panelReserve ?? DEFAULT_PANEL_RESERVE),
|
|
540
|
+
[availableSize, panelReserve],
|
|
541
|
+
)
|
|
542
|
+
// CLAMPED at render rather than written back to the host. A bound that moves
|
|
543
|
+
// with the viewport is not a reason to throw away a stored preference: shrink
|
|
544
|
+
// the window and the panel comes down to the ceiling, widen it again and the
|
|
545
|
+
// operator's own width returns.
|
|
546
|
+
const panelWidth = clamp(requestedPanel, panelBounds.min, panelBounds.max)
|
|
547
|
+
|
|
548
|
+
const setPanelWidth = useCallback(
|
|
549
|
+
(next: number) => {
|
|
550
|
+
if (!panelControlled) setInternalPanelSize(next)
|
|
551
|
+
onPanelSizeChange?.(next)
|
|
552
|
+
},
|
|
553
|
+
[panelControlled, onPanelSizeChange],
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const commitPanelWidth = useCallback(
|
|
557
|
+
(next: number) => {
|
|
558
|
+
onPanelSizeCommit?.(next)
|
|
559
|
+
},
|
|
560
|
+
[onPanelSizeCommit],
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
/* ---- which pane is collapsed, per orientation -------------------- */
|
|
564
|
+
|
|
565
|
+
const [internalCollapsed, setInternalCollapsed] = useState<
|
|
566
|
+
Record<DetailPaneSide, DetailCollapse>
|
|
567
|
+
>({ side: 'none', bottom: 'none' })
|
|
568
|
+
const collapseControlled = collapsed !== undefined
|
|
569
|
+
const collapse: DetailCollapse = collapseControlled ? collapsed : internalCollapsed[side]
|
|
570
|
+
|
|
571
|
+
const setCollapse = useCallback(
|
|
572
|
+
(next: DetailCollapse) => {
|
|
573
|
+
if (!collapseControlled) {
|
|
574
|
+
setInternalCollapsed((current) => ({ ...current, [side]: next }))
|
|
575
|
+
}
|
|
576
|
+
onCollapsedChange?.(next, side)
|
|
577
|
+
},
|
|
578
|
+
[collapseControlled, onCollapsedChange, side],
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
/* ---- collapsing moves focus to the rail it leaves behind ---------
|
|
582
|
+
*
|
|
583
|
+
* The collapse capsule lives INSIDE the divider, and the divider is
|
|
584
|
+
* `hidden` the moment a pane collapses — so the button the operator just
|
|
585
|
+
* pressed unmounts under their finger and focus falls to `<body>`. A
|
|
586
|
+
* keyboard user then has to Tab from the top of the document to get back
|
|
587
|
+
* to anything, and the thing they were doing (put a pane away, bring it
|
|
588
|
+
* back) is exactly the thing they can no longer reach.
|
|
589
|
+
*
|
|
590
|
+
* The `RestoreRail` mounts in the SAME commit the capsule unmounts, so the
|
|
591
|
+
* move cannot happen in the click handler — the rail's DOM node does not
|
|
592
|
+
* exist yet. It is a layout effect keyed on a bumped counter rather than on
|
|
593
|
+
* `collapse` itself, so a re-render for any other reason (a resize, a prop
|
|
594
|
+
* change) never steals focus, and collapsing twice to the same value still
|
|
595
|
+
* fires.
|
|
596
|
+
*
|
|
597
|
+
* `useLayoutEffect` and not `useEffect`: it runs before paint, so focus
|
|
598
|
+
* never visibly sits on `<body>` in between.
|
|
599
|
+
*/
|
|
600
|
+
const restoreRailRef = useRef<HTMLButtonElement | null>(null)
|
|
601
|
+
const [restoreFocusTick, setRestoreFocusTick] = useState(0)
|
|
602
|
+
const restoreFocusPending = useRef<'rail' | 'fields-capsule' | 'aside-capsule' | null>(
|
|
603
|
+
null,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
const collapseTo = useCallback(
|
|
607
|
+
(next: DetailCollapse) => {
|
|
608
|
+
// Both directions unmount the control that was pressed. Collapsing
|
|
609
|
+
// replaces the capsule with the rail; EXPANDING unmounts the rail and
|
|
610
|
+
// brings the capsules back — measured in the browser landing on
|
|
611
|
+
// <body>, exactly the same defect mirrored.
|
|
612
|
+
restoreFocusPending.current =
|
|
613
|
+
next !== 'none'
|
|
614
|
+
? 'rail'
|
|
615
|
+
: collapse === 'aside'
|
|
616
|
+
? 'aside-capsule'
|
|
617
|
+
: 'fields-capsule'
|
|
618
|
+
setCollapse(next)
|
|
619
|
+
setRestoreFocusTick((tick) => tick + 1)
|
|
620
|
+
},
|
|
621
|
+
[collapse, setCollapse],
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
useLayoutEffect(() => {
|
|
625
|
+
const target = restoreFocusPending.current
|
|
626
|
+
if (target === null) return
|
|
627
|
+
restoreFocusPending.current = null
|
|
628
|
+
if (target === 'rail') {
|
|
629
|
+
restoreRailRef.current?.focus()
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
// The capsules belong to `DetailDivider`, which mounts them in the same
|
|
633
|
+
// commit — scoped to THIS surface's split, in DOM order (fields, then
|
|
634
|
+
// activity), so a second detail surface on the page cannot be hit.
|
|
635
|
+
const capsules = splitRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
636
|
+
'[data-slot="detail-collapse-button"]',
|
|
637
|
+
)
|
|
638
|
+
const capsule = capsules?.[target === 'aside-capsule' ? 1 : 0]
|
|
639
|
+
capsule?.focus()
|
|
640
|
+
}, [restoreFocusTick])
|
|
641
|
+
|
|
642
|
+
// A deliberate size gesture — drag, arrow, Home — is also a statement that
|
|
643
|
+
// the operator wants both panes. Matching `ResizableSplit`'s `setMode('split')`.
|
|
644
|
+
const resizeTo = useCallback(
|
|
645
|
+
(next: number) => {
|
|
646
|
+
if (collapse !== 'none') setCollapse('none')
|
|
647
|
+
setSize(next)
|
|
648
|
+
},
|
|
649
|
+
[collapse, setCollapse, setSize],
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
/* ---- focus: in on open, back to the opener on close ------------- */
|
|
653
|
+
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
if (!open) return
|
|
656
|
+
const panel = panelRef.current
|
|
657
|
+
if (!panel) return
|
|
658
|
+
const active = document.activeElement
|
|
659
|
+
const previous =
|
|
660
|
+
active instanceof HTMLElement && active !== document.body ? active : null
|
|
661
|
+
const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
|
|
662
|
+
;(initial ?? panel).focus()
|
|
663
|
+
return () => {
|
|
664
|
+
// The half that gets forgotten. Without it, closing drops the caret to
|
|
665
|
+
// <body> and the next Tab restarts from the top of the page.
|
|
666
|
+
if (previous && previous.isConnected) previous.focus()
|
|
667
|
+
}
|
|
668
|
+
}, [open])
|
|
669
|
+
|
|
670
|
+
/* ---- Escape, through the shared stack --------------------------- */
|
|
671
|
+
|
|
672
|
+
// `modal` for the two centred modes so the Tab trap's topmost-MODAL guard
|
|
673
|
+
// can find it; `popover` for the side panel, which is non-modal and must
|
|
674
|
+
// let a select opened inside it answer Escape first.
|
|
675
|
+
const layer = useLayer({
|
|
676
|
+
enabled: open,
|
|
677
|
+
kind: isModal ? 'modal' : 'popover',
|
|
678
|
+
elementRef: panelRef,
|
|
679
|
+
onEscape: () => onClose(),
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
/* ---- Tab trap, modal modes only --------------------------------- */
|
|
683
|
+
|
|
684
|
+
useEffect(() => {
|
|
685
|
+
if (!open || !isModal) return
|
|
686
|
+
const panel = panelRef.current
|
|
687
|
+
if (!panel) return
|
|
688
|
+
|
|
689
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
690
|
+
if (event.key !== 'Tab') return
|
|
691
|
+
if (!layer.isTopOfKind()) return
|
|
692
|
+
const active = document.activeElement
|
|
693
|
+
// Focus outside the panel is almost always a body-level portal (a
|
|
694
|
+
// menu, a popover) opened FROM it. Wrapping that back in would break
|
|
695
|
+
// the portal's own keyboard handling.
|
|
696
|
+
if (!(active instanceof HTMLElement) || !panel.contains(active)) return
|
|
697
|
+
|
|
698
|
+
const tabbables = tabbablesWithin(panel)
|
|
699
|
+
const first = tabbables[0]
|
|
700
|
+
const last = tabbables[tabbables.length - 1]
|
|
701
|
+
if (!first || !last) {
|
|
702
|
+
event.preventDefault()
|
|
703
|
+
panel.focus()
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (active === panel) {
|
|
707
|
+
if (event.shiftKey) {
|
|
708
|
+
event.preventDefault()
|
|
709
|
+
last.focus()
|
|
710
|
+
}
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
if (event.shiftKey && active === first) {
|
|
714
|
+
event.preventDefault()
|
|
715
|
+
last.focus()
|
|
716
|
+
} else if (!event.shiftKey && active === last) {
|
|
717
|
+
event.preventDefault()
|
|
718
|
+
first.focus()
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
document.addEventListener('keydown', onKeyDown)
|
|
723
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
724
|
+
}, [open, isModal, layer])
|
|
725
|
+
|
|
726
|
+
const backdropLit = open && isModal
|
|
727
|
+
|
|
728
|
+
/* ---- the split's geometry ---------------------------------------- */
|
|
729
|
+
|
|
730
|
+
const hasAside = aside !== undefined
|
|
731
|
+
const fieldsCollapsed = hasAside && collapse === 'fields'
|
|
732
|
+
const asideCollapsed = !hasAside || collapse === 'aside'
|
|
733
|
+
const floor = FIELDS_MIN[side]
|
|
734
|
+
|
|
735
|
+
// The size the fields pane is holding right now. When it collapses, its
|
|
736
|
+
// INNER box is pinned here so its content does not reflow to zero width and
|
|
737
|
+
// take the scroll position with it.
|
|
738
|
+
const railShowing = hasAside && collapse !== 'none'
|
|
739
|
+
const fieldsFrozen = Math.max(
|
|
740
|
+
floor,
|
|
741
|
+
containerSize === null
|
|
742
|
+
? floor
|
|
743
|
+
: containerSize - DIVIDER_SIZE - size - (railShowing ? RESTORE_RAIL_SIZE : 0),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
/** Along the split axis; the cross axis is always released to 0. */
|
|
747
|
+
const axis = (along: CSSProperties['inlineSize']): CSSProperties =>
|
|
748
|
+
vertical
|
|
749
|
+
? { inlineSize: along, minInlineSize: along, minBlockSize: 0 }
|
|
750
|
+
: { blockSize: along, minBlockSize: along, minInlineSize: 0 }
|
|
751
|
+
|
|
752
|
+
const fieldsClipStyle: CSSProperties = fieldsCollapsed
|
|
753
|
+
? { flex: '0 0 0px', ...axis(0), overflow: 'hidden' }
|
|
754
|
+
: {
|
|
755
|
+
flex: '1 1 0%',
|
|
756
|
+
...(vertical
|
|
757
|
+
? { minInlineSize: `${floor}px`, minBlockSize: 0 }
|
|
758
|
+
: { minBlockSize: `${floor}px`, minInlineSize: 0 }),
|
|
759
|
+
overflow: 'hidden',
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const fieldsInnerStyle: CSSProperties | undefined = fieldsCollapsed
|
|
763
|
+
? {
|
|
764
|
+
flex: '0 0 auto',
|
|
765
|
+
...(vertical
|
|
766
|
+
? { inlineSize: `${fieldsFrozen}px`, blockSize: '100%' }
|
|
767
|
+
: { blockSize: `${fieldsFrozen}px`, inlineSize: '100%' }),
|
|
768
|
+
}
|
|
769
|
+
: undefined
|
|
770
|
+
|
|
771
|
+
/** Fill what is left, along either axis. */
|
|
772
|
+
const fill: CSSProperties = {
|
|
773
|
+
flex: '1 1 0%',
|
|
774
|
+
minInlineSize: 0,
|
|
775
|
+
minBlockSize: 0,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const asideClipStyle: CSSProperties = asideCollapsed
|
|
779
|
+
? { flex: '0 0 0px', ...axis(0), overflow: 'hidden' }
|
|
780
|
+
: fieldsCollapsed
|
|
781
|
+
? // The other pane is away, so this one takes the room. Without this the
|
|
782
|
+
// aside sat at its stored 400px with the rest of the panel blank.
|
|
783
|
+
{ ...fill, overflow: 'hidden' }
|
|
784
|
+
: { flex: '0 0 auto', ...axis(`${size}px`), overflow: 'hidden' }
|
|
785
|
+
|
|
786
|
+
// Explicit while the pane is the one that may be COLLAPSED — that is what
|
|
787
|
+
// keeps its content laid out at the width it had while its clip is at zero,
|
|
788
|
+
// and therefore what keeps its scroll position. When the OTHER pane is the
|
|
789
|
+
// collapsed one this pane is on screen and simply fills.
|
|
790
|
+
const asideInnerStyle: CSSProperties = fieldsCollapsed
|
|
791
|
+
? fill
|
|
792
|
+
: {
|
|
793
|
+
flex: '0 0 auto',
|
|
794
|
+
...(vertical
|
|
795
|
+
? { inlineSize: `${size}px`, blockSize: '100%' }
|
|
796
|
+
: { blockSize: `${size}px`, inlineSize: '100%' }),
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// `panel` is the only mode whose inline size is the operator's to set, so it
|
|
800
|
+
// is the only one whose geometry is not the table's verbatim.
|
|
801
|
+
const geometry: CSSProperties = isPanel
|
|
802
|
+
? { ...PANEL_GEOMETRY.panel, inlineSize: `${panelWidth}px` }
|
|
803
|
+
: PANEL_GEOMETRY[mode]
|
|
804
|
+
|
|
805
|
+
// The chevron points the way the pane will come BACK — outward from the
|
|
806
|
+
// rail, which is the edge it went behind.
|
|
807
|
+
const RestoreFieldsIcon = vertical ? ChevronsRight : ChevronsDown
|
|
808
|
+
const RestoreAsideIcon = vertical ? ChevronsLeft : ChevronsUp
|
|
809
|
+
|
|
810
|
+
return (
|
|
811
|
+
<div
|
|
812
|
+
ref={wrapperRef}
|
|
813
|
+
data-slot="detail-surface"
|
|
814
|
+
data-mode={mode}
|
|
815
|
+
data-state={open ? 'open' : 'closed'}
|
|
816
|
+
className={cn(
|
|
817
|
+
'fixed inset-0 z-50 flex',
|
|
818
|
+
mode === 'panel' ? 'items-stretch justify-end' : 'items-center justify-center',
|
|
819
|
+
)}
|
|
820
|
+
// The wrapper covers the viewport in every mode so the panel can be
|
|
821
|
+
// positioned against it, but only the backdrop and the panel take
|
|
822
|
+
// pointer events — otherwise `panel` mode would swallow every click on
|
|
823
|
+
// the board it is meant to leave usable.
|
|
824
|
+
style={{ padding: WRAPPER_PADDING[mode], pointerEvents: 'none' }}
|
|
825
|
+
>
|
|
826
|
+
{/* Backdrop. Present in every mode and driven by opacity, so switching
|
|
827
|
+
from `panel` to `full` fades one in rather than popping it. It is
|
|
828
|
+
translucent by token (`--modal-overlay`), which is what leaves the
|
|
829
|
+
board faintly visible behind `full`. */}
|
|
830
|
+
<div
|
|
831
|
+
aria-hidden="true"
|
|
832
|
+
data-slot="detail-backdrop"
|
|
833
|
+
onClick={onClose}
|
|
834
|
+
className="absolute inset-0 transition-opacity duration-[var(--dur-base)] ease-[var(--ease-out)] motion-reduce:transition-none"
|
|
835
|
+
style={{
|
|
836
|
+
background: 'rgb(var(--modal-overlay))',
|
|
837
|
+
opacity: backdropLit ? 1 : 0,
|
|
838
|
+
pointerEvents: backdropLit ? 'auto' : 'none',
|
|
839
|
+
}}
|
|
840
|
+
/>
|
|
841
|
+
|
|
842
|
+
<div
|
|
843
|
+
ref={panelRef}
|
|
844
|
+
role="dialog"
|
|
845
|
+
aria-label={ariaLabel}
|
|
846
|
+
{...(isModal ? { 'aria-modal': true } : {})}
|
|
847
|
+
tabIndex={-1}
|
|
848
|
+
inert={!open}
|
|
849
|
+
// `inert` already takes the closed panel out of the accessibility
|
|
850
|
+
// tree and the tab ring in a browser. `aria-hidden` says the same
|
|
851
|
+
// thing to everything that does NOT implement `inert` — older AT, and
|
|
852
|
+
// every testing-library query, which is not a footnote: an
|
|
853
|
+
// always-mounted dialog that stays visible to `getByRole('dialog')`
|
|
854
|
+
// makes the NEXT dialog on the page ambiguous, and the host's own
|
|
855
|
+
// tests start failing on a component they never touched. Safe
|
|
856
|
+
// together here because nothing inside can hold focus while closed:
|
|
857
|
+
// the same `open` that sets this drives the focus effect.
|
|
858
|
+
aria-hidden={open ? undefined : true}
|
|
859
|
+
data-slot="detail-panel"
|
|
860
|
+
data-mode={mode}
|
|
861
|
+
className={cn(
|
|
862
|
+
'ds-card-surface relative flex min-h-0 flex-col overflow-hidden',
|
|
863
|
+
'transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-out)]',
|
|
864
|
+
'motion-reduce:transition-none',
|
|
865
|
+
// The panel takes focus on open (`tabIndex={-1}` + the focus
|
|
866
|
+
// effect below), so `focus:outline-none` with nothing in its
|
|
867
|
+
// place left the first keyboard landing spot in a dialog with no
|
|
868
|
+
// visible cue at all. Same treatment as every other control in
|
|
869
|
+
// the package: kill the UA outline on the `focus-visible` state
|
|
870
|
+
// only, and replace it with the ring. Forced-colors restores a
|
|
871
|
+
// real outline (styles.css).
|
|
872
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
873
|
+
open ? 'opacity-100' : 'opacity-0',
|
|
874
|
+
!open && (mode === 'panel' ? 'translate-x-2' : 'translate-y-2'),
|
|
875
|
+
className,
|
|
876
|
+
)}
|
|
877
|
+
// `pointerEvents` is INLINE, not a utility class. The wrapper sets
|
|
878
|
+
// `none` so `panel` mode leaves the board clickable, and the panel has
|
|
879
|
+
// to opt back in — through the one channel that cannot be lost to a
|
|
880
|
+
// missing Tailwind `@source` (CLAUDE.md §2: build-clean but visually
|
|
881
|
+
// broken). Whether a dialog can be clicked is behaviour, not styling.
|
|
882
|
+
style={{
|
|
883
|
+
...geometry,
|
|
884
|
+
borderRadius: 'var(--radius-xl)',
|
|
885
|
+
pointerEvents: open ? 'auto' : 'none',
|
|
886
|
+
}}
|
|
887
|
+
>
|
|
888
|
+
{/* The panel's OWN edge, and only in `panel` mode. `full` and `modal`
|
|
889
|
+
are centred overlays already sized against the viewport, so an edge
|
|
890
|
+
handle on them would be a control with nothing to say.
|
|
891
|
+
|
|
892
|
+
It is `ResizableHandle` itself, not a third implementation of a
|
|
893
|
+
drag: the WAI-ARIA splitter role, `aria-valuenow/min/max`, the
|
|
894
|
+
arrows, `Home` and the double-click reset all come from there, the
|
|
895
|
+
same as the inner divider's. What differs is that this one needs no
|
|
896
|
+
strip of its own — the panel's border already draws the seam — so
|
|
897
|
+
it is absolute on the leading edge, offset by `HANDLE_HIT_PAD` so
|
|
898
|
+
the whole 8px target lands inside the panel's `overflow: hidden`
|
|
899
|
+
rather than half outside it. */}
|
|
900
|
+
{isPanel ? (
|
|
901
|
+
<div
|
|
902
|
+
data-slot="detail-panel-resize"
|
|
903
|
+
className="absolute z-[2] w-1"
|
|
904
|
+
style={{ insetBlock: 0, insetInlineStart: `${HANDLE_HIT_PAD}px` }}
|
|
905
|
+
>
|
|
906
|
+
<ResizableHandle
|
|
907
|
+
side="left"
|
|
908
|
+
value={panelWidth}
|
|
909
|
+
onChange={setPanelWidth}
|
|
910
|
+
onCommit={commitPanelWidth}
|
|
911
|
+
min={panelBounds.min}
|
|
912
|
+
max={panelBounds.max}
|
|
913
|
+
defaultValue={clamp(
|
|
914
|
+
DETAIL_PANEL_DEFAULT_SIZE,
|
|
915
|
+
panelBounds.min,
|
|
916
|
+
panelBounds.max,
|
|
917
|
+
)}
|
|
918
|
+
aria-label={panelResizeLabel}
|
|
919
|
+
// A RING, not the handle's own fill: this edge sits over the
|
|
920
|
+
// panel's border rather than between two panes, and a 4px tint
|
|
921
|
+
// there is not a focus indicator anyone can find.
|
|
922
|
+
className="focus-visible:[box-shadow:var(--ring-focus)]"
|
|
923
|
+
/>
|
|
924
|
+
</div>
|
|
925
|
+
) : null}
|
|
926
|
+
|
|
927
|
+
{header === undefined ? null : header}
|
|
928
|
+
|
|
929
|
+
<div
|
|
930
|
+
ref={splitRef}
|
|
931
|
+
data-slot="detail-split"
|
|
932
|
+
data-orientation={vertical ? 'side' : 'bottom'}
|
|
933
|
+
data-collapsed={collapse}
|
|
934
|
+
className={cn(
|
|
935
|
+
'relative flex min-h-0 flex-1',
|
|
936
|
+
vertical ? 'flex-row' : 'flex-col',
|
|
937
|
+
)}
|
|
938
|
+
>
|
|
939
|
+
{/* The way back, and it is IN FLOW on the edge the pane went behind
|
|
940
|
+
— a pane the operator cannot restore is a trap, and a floating
|
|
941
|
+
pill over the field list's own toolbar is a different bug. */}
|
|
942
|
+
{fieldsCollapsed ? (
|
|
943
|
+
<RestoreRail
|
|
944
|
+
buttonRef={restoreRailRef}
|
|
945
|
+
vertical={vertical}
|
|
946
|
+
edge="start"
|
|
947
|
+
label="Show the fields pane"
|
|
948
|
+
onClick={() => collapseTo('none')}
|
|
949
|
+
>
|
|
950
|
+
<RestoreFieldsIcon size={14} strokeWidth={1.5} aria-hidden="true" />
|
|
951
|
+
</RestoreRail>
|
|
952
|
+
) : null}
|
|
953
|
+
|
|
954
|
+
{/* Outer box collapses; inner box keeps its size. See the header. */}
|
|
955
|
+
<div
|
|
956
|
+
data-slot="detail-fields-clip"
|
|
957
|
+
data-detail-clip=""
|
|
958
|
+
className="flex"
|
|
959
|
+
style={fieldsClipStyle}
|
|
960
|
+
inert={fieldsCollapsed}
|
|
961
|
+
// Same policy as the closed dialog above, and for the same reason:
|
|
962
|
+
// `inert` is honoured by browsers, `aria-hidden` is what every
|
|
963
|
+
// non-`inert`-aware AT and every testing-library query reads. A
|
|
964
|
+
// collapsed pane is clipped to zero, so leaving it in the
|
|
965
|
+
// accessibility tree means a screen reader still walks fields the
|
|
966
|
+
// operator has put away. Safe together because nothing inside can
|
|
967
|
+
// hold focus while collapsed — collapsing moves focus to the
|
|
968
|
+
// restore rail (see `collapseTo`).
|
|
969
|
+
aria-hidden={fieldsCollapsed ? true : undefined}
|
|
970
|
+
>
|
|
971
|
+
<div
|
|
972
|
+
data-slot="detail-fields"
|
|
973
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
|
974
|
+
{...(fieldsInnerStyle === undefined ? {} : { style: fieldsInnerStyle })}
|
|
975
|
+
>
|
|
976
|
+
{children}
|
|
977
|
+
</div>
|
|
978
|
+
</div>
|
|
979
|
+
|
|
980
|
+
{aside === undefined ? null : (
|
|
981
|
+
<>
|
|
982
|
+
<DetailDivider
|
|
983
|
+
orientation={side}
|
|
984
|
+
value={size}
|
|
985
|
+
onChange={resizeTo}
|
|
986
|
+
onCommit={commitSize}
|
|
987
|
+
min={bounds.min}
|
|
988
|
+
max={bounds.max}
|
|
989
|
+
defaultValue={DEFAULT_ASIDE_SIZE[side]}
|
|
990
|
+
aria-label={dividerLabel}
|
|
991
|
+
hidden={collapse !== 'none'}
|
|
992
|
+
onCollapseFields={() => collapseTo('fields')}
|
|
993
|
+
onCollapseAside={() => collapseTo('aside')}
|
|
994
|
+
onToggleCollapse={() =>
|
|
995
|
+
collapseTo(collapse === 'none' ? 'aside' : 'none')
|
|
996
|
+
}
|
|
997
|
+
/>
|
|
998
|
+
<div
|
|
999
|
+
data-slot="detail-activity-clip"
|
|
1000
|
+
data-detail-clip=""
|
|
1001
|
+
className="flex"
|
|
1002
|
+
style={asideClipStyle}
|
|
1003
|
+
inert={asideCollapsed}
|
|
1004
|
+
// See the fields pane above — same policy, same reason.
|
|
1005
|
+
aria-hidden={asideCollapsed ? true : undefined}
|
|
1006
|
+
>
|
|
1007
|
+
<div
|
|
1008
|
+
data-slot="detail-activity"
|
|
1009
|
+
className="flex min-h-0 min-w-0 flex-col"
|
|
1010
|
+
style={asideInnerStyle}
|
|
1011
|
+
>
|
|
1012
|
+
{aside}
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
|
|
1016
|
+
{collapse === 'aside' ? (
|
|
1017
|
+
<RestoreRail
|
|
1018
|
+
buttonRef={restoreRailRef}
|
|
1019
|
+
vertical={vertical}
|
|
1020
|
+
edge="end"
|
|
1021
|
+
label="Show the activity pane"
|
|
1022
|
+
onClick={() => collapseTo('none')}
|
|
1023
|
+
>
|
|
1024
|
+
<RestoreAsideIcon size={14} strokeWidth={1.5} aria-hidden="true" />
|
|
1025
|
+
</RestoreRail>
|
|
1026
|
+
) : null}
|
|
1027
|
+
</>
|
|
1028
|
+
)}
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The rail a collapsed pane leaves behind.
|
|
1037
|
+
*
|
|
1038
|
+
* The whole rail IS the button — the same trick the thread's collapse rail
|
|
1039
|
+
* uses: a 1px hairline is the right VISUAL weight for a seam and a hopeless
|
|
1040
|
+
* target, so the target is 24px wide and the hairline is pinned inside it,
|
|
1041
|
+
* leaving 23px of it transparent.
|
|
1042
|
+
*/
|
|
1043
|
+
function RestoreRail({
|
|
1044
|
+
buttonRef,
|
|
1045
|
+
vertical,
|
|
1046
|
+
edge,
|
|
1047
|
+
label,
|
|
1048
|
+
onClick,
|
|
1049
|
+
children,
|
|
1050
|
+
}: {
|
|
1051
|
+
/** The surface parks focus here after a collapse — see `collapseTo`. Only
|
|
1052
|
+
* one rail is ever mounted (`collapse` is one of three exclusive values),
|
|
1053
|
+
* so both call sites can share a single ref. */
|
|
1054
|
+
buttonRef?: RefObject<HTMLButtonElement | null> | undefined
|
|
1055
|
+
vertical: boolean
|
|
1056
|
+
edge: 'start' | 'end'
|
|
1057
|
+
label: string
|
|
1058
|
+
onClick: () => void
|
|
1059
|
+
children: ReactNode
|
|
1060
|
+
}) {
|
|
1061
|
+
return (
|
|
1062
|
+
<button
|
|
1063
|
+
type="button"
|
|
1064
|
+
ref={buttonRef}
|
|
1065
|
+
data-slot="detail-restore"
|
|
1066
|
+
data-edge={edge}
|
|
1067
|
+
onClick={onClick}
|
|
1068
|
+
aria-label={label}
|
|
1069
|
+
title={label}
|
|
1070
|
+
className={cn(
|
|
1071
|
+
'relative flex shrink-0 items-center justify-center',
|
|
1072
|
+
'transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
1073
|
+
'motion-reduce:transition-none',
|
|
1074
|
+
'hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
|
|
1075
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
1076
|
+
)}
|
|
1077
|
+
style={{
|
|
1078
|
+
...(vertical
|
|
1079
|
+
? { inlineSize: `${RESTORE_RAIL_SIZE}px`, alignSelf: 'stretch' }
|
|
1080
|
+
: { blockSize: `${RESTORE_RAIL_SIZE}px`, inlineSize: '100%' }),
|
|
1081
|
+
color: 'rgb(var(--text-tertiary))',
|
|
1082
|
+
}}
|
|
1083
|
+
>
|
|
1084
|
+
{/* The seam keeps reading as a seam while the pane is away. */}
|
|
1085
|
+
<span
|
|
1086
|
+
aria-hidden="true"
|
|
1087
|
+
className="pointer-events-none absolute"
|
|
1088
|
+
style={{
|
|
1089
|
+
background: 'rgb(var(--border))',
|
|
1090
|
+
...(vertical
|
|
1091
|
+
? {
|
|
1092
|
+
insetBlock: 0,
|
|
1093
|
+
inlineSize: '1px',
|
|
1094
|
+
...(edge === 'end'
|
|
1095
|
+
? { insetInlineStart: 0 }
|
|
1096
|
+
: { insetInlineEnd: 0 }),
|
|
1097
|
+
}
|
|
1098
|
+
: {
|
|
1099
|
+
insetInline: 0,
|
|
1100
|
+
blockSize: '1px',
|
|
1101
|
+
...(edge === 'end'
|
|
1102
|
+
? { insetBlockStart: 0 }
|
|
1103
|
+
: { insetBlockEnd: 0 }),
|
|
1104
|
+
}),
|
|
1105
|
+
}}
|
|
1106
|
+
/>
|
|
1107
|
+
{children}
|
|
1108
|
+
</button>
|
|
1109
|
+
)
|
|
1110
|
+
}
|