@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- 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 +307 -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__/sortable.test.tsx +394 -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 +369 -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 +611 -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 +301 -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 +303 -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 +520 -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 +252 -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 +742 -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 +967 -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 +232 -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,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
|
+
}
|