Skip to content
Open
20 changes: 20 additions & 0 deletions .changeset/chat-feedback-component.md
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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 <Text>ChatFeedback is not available for Native mobile apps.</Text>;
};

const ChatFeedback = assignWithoutSideEffects(_ChatFeedback, {
componentId: MetaConstants.ChatFeedback,
displayName: 'ChatFeedback',
});

export { ChatFeedback };
Original file line number Diff line number Diff line change
@@ -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': <span>😒</span>,
dissatisfied: <span>πŸ˜•</span>,
satisfied: <span>πŸ™‚</span>,
'very-satisfied': <span>😍</span>,
};

const Flow = (): React.ReactElement => (
<Box maxWidth="600px">
<ChatFeedback question="How's this going?" feedbackIcons={feedbackIcons} autoDismiss={false} />
</Box>
);

/**
* 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 => <Flow />;

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 => <Flow />;

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 => <Flow />;

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'),
);
};
Loading
Loading