@lovett/ui 0.0.11 → 0.2.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 (142) 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 +5574 -57
  4. package/dist/index.js +21650 -11206
  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 +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The composer's action row, shared by the plain and rich composers.
3
+ *
4
+ * It exists because both footers were the same shape written twice, and the
5
+ * two things the owner asked for on 2026-09-07 both live here:
6
+ *
7
+ * 1. a plain/rich switcher at the inline-start, behind a thin vertical rule;
8
+ * 2. the row must not STACK on a narrow screen. It used to be `flex-wrap`,
9
+ * so the formatting buttons pushed Cancel and Comment onto a second line
10
+ * and the composer grew a row taller the narrower it got — the opposite
11
+ * of what a small screen wants.
12
+ *
13
+ * At a phone width even that is not enough: the fixed cluster (switcher,
14
+ * counter, Cancel, Comment) is wider than 390px on its own, so `flex-nowrap`
15
+ * simply overflowed sideways and cut the send button in half — trading a stack
16
+ * for a clip. Two things drop out below `--composer-narrow`, chosen because
17
+ * neither means anything on a touch device: the character counter, and the
18
+ * `⌘↵` keycap on the send button. A CONTAINER query, not a media query, since
19
+ * the composer also lives in a narrow drawer on a wide screen (interface
20
+ * standards §3).
21
+ *
22
+ * The layout rule: exactly one thing may shrink, and it is the toolbar. The
23
+ * switcher, the counter and the send cluster are `shrink-0` and always whole;
24
+ * the toolbar sits in a `min-w-0` track that scrolls horizontally once it runs
25
+ * out of room. Scrolling rather than hiding, because a hidden formatting
26
+ * button is unreachable, and rather than wrapping, because the send button
27
+ * moving is what makes a composer feel unstable.
28
+ */
29
+
30
+ import type { ReactNode } from 'react'
31
+ import { PencilLine, Type } from 'lucide-react'
32
+ import { Button } from '../button'
33
+ import { cn } from '../lib/utils'
34
+
35
+ export type ComposerMode = 'plain' | 'rich'
36
+
37
+ export interface ComposerFooterProps {
38
+ /** The formatting controls. Rendered in the one track that may shrink. */
39
+ readonly toolbar?: ReactNode | undefined
40
+ /** Current editor mode. Omit `onModeChange` to hide the switcher entirely. */
41
+ readonly mode?: ComposerMode | undefined
42
+ readonly onModeChange?: ((mode: ComposerMode) => void) | undefined
43
+ readonly modeDisabled?: boolean | undefined
44
+ /** Rendered between the toolbar and the send cluster (the character counter). */
45
+ readonly meta?: ReactNode | undefined
46
+ /** Cancel + submit. Never shrinks, never wraps. */
47
+ readonly actions: ReactNode
48
+ }
49
+
50
+ export function ComposerFooter({
51
+ toolbar,
52
+ mode,
53
+ onModeChange,
54
+ modeDisabled = false,
55
+ meta,
56
+ actions,
57
+ }: ComposerFooterProps) {
58
+ const showSwitcher = onModeChange !== undefined && mode !== undefined
59
+ const next: ComposerMode = mode === 'rich' ? 'plain' : 'rich'
60
+
61
+ return (
62
+ <div
63
+ // NOT `flex-wrap`. See the file header — wrapping is the defect.
64
+ className="ds-composer-footer flex min-w-0 flex-nowrap items-center gap-[var(--space-1)]"
65
+ style={{ padding: '0 var(--space-2) var(--space-2)' }}
66
+ >
67
+ {showSwitcher ? (
68
+ <>
69
+ <Button
70
+ variant="icon"
71
+ size="sm"
72
+ className="shrink-0"
73
+ aria-label={`Switch to the ${next} editor`}
74
+ title={`Switch to the ${next} editor`}
75
+ disabled={modeDisabled}
76
+ onMouseDown={(event) => {
77
+ event.preventDefault()
78
+ }}
79
+ onClick={() => {
80
+ onModeChange(next)
81
+ }}
82
+ >
83
+ {mode === 'rich' ? (
84
+ <PencilLine size={12} strokeWidth={1.75} aria-hidden="true" />
85
+ ) : (
86
+ <Type size={12} strokeWidth={1.75} aria-hidden="true" />
87
+ )}
88
+ </Button>
89
+ {/* The thin vertical rule the owner asked for: it separates "which
90
+ editor" from "what this editor can do", which are different
91
+ questions about the same box. `--space-4` tall so it reads as a
92
+ rule beside a 24px control rather than a full-height divider. */}
93
+ <span
94
+ aria-hidden="true"
95
+ className="shrink-0"
96
+ style={{
97
+ inlineSize: '1px',
98
+ blockSize: 'var(--space-4)',
99
+ marginInline: 'var(--space-1)',
100
+ background: 'rgb(var(--border))',
101
+ }}
102
+ />
103
+ </>
104
+ ) : null}
105
+
106
+ {/* The ONE thing that may shrink. `min-w-0` lets it actually do so
107
+ inside a flex row, and the horizontal scroll keeps every control
108
+ reachable instead of clipping it out of existence. */}
109
+ <div
110
+ className={cn(
111
+ 'flex min-w-0 flex-1 items-center gap-[var(--space-1)]',
112
+ 'overflow-x-auto overscroll-x-contain',
113
+ // A scrollbar in a 24px-tall control strip is louder than the
114
+ // controls; the row is short enough to drag or shift-wheel.
115
+ '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
116
+ )}
117
+ >
118
+ {toolbar}
119
+ </div>
120
+
121
+ <span className="ds-composer-meta contents">{meta}</span>
122
+ {actions}
123
+ </div>
124
+ )
125
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * ThreadComposer — a textarea, a toolbar that actually formats, and a send.
3
+ *
4
+ * ADR-147 D7. Markdown over a `<textarea>`; not TipTap, not contenteditable.
5
+ * A textarea gets undo, spellcheck, IME and mobile keyboards right for free,
6
+ * and markdown survives into an email digest, a notification preview, an
7
+ * agent's context window and a CSV export — none of which a ProseMirror
8
+ * document survives without a renderer.
9
+ *
10
+ * Every toolbar button wires real behaviour (see markdown-format.ts). The
11
+ * decorative toolbar in the source this replaces is the anti-pattern, and a
12
+ * button that does nothing is worse than no button: it teaches people the
13
+ * surface is broken.
14
+ *
15
+ * Keyboard: Cmd/Ctrl+Enter sends. Cmd/Ctrl+B / I / K format. Escape cancels a
16
+ * nested reply composer when one is open.
17
+ */
18
+
19
+ import { useCallback, useLayoutEffect, useRef } from 'react'
20
+ import type { KeyboardEvent, ReactNode } from 'react'
21
+ import { Bold, Code, CornerDownRight, Italic, Link as LinkIcon, List } from 'lucide-react'
22
+ import { Button } from '../button'
23
+ import { ComposerFooter, type ComposerMode } from './composer-footer'
24
+ import { Kbd } from '../kbd'
25
+ import { cn } from '../lib/utils'
26
+ import { applyMarkdownFormat, type MarkdownFormat } from './markdown-format'
27
+
28
+ /**
29
+ * The send shortcut, spelled the way the platform spells it.
30
+ *
31
+ * Read once at module scope from the UA rather than per render: it cannot
32
+ * change while the page is open, and a value that is stable for the life of the
33
+ * document must not be a render-time branch. Guarded for a non-DOM environment
34
+ * (SSR, the test runner), where it resolves to the Ctrl form.
35
+ */
36
+ const SEND_KEYCAP: string =
37
+ typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent)
38
+ ? '⌘↵'
39
+ : 'Ctrl ↵'
40
+
41
+ interface ToolbarAction {
42
+ readonly format: MarkdownFormat
43
+ readonly label: string
44
+ readonly icon: typeof Bold
45
+ readonly shortcut?: string
46
+ }
47
+
48
+ const TOOLBAR: readonly ToolbarAction[] = [
49
+ { format: 'bold', label: 'Bold', icon: Bold, shortcut: 'b' },
50
+ { format: 'italic', label: 'Italic', icon: Italic, shortcut: 'i' },
51
+ { format: 'code', label: 'Code', icon: Code },
52
+ { format: 'link', label: 'Link', icon: LinkIcon, shortcut: 'k' },
53
+ { format: 'list', label: 'Bulleted list', icon: List },
54
+ ]
55
+
56
+ export interface ThreadComposerProps {
57
+ readonly value: string
58
+ readonly onChange: (value: string) => void
59
+ /**
60
+ * The rich composer supplies verified attachment ticket ids. The plain
61
+ * composer calls this with no argument, preserving its original contract.
62
+ */
63
+ readonly onSubmit: (attachmentIds?: readonly string[]) => void
64
+ readonly onCancel?: (() => void) | undefined
65
+ readonly placeholder?: string | undefined
66
+ /** Sentence case, and it names what happens (interface standards §7). */
67
+ readonly submitLabel?: string | undefined
68
+ readonly cancelLabel?: string | undefined
69
+ readonly sending?: boolean | undefined
70
+ readonly disabled?: boolean | undefined
71
+ /**
72
+ * Display name of the comment being answered. Renders a "Replying to <name>"
73
+ * line above the field — without it a nested composer is a naked textarea
74
+ * some distance down a long thread, and which comment it is aimed at is
75
+ * carried only by where it happens to sit on screen.
76
+ */
77
+ readonly replyingTo?: string | undefined
78
+ readonly avatar?: ReactNode | undefined
79
+ readonly rows?: number | undefined
80
+ readonly autoFocus?: boolean | undefined
81
+ /** Hide the formatting row on a cramped nested composer. */
82
+ readonly toolbar?: boolean | undefined
83
+ readonly maxLength?: number | undefined
84
+ /**
85
+ * Which editor is active, and how to change it. Both composers render the
86
+ * switcher so a reader can get back from plain; omit `onModeChange` and no
87
+ * switcher appears at all (a host that offers only one editor).
88
+ */
89
+ readonly mode?: ComposerMode | undefined
90
+ readonly onModeChange?: ((mode: ComposerMode) => void) | undefined
91
+ readonly label: string
92
+ readonly className?: string | undefined
93
+ }
94
+
95
+ export function ThreadComposer({
96
+ value,
97
+ onChange,
98
+ onSubmit,
99
+ onCancel,
100
+ placeholder = 'Add a comment…',
101
+ submitLabel = 'Comment',
102
+ cancelLabel = 'Cancel',
103
+ sending = false,
104
+ disabled = false,
105
+ replyingTo,
106
+ avatar,
107
+ rows = 3,
108
+ autoFocus = false,
109
+ toolbar = true,
110
+ maxLength,
111
+ mode,
112
+ onModeChange,
113
+ label,
114
+ className,
115
+ }: ThreadComposerProps) {
116
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
117
+ const pendingSelection = useRef<{ start: number; end: number } | null>(null)
118
+
119
+ // The textarea is controlled, so a format applied through React lands one
120
+ // render later — the caret has to be restored after that render or every
121
+ // toolbar press dumps the cursor at the end of the field.
122
+ useLayoutEffect(() => {
123
+ const selection = pendingSelection.current
124
+ const element = textareaRef.current
125
+ if (selection === null || element === null) return
126
+ pendingSelection.current = null
127
+ element.focus()
128
+ element.setSelectionRange(selection.start, selection.end)
129
+ }, [value])
130
+
131
+ const format = useCallback(
132
+ (next: MarkdownFormat) => {
133
+ const element = textareaRef.current
134
+ if (element === null) return
135
+ const edit = applyMarkdownFormat(
136
+ value,
137
+ element.selectionStart,
138
+ element.selectionEnd,
139
+ next,
140
+ )
141
+ pendingSelection.current = { start: edit.selectionStart, end: edit.selectionEnd }
142
+ onChange(edit.value)
143
+ },
144
+ [onChange, value],
145
+ )
146
+
147
+ const onKeyDown = useCallback(
148
+ (event: KeyboardEvent<HTMLTextAreaElement>) => {
149
+ const mod = event.metaKey || event.ctrlKey
150
+ if (mod && event.key === 'Enter') {
151
+ event.preventDefault()
152
+ onSubmit()
153
+ return
154
+ }
155
+ if (event.key === 'Escape' && onCancel !== undefined) {
156
+ event.preventDefault()
157
+ onCancel()
158
+ return
159
+ }
160
+ if (!mod) return
161
+ const action = TOOLBAR.find((entry) => entry.shortcut === event.key.toLowerCase())
162
+ if (action === undefined) return
163
+ event.preventDefault()
164
+ format(action.format)
165
+ },
166
+ [format, onCancel, onSubmit],
167
+ )
168
+
169
+ const canSend = value.trim() !== '' && !sending && !disabled
170
+
171
+ return (
172
+ <div className={cn('flex flex-col gap-[var(--space-1)]', className)}>
173
+ {replyingTo === undefined ? null : (
174
+ <p
175
+ className="flex items-center gap-[var(--space-1)] text-[12px]"
176
+ style={{ color: 'rgb(var(--text-tertiary))' }}
177
+ >
178
+ <CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
179
+ Replying to{' '}
180
+ <span className="font-semibold" style={{ color: 'rgb(var(--text-secondary))' }}>
181
+ {replyingTo}
182
+ </span>
183
+ </p>
184
+ )}
185
+ <div className="flex items-start gap-[var(--space-2)]">
186
+ {avatar}
187
+ <div
188
+ className={cn(
189
+ 'min-w-0 flex-1 overflow-hidden border',
190
+ 'transition-[border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
191
+ 'motion-reduce:transition-none',
192
+ 'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus)]',
193
+ )}
194
+ style={{
195
+ // Concentric: the host panel declares --thread-inner-radius as its
196
+ // own radius minus its padding (interface standards §1). Nothing
197
+ // here eyeballs a corner.
198
+ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
199
+ borderColor: 'rgb(var(--border))',
200
+ background: 'rgb(var(--surface-card))',
201
+ }}
202
+ >
203
+ <textarea
204
+ ref={textareaRef}
205
+ aria-label={label}
206
+ className="block w-full resize-none bg-transparent outline-none"
207
+ placeholder={placeholder}
208
+ rows={rows}
209
+ value={value}
210
+ disabled={disabled}
211
+ autoFocus={autoFocus}
212
+ onChange={(event) => {
213
+ onChange(event.target.value)
214
+ }}
215
+ onKeyDown={onKeyDown}
216
+ {...(maxLength === undefined ? {} : { maxLength })}
217
+ style={{
218
+ // Optical: vertical is a step tighter than horizontal, because the
219
+ // line box already carries space the glyphs do not fill.
220
+ padding: 'var(--space-2) var(--space-3)',
221
+ color: 'rgb(var(--foreground))',
222
+ fontFamily: 'var(--font-sans)',
223
+ fontSize: '13px',
224
+ lineHeight: 1.55,
225
+ }}
226
+ />
227
+ <ComposerFooter
228
+ {...(mode === undefined ? {} : { mode })}
229
+ {...(onModeChange === undefined ? {} : { onModeChange })}
230
+ modeDisabled={disabled}
231
+ toolbar={
232
+ <>
233
+ {toolbar
234
+ ? TOOLBAR.map((action) => {
235
+ const Icon = action.icon
236
+ return (
237
+ <Button
238
+ key={action.format}
239
+ variant="icon"
240
+ size="sm"
241
+ aria-label={action.label}
242
+ title={action.label}
243
+ disabled={disabled || sending}
244
+ onClick={() => {
245
+ format(action.format)
246
+ }}
247
+ >
248
+ <Icon size={12} strokeWidth={1.75} aria-hidden="true" />
249
+ </Button>
250
+ )
251
+ })
252
+ : null}
253
+ </>
254
+ }
255
+ meta={
256
+ <>
257
+ {maxLength === undefined ? null : (
258
+ <span
259
+ className="tabular-nums text-right"
260
+ // Reserved slot so the counter cannot push the buttons around.
261
+ style={{
262
+ minInlineSize: '7ch',
263
+ fontSize: '11.5px',
264
+ color:
265
+ value.length > maxLength * 0.9
266
+ ? 'rgb(var(--warning-ink))'
267
+ : 'rgb(var(--text-tertiary))',
268
+ }}
269
+ >
270
+ {value.length}/{maxLength}
271
+ </span>
272
+ )}
273
+ </>
274
+ }
275
+ actions={
276
+ <>
277
+ {onCancel === undefined ? null : (
278
+ <Button
279
+ variant="secondary"
280
+ size="sm"
281
+ onClick={onCancel}
282
+ disabled={disabled || sending}
283
+ >
284
+ {cancelLabel}
285
+ </Button>
286
+ )}
287
+ <Button
288
+ variant="primary"
289
+ size="sm"
290
+ onClick={() => {
291
+ onSubmit()
292
+ }}
293
+ disabled={!canSend}
294
+ // The visible keycap must not end up in the accessible name — a
295
+ // screen reader saying "Reply Cmd Return button" is noise, and the
296
+ // shortcut is announced by the field's own instructions instead.
297
+ aria-label={sending ? 'Sending…' : submitLabel}
298
+ // The Kbd primitive, not a fork of it. The inline version this
299
+ // replaces carried `--text-tertiary` ink and a `--border` hairline
300
+ // — neutral-surface tokens sitting on an accent fill, which is
301
+ // why the shortcut read as grey mush on red.
302
+ trailingIcon={
303
+ <span aria-hidden="true" className="ds-composer-keycap">
304
+ <Kbd size="sm" tone="on-accent">
305
+ {SEND_KEYCAP}
306
+ </Kbd>
307
+ </span>
308
+ }
309
+ >
310
+ {sending ? 'Sending…' : submitLabel}
311
+ </Button>
312
+ </>
313
+ }
314
+ />
315
+ </div>
316
+ </div>
317
+ </div>
318
+ )
319
+ }