diff --git a/.changeset/chat-input-attached-feedback.md b/.changeset/chat-input-attached-feedback.md new file mode 100644 index 0000000000..403812a63c --- /dev/null +++ b/.changeset/chat-input-attached-feedback.md @@ -0,0 +1,11 @@ +--- +'@razorpay/blade': minor +--- + +feat(ChatInput): add `feedback` — attaches a `ChatFeedback` prompt to the top edge of the composer, on a tinted surface that holds the two together as one object. Web only. Omit the prop and the composer renders exactly as it does today; the surface is only drawn while the prompt is showing + +fix(ChatInput): dissolve the attached surface instead of dropping it. Background and padding now transition on the same beat as the prompt's fade, so the composer no longer jumps up by the padding at the moment the confirmation leaves + +feat(ChatInput): picking the feedback prompt's free-text tag hands the composer over — placeholder swaps, a dismissable `Feedback` tag replaces the upload link, focus moves to the field, and Enter submits the comment. Configurable with `feedback.freeTextTag` (default `'Other'`) and `feedback.commentPlaceholder` + +The chat submit path is blocked while that mode is on rather than redirected: sending someone's candid feedback to the assistant as a prompt is not a recoverable mistake. Anything already typed for the chat is stashed and restored on the way out, and every exit — Esc, the tag's dismiss, submitting, deselecting the tag, or the prompt going away — also releases the tag, so nobody is left holding a choice with no way to send it diff --git a/packages/blade/src/components/ChatInput/ChatInput.native.tsx b/packages/blade/src/components/ChatInput/ChatInput.native.tsx index abfe80b2e4..fdd54e53e8 100644 --- a/packages/blade/src/components/ChatInput/ChatInput.native.tsx +++ b/packages/blade/src/components/ChatInput/ChatInput.native.tsx @@ -52,6 +52,7 @@ const _ChatInput: React.ForwardRefRenderFunction😢, + dissatisfied: 😕, + satisfied: 🙂, + 'very-satisfied': 😍, +}; + +const FeedbackComposer = (): React.ReactElement => { + const [isVisible, setIsVisible] = React.useState(true); + const [picked, setPicked] = React.useState(null); + + return ( + + setPicked(mood), + onDismiss: () => setIsVisible(false), + }} + /> + {picked ?? 'none'} + + ); +}; + +/** + * The regression this exists for: `ChatInput` keeps its validation region mounted above the card + * even with no error, and as a full-width transparent box it used to swallow clicks meant for the + * prompt — the lower two-thirds of every mood button, with nothing on screen to explain it. + * + * Asserted by hit-testing rather than by clicking: `userEvent.click` dispatches at the element + * regardless of what covers it, so it would pass against the bug. `elementFromPoint` asks the + * question the user's cursor actually asks — what is on top here? + */ +export const MoodScaleIsNotCovered: StoryFn = (): React.ReactElement => ; + +MoodScaleIsNotCovered.play = async ({ canvasElement }) => { + const { getByRole } = within(canvasElement); + const button = getByRole('radio', { name: 'Good' }); + + await waitFor(() => expect(button).toBeVisible()); + + const box = button.getBoundingClientRect(); + // The centre, and a point near the bottom edge — the part the error slot used to cover. + const points = [ + { x: box.left + box.width / 2, y: box.top + box.height / 2 }, + { x: box.left + box.width / 2, y: box.bottom - 2 }, + ]; + + points.forEach(({ x, y }) => { + const topMost = document.elementFromPoint(x, y); + expect(button.contains(topMost)).toBe(true); + }); +}; + +/** + * The other half of the same contract: when the validation region *is* saying something, it has to + * stay interactive. + * + * Deliberately without a feedback prompt. The two are not a combination this composer is expected + * to be in — feedback is asked for once a response has rendered, and an error arriving mid-answer + * takes the prompt away with it — so pairing them here would put a state in Storybook that no + * product reaches, and invite it to be treated as a supported layout. The contract being guarded + * belongs to the error region alone: it must not be left permanently inert by the fix that stops + * it swallowing clicks when idle. + */ +export const ErrorRegionStaysInteractive: StoryFn = (): React.ReactElement => { + const [isDismissed, setIsDismissed] = React.useState(false); + + return ( + + setIsDismissed(true)} + /> + + ); +}; + +ErrorRegionStaysInteractive.play = async ({ canvasElement }) => { + const { getByRole, queryByRole } = within(canvasElement); + const alert = getByRole('alert'); + + await waitFor(() => expect(alert).toBeVisible()); + await waitFor(() => expect(window.getComputedStyle(alert).pointerEvents).not.toBe('none')); + // Reachable in practice, not merely painted: its dismiss control must be clickable. + await userEvent.click(getByRole('button', { name: 'Dismiss error' })); + await waitFor(() => expect(queryByRole('alert')).toBeNull()); +}; + +/** Picking a mood records it and moves the flow on to the follow-up step. */ +export const PickingAMoodAdvancesTheFlow: StoryFn = (): React.ReactElement => ; + +PickingAMoodAdvancesTheFlow.play = async ({ canvasElement }) => { + const { getByRole, getByTestId, queryByRole } = within(canvasElement); + + await userEvent.click(getByRole('radio', { name: 'Love it!' })); + + await waitFor(() => expect(getByTestId('picked-mood')).toHaveTextContent('very-satisfied')); + // The scale is replaced by the follow-up, rather than both being on the strip at once. + await waitFor(() => expect(queryByRole('radiogroup')).toBeNull()); + await waitFor(() => expect(getByRole('button', { name: 'Back to rating' })).toBeVisible()); +}; + +/** + * The composer must not move when the prompt does. It sits directly on top, so a step even a + * pixel taller pushes the whole composer down — at the moment the user is reading the strip. + */ +export const ComposerHoldsStillAcrossSteps: StoryFn = (): React.ReactElement => ( + +); + +ComposerHoldsStillAcrossSteps.play = async ({ canvasElement }) => { + const { getByRole } = within(canvasElement); + const textarea = canvasElement.querySelector('textarea'); + + expect(textarea).not.toBeNull(); + const before = textarea.getBoundingClientRect().top; + + await userEvent.click(getByRole('radio', { name: 'Bad' })); + await waitFor(() => expect(getByRole('button', { name: 'Back to rating' })).toBeVisible()); + + const after = textarea.getBoundingClientRect().top; + // One pixel of tolerance for sub-pixel layout, not for a shifted composer. + expect(Math.abs(after - before)).toBeLessThanOrEqual(1); +}; diff --git a/packages/blade/src/components/ChatInput/ChatInput.web.tsx b/packages/blade/src/components/ChatInput/ChatInput.web.tsx index a2d6e6cb2b..033e7236c8 100644 --- a/packages/blade/src/components/ChatInput/ChatInput.web.tsx +++ b/packages/blade/src/components/ChatInput/ChatInput.web.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from 'framer-motion'; import type { ChatInputProps } from './types'; import { chatInputFilePreviewItemWidth } from './chatInputTokens'; import { ChatInputActionBar } from './ChatInputActionBar'; +import { ChatInputFeedback } from './ChatInputFeedback.web'; import { ChatInputGhostSuggestion } from './ChatInputGhostSuggestion'; import { useChatInput } from './useChatInput'; import { useTheme } from '~components/BladeProvider'; @@ -14,8 +15,10 @@ import { getStyledProps } from '~components/Box/styledProps'; import { IconButton } from '~components/Button/IconButton'; import { FileUploadItem } from '~components/FileUpload/FileUploadItem'; import { CloseIcon, InfoIcon } from '~components/Icons'; -import { BaseInput } from '~components/Input/BaseInput/BaseInput'; +import { Tag } from '~components/Tag'; +import type { ChatFeedbackControls } from '~components/ChatFeedback'; import { Text } from '~components/Typography'; +import { BaseInput } from '~components/Input/BaseInput/BaseInput'; import { castWebType, makeSpace } from '~utils'; import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; @@ -30,6 +33,22 @@ const HiddenScrollbarBox = styled(BaseBox)(() => ({ scrollbarWidth: 'none' as const, })); +/** + * Carries the attached surface's dissolve. + * + * The prompt *fades* as it leaves, and the surface holding it used to lose its background and + * padding in the same frame — so the composer jumped up by the padding at the exact moment the + * user was watching the confirmation go. Transitioning both across the same beat as the fade + * means the surface recedes with its contents instead of being pulled out from under them. + * + * A `styled` wrapper rather than props on `BaseBox`, which has no way to express a transition. + */ +const FeedbackSurface = styled(BaseBox)(({ theme }) => ({ + transition: `background-color ${theme.motion.duration.moderate}ms ${castWebType( + theme.motion.easing.exit, + )}, padding ${theme.motion.duration.moderate}ms ${castWebType(theme.motion.easing.exit)}`, +})); + const _ChatInput: React.ForwardRefRenderFunction = ( { value, @@ -56,6 +75,7 @@ const _ChatInput: React.ForwardRefRenderFunction ); + /* + * Free-text feedback borrows this composer rather than opening a field of its own. + * + * The alternative — a second input inside the prompt — puts two places to type directly above + * one another, and the one that looks like the composer is not the one that has focus. Handing + * the composer over means there is only ever one. + * + * Two things this has to get right, both of them silent when wrong: + * + * - **Anything already typed is the user's, not ours.** Entering the mode stashes the chat + * draft and restores it on the way out; without that, picking a tag quietly destroys a + * half-written message. + * - **Enter must not reach the chat.** Sending someone's candid feedback to the assistant as a + * prompt is not a recoverable mistake, so the chat path is blocked outright while the mode + * is on rather than merely redirected. + */ + const freeTextTag = feedback?.freeTextTag ?? 'Other'; + const feedbackControls = React.useRef(null); + /* + * Mirrors the mode as a ref, because leaving it is re-entrant. + * + * Exiting releases the free-text tag, which fires `onTagsChange`, which routes back here as + * another exit. State read from a closure is still `true` at that point, so the second pass + * would restore an already-cleared draft over the one just put back. The ref is the only value + * that is current by then. + */ + const isFeedbackInputRef = React.useRef(false); + const [isFeedbackInput, setIsFeedbackInput] = React.useState(false); + const [feedbackTags, setFeedbackTags] = React.useState([]); + const stashedDraft = React.useRef(''); + + const enterFeedbackInput = React.useCallback(() => { + stashedDraft.current = textValue; + handleTextChange({ value: '' }); + isFeedbackInputRef.current = true; + setIsFeedbackInput(true); + }, [handleTextChange, textValue]); + + const exitFeedbackInput = React.useCallback(() => { + if (!isFeedbackInputRef.current) return; + isFeedbackInputRef.current = false; + setIsFeedbackInput(false); + handleTextChange({ value: stashedDraft.current }); + stashedDraft.current = ''; + + /* + * Release the tag as well as the mode. + * + * Leaving it selected strands the user: the tick stays hidden because the only tag picked is + * the free-text one, and the composer is back to chatting — so they have chosen something + * with no way left to send it. Backing out has to undo the choice that got them here. + */ + feedbackControls.current?.setTags( + feedbackTags.filter((tag) => tag !== (feedback?.freeTextTag ?? 'Other')), + ); + }, [feedback?.freeTextTag, feedbackTags, handleTextChange]); + + /* + * Move the caret to the composer as the mode opens. + * + * Picking the tag is the user saying they have something to type; leaving focus where it was + * makes them click a second time to start, and on a strip this small the field is easy to miss + * changing at all. Focus is what makes the handover legible — the composer lights up, so it is + * obvious which of the two things on screen is now listening. + * + * In an effect rather than inside the handler, so it runs after the placeholder and the cleared + * value have been committed. + */ + React.useEffect(() => { + if (!isFeedbackInput) return; + if (inputRef.current instanceof HTMLElement) inputRef.current.focus(); + }, [isFeedbackInput]); + + /* + * Submitting ends the mode as surely as cancelling does. + * + * Firing the flow's submit is not enough on its own: the prompt going away is the *strip's* + * business, and the composer stays in feedback mode until told otherwise — leaving a Feedback + * tag, an "esc to cancel" hint and the wrong placeholder attached to a composer with nothing + * left to give feedback to. + */ + const submitFeedbackInput = React.useCallback(() => { + feedbackControls.current?.submit(); + exitFeedbackInput(); + }, [exitFeedbackInput]); + + const handleFeedbackTagsChange = React.useCallback( + ({ tags }: { tags: string[] }) => { + setFeedbackTags(tags); + const wantsFreeText = tags.includes(freeTextTag); + /* + * Read from the ref rather than from state. This runs inside `ChatFeedback`'s callbacks, + * which can be memoised against an older render — so the state copy here may say the mode is + * off when it is on, and the exit never happens. That is how the back-chevron left a composer + * stranded in feedback mode with nothing selected. + */ + if (wantsFreeText && !isFeedbackInputRef.current) enterFeedbackInput(); + if (!wantsFreeText && isFeedbackInputRef.current) exitFeedbackInput(); + }, + [enterFeedbackInput, exitFeedbackInput, freeTextTag], + ); + const actionBarContent = ( + {/* + A dismissable tag rather than a line of instructions: it says which mode you are in + and is itself the way out. Esc does the same thing, but there is no Esc key on a + phone, so the tap target is the affordance that has to exist. + */} + + Feedback + + + esc to cancel + + + ) : undefined + } /> ); const isError = validationState === 'error'; + /* + * The surface that holds prompt and composer together. + * + * Only drawn while the prompt is actually showing: with the prompt gone the composer has to look + * exactly as it does with the feature switched off, and a leftover border with 4px of padding + * around a lone composer is a worse artefact than no feature at all. + * + * Nothing at all is emitted when the feature is unused, rather than the same properties set to + * transparent and zero. A composer without a feedback prompt should render byte-for-byte as it + * did before this existed — a border-style and a radius that no consumer asked for is the kind of + * change that shows up as unexplained diff noise in every snapshot downstream. + */ + const handleComposerKeyDown = React.useCallback( + (args: Parameters[0]) => { + if (!isFeedbackInput) { + handleKeyDown(args); + return; + } + + if (args.event?.key === 'Escape') { + args.event.preventDefault(); + exitFeedbackInput(); + return; + } + + // Enter submits the feedback; Shift+Enter still breaks the line. Nothing here reaches the + // chat's own submit, which is the point. + if (args.event?.key === 'Enter' && !args.event.shiftKey) { + args.event.preventDefault(); + submitFeedbackInput(); + } + }, + [exitFeedbackInput, handleKeyDown, isFeedbackInput, submitFeedbackInput], + ); + + const isFeedbackVisible = Boolean(feedback) && feedback?.isVisible !== false; + + /* + * The mode cannot outlive the prompt. + * + * A consumer can take the prompt away at any moment — on submit, on dismiss, or because the + * whole surface is being torn down — and none of those routes go through the handlers above. + * Without this the composer is left wearing a Feedback tag with nothing behind it, and Enter + * still routed away from the chat. + */ + React.useEffect(() => { + if (!isFeedbackVisible && isFeedbackInput) exitFeedbackInput(); + }, [exitFeedbackInput, isFeedbackInput, isFeedbackVisible]); + const frameProps = feedback + ? ({ + display: 'flex', + flexDirection: 'column', + // 4px between the prompt and the card, per the design. + gap: 'spacing.2', + /* + * A tinted surface rather than a grey one: the prompt is Ray asking for something, not a + * disabled or secondary region, and the azure wash ties it to the assistant rather than to + * the page chrome. No border — the tint alone separates it from the page, and an outline + * around an outline (the card carries its own) reads as two boxes rather than one. + */ + backgroundColor: isFeedbackVisible ? 'surface.background.primary.subtle' : 'transparent', + // 20px outside, 16px on the card within, per the design. + borderRadius: 'xlarge', + /* + * The design asks for 6px, which is not on Blade's spacing scale — it steps 4 to 8 — so + * this rounds up rather than inventing a value off-scale. + */ + padding: isFeedbackVisible ? 'spacing.3' : 'spacing.0', + } as const) + : {}; + + const Frame = feedback ? FeedbackSurface : BaseBox; + return ( - @@ -234,6 +454,24 @@ const _ChatInput: React.ForwardRefRenderFunction + ); }; diff --git a/packages/blade/src/components/ChatInput/ChatInputActionBar.tsx b/packages/blade/src/components/ChatInput/ChatInputActionBar.tsx index 8b7d0e58c0..20441e5f8e 100644 --- a/packages/blade/src/components/ChatInput/ChatInputActionBar.tsx +++ b/packages/blade/src/components/ChatInput/ChatInputActionBar.tsx @@ -5,6 +5,14 @@ import { Link } from '~components/Link'; import { ArrowUpIcon, StopCircleIcon, PlusIcon } from '~components/Icons'; type ChatInputActionBarProps = { + /** + * Replaces the upload link on the left of the bar. + * + * Used when the composer is doing something other than composing a message — collecting + * feedback, say — where offering an attachment would be meaningless and the space is better + * spent saying what mode you are in and how to leave it. + */ + leadingSlot?: React.ReactNode; isDisabled?: boolean; isGenerating?: boolean; isSubmitDisabled?: boolean; @@ -22,6 +30,7 @@ const ChatInputActionBar = ({ onUploadClick, onSubmit, onStop, + leadingSlot, }: ChatInputActionBarProps): React.ReactElement => { return ( - {hideFileUpload ? ( - - ) : ( - - - Upload file - - - )} + {leadingSlot ?? + (hideFileUpload ? ( + + ) : ( + + + Upload file + + + ))} {isGenerating ? (
"`; +exports[` should render ChatInput 1`] = `"
"`; exports[` should render ChatInput 2`] = ` .c0.c0.c0.c0.c0 { position: relative; + min-width: 700px; } .c1.c1.c1.c1.c1 { diff --git a/packages/blade/src/components/ChatInput/__tests__/__snapshots__/ChatInput.web.test.tsx.snap b/packages/blade/src/components/ChatInput/__tests__/__snapshots__/ChatInput.web.test.tsx.snap index f603375a3a..3592178bed 100644 --- a/packages/blade/src/components/ChatInput/__tests__/__snapshots__/ChatInput.web.test.tsx.snap +++ b/packages/blade/src/components/ChatInput/__tests__/__snapshots__/ChatInput.web.test.tsx.snap @@ -3,6 +3,7 @@ exports[` should render ChatInput 1`] = ` .c0.c0.c0.c0.c0 { position: relative; + min-width: 700px; } .c1.c1.c1.c1.c1 { diff --git a/packages/blade/src/components/ChatInput/_decisions/decisions.md b/packages/blade/src/components/ChatInput/_decisions/decisions.md index 8164173e25..e02b980e97 100644 --- a/packages/blade/src/components/ChatInput/_decisions/decisions.md +++ b/packages/blade/src/components/ChatInput/_decisions/decisions.md @@ -230,6 +230,12 @@ type ChatInputProps = { */ accessibilityLabel?: string; + /** + * Attaches a feedback prompt to the top of the composer, on a shared surface. Web only. + * Omit it and the composer is unchanged. See "Attached Feedback Prompt" below. + */ + feedback?: ChatInputFeedbackProps; + /** * Test ID for automation testing */ @@ -449,6 +455,79 @@ The uploading guard prevents `onSubmit` from receiving a not-yet-ready file in ` When submit is disabled because of an error or uploading file, no additional tooltip or `errorText` is surfaced on the submit button itself. The visual feedback is already present on the `FileUploadItem` chip (red error state, spinner for uploading). Adding a redundant tooltip or `errorText` slot message would duplicate that signal without adding clarity. If a consumer needs custom messaging they can use the `validationState` / `errorText` props. +## Attached Feedback Prompt + +Passing a `feedback` object attaches a `ChatFeedback` flow to the composer's top edge, the two +sharing one tinted surface so they read as a single object. There is no `showFeedback` boolean — +passing the object is the switch, the same way `Tooltip` has no `showTitle`. Omit it and the +composer renders byte-for-byte as it does without the feature: the surface properties are only +emitted when the prop is present, rather than set to transparent and zero. + +```ts +type ChatInputFeedbackProps = Pick< + ChatFeedbackProps, + 'question' | 'moodConfig' | 'feedbackIcons' | 'isDisabled' | 'onMoodSelect' | 'onSubmit' | 'onDismiss' +> & { + /** @default true */ + isVisible?: boolean; + /** The tag that collects free text instead of standing alone. @default 'Other' */ + freeTextTag?: string; + /** Placeholder while the composer is collecting that text. @default 'Anything else? (optional)' */ + commentPlaceholder?: string; +}; +``` + +`isFullWidth`, `isSubmitHidden`, `controlsRef`, `comment` and `onTagsChange` are deliberately not +forwarded: they describe how the flow is laid out and driven, which is the composer's business +rather than the caller's. + +### Why the prompt lives inside ChatInput + +The obvious composition — render `ChatFeedback` above a `ChatInput` — is silently broken. The +composer keeps its validation region mounted directly above the card even when there is no error, +and as a full-width transparent box it swallows clicks aimed at anything stacked there. Measured +against a mood scale placed in that space, it covered the lower two-thirds of every button: hover +fired late and a click near the middle of a control did nothing, with nothing on screen to explain +why. + +Owning the prompt here means the layer it sits on is decided in the same file as the layer the +error region sits on, so the two cannot be composed into conflict. + +### The composer takeover + +Picking `freeTextTag` hands the composer over to the feedback flow: + +| | | +|---|---| +| Placeholder | becomes `commentPlaceholder` | +| Chat draft | stashed on entry, restored on exit | +| Action bar | upload link replaced by a dismissable `Feedback` tag and an `esc to cancel` hint | +| Focus | moves to the composer | +| Enter | submits the feedback; the chat path is **blocked**, not redirected | +| The flow's own tick | hidden — the composer's send arrow is the submit | +| Exits | Esc · the tag's dismiss · submitting · deselecting the tag · the prompt going away | + +Two properties here are silent when wrong, and both have unit tests: + +**The draft belongs to the user.** Without stashing it, picking the tag destroys a half-written +message with no warning and no undo. + +**Enter must never reach the chat.** Sending someone's candid feedback to the assistant as a prompt +is not a recoverable mistake, so the path is blocked outright rather than merely redirected. + +**Every exit also releases the tag.** Leaving it selected strands the user: the tick stays hidden +because the only tag picked is the free-text one, and the composer has gone back to chatting — a +choice made with no way left to send it. This was reached by three separate routes during +development (Esc, submit, and the back control) before the exit was made common to all of them. + +### Reading the mode from a ref + +`ChatFeedback`'s callbacks can be invoked from a memoised closure belonging to an earlier render, so +a state copy read inside them may say the mode is off when it is on. The mode is mirrored in a ref +and read from there. Leaving the mode is also re-entrant — releasing the tag fires `onTagsChange`, +which routes back into the same exit — so the exit returns early when the ref says it has already +run, rather than restoring an already-cleared draft over the one just put back. + ## Accessibility - **Keyboard Navigation:** diff --git a/packages/blade/src/components/ChatInput/docs/ChatInput.stories.tsx b/packages/blade/src/components/ChatInput/docs/ChatInput.stories.tsx index f61e394cb6..d03b9dcc7f 100644 --- a/packages/blade/src/components/ChatInput/docs/ChatInput.stories.tsx +++ b/packages/blade/src/components/ChatInput/docs/ChatInput.stories.tsx @@ -14,6 +14,7 @@ import { Card, CardBody } from '~components/Card'; import { Radio, RadioGroup } from '~components/Radio'; import { Move } from '~components/Move'; import { Badge } from '~components/Badge'; +import { defaultFeedbackIcons } from '~components/ChatFeedback/moodIcons'; import { isReactNative } from '~utils'; /** Native onFileChange fires before pick with the current list — simulate a picked file for Storybook. */ @@ -82,7 +83,7 @@ export default { const ChatInputTemplate: StoryFn = (args) => { return ( - + ); @@ -109,7 +110,7 @@ Disabled.args = { export const WithGhostSuggestions: StoryFn = () => { return ( - + = () => { const [files, setFiles] = useState([]); return ( - + = () => { return ( = () => { const abortRef = useRef(null); return ( - + = () => { }; return ( - + setText(value)} @@ -267,7 +268,7 @@ export const PasteImageUpload: StoryFn = () => { const [files, setFiles] = useState([]); return ( - + = () => { ]); return ( - + = () => { ]); return ( - + = () => { ]); return ( - + = () => { ); }; WithManyFiles.storyName = 'With Many Files (Autoscroll)'; + +/** + * The composer can carry a feedback prompt on its top edge. Prompt and composer share one + * surface, so they read as a single object rather than as two things that happen to be adjacent. + * + * `ChatFeedback` never removes itself. Hide the prompt on **`onDismiss`**, not on `onSubmit` — + * submitting moves the flow to its thank-you step, and taking the prompt away there means the + * confirmation is never seen. `onDismiss` fires once that step has been held. With `feedback` + * gone the composer looks exactly as it does without the feature. + * + * Blade ships an animated set for the rating scale, so nothing extra is required. Pass + * `feedbackIcons` only to use a product's own artwork. + */ +export const WithFeedback: StoryFn = () => { + const [value, setValue] = useState(''); + const [showFeedback, setShowFeedback] = useState(true); + const [answer, setAnswer] = useState(null); + + return ( + + setValue(next)} + onSubmit={() => setValue('')} + feedback={{ + feedbackIcons: defaultFeedbackIcons, + isVisible: showFeedback, + question: "How's Ray doing so far?", + /* + * Record the answer here, but do not take the prompt away — `ChatFeedback` still has a + * thank-you step to show. Hiding on submit removes it before the confirmation renders, + * which reads as the prompt vanishing the instant you answer. + */ + onSubmit: ({ mood, tags, comment }) => { + setAnswer( + [mood, tags.join(', '), comment && `“${comment}”`].filter(Boolean).join(' — '), + ); + }, + // Fired once the thank-you step has been held; this is where the prompt leaves. + onDismiss: () => setShowFeedback(false), + }} + /> + {answer ? ( + + Recorded: {answer} + + ) : null} + {showFeedback ? null : ( + + + The prompt is gone and the composer is back to its plain state. + + + )} + + ); +}; +WithFeedback.storyName = 'With feedback prompt (attached)'; + +/** + * Every point of the scale takes a glyph of your own — a product's icon set, or plain emoji + * characters as below. + * + * Hover or pick one: the selected state is drawn on the *button*, not the glyph, and the glyph + * animates off that same state, so pointer, keyboard and selection all read alike. + */ +export const WithCustomMoodIcons: StoryFn = () => { + const [answers, setAnswers] = useState>({}); + + const record = (row: string) => ({ mood }: { mood: string }) => + setAnswers((prev) => ({ ...prev, [row]: mood })); + + const rows = [ + { + key: 'animated', + label: 'feedbackIcons — animated faces (hover or focus a button)', + feedback: { question: "How's Ray doing so far?", feedbackIcons: defaultFeedbackIcons }, + }, + ]; + + return ( + + {rows.map((row) => ( + + + {row.label} + + + + picked: {answers[row.key] ?? '—'} + + + ))} + + ); +}; +WithCustomMoodIcons.storyName = 'With custom mood icons'; diff --git a/packages/blade/src/components/ChatInput/index.ts b/packages/blade/src/components/ChatInput/index.ts index 6909e1ecea..1ec530c55c 100644 --- a/packages/blade/src/components/ChatInput/index.ts +++ b/packages/blade/src/components/ChatInput/index.ts @@ -1,2 +1,2 @@ -export type { ChatInputProps } from './types'; +export type { ChatInputProps, ChatInputFeedbackProps } from './types'; export { ChatInput } from './ChatInput'; diff --git a/packages/blade/src/components/ChatInput/types.ts b/packages/blade/src/components/ChatInput/types.ts index ea0cf60d8e..df4e14f9b6 100644 --- a/packages/blade/src/components/ChatInput/types.ts +++ b/packages/blade/src/components/ChatInput/types.ts @@ -2,6 +2,55 @@ import type { DataAnalyticsAttribute, TestID } from '~utils/types'; import type { StyledPropsBlade } from '~components/Box/styledProps'; import type { BladeFile, BladeFileList } from '~components/FileUpload/types'; import type { FormInputOnEvent } from '~components/Form/FormTypes'; +import type { ChatFeedbackProps } from '~components/ChatFeedback'; + +/** + * The feedback prompt attached to the top of a `ChatInput`. + * + * A subset of `ChatFeedback`'s props: the flow is the same one, but the parts that describe how + * it is *laid out* are decided by the composer it is attached to, not by the caller. `isFullWidth` + * is fixed because a strip spanning a composer has a width already, and the thank-you copy and + * dismissal are left to `ChatFeedback`'s own defaults. + */ +type ChatInputFeedbackProps = Pick< + ChatFeedbackProps, + | 'question' + | 'moodConfig' + | 'feedbackIcons' + | 'isDisabled' + | 'onMoodSelect' + | 'onSubmit' + | 'onDismiss' +> & { + /** + * Whether the prompt is showing. + * + * The prompt does not remove itself — hide it in response to `onSubmit` or `onDismiss`. Setting + * this back to `true` starts a fresh flow. + * + * @default true + */ + isVisible?: boolean; + + /** + * The tag that collects free text rather than standing on its own. + * + * Picking it hands the composer over to feedback: the placeholder changes, a dismissable + * `Feedback` tag appears in the action bar, and what the user types is submitted as the comment. + * Anything they had typed for the chat is stashed and put back when they leave. + * + * Named rather than inferred from the copy, so it survives translation and a custom `moodConfig`. + * + * @default 'Other' + */ + freeTextTag?: string; + + /** + * Placeholder shown while the composer is collecting free-text feedback. + * @default 'Anything else? (optional)' + */ + commentPlaceholder?: string; +}; type ChatInputProps = { /** @@ -167,8 +216,19 @@ type ChatInputProps = { * Accessibility label for the input. Required when no visible label is present. */ accessibilityLabel?: string; + + /** + * Attaches a feedback prompt to the top of the composer. + * + * The two share one surface, so they read as a single object rather than as a prompt that + * happens to be sitting above an input. Omit this and the composer looks exactly as it does + * without the feature — the surface is only drawn while the prompt is there. + * + * Web only. + */ + feedback?: ChatInputFeedbackProps; } & TestID & DataAnalyticsAttribute & StyledPropsBlade; -export type { ChatInputProps }; +export type { ChatInputProps, ChatInputFeedbackProps }; diff --git a/packages/blade/src/utils/storybook/componentStatusData.ts b/packages/blade/src/utils/storybook/componentStatusData.ts index b5d3aa0102..c81bcc1571 100644 --- a/packages/blade/src/utils/storybook/componentStatusData.ts +++ b/packages/blade/src/utils/storybook/componentStatusData.ts @@ -24,6 +24,21 @@ type ComponentStatusDataType = { }[]; const componentData: ComponentStatusDataType = [ + { + name: 'ChatInput', + description: + 'Composer for conversational surfaces, with file attachments, ghost suggestions and an optional attached feedback prompt.', + platform: 'all', + frameworks: { + react: { + status: 'in-development', + storybookLink: 'Components/ChatInput', + }, + svelte: { + status: 'to-be-decided', + }, + }, + }, { name: 'ChatFeedback', description: