@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.
Files changed (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. 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 fenced block (handled by CodeBlock via MarkdownCode).
97
- // pre just passes through so the highlighter owns its own container.
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: ({ children }: NodeProps) => <>{children}</>,
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
- const REMARK_PLUGINS = [remarkGfm]
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 just want the raw component map
214
- export { MARKDOWN_COMPONENTS as markdownComponents }
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 }
@@ -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
- className={
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 their
60
- * content THERE, so a select inside a modal would go dead. Focus trap it
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
- * Open panels, outermost first. Only the topmost one answers Escape and
67
- * owns the tab ring, so nested modals do not both close on one Escape
68
- * and do not fight over focus.
69
- */
70
- const openPanels: HTMLElement[] = []
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({ isOpen, onClose, title, children, className, ariaLabel }: ModalProps) {
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 + tab trap. One document listener; the topmost-panel guard
148
- // keeps nested modals from both reacting.
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, onClose])
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
- <div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
246
- <h2
247
- id={titleId}
248
- className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
249
- >
250
- {title}
251
- </h2>
252
- <button
253
- onClick={onClose}
254
- 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"
255
- aria-label="Close"
256
- >
257
- <X className="h-[15px] w-[15px]" />
258
- </button>
259
- </div>
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>