banhaten 0.4.0 → 0.6.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/CHANGELOG.md +78 -1
- package/CONTRIBUTING.md +6 -4
- package/README.md +4 -2
- package/docs/design-spacing.md +13 -0
- package/docs/design-system/README.md +2 -0
- package/docs/design-system/appearances/default.md +1 -1
- package/docs/design-system/appearances/rounded.md +1 -1
- package/docs/design-system/appearances/sharp.md +1 -1
- package/docs/design-system/component-showcase-consistency-report.md +5 -5
- package/docs/design-system/components/README.md +15 -0
- package/docs/design-system/components/accordion.md +1 -1
- package/docs/design-system/components/activity-feed.md +1 -1
- package/docs/design-system/components/activity-heatmap.md +1 -1
- package/docs/design-system/components/agent-input.md +85 -0
- package/docs/design-system/components/alert-dialog.md +1 -1
- package/docs/design-system/components/alert.md +10 -4
- package/docs/design-system/components/app-header.md +97 -0
- package/docs/design-system/components/approval-card.md +134 -0
- package/docs/design-system/components/attribute.md +14 -2
- package/docs/design-system/components/avatar.md +1 -1
- package/docs/design-system/components/badge.md +1 -1
- package/docs/design-system/components/banner.md +1 -1
- package/docs/design-system/components/breadcrumbs.md +8 -3
- package/docs/design-system/components/button-group.md +1 -1
- package/docs/design-system/components/button.md +8 -5
- package/docs/design-system/components/card.md +15 -1
- package/docs/design-system/components/catalog-components.md +1 -1
- package/docs/design-system/components/chart.md +95 -24
- package/docs/design-system/components/checkbox.md +1 -1
- package/docs/design-system/components/collapsible.md +20 -6
- package/docs/design-system/components/command.md +1 -1
- package/docs/design-system/components/context-menu.md +1 -1
- package/docs/design-system/components/date-picker.md +1 -1
- package/docs/design-system/components/file-upload.md +1 -1
- package/docs/design-system/components/hover-card.md +1 -1
- package/docs/design-system/components/inline-citations.md +93 -0
- package/docs/design-system/components/input-group.md +1 -1
- package/docs/design-system/components/input.md +59 -3
- package/docs/design-system/components/item.md +1 -1
- package/docs/design-system/components/kbd.md +6 -4
- package/docs/design-system/components/menu.md +35 -4
- package/docs/design-system/components/menubar.md +1 -1
- package/docs/design-system/components/modal.md +2 -2
- package/docs/design-system/components/native-select.md +1 -1
- package/docs/design-system/components/navigation-menu.md +1 -1
- package/docs/design-system/components/onboarding-step-list-item.md +1 -1
- package/docs/design-system/components/page-header.md +58 -17
- package/docs/design-system/components/pagination.md +2 -2
- package/docs/design-system/components/popover.md +1 -1
- package/docs/design-system/components/progress-slider.md +1 -1
- package/docs/design-system/components/radio-card.md +1 -1
- package/docs/design-system/components/radio-group.md +1 -1
- package/docs/design-system/components/resizable.md +1 -1
- package/docs/design-system/components/rolling-count.md +61 -0
- package/docs/design-system/components/scroll-area.md +1 -1
- package/docs/design-system/components/select.md +12 -2
- package/docs/design-system/components/sheet.md +1 -1
- package/docs/design-system/components/shimmer-text.md +67 -0
- package/docs/design-system/components/sidebar.md +75 -8
- package/docs/design-system/components/skeleton.md +2 -2
- package/docs/design-system/components/slideout.md +1 -1
- package/docs/design-system/components/slider.md +2 -2
- package/docs/design-system/components/social-button.md +2 -2
- package/docs/design-system/components/streaming-text.md +82 -0
- package/docs/design-system/components/table.md +103 -21
- package/docs/design-system/components/tabs.md +25 -6
- package/docs/design-system/components/tag.md +1 -1
- package/docs/design-system/components/task-list.md +95 -0
- package/docs/design-system/components/textarea.md +1 -1
- package/docs/design-system/components/thinking-reasoning.md +72 -0
- package/docs/design-system/components/thinking-state.md +67 -0
- package/docs/design-system/components/timeline.md +1 -1
- package/docs/design-system/components/toast.md +1 -1
- package/docs/design-system/components/toggle.md +2 -2
- package/docs/design-system/components/toolbar.md +11 -6
- package/docs/design-system/components/tooltip.md +6 -4
- package/docs/design-system/density-and-elevation.md +16 -16
- package/docs/design-system/design-system-test-missing-items.md +8 -8
- package/docs/design-system/installation.md +2 -0
- package/docs/design-system/tabs.md +2 -1
- package/docs/design-system/template-gap-audit.md +32 -0
- package/docs/design-system/templates.md +59 -0
- package/docs/design-system/token-governance.md +15 -2
- package/package.json +2 -2
- package/registry/components/accordion.tsx +2 -2
- package/registry/components/agent-input-parts.tsx +552 -0
- package/registry/components/agent-input-state.ts +156 -0
- package/registry/components/agent-input.css +307 -0
- package/registry/components/agent-input.tsx +382 -0
- package/registry/components/alert-dialog.tsx +2 -2
- package/registry/components/alert.tsx +23 -7
- package/registry/components/app-header.tsx +151 -0
- package/registry/components/approval-card.css +409 -0
- package/registry/components/approval-card.tsx +401 -0
- package/registry/components/approval-frame.tsx +108 -0
- package/registry/components/approval-questions.tsx +559 -0
- package/registry/components/attribute.tsx +48 -13
- package/registry/components/autocomplete.tsx +15 -68
- package/registry/components/avatar.tsx +2 -2
- package/registry/components/badge.tsx +8 -7
- package/registry/components/button-group.tsx +2 -2
- package/registry/components/button.tsx +19 -14
- package/registry/components/card.tsx +18 -5
- package/registry/components/chart-block-data.ts +177 -0
- package/registry/components/chart-block.css +171 -0
- package/registry/components/chart-block.tsx +260 -0
- package/registry/components/chart-interactions.ts +123 -0
- package/registry/components/chart-options.cartesian.ts +162 -0
- package/registry/components/chart-options.paint.ts +99 -0
- package/registry/components/chart-options.parts.ts +72 -0
- package/registry/components/chart-options.radial.ts +109 -0
- package/registry/components/chart-options.ts +44 -0
- package/registry/components/chart-options.types.ts +67 -0
- package/registry/components/chart.tsx +1 -1
- package/registry/components/checkbox.tsx +6 -6
- package/registry/components/collapsible.tsx +43 -3
- package/registry/components/command.tsx +1 -1
- package/registry/components/context-menu.tsx +1 -1
- package/registry/components/data-chart.tsx +40 -0
- package/registry/components/date-picker.tsx +9 -9
- package/registry/components/echart-theme.ts +246 -0
- package/registry/components/echart.css +49 -0
- package/registry/components/echart.tsx +178 -0
- package/registry/components/expanded/Breadcrumbs.tsx +5 -1
- package/registry/components/expanded/PageHeader.tsx +67 -24
- package/registry/components/expanded/Table.tsx +60 -22
- package/registry/components/expanded/activityFeed.css +2 -2
- package/registry/components/expanded/activityHeatmap.css +1 -1
- package/registry/components/expanded/banner.css +2 -2
- package/registry/components/expanded/breadcrumbs.css +19 -2
- package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
- package/registry/components/expanded/fileUpload.css +7 -7
- package/registry/components/expanded/onboardingStepListItem.css +2 -2
- package/registry/components/expanded/pageHeader.css +38 -44
- package/registry/components/expanded/slideout.css +4 -4
- package/registry/components/expanded/table.css +58 -9
- package/registry/components/expanded/timeline.css +1 -1
- package/registry/components/hover-card.tsx +1 -1
- package/registry/components/inline-citations.css +126 -0
- package/registry/components/inline-citations.tsx +292 -0
- package/registry/components/input-group.tsx +4 -4
- package/registry/components/input.tsx +150 -137
- package/registry/components/item.tsx +1 -1
- package/registry/components/kbd.tsx +3 -2
- package/registry/components/menu.tsx +117 -12
- package/registry/components/menubar.tsx +2 -2
- package/registry/components/modal.tsx +3 -3
- package/registry/components/native-select.tsx +4 -4
- package/registry/components/navigation-menu.tsx +2 -2
- package/registry/components/pagination.tsx +4 -4
- package/registry/components/popover.tsx +3 -3
- package/registry/components/progress-slider.tsx +1 -1
- package/registry/components/radio-card.tsx +7 -7
- package/registry/components/radio-group.tsx +2 -2
- package/registry/components/resizable.tsx +2 -2
- package/registry/components/rolling-count.css +37 -0
- package/registry/components/rolling-count.tsx +110 -0
- package/registry/components/scroll-area.tsx +1 -1
- package/registry/components/select.tsx +15 -10
- package/registry/components/sheet.tsx +2 -2
- package/registry/components/shimmer-text.css +69 -0
- package/registry/components/shimmer-text.tsx +43 -0
- package/registry/components/sidebar.tsx +528 -223
- package/registry/components/skeleton.tsx +1 -1
- package/registry/components/slider.tsx +2 -2
- package/registry/components/social-button.tsx +1 -1
- package/registry/components/streaming-text.css +45 -0
- package/registry/components/streaming-text.tsx +165 -0
- package/registry/components/table-elements.tsx +164 -5
- package/registry/components/tabs.css +32 -4
- package/registry/components/tabs.tsx +5 -1
- package/registry/components/tag.tsx +1 -1
- package/registry/components/task-list.css +328 -0
- package/registry/components/task-list.tsx +429 -0
- package/registry/components/textarea.tsx +4 -4
- package/registry/components/thinking-reasoning.css +178 -0
- package/registry/components/thinking-reasoning.tsx +253 -0
- package/registry/components/thinking-state.css +8 -0
- package/registry/components/thinking-state.tsx +46 -0
- package/registry/components/toast.tsx +1 -1
- package/registry/components/toggle.tsx +2 -2
- package/registry/components/toolbar.tsx +6 -6
- package/registry/components/tooltip.tsx +6 -4
- package/registry/examples/agent-input-demo.tsx +90 -0
- package/registry/examples/app-header-demo.tsx +40 -0
- package/registry/examples/approval-card-demo.tsx +83 -0
- package/registry/examples/button-demo.tsx +3 -0
- package/registry/examples/chart-block-demo.tsx +5 -0
- package/registry/examples/chart-demo.tsx +6 -21
- package/registry/examples/collection-filter-bar-demo.tsx +1 -1
- package/registry/examples/control-density-contract-demo.tsx +5 -1
- package/registry/examples/inline-citations-demo.tsx +31 -0
- package/registry/examples/input-actions-demo.tsx +36 -0
- package/registry/examples/menu-demo.tsx +1 -1
- package/registry/examples/popover-demo.tsx +1 -0
- package/registry/examples/rolling-count-demo.tsx +39 -0
- package/registry/examples/shimmer-text-demo.tsx +20 -0
- package/registry/examples/streaming-text-demo.tsx +28 -0
- package/registry/examples/task-list-demo.tsx +74 -0
- package/registry/examples/thinking-reasoning-demo.tsx +74 -0
- package/registry/examples/thinking-state-demo.tsx +13 -0
- package/registry/guidance/charts.LICENSE.txt +24 -0
- package/registry/hooks/use-controllable-state.ts +33 -0
- package/registry/hooks/use-listbox-navigation.ts +73 -0
- package/registry/hooks/use-motion-end.ts +55 -0
- package/registry/hooks/use-reduced-motion.ts +28 -0
- package/registry/index.json +1808 -190
- package/registry/styles/globals.css +992 -1088
- package/registry.json +385 -10
- package/schema/registry.schema.json +5 -1
- package/schema/tokens.schema.json +8 -4
- package/src/cli/file-transaction.js +15 -8
- package/src/cli/index.js +22 -29
- package/tokens/banhaten.tokens.json +490 -192
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
ChevronDownIcon,
|
|
6
|
+
ChevronUpIcon,
|
|
7
|
+
MessageCircleQuestionIcon,
|
|
8
|
+
} from "lucide-react"
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/button"
|
|
11
|
+
import { Kbd } from "@/components/ui/kbd"
|
|
12
|
+
import { RollingCount } from "@/components/ui/rolling-count"
|
|
13
|
+
import { useReducedMotion } from "@/hooks/use-reduced-motion"
|
|
14
|
+
|
|
15
|
+
import { ApprovalFrame, type ApprovalCardBaseProps } from "./approval-frame"
|
|
16
|
+
|
|
17
|
+
type ApprovalQuestion = {
|
|
18
|
+
id: string
|
|
19
|
+
options: readonly string[]
|
|
20
|
+
prompt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ApprovalAnswers = Record<string, string>
|
|
24
|
+
|
|
25
|
+
type ApprovalQuestionsPayload = {
|
|
26
|
+
/** Answers keyed by question id. */
|
|
27
|
+
answers: ApprovalAnswers
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ApprovalQuestionsLabels = {
|
|
31
|
+
customAnswer: string
|
|
32
|
+
customAnswerFor: (prompt: string) => string
|
|
33
|
+
nextQuestion: string
|
|
34
|
+
previousQuestion: string
|
|
35
|
+
questionOf: (current: number, total: number) => string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ApprovalQuestionsProps = ApprovalCardBaseProps & {
|
|
39
|
+
labels?: Partial<ApprovalQuestionsLabels>
|
|
40
|
+
/** Fires once every question has an answer. */
|
|
41
|
+
onApprove?: (payload: ApprovalQuestionsPayload) => void
|
|
42
|
+
onReject?: () => void
|
|
43
|
+
/** Single-choice questions, answered one at a time. */
|
|
44
|
+
questions: readonly ApprovalQuestion[]
|
|
45
|
+
rejectLabel?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultLabels: ApprovalQuestionsLabels = {
|
|
49
|
+
customAnswer: "Something else…",
|
|
50
|
+
customAnswerFor: (prompt) => `Custom answer for: ${prompt}`,
|
|
51
|
+
nextQuestion: "Next question",
|
|
52
|
+
previousQuestion: "Previous question",
|
|
53
|
+
questionOf: (current, total) => `Question ${current} of ${total}`,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Beat between picking an option and sliding to the next question.
|
|
57
|
+
const ADVANCE_MS = 320
|
|
58
|
+
|
|
59
|
+
type QuestionChoice = { kind: "option"; option: string } | { kind: "custom" }
|
|
60
|
+
|
|
61
|
+
/** A question's pick, plus its free-text draft kept while switching picks. */
|
|
62
|
+
type QuestionEntry = { choice?: QuestionChoice; draft: string }
|
|
63
|
+
|
|
64
|
+
const emptyEntry: QuestionEntry = { draft: "" }
|
|
65
|
+
|
|
66
|
+
function answerOf(entry: QuestionEntry | undefined) {
|
|
67
|
+
if (entry?.choice?.kind === "option") return entry.choice.option
|
|
68
|
+
if (entry?.choice?.kind === "custom") return entry.draft.trim()
|
|
69
|
+
return ""
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectAnswers(
|
|
73
|
+
questions: readonly ApprovalQuestion[],
|
|
74
|
+
entries: Record<string, QuestionEntry>
|
|
75
|
+
) {
|
|
76
|
+
const answers: ApprovalAnswers = {}
|
|
77
|
+
for (const question of questions) {
|
|
78
|
+
const answer = answerOf(entries[question.id])
|
|
79
|
+
if (answer) answers[question.id] = answer
|
|
80
|
+
}
|
|
81
|
+
return answers
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isComplete(
|
|
85
|
+
questions: readonly ApprovalQuestion[],
|
|
86
|
+
answers: ApprovalAnswers
|
|
87
|
+
) {
|
|
88
|
+
return (
|
|
89
|
+
questions.length > 0 &&
|
|
90
|
+
questions.every((question) => Boolean(answers[question.id]?.trim()))
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sizes the viewport to the active question and slides the track to it. When
|
|
96
|
+
* a slide would leave keyboard focus on a hidden question, focus follows to
|
|
97
|
+
* the visible one.
|
|
98
|
+
*/
|
|
99
|
+
function useQuestionTrack(
|
|
100
|
+
step: number,
|
|
101
|
+
questions: readonly ApprovalQuestion[],
|
|
102
|
+
entries: Record<string, QuestionEntry>
|
|
103
|
+
) {
|
|
104
|
+
const viewportRef = React.useRef<HTMLDivElement>(null)
|
|
105
|
+
const questionRefs = React.useRef<Array<HTMLDivElement | null>>([])
|
|
106
|
+
const measured = React.useRef(false)
|
|
107
|
+
const [height, setHeight] = React.useState<number | null>(null)
|
|
108
|
+
const [offset, setOffset] = React.useState(0)
|
|
109
|
+
const [animate, setAnimate] = React.useState(false)
|
|
110
|
+
const reducedMotion = useReducedMotion()
|
|
111
|
+
|
|
112
|
+
const sync = React.useCallback(
|
|
113
|
+
(shouldAnimate: boolean) => {
|
|
114
|
+
const item = questionRefs.current[step]
|
|
115
|
+
const viewport = viewportRef.current
|
|
116
|
+
if (!item || !viewport) return
|
|
117
|
+
// The viewport pads the track so focus rings are not clipped.
|
|
118
|
+
const padding = Number.parseFloat(window.getComputedStyle(viewport).paddingTop) || 0
|
|
119
|
+
setHeight(item.offsetHeight + 2 * padding)
|
|
120
|
+
setOffset(item.offsetTop)
|
|
121
|
+
setAnimate(shouldAnimate && !reducedMotion)
|
|
122
|
+
},
|
|
123
|
+
[reducedMotion, step]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Answers can change a question's height, so they re-measure too.
|
|
127
|
+
React.useLayoutEffect(() => {
|
|
128
|
+
const shouldAnimate = measured.current
|
|
129
|
+
measured.current = true
|
|
130
|
+
sync(shouldAnimate)
|
|
131
|
+
}, [entries, questions, sync])
|
|
132
|
+
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
const frame = window.requestAnimationFrame(() => sync(measured.current))
|
|
135
|
+
return () => window.cancelAnimationFrame(frame)
|
|
136
|
+
}, [questions, sync])
|
|
137
|
+
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
const viewport = viewportRef.current
|
|
140
|
+
const question = questionRefs.current[step]
|
|
141
|
+
const focused = document.activeElement
|
|
142
|
+
if (!viewport || !question || !focused) return
|
|
143
|
+
if (!viewport.contains(focused) || question.contains(focused)) return
|
|
144
|
+
const radio =
|
|
145
|
+
question.querySelector<HTMLInputElement>(
|
|
146
|
+
'[data-slot="approval-card-option-radio"]:checked'
|
|
147
|
+
) ??
|
|
148
|
+
question.querySelector<HTMLInputElement>('[data-slot="approval-card-option-radio"]')
|
|
149
|
+
radio?.focus({ preventScroll: true })
|
|
150
|
+
}, [step])
|
|
151
|
+
|
|
152
|
+
return { animate, height, offset, questionRefs, viewportRef }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type QuestionStepNavProps = {
|
|
156
|
+
labels: ApprovalQuestionsLabels
|
|
157
|
+
onStep: (step: number) => void
|
|
158
|
+
step: number
|
|
159
|
+
total: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Previous and next controls around a rolling "2 / 3" counter, grouped under
|
|
164
|
+
* "Question 2 of 3". A plain div cannot carry that name; a group can.
|
|
165
|
+
*/
|
|
166
|
+
function QuestionStepNav({ labels, onStep, step, total }: QuestionStepNavProps) {
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
aria-label={labels.questionOf(step + 1, total)}
|
|
170
|
+
data-slot="approval-card-step-nav"
|
|
171
|
+
role="group"
|
|
172
|
+
>
|
|
173
|
+
<Button
|
|
174
|
+
aria-label={labels.previousQuestion}
|
|
175
|
+
disabled={step <= 0}
|
|
176
|
+
onClick={() => onStep(step - 1)}
|
|
177
|
+
size="icon-xs"
|
|
178
|
+
variant="ghost"
|
|
179
|
+
>
|
|
180
|
+
<ChevronUpIcon />
|
|
181
|
+
</Button>
|
|
182
|
+
<span aria-live="polite" data-slot="approval-card-step">
|
|
183
|
+
<RollingCount value={`${step + 1} / ${total}`} />
|
|
184
|
+
</span>
|
|
185
|
+
<Button
|
|
186
|
+
aria-label={labels.nextQuestion}
|
|
187
|
+
disabled={step >= total - 1}
|
|
188
|
+
onClick={() => onStep(step + 1)}
|
|
189
|
+
size="icon-xs"
|
|
190
|
+
variant="ghost"
|
|
191
|
+
>
|
|
192
|
+
<ChevronDownIcon />
|
|
193
|
+
</Button>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type QuestionOptionsProps = {
|
|
199
|
+
active: boolean
|
|
200
|
+
entry: QuestionEntry
|
|
201
|
+
labels: ApprovalQuestionsLabels
|
|
202
|
+
name: string
|
|
203
|
+
/** Selects "Something else"; the field is focused separately. */
|
|
204
|
+
onChooseCustom: () => void
|
|
205
|
+
onCommitCustom: (draft: string) => void
|
|
206
|
+
/** Enter on the choices: advance, or approve from the last question. */
|
|
207
|
+
onConfirm: () => void
|
|
208
|
+
onEditCustom: (draft: string) => void
|
|
209
|
+
onPick: (option: string, advance: boolean) => void
|
|
210
|
+
promptId: string
|
|
211
|
+
question: ApprovalQuestion
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One question's choices as native radios: one tab stop, arrow keys, and a
|
|
216
|
+
* letter per option. "Something else" is its own radio next to a text field;
|
|
217
|
+
* focusing or typing in the field selects it.
|
|
218
|
+
*/
|
|
219
|
+
function QuestionOptions({
|
|
220
|
+
active,
|
|
221
|
+
entry,
|
|
222
|
+
labels,
|
|
223
|
+
name,
|
|
224
|
+
onChooseCustom,
|
|
225
|
+
onCommitCustom,
|
|
226
|
+
onConfirm,
|
|
227
|
+
onEditCustom,
|
|
228
|
+
onPick,
|
|
229
|
+
promptId,
|
|
230
|
+
question,
|
|
231
|
+
}: QuestionOptionsProps) {
|
|
232
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
233
|
+
// Set by an arrow-key press so the radio change it causes moves the choice
|
|
234
|
+
// without advancing, the way a native radio group browses.
|
|
235
|
+
const arrowKey = React.useRef(false)
|
|
236
|
+
const custom = entry.choice?.kind === "custom"
|
|
237
|
+
const picked = entry.choice?.kind === "option" ? entry.choice.option : null
|
|
238
|
+
|
|
239
|
+
const chooseCustom = (focusField: boolean) => {
|
|
240
|
+
onChooseCustom()
|
|
241
|
+
if (focusField) window.requestAnimationFrame(() => inputRef.current?.focus())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Letters pick options by their key hint; Enter confirms the current answer.
|
|
245
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
246
|
+
if ((event.target as HTMLElement).dataset.slot === "approval-card-option-input") return
|
|
247
|
+
if (event.key.startsWith("Arrow")) {
|
|
248
|
+
arrowKey.current = true
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
if (event.key === "Enter") {
|
|
252
|
+
event.preventDefault()
|
|
253
|
+
onConfirm()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.key.length !== 1) return
|
|
257
|
+
const index = event.key.toUpperCase().charCodeAt(0) - 65
|
|
258
|
+
if (index < 0 || index > question.options.length) return
|
|
259
|
+
event.preventDefault()
|
|
260
|
+
if (index === question.options.length) {
|
|
261
|
+
chooseCustom(true)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
event.currentTarget
|
|
265
|
+
.querySelectorAll<HTMLInputElement>('[data-slot="approval-card-option-radio"]')
|
|
266
|
+
[index]?.focus()
|
|
267
|
+
onPick(question.options[index], true)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
aria-labelledby={promptId}
|
|
273
|
+
data-slot="approval-card-options"
|
|
274
|
+
onKeyDown={handleKeyDown}
|
|
275
|
+
onPointerDown={() => {
|
|
276
|
+
arrowKey.current = false
|
|
277
|
+
}}
|
|
278
|
+
role="radiogroup"
|
|
279
|
+
>
|
|
280
|
+
{question.options.map((option, optionIndex) => {
|
|
281
|
+
const selected = picked === option
|
|
282
|
+
return (
|
|
283
|
+
<label
|
|
284
|
+
data-selected={selected ? "true" : "false"}
|
|
285
|
+
data-slot="approval-card-option"
|
|
286
|
+
key={option}
|
|
287
|
+
>
|
|
288
|
+
<input
|
|
289
|
+
checked={selected}
|
|
290
|
+
className="sr-only"
|
|
291
|
+
data-slot="approval-card-option-radio"
|
|
292
|
+
name={name}
|
|
293
|
+
onChange={() => {
|
|
294
|
+
if (active) onPick(option, !arrowKey.current)
|
|
295
|
+
arrowKey.current = false
|
|
296
|
+
}}
|
|
297
|
+
tabIndex={active ? undefined : -1}
|
|
298
|
+
type="radio"
|
|
299
|
+
value={option}
|
|
300
|
+
/>
|
|
301
|
+
<Kbd aria-hidden="true" size="sm">
|
|
302
|
+
{String.fromCharCode(65 + optionIndex)}
|
|
303
|
+
</Kbd>
|
|
304
|
+
<span dir="auto">{option}</span>
|
|
305
|
+
</label>
|
|
306
|
+
)
|
|
307
|
+
})}
|
|
308
|
+
<div
|
|
309
|
+
data-other="true"
|
|
310
|
+
data-selected={custom ? "true" : "false"}
|
|
311
|
+
data-slot="approval-card-option"
|
|
312
|
+
onClick={(event) => {
|
|
313
|
+
if (event.target === event.currentTarget) inputRef.current?.focus()
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<label data-slot="approval-card-other-choice">
|
|
317
|
+
<input
|
|
318
|
+
aria-label={labels.customAnswer}
|
|
319
|
+
checked={custom}
|
|
320
|
+
className="sr-only"
|
|
321
|
+
data-slot="approval-card-option-radio"
|
|
322
|
+
name={name}
|
|
323
|
+
onChange={() => {
|
|
324
|
+
if (active) chooseCustom(!arrowKey.current)
|
|
325
|
+
arrowKey.current = false
|
|
326
|
+
}}
|
|
327
|
+
tabIndex={active ? undefined : -1}
|
|
328
|
+
type="radio"
|
|
329
|
+
value=""
|
|
330
|
+
/>
|
|
331
|
+
<Kbd aria-hidden="true" size="sm">
|
|
332
|
+
{String.fromCharCode(65 + question.options.length)}
|
|
333
|
+
</Kbd>
|
|
334
|
+
</label>
|
|
335
|
+
<input
|
|
336
|
+
aria-label={labels.customAnswerFor(question.prompt)}
|
|
337
|
+
data-slot="approval-card-option-input"
|
|
338
|
+
onChange={(event) => {
|
|
339
|
+
if (active) onEditCustom(event.target.value)
|
|
340
|
+
}}
|
|
341
|
+
onFocus={() => {
|
|
342
|
+
if (active && !custom) chooseCustom(false)
|
|
343
|
+
}}
|
|
344
|
+
onKeyDown={(event) => {
|
|
345
|
+
event.stopPropagation()
|
|
346
|
+
if (!active) return
|
|
347
|
+
// Enter that confirms an IME candidate is part of the composition,
|
|
348
|
+
// not the answer (Safari reports it with keyCode 229).
|
|
349
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return
|
|
350
|
+
if (event.key === "Enter") {
|
|
351
|
+
event.preventDefault()
|
|
352
|
+
onCommitCustom(event.currentTarget.value)
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
placeholder={labels.customAnswer}
|
|
356
|
+
ref={inputRef}
|
|
357
|
+
tabIndex={active ? 0 : -1}
|
|
358
|
+
type="text"
|
|
359
|
+
value={entry.draft}
|
|
360
|
+
/>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Pauses the agent for a few single-choice questions, one at a time, each with
|
|
368
|
+
* a free-text answer.
|
|
369
|
+
*/
|
|
370
|
+
const ApprovalQuestions = React.forwardRef<HTMLDivElement, ApprovalQuestionsProps>(
|
|
371
|
+
function ApprovalQuestions(
|
|
372
|
+
{
|
|
373
|
+
approveLabel = "Continue",
|
|
374
|
+
labels: labelOverrides,
|
|
375
|
+
onApprove,
|
|
376
|
+
onKeyDown,
|
|
377
|
+
onReject,
|
|
378
|
+
questions,
|
|
379
|
+
rejectLabel = "Skip",
|
|
380
|
+
title = "Questions",
|
|
381
|
+
...props
|
|
382
|
+
},
|
|
383
|
+
ref
|
|
384
|
+
) {
|
|
385
|
+
const labels = { ...defaultLabels, ...labelOverrides }
|
|
386
|
+
const [entries, setEntries] = React.useState<Record<string, QuestionEntry>>({})
|
|
387
|
+
const [step, setStep] = React.useState(0)
|
|
388
|
+
const advanceTimer = React.useRef<number | null>(null)
|
|
389
|
+
const groupId = React.useId()
|
|
390
|
+
|
|
391
|
+
React.useEffect(
|
|
392
|
+
() => () => {
|
|
393
|
+
if (advanceTimer.current) window.clearTimeout(advanceTimer.current)
|
|
394
|
+
},
|
|
395
|
+
[]
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
const lastStep = Math.max(questions.length - 1, 0)
|
|
399
|
+
const safeStep = Math.min(step, lastStep)
|
|
400
|
+
const answers = collectAnswers(questions, entries)
|
|
401
|
+
const complete = isComplete(questions, answers)
|
|
402
|
+
const track = useQuestionTrack(safeStep, questions, entries)
|
|
403
|
+
|
|
404
|
+
const clearAdvance = () => {
|
|
405
|
+
if (advanceTimer.current) window.clearTimeout(advanceTimer.current)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const advance = () => setStep((current) => Math.min(current + 1, lastStep))
|
|
409
|
+
|
|
410
|
+
const goToStep = (next: number) => {
|
|
411
|
+
clearAdvance()
|
|
412
|
+
setStep(Math.min(Math.max(next, 0), lastStep))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const updateEntry = (questionId: string, patch: Partial<QuestionEntry>) =>
|
|
416
|
+
setEntries((previous) => ({
|
|
417
|
+
...previous,
|
|
418
|
+
[questionId]: { ...(previous[questionId] ?? emptyEntry), ...patch },
|
|
419
|
+
}))
|
|
420
|
+
|
|
421
|
+
const approve = (current: ApprovalAnswers) => {
|
|
422
|
+
if (isComplete(questions, current)) onApprove?.({ answers: current })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// A click, Space, or letter picks and advances after a beat.
|
|
426
|
+
const pickOption = (questionId: string, option: string, advanceAfter: boolean) => {
|
|
427
|
+
updateEntry(questionId, { choice: { kind: "option", option } })
|
|
428
|
+
if (advanceAfter && safeStep < lastStep) {
|
|
429
|
+
clearAdvance()
|
|
430
|
+
advanceTimer.current = window.setTimeout(advance, ADVANCE_MS)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const chooseCustom = (questionId: string) => {
|
|
435
|
+
clearAdvance()
|
|
436
|
+
updateEntry(questionId, { choice: { kind: "custom" } })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const editCustom = (questionId: string, draft: string) =>
|
|
440
|
+
updateEntry(questionId, { choice: { kind: "custom" }, draft })
|
|
441
|
+
|
|
442
|
+
// Enter in the free-text field answers the question: it advances, or
|
|
443
|
+
// submits from the last question.
|
|
444
|
+
const commitCustom = (questionId: string, draft: string) => {
|
|
445
|
+
if (!draft.trim()) return
|
|
446
|
+
editCustom(questionId, draft)
|
|
447
|
+
if (safeStep < lastStep) {
|
|
448
|
+
clearAdvance()
|
|
449
|
+
advance()
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
approve({ ...answers, [questionId]: draft.trim() })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const confirm = (questionId: string) => {
|
|
456
|
+
if (!answerOf(entries[questionId])) return
|
|
457
|
+
if (safeStep < lastStep) {
|
|
458
|
+
clearAdvance()
|
|
459
|
+
advance()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
approve(answers)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<ApprovalFrame
|
|
467
|
+
{...props}
|
|
468
|
+
approveDisabled={!complete}
|
|
469
|
+
approveLabel={approveLabel}
|
|
470
|
+
footerStart={
|
|
471
|
+
<QuestionStepNav
|
|
472
|
+
labels={labels}
|
|
473
|
+
onStep={goToStep}
|
|
474
|
+
step={safeStep}
|
|
475
|
+
total={questions.length}
|
|
476
|
+
/>
|
|
477
|
+
}
|
|
478
|
+
icon={<MessageCircleQuestionIcon />}
|
|
479
|
+
onApprove={() => approve(answers)}
|
|
480
|
+
onKeyDown={(event) => {
|
|
481
|
+
onKeyDown?.(event)
|
|
482
|
+
if (event.defaultPrevented || event.key !== "Enter") return
|
|
483
|
+
if (safeStep !== lastStep || !complete) return
|
|
484
|
+
const target = event.target as HTMLElement
|
|
485
|
+
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return
|
|
486
|
+
if (target.closest("[data-slot='approval-card-buttons']")) return
|
|
487
|
+
event.preventDefault()
|
|
488
|
+
approve(answers)
|
|
489
|
+
}}
|
|
490
|
+
onSecondary={() => onReject?.()}
|
|
491
|
+
ref={ref}
|
|
492
|
+
secondaryLabel={rejectLabel}
|
|
493
|
+
title={title}
|
|
494
|
+
tone="info"
|
|
495
|
+
>
|
|
496
|
+
{questions.length > 0 ? (
|
|
497
|
+
<div
|
|
498
|
+
aria-live="polite"
|
|
499
|
+
data-animate={track.animate ? "true" : "false"}
|
|
500
|
+
data-slot="approval-card-questions"
|
|
501
|
+
ref={track.viewportRef}
|
|
502
|
+
style={track.height !== null ? { height: `${track.height}px` } : undefined}
|
|
503
|
+
>
|
|
504
|
+
<div
|
|
505
|
+
data-animate={track.animate ? "true" : "false"}
|
|
506
|
+
data-slot="approval-card-questions-track"
|
|
507
|
+
style={{ transform: `translate3d(0, ${-track.offset}px, 0)` }}
|
|
508
|
+
>
|
|
509
|
+
{questions.map((question, questionIndex) => {
|
|
510
|
+
const active = questionIndex === safeStep
|
|
511
|
+
const promptId = `${groupId}-${question.id}-prompt`
|
|
512
|
+
|
|
513
|
+
return (
|
|
514
|
+
<div
|
|
515
|
+
aria-hidden={active ? undefined : "true"}
|
|
516
|
+
data-active={active ? "true" : "false"}
|
|
517
|
+
data-slot="approval-card-question"
|
|
518
|
+
key={question.id}
|
|
519
|
+
ref={(element) => {
|
|
520
|
+
track.questionRefs.current[questionIndex] = element
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
<div data-slot="approval-card-prompt" dir="auto" id={promptId}>
|
|
524
|
+
{question.prompt}
|
|
525
|
+
</div>
|
|
526
|
+
<QuestionOptions
|
|
527
|
+
active={active}
|
|
528
|
+
entry={entries[question.id] ?? emptyEntry}
|
|
529
|
+
labels={labels}
|
|
530
|
+
name={`${groupId}-${question.id}`}
|
|
531
|
+
onChooseCustom={() => chooseCustom(question.id)}
|
|
532
|
+
onCommitCustom={(draft) => commitCustom(question.id, draft)}
|
|
533
|
+
onConfirm={() => confirm(question.id)}
|
|
534
|
+
onEditCustom={(draft) => editCustom(question.id, draft)}
|
|
535
|
+
onPick={(option, advanceAfter) =>
|
|
536
|
+
pickOption(question.id, option, advanceAfter)
|
|
537
|
+
}
|
|
538
|
+
promptId={promptId}
|
|
539
|
+
question={question}
|
|
540
|
+
/>
|
|
541
|
+
</div>
|
|
542
|
+
)
|
|
543
|
+
})}
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
) : null}
|
|
547
|
+
</ApprovalFrame>
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
export { ApprovalQuestions }
|
|
553
|
+
export type {
|
|
554
|
+
ApprovalAnswers,
|
|
555
|
+
ApprovalQuestion,
|
|
556
|
+
ApprovalQuestionsLabels,
|
|
557
|
+
ApprovalQuestionsPayload,
|
|
558
|
+
ApprovalQuestionsProps,
|
|
559
|
+
}
|
|
@@ -79,10 +79,17 @@ const attributeListVariants = cva("grid min-w-0", {
|
|
|
79
79
|
"overflow-hidden rounded-[var(--bh-radius-lg-8)] border border-[var(--bh-border-subtle)] [&>[data-slot='attribute-item']]:px-[var(--bh-space-3xl-16)] sm:[&>[data-slot='attribute-item']]:px-[var(--bh-space-4xl-20)]",
|
|
80
80
|
false: "",
|
|
81
81
|
},
|
|
82
|
+
// comfortable spaces the rows with a gap instead of row padding, as in the app-shell detail cards.
|
|
83
|
+
density: {
|
|
84
|
+
compact: "",
|
|
85
|
+
default: "",
|
|
86
|
+
comfortable: "gap-y-[var(--bh-space-3xl-16)]",
|
|
87
|
+
},
|
|
82
88
|
},
|
|
83
89
|
defaultVariants: {
|
|
84
90
|
layout: "inline",
|
|
85
91
|
bordered: false,
|
|
92
|
+
density: "default",
|
|
86
93
|
},
|
|
87
94
|
})
|
|
88
95
|
|
|
@@ -99,7 +106,7 @@ const attributeItemVariants = cva("min-w-0", {
|
|
|
99
106
|
density: {
|
|
100
107
|
compact: "py-[var(--bh-space-md-8)]",
|
|
101
108
|
default: "py-[var(--bh-space-3xl-16)]",
|
|
102
|
-
comfortable: "py-[var(--bh-space-
|
|
109
|
+
comfortable: "py-[var(--bh-space-none)]",
|
|
103
110
|
},
|
|
104
111
|
divided: {
|
|
105
112
|
true: "border-b border-[var(--bh-border-subtle)] last:border-b-0",
|
|
@@ -110,6 +117,13 @@ const attributeItemVariants = cva("min-w-0", {
|
|
|
110
117
|
false: "",
|
|
111
118
|
},
|
|
112
119
|
},
|
|
120
|
+
compoundVariants: [
|
|
121
|
+
{
|
|
122
|
+
layout: "inline",
|
|
123
|
+
density: "comfortable",
|
|
124
|
+
class: "items-start gap-x-[var(--bh-space-3xl-16)]",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
113
127
|
defaultVariants: {
|
|
114
128
|
layout: "inline",
|
|
115
129
|
density: "default",
|
|
@@ -119,21 +133,31 @@ const attributeItemVariants = cva("min-w-0", {
|
|
|
119
133
|
})
|
|
120
134
|
|
|
121
135
|
const attributeLabelVariants = cva(
|
|
122
|
-
[
|
|
123
|
-
"min-w-0 break-words text-[length:var(--bh-text-body-md-regular-font-size)]",
|
|
124
|
-
"font-[var(--bh-text-body-md-regular-font-weight)]",
|
|
125
|
-
"leading-[var(--bh-text-body-md-regular-line-height)]",
|
|
126
|
-
"tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
|
|
127
|
-
"text-[var(--bh-content-subtle)]",
|
|
128
|
-
],
|
|
136
|
+
["min-w-0 break-words text-[var(--bh-content-subtle)]"],
|
|
129
137
|
{
|
|
130
138
|
variants: {
|
|
139
|
+
// Comfortable lists set the label in body-sm, one step below the value.
|
|
140
|
+
size: {
|
|
141
|
+
default: [
|
|
142
|
+
"text-[length:var(--bh-text-body-md-regular-font-size)]",
|
|
143
|
+
"font-[var(--bh-text-body-md-regular-font-weight)]",
|
|
144
|
+
"leading-[var(--bh-text-body-md-regular-line-height)]",
|
|
145
|
+
"tracking-[var(--bh-text-body-md-regular-letter-spacing)]",
|
|
146
|
+
],
|
|
147
|
+
sm: [
|
|
148
|
+
"text-[length:var(--bh-text-body-sm-regular-font-size)]",
|
|
149
|
+
"font-[var(--bh-text-body-sm-regular-font-weight)]",
|
|
150
|
+
"leading-[var(--bh-text-body-sm-regular-line-height)]",
|
|
151
|
+
"tracking-[var(--bh-text-body-sm-regular-letter-spacing)]",
|
|
152
|
+
],
|
|
153
|
+
},
|
|
131
154
|
truncate: {
|
|
132
155
|
true: "truncate",
|
|
133
156
|
false: "",
|
|
134
157
|
},
|
|
135
158
|
},
|
|
136
159
|
defaultVariants: {
|
|
160
|
+
size: "default",
|
|
137
161
|
truncate: false,
|
|
138
162
|
},
|
|
139
163
|
}
|
|
@@ -258,11 +282,16 @@ const AttributeList = React.forwardRef<HTMLDListElement, AttributeListProps>(
|
|
|
258
282
|
className,
|
|
259
283
|
density = "default",
|
|
260
284
|
dividers = false,
|
|
261
|
-
labelWidth
|
|
285
|
+
labelWidth: labelWidthProp,
|
|
262
286
|
layout = "inline",
|
|
263
287
|
style,
|
|
264
288
|
...props
|
|
265
289
|
}, ref) {
|
|
290
|
+
const labelWidth =
|
|
291
|
+
labelWidthProp ??
|
|
292
|
+
(density === "comfortable"
|
|
293
|
+
? "var(--bh-attribute-comfortable-label-width)"
|
|
294
|
+
: defaultAttributeContext.labelWidth)
|
|
266
295
|
const value = React.useMemo(
|
|
267
296
|
() => ({
|
|
268
297
|
density,
|
|
@@ -285,7 +314,7 @@ const AttributeList = React.forwardRef<HTMLDListElement, AttributeListProps>(
|
|
|
285
314
|
data-slot="attribute-list"
|
|
286
315
|
ref={ref}
|
|
287
316
|
style={attributeStyle}
|
|
288
|
-
className={cn(attributeListVariants({ layout, bordered, className }))}
|
|
317
|
+
className={cn(attributeListVariants({ layout, bordered, density, className }))}
|
|
289
318
|
{...props}
|
|
290
319
|
/>
|
|
291
320
|
</AttributeContext.Provider>
|
|
@@ -343,7 +372,11 @@ const AttributeItem = React.forwardRef<HTMLDivElement, AttributeItemProps>(
|
|
|
343
372
|
)}
|
|
344
373
|
{...props}
|
|
345
374
|
>
|
|
346
|
-
<AttributeLabel
|
|
375
|
+
<AttributeLabel
|
|
376
|
+
className={labelClassName}
|
|
377
|
+
size={selectedDensity === "comfortable" ? "sm" : undefined}
|
|
378
|
+
truncate={truncate}
|
|
379
|
+
>
|
|
347
380
|
{label}
|
|
348
381
|
</AttributeLabel>
|
|
349
382
|
<AttributeValue
|
|
@@ -363,12 +396,14 @@ type AttributeLabelProps = React.ComponentProps<"dt"> &
|
|
|
363
396
|
VariantProps<typeof attributeLabelVariants>
|
|
364
397
|
|
|
365
398
|
const AttributeLabel = React.forwardRef<HTMLElement, AttributeLabelProps>(
|
|
366
|
-
function AttributeLabel({ children, className, truncate, ...props }, ref) {
|
|
399
|
+
function AttributeLabel({ children, className, size, truncate, ...props }, ref) {
|
|
400
|
+
const context = React.useContext(AttributeContext)
|
|
401
|
+
const selectedSize = size ?? (context.density === "comfortable" ? "sm" : "default")
|
|
367
402
|
return (
|
|
368
403
|
<dt
|
|
369
404
|
data-slot="attribute-label"
|
|
370
405
|
ref={ref}
|
|
371
|
-
className={cn(attributeLabelVariants({ truncate, className }))}
|
|
406
|
+
className={cn(attributeLabelVariants({ size: selectedSize, truncate, className }))}
|
|
372
407
|
{...props}
|
|
373
408
|
>
|
|
374
409
|
{renderAttributeText(children, "attribute-label-text")}
|