diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 5e6479be9..075628958 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -3,6 +3,10 @@ max-width: 1200px; } + /* When a story is taller than a sized viewport, the element the backgrounds addon colours + (.sb-show-main) can be shorter than the content, exposing the iframe's default grey behind it. + Painting makes the surface propagate across the whole scroll area; !important beats + Storybook's own preview body background. The token resolves per the theme class on . */ html.tedi-theme--default, html.tedi-theme--dark { background-color: var(--general-surface-primary) !important; diff --git a/.vscode/settings.json b/.vscode/settings.json index ed814de1b..51bc26339 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,15 @@ "i18n-ally.localesPaths": [], "i18n-ally.keystyle": "nested", "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "eslint.format.enable": true, "eslint.alwaysShowStatus": true, "eslint.workingDirectories": [ diff --git a/component.manifest.json b/component.manifest.json index 97a58348b..4ba38a4c3 100644 --- a/component.manifest.json +++ b/component.manifest.json @@ -601,7 +601,9 @@ "name": "TableOfContents", "category": "navigation", "sourcePath": "src/tedi/components/navigation/table-of-contents", - "status": [], + "status": [ + "breakpointSupport" + ], "description": "Navigational table of contents for long pages or multistep forms, composed from nestable TableOfContents.Item entries.", "keyProps": [ "activeId", @@ -744,7 +746,9 @@ "name": "Radio", "category": "form", "sourcePath": "src/tedi/components/form/radio", - "status": [], + "status": [ + "breakpointSupport" + ], "description": "Single-select radio input with a label and helper text; supports a selectable card variant and grouping via Radio.Group.", "keyProps": [ "id", @@ -852,7 +856,9 @@ "name": "Checkbox", "category": "form", "sourcePath": "src/tedi/components/form/checkbox", - "status": [], + "status": [ + "breakpointSupport" + ], "description": "Checkbox input with a label and helper text; supports an indeterminate state, a selectable card variant, and grouping via Checkbox.Group.", "keyProps": [ "id", @@ -1157,6 +1163,23 @@ "indicatorSemantics" ] }, + { + "id": "form/rating/rating", + "name": "Rating", + "category": "form", + "sourcePath": "src/tedi/components/form/rating/rating", + "status": [ + "breakpointSupport" + ], + "description": "Accessible rating scale (native radiogroup) with star, number, or icon fill; supports controlled/uncontrolled, a read-only summary/scale variant, and breakpoint-aware orientation.", + "keyProps": [ + "label", + "type", + "value", + "onChange", + "count" + ] + }, { "id": "layout/sidenav", "name": "SideNav", diff --git a/skills/tedi-react/SKILL.md b/skills/tedi-react/SKILL.md index 7973820d7..af75aef1b 100644 --- a/skills/tedi-react/SKILL.md +++ b/skills/tedi-react/SKILL.md @@ -242,7 +242,7 @@ const [email, setEmail] = useState(''); ``` -Form controls: `TextField`, `Select`, `Textarea`, `NumberField`, `Checkbox` (+ `Checkbox.Group`), `Radio` (+ `Radio.Group`), `Search`, `DateField`, `TimeField`, `Filter` (+ `FilterGroup`), `FileUpload`, `FileDropzone`. `ChoiceGroup` is **deprecated** — use `Radio.Group` / `Checkbox.Group`. Verify the roster and per-control usage against [references/forms.md](references/forms.md) and the installed package (see Authoritative Sources). +Form controls: `TextField`, `Select`, `Textarea`, `NumberField`, `Checkbox` (+ `Checkbox.Group`), `Radio` (+ `Radio.Group`), `Search`, `Rating`, `DateField`, `TimeField`, `Filter` (+ `FilterGroup`), `FileUpload`, `FileDropzone`. `ChoiceGroup` is **deprecated** — use `Radio.Group` / `Checkbox.Group`. Verify the roster and per-control usage against [references/forms.md](references/forms.md) and the installed package (see Authoritative Sources). ## Theming diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index 0031c6f4b..f9d1d98fb 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -129,6 +129,13 @@ files. This is the part of this document worth maintaining by hand. - **`TableCard` is `Table`'s readable mobile form.** Below a breakpoint, render a list of `TableCard` (each row a stacked `
` of label / value pairs with its own title / status / actions) instead of collapsing columns; swap via `useBreakpoint` + `isBreakpointBelow(bp, 'md')`. +- **`Rating` is a native `radiogroup`, not a decorative widget.** It renders real radios, so it's + keyboard-navigable (arrow keys) and form-submittable like any radio group. `value` is **1-based** + (`0` = no rating), and `type` changes the fill: `star` / `number` fill cumulatively up to the + value, while `icon` highlights only the single selected item (defaults to the five sentiment + faces at `count={5}`). Pass `readOnly` for a compact aggregate summary (or `readOnlyVariant="scale"` + for a fractional star scale with `ratingsCount`), and `orientation` is breakpoint-aware + (`orientation="vertical" md={{ orientation: 'horizontal' }}`) for narrow layouts. ### Composition constraints diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md index 6b7ddc823..095e74004 100644 --- a/skills/tedi-react/references/forms.md +++ b/skills/tedi-react/references/forms.md @@ -23,6 +23,7 @@ Orientation only. Verify the current roster against the installed package's barr | Radio | `boolean` (via onChange) | `Radio.Group`, card variant, segmented layout | | ChoiceGroup | `ChoiceGroupValue` | **Deprecated** — use `Radio.Group` / `Checkbox.Group` | | Search | `string` | Search button, onSearch callback | +| Rating | `number` (1-based; `0` = none) | Star / number / icon scale, native `radiogroup`, `readOnly` summary/scale, breakpoint-aware `orientation`, `itemLabels` | | DateField | `Date \| Date[] \| DateRange` | Single/multiple/range, manual input, min/max, native picker, clearable, breakpoint-aware | | TimeField | `string` (`"HH:mm"`) | Wheel / grid picker, native fallback, stepMinutes, availableTimes, clearable | | Filter | `boolean \| string \| string[]` | Pill-shaped toggle / dropdown filter — single, multi-select, custom panel; pairs with `FilterGroup` | @@ -456,6 +457,7 @@ TEDI form controls hand you the **parsed value**, not the raw DOM event. The con - **Select** calls `onChange` with the selected option object(s), or `null` when cleared. - **DateField** uses `onSelect`; the value shape follows the active `mode` (single `Date`, `Date[]`, or a range). - **TimeField / TimePicker** call `onChange` with a `"HH:mm"` 24-hour string (empty when cleared). +- **Rating** calls `onChange` with the selected 1-based number (`0` means no rating). ## Disabled State 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..aea0e7273 --- /dev/null +++ b/src/tedi/components/form/rating/rating.module.scss @@ -0,0 +1,232 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; + +.tedi-rating { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + align-items: flex-start; + width: fit-content; +} + +.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 { + flex-wrap: wrap; + 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--vertical .tedi-rating__items { + flex-direction: column; + align-items: stretch; +} + +.tedi-rating--vertical .tedi-rating__item { + flex-direction: row; + gap: var(--layout-grid-gutters-08); + justify-content: flex-start; +} + +.tedi-rating--vertical .tedi-rating__caption { + text-align: left; +} + +.tedi-rating__input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: inherit; + opacity: 0; +} + +.tedi-rating__visual { + position: relative; + display: inline-flex; +} + +.tedi-rating .tedi-rating__star { + position: relative; // paint above the absolutely positioned backdrop + line-height: 1; + color: var(--general-text-brand); + text-align: center; +} + +.tedi-rating--star .tedi-rating__visual { + padding: var(--feedback-star-inner-spacing); +} + +// Filled glyph stacked under the outlined one; transparent unless the star is hovered while unselected. +.tedi-rating .tedi-rating__star-backdrop { + position: absolute; + inset: var(--feedback-star-inner-spacing); + color: transparent; +} + +.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(--general-text-brand); + background-color: var(--general-surface-primary); + border: var(--tedi-borders-01) solid var(--general-border-secondary); + border-radius: 50%; +} + +.tedi-rating__visual--selected .tedi-rating__circle, +.tedi-rating__circle--filled { + color: var(--general-text-white); + background-color: var(--general-surface-brand-primary); + border-color: var(--general-surface-brand-primary); +} + +.tedi-rating:not(.tedi-rating--disabled) { + .tedi-rating__visual--hover, + .tedi-rating__item:hover > .tedi-rating__visual { + .tedi-rating__star { + color: var(--general-icon-brand-dark); + } + + .tedi-rating__star-backdrop { + color: var(--general-surface-brand-quaternary); + } + + .tedi-rating__circle { + color: var(--general-icon-brand-dark); + background-color: var(--general-surface-brand-quaternary); + border-color: var(--general-icon-brand-dark); + } + + &.tedi-rating__visual--selected .tedi-rating__circle { + color: var(--general-text-white); + background-color: var(--general-icon-brand-dark); + border-color: var(--general-icon-brand-dark); + } + } +} + +.tedi-rating__input:focus-visible + .tedi-rating__visual::after { + position: absolute; + inset: -3px; + pointer-events: none; + content: ''; + border-radius: 50%; + box-shadow: inset 0 0 0 2px var(--general-effect-colors-focus-outset-outer-shadow), + inset 0 0 0 3px var(--general-effect-colors-focus-outset-inner-shadow); +} + +.tedi-rating__caption { + font-size: var(--body-extra-small-size); + line-height: var(--body-extra-small-line-height); + color: var(--general-text-secondary); + text-align: center; +} + +.tedi-rating__star-caption { + align-self: stretch; + min-height: var(--body-extra-small-line-height); + font-size: var(--body-extra-small-size); + line-height: var(--body-extra-small-line-height); + color: var(--general-text-secondary); + text-align: center; +} + +.tedi-rating--number:not(.tedi-rating--vertical) { + .tedi-rating__items--captioned { + row-gap: var(--layout-grid-gutters-24); + padding-bottom: var(--layout-grid-gutters-24); + } + + .tedi-rating__caption { + position: absolute; + top: 100%; + left: 50%; + margin-top: var(--layout-grid-gutters-04); + white-space: nowrap; + transform: translateX(-50%); + } +} + +.tedi-rating--disabled { + .tedi-rating__star { + color: var(--form-general-border-disabled); + } + + .tedi-rating__visual--selected .tedi-rating__star { + color: var(--form-general-background-disabled); + } + + .tedi-rating__circle { + color: var(--general-text-disabled); + background-color: transparent; + border-color: var(--form-general-border-disabled); + } + + .tedi-rating__visual--selected .tedi-rating__circle { + background-color: var(--form-general-background-disabled); + } +} + +.tedi-rating--readonly { + flex-direction: row; + align-items: center; +} + +.tedi-rating__summary { + display: inline-flex; + gap: var(--layout-grid-gutters-08); + align-items: center; +} + +.tedi-rating__stars { + display: inline-flex; + gap: var(--feedback-star-inner-spacing); +} + +.tedi-rating__star-partial { + position: relative; + display: inline-flex; +} + +.tedi-rating__star-partial-fill { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + display: inline-flex; + width: var(--tedi-rating-star-fill); + overflow: hidden; +} + +.tedi-rating__summary-text { + font-size: var(--heading-h6-size); + font-weight: var(--heading-h6-weight); + line-height: var(--heading-h6-line-height); + color: var(--general-text-secondary); +} + +@include breakpoints.media-breakpoint-down(md) { + .tedi-rating--number:not(.tedi-rating--readonly, .tedi-rating--vertical) .tedi-rating__items { + column-gap: var(--layout-grid-gutters-04); + } +} 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..9c480e21a --- /dev/null +++ b/src/tedi/components/form/rating/rating.spec.tsx @@ -0,0 +1,184 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import { LabelProvider } from '../../../providers/label-provider'; +import { Rating } from './rating'; + +import '@testing-library/jest-dom'; + +const renderWithLabels = (ui: JSX.Element) => render({ui}); + +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('falls back to the type default when count is not a positive integer', () => { + const { rerender } = render(); + expect(screen.getAllByRole('radio')).toHaveLength(5); + + rerender(); + expect(screen.getAllByRole('radio')).toHaveLength(5); + + rerender(); + expect(screen.getAllByRole('radio')).toHaveLength(5); + }); + + it('marks the selected radio from defaultValue (uncontrolled)', () => { + render(); + expect(screen.getByRole('radio', { name: '3/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/5' })); + expect(onChange).toHaveBeenCalledWith(4); + expect(screen.getByRole('radio', { name: '4/5' })).toBeChecked(); + }); + + it('respects the controlled value and does not self-update', () => { + const onChange = jest.fn(); + render(); + fireEvent.click(screen.getByRole('radio', { name: '5/5' })); + expect(onChange).toHaveBeenCalledWith(5); + expect(screen.getByRole('radio', { name: '2/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/10' })).toBeInTheDocument(); + }); + + describe('hover preview', () => { + const visuals = (container: HTMLElement) => + Array.from(container.querySelectorAll('.tedi-rating__visual')); + const isSelected = (el: HTMLElement) => el.classList.contains('tedi-rating__visual--selected'); + const isHovered = (el: HTMLElement) => el.classList.contains('tedi-rating__visual--hover'); + + it('marks the hovered range as hover and keeps only committed items in it selected', () => { + const { container } = render(); + fireEvent.mouseEnter(screen.getByRole('radio', { name: '4/5' }).closest('label') as HTMLElement); + + const items = visuals(container); + expect(items.map(isHovered)).toEqual([true, true, true, true, false]); + expect(items.map(isSelected)).toEqual([true, true, false, false, false]); + }); + + it('shows committed items outside the hovered range as unselected, and restores them on leave', () => { + const { container } = render(); + const label = screen.getByRole('radio', { name: '2/5' }).closest('label') as HTMLElement; + + fireEvent.mouseEnter(label); + expect(visuals(container).map(isSelected)).toEqual([true, true, false, false, false]); + + fireEvent.mouseLeave(label.parentElement as HTMLElement); + expect(visuals(container).map(isSelected)).toEqual([true, true, true, true, false]); + expect(visuals(container).some(isHovered)).toBe(false); + }); + + it('previews only the hovered item for the icon type', () => { + const { container } = render(); + fireEvent.mouseEnter(screen.getByRole('radio', { name: '3/5' }).closest('label') as HTMLElement); + + const items = visuals(container); + expect(items.map(isHovered)).toEqual([false, false, true, false, false]); + expect(items.map(isSelected)).toEqual([false, false, true, false, false]); + }); + + it('keeps the current pick selected while hovering another icon', () => { + const { container } = render(); + fireEvent.mouseEnter(screen.getByRole('radio', { name: '5/5' }).closest('label') as HTMLElement); + + const items = visuals(container); + expect(items.map(isHovered)).toEqual([false, false, false, false, true]); + expect(items.map(isSelected)).toEqual([false, false, true, false, false]); + }); + + it('does not preview while disabled', () => { + const { container } = render(); + fireEvent.mouseEnter(screen.getByRole('radio', { name: '3/5' }).closest('label') as HTMLElement); + expect(visuals(container).some(isHovered)).toBe(false); + }); + }); + + it('disables every radio when disabled', () => { + render(); + screen.getAllByRole('radio').forEach((radio) => expect(radio).toBeDisabled()); + }); + + describe('readOnly aggregate summary', () => { + it('renders a non-interactive summary (no radios) with the value, max and rater count', () => { + renderWithLabels(); + expect(screen.queryAllByRole('radio')).toHaveLength(0); + const summary = screen.getByRole('img'); + + expect(summary).toHaveAccessibleName(/^Teenuse hinnang: 3[.,]5\/5 - 271 hindajat$/); + }); + + it('uses the singular rater form when there is exactly one rating', () => { + renderWithLabels(); + expect(screen.getByRole('img')).toHaveAccessibleName('Hinnang: 4/5 - 1 hindaja'); + }); + + it('omits the rater count when ratingsCount is not provided', () => { + renderWithLabels(); + expect(screen.getByRole('img')).toHaveAccessibleName('Hinnang: 4/5'); + }); + + it('shows the count number without the raters label when showRatingsCountLabel is false', () => { + renderWithLabels( + + ); + expect(screen.getByRole('img')).toHaveAccessibleName(/^Hinnang: 3[.,]5\/5 - 271$/); + }); + + it('partially fills the boundary star in the scale variant (3.5 -> 3 full, 1 half, 1 empty)', () => { + const { container } = renderWithLabels( + + ); + const fills = container.querySelectorAll('.tedi-rating__star-partial-fill'); + + expect(fills).toHaveLength(4); + expect(fills[0].style.getPropertyValue('--tedi-rating-star-fill')).toBe('100%'); + expect(fills[2].style.getPropertyValue('--tedi-rating-star-fill')).toBe('100%'); + expect(fills[3].style.getPropertyValue('--tedi-rating-star-fill')).toBe('50%'); + }); + + it('renders a single star (no partial-fill overlays) in the default summary variant', () => { + const { container } = renderWithLabels(); + expect(container.querySelectorAll('.tedi-rating__star-partial-fill')).toHaveLength(0); + }); + + it('always renders the summary visual filled, even for a zero rating', () => { + const zero = renderWithLabels(); + expect(zero.getByRole('img')).toHaveAccessibleName('Hinnang: 0/5'); + expect(zero.container.querySelector('.tedi-rating__circle--filled')).not.toBeNull(); + }); + + it('does not render the interactive scale in readOnly mode', () => { + const onChange = jest.fn(); + renderWithLabels(); + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); +}); 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..7b6775f33 --- /dev/null +++ b/src/tedi/components/form/rating/rating.stories.tsx @@ -0,0 +1,369 @@ +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 '../../content/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', + args: { + label: 'Hinnang', + type: 'star', + }, + parameters: { + status: { + type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], + }, + controls: { + exclude: ['sm', 'md', 'lg', 'xl', 'xxl'], + }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.76.92?node-id=15548-139124&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) => ( + // Row label sits on top on mobile (so the scale gets the full width) and moves to a left column from md up. +
+
+ {rowLabel(value, max)} +
+ +
+ ))} +
+
+ Read only +
+ + + + + +
+
+); + +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 States: Story = { + parameters: { + controls: { disable: true }, + pseudo: { hover: '[id^="rating-hover"] label', focusVisible: '[id^="rating-focus"] input' }, + }, + render: () => { + const types = ['star', 'number', 'icon'] as const; + const rows: { label: string; state?: 'hover' | 'focus'; disabled?: boolean }[] = [ + { label: 'Default' }, + { label: 'Hover', state: 'hover' }, + { label: 'Disabled', disabled: true }, + { label: 'Focus', state: 'focus' }, + ]; + const cell = { padding: '0.5rem 1rem', textAlign: 'left' as const, verticalAlign: 'middle' as const }; + + return ( +
+ + + + + ))} + + + + {types.map((type) => + ['Default', 'Selected'].map((column) => ( + + )) + )} + + + + {rows.map((row) => ( + + + {types.map((type) => + [0, 1].map((selected) => ( + + )) + )} + + ))} + +
+ {types.map((type) => ( + + + {type[0].toUpperCase() + type.slice(1)} + +
+ State + + {column} +
+ {row.label} + + +
+
+ ); + }, +}; + +/** + * `orientation="vertical"` stacks the scale and puts each icon beside its caption, so long labels fit + * on narrow (mobile) layouts where the horizontal row would overflow. + */ +export const VerticalIcons: Story = { + name: 'Vertical (icons)', + parameters: { controls: { disable: true } }, + render: () => ( + + ), +}; + +export const Controlled: Story = { + render: (args) => { + const [value, setValue] = useState(2); + return ( + + + Valitud: {value || '—'} + + ); + }, +}; + +/** + * The read-only `scale` variant renders the whole star scale with the boundary star filled to the + * fraction (e.g. `3,5` → three full, one half, one empty), followed by the summary text. Use it to + * show an aggregate rating inline; the default `summary` variant shows a single star instead. + */ +export const ReadOnlyScale: Story = { + parameters: { controls: { disable: true } }, + render: () => ( + + {[4.8, 3.5, 2.5, 1.2].map((value) => ( + + ))} + + ), +}; + +/** + * Swap glyphs via `icons`: + * - `type="icon"` takes an array (one per item) for a single-highlight scale — here a weather scale. + * - `type="star"` takes a single string to change the cumulative glyph (e.g. a pointier `star` or a + * `favorite` heart), keeping the fill and hover/focus behaviour. + * + * Pair with `itemLabels` for accessible names / captions. + */ +export const CustomIcons: 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 +
+ + +