From 32b94374afbcf48b3ba9a2e05d99a5551f987db8 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:27:54 +0300 Subject: [PATCH 01/12] feat(rating): tedi-ready component #531 --- skills/tedi-react/references/components.md | 24 ++ src/tedi/components/form/rating/index.ts | 1 + .../components/form/rating/rating.module.scss | 97 +++++++ .../components/form/rating/rating.spec.tsx | 69 +++++ .../components/form/rating/rating.stories.tsx | 262 ++++++++++++++++++ src/tedi/components/form/rating/rating.tsx | 210 ++++++++++++++ src/tedi/index.ts | 1 + 7 files changed, 664 insertions(+) create mode 100644 src/tedi/components/form/rating/index.ts create mode 100644 src/tedi/components/form/rating/rating.module.scss create mode 100644 src/tedi/components/form/rating/rating.spec.tsx create mode 100644 src/tedi/components/form/rating/rating.stories.tsx create mode 100644 src/tedi/components/form/rating/rating.tsx diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 4584cba94..b3182b0ff 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -1605,6 +1605,30 @@ Sub-components: `OptionContent.Label`, `OptionContent.Meta` )} /> ``` +### Rating + +Rating scale for collecting feedback. Rendered as a `radiogroup` of native radios (keyboard arrows move between items, form-submittable). + +**Props:** `RatingProps` +- `label: string` (required) — accessible name for the group +- `type?: 'star' | 'number' | 'icon' = 'star'` — `star`/`number` fill cumulatively (every item up to the value); `icon` highlights a single item +- `count?: number` — items in the scale (default `5` for `star`/`icon`, `10` for `number`) +- `value?: number`, `defaultValue?: number = 0` — selected value (1-based; `0` = no rating). Controlled with `value` + `onChange` +- `onChange?: (value: number) => void` +- `itemLabels?: string[]` — per-item labels (length = `count`); used as each radio's accessible name and shown as captions (`icon`: under each item; `number`: first/last as start/end; `star`: the selected item's label as a single caption) +- `icons?: string[]` — glyph names for `type="icon"` (defaults to the five sentiment faces when `count` is 5) +- `disabled?: boolean`, `readOnly?: boolean`, `name?: string`, `className?: string` + +```tsx + +``` + States: the `Label` uses `color: inherit`, so it follows the parent item's per-state text colour (default → hover → active → disabled) defined on `DropdownItem` / the Select option — both surfaces share the `--dropdown-item-*` and `--form-checkbox-radio-*` tokens. The leading icon and `Meta` keep their secondary/tertiary colours. `Select` renders its option content with `` internally, so a custom `renderOption` is only needed for *richer* content (icon/meta), not to match the default look. --- diff --git a/src/tedi/components/form/rating/index.ts b/src/tedi/components/form/rating/index.ts new file mode 100644 index 000000000..75c51e756 --- /dev/null +++ b/src/tedi/components/form/rating/index.ts @@ -0,0 +1 @@ +export * from './rating'; diff --git a/src/tedi/components/form/rating/rating.module.scss b/src/tedi/components/form/rating/rating.module.scss new file mode 100644 index 000000000..166131da9 --- /dev/null +++ b/src/tedi/components/form/rating/rating.module.scss @@ -0,0 +1,97 @@ +.tedi-rating { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + align-items: flex-start; +} + +.tedi-rating__items { + display: flex; + gap: var(--feedback-star-inner-spacing); + align-items: flex-start; +} + +.tedi-rating--number .tedi-rating__items, +.tedi-rating--icon .tedi-rating__items { + gap: var(--layout-grid-gutters-08); +} + +.tedi-rating__item { + position: relative; + display: inline-flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + align-items: center; + cursor: pointer; +} + +.tedi-rating--disabled .tedi-rating__item { + cursor: not-allowed; +} + +.tedi-rating__input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: inherit; + opacity: 0; +} + +.tedi-rating__visual { + display: inline-flex; +} + +.tedi-rating__input:focus-visible + .tedi-rating__visual { + outline: 2px solid var(--general-border-brand); + outline-offset: 2px; + border-radius: var(--button-radius-default); +} + +.tedi-rating__circle { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--button-sm-height); + height: var(--button-sm-height); + font-size: var(--body-small-regular-size); + color: var(--feedback-indicator); + background-color: var(--general-surface-primary); + border: var(--tedi-borders-01) solid var(--feedback-indicator); + border-radius: 50%; +} + +.tedi-rating__item--active .tedi-rating__circle { + color: var(--general-text-white); + background-color: var(--feedback-indicator); +} + +.tedi-rating__caption, +.tedi-rating__endpoints, +.tedi-rating__star-caption { + font-size: var(--body-small-regular-size); + color: var(--general-text-secondary); +} + +.tedi-rating__caption { + text-align: center; +} + +.tedi-rating__endpoints { + display: flex; + justify-content: space-between; + width: 100%; +} + +.tedi-rating--disabled { + .tedi-rating__circle { + color: var(--general-text-disabled); + border-color: var(--general-text-disabled); + } + + .tedi-rating__item--active .tedi-rating__circle { + color: var(--general-text-white); + background-color: var(--general-text-disabled); + } +} diff --git a/src/tedi/components/form/rating/rating.spec.tsx b/src/tedi/components/form/rating/rating.spec.tsx new file mode 100644 index 000000000..981554b15 --- /dev/null +++ b/src/tedi/components/form/rating/rating.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import { Rating } from './rating'; + +import '@testing-library/jest-dom'; + +describe('Rating', () => { + it('renders a labelled radiogroup with the requested number of items', () => { + render(); + const group = screen.getByRole('radiogroup', { name: 'Feedback' }); + expect(group).toBeInTheDocument(); + expect(within(group).getAllByRole('radio')).toHaveLength(5); + }); + + it('defaults to 10 items for the number type', () => { + render(); + expect(screen.getAllByRole('radio')).toHaveLength(10); + }); + + it('marks the selected radio from defaultValue (uncontrolled)', () => { + render(); + expect(screen.getByRole('radio', { name: '3 of 5' })).toBeChecked(); + }); + + it('fires onChange and updates selection when an item is chosen', () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '4 of 5' })); + expect(onChange).toHaveBeenCalledWith(4); + expect(screen.getByRole('radio', { name: '4 of 5' })).toBeChecked(); + }); + + it('respects the controlled value and does not self-update', () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '5 of 5' })); + expect(onChange).toHaveBeenCalledWith(5); + expect(screen.getByRole('radio', { name: '2 of 5' })).toBeChecked(); + }); + + it('uses itemLabels as accessible names', () => { + render(); + expect(screen.getByRole('radio', { name: 'Good' })).toBeInTheDocument(); + }); + + it('gives every radio a non-empty name even with sparse (endpoint-only) labels', () => { + render( + + ); + const radios = screen.getAllByRole('radio'); + radios.forEach((radio) => expect(radio).toHaveAccessibleName(/.+/)); + expect(screen.getByRole('radio', { name: 'Low' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'High' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: '5 of 10' })).toBeInTheDocument(); + }); + + it('disables every radio when disabled', () => { + render(); + screen.getAllByRole('radio').forEach((radio) => expect(radio).toBeDisabled()); + }); + + it('is non-interactive when readOnly', () => { + const onChange = jest.fn(); + render(); + const radios = screen.getAllByRole('radio'); + radios.forEach((radio) => expect(radio).toBeDisabled()); + expect(screen.getByRole('radio', { name: '2 of 5' })).toBeChecked(); + }); +}); diff --git a/src/tedi/components/form/rating/rating.stories.tsx b/src/tedi/components/form/rating/rating.stories.tsx new file mode 100644 index 000000000..aca670924 --- /dev/null +++ b/src/tedi/components/form/rating/rating.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; + +import { Heading } from '../../base/typography/heading/heading'; +import { Text } from '../../base/typography/text/text'; +import { Button } from '../../buttons/button/button'; +import { Card, CardContent } from '../../cards/card'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { ChoiceGroup } from '../choice-group/choice-group'; +import { ChoiceGroupValue } from '../choice-group/choice-group.types'; +import { TextArea } from '../textarea/textarea'; +import { Rating, RatingType } from './rating'; + +/** + * Figma ↗ + */ +const meta: Meta = { + component: Rating, + title: 'TEDI-Ready/Components/Form/Rating', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.63.78?node-id=15548-139123&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const STAR_LABELS = [ + 'Ei jäänud üldse rahule', + 'Ei jäänud rahule', + 'Osaliselt rahul', + 'Jäin rahule', + 'Jäin väga rahule', +]; + +const ICON_LABELS = ['Väga halb', 'Halb', 'Keskmine', 'Hea', 'Väga hea']; +const NUMBER_LABELS = Array.from({ length: 10 }, (_, index) => + index === 0 ? 'Väga halb' : index === 9 ? 'Suurepärane' : '' +); + +const rowLabel = (value: number, max: number): string => (value === 0 ? 'No rating' : `${value} of ${max}`); + +const ScaleShowcase = ({ + type, + max, + itemLabels, +}: { + type: RatingType; + max: number; + itemLabels?: string[]; +}): JSX.Element => ( + + {Array.from({ length: max + 1 }, (_, value) => ( +
+
+ {rowLabel(value, max)} +
+ +
+ ))} +
+); + +export const Default: Story = { + render: (args) => , +}; + +export const Stars: Story = { + parameters: { controls: { disable: true } }, + render: () => , +}; + +export const Numbers: Story = { + parameters: { controls: { disable: true } }, + render: () => , +}; + +export const Icons: Story = { + parameters: { controls: { disable: true } }, + render: () => , +}; + +export const Indicator: Story = { + parameters: { controls: { disable: true } }, + render: () => { + const cell = (type: RatingType, value: number) => ( + + ); + + return ( + + + + + ))} + + + + {[ + { label: 'Not selected', value: 0 }, + { label: 'Selected', value: 1 }, + ].map((row) => ( + + + {(['star', 'number', 'icon'] as const).map((type) => ( + + ))} + + ))} + +
+ {(['star', 'number', 'icon'] as const).map((type) => ( + + {type[0].toUpperCase() + type.slice(1)} +
+ {row.label} + + {cell(type, row.value)} +
+ ); + }, +}; + +export const Controlled: Story = { + render: (args) => { + const [value, setValue] = useState(2); + return ( + + + Valitud: {value || '—'} + + ); + }, +}; + +export const States: Story = { + parameters: { controls: { disable: true } }, + render: () => ( + + + + + + + ), +}; + +const REASON_ITEMS = [ + { id: 'reason-error', value: 'error', label: 'Näen veateadet' }, + { id: 'reason-wrong', value: 'wrong', label: 'Andmed on valed' }, + { id: 'reason-missing', value: 'missing', label: 'Andmed puuduvad' }, + { id: 'reason-no-right', value: 'no-right', label: 'Õigus puudub' }, + { id: 'reason-not-found', value: 'not-found', label: 'Ei leidnud, mida otsisin' }, + { id: 'reason-complex', value: 'complex', label: 'Keeruline' }, + { id: 'reason-other', value: 'other', label: 'Muu põhjus' }, +]; + +const centered = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 'var(--layout-grid-gutters-08)', +} as const; + +export const FeedbackFormExample: Story = { + parameters: { + controls: { disable: true }, + layout: 'padded', + }, + render: () => { + const [rating, setRating] = useState(1); + const [reasons, setReasons] = useState(['error']); + const [comment, setComment] = useState(''); + const [submitted, setSubmitted] = useState(false); + + return ( + + + {submitted ? ( +
+ + Aitäh! + + Sinu tagasiside aitab portaali paremaks muuta. +
+ ) : ( + +
+ + Kuidas jäid rahule teenuse kasutamisega? + + +
+ + {rating > 0 && ( + <> +
+ + Mis mõjutas Sinu vastust? + + Vali sobivad märksõnad +
+ + +