diff --git a/.changeset/chat-feedback-component.md b/.changeset/chat-feedback-component.md new file mode 100644 index 0000000000..4cfc9836df --- /dev/null +++ b/.changeset/chat-feedback-component.md @@ -0,0 +1,20 @@ +--- +'@razorpay/blade': minor +--- + +feat(ChatFeedback): add `ChatFeedback` — a four-point rating flow for conversational surfaces: mood, follow-up tags, and an optional free-text comment. Web only for now; the native counterpart throws until it is implemented + +`moodIcons` is required: Blade ships no artwork for the scale yet, so each point takes a glyph of your own — a product's icon set, or plain emoji characters. When a designed set lands, `moodIcons` becomes optional and a `moodScale` prop picks between sets, which is a non-breaking direction of travel + +fix(ChatFeedback): hold the thank-you step for 1.3s before dismissing. It previously ran on `motion.delay.xgentle` (960ms), and on a strip that is also fading out the confirmation was gone before it registered + +feat(ChatFeedback): closing copy now follows the mood — `moodConfig[mood].thanksLabel`, with defaults that acknowledge rather than celebrate at the unhappy end. A top-level `thanksLabel` still speaks for every mood + +feat(ChatFeedback): add `onTagsChange`, `controlsRef` and `isSubmitHidden`, so a surrounding surface can collect the free-text comment in an input of its own, submit the flow from its own control, and hide the flow's tick while it does + +**Breaking within this unreleased component:** the free-text `comment` step and its `Add more feedback` link are removed, along with `addCommentLabel` and `commentPlaceholder`. Free text is now the host's to collect — `ChatFeedbackProps.comment` folds it into the submit payload. `ChatFeedbackStep` loses `'comment'` + +fix(ChatFeedback): report every change to the tag selection through `onTagsChange`, not only the ones made in the chip group. Picking a new mood and going back both clear the tags, and were previously silent — so a host mirroring the selection acted on tags that no longer existed + +feat(ChatFeedback): ship an animated four-point icon set as the default artwork. Each face is static at rest and animates only while its button is hovered, focused or selected + diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx new file mode 100644 index 0000000000..4c9dc8f63b --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.native.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { ChatFeedbackProps } from './types'; +import { Text } from '~components/Typography'; +import { throwBladeError } from '~utils/logger'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { MetaConstants } from '~utils/metaAttribute'; + +const _ChatFeedback = (_props: ChatFeedbackProps): React.ReactElement => { + throwBladeError({ + message: 'ChatFeedback is not yet implemented for native.', + moduleName: 'ChatFeedback', + }); + + return ChatFeedback is not available for Native mobile apps.; +}; + +const ChatFeedback = assignWithoutSideEffects(_ChatFeedback, { + componentId: MetaConstants.ChatFeedback, + displayName: 'ChatFeedback', +}); + +export { ChatFeedback }; diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx new file mode 100644 index 0000000000..b902d29a20 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.test.stories.tsx @@ -0,0 +1,114 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-extraneous-dependencies */ +import type { StoryFn } from '@storybook/react-vite'; +import { within, waitFor, userEvent, expect } from 'storybook/test'; +import React from 'react'; +import { ChatFeedback } from './index'; +import { Box } from '~components/Box'; + +/** + * Interaction tests for the rating flow. + * + * These run in a real browser because the things worth guarding are geometric: whether a tap + * lands on the point it looks like it lands on, and whether a selection is visible at all. + * Neither is observable in jsdom, which has no layout and paints nothing. + */ +export default { + title: 'Components/ChatFeedback/ChatFeedback Interaction Tests', + component: ChatFeedback, + parameters: { + controls: { disable: true }, + a11y: { disable: false }, + chromatic: { disableSnapshot: true }, + }, +}; + +/** + * Emoji rather than the shipped SVGs on purpose. + * + * Untintable artwork is the harder case: colour does nothing to it and it has no filled twin, so + * these stories prove the selected state survives on the worst input rather than the best. + */ +const feedbackIcons = { + 'very-dissatisfied': 😢, + dissatisfied: 😕, + satisfied: 🙂, + 'very-satisfied': 😍, +}; + +const Flow = (): React.ReactElement => ( + + + +); + +/** + * Each target must contain the point a user aims at, and stop before its neighbour's. + * + * The buttons are 32px around a 20px glyph and butted together, so there is no dead space between + * them: a tap a few pixels wide of a face lands on the next one and records the *adjacent* rating. + * On a four-point scale that is a wrong answer, not a near miss — so this checks the centre and + * both inner edges resolve to the button they appear to belong to. + */ +export const HitTargetsResolveToTheRightMood: StoryFn = (): React.ReactElement => ; + +HitTargetsResolveToTheRightMood.play = async ({ canvasElement }) => { + const { getByRole } = within(canvasElement); + const button = getByRole('radio', { name: 'Good' }); + + await waitFor(() => expect(button).toBeVisible()); + + const box = button.getBoundingClientRect(); + const points = [ + { x: box.left + box.width / 2, y: box.top + box.height / 2 }, + { x: box.left + 2, y: box.top + box.height / 2 }, + { x: box.right - 2, y: box.top + box.height / 2 }, + ]; + + points.forEach(({ x, y }) => { + expect(button.contains(document.elementFromPoint(x, y))).toBe(true); + }); +}; + +/** + * Selection has to be visible even when the glyph cannot carry it. + * + * Supplied artwork may be untintable and has no filled twin, so recolouring the icon does nothing. + * The button's background is the cue that survives — without it, a 12% scale would be the only + * sign a rating registered. + */ +export const SelectionIsVisibleWithUntintableArtwork: StoryFn = (): React.ReactElement => ; + +SelectionIsVisibleWithUntintableArtwork.play = async ({ canvasElement }) => { + const { getByRole } = within(canvasElement); + const button = getByRole('radio', { name: 'Terrible' }); + // The disc is a pseudo-element, so this is the only place it can be read — a real browser. + const discOpacity = (): string => window.getComputedStyle(button, '::before').opacity; + + await waitFor(() => expect(button).toBeVisible()); + expect(discOpacity()).toBe('0'); + + await userEvent.click(button); + + await waitFor(() => expect(discOpacity()).toBe('1')); +}; + +/** The flow advances to the follow-up, and can be walked back to the scale. */ +export const TheFlowAdvancesAndReturns: StoryFn = (): React.ReactElement => ; + +TheFlowAdvancesAndReturns.play = async ({ canvasElement }) => { + const { getByRole, queryByRole } = within(canvasElement); + + await userEvent.click(getByRole('radio', { name: 'Love it!' })); + + await waitFor(() => + expect(queryByRole('radiogroup', { name: 'Rate this experience' })).toBeNull(), + ); + const back = getByRole('button', { name: 'Back to rating' }); + + await userEvent.click(back); + + await waitFor(() => + expect(getByRole('radio', { name: 'Love it!' })).toHaveAttribute('aria-checked', 'false'), + ); +}; diff --git a/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx b/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx new file mode 100644 index 0000000000..b6b9ff5bc5 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/ChatFeedback.web.tsx @@ -0,0 +1,338 @@ +import React from 'react'; +import styled from 'styled-components'; +import type { ChatFeedbackProps, ChatFeedbackStep } from './types'; +import { chatFeedbackChipSize, chatFeedbackSubmitRevealWidth } from './chatFeedbackTokens'; +import { useChatFeedback } from './useChatFeedback'; +import { ChatFeedbackMoodScale } from './ChatFeedbackMoodScale.web'; +import { ChatFeedbackCheck } from './ChatFeedbackCheck.web'; +import { defaultFeedbackIcons } from './moodIcons'; +import BaseBox from '~components/Box/BaseBox'; +import { getStyledProps } from '~components/Box/styledProps'; +import { BaseMotionBox } from '~components/BaseMotion'; +import type { MotionVariantsType } from '~components/BaseMotion'; +import { useTheme } from '~components/BladeProvider'; +import { Button } from '~components/Button'; +import { IconButton } from '~components/Button/IconButton'; +import { Chip, ChipGroup } from '~components/Chip'; +import { CheckIcon, ChevronLeftIcon } from '~components/Icons'; +import { Text } from '~components/Typography'; +import { castWebType, makeSize } from '~utils'; +import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects'; +import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute'; +import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; +import { msToSeconds } from '~utils/msToSeconds'; +import { cssBezierToArray } from '~utils/cssBezierToArray'; +import { useIsMobile } from '~utils/useIsMobile'; +import { chipGroupGapTokens } from '~components/Chip/chipTokens'; +import getIn from '~utils/lodashButBetter/get'; + +/** + * `ChipGroup` always reserves space beneath its chips for the `FormHint` slot, even when no + * `helpText` or `errorText` is set. That makes its box taller than the chips themselves, so + * centring it in a row leaves the chips sitting above the submit button's centre line. + * + * Cancelling exactly that reserved space realigns the two. The amount is read from the same + * token `ChipGroup` uses, so the two cannot drift apart. + */ +const ChipGroupAligner = styled(BaseBox)(({ theme }) => ({ + marginBottom: `-${getIn(theme, chipGroupGapTokens[chatFeedbackChipSize].bottom)}px`, +})); + +/** + * Holds the space its submit control will occupy, before the control exists. + * + * Ported from the prototype's chip row. The control is revealed on the first tag pick; letting it + * arrive in the flow moved every chip 36px left in the same frame, which is the one moment the + * user is reading those chips. So the row grows its own right edge on the settle curve and the + * control fades into the space that opens, and the chips never move at all. + */ +const SubmitReserve = styled(BaseBox)<{ $hasSubmit: boolean }>(({ theme, $hasSubmit }) => ({ + position: 'relative', + paddingRight: $hasSubmit ? makeSize(chatFeedbackSubmitRevealWidth) : '0px', + transition: `padding-right ${theme.motion.duration.moderate}ms ${castWebType( + theme.motion.easing.settle, + )}`, +})); + +/** + * The control itself, parked in the space above. + * + * Absolute so its arrival cannot lay anything out, and `visibility` trails the fade by the fade's + * own duration so it leaves the tab order only once it has actually gone — a transparent button + * that still takes focus is worse than one that pops. + */ +const SubmitSlot = styled.span<{ $isReady: boolean }>(({ theme, $isReady }) => { + const fade = theme.motion.duration.moderate; + return { + position: 'absolute', + right: 0, + top: '50%', + transform: 'translateY(-50%)', + display: 'inline-flex', + opacity: $isReady ? 1 : 0, + visibility: $isReady ? 'visible' : 'hidden', + pointerEvents: $isReady ? 'auto' : 'none', + transition: `opacity ${fade}ms ${castWebType(theme.motion.easing.entrance)}, visibility 0s ${ + $isReady ? '0s' : `${fade}ms` + }`, + }; +}); + +const _ChatFeedback = ({ + question = 'How are we doing so far?', + moodConfig, + onMoodSelect, + onTagsChange, + controlsRef, + isSubmitHidden = false, + onSubmit, + onDismiss, + thanksLabel, + autoDismiss = true, + isFullWidth = true, + isDisabled = false, + feedbackIcons = defaultFeedbackIcons, + testID, + ...rest +}: ChatFeedbackProps): React.ReactElement => { + const { theme } = useTheme(); + const isMobile = useIsMobile(); + + /** + * Full-width steps spread edge to edge; hugging steps are only as wide as their content + * and rely on the gap to separate the prompt from the controls. This mirrors how the + * prototype distinguishes the attached strip from the floating bar — the bar itself does + * nothing special, it is the step that stops stretching. + */ + const rowLayout = isFullWidth + ? ({ width: '100%', justifyContent: 'space-between', gap: 'spacing.4' } as const) + : ({ width: undefined, justifyContent: 'flex-start', gap: 'spacing.3' } as const); + + const { + step, + selectedMood, + selectedTags, + question: followUpQuestion, + thanksLabel: moodThanksLabel, + tags, + hasSelectedTags, + selectMood, + setSelectedTags, + submitTags, + goBackToMood, + } = useChatFeedback({ + moodConfig, + onMoodSelect, + onTagsChange, + onSubmit, + onDismiss, + autoDismiss, + }); + + /* + * The handle is published in an effect, and delegates through refs. + * + * Two constraints pull against each other. It cannot be written during render — that is a side + * effect in render, which double-fires under StrictMode and is unsafe once rendering can be + * interrupted. But it also cannot be captured once, because `submitTags` closes over the current + * selection: a handle built on mount would submit whatever happened to be picked at the time. + * + * Delegating satisfies both. The published functions are stable and read the latest + * implementations out of refs when called, so the handle is written once per `controlsRef`, + * never during render, and is never stale. It is cleared on unmount so a host cannot drive a + * flow that is no longer on screen. + */ + const latest = React.useRef({ submitTags, setSelectedTags }); + React.useLayoutEffect(() => { + latest.current = { submitTags, setSelectedTags }; + }); + + React.useLayoutEffect(() => { + if (!controlsRef) return undefined; + controlsRef.current = { + submit: () => latest.current.submitTags(), + setTags: (values) => latest.current.setSelectedTags(values), + }; + + return () => { + controlsRef.current = null; + }; + }, [controlsRef]); + + // Steps arrive on the settle curve. The exit is a plain fade — the incoming step is the + // thing worth watching, so the outgoing one should get out of the way quickly. + const stepInVariants: MotionVariantsType = { + initial: { opacity: 0, y: -4 }, + animate: { + opacity: 1, + y: 0, + transition: { + duration: msToSeconds(theme.motion.duration.quick), + ease: cssBezierToArray(castWebType(theme.motion.easing.settle)), + }, + }, + exit: { + opacity: 0, + transition: { + duration: msToSeconds(theme.motion.duration['2xquick']), + ease: cssBezierToArray(castWebType(theme.motion.easing.exit)), + }, + }, + }; + + // A record rather than a switch, so TypeScript can prove every step renders an element + // and the motion wrapper never receives null. + const stepRenderers: Record React.ReactElement> = { + mood: () => ( + + + {question} + + + + ), + + // On mobile the question claims its own line: a prompt, four chips and a submit + // cannot share a row below ~400px without overflowing. + tags: () => ( + + {/* Prompt holds one line (flexShrink 0); when the chips + submit can't fit beside + it, the whole group below wraps to the next line rather than the submit + orphaning onto a row of its own. */} + + + + {followUpQuestion} + + + + + + setSelectedTags(values)} + > + {tags.map((tag) => ( + + {tag} + + ))} + + + + {/* + Kept mounted and revealed, rather than mounted on first pick. `SubmitReserve` above has + already opened the space it lands in, so revealing it costs the chips nothing — which is + what makes it safe to hide in the first place. + */} + + + + + + + + + + +`; diff --git a/packages/blade/src/components/ChatFeedback/_decisions/decisions.md b/packages/blade/src/components/ChatFeedback/_decisions/decisions.md new file mode 100644 index 0000000000..347c3f9789 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/_decisions/decisions.md @@ -0,0 +1,274 @@ +# ChatFeedback 🙂 + +A compact, inline feedback flow for conversational surfaces. It walks the user from a four-point +mood scale, to a quick tag follow-up, to a confirmation — and then leaves. It draws no surface of +its own and it never removes itself. + +Currently **web only**; the native counterpart throws until it is implemented. + +## Design + +- Figma: `Agentic Dashboard — Working File`, node `380:81103` +- The attached-to-a-composer arrangement is `ChatInput`'s `feedback` prop, specified at the end of + this document. + +## States + +Three steps, one direction of travel, and one way back. + +``` + ┌────────────── goBackToMood ──────────────┐ + ▼ │ + ┌──────┐ selectMood ┌──────┐ submitTags ┴─────────┐ after 1.3s ┌──────────┐ + │ mood │ ─────────────► │ tags │ ───────────► │ thanks │ ─────────────► │ dismissed │ + └──────┘ (+200ms) └──────┘ └─────────┘ autoDismiss └──────────┘ +``` + +`dismissed` is not a step. The component has no such state — it fires `onDismiss` and keeps +rendering the confirmation until the host stops rendering it. That is deliberate: a component that +removes itself cannot be animated out by whatever contains it. + +### mood + +| | | +|---|---| +| Shows | the question, and four buttons carrying `feedbackIcons` | +| Sub-states | nothing selected · one selected (200ms, then `tags`) | +| Emits | `onMoodSelect` | + +The 200ms hold exists so the selection is seen before the step changes. It is a `theme.motion.duration.quick` beat, not a magic number. + +### tags + +| | | +|---|---| +| Shows | back control, follow-up question, up to four chips, and the submit | +| Sub-states | nothing selected · some selected · **only the free-text tag** · mixed | +| Emits | `onTagsChange` on **every** change, `onSubmit` on submit | + +Selecting a mood, or going back, also clears the tags — and both report through `onTagsChange`. +This matters more than it looks: see *Reporting* below. + +### thanks + +| | | +|---|---| +| Shows | a filled check and one line, whose copy follows the mood | +| Held for | `chatFeedbackThanksDurationMs` (1300ms) when `autoDismiss` | +| Emits | `onDismiss` | + +Copy resolution is `thanksLabel` prop → `moodConfig[mood].thanksLabel` → a generic default. The +per-mood defaults differ by sentiment: someone who has just said the assistant got it wrong is +acknowledged, not thanked for a lovely contribution. + +### Orthogonal to the steps + +- **`isDisabled`** — every control inert, at any step. +- **`isFullWidth`** — each step spreads edge to edge, or hugs its content. +- **`isSubmitHidden`** — the step keeps its state; only the tick goes. + +## Invariants + +These are the properties worth testing, and the ones that were broken at some point during the +build: + +1. **A tap resolves to the mood it looks like.** Targets are 32px around a 20px glyph, butted + together for a 32px pitch. On a four-point scale, landing on the neighbour is a wrong answer, + not a near miss. +2. **Selection is visible without relying on the glyph.** Supplied artwork may be untintable and + has no filled twin, so hover and selected are drawn on the *button*. +3. **The flow never traps the user.** Every state has a way forward or back. This is the invariant + the free-text arrangement broke three separate times. +4. **The host is never told less than the truth.** Any change to the selection is reported. +5. **Nothing is announced twice.** The mood's name is the button's accessible name; supplied + artwork is `aria-hidden`. + +## Reporting + +`onTagsChange` fires from a single setter inside the hook, not from the chip group. Every route +that mutates the selection — the chips, picking a new mood, going back — goes through it. + +This was originally wired to the chip group alone, and the two internal clears were silent. A host +mirroring the selection then held tags that no longer existed and acted on them: `ChatInput` kept +its composer in feedback mode after the strip had walked back to the mood step, with no tag +selected and no way out. One reporting path makes that drift impossible rather than unlikely. + +**Corollary for hosts:** read the mode you are driving from a ref, not from state. These callbacks +can be invoked from a memoised closure belonging to an earlier render, and a state copy read there +may be stale. That is the second way the same bug appeared. + +## Proposed API + +```jsx + record(mood, tags, comment)} + onDismiss={() => setShowFeedback(false)} +/> +``` + +### Props + +```ts +type ChatFeedbackMood = 'very-dissatisfied' | 'dissatisfied' | 'satisfied' | 'very-satisfied'; +type ChatFeedbackStep = 'mood' | 'tags' | 'thanks'; +type ChatFeedbackIcons = Record; + +type ChatFeedbackMoodConfig = { + /** Follow-up question shown once this mood is picked. */ + question: string; + /** Quick-select tags offered for this mood. */ + tags: string[]; + /** Closing line for this mood. Defaults differ by sentiment. */ + thanksLabel?: string; +}; + +type ChatFeedbackSubmitPayload = { + mood: ChatFeedbackMood; + /** Tags selected. Empty when submitted without picking any. */ + tags: string[]; + /** Free text, present only when the host collected some. */ + comment?: string; +}; + +/** The parts of a running flow a host may need to drive. */ +type ChatFeedbackControls = { + submit: () => void; + setTags: (tags: string[]) => void; +}; + +type ChatFeedbackProps = { + /** @default "How are we doing so far?" */ + question?: string; + + /** + * Artwork for the scale, one entry per mood. Optional — Blade ships an animated set and uses it + * when omitted. Rendered as given, in a fixed box, hidden from assistive technology. + */ + feedbackIcons?: ChatFeedbackIcons; + + /** Overrides follow-up copy, tags and closing line per mood. Partial: unlisted moods keep defaults. */ + moodConfig?: Partial>; + + /** Closing line for every mood. Unset, the copy follows the mood. */ + thanksLabel?: string; + + onMoodSelect?: ({ mood }: { mood: ChatFeedbackMood }) => void; + /** Fires on **every** change to the selection, from any route. */ + onTagsChange?: ({ tags }: { tags: string[] }) => void; + onSubmit?: (payload: ChatFeedbackSubmitPayload) => void; + /** The flow is finished. It does **not** remove itself — stop rendering it here. */ + onDismiss?: () => void; + + /** Dismisses itself 1.3s after the confirmation. @default true */ + autoDismiss?: boolean; + /** Steps spread edge to edge, or hug their content. @default true */ + isFullWidth?: boolean; + /** @default false */ + isDisabled?: boolean; + + /** Hides this flow's own tick, when the host is showing one. @default false */ + isSubmitHidden?: boolean; + /** Receives a handle on the running flow, for a host with controls of its own. */ + controlsRef?: React.MutableRefObject; + /** Free text gathered by the host, folded into the submit payload. */ + comment?: string; +} & TestID & + DataAnalyticsAttribute & + StyledPropsBlade; +``` + +## Host contract: `ChatInput`'s `feedback` + +```jsx + record(mood, tags, comment), + onDismiss: () => setShowFeedback(false), + }} +/> +``` + +```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; +}; +``` + +Passing the object is the switch — there is no `showFeedback` boolean, in the same way `Tooltip` +has no `showTitle`. Omit it and the composer renders byte-for-byte as it does without the feature; +the surface is only drawn while the prompt is showing. + +`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, not +the caller's. + +### The composer takeover + +Picking `freeTextTag` hands the composer over: + +| | | +|---|---| +| Placeholder | becomes `commentPlaceholder` | +| Chat draft | stashed, and restored on the way out | +| 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 tick | hidden — the composer's send arrow is the submit | +| Exits | Esc · the tag's ✕ · submitting · deselecting the tag · the prompt going away | + +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. + +Enter is blocked rather than rerouted because sending someone's candid feedback to the assistant as +a prompt is not a recoverable mistake. + +## Alternatives considered + +**A slot for the whole strip** (`header?: ReactNode`) — smaller API, and it would have kept +`ChatFeedback` out of Blade entirely. Rejected because a `ReactNode` slot has no representation in +the Figma DSL: designers could neither toggle nor preview it, and code/design parity breaks. It +would also hand every consumer the a11y, hit targets and selected state — the three things that +were hardest to get right here. + +**A slot for the scale.** Same objection at smaller scale, plus it hides a contract types cannot +express ("an SVG that inherits `currentColor`"). `feedbackIcons` swaps the glyphs, not the control. + +**`moodScale: 'faces' | 'thumbs'`.** Built, then removed: both values depended on artwork that did +not exist at the time. A switch with no working position is worse than no switch. Blade now ships a +single animated set as the default, and `feedbackIcons` is the override — if a second named set +ever earns its place, `moodScale` can return alongside it. + +**Its own free-text step.** The component used to own a `comment` step behind an +"Add more feedback" link. Removed: two places to type stacked vertically, and the one that looks +like the composer was not the one with focus. + +## Open questions + +1. **The host coupling is four props** — `onTagsChange`, `isSubmitHidden`, `controlsRef`, `comment` + — which together express one idea: *the surrounding surface is collecting the free text*. It + works, and every piece is load-bearing, but a single higher-level prop would be harder to + misuse. Worth revisiting once a second host exists; premature to design for one. +2. **`controlsRef` is a prop rather than a forwarded ref.** The handle itself is published from a + layout effect and delegates through refs, so it is neither written during render nor ever stale. + A forwarded ref with `useImperativeHandle` would be the more idiomatic shape, but it would make + the flow's identity a ref — which `ChatInput` already spends on the composer's own input. +3. **`freeTextTag` fails silently** when it matches no tag in `moodConfig` — the composer simply + never engages. A `__DEV__` warning would fail fast instead. +4. **Native.** The whole component throws. The mood scale and tags step have no platform-specific + requirement; the composer takeover does. +5. **Standalone free-text.** With the comment step gone, a surface without a composer — a floating + bar, say — cannot collect free text at all. diff --git a/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts b/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts new file mode 100644 index 0000000000..f6dd83eebe --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/chatFeedbackTokens.ts @@ -0,0 +1,181 @@ +import type { ChatFeedbackMood, ChatFeedbackMoodConfig } from './types'; +import type { IconProps } from '~components/Icons'; + +/** Worst to best. Drives render order of the scale. */ +const chatFeedbackMoods: ChatFeedbackMood[] = [ + 'very-dissatisfied', + 'dissatisfied', + 'satisfied', + 'very-satisfied', +]; + +/** + * ## Blade ships no artwork for this scale yet + * + * The four points, their colours and their names are settled; the glyphs are not. Rather than + * ship a placeholder set and have products build on it, the component asks for the artwork: + * `feedbackIcons` is required, and this table carries everything *except* the icons. + * + * When a designed set does land it goes here, `feedbackIcons` becomes optional, and a `moodScale` + * prop picks between the sets. That direction is safe — a required prop becoming optional breaks + * no one, which is why the artwork is missing rather than provisional. + * + * Blade's feedback ramp has three usable sentiments, so `satisfied` and `very-satisfied` share + * `positive`; the design distinguished them with a yellow-green, which has no token equivalent. + */ +/** `currentColor` excluded so the value can also be resolved against the theme for CSS. */ +type ChatFeedbackMoodColor = Exclude; + +/** The three sentiments the four moods map onto, as background tokens. */ +type ChatFeedbackMoodSurfaceColor = `feedback.background.${ + | 'positive' + | 'negative' + | 'notice'}.subtle`; + +const chatFeedbackMoodTokens: Record< + ChatFeedbackMood, + { + color: ChatFeedbackMoodColor; + /** + * Filled behind the button when the mood is hovered or picked. + * + * Selection used to be carried entirely by the glyph — recoloured, and swapped for its filled + * twin. That only works while Blade owns the artwork. A consumer supplying its own icons + * through `feedbackIcons` may pass something that cannot be tinted at all (a system emoji, a + * raster), and then two of the three selected cues silently vanish, leaving a 12% scale as the + * only sign that an answer registered. Putting the state on the button instead means it reads + * the same whatever is sitting on top of it. + */ + surfaceColor: ChatFeedbackMoodSurfaceColor; + label: string; + } +> = { + 'very-dissatisfied': { + color: 'feedback.icon.negative.intense', + surfaceColor: 'feedback.background.negative.subtle', + label: 'Terrible', + }, + dissatisfied: { + color: 'feedback.icon.notice.intense', + surfaceColor: 'feedback.background.notice.subtle', + label: 'Bad', + }, + satisfied: { + color: 'feedback.icon.positive.intense', + surfaceColor: 'feedback.background.positive.subtle', + label: 'Good', + }, + 'very-satisfied': { + color: 'feedback.icon.positive.intense', + surfaceColor: 'feedback.background.positive.subtle', + label: 'Love it!', + }, +}; + +/** + * Default follow-up copy. Deliberately product-agnostic — consumers override via `moodConfig`. + */ +const chatFeedbackDefaultMoodConfig: Record = { + 'very-dissatisfied': { + question: 'Sorry to hear that. What went wrong?', + tags: ['Wrong answers', 'Too slow', 'Inaccurate', 'Other'], + thanksLabel: "Thanks for flagging it — we'll look into this.", + }, + dissatisfied: { + question: 'Thanks. What could be better?', + tags: ['Accuracy', 'Speed', 'Tone', 'Other'], + thanksLabel: "Thanks — we'll work on this.", + }, + satisfied: { + question: 'Glad it helped! What worked well?', + tags: ['Helpful', 'Fast', 'Clear', 'Other'], + thanksLabel: 'Thanks for the feedback!', + }, + 'very-satisfied': { + question: 'Love it! What stood out most?', + tags: ['Nailed it', 'Super fast', 'Great tone', 'Other'], + thanksLabel: 'Thanks — glad it helped!', + }, +}; + +/** + * Chip size used by the tags step. Exported so the alignment fix in `ChatFeedback` can read + * the matching `chipGroupGapTokens` entry instead of hardcoding a value that would silently + * drift if this size ever changes. + */ +const chatFeedbackChipSize = 'xsmall' as const; + +/** + * How long the thank-you step is held before the flow dismisses itself. + * + * Long enough to be read — the previous value came from `motion.delay.xgentle` (960ms), and on a + * strip that is also fading out the confirmation was gone before it registered. Short enough that + * nobody waits on it: this sits above a composer someone is trying to type in. + * + * A literal rather than a delay token because the scale steps 960 → 2000 with nothing between, + * and both ends are wrong here. If a token lands in that gap, this should become it. + */ +const chatFeedbackThanksDurationMs = 1300; + +/** + * Edge of the square a mood glyph is drawn in. + * + * Off the spacing scale, which stops at 24 and jumps to 32. A face is not an icon: an icon is one + * shape read at a glance, while these carry eyes, a mouth and — on two of them — a thumb, and + * every one has to survive the same pass. At the 20px this used to be, the faces landed around + * 15px and read as coloured dots rather than as expressions. + */ +const chatFeedbackMoodGlyphSize = 28; + +/** + * Height of a mood button, and so of the tallest step the strip has to hold. + * + * The glyph plus `spacing[3]` of padding on each side. + * + * It is exported because the strip must reserve this much for *every* step, not just the mood + * one. The three steps have different natural heights, and a strip that only reserves what the + * current step needs changes height on each swap — pushing the composer below it up and down at + * the exact moment someone is reading the step that just replaced the last one. + * + * Anything that sets the strip's height reads it from here so the two cannot drift, which they + * did once already: the glyph grew from 20px and the reserved height was left behind, and the + * composer started jumping 12px on every transition. + */ +const chatFeedbackMoodButtonSize = chatFeedbackMoodGlyphSize + 8 * 2; + +/** + * The tinted disc shown behind a glyph on hover, focus and selection. + * + * Smaller than the button on purpose. The button's size is a tap target and belongs at 44px; the + * disc is decoration. Sizing the disc off the button would force a choice between a comfortable + * target and a tidy circle — shrinking the target to tighten the circle is what let the + * adjacent-rating mis-taps back in once already. + * + * At 36px it clears the 28px glyph by 4px on every side: enough to read as a halo behind the face + * rather than as a chip around it, and still 4px inside the button it sits in. + */ +const chatFeedbackMoodDiscSize = 36; + +/** + * Space the tags step holds open for its submit control. + * + * Blade's `xsmall` icon-only `Button` measures 28px, plus `spacing[3]` of gap before it. + * + * It is reserved rather than occupied. The control is revealed on the first tag pick, and a button + * that arrives *in* the flex row shoves every chip 36px to the left at the moment the user is + * reading them — measured, not assumed. Holding the space and fading the control into it means the + * row makes room instead of being pushed aside. + */ +const chatFeedbackSubmitRevealWidth = 28 + 8; + +export { + chatFeedbackMoods, + chatFeedbackMoodTokens, + chatFeedbackDefaultMoodConfig, + chatFeedbackChipSize, + chatFeedbackThanksDurationMs, + chatFeedbackMoodGlyphSize, + chatFeedbackMoodButtonSize, + chatFeedbackMoodDiscSize, + chatFeedbackSubmitRevealWidth, +}; diff --git a/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx b/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx new file mode 100644 index 0000000000..06bc702170 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/docs/ChatFeedback.stories.tsx @@ -0,0 +1,113 @@ +import type { StoryFn, Meta } from '@storybook/react-vite'; +import React, { useState } from 'react'; +import { ChatFeedback } from '../ChatFeedback'; +import type { ChatFeedbackProps } from '../types'; +import { Box } from '~components/Box'; +import { Button } from '~components/Button'; +import { Heading } from '~components/Typography'; +import { getStyledPropsArgTypes } from '~components/Box/BaseBox/storybookArgTypes'; +import StoryPageWrapper from '~utils/storybook/StoryPageWrapper'; +import { Sandbox } from '~utils/storybook/Sandbox'; + +const Page = (): React.ReactElement => { + return ( + + Usage + + {` + import { ChatFeedback } from '@razorpay/blade/components'; + + function App() { + const [show, setShow] = React.useState(true); + + if (!show) return null; + + + return ( + console.log('feedback', payload)} + onDismiss={() => setShow(false)} + /> + ); + } + + export default App; + `} + + + ); +}; + +export default { + title: 'Components/ChatFeedback', + component: ChatFeedback, + tags: ['autodocs'], + argTypes: { + ...getStyledPropsArgTypes(), + }, + parameters: { + docs: { page: Page }, + }, +} as Meta; + +/** + * ChatFeedback does not remove itself — it fires `onDismiss` when the flow ends and the + * consumer hides it. The replay button remounts a fresh flow, since no state survives unmount. + */ +const ChatFeedbackTemplate: StoryFn = (args) => { + const [show, setShow] = useState(true); + + return ( + + {show ? ( + { + // eslint-disable-next-line no-console + console.log('onSubmit', payload); + }} + onDismiss={() => setShow(false)} + /> + ) : ( + + )} + + ); +}; + +export const Default = ChatFeedbackTemplate.bind({}); +Default.args = { question: "How's Ray doing so far?" }; +Default.storyName = 'Default'; + +export const WithoutAutoDismiss = ChatFeedbackTemplate.bind({}); +WithoutAutoDismiss.args = { + question: "How's Ray doing so far?", + // Nothing hides the flow after the thank-you; the consumer stays in control. + autoDismiss: false, +}; +WithoutAutoDismiss.storyName = 'Without auto dismiss'; + +export const CustomMoodConfig = ChatFeedbackTemplate.bind({}); +CustomMoodConfig.args = { + question: 'How was this answer?', + // Only the moods you pass are overridden; the rest keep their defaults. + moodConfig: { + 'very-satisfied': { + question: 'Amazing! What made it great?', + tags: ['Accurate', 'Well written', 'Saved me time', 'Other'], + }, + 'very-dissatisfied': { + question: 'Sorry about that. What broke?', + tags: ['Wrong', 'Off-topic', 'Too vague', 'Other'], + }, + }, +}; +CustomMoodConfig.storyName = 'Custom mood config'; diff --git a/packages/blade/src/components/ChatFeedback/index.ts b/packages/blade/src/components/ChatFeedback/index.ts new file mode 100644 index 0000000000..3a8db9fe67 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/index.ts @@ -0,0 +1,9 @@ +export type { + ChatFeedbackControls, + ChatFeedbackProps, + ChatFeedbackMood, + ChatFeedbackMoodConfig, + ChatFeedbackStep, + ChatFeedbackSubmitPayload, +} from './types'; +export { ChatFeedback } from './ChatFeedback'; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx new file mode 100644 index 0000000000..b194346e7b --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/BadIcon.tsx @@ -0,0 +1,141 @@ +/** + * Thumbs-down over a disappointed face. + * + * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states, + * which styled-components/native cannot express. + * + * The button owns the hover target, not this glyph. A 20px face inside a 32px control means + * hovering the padding — most of the control — would leave the icon inert if the animation hung + * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead. + */ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import type { MoodIconProps } from './types'; +import { WHEN_ACTIVE } from './whenActive'; + +/* Mirrors GoodIcon exactly, negated: the pair is one gesture in two directions. */ +const reject = keyframes` + 0% { + transform: translateY(0) rotate(0deg) scale(1); + animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1); + } + 18% { + transform: translateY(-0.22px) rotate(-1.5deg) scale(0.99); + animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6); + } + 52% { + transform: translateY(1.15px) rotate(6deg) scale(1.12); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1); + } + 74% { transform: translateY(0.6px) rotate(2.5deg) scale(1.06); } + 100% { transform: translateY(0.85px) rotate(4.5deg) scale(1.09); } +`; + +const recoil = keyframes` + 0% { transform: translate(0, 0); } + 55% { transform: translate(0.22px, 0.36px); } + 100% { transform: translate(0.16px, 0.28px); } +`; + +const Svg = styled.svg` + /* + * Centred on the resting composite, which puts the press about a pixel past the box at its + * deepest. Nothing between here and the button clips — the slot is an inline-flex span and the + * button sets no overflow — so the frame is allowed to paint rather than shifting the artwork + * off-centre for the other 99% of the time it is sitting still. + */ + overflow: visible; + + .face { + transform-box: fill-box; + transform-origin: center; + } + + /* Pivots at the wrist, which sits at the top of a thumb pointing down. */ + .thumb { + transform-box: fill-box; + transform-origin: 50% 8%; + } + + ${WHEN_ACTIVE} { + .thumb { + animation: ${reject} 0.6s forwards; + } + + .face { + animation: ${recoil} 0.6s cubic-bezier(0.33, 0, 0.3, 1) forwards; + } + } + /* No Blade token for this yet, so the query is inline. */ + @media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + } + } +`; + +const BadIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => { + const gradientId = React.useId(); + + return ( + + ); +}; + +export { BadIcon }; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx new file mode 100644 index 0000000000..b051bef17f --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/GoodIcon.tsx @@ -0,0 +1,101 @@ +/** + * Thumbs-up over a beaming face. + * + * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states, + * which styled-components/native cannot express. + * + * The button owns the hover target, not this glyph. A 20px face inside a 32px control means + * hovering the padding — most of the control — would leave the icon inert if the animation hung + * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead. + */ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import type { MoodIconProps } from './types'; +import { WHEN_ACTIVE } from './whenActive'; + +const approve = keyframes` + 0% { + transform: translateY(0) rotate(0deg) scale(1); + animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1); + } + 18% { + transform: translateY(0.22px) rotate(1.5deg) scale(0.99); + animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.6); + } + 52% { + transform: translateY(-1.15px) rotate(-6deg) scale(1.12); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1); + } + 74% { transform: translateY(-0.6px) rotate(-2.5deg) scale(1.06); } + 100% { transform: translateY(-0.85px) rotate(-4.5deg) scale(1.09); } +`; + +const Svg = styled.svg` + /* Pivots at the wrist, at the bottom of a thumb pointing up. */ + .thumb { + transform-box: fill-box; + transform-origin: 50% 92%; + } + + ${WHEN_ACTIVE} { + .thumb { + animation: ${approve} 0.6s forwards; + } + } + /* No Blade token for this yet, so the query is inline. */ + @media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + } + } +`; + +const GoodIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => { + return ( + + ); +}; + +export { GoodIcon }; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx new file mode 100644 index 0000000000..3dedb23326 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/LoveItIcon.tsx @@ -0,0 +1,107 @@ +/** + * Heart eyes — the top of the scale. + * + * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states, + * which styled-components/native cannot express. + * + * The button owns the hover target, not this glyph. A 20px face inside a 32px control means + * hovering the padding — most of the control — would leave the icon inert if the animation hung + * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead. + */ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import type { MoodIconProps } from './types'; +import { WHEN_ACTIVE } from './whenActive'; + +const pop = keyframes` + 0% { transform: scale(1); } + 38% { transform: scale(1.28); } + 64% { transform: scale(1.1); } + 84% { transform: scale(1.2); } + 100% { transform: scale(1.14); } +`; + +const beat = keyframes` + 0%, 100% { transform: scale(1.14); } + 20% { transform: scale(1.26); } + 36% { transform: scale(1.14); } + 52% { transform: scale(1.21); } + 70% { transform: scale(1.14); } +`; + +const Svg = styled.svg` + .heart { + transform-box: fill-box; + transform-origin: center; + } + + ${WHEN_ACTIVE} { + .heart { + animation: ${pop} 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, + ${beat} 1s ease-in-out 0.5s infinite; + } + + /* Offset so the pair beats as two hearts rather than one wide shape. */ + .right { + animation-delay: 0.05s, 0.55s; + } + } + /* No Blade token for this yet, so the query is inline. */ + @media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + } + } +`; + +const LoveItIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => { + return ( + + ); +}; + +export { LoveItIcon }; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx new file mode 100644 index 0000000000..6b1d13d395 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/TerribleIcon.tsx @@ -0,0 +1,138 @@ +/** + * Crying face — the worst rung of the scale. + * + * Web only: the motion is CSS driven by the mood button's own hover, focus and selected states, + * which styled-components/native cannot express. + * + * The button owns the hover target, not this glyph. A 20px face inside a 32px control means + * hovering the padding — most of the control — would leave the icon inert if the animation hung + * off the SVG itself, so every rule below is keyed on an ancestor `button` state instead. + */ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; +import type { MoodIconProps } from './types'; +import { WHEN_ACTIVE } from './whenActive'; + +const droop = keyframes` + 0%, 100% { transform: translateY(0) scaleY(1); } + 55% { transform: translateY(0.35px) scaleY(0.985); } +`; + +/* Starts and ends on the resting pose, so hovering on or off never jumps the tear. */ +const fall = keyframes` + 0% { transform: translate(0, 0) scale(1); opacity: 1; } + 18% { transform: translate(0.15px, 0.3px) scale(0.85); opacity: 0; } + 19% { transform: translate(-0.8px, -4.2px) scale(0.34); opacity: 0; } + 26% { transform: translate(-0.75px, -4px) scale(0.5); opacity: 1; } + 40% { + transform: translate(-0.55px, -3.1px) scale(0.7); + opacity: 1; + animation-timing-function: cubic-bezier(0.5, 0, 0.85, 0.45); + } + 78% { transform: translate(-0.05px, -0.15px) scale(1.02); opacity: 1; } + 94% { transform: translate(0, 0) scale(1.02); opacity: 1; } + 100% { transform: translate(0, 0) scale(1); opacity: 1; } +`; + +const Svg = styled.svg` + .face { + transform-box: fill-box; + transform-origin: center; + } + + /* The tear stays visible at rest — without it this reads as a plain sad face. */ + .tear { + transform-box: fill-box; + transform-origin: 50% 0%; + } + + ${WHEN_ACTIVE} { + .face { + animation: ${droop} 2.8s ease-in-out infinite; + } + + .tear { + animation: ${fall} 2.8s linear infinite; + } + } + /* No Blade token for this yet, so the query is inline. */ + @media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + } + } +`; + +const TerribleIcon = ({ size = 28 }: MoodIconProps): React.ReactElement => { + const gradientId = React.useId(); + + return ( + + ); +}; + +export { TerribleIcon }; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx b/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx new file mode 100644 index 0000000000..b76a9f1219 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/index.tsx @@ -0,0 +1,32 @@ +/** + * Artwork for the four-point rating scale. + * + * Not exported from Blade's public entry point. These are fixed-palette faces with gradients and + * a coloured thumb outline — they cannot honour a `color` prop, so shipping them through the icon + * pipeline would hand consumers an icon that silently ignores half its contract. They stay here, + * beside the only component that renders them, until there is a reason to promote them. + * + * Each is static at rest and animates only while its button is hovered, focused or selected. + */ +import React from 'react'; +import { TerribleIcon } from './TerribleIcon'; +import { BadIcon } from './BadIcon'; +import { GoodIcon } from './GoodIcon'; +import { LoveItIcon } from './LoveItIcon'; +import type { ChatFeedbackIcons } from '~components/ChatFeedback/types'; + +/** + * Ready to pass straight to `feedbackIcons`. + * + * Declared once and imported wherever the scale appears, so changing the artwork is one edit + * rather than one per composer. + */ +const defaultFeedbackIcons: ChatFeedbackIcons = { + 'very-dissatisfied': , + dissatisfied: , + satisfied: , + 'very-satisfied': , +}; + +export { defaultFeedbackIcons, TerribleIcon, BadIcon, GoodIcon, LoveItIcon }; +export type { MoodIconProps } from './types'; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/types.ts b/packages/blade/src/components/ChatFeedback/moodIcons/types.ts new file mode 100644 index 0000000000..88a22d971f --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/types.ts @@ -0,0 +1,11 @@ +type MoodIconProps = { + /** + * Rendered size, in px unless a CSS length is given. + * + * Defaults to 28, which is what `ChatFeedbackMoodScale`'s slot gives it. The artwork is drawn + * in a 24 unit box, so a face lands at 21px of that. + */ + size?: string | number; +}; + +export type { MoodIconProps }; diff --git a/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts b/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts new file mode 100644 index 0000000000..b53d72159d --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/moodIcons/whenActive.ts @@ -0,0 +1,16 @@ +/** + * The states a mood glyph animates on. + * + * The glyph is not the hover target. `ChatFeedbackMoodScale` draws a 20px face inside a 32px + * button, so two thirds of the control is padding; keying the animation on the SVG itself would + * leave it inert for most of the area a merchant actually points at, and dead entirely for anyone + * on a keyboard. Every rule hangs off the ancestor button instead. + * + * `aria-checked` is included because the button treats hover, focus and selection as one + * treatment. A glyph that snapped back to rest the moment the pointer left would undo the only + * confirmation a merchant gets that the rating registered. + */ +const WHEN_ACTIVE = + "button:hover:not(:disabled) &, button:focus-visible &, button[aria-checked='true'] &"; + +export { WHEN_ACTIVE }; diff --git a/packages/blade/src/components/ChatFeedback/types.ts b/packages/blade/src/components/ChatFeedback/types.ts new file mode 100644 index 0000000000..e9fa43743c --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/types.ts @@ -0,0 +1,165 @@ +import type React from 'react'; +import type { DataAnalyticsAttribute, TestID } from '~utils/types'; +import type { StyledPropsBlade } from '~components/Box/styledProps'; + +/** The four points of the sentiment scale, worst to best. */ +type ChatFeedbackMood = 'very-dissatisfied' | 'dissatisfied' | 'satisfied' | 'very-satisfied'; + +type ChatFeedbackStep = 'mood' | 'tags' | 'thanks'; + +type ChatFeedbackMoodConfig = { + /** Follow-up question shown once this mood is picked */ + question: string; + /** Quick-select tags offered for this mood */ + tags: string[]; + /** + * Closing line for this mood. + * + * A single thank-you across all four points rings false at the unhappy end: someone who has + * just said the assistant got it wrong is told their feedback was lovely to receive. The + * negative moods acknowledge and commit to something instead. + */ + thanksLabel?: string; +}; + +/** One glyph per point of the scale. */ +type ChatFeedbackIcons = Record; + +/** The parts of a running flow a host may need to drive. */ +type ChatFeedbackControls = { + /** Submits the current mood and tags, as the flow's own tick would. */ + submit: () => void; + /** Replaces the selected tags. */ + setTags: (tags: string[]) => void; +}; + +type ChatFeedbackSubmitPayload = { + mood: ChatFeedbackMood; + /** Tags the user selected. Empty when they submitted without picking any. */ + tags: string[]; + /** Free-text follow-up, present only when the user chose to add more. */ + comment?: string; +}; + +type ChatFeedbackProps = { + /** + * Question shown in the first step, alongside the mood scale. + * @default 'How are we doing so far?' + */ + question?: string; + + /** + * Overrides the follow-up question and tags offered for each mood. + * Only the moods you provide are overridden — the rest keep their defaults. + */ + moodConfig?: Partial>; + + /** + * Callback fired when the user picks a mood, before they submit. + * Use this if you want to record the rating even when the flow is abandoned. + */ + onMoodSelect?: ({ mood }: { mood: ChatFeedbackMood }) => void; + + /** + * Callback fired whenever the selected tags change. + * + * Use it to react to a particular tag being picked — a host that wants to collect free text in + * its own input rather than this component's, for example. + */ + onTagsChange?: ({ tags }: { tags: string[] }) => void; + + /** + * Hides this flow's own submit control. + * + * Set it when the surrounding surface is showing a submit of its own — a composer collecting + * the free-text comment, say. Two ticks on screen doing the same thing is worse than one in the + * place the user is already looking. + */ + isSubmitHidden?: boolean; + + /** + * Receives a handle on this flow, so a surrounding surface can drive it. + * + * Set when the host has controls of its own that must act on the same state — a composer that + * collects the free-text comment needs to submit the flow from its own send button, and to + * release the tag again when the user backs out of typing. + */ + controlsRef?: React.MutableRefObject; + + /** + * Callback fired when the user submits their feedback. + * + * Fires once when tags are submitted, and again with `comment` populated if the user + * goes on to add a free-text follow-up. + */ + onSubmit?: (payload: ChatFeedbackSubmitPayload) => void; + + /** + * Callback fired when the flow finishes and the component should be taken away. + * `ChatFeedback` does not remove itself — hide it in response to this. + */ + onDismiss?: () => void; + + /** + * Message shown on the thank-you step, for every mood. + * + * Leave it unset to use the per-mood copy from `moodConfig`, which differs by sentiment. + */ + thanksLabel?: string; + + /** + * Whether the flow dismisses itself shortly after the thank-you step. + * When false, `onDismiss` is never fired automatically and you control removal. + * @default true + */ + autoDismiss?: boolean; + + /** + * Whether the flow fills the width available to it. + * + * `true` spreads each step edge to edge, pushing the trailing control to the far right. + * Use it when the flow sits inside a surface that already has a width — a strip attached + * above a composer, for example. + * + * `false` makes each step only as wide as its own content, so the whole flow can be + * dropped into a bar that hugs it. Pair it with `alignSelf="center"` on the wrapper to get + * a floating bar that shrinks to fit. + * + * @default true + */ + isFullWidth?: boolean; + + /** + * Artwork for the rating scale, one entry per mood. + * + * Optional — Blade ships an animated set and uses it when this is omitted, so the component + * renders properly on install. Supply your own to use a product's icon set, or plain emoji + * characters; each is rendered as given, in a fixed box and hidden from assistive technology, + * since the button already carries the mood's name. + * + * Declare a replacement once in a module and import it, rather than inline — swapping the scale + * should be one edit rather than one per surface. + * + * Hover and selected states are drawn on the button, not the glyph, so they read the same + * whether what you pass can be tinted or not. + */ + feedbackIcons?: ChatFeedbackIcons; + + /** + * Disables every control in the flow. + * @default false + */ + isDisabled?: boolean; +} & TestID & + DataAnalyticsAttribute & + StyledPropsBlade; + +export type { + ChatFeedbackControls, + ChatFeedbackIcons, + ChatFeedbackProps, + ChatFeedbackMood, + ChatFeedbackMoodConfig, + ChatFeedbackStep, + ChatFeedbackSubmitPayload, +}; diff --git a/packages/blade/src/components/ChatFeedback/useChatFeedback.ts b/packages/blade/src/components/ChatFeedback/useChatFeedback.ts new file mode 100644 index 0000000000..2622203690 --- /dev/null +++ b/packages/blade/src/components/ChatFeedback/useChatFeedback.ts @@ -0,0 +1,130 @@ +import React from 'react'; +import type { ChatFeedbackMood, ChatFeedbackProps, ChatFeedbackStep } from './types'; +import { chatFeedbackDefaultMoodConfig, chatFeedbackThanksDurationMs } from './chatFeedbackTokens'; +import { useTheme } from '~components/BladeProvider'; + +type UseChatFeedbackProps = Pick< + ChatFeedbackProps, + 'moodConfig' | 'onMoodSelect' | 'onTagsChange' | 'onSubmit' | 'onDismiss' | 'autoDismiss' +>; + +/** + * The whole flow, with no rendering attached. + * + * Every timer goes through `schedule` so that a step change or unmount cancels anything + * still pending — an auto-dismiss that fires after the user has navigated away is the + * bug this indirection exists to prevent. + */ +const useChatFeedback = ({ + moodConfig, + onMoodSelect, + onTagsChange, + onSubmit, + onDismiss, + autoDismiss = true, +}: UseChatFeedbackProps): { + step: ChatFeedbackStep; + selectedMood: ChatFeedbackMood | null; + selectedTags: string[]; + question: string; + thanksLabel?: string; + tags: string[]; + hasSelectedTags: boolean; + selectMood: (mood: ChatFeedbackMood) => void; + setSelectedTags: (values: string[]) => void; + submitTags: () => void; + goBackToMood: () => void; +} => { + const { theme } = useTheme(); + + const [step, setStep] = React.useState('mood'); + const [selectedMood, setSelectedMood] = React.useState(null); + const [selectedTags, setSelectedTagsState] = React.useState([]); + + /* + * Every route that changes the selection reports it. + * + * The chip group used to be the only one that did, so the two internal clears — going back to + * the moods, and picking a new mood — changed the selection silently. A host mirroring it to + * drive its own UI then held tags that no longer existed, and acted on them: a composer handed + * the free-text tag stayed in feedback mode after the strip had walked back to the mood step, + * with no tag selected and no way out. Reporting from one place makes drift impossible rather + * than merely unlikely. + */ + const setSelectedTags = React.useCallback( + (values: string[]) => { + setSelectedTagsState(values); + onTagsChange?.({ tags: values }); + }, + [onTagsChange], + ); + + const timers = React.useRef>>(new Set()); + + const schedule = React.useCallback((fn: () => void, ms: number) => { + const id = setTimeout(() => { + timers.current.delete(id); + fn(); + }, ms); + timers.current.add(id); + }, []); + + const clearTimers = React.useCallback(() => { + timers.current.forEach(clearTimeout); + timers.current.clear(); + }, []); + + React.useEffect(() => clearTimers, [clearTimers]); + + const resolvedConfig = selectedMood + ? { ...chatFeedbackDefaultMoodConfig[selectedMood], ...moodConfig?.[selectedMood] } + : null; + + const goToThanks = React.useCallback(() => { + clearTimers(); + setStep('thanks'); + if (!autoDismiss) return; + schedule(() => onDismiss?.(), chatFeedbackThanksDurationMs); + }, [autoDismiss, clearTimers, onDismiss, schedule]); + + const selectMood = React.useCallback( + (mood: ChatFeedbackMood) => { + clearTimers(); + setSelectedMood(mood); + setSelectedTags([]); + onMoodSelect?.({ mood }); + // A beat before the follow-up, so the selection is seen before the step changes. + schedule(() => setStep('tags'), theme.motion.duration.quick); + }, + [clearTimers, onMoodSelect, schedule, setSelectedTags, theme.motion.duration.quick], + ); + + const submitTags = React.useCallback(() => { + if (!selectedMood) return; + onSubmit?.({ mood: selectedMood, tags: selectedTags }); + goToThanks(); + }, [goToThanks, onSubmit, selectedMood, selectedTags]); + + const goBackToMood = React.useCallback(() => { + clearTimers(); + setSelectedMood(null); + setSelectedTags([]); + setStep('mood'); + }, [clearTimers, setSelectedTags]); + + return { + step, + selectedMood, + selectedTags, + question: resolvedConfig?.question ?? '', + tags: resolvedConfig?.tags ?? [], + thanksLabel: resolvedConfig?.thanksLabel, + hasSelectedTags: selectedTags.length > 0, + selectMood, + setSelectedTags, + submitTags, + goBackToMood, + }; +}; + +export { useChatFeedback }; diff --git a/packages/blade/src/components/index.ts b/packages/blade/src/components/index.ts index f0a3597f37..6e74e47acd 100644 --- a/packages/blade/src/components/index.ts +++ b/packages/blade/src/components/index.ts @@ -18,6 +18,7 @@ export * from './ButtonGroup'; export * from './Card'; export * from './Carousel'; export * from './Checkbox'; +export * from './ChatFeedback'; export * from './ChatInput'; export * from './ChatMessage'; export * from './Charts'; diff --git a/packages/blade/src/utils/metaAttribute/metaConstants.ts b/packages/blade/src/utils/metaAttribute/metaConstants.ts index fba22eb302..329c1973e6 100644 --- a/packages/blade/src/utils/metaAttribute/metaConstants.ts +++ b/packages/blade/src/utils/metaAttribute/metaConstants.ts @@ -29,6 +29,8 @@ export const MetaConstants = { BottomNav: 'bottomnav', BottomNavItem: 'bottomnav-item', Carousel: 'carousel', + ChatComposer: 'chat-composer', + ChatFeedback: 'chat-feedback', ChatInput: 'chat-input', ChatMessage: 'chat-message', ChatMessageThumbnailPreview: 'chat-message-thumbnail-preview', diff --git a/packages/blade/src/utils/storybook/componentStatusData.ts b/packages/blade/src/utils/storybook/componentStatusData.ts index deab6e3f1b..b5d3aa0102 100644 --- a/packages/blade/src/utils/storybook/componentStatusData.ts +++ b/packages/blade/src/utils/storybook/componentStatusData.ts @@ -24,6 +24,22 @@ type ComponentStatusDataType = { }[]; const componentData: ComponentStatusDataType = [ + { + name: 'ChatFeedback', + description: + 'Four-point rating flow for conversational surfaces — mood, follow-up tags, and an optional free-text comment.', + // Web only for now: the native counterpart throws until it is implemented. + platform: 'web', + frameworks: { + react: { + status: 'in-development', + storybookLink: 'Components/ChatFeedback', + }, + svelte: { + status: 'to-be-decided', + }, + }, + }, { name: 'Avatar', description: 'Avatar component for displaying user profile images or initials.',