diff --git a/.claude/hooks/post-edit-test.sh b/.claude/hooks/post-edit-test.sh new file mode 100755 index 000000000..e431fd74c --- /dev/null +++ b/.claude/hooks/post-edit-test.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Post-edit hook: finds and runs the nearest spec file when a component file is edited. +# Reads file path from stdin JSON (PostToolUse hook format). +# Exits 0 always — test failures are reported as output, not as hook failures. + +INPUT=$(cat /dev/stdin) +ABSOLUTE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +if [[ -z "$ABSOLUTE_PATH" ]]; then + exit 0 +fi + +# Convert absolute path to relative path from project root +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') +if [[ -n "$CWD" ]]; then + FILE="${ABSOLUTE_PATH#$CWD/}" +else + FILE="$ABSOLUTE_PATH" +fi + +# Only trigger for component source files in src/tedi/ +if [[ ! "$FILE" =~ ^src/tedi/ ]]; then + exit 0 +fi + +# Skip if the edited file is itself a spec or story +if [[ "$FILE" =~ \.(spec|stories)\. ]]; then + exit 0 +fi + +# Derive the spec file path +if [[ "$FILE" =~ \.tsx$ ]]; then + SPEC="${FILE%.tsx}.spec.tsx" +elif [[ "$FILE" =~ \.ts$ ]]; then + SPEC="${FILE%.ts}.spec.ts" +elif [[ "$FILE" =~ \.module\.scss$ ]]; then + # For SCSS modules, find the corresponding component spec + DIR=$(dirname "$FILE") + BASENAME=$(basename "$FILE" .module.scss) + SPEC="$DIR/$BASENAME.spec.tsx" +fi + +# Run test if spec file exists +if [[ -n "$SPEC" && -f "$SPEC" ]]; then + echo "Running: npm test -- --testPathPattern=\"$SPEC\"" + npm test -- --testPathPattern="$SPEC" 2>&1 +else + echo "No spec file found at $SPEC — skipping auto-test." +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..67a296513 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/post-edit-test.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/contributing/SKILL.md b/.claude/skills/contributing/SKILL.md new file mode 100644 index 000000000..d5552f4fe --- /dev/null +++ b/.claude/skills/contributing/SKILL.md @@ -0,0 +1,69 @@ +--- +name: contributing +description: > + Guide for contributing to TEDI Design System React. Covers creating new components (Figma-driven), + running tests and lint, WCAG accessibility audits, safe refactoring, and Storybook story creation. + Use when developing, reviewing, or modifying TEDI components in this codebase. +user-invocable: true +argument-hint: [task description or component path] +--- + +# TEDI React Contributing + +You are a senior React and TypeScript engineer specializing in accessible UI component libraries. You have expert-level knowledge of WCAG 2.1/2.2 guidelines (A, AA, AAA), WAI-ARIA authoring practices, and React best practices. + +## Before Any Code + +1. Read `CLAUDE.md` at the project root for commands, architecture, and conventions. +2. Read [best-practices](references/best-practices.md) for coding patterns. +3. If creating or modifying a component, check if TEDI Angular (`../angular/tedi/components/`) has an equivalent — use as behavioral reference. +4. Check TEDI Core (`../core/src/`) for available design tokens, mixins, and shared styles. +5. Check `package.json` before considering any new dependency. + +## Task Router + +Load the appropriate reference based on what you're doing: + +| If the task involves... | Load reference | +|---|---| +| Creating a new component from scratch | [new-component.md](references/new-component.md) | +| Running tests, fixing test/lint failures | [testing.md](references/testing.md) | +| WCAG audit or accessibility review | [a11y-review.md](references/a11y-review.md) | +| Renaming, restructuring, extracting, merging | [refactoring.md](references/refactoring.md) | +| Creating or updating Storybook stories | [stories.md](references/stories.md) | +| Need to check coding patterns | [best-practices.md](references/best-practices.md) | + +For **compound tasks** (e.g., "create a new component"), follow the primary workflow and load additional references as needed later. Creating a component will also need testing.md and stories.md at the end. + +## Cross-Cutting Rules + +### Figma Integration +Use `figma-desktop` MCP tools to fetch design context, screenshots, and metadata from provided Figma links. Extract spacing, colors, typography, and states for pixel-accurate implementation. + +### Third-Party Libraries +Always prefer existing dependencies. When a new one is needed, **stop and ask for permission** with: library name, why it's needed, alternatives considered, and bundle size impact. + +### Parallel Work +For bulk tasks (e.g., "audit all form components for a11y"), launch parallel agents — one per component — to speed up the work. Collect and summarize results. + +### Consumer Catalog Maintenance +When you add, remove, rename, or change the API of a component, update the consumer component catalog at `skills/tedi-react/references/components.md`: +- **New component** → add entry to the appropriate section (TEDI-Ready or Community) with import path, key props, and a usage example. +- **Removed component** → delete its entry. +- **Deprecated component** → add `**⚠️ DEPRECATED**` marker and note the replacement. +- **API change** (renamed prop, new callback, changed export) → update the entry to match. + +### Communication +- Be direct and concise. +- No unnecessary comments in code — code should be self-documenting. Do not add comments that restate what a selector, class name, or variable already says (e.g., `// Secondary variant` above `.tedi-checkbox-card--secondary`). This applies to styles, templates, and code equally. Only add comments when the logic isn't self-evident. +- When explaining decisions, focus on the "why" not the "what". + +## Commands + +```bash +npm start # Storybook dev server (port 4400) +npm test # Run all tests (Jest) +npm test -- --testPathPattern="component" # Run specific tests +npm run lint # ESLint + Stylelint with --fix +npm run build # Build library with Vite +``` diff --git a/.claude/skills/contributing/references/a11y-review.md b/.claude/skills/contributing/references/a11y-review.md new file mode 100644 index 000000000..d01d1c234 --- /dev/null +++ b/.claude/skills/contributing/references/a11y-review.md @@ -0,0 +1,79 @@ +# WCAG Accessibility Review + +Target component: `$ARGUMENTS` + +## Audit Procedure + +### 1. Read the Component + +Read all files for the target component: +- `.tsx` — check props, ARIA attributes, semantic HTML, keyboard handlers +- `.module.scss` — check focus styles, contrast, reduced motion support +- `.spec.tsx` — check if accessibility scenarios are tested + +### 2. ARIA & Semantics + +Check against WAI-ARIA Authoring Practices for the component pattern: + +- [ ] Correct `role` attribute for the component type +- [ ] Required ARIA attributes present (`aria-label`, `aria-labelledby`, `aria-describedby`, `aria-expanded`, `aria-selected`, `aria-checked`, etc.) +- [ ] `aria-live` regions for dynamic content updates +- [ ] Semantic HTML elements used where possible (` + + ); +} +``` + +## Component Patterns + +### Polymorphic components +Many components accept an `as` prop to render as different elements: + +```tsx +import { Link, Label } from '@tedi-design-system/react/tedi'; +import { NavLink } from 'react-router-dom'; + +Profile + +``` + +### Breakpoint support +Components with breakpoint support accept responsive prop overrides: + +```tsx +import { Text, Collapse } from '@tedi-design-system/react/tedi'; + + + Responsive text + + + + Content + +``` + +Breakpoints: `xs` (< 576px), `sm` (≥ 576px), `md` (≥ 768px), `lg` (≥ 992px), `xl` (≥ 1200px), `xxl` (≥ 1400px). + +### Compound components +Some components use sub-component pattern: + +```tsx +import { Card, Dropdown, Tooltip } from '@tedi-design-system/react/tedi'; + + + Title + Body + + + + + + Edit + + Delete + + +``` + +## Forms + +TEDI form controls support both **controlled** and **uncontrolled** modes: + +```tsx +import { TextField, Select, Checkbox, ChoiceGroup } from '@tedi-design-system/react/tedi'; + +// Controlled +const [email, setEmail] = useState(''); + + +// Uncontrolled + + +// Select + +``` + +### TextArea +**Props:** `TextAreaProps` extends TextFieldProps | fRef, bp, form +- `characterLimit?: number` + +### NumberField +**Props:** `NumberFieldProps` | form +- `id: string` (required) +- `label?: string` +- `value?: number`, `defaultValue?: number` +- `onChange?: (value: number) => void` +- `min?: number`, `max?: number`, `step?: number = 1` +- `suffix?: string` + +### Checkbox +**Props:** `CheckboxProps` | form +- `id: string` (required) +- `label?: ReactNode` +- `value: string` (required) +- `checked?: boolean`, `defaultChecked?: boolean` +- `onChange?: (value: string, checked: boolean) => void` +- `indeterminate?: boolean` +- `size?: 'default' | 'small'` + +### Radio +**Props:** `RadioProps` | form +Same as Checkbox (without indeterminate) + +### ChoiceGroup +**Props:** `ChoiceGroupProps` | bp, form +- `id: string` (required), `name: string` (required) +- `label: ReactNode | string` +- `items: ExtendedChoiceGroupItemProps[]` +- `inputType?: 'radio' | 'checkbox' = 'radio'` +- `value?: ChoiceGroupValue`, `defaultValue?: ChoiceGroupValue` +- `variant?: 'default' | 'segmented'` + +### Search +**Props:** `SearchProps` extends TextFieldProps | bp, form +- `onSearch?: (value: string) => void` +- `button?: Partial` + +### FileUpload +**Props:** `FileUploadProps` | form +- `id: string` (required), `name: string` (required) +- `accept?: string` +- `multiple?: boolean` +- `files?: FileUploadFile[]`, `defaultFiles?: FileUploadFile[]` +- `maxSize?: number` + +### FileDropzone +**Props:** `FileDropzoneProps` +- `label: string` (required) +- `accept?: string`, `multiple?: boolean`, `maxSize?: number` + +## Layout + +### Row / Col (Grid) +```tsx + + Half + Half + +``` + +**Row:** `cols`, `gutter` (0-5), `gutterX`, `gutterY`, `gap`, `justifyContent`, `alignItems`, `direction`, `wrap` + breakpoints +**Col:** `width` (1-12 or 'auto'), `offset`, `order`, `grow`, `shrink` + breakpoints + +### VerticalSpacing +**Props:** `VerticalSpacingProps` | bp +- `children: ReactNode` +- `size?: VerticalSpacingSize = 1` — 0-5 in em +- `element?: keyof JSX.IntrinsicElements = 'div'` + +Sub-component: `VerticalSpacing.Item` + +### SideNav +**Props:** `SideNavProps` | poly +- `ariaLabel: string` (required) +- `navItems: SideNavItemProps[]` (required) +- `linkAs?: ElementType` — polymorphic link component +- `collapsible?: boolean` +- `mobileBreakpoint?: 'mobile' | 'tablet'` + +Sub-components: `SideNav.Toggle`, `SideNav.Item`, `SideNav.Dropdown`, `SideNav.Mobile` + +## Loaders + +### Spinner +**Props:** `SpinnerProps` | bp +- `size: SpinnerSize = 16` — 10, 16, 18, 48 +- `color: 'primary' | 'secondary' = 'primary'` +- `label?: string` + +### Skeleton +**Props:** `SkeletonProps` +- `children?: ReactNode` +- `label?: string` + +Sub-component: `Skeleton.Block` + +## Navigation + +### Link +**Props:** `LinkProps` | fRef, poly, bp +- `children: ReactNode` +- `underline?: boolean = true` +- `visualType?: ButtonType = 'link'` +- `icon?: string | IconProps` +- `iconLeft?: string | IconProps` +- `target?: string`, `href?: string` + +```tsx +Documentation +Profile +``` + +## Notifications + +### Alert +**Props:** `AlertProps` | bp +- `children?: ReactNode` +- `title?: ReactNode` +- `type: AlertType = 'info'` — info, success, warning, danger +- `icon?: string | IconProps` +- `onClose?: () => void` — adds close button +- `role?: 'alert' | 'status' | 'none' = 'alert'` + +```tsx +Changes saved. +``` + +### Toast +```tsx +import { sendNotification, ToastContainer } from '@tedi-design-system/react/tedi'; + +// In root + + +// Trigger +sendNotification({ type: 'info', title: 'Update', children: 'New version available' }); +``` + +## Overlays + +### Tooltip +Sub-components: `Tooltip.Trigger`, `Tooltip.Content` + +```tsx + + + Help text + +``` + +**Props:** `openWith?: 'hover' | 'click' | 'focus' | 'manual'` + +### Dropdown +Sub-components: `Dropdown.Trigger`, `Dropdown.Content`, `Dropdown.Item`, `Dropdown.Separator` + +```tsx + + + + Edit + + Delete + + +``` + +**Props:** `width`, `placement`, `divided`, `open?` (controlled), `modal?: boolean` + +### Popover +Sub-components: `Popover.Trigger`, `Popover.Content` + +**Props:** `openWith?: 'click'`, `dismissible`, `role?: 'dialog'` + +## Tags + +### Tag +**Props:** `TagProps` | bp +- `children: ReactNode` (required) +- `color: TagColor = 'primary'` +- `onClose?: MouseEventHandler` — shows close button +- `isLoading?: boolean` + +```tsx +React +``` + +### StatusBadge +**Props:** `StatusBadgeProps` | bp +- `children?: ReactNode` +- `color: StatusBadgeColor = 'neutral'` +- `variant: 'filled' | 'filled-bordered' | 'bordered' = 'filled'` +- `status?: 'danger' | 'success' | 'warning' | 'inactive'` +- `icon?: string` + +```tsx +Active +``` + +## Misc + +### Separator +**Props:** `SeparatorProps` | bp +- `axis: 'horizontal' | 'vertical' = 'horizontal'` +- `color: 'primary' | 'secondary' | 'accent' = 'primary'` +- `variant?: 'dotted' | 'dot-only'` +- `thickness?: 1 | 2` +- `spacing?: SeparatorSpacing` + +--- + +# Community Components + +Import from `@tedi-design-system/react/community`. These are community-contributed, have relaxed review standards, and are **not recommended** when a TEDI-Ready equivalent exists. + +## Cards + +### Accordion +- `openItem?: string[]`, `onToggleItem?: (id: string) => void`, `gutter?: VerticalSpacingSize` +- Sub-components: AccordionItem, AccordionItemHeader, AccordionItemContent + +### Card +- `border?: CardBorderType`, `borderless?: boolean`, `padding?: number`, `background?: CardBackground` +- Sub-components: Card.Header, Card.Content, Card.Notification + +## Buttons + +### Button — **DEPRECATED** (use TEDI-Ready Button) +### Anchor — **DEPRECATED** (use TEDI-Ready Link) + +## Form + +### Check (Checkbox) — **DEPRECATED** (use TEDI-Ready Checkbox) +### Radio — **DEPRECATED** (use TEDI-Ready Radio via ChoiceGroup) + +### Select +- `id: string`, `options`, `value?`, `defaultValue?`, `onChange?` +- `multiple?: boolean`, `async?: boolean`, `isSearchable?: boolean`, `isClearable?: boolean` + +### Toggle +- `ariaLabel: string`, `label?`, `checked?`, `defaultChecked?`, `onChange?` +- `size?: 'medium' | 'large'`, `color?: 'default' | 'alternative'`, `icon?`, `disabled?` + +### ChoiceGroup +- `id: string`, `items: ChoiceGroupItemProps[]`, `inputType?: 'radio' | 'checkbox'` +- `type?: 'light' | 'selector' | 'filter' | 'default'`, `value?`, `onChange?` + +### FileUpload +- `id: string`, `name: string`, `accept?`, `multiple?`, `maxSize?` +- `files?`, `defaultFiles?`, `onChange?`, `onDelete?` + +### TextEditor (Draft.js) +- `id: string`, `defaultValue?`, `onChange?`, `placeholder?` +- `inlineStyleControls?: string[]`, `blockStyleControls?: string[]` + +### HiddenField (toggle-to-edit) +- `fieldType: 'textfield' | 'select' | 'datetime'` +- `fieldOptions: TextFieldProps | SelectProps | DateTimePickerProps` +- `content: ReactNode` + +### DateTimePicker +- Date/time picker using MUI x-date-pickers + +## Navigation + +### Stepper +- `activeStep?`, `defaultActiveStep?: number`, `onActiveStepChange?` +- `allowStepLabelClick?: boolean`, `ariaLabel: string`, `card?: CardProps | boolean` + +### Tabs +- `currentTab?: string`, `defaultCurrentTab?`, `onTabChange?` +- Sub-components: Tabs.Nav, Tabs.NavItem, Tabs.Item + +### TableOfContents +- `items: TableOfContentsItemProps[]`, `heading?`, `open?`, `defaultOpen?` +- `showIcons?: boolean`, `breakToMobile?: boolean` + +## Overlay + +### Dropdown — **DEPRECATED** (use TEDI-Ready Dropdown) + +### Modal +- `size?: 12 | 10 | 8 | 6`, `position?: 'center' | 'right' | 'bottom'` +- `lockScroll?: boolean`, `trapFocus?: boolean`, `returnFocus?: boolean` +- Sub-components: ModalProvider, ModalTrigger, ModalCloser + +### Tooltip — **DEPRECATED** (use TEDI-Ready Tooltip) + +### Feedback (modal-based) +- `triggerProps?: ButtonProps`, `fixedTrigger?: 'both' | 'desktop' | 'mobile'` + +## Tags + +### Tag — **DEPRECATED** (use TEDI-Ready Tag) + +### Status +- `type: 'error' | 'success' | 'inactive' | 'warning'` +- `tooltipContent?: ReactNode` + +## Table + +### Table (TanStack React Table wrapper) +- `data: TData[]`, `columns: ColumnDef[]` +- `pagination?`, `sorting?`, `rowSelection?`, `columnPinning?` +- `onPaginationChange?`, `onSortingChange?`, `onRowSelectionChange?` +- `hidePagination?: boolean`, `size?: 'medium'` + +## Layout + +### Header +Comprehensive header with sub-components: HeaderContent, HeaderActions, HeaderNavigation, HeaderLanguage, HeaderRole, HeaderSettings, HeaderNotifications, HeaderLogo + +## Misc + +### Placeholder (empty state) +- `icon?: string | IconProps | ReactNode`, `cardProps?`, `isNested?: boolean` + +### VerticalStepper +- `isCompact?: boolean` +- Sub-components: StepItem, SubItem + +### VerticalProgress +- `children: ReactNode`, `onItemOpen: (index: number) => void` + +### ToggleOpen +- `openText: string`, `closeText: string`, `isOpen: boolean` + +### Map Components +14 specialized components for map UI interactions (BaseMapSelection, Legend, MapLayer, Directions, Timeline, etc.) diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md new file mode 100644 index 000000000..cedd7c869 --- /dev/null +++ b/skills/tedi-react/references/forms.md @@ -0,0 +1,223 @@ +# Form Controls + +TEDI form controls support both **controlled** and **uncontrolled** modes, following standard React patterns. + +## Available Form Controls + +| Component | Value Type | Key Features | +|-----------|-----------|--------------| +| TextField | `string` | Icon, clearable, size variants | +| TextArea | `string` | Character limit counter | +| NumberField | `number` | Min/max, step, suffix, increment buttons | +| Select | `ISelectOption \| ISelectOption[] \| null` | Async, multi-select, searchable | +| Checkbox | `boolean` (via onChange) | Indeterminate state | +| Radio | `boolean` (via onChange) | Used in ChoiceGroup | +| ChoiceGroup | `ChoiceGroupValue` | Radio/checkbox groups, segmented variant | +| Search | `string` | Search button, onSearch callback | +| FileUpload | `FileUploadFile[]` | Multi-file, validation, loading states | +| FileDropzone | `FileUploadFile[]` | Drag-and-drop | + +## Controlled vs Uncontrolled + +```tsx +// Controlled — you manage state +const [value, setValue] = useState(''); + + +// Uncontrolled — component manages state internally + +``` + +**Rule:** Every form component must accept both `value` (controlled) and `defaultValue` (uncontrolled). + +## TextField + +```tsx +import { TextField } from '@tedi-design-system/react/tedi'; + + +``` + +Key props: `icon`, `isClearable`, `onClear`, `size` ('default' | 'small' | 'large'), `helper` (FeedbackTextProps), `hideLabel`, `readOnly`. + +## Select + +```tsx +import { Select } from '@tedi-design-system/react/tedi'; + +// Single select + + +// Async select + + +``` diff --git a/skills/tedi-react/references/theming.md b/skills/tedi-react/references/theming.md new file mode 100644 index 000000000..0974afe33 --- /dev/null +++ b/skills/tedi-react/references/theming.md @@ -0,0 +1,108 @@ +# Theming + +TEDI uses design tokens from `@tedi-design-system/core` exposed as CSS custom properties. Components use CSS Modules with BEM naming and the `tedi-` prefix. + +## Setup + +Import TEDI styles: + +```tsx +// In your entry file +import '@tedi-design-system/react/index.css'; +``` + +Or in SCSS: +```scss +@use '@tedi-design-system/core/scss' as tedi; +``` + +Wrap your app with `ThemeProvider`: + +```tsx +import { ThemeProvider } from '@tedi-design-system/react/tedi'; + + + + +``` + +## Theme Switching + +Themes are applied as a CSS class on ``: `tedi-theme--default`, `tedi-theme--dark`. + +The ThemeProvider manages theme state and persistence (via cookie `tedi-theme`). + +## Design Tokens + +Tokens follow the naming pattern `--tedi-{category}-{name}`: + +| Category | Examples | +|----------|---------| +| Color | `--tedi-color-primary`, `--tedi-color-bg-default`, `--tedi-color-text-secondary` | +| Spacing | `--tedi-spacing-1`, `--tedi-spacing-2`, `--tedi-spacing-4` | +| Typography | `--tedi-font-size-sm`, `--tedi-font-weight-bold`, `--tedi-line-height-default` | +| Border | `--tedi-border-radius-sm`, `--tedi-border-width-default` | +| Shadow | `--tedi-shadow-sm`, `--tedi-shadow-md` | + +Use tokens in your own SCSS: + +```scss +.my-custom-section { + padding: var(--tedi-spacing-4); + background-color: var(--tedi-color-bg-default); + border-radius: var(--tedi-border-radius-sm); +} +``` + +**Important:** Do NOT use fallback values in `var()`. Write `var(--tedi-spacing-4)`, not `var(--tedi-spacing-4, 16px)`. + +## Overriding Component Styles + +TEDI components use CSS Modules with BEM naming. The class names are hashed at build time, but the BEM structure is consistent. To override styles, target the BEM classes: + +```scss +// Override button primary color +.tedi-button--primary { + background-color: var(--my-brand-primary); +} +``` + +## Custom Themes + +Create a custom theme by defining token values under a theme class: + +```scss +.tedi-theme--my-brand { + --tedi-color-primary: #1a73e8; + --tedi-color-bg-default: #fafafa; + // ... override tokens as needed +} +``` + +## StyleProvider + +The `StyleProvider` wraps components that need runtime style injection: + +```tsx +import { StyleProvider } from '@tedi-design-system/react/tedi'; + + + + +``` + +## Responsive Styles + +For responsive breakpoints in SCSS: + +```scss +@use '@tedi-design-system/core/bootstrap-utility/breakpoints' as bp; + +.my-component { + padding: var(--tedi-spacing-2); + + @include bp.media-breakpoint-up(md) { + padding: var(--tedi-spacing-4); + } +} +``` diff --git a/src/community/components/tabs/tabs.stories.tsx b/src/community/components/tabs/tabs.stories.tsx index c120e063f..8b3b481b0 100644 --- a/src/community/components/tabs/tabs.stories.tsx +++ b/src/community/components/tabs/tabs.stories.tsx @@ -8,6 +8,11 @@ const meta: Meta = { component: Tabs, title: 'Community/Tabs', subcomponents: { TabsItem } as never, + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/tedi/components/misc/scroll-fade/scroll-fade.tsx b/src/tedi/components/misc/scroll-fade/scroll-fade.tsx index 9a570bd24..11f043c09 100644 --- a/src/tedi/components/misc/scroll-fade/scroll-fade.tsx +++ b/src/tedi/components/misc/scroll-fade/scroll-fade.tsx @@ -1,6 +1,7 @@ import cn from 'classnames'; -import { forwardRef, useCallback, useState } from 'react'; +import { forwardRef, useCallback } from 'react'; +import { useScrollFade } from '../../../helpers'; import styles from './scroll-fade.module.scss'; export interface ScrollFadeProps { @@ -47,61 +48,32 @@ export const ScrollFade = forwardRef((props, re fadeSize = 20, fadePosition = 'both', } = props; - const [fade, setFade] = useState({ top: false, bottom: false }); - const handleFade = useCallback( - (scrollTop: number, scrollHeight: number, clientHeight: number) => { - const atTop = scrollTop === 0; - const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1; - - let fadeTop = true; - let fadeBottom = true; - - if (atTop) { - fadeTop = false; - onScrollToTop?.(); - } - - if (atBottom) { - fadeBottom = false; - onScrollToBottom?.(); - } - - setFade({ top: fadeTop, bottom: fadeBottom }); - }, - [onScrollToTop, onScrollToBottom] - ); - - const onScroll = useCallback( - (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLDivElement; - handleFade(scrollTop, scrollHeight, clientHeight); - }, - [handleFade] - ); + const { scrollRef, canScrollStart, canScrollEnd, handleScroll } = useScrollFade({ + direction: 'vertical', + onScrollToStart: onScrollToTop, + onScrollToEnd: onScrollToBottom, + }); - const callbackRef = useCallback( + const mergedRef = useCallback( (node: HTMLDivElement | null) => { + scrollRef(node); if (typeof ref === 'function') { ref(node); } else if (ref) { ref.current = node; } - - if (node) { - handleFade(node.scrollTop, node.scrollHeight, node.clientHeight); - } }, - [handleFade, ref] + [scrollRef, ref] ); + const showStartFade = canScrollStart && (fadePosition === 'both' || fadePosition === 'top'); + const showEndFade = canScrollEnd && (fadePosition === 'both' || fadePosition === 'bottom'); + const ScrollFadeBEM = cn( styles['tedi-scroll-fade'], - { [styles[`tedi-scroll-fade--top-${fadeSize}`]]: fade.top && (fadePosition === 'both' || fadePosition === 'top') }, - { - [styles[`tedi-scroll-fade--bottom-${fadeSize}`]]: - fade.bottom && (fadePosition === 'both' || fadePosition === 'bottom'), - }, + { [styles[`tedi-scroll-fade--top-${fadeSize}`]]: showStartFade }, + { [styles[`tedi-scroll-fade--bottom-${fadeSize}`]]: showEndFade }, className ); @@ -111,7 +83,7 @@ export const ScrollFade = forwardRef((props, re return (
-
+
{children}
diff --git a/src/tedi/components/navigation/tabs/index.ts b/src/tedi/components/navigation/tabs/index.ts new file mode 100644 index 000000000..f2a4981c8 --- /dev/null +++ b/src/tedi/components/navigation/tabs/index.ts @@ -0,0 +1,5 @@ +export * from './tabs'; +export * from './tabs-context'; +export * from './tabs-list/tabs-list'; +export * from './tabs-trigger/tabs-trigger'; +export * from './tabs-content/tabs-content'; diff --git a/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx b/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx new file mode 100644 index 000000000..cff74193c --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-content/tabs-content.tsx @@ -0,0 +1,47 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; + +export interface TabsContentProps { + /** + * Unique identifier matching the corresponding TabsTrigger id. + * When provided, content is only shown when this tab is active. + * When omitted, content is always rendered (useful for router outlets). + */ + id?: string; + /** + * Tab panel content + */ + children: React.ReactNode; + /** + * Additional class name(s) + */ + className?: string; +} + +export const TabsContent = (props: TabsContentProps) => { + const { id, children, className } = props; + const { currentTab } = useTabsContext(); + + if (id && currentTab !== id) { + return null; + } + + return ( +
+ {children} +
+ ); +}; + +TabsContent.displayName = 'TabsContent'; + +export default TabsContent; diff --git a/src/tedi/components/navigation/tabs/tabs-context.tsx b/src/tedi/components/navigation/tabs/tabs-context.tsx new file mode 100644 index 000000000..c762152fd --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-context.tsx @@ -0,0 +1,16 @@ +import React, { useContext } from 'react'; + +export type TabsContextValue = { + currentTab: string; + setCurrentTab: (id: string) => void; +}; + +export const TabsContext = React.createContext(null); + +export const useTabsContext = () => { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error('Tabs components must be used within '); + } + return ctx; +}; diff --git a/src/tedi/components/navigation/tabs/tabs-helpers.ts b/src/tedi/components/navigation/tabs/tabs-helpers.ts new file mode 100644 index 000000000..5307c7307 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-helpers.ts @@ -0,0 +1,43 @@ +/** + * Navigates to a sibling tab in the tablist using ArrowLeft/ArrowRight/Home/End keys. + * Returns the target tab element if navigation occurred, or null otherwise. + */ +export const navigateTablist = (e: React.KeyboardEvent): HTMLButtonElement | null => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') { + return null; + } + + const tablist = e.currentTarget.closest('[role="tablist"]'); + if (!tablist) return null; + + const tabs = Array.from(tablist.querySelectorAll('[role="tab"]:not([disabled])')).filter( + (tab) => getComputedStyle(tab).display !== 'none' + ); + const currentIndex = tabs.indexOf(e.currentTarget); + if (tabs.length === 0 || currentIndex === -1) return null; + let newIndex = -1; + + switch (e.key) { + case 'ArrowLeft': + newIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1; + break; + case 'ArrowRight': + newIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1; + break; + case 'Home': + newIndex = 0; + break; + case 'End': + newIndex = tabs.length - 1; + break; + } + + if (newIndex !== -1) { + e.preventDefault(); + tabs[newIndex].focus(); + tabs[newIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' }); + return tabs[newIndex]; + } + + return null; +}; diff --git a/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx new file mode 100644 index 000000000..710e37111 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-list/tabs-list.tsx @@ -0,0 +1,202 @@ +import cn from 'classnames'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { useScrollFade } from '../../../../helpers'; +import { useLabels } from '../../../../providers/label-provider'; +import { Icon } from '../../../base/icon/icon'; +import Print, { PrintProps } from '../../../misc/print/print'; +import { Dropdown } from '../../../overlays/dropdown/dropdown'; +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; + +export interface TabsListProps { + /** + * Tab trigger elements + */ + children: React.ReactNode; + /** + * Additional class name(s) + */ + className?: string; + /** + * Accessible label for the tablist + */ + 'aria-label'?: string; + /** + * ID of element labelling the tablist + */ + 'aria-labelledby'?: string; + /** + * Controls visibility when printing + * @default 'show' + */ + printVisibility?: PrintProps['visibility']; + /** + * How to handle tab overflow when tabs don't fit in available space. + * - 'dropdown': Shows a dropdown button containing overflowing tabs (default) + * - 'scroll': Enables horizontal scrolling with a fade indicator + * @default 'dropdown' + */ + overflowMode?: 'dropdown' | 'scroll'; +} + +interface OverflowItem { + id: string; + label: React.ReactNode; + disabled?: boolean; +} + +export const TabsList = (props: TabsListProps) => { + const { + children, + className, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + printVisibility = 'show', + overflowMode = 'dropdown', + } = props; + + const { getLabel } = useLabels(); + const { currentTab, setCurrentTab } = useTabsContext(); + + const wrapperRef = useRef(null); + const listRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const isOverflowingRef = useRef(false); + const naturalWidthRef = useRef(0); + + isOverflowingRef.current = isOverflowing; + + const { + scrollRef, + canScrollStart: canScrollLeft, + canScrollEnd: canScrollRight, + handleScroll, + } = useScrollFade({ direction: 'horizontal' }); + + const mergedListRef = useCallback( + (node: HTMLDivElement | null) => { + (listRef as React.MutableRefObject).current = node; + if (overflowMode === 'scroll') { + scrollRef(node); + } + }, + [overflowMode, scrollRef] + ); + + const childArray = React.useMemo(() => { + return React.Children.toArray(children).filter(React.isValidElement); + }, [children]); + + const allItems = React.useMemo(() => { + const result: OverflowItem[] = []; + childArray.forEach((child) => { + const triggerProps = child.props as { id: string; children: React.ReactNode; disabled?: boolean }; + result.push({ id: triggerProps.id, label: triggerProps.children, disabled: triggerProps.disabled }); + }); + return result; + }, [childArray]); + + const showMore = overflowMode === 'dropdown' && isOverflowing && allItems.length > 1; + const dropdownItems = allItems.filter((item) => item.id !== currentTab); + + const handleMoreSelect = (id: string) => { + if (id) { + setCurrentTab(id); + } + }; + + // Capture natural width when all tabs are visible and check for overflow synchronously + useLayoutEffect(() => { + if (overflowMode !== 'dropdown') return; + const list = listRef.current; + if (!list || isOverflowingRef.current) return; + + naturalWidthRef.current = list.scrollWidth; + if (list.scrollWidth > list.clientWidth && list.clientWidth > 0) { + setIsOverflowing(true); + } + }, [overflowMode, isOverflowing, childArray]); + + // ResizeObserver for "dropdown" mode overflow detection + useEffect(() => { + if (overflowMode !== 'dropdown') return; + const wrapper = wrapperRef.current; + const list = listRef.current; + if (!wrapper || !list) return; + + const checkOverflow = () => { + if (isOverflowingRef.current) { + if (naturalWidthRef.current <= wrapper.clientWidth) { + setIsOverflowing(false); + } + } else { + if (list.scrollWidth > list.clientWidth && list.clientWidth > 0) { + naturalWidthRef.current = list.scrollWidth; + setIsOverflowing(true); + } + } + }; + + const ro = new ResizeObserver(checkOverflow); + ro.observe(wrapper); + return () => ro.disconnect(); + }, [overflowMode]); + + return ( + +
+
+ {children} +
+ {showMore && ( +
+ + + + + + {dropdownItems.map((item, index) => ( + handleMoreSelect(item.id)} + > + {item.label} + + ))} + + +
+ )} +
+
+ ); +}; + +TabsList.displayName = 'TabsList'; + +export default TabsList; diff --git a/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx new file mode 100644 index 000000000..2450ce79b --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs-trigger/tabs-trigger.tsx @@ -0,0 +1,79 @@ +import cn from 'classnames'; +import React from 'react'; + +import { Icon, IconProps } from '../../../base/icon/icon'; +import styles from '../tabs.module.scss'; +import { useTabsContext } from '../tabs-context'; +import { navigateTablist } from '../tabs-helpers'; + +export interface TabsTriggerProps { + /** + * Unique identifier for this tab. Used as the element id and to link to the + * corresponding TabsContent panel (aria-controls="{id}-panel"). + */ + id: string; + /** + * Tab label text + */ + children: React.ReactNode; + /** + * Icon displayed before the label + */ + icon?: IconProps['name']; + /** + * Whether the tab is disabled + * @default false + */ + disabled?: boolean; + /** + * Additional class name(s) + */ + className?: string; +} + +export const TabsTrigger = (props: TabsTriggerProps) => { + const { id, children, icon, disabled = false, className } = props; + const { currentTab, setCurrentTab } = useTabsContext(); + const isSelected = currentTab === id; + + const handleClick = () => { + if (!disabled) { + setCurrentTab(id); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const target = navigateTablist(e); + if (target) { + setCurrentTab(target.id); + } + }; + + return ( + + ); +}; + +TabsTrigger.displayName = 'TabsTrigger'; + +export default TabsTrigger; diff --git a/src/tedi/components/navigation/tabs/tabs.module.scss b/src/tedi/components/navigation/tabs/tabs.module.scss new file mode 100644 index 000000000..6edecb75e --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.module.scss @@ -0,0 +1,129 @@ +@mixin tab-button-base { + --_tab-background: var(--tab-item-default-background); + --_tab-color: var(--tab-item-default-text); + + display: inline-flex; + gap: var(--tab-inner-spacing); + align-items: center; + justify-content: center; + padding: var(--tab-spacing-y) var(--tab-spacing-x); + font-size: var(--body-regular-size); + color: var(--_tab-color); + white-space: nowrap; + cursor: pointer; + background: var(--_tab-background); + border: none; + + &:hover { + --_tab-background: var(--tab-item-hover-background); + --_tab-color: var(--tab-item-hover-text); + } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 var(--borders-02) var(--general-border-brand); + } + + &:active { + --_tab-background: var(--tab-item-active-background); + --_tab-color: var(--tab-item-active-text); + } +} + +.tedi-tabs { + &__list-wrapper { + position: relative; + display: flex; + background: var(--tab-background); + border-top-left-radius: var(--tab-top-radius); + border-top-right-radius: var(--tab-top-radius); + + &--scroll-fade-left::before, + &--scroll-fade-right::after { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + width: 48px; + pointer-events: none; + content: ''; + } + + &--scroll-fade-left::before { + left: 0; + background: linear-gradient(to right, var(--tab-background) 10%, transparent 80%); + } + + &--scroll-fade-right::after { + right: 0; + background: linear-gradient(to left, var(--tab-background) 10%, transparent 80%); + } + } + + &__list { + display: flex; + flex: 1; + min-width: 0; + overflow: hidden; + + &--overflow { + .tedi-tabs__trigger:not(.tedi-tabs__trigger--selected) { + display: none; + } + + .tedi-tabs__trigger--selected { + flex-grow: 1; + } + } + + &--scroll { + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + } + + &__trigger { + @include tab-button-base; + + position: relative; + text-decoration: none; + + &--selected { + --_tab-background: var(--tab-item-selected-background); + --_tab-color: var(--tab-item-selected-text); + + font-weight: var(--body-bold-weight); + + &::after { + position: absolute; + top: 0; + right: 0; + left: 0; + content: ''; + border-top: 3px solid var(--tab-item-selected-border); + } + } + + &--disabled { + pointer-events: none; + opacity: 0.4; + } + } + + &__content { + background: var(--tab-item-selected-background); + } + + &__more-wrapper { + display: flex; + flex-shrink: 0; + } + + &__more-btn { + @include tab-button-base; + } +} diff --git a/src/tedi/components/navigation/tabs/tabs.spec.tsx b/src/tedi/components/navigation/tabs/tabs.spec.tsx new file mode 100644 index 000000000..c8098ed35 --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.spec.tsx @@ -0,0 +1,350 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { Tabs, TabsProps } from './tabs'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: jest.fn(() => ({ + getLabel: jest.fn((key: string) => { + const labels: Record = { 'tabs.more': 'More', close: 'Close' }; + return labels[key] ?? key; + }), + })), +})); + +let resizeCallback: (() => void) | null = null; + +beforeAll(() => { + global.ResizeObserver = jest.fn().mockImplementation((cb: ResizeObserverCallback) => ({ + observe: jest.fn(() => { + resizeCallback = () => cb([], {} as ResizeObserver); + }), + unobserve: jest.fn(), + disconnect: jest.fn(() => { + resizeCallback = null; + }), + })); +}); + +const renderTabs = (props?: Partial) => { + return render( + + + Tab 1 + Tab 2 + + Tab 3 + + + Content 1 + Content 2 + Content 3 + + ); +}; + +const simulateOverflow = () => { + const listEl = screen.getByRole('tablist'); + Object.defineProperty(listEl, 'scrollWidth', { value: 500, configurable: true }); + Object.defineProperty(listEl, 'clientWidth', { value: 300, configurable: true }); + + const wrapper = listEl.parentElement!; + Object.defineProperty(wrapper, 'clientWidth', { value: 300, configurable: true }); + + act(() => { + resizeCallback?.(); + }); +}; + +const simulateNoOverflow = () => { + const listEl = screen.getByRole('tablist'); + Object.defineProperty(listEl, 'scrollWidth', { value: 300, configurable: true }); + Object.defineProperty(listEl, 'clientWidth', { value: 600, configurable: true }); + + const wrapper = listEl.parentElement!; + Object.defineProperty(wrapper, 'clientWidth', { value: 600, configurable: true }); + + act(() => { + resizeCallback?.(); + }); +}; + +describe('Tabs component', () => { + it('renders the tablist with correct role', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); + + it('renders tab triggers with correct roles', () => { + renderTabs(); + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(3); + }); + + it('renders the default active tab content', () => { + renderTabs(); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + }); + + it('switches tab on click', () => { + renderTabs(); + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('sets aria-selected correctly', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Tab 2')).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByText('Tab 2')).toHaveAttribute('aria-selected', 'true'); + }); + + it('sets aria-controls on triggers and aria-labelledby on panels', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('aria-controls', 'tab-1-panel'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'tab-1'); + }); + + it('manages tabIndex — only selected tab is in tab order', () => { + renderTabs(); + expect(screen.getByText('Tab 1')).toHaveAttribute('tabIndex', '0'); + expect(screen.getByText('Tab 2')).toHaveAttribute('tabIndex', '-1'); + }); + + it('navigates with ArrowRight key', () => { + renderTabs(); + const tab1 = screen.getByText('Tab 1'); + fireEvent.keyDown(tab1, { key: 'ArrowRight' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('navigates with ArrowLeft key and wraps around', () => { + renderTabs(); + const tab1 = screen.getByText('Tab 1'); + fireEvent.keyDown(tab1, { key: 'ArrowLeft' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + }); + + it('navigates with Home and End keys', () => { + renderTabs(); + fireEvent.click(screen.getByText('Tab 2')); + const tab2 = screen.getByText('Tab 2'); + + fireEvent.keyDown(tab2, { key: 'Home' }); + expect(screen.getByText('Tab 1')).toHaveFocus(); + + fireEvent.keyDown(screen.getByText('Tab 1'), { key: 'End' }); + expect(screen.getByText('Tab 2')).toHaveFocus(); + }); + + it('does not activate disabled tabs on click', () => { + renderTabs(); + const disabledTab = screen.getByText('Tab 3'); + expect(disabledTab).toBeDisabled(); + fireEvent.click(disabledTab); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + }); + + it('works in controlled mode', () => { + const onChange = jest.fn(); + const { rerender } = render( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + fireEvent.click(screen.getByText('Tab 2')); + expect(onChange).toHaveBeenCalledWith('tab-2'); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + + rerender( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('renders with custom className', () => { + renderTabs({ className: 'custom-class' }); + const container = screen.getByRole('tablist').closest('[data-name="tabs"]'); + expect(container).toHaveClass('custom-class'); + }); + + it('renders tabpanel with correct id', () => { + renderTabs(); + expect(screen.getByRole('tabpanel')).toHaveAttribute('id', 'tab-1-panel'); + }); + + it('applies data-name attributes', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toHaveAttribute('data-name', 'tabs-list'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('data-name', 'tabs-content'); + }); + + it('does not set tabIndex on tabpanel', () => { + renderTabs(); + expect(screen.getByRole('tabpanel')).not.toHaveAttribute('tabIndex'); + }); + + it('does not render "More" button when tabs do not overflow', () => { + renderTabs(); + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); +}); + +describe('Tabs overflow (more mode)', () => { + it('renders "More" button when tabs overflow', () => { + renderTabs(); + simulateOverflow(); + expect(screen.getByText('More')).toBeInTheDocument(); + }); + + it('does not render "More" button when there is only one tab', () => { + render( + + + Only Tab + + Content + + ); + + const listEl = screen.getByRole('tablist'); + Object.defineProperty(listEl, 'scrollWidth', { value: 500, configurable: true }); + Object.defineProperty(listEl, 'clientWidth', { value: 300, configurable: true }); + const wrapper = listEl.parentElement!; + Object.defineProperty(wrapper, 'clientWidth', { value: 300, configurable: true }); + act(() => { + resizeCallback?.(); + }); + + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); + + it('opens dropdown with non-selected tabs', () => { + renderTabs(); + simulateOverflow(); + fireEvent.click(screen.getByText('More')); + + const menu = screen.getByRole('menu'); + const menuItems = menu.querySelectorAll('[role="menuitem"]'); + expect(menuItems).toHaveLength(2); + }); + + it('closes dropdown on toggle', () => { + renderTabs(); + simulateOverflow(); + const moreBtn = screen.getByText('More'); + + fireEvent.click(moreBtn); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + fireEvent.click(moreBtn); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('selects a tab from dropdown and closes it', () => { + renderTabs(); + simulateOverflow(); + fireEvent.click(screen.getByText('More')); + + const menuItems = screen.getAllByRole('menuitem'); + const tab2Item = menuItems.find((item) => item.textContent === 'Tab 2'); + fireEvent.click(tab2Item!); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + }); + + it('closes dropdown on Escape key', () => { + renderTabs(); + simulateOverflow(); + fireEvent.click(screen.getByText('More')); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + act(() => { + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('removes overflow when container grows', () => { + renderTabs(); + simulateOverflow(); + expect(screen.getByText('More')).toBeInTheDocument(); + + simulateNoOverflow(); + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); +}); + +describe('Tabs overflow (scroll mode)', () => { + it('renders with scroll mode', () => { + render( + + + Tab 1 + Tab 2 + + Content 1 + Content 2 + + ); + + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); + expect(screen.queryByText('More')).not.toBeInTheDocument(); + }); +}); + +describe('TabsContent without id', () => { + it('always renders when id is omitted', () => { + render( + + + Tab 1 + Tab 2 + + Always visible + + ); + + expect(screen.getByText('Always visible')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.getByText('Always visible')).toBeInTheDocument(); + }); + + it('does not set id or aria-labelledby when id is omitted', () => { + render( + + + Tab 1 + + Router outlet + + ); + + const panel = screen.getByRole('tabpanel'); + expect(panel).not.toHaveAttribute('id'); + expect(panel).not.toHaveAttribute('aria-labelledby'); + }); +}); diff --git a/src/tedi/components/navigation/tabs/tabs.stories.tsx b/src/tedi/components/navigation/tabs/tabs.stories.tsx new file mode 100644 index 000000000..112a2b20f --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.stories.tsx @@ -0,0 +1,303 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import { CardContent } from '../../cards/card/card-content/card-content'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { StatusBadge } from '../../tags/status-badge/status-badge'; +import { StatusIndicator } from '../../tags/status-indicator/status-indicator'; +import { Tabs, TabsProps } from './tabs'; +import { TabsContext } from './tabs-context'; +import { TabsTrigger } from './tabs-trigger/tabs-trigger'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +const meta: Meta = { + component: Tabs, + title: 'TEDI-Ready/Components/Navigation/Tabs', + subcomponents: { + 'Tabs.List': Tabs.List, + 'Tabs.Trigger': Tabs.Trigger, + 'Tabs.Content': Tabs.Content, + } as never, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=3419-38773&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const contentText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas turpis odio, iaculis quis sodales at, placerat vitae risus. Integer hendrerit ex eget nisl euismod pharetra.'; + +const stateArray = ['Default', 'Hover', 'Active', 'Focus', 'Selected']; + +interface TemplateStateProps extends TabsProps { + array: typeof stateArray; +} + +const noop = () => null; + +const TemplateColumnWithStates: StoryFn = (args) => { + const { array } = args; + + return ( +
+ {array.map((state, index) => { + const triggerId = state === 'Selected' ? 'state-tab' : `${state}-tab`; + const currentTab = state === 'Selected' ? 'state-tab' : ''; + + return ( + + + {state} + + + +
+ Health timeline +
+
+ +
+ ); + })} +
+ ); +}; + +export const Default: Story = { + render: () => ( + + + Health timeline + Course of diseases + Medication history + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + + ), +}; + +export const WithIcons: Story = { + render: () => ( + + + + Table + + + Grid + + + + + {contentText} + + + + + {contentText} + + + + ), +}; + +export const WithStatusBadge: Story = { + render: () => ( + + + + Health timeline Submitted + + + + Unread messages  + + + + Medication history + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + + ), +}; + +export const States: StoryObj = { + render: TemplateColumnWithStates, + args: { + array: stateArray, + }, + parameters: { + pseudo: { + hover: '#Hover-tab', + active: '#Active-tab', + focusVisible: '#Focus-tab', + }, + }, +}; + +export const Controlled: Story = { + render: () => { + const [currentTab, setCurrentTab] = useState('tab-1'); + + return ( + +

+ Current tab: {currentTab} +

+ + + Health timeline + Course of diseases + Medication history + + + {contentText} + + + {contentText} + + + {contentText} + + +
+ ); + }, +}; + +export const WithDisabledTab: Story = { + render: () => ( + + + Health timeline + Course of diseases + + Medication history + + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + + ), +}; + +export const OverflowBehavior: Story = { + render: () => ( + + Dropdown (default) +
+ + + Health timeline + Course of diseases + Medication history + Declarations of intent + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + +
+ Horizontal scroll +
+ + + Health timeline + Course of diseases + Medication history + Declarations of intent + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + + + {contentText} + + + +
+
+ ), +}; diff --git a/src/tedi/components/navigation/tabs/tabs.tsx b/src/tedi/components/navigation/tabs/tabs.tsx new file mode 100644 index 000000000..c5703623d --- /dev/null +++ b/src/tedi/components/navigation/tabs/tabs.tsx @@ -0,0 +1,64 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from './tabs.module.scss'; +import { TabsContent } from './tabs-content/tabs-content'; +import { TabsContext } from './tabs-context'; +import { TabsList } from './tabs-list/tabs-list'; +import { TabsTrigger } from './tabs-trigger/tabs-trigger'; + +export interface TabsProps { + /** + * Tabs content — should include Tabs.List and Tabs.Content elements + */ + children: React.ReactNode; + /** + * Controlled active tab id. Use together with onChange. + */ + value?: string; + /** + * Default active tab id for uncontrolled usage. + */ + defaultValue?: string; + /** + * Callback fired when the active tab changes + */ + onChange?: (tabId: string) => void; + /** + * Additional class name(s) + */ + className?: string; +} + +export const Tabs = (props: TabsProps) => { + const { children, value: controlledTab, defaultValue, onChange, className } = props; + const [uncontrolledTab, setUncontrolledTab] = React.useState(defaultValue || ''); + + const currentTab = controlledTab ?? uncontrolledTab; + + const setCurrentTab = React.useCallback( + (id: string) => { + if (id === currentTab) return; + if (controlledTab === undefined) { + setUncontrolledTab(id); + } + onChange?.(id); + }, + [controlledTab, currentTab, onChange] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +Tabs.displayName = 'Tabs'; +Tabs.List = TabsList; +Tabs.Trigger = TabsTrigger; +Tabs.Content = TabsContent; + +export default Tabs; diff --git a/src/tedi/components/navigation/tabs/usage-with-router.mdx b/src/tedi/components/navigation/tabs/usage-with-router.mdx new file mode 100644 index 000000000..157d42873 --- /dev/null +++ b/src/tedi/components/navigation/tabs/usage-with-router.mdx @@ -0,0 +1,56 @@ +import { Meta } from '@storybook/blocks'; + + + +# Usage with React Router + +Use controlled mode (`value`/`onChange`) to sync tabs with the current route. +Wrap the router outlet in `` without an `id` — it always renders +and provides the content panel styling. + +--- + +## Example + +```tsx +import { useLocation, useNavigate, Routes, Route } from 'react-router-dom'; +import { Tabs } from '@tedi-design-system/react/tedi'; + +const tabs = [ + { id: '/toimingud', label: 'Toimingud' }, + { id: '/dokumendid', label: 'Dokumendid' }, + { id: '/esindusõigused', label: 'Esindusõigused' }, +]; + +function TabsWithRouting() { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + navigate(path)}> + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + + + Toimingud content

} /> + Dokumendid content

} /> + Esindusõigused content

} /> +
+
+
+ ); +} +``` + +--- + +## Key Points + +- Use `value` and `onChange` to keep tab state in sync with the URL. +- `Tabs.Content` without an `id` always renders its children — use it to wrap your router outlet. +- Each `Tabs.Trigger` `id` should match the route path so the correct tab is highlighted. diff --git a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx index f169a49f3..e4e14cf4c 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.spec.tsx @@ -31,7 +31,7 @@ describe('DropdownTrigger', () => { expect(mockGetReferenceProps).toHaveBeenCalled(); }); - it('passes ref to setReference', () => { + it('passes merged ref that calls setReference', () => { render( @@ -39,6 +39,8 @@ describe('DropdownTrigger', () => { ); const refCall = mockGetReferenceProps.mock.calls[0][0]; - expect(refCall.ref).toBe(mockSetReference); + const mockNode = document.createElement('div'); + refCall.ref(mockNode); + expect(mockSetReference).toHaveBeenCalledWith(mockNode); }); }); diff --git a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx index d98e32ca1..4c028aed6 100644 --- a/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown-trigger/dropdown-trigger.tsx @@ -1,3 +1,4 @@ +import { useMergeRefs } from '@floating-ui/react'; import { cloneElement, ReactElement } from 'react'; import { useDropdownContext } from '../dropdown-context'; @@ -11,11 +12,14 @@ export type DropdownTriggerProps = { export const DropdownTrigger = ({ children }: DropdownTriggerProps) => { const { refs, getReferenceProps } = useDropdownContext(); + const childRef = (children as ReactElement & { ref?: React.Ref }).ref ?? null; + const mergedRef = useMergeRefs([refs.setReference, childRef]); return cloneElement( children, getReferenceProps({ - ref: refs.setReference, + ...children.props, + ref: mergedRef, }) ); }; diff --git a/src/tedi/components/tags/status-badge/status-badge.module.scss b/src/tedi/components/tags/status-badge/status-badge.module.scss index 7e20a99d1..99a028971 100644 --- a/src/tedi/components/tags/status-badge/status-badge.module.scss +++ b/src/tedi/components/tags/status-badge/status-badge.module.scss @@ -2,7 +2,6 @@ $badge-colors: ('neutral', 'brand', 'accent', 'success', 'danger', 'warning', 'transparent'); $badge-variants: ('filled', 'filled-bordered', 'bordered'); -$badge-status-colors: ('inactive', 'success', 'danger', 'warning'); :root { --status-badge-icon-primary: var(--tedi-blue-700); @@ -54,33 +53,6 @@ $badge-status-colors: ('inactive', 'success', 'danger', 'warning'); } } - &--status { - &::before { - position: absolute; - top: -0.25rem; - right: -0.25rem; - z-index: 1; - width: 0.625rem; - height: 0.625rem; - content: ''; - border: 1px solid var(--tedi-neutral-100); - border-radius: 50%; - } - - &.tedi-badge--large::before { - top: -0.1875rem; - right: -0.1875rem; - width: 0.875rem; - height: 0.875rem; - } - - @each $status in $badge-status-colors { - &-#{$status}::before { - background-color: var(--status-badge-indicator-#{$status}); - } - } - } - &__icon-only { display: inline-flex; align-items: center; diff --git a/src/tedi/components/tags/status-badge/status-badge.spec.tsx b/src/tedi/components/tags/status-badge/status-badge.spec.tsx index 8b680e754..a5f1a9a33 100644 --- a/src/tedi/components/tags/status-badge/status-badge.spec.tsx +++ b/src/tedi/components/tags/status-badge/status-badge.spec.tsx @@ -48,9 +48,9 @@ describe('StatusBadge component', () => { Warning Badge ); - const badge = container.querySelector('.tedi-status-badge'); - expect(badge).toHaveClass('tedi-status-badge--status'); - expect(badge).toHaveClass('tedi-status-badge--status-warning'); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('tedi-status-indicator--warning'); }); it('renders with icon only', () => { @@ -112,8 +112,10 @@ describe('StatusBadge component', () => { const { container } = render(All Props Badge); const badge = container.querySelector('.tedi-status-badge'); expect(badge).toHaveClass('tedi-status-badge--variant-filled-bordered'); - expect(badge).toHaveClass('tedi-status-badge--status-success'); expect(badge).toHaveClass('tedi-status-badge--large'); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveClass('tedi-status-indicator--success'); expect(badge).toHaveClass('custom-class'); expect(badge).toHaveTextContent('All Props Badge'); expect(badge).toHaveAttribute('title', 'Success Badge'); diff --git a/src/tedi/components/tags/status-badge/status-badge.tsx b/src/tedi/components/tags/status-badge/status-badge.tsx index a2ee589cd..ef8fb5282 100644 --- a/src/tedi/components/tags/status-badge/status-badge.tsx +++ b/src/tedi/components/tags/status-badge/status-badge.tsx @@ -2,6 +2,7 @@ import cn from 'classnames'; import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { Icon } from '../../base/icon/icon'; +import { StatusIndicator } from '../status-indicator/status-indicator'; import styles from './status-badge.module.scss'; export type StatusBadgeColor = 'neutral' | 'brand' | 'accent' | 'success' | 'danger' | 'warning' | 'transparent'; @@ -82,8 +83,6 @@ export const StatusBadge = (props: StatusBadgeProps): JSX.Element => { styles['tedi-status-badge'], styles[`tedi-status-badge--variant-${variant}`], styles[`tedi-status-badge--color-${color}`], - status && styles['tedi-status-badge--status'], - status && styles[`tedi-status-badge--status-${status}`], size === 'large' && styles['tedi-status-badge--large'], icon && !children && styles['tedi-status-badge__icon-only'], className @@ -100,6 +99,7 @@ export const StatusBadge = (props: StatusBadgeProps): JSX.Element => { aria-live={role ? ariaLive : undefined} {...rest} > + {status && } {icon && } {children && {children}} diff --git a/src/tedi/components/tags/status-indicator/index.ts b/src/tedi/components/tags/status-indicator/index.ts new file mode 100644 index 000000000..e012d8585 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/index.ts @@ -0,0 +1 @@ +export * from './status-indicator'; diff --git a/src/tedi/components/tags/status-indicator/status-indicator.module.scss b/src/tedi/components/tags/status-indicator/status-indicator.module.scss new file mode 100644 index 000000000..d37a6b7b7 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.module.scss @@ -0,0 +1,34 @@ +$indicator-types: ('success', 'danger', 'warning', 'inactive'); + +.tedi-status-indicator { + display: inline-block; + flex-shrink: 0; + border-radius: 50%; + + &--top-right { + position: absolute; + top: -0.25rem; + right: -0.25rem; + z-index: 1; + } + + &--sm { + width: var(--status-indicator-sm); + height: var(--status-indicator-sm); + } + + &--lg { + width: var(--status-indicator-lg); + height: var(--status-indicator-lg); + } + + &--bordered { + box-shadow: 0 0 0 2px var(--tedi-neutral-100, white); + } + + @each $type in $indicator-types { + &--#{$type} { + background-color: var(--status-badge-indicator-#{$type}); + } + } +} diff --git a/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx b/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx new file mode 100644 index 000000000..dbdd42f2a --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.spec.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react'; + +import { StatusIndicator } from './status-indicator'; + +describe('StatusIndicator', () => { + it('renders with default props', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toBeInTheDocument(); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('renders with danger type', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--danger'); + }); + + it('renders with large size', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--lg'); + }); + + it('renders with border', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('tedi-status-indicator--bordered'); + }); + + it('applies custom className', () => { + const { container } = render(); + const indicator = container.querySelector('[data-name="status-indicator"]'); + expect(indicator).toHaveClass('custom'); + }); +}); diff --git a/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx new file mode 100644 index 000000000..15c8d24f1 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.stories.tsx @@ -0,0 +1,73 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { StatusIndicator } from './status-indicator'; + +/** + * Figma ↗ + */ +const meta: Meta = { + component: StatusIndicator, + title: 'TEDI-Ready/Components/Tag/StatusIndicator', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=2405-53326&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + type: 'success', + size: 'sm', + hasBorder: false, + }, +}; + +const types = ['success', 'danger', 'warning', 'inactive'] as const; +const sizes = ['sm', 'lg'] as const; + +export const AllVariants: Story = { + render: () => ( +
+ {sizes.map((size) => ( + + + {size === 'sm' ? 'Small' : 'Large'} + + + {types.map((type) => ( + + ))} + + + ))} + {sizes.map((size) => ( + + + {size === 'sm' ? 'Small bordered' : 'Large bordered'} + + + {types.map((type) => ( + + ))} + + + ))} +
+ ), +}; + +export const Examples: Story = { + render: () => ( + + Lugemata teated  + + + ), +}; diff --git a/src/tedi/components/tags/status-indicator/status-indicator.tsx b/src/tedi/components/tags/status-indicator/status-indicator.tsx new file mode 100644 index 000000000..994b26358 --- /dev/null +++ b/src/tedi/components/tags/status-indicator/status-indicator.tsx @@ -0,0 +1,60 @@ +import cn from 'classnames'; + +import styles from './status-indicator.module.scss'; + +export type StatusIndicatorType = 'success' | 'danger' | 'warning' | 'inactive'; +export type StatusIndicatorSize = 'sm' | 'lg'; +export type StatusIndicatorPosition = 'default' | 'top-right'; + +export interface StatusIndicatorProps { + /** + * The status type, which determines the indicator color. + * @default 'success' + */ + type?: StatusIndicatorType; + /** + * The size of the indicator. + * @default 'sm' + */ + size?: StatusIndicatorSize; + /** + * Whether the indicator has a white border ring. + * @default false + */ + hasBorder?: boolean; + /** + * Controls positioning of the indicator. + * - `'default'` — inline, no absolute positioning + * - `'top-right'` — absolutely positioned at the top-right corner of the parent + * @default 'default' + */ + position?: StatusIndicatorPosition; + /** + * Additional class name(s) + */ + className?: string; +} + +export const StatusIndicator = (props: StatusIndicatorProps): JSX.Element => { + const { type = 'success', size = 'sm', hasBorder = false, position = 'default', className } = props; + + return ( +