← All proposals

Feedback Bar

A small atomic component: a thumbs-up and thumbs-down pair with one piece of state, built from the local IconButton (its existing isPressed toggle, which maps to aria-pressed).

Net-new componentOpen in Storybook

proposals/FeedbackBar/FeedbackBar.stories.tsx150 lines · View on GitHub
import { useEffect, useRef, useState } from 'react';

import { Meta, StoryObj } from '@storybook/react-vite';

import { ClipboardButton } from '@pantheon-systems/pds-toolkit-react';

import { IconButton } from '../IconButton/IconButton';
import { FeedbackBar, FeedbackBarProps, FeedbackValue } from './FeedbackBar';

import './feedback-bar-story.css';

const THANKS_MS = 4500;

// The consumer owns the choice and the timing, so the story keeps them in
// state: after a choice the thumbs are replaced by the status text for a few
// seconds, then come back with the chosen one pressed. Pressing it again clears
// the choice.
const InteractiveFeedbackBar = ({
	statusLabel,
	value: initial,
	...args
}: FeedbackBarProps) => {
	const [value, setValue] = useState<FeedbackValue | null>(initial ?? null);
	const [showStatus, setShowStatus] = useState(false);
	const timer = useRef<ReturnType<typeof setTimeout>>();

	useEffect(() => () => clearTimeout(timer.current), []);

	const handleChange = (next: FeedbackValue | null) => {
		setValue(next);
		clearTimeout(timer.current);
		setShowStatus(next !== null && Boolean(statusLabel));
		if (next !== null) {
			timer.current = setTimeout(() => setShowStatus(false), THANKS_MS);
		}
	};

	return (
		<FeedbackBar
			{...args}
			onValueChange={handleChange}
			statusLabel={showStatus ? statusLabel : undefined}
			value={value}
		/>
	);
};

const meta: Meta<typeof FeedbackBar> = {
	tags: ['new-pattern', 'ready-for-dev'],
	title: 'Component Proposals/Feedback Bar',
	component: FeedbackBar,
	parameters: {
		docs: {
			description: {
				component:
					'**New pattern:** not in PDS v2 today. Thumbs up and down for rating agent output. It reports the choice and does nothing else; what the screen does with it (analytics, a reason, a thank-you) is up to the screen.',
			},
		},
	},
	argTypes: {
		children: { control: false, table: { category: 'Slots' } },
		className: { table: { category: 'Base attributes' } },
		downLabel: { control: 'text', table: { category: 'Content' } },
		groupLabel: { control: 'text', table: { category: 'Content' } },
		onValueChange: { table: { category: 'Events' } },
		statusLabel: { control: 'text', table: { category: 'Content' } },
		upLabel: { control: 'text', table: { category: 'Content' } },
		value: {
			control: 'inline-radio',
			options: [null, 'up', 'down'],
			table: { category: 'State' },
		},
	},
	args: {
		downLabel: 'Bad response',
		groupLabel: 'Rate this response',
		upLabel: 'Good response',
		value: null,
	},
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Interactive: Story = {
	args: { statusLabel: 'Thanks for the feedback' },
	parameters: {
		docs: {
			description: {
				story:
					'Press a thumb: the status text shows for a few seconds, then the thumbs come back with your choice pressed. Press the chosen thumb again to clear it. Story-only wiring: the screen owns the choice and the timing.',
			},
		},
	},
	render: (args) => <InteractiveFeedbackBar {...args} />,
};

const LABELS = {
	downLabel: 'Bad response',
	groupLabel: 'Rate this response',
	upLabel: 'Good response',
};

const EXAMPLES: Array<{ caption: string; props: Partial<FeedbackBarProps> }> = [
	{ caption: 'No choice', props: {} },
	{ caption: 'Thumbs up chosen', props: { value: 'up' } },
	{ caption: 'Thumbs down chosen', props: { value: 'down' } },
	{
		caption: 'With status text',
		props: { statusLabel: 'Thanks for the feedback' },
	},
	{
		caption: 'With extra actions',
		props: {
			children: (
				<>
					<ClipboardButton
						ariaLabel='Copy'
						clipboardText='Sample response text'
						size='s'
					/>
					<IconButton ariaLabel='Try again' iconName='rotate' size='s' />
				</>
			),
		},
	},
];

export const AllStates: Story = {
	parameters: {
		docs: {
			description: {
				story:
					'Every state, static. The status text (`statusLabel`) replaces the thumbs and is announced to screen readers; the screen decides when to show it and for how long. The `children` slot takes extra actions after the thumbs.',
			},
		},
	},
	render: () => (
		<div className='feedback-bar-story__stack'>
			{EXAMPLES.map(({ caption, props }) => (
				<div className='feedback-bar-story__example' key={caption}>
					<p className='pds-copy-xs feedback-bar-story__caption'>{caption}</p>
					<FeedbackBar {...LABELS} {...props} />
				</div>
			))}
		</div>
	),
};