@lovett/ui 0.0.10 → 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 +6064 -80
- package/dist/index.js +21561 -9696
- 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 +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- 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__/combobox.test.tsx +677 -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 +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -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/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- 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/combobox.tsx +796 -0
- 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 +412 -151
- 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 +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- 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/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- 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 +121 -0
- package/src/tooltip.tsx +297 -0
package/src/markdown.tsx
CHANGED
|
@@ -18,7 +18,8 @@ import type {
|
|
|
18
18
|
} from 'react'
|
|
19
19
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
20
20
|
import remarkGfm from 'remark-gfm'
|
|
21
|
-
import { MarkdownCode } from './code-block'
|
|
21
|
+
import { MarkdownCode, MarkdownPre } from './code-block'
|
|
22
|
+
import { remarkUnderline } from './lib/remark-underline'
|
|
22
23
|
|
|
23
24
|
type NodeProps = HTMLAttributes<HTMLElement>
|
|
24
25
|
type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>
|
|
@@ -93,10 +94,11 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
93
94
|
{children}
|
|
94
95
|
</em>
|
|
95
96
|
),
|
|
96
|
-
// code: inline chip or
|
|
97
|
-
//
|
|
97
|
+
// code: inline chip or code block (CodeBlock via MarkdownCode). pre renders
|
|
98
|
+
// no element — CodeBlock owns its container — but it is what tells the code
|
|
99
|
+
// inside it that it is a block, language or not (see PreContext).
|
|
98
100
|
code: MarkdownCode,
|
|
99
|
-
pre:
|
|
101
|
+
pre: MarkdownPre,
|
|
100
102
|
blockquote: ({ children }: NodeProps) => (
|
|
101
103
|
<blockquote
|
|
102
104
|
className="my-4 border-l-4 py-1 pl-4 italic"
|
|
@@ -169,6 +171,19 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
169
171
|
{children}
|
|
170
172
|
</del>
|
|
171
173
|
),
|
|
174
|
+
// Underline — `++text++` via remarkUnderline (ADR-148 D13). The line is a
|
|
175
|
+
// step softer than the ink so it reads as a deliberate underline, not a link.
|
|
176
|
+
u: ({ children }: NodeProps) => (
|
|
177
|
+
<u
|
|
178
|
+
className="underline-offset-2"
|
|
179
|
+
style={{
|
|
180
|
+
color: 'rgb(var(--foreground))',
|
|
181
|
+
textDecorationColor: 'rgb(var(--text-tertiary))',
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</u>
|
|
186
|
+
),
|
|
172
187
|
// Task-list checkboxes rendered by remark-gfm for "- [ ] ..." / "- [x] ...".
|
|
173
188
|
input: (props: InputHTMLAttributes<HTMLInputElement>) =>
|
|
174
189
|
props.type === 'checkbox' ? (
|
|
@@ -183,7 +198,10 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
183
198
|
),
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
|
|
201
|
+
// THE remark plugin list. Every markdown surface in the package parses with
|
|
202
|
+
// this — `CommentBody` imports it rather than keeping its own, so GFM and the
|
|
203
|
+
// `++text++` underline (ADR-148 D13) cannot drift between surfaces.
|
|
204
|
+
const REMARK_PLUGINS = [remarkGfm, remarkUnderline]
|
|
187
205
|
|
|
188
206
|
export interface MarkdownRendererProps {
|
|
189
207
|
/** Raw markdown string to render. */
|
|
@@ -210,5 +228,6 @@ export function MarkdownRenderer({ children, className }: MarkdownRendererProps)
|
|
|
210
228
|
)
|
|
211
229
|
}
|
|
212
230
|
|
|
213
|
-
// Re-export for callers that
|
|
214
|
-
|
|
231
|
+
// Re-export for callers that drive ReactMarkdown themselves (CommentBody):
|
|
232
|
+
// the raw component map and the plugin list, so both surfaces stay one.
|
|
233
|
+
export { MARKDOWN_COMPONENTS as markdownComponents, REMARK_PLUGINS as markdownRemarkPlugins }
|
package/src/metric-card.tsx
CHANGED
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
* Token discipline: all colors via public tokens. Tone variant gives
|
|
12
12
|
* the value text an accent/success/warning/destructive/info color;
|
|
13
13
|
* background is always neutral surface-overlay.
|
|
14
|
+
*
|
|
15
|
+
* `copyable` (ADR-145 amendment, 2026-09-04): the card becomes a button
|
|
16
|
+
* that copies `copyValue` (or the string `value`) and toasts
|
|
17
|
+
* "Copied <label>". Same hover/focus affordance as ValueChip and the
|
|
18
|
+
* copyable StatRow. Inert by default so a read-only KPI never advertises
|
|
19
|
+
* an interaction it doesn't have.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import type { ReactNode } from 'react'
|
|
23
|
+
import { cn } from './lib/utils'
|
|
24
|
+
import { copyText } from './lib/clipboard'
|
|
17
25
|
|
|
18
26
|
export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
|
|
19
27
|
|
|
@@ -46,6 +54,11 @@ export interface MetricCardProps {
|
|
|
46
54
|
labelPosition?: 'top' | 'bottom'
|
|
47
55
|
/** Optional className override for the outer container. */
|
|
48
56
|
className?: string
|
|
57
|
+
/** Make the whole card a copy-to-clipboard button. */
|
|
58
|
+
copyable?: boolean
|
|
59
|
+
/** What a copyable card writes. Defaults to `value` when it is a string;
|
|
60
|
+
* REQUIRED when `value` is a node (there is nothing else to copy). */
|
|
61
|
+
copyValue?: string
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export function MetricCard({
|
|
@@ -55,6 +68,8 @@ export function MetricCard({
|
|
|
55
68
|
icon,
|
|
56
69
|
labelPosition = 'bottom',
|
|
57
70
|
className,
|
|
71
|
+
copyable = false,
|
|
72
|
+
copyValue,
|
|
58
73
|
}: MetricCardProps) {
|
|
59
74
|
const labelEl =
|
|
60
75
|
labelPosition === 'top' ? (
|
|
@@ -89,29 +104,49 @@ export function MetricCard({
|
|
|
89
104
|
</div>
|
|
90
105
|
)
|
|
91
106
|
|
|
107
|
+
const body =
|
|
108
|
+
labelPosition === 'top' ? (
|
|
109
|
+
<>
|
|
110
|
+
{labelEl}
|
|
111
|
+
{valueEl}
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
{valueEl}
|
|
116
|
+
{labelEl}
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
const surfaceClass = className ?? 'flex flex-col gap-1 px-4 py-3 border'
|
|
120
|
+
const surfaceStyle = {
|
|
121
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
122
|
+
borderColor: 'rgb(var(--border))',
|
|
123
|
+
borderRadius: 'var(--radius-lg)',
|
|
124
|
+
} as const
|
|
125
|
+
|
|
126
|
+
const text = copyValue ?? (typeof value === 'string' ? value : undefined)
|
|
127
|
+
if (copyable && text !== undefined) {
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => void copyText(text, label)}
|
|
132
|
+
title={`Copy ${label}`}
|
|
133
|
+
aria-label={`Copy ${label}: ${text}`}
|
|
134
|
+
className={cn(
|
|
135
|
+
surfaceClass,
|
|
136
|
+
'text-left cursor-pointer transition-[border-color,box-shadow]',
|
|
137
|
+
'hover:border-[rgb(var(--border-strong))] hover:[box-shadow:var(--shadow-sm)]',
|
|
138
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
139
|
+
)}
|
|
140
|
+
style={surfaceStyle}
|
|
141
|
+
>
|
|
142
|
+
{body}
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
return (
|
|
93
|
-
<div
|
|
94
|
-
|
|
95
|
-
className ??
|
|
96
|
-
'flex flex-col gap-1 px-4 py-3 border'
|
|
97
|
-
}
|
|
98
|
-
style={{
|
|
99
|
-
background: 'rgb(var(--surface-overlay-soft))',
|
|
100
|
-
borderColor: 'rgb(var(--border))',
|
|
101
|
-
borderRadius: 'var(--radius-lg)',
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{labelPosition === 'top' ? (
|
|
105
|
-
<>
|
|
106
|
-
{labelEl}
|
|
107
|
-
{valueEl}
|
|
108
|
-
</>
|
|
109
|
-
) : (
|
|
110
|
-
<>
|
|
111
|
-
{valueEl}
|
|
112
|
-
{labelEl}
|
|
113
|
-
</>
|
|
114
|
-
)}
|
|
148
|
+
<div className={surfaceClass} style={surfaceStyle}>
|
|
149
|
+
{body}
|
|
115
150
|
</div>
|
|
116
151
|
)
|
|
117
152
|
}
|
package/src/modal.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from 'react'
|
|
11
11
|
import { X } from 'lucide-react'
|
|
12
12
|
import { cn } from './lib/utils'
|
|
13
|
+
import { tabbablesWithin } from './lib/focus'
|
|
14
|
+
import { useLayer } from './lib/layer-stack'
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Modal — a centered floating panel on top of a backdrop. Built on
|
|
@@ -56,47 +58,25 @@ import { cn } from './lib/utils'
|
|
|
56
58
|
* the `.cs-frame`-scoped token layer and out of their current stacking /
|
|
57
59
|
* inheritance context, which is a much larger change than an a11y fix
|
|
58
60
|
* should make. Inerting `document.body`'s other children is also wrong:
|
|
59
|
-
* `DropdownMenu`, `TagChipInput` and `FolderTreePicker` portal
|
|
60
|
-
* content THERE, so a select inside a modal would go dead. Focus trap
|
|
61
|
-
* is — the keyboard hole is closed; pointer and AT virtual-cursor access
|
|
61
|
+
* `DropdownMenu`, `Popover`, `TagChipInput` and `FolderTreePicker` portal
|
|
62
|
+
* their content THERE, so a select inside a modal would go dead. Focus trap
|
|
63
|
+
* it is — the keyboard hole is closed; pointer and AT virtual-cursor access
|
|
62
64
|
* to the background remains, which is the documented residual.
|
|
65
|
+
*
|
|
66
|
+
* Escape and dismiss order come from the shared layer stack
|
|
67
|
+
* (`lib/layer-stack.ts`): the topmost open surface — modal, menu or
|
|
68
|
+
* popover — answers Escape alone.
|
|
63
69
|
*/
|
|
64
70
|
|
|
65
71
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* Tabbable candidates. Deliberately attribute-only — no geometry check.
|
|
74
|
-
* `offsetWidth` / `getClientRects()` are always zero in jsdom, so a
|
|
75
|
-
* visibility filter would empty this list under test and silently
|
|
76
|
-
* disable the trap in exactly the environment that verifies it.
|
|
72
|
+
* Dismiss order lives in the shared layer stack (`lib/layer-stack.ts`),
|
|
73
|
+
* which this file used to own as a module-level `openPanels` array. Only
|
|
74
|
+
* the topmost layer answers Escape, so nested modals do not both close on
|
|
75
|
+
* one keypress — and now a DropdownMenu or Popover opened from inside a
|
|
76
|
+
* modal closes on its own first, leaving the modal open. The Tab trap asks
|
|
77
|
+
* for the topmost MODAL (`isTopOfKind`), so a popover above it does not
|
|
78
|
+
* switch the trap off.
|
|
77
79
|
*/
|
|
78
|
-
const FOCUSABLE_SELECTOR = [
|
|
79
|
-
'a[href]',
|
|
80
|
-
'area[href]',
|
|
81
|
-
'button:not([disabled])',
|
|
82
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
83
|
-
'select:not([disabled])',
|
|
84
|
-
'textarea:not([disabled])',
|
|
85
|
-
'iframe',
|
|
86
|
-
'summary',
|
|
87
|
-
'[contenteditable="true"]',
|
|
88
|
-
'[tabindex]',
|
|
89
|
-
].join(',')
|
|
90
|
-
|
|
91
|
-
function tabbablesWithin(panel: HTMLElement): HTMLElement[] {
|
|
92
|
-
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
93
|
-
(el) =>
|
|
94
|
-
el.tabIndex >= 0 &&
|
|
95
|
-
!el.hasAttribute('inert') &&
|
|
96
|
-
el.getAttribute('aria-hidden') !== 'true' &&
|
|
97
|
-
!el.closest('[hidden]'),
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
80
|
|
|
101
81
|
interface ModalProps {
|
|
102
82
|
isOpen: boolean
|
|
@@ -109,9 +89,31 @@ interface ModalProps {
|
|
|
109
89
|
* when `title` is non-empty — the `<h2>` names the dialog then.
|
|
110
90
|
*/
|
|
111
91
|
ariaLabel?: string | undefined
|
|
92
|
+
/**
|
|
93
|
+
* Suppress the built-in header row (title + close) so the CALLER owns the
|
|
94
|
+
* whole chrome.
|
|
95
|
+
*
|
|
96
|
+
* Exists for one shape: a content component that must render identically
|
|
97
|
+
* inside `Modal` and `FloatingDrawer` (the Tasks item detail — one content
|
|
98
|
+
* component, two presentations). Without it that content carries its own
|
|
99
|
+
* header and the modal stacks a second, near-empty one with a second close
|
|
100
|
+
* button above it.
|
|
101
|
+
*
|
|
102
|
+
* The dialog still needs a NAME: pass `ariaLabel`, because there is no
|
|
103
|
+
* `<h2>` left to point `aria-labelledby` at.
|
|
104
|
+
*/
|
|
105
|
+
hideHeader?: boolean | undefined
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
function Modal({
|
|
108
|
+
function Modal({
|
|
109
|
+
isOpen,
|
|
110
|
+
onClose,
|
|
111
|
+
title,
|
|
112
|
+
children,
|
|
113
|
+
className,
|
|
114
|
+
ariaLabel,
|
|
115
|
+
hideHeader = false,
|
|
116
|
+
}: ModalProps) {
|
|
115
117
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
116
118
|
const titleId = useId()
|
|
117
119
|
|
|
@@ -130,13 +132,10 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
130
132
|
? document.activeElement
|
|
131
133
|
: null
|
|
132
134
|
|
|
133
|
-
openPanels.push(panel)
|
|
134
135
|
const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
|
|
135
136
|
;(initial ?? panel).focus()
|
|
136
137
|
|
|
137
138
|
return () => {
|
|
138
|
-
const at = openPanels.indexOf(panel)
|
|
139
|
-
if (at !== -1) openPanels.splice(at, 1)
|
|
140
139
|
// Restoring is the half that gets forgotten: without it, closing a
|
|
141
140
|
// modal drops the caret to <body> and the next Tab restarts from
|
|
142
141
|
// the top of the page.
|
|
@@ -144,21 +143,27 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
144
143
|
}
|
|
145
144
|
}, [isOpen])
|
|
146
145
|
|
|
147
|
-
// Escape
|
|
148
|
-
//
|
|
146
|
+
// Escape: the shared stack dispatches it to the topmost layer only, and
|
|
147
|
+
// reads `onClose` through a ref so an inline arrow does not re-register
|
|
148
|
+
// (or reorder) the layer on every render.
|
|
149
|
+
const layer = useLayer({
|
|
150
|
+
enabled: isOpen,
|
|
151
|
+
kind: 'modal',
|
|
152
|
+
elementRef: panelRef,
|
|
153
|
+
onEscape: () => onClose(),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Tab trap. One document listener; the topmost-MODAL guard keeps nested
|
|
157
|
+
// modals from both reacting, and keeps this trap live while a popover
|
|
158
|
+
// sits above it.
|
|
149
159
|
useEffect(() => {
|
|
150
160
|
if (!isOpen) return
|
|
151
161
|
const panel = panelRef.current
|
|
152
162
|
if (!panel) return
|
|
153
163
|
|
|
154
164
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
155
|
-
if (openPanels[openPanels.length - 1] !== panel) return
|
|
156
|
-
|
|
157
|
-
if (e.key === 'Escape') {
|
|
158
|
-
onClose()
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
165
|
if (e.key !== 'Tab') return
|
|
166
|
+
if (!layer.isTopOfKind()) return
|
|
162
167
|
|
|
163
168
|
const active = document.activeElement
|
|
164
169
|
// Focus is outside the panel — almost always a body-level portal
|
|
@@ -199,7 +204,7 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
199
204
|
|
|
200
205
|
document.addEventListener('keydown', handleKeyDown)
|
|
201
206
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
202
|
-
}, [isOpen,
|
|
207
|
+
}, [isOpen, layer])
|
|
203
208
|
|
|
204
209
|
if (!isOpen) return null
|
|
205
210
|
|
|
@@ -222,8 +227,8 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
222
227
|
ref={panelRef}
|
|
223
228
|
role="dialog"
|
|
224
229
|
aria-modal="true"
|
|
225
|
-
aria-labelledby={title ? titleId : undefined}
|
|
226
|
-
aria-label={title ? undefined : ariaLabel}
|
|
230
|
+
aria-labelledby={title && !hideHeader ? titleId : undefined}
|
|
231
|
+
aria-label={title && !hideHeader ? undefined : ariaLabel}
|
|
227
232
|
tabIndex={-1}
|
|
228
233
|
data-framed="true"
|
|
229
234
|
className={cn(
|
|
@@ -242,21 +247,23 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
242
247
|
60px tall for a 23px title — and because the button was taller
|
|
243
248
|
than the text, the BUTTON set the row height and the title
|
|
244
249
|
floated in slack it never asked for. */}
|
|
245
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
250
|
+
{hideHeader ? null : (
|
|
251
|
+
<div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
|
|
252
|
+
<h2
|
|
253
|
+
id={titleId}
|
|
254
|
+
className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
|
|
255
|
+
>
|
|
256
|
+
{title}
|
|
257
|
+
</h2>
|
|
258
|
+
<button
|
|
259
|
+
onClick={onClose}
|
|
260
|
+
className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded-[var(--radius-sm)] text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-colors"
|
|
261
|
+
aria-label="Close"
|
|
262
|
+
>
|
|
263
|
+
<X className="h-[15px] w-[15px]" />
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
260
267
|
{renderBody(children)}
|
|
261
268
|
</div>
|
|
262
269
|
</div>
|