From 2c992a09e6eb9af4cc84f14c6276a77a695824c3 Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 26 Mar 2026 10:22:46 +0200 Subject: [PATCH 01/18] feat(checkbox): changes from review + added card variant #222 --- .claude/hooks/post-edit-test.sh | 52 +++ .claude/settings.json | 15 + .claude/skills/contributing/SKILL.md | 69 +++ .../contributing/references/a11y-review.md | 78 ++++ .../contributing/references/best-practices.md | 296 ++++++++++++ .../contributing/references/new-component.md | 68 +++ .../contributing/references/refactoring.md | 60 +++ .../skills/contributing/references/stories.md | 135 ++++++ .../skills/contributing/references/testing.md | 89 ++++ .coderabbit.yaml | 44 ++ CLAUDE.md | 155 +++++++ .../checkbox-card.component.scss | 125 ++++++ .../checkbox-card.component.spec.ts | 60 +++ .../checkbox-card/checkbox-card.component.ts | 29 ++ .../form/checkbox/checkbox.component.scss | 6 + .../form/checkbox/checkbox.stories.ts | 420 +++++++++++++++++- tedi/components/form/index.ts | 1 + 17 files changed, 1693 insertions(+), 9 deletions(-) create mode 100755 .claude/hooks/post-edit-test.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/contributing/SKILL.md create mode 100644 .claude/skills/contributing/references/a11y-review.md create mode 100644 .claude/skills/contributing/references/best-practices.md create mode 100644 .claude/skills/contributing/references/new-component.md create mode 100644 .claude/skills/contributing/references/refactoring.md create mode 100644 .claude/skills/contributing/references/stories.md create mode 100644 .claude/skills/contributing/references/testing.md create mode 100644 .coderabbit.yaml create mode 100644 CLAUDE.md create mode 100644 tedi/components/form/checkbox-card/checkbox-card.component.scss create mode 100644 tedi/components/form/checkbox-card/checkbox-card.component.spec.ts create mode 100644 tedi/components/form/checkbox-card/checkbox-card.component.ts diff --git a/.claude/hooks/post-edit-test.sh b/.claude/hooks/post-edit-test.sh new file mode 100755 index 000000000..e786d601d --- /dev/null +++ b/.claude/hooks/post-edit-test.sh @@ -0,0 +1,52 @@ +#!/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 tedi/ +if [[ ! "$FILE" =~ ^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 +SPEC="${FILE%.*}.spec.${FILE##*.}" +# Handle .component.ts -> .component.spec.ts +if [[ "$FILE" =~ \.component\.ts$ ]]; then + SPEC="${FILE%.component.ts}.component.spec.ts" +elif [[ "$FILE" =~ \.component\.html$ ]]; then + SPEC="${FILE%.component.html}.component.spec.ts" +elif [[ "$FILE" =~ \.component\.scss$ ]]; then + SPEC="${FILE%.component.scss}.component.spec.ts" +elif [[ "$FILE" =~ \.directive\.ts$ ]]; then + SPEC="${FILE%.directive.ts}.directive.spec.ts" +elif [[ "$FILE" =~ \.service\.ts$ ]]; then + SPEC="${FILE%.service.ts}.service.spec.ts" +fi + +# Run test if spec file exists +if [[ -f "$SPEC" ]]; then + echo "Running: npx jest $SPEC" + npx jest "$SPEC" --no-coverage 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..2aa508efc --- /dev/null +++ b/.claude/skills/contributing/SKILL.md @@ -0,0 +1,69 @@ +--- +name: contributing +description: > + Guide for contributing to TEDI Design System Angular. 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 Angular Contributing + +You are a senior Angular 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 Angular 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 React (`../react/src/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-angular/references/components.md`: +- **New component** → add entry to the appropriate section (TEDI-Ready or Community) with selector, key inputs/outputs, and a usage example. +- **Removed component** → delete its entry. +- **Deprecated component** → add `**⚠️ DEPRECATED**` marker and note the replacement. +- **API change** (renamed input, new output, changed selector) → 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 6006) +npm test # Run all tests (Jest) +npx jest path/to/file # Run a single test file +npm run lint # Stylelint + ESLint with --fix +npm run build # Build library to dist/ +``` diff --git a/.claude/skills/contributing/references/a11y-review.md b/.claude/skills/contributing/references/a11y-review.md new file mode 100644 index 000000000..93954eff3 --- /dev/null +++ b/.claude/skills/contributing/references/a11y-review.md @@ -0,0 +1,78 @@ +# WCAG Accessibility Review + +Target component: `$ARGUMENTS` + +## Audit Procedure + +### 1. Read the Component + +Read all files for the target component: +- `.component.ts` — check host bindings, ARIA attributes set programmatically +- `.component.html` — check template for roles, aria-* attributes, semantic HTML +- `.component.scss` — check focus styles, contrast, reduced motion support +- `.component.spec.ts` — 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 (``, +}) +export class MyComponent {} +``` + +## Component Patterns + +### Standalone imports +Every TEDI component is standalone. Import only what you use: + +```typescript +import { + TextFieldComponent, + FormFieldComponent, + LabelComponent, +} from '@tedi-design-system/angular/tedi'; +``` + +### Attribute vs element selectors +Some components use attribute selectors to enhance native elements: + +```html + + + + + + + +... + +``` + +### Signal-based inputs +All component APIs use Angular signals (`input()`, `model()`, `output()`): + +```html + + + + + +... +``` + +## Forms + +TEDI form controls implement `ControlValueAccessor` for seamless reactive forms integration: + +```typescript +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { TextFieldComponent, FormFieldComponent, LabelComponent } from '@tedi-design-system/angular/tedi'; + +@Component({ + standalone: true, + imports: [ReactiveFormsModule, TextFieldComponent, FormFieldComponent, LabelComponent], + template: ` + + Email + + + `, +}) +export class MyFormComponent { + email = new FormControl(''); +} +``` + +Form controls: `TextFieldComponent`, `NumberFieldComponent`, `CheckboxComponent`, `ToggleComponent`, `DatePickerComponent`, `DropdownComponent`. + +## Theming + +TEDI uses CSS custom properties (design tokens) from `@tedi-design-system/core`. Switch themes at runtime: + +```typescript +import { ThemeService } from '@tedi-design-system/angular/tedi'; + +export class MyComponent { + private themeService = inject(ThemeService); + + toggleDark() { + this.themeService.theme.set('dark'); + } +} +``` + +Themes apply via CSS class on ``: `tedi-theme--default`, `tedi-theme--dark`. + +## Translation + +Built-in support for Estonian, English, and Russian: + +```typescript +import { TediTranslationService } from '@tedi-design-system/angular/tedi'; + +export class MyComponent { + private translation = inject(TediTranslationService); + + switchLanguage() { + this.translation.setLanguage('en'); + } +} +``` + +## Additional References + +Load based on your task — **do not load all at once**: + +- [references/components.md](references/components.md) — All components by category with selectors, inputs, and usage +- [references/theming.md](references/theming.md) — Design tokens, SCSS customization, theme service +- [references/forms.md](references/forms.md) — Form controls, validation, reactive forms patterns diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md new file mode 100644 index 000000000..feb326c22 --- /dev/null +++ b/skills/tedi-angular/references/components.md @@ -0,0 +1,714 @@ +# Component Reference + +Two component namespaces are available. **Always prefer TEDI-Ready** components — they are production-grade, follow stricter conventions, and are actively maintained. Use Community components only when no TEDI-Ready equivalent exists. + +- `@tedi-design-system/angular/tedi` — TEDI-Ready (preferred) +- `@tedi-design-system/angular/community` — Community/extended + +--- + +# TEDI-Ready Components + +All components are standalone (`standalone: true`), use `ChangeDetectionStrategy.OnPush`, and `ViewEncapsulation.None`. Import from `@tedi-design-system/angular/tedi`. + +## Base + +### Icon +**Selector:** `tedi-icon` +**Inputs:** +- `name: string` — Material Icon name (required) +- `size: IconSize = 24` — 8, 12, 16, 18, 24, 36, 48, or "inherit" +- `color: IconColor = "primary"` +- `background: IconBackgroundColor` — circular background color +- `variant: IconVariant = "outlined"` — "filled" or "outlined" +- `type: IconType = "outlined"` — Material Symbols style +- `label: string` — accessible label + +### Text +**Selector:** `[tedi-text]` +**Inputs:** +- `modifiers: TextModifiers[] | TextModifiers` — h1-h6, bold, italic, uppercase, etc. +- `color: TextColor = "primary"` +**Slots:** default + +## Buttons + +### Button +**Selector:** `[tedi-button]` +**Inputs:** +- `variant: ButtonVariant = "primary"` +- `size: ButtonSize = "default"` — "default" or "small" +**Slots:** default + +```html + + +``` + +### ClosingButton +**Selector:** `button[tedi-closing-button]` +**Inputs:** +- `size: ClosingButtonSize = "default"` +- `iconSize: ClosingButtonIconSize = 24` — 18 or 24 +- `ariaLabel: string` + +### Collapse +**Selector:** `tedi-collapse` +**Inputs:** +- `openText: string` — text when collapsed +- `closeText: string` — text when expanded +- `defaultOpen: boolean = false` +- `hideCollapseText: boolean = false` +- `arrowType: ArrowType = "default"` +**Slots:** default + +### InfoButton +**Selector:** `button[tedi-info-button]` +**Inputs:** +- `ariaLabel: string` + +## Cards + +### Accordion +**Selector:** `tedi-accordion` +**Inputs:** +- `allowMultiple: boolean = false` +**Slots:** default (AccordionItem children) + +### AccordionItem +**Selector:** `tedi-accordion-item` +**Inputs:** +- `title: string = ""` +- `titleLayout: "hug" | "fill" = "hug"` +- `defaultExpanded: boolean = false` +- `expandActionPosition: "start" | "end" = "end"` +- `description: string` +- `showIconCard: boolean = false` +- `selected: boolean = false` +- `headerClass: string | null` +- `bodyClass: string | null` +**Model:** `expanded: boolean` +**Slots:** default, `[tedi-accordion-icon-card]`, `[tedi-accordion-start-action]`, `[tedi-accordion-end-action]` + +```html + + Content 1 + Content 2 + +``` + +## Content + +### Carousel +**Selector:** `tedi-carousel` + +Composed of sub-components: + +```html + + Title + +
Slide 1
+
Slide 2
+
+ + + + +
+``` + +### CarouselContent +**Selector:** `tedi-carousel-content` +**Inputs:** +- `slidesPerView: BreakpointInput = {xs: 1}` +- `gap: BreakpointInput = {xs: 16}` +- `fade: boolean = false` +- `transitionMs: number = 400` + +### CarouselIndicators +**Selector:** `tedi-carousel-indicators` +**Inputs:** +- `withArrows: boolean = false` +- `variant: CarouselIndicatorsVariant = "dots"` — "dots" or "numbers" + +### List +**Selector:** `ul[tedi-list]` or `ol[tedi-list]` +**Inputs:** +- `styled: boolean = true` +- `color: BulletColor = "brand"` +**Slots:** default + +```html +
    +
  • Item 1
  • +
  • Item 2
  • +
+``` + +### TextGroup +**Selector:** `tedi-text-group` +**Inputs:** +- `type: TextGroupType = "horizontal"` — "vertical" or "horizontal" +- `labelWidth: string` — e.g., "200px", "30%" +- Responsive: `xs, sm, md, lg, xl, xxl: TextGroupInputs` + +```html + + Name + John Doe + +``` + +## Form + +### TextField +**Selector:** `input[tedi-text-field]` +**Model:** `value: string` +**Inputs:** +- `arrowsHidden: boolean = true` +**Outputs:** +- `clear: void` + +```html + + +``` + +### NumberField +**Selector:** `tedi-number-field` +**Model:** `value: number` +**Inputs:** +- `inputId: string` (required) +- `label: string` +- `min: number`, `max: number`, `step: number = 1` +- `size: NumberFieldSize = "default"` +- `suffix: string` — unit text +- `fullWidth: boolean = false` +- `disabled: boolean = false` +- `required: boolean = false` +- `invalid: boolean = false` + +### Checkbox +**Selector:** `input[type=checkbox][tedi-checkbox]` +**Inputs:** +- `size: CheckboxSize = "default"` — "default" or "large" +- `invalid: boolean = false` + +```html + +``` + +### Toggle +**Selector:** `tedi-toggle` +**Model:** `checked: boolean` +**Inputs:** +- `inputId: string` (required) +- `variant: ToggleVariant = "primary"` — "primary" or "colored" +- `type: ToggleType = "filled"` — "filled" or "outlined" +- `size: ToggleSize = "default"` — "default" or "large" +- `icon: boolean = false` +- `disabled: boolean = false` +- `required: boolean = false` + +### DatePicker +**Selector:** `tedi-date-picker` +**Model:** `selected: Date | null`, `month: Date` +**Inputs:** +- `disabled: DatePickerMatcher | null` — function `(date: Date) => boolean` +- `monthMode: DatePickerSelectorMode = "dropdown"` +- `yearMode: DatePickerSelectorMode = "dropdown"` +- `allowManualInput: boolean = true` +- `showWeekNumbers: boolean = false` +- `closeOnSelect: boolean = true` +- `inputState: "default" | "error" | "valid" = "default"` +- `inputSize: "default" | "small" = "default"` +- `inputDisabled: boolean = false` +- `inputId: string`, `inputPlaceholder: string` + +```html + +``` + +### FormField +**Selector:** `tedi-form-field` +**Inputs:** +- `size: InputSize = "default"` +- `icon: string | FormFieldIcon` +- `clearable: boolean = false` +- `inputClass: string | null` + +```html + + Search + + + +``` + +### Label +**Selector:** `[tedi-label]` +**Inputs:** +- `size: LabelSize = "default"` +- `required: boolean = false` +- `color: LabelColor = "secondary"` + +### FeedbackText +**Selector:** `tedi-feedback-text` +**Inputs:** +- `text: string` (required) +- `type: FeedbackTextType = "hint"` — "hint", "valid", "error" +- `position: FeedbackTextPosition = "left"` + +## Helpers + +### Row / Col (Grid) +**Selectors:** `tedi-row`, `tedi-col` + +```html + + Wide column + Narrow column + +``` + +**Row inputs:** `cols`, `minColWidth`, `justifyItems`, `alignItems`, `gap`, `gapX`, `gapY` + responsive breakpoints +**Col inputs:** `width` (1-12), `justifySelf`, `alignSelf` + responsive breakpoints + +### Separator +**Selector:** `tedi-separator` +**Inputs:** +- `axis: "horizontal" | "vertical" = "horizontal"` +- `color: SeparatorColor = "primary"` +- `variant: SeparatorVariant` +- `thickness: number = 1` +- `spacing: SeparatorSpacingValue | SeparatorSpacing` +- `size: string = "100%"` + +### ScrollFade +**Selector:** `tedi-scroll-fade` +**Inputs:** +- `fadeSize: ScrollFadeSize = 20` — gradient size in percent (0, 10, 20) +- `fadePosition: ScrollFadePosition = "both"` — `"top"`, `"bottom"`, or `"both"` +- `scrollBar: ScrollFadeScrollbar = "custom"` — `"default"` or `"custom"` +**Outputs:** +- `scrolledToTop: void` +- `scrolledToBottom: void` + +```html + + + +``` + +### Timeline +**Selector:** `tedi-timeline` +**Inputs:** +- `activeIndex: number` + +```html + + + Step 1 + Description + + +``` + +## Layout + +### Header +**Selector:** `header[tedi-header]` + +```html +
+ + Logo + + + + + + + + +
+``` + +### SideNav +**Selector:** `nav[tedi-sidenav]` +**Inputs:** +- `dividers: boolean = true` +- `size: SideNavItemSize = "large"` +- `collapsible: boolean = false` +- `desktopBreakpoint: Breakpoint = "lg"` + +```html + +``` + +### Footer +**Selector:** `tedi-footer` + +```html + + + +

+372 123 4567

+
+
+ + © 2024 + +
+``` + +## Loader + +### Spinner +**Selector:** `tedi-spinner` +**Inputs:** +- `size: SpinnerSize = 16` — 10, 16, or 48 +- `color: SpinnerColor = "primary"` +- `label: string` — screen reader label + +## Navigation + +### Link +**Selector:** `[tedi-link]` +**Inputs:** +- `variant: LinkVariant = "default"` +- `size: LinkSize = "default"` +- `underline: boolean = true` +- `target: string` +- Responsive: `xs, sm, md, lg, xl, xxl: LinkInputs` +**Slots:** default + +```html +Go to page +``` + +## Notifications + +### Alert +**Selector:** `tedi-alert` +**Model:** `open: boolean = true` +**Inputs:** +- `title: string` +- `type: AlertType = "info"` +- `icon: string` +- `showClose: boolean = false` +- `role: AlertRole = "alert"` +- `variant: AlertVariant = "default"` +- `closeDelay: number = 0` +**Outputs:** +- `closeClick: void` +**Slots:** default + +```html + + Your changes have been saved. + +``` + +### Toast (via ToastService) + +```typescript +import { ToastService } from '@tedi-design-system/angular/tedi'; + +export class MyComponent { + private toastService = inject(ToastService); + + showToast() { + this.toastService.open({ + title: 'Success', + type: 'success', + duration: 6000, + }); + } +} +``` + +Add `` to your root template. + +## Overlay + +### Modal (via ModalService) + +Open modals programmatically via `ModalService.open()`. Uses Angular CDK Dialog for overlay, backdrop, focus trapping, scroll blocking, and keyboard events. + +```typescript +import { ModalService, ModalRef, MODAL_DATA } from '@tedi-design-system/angular/tedi'; + +// Opening a modal +private modalService = inject(ModalService); + +openModal() { + const ref = this.modalService.open(MyModalContent, { + data: { title: 'Hello' }, + width: 'md', // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | custom CSS value + size: 'default', // 'default' | 'small' + position: 'center', // 'center' | 'top' | 'left' | 'right' + closeOnBackdropClick: true, + scrollBehavior: 'content', // 'content' | 'page' + mobileFullscreen: false, + }); + + ref.closed.subscribe(result => console.log(result)); +} +``` + +**ModalConfig inputs:** +- `data: unknown` — injected via `MODAL_DATA` token +- `width: ModalWidth = "sm"` — preset (`xs`-`xl`) or custom CSS value (`"80%"`, `"600px"`) +- `size: ModalSize = "default"` — `"default"` or `"small"` +- `position: ModalPosition = "center"` — `"center"`, `"top"`, `"left"`, `"right"` +- `closeOnBackdropClick: boolean = true` +- `scrollBehavior: "content" | "page" = "content"` +- `mobileFullscreen: boolean = false` + +**ModalRef methods/properties:** +- `close(result?: R)` — close with optional result +- `closed: Observable` — emits on close +- `backdropClick(): Observable` +- `keydownEvents(): Observable` +- `updateSize(width: string, height: string)` + +**Content component pattern:** + +```typescript +@Component({ + imports: [ModalComponent, ModalHeaderComponent, ModalContentComponent, ModalFooterComponent, ButtonComponent], + template: ` + + +

{{ data.title }}

+

Optional description

+
+ + + + + + + +
+ `, +}) +class MyModalContent { + data = inject(MODAL_DATA); + ref = inject(ModalRef); +} +``` + +**Sub-components:** +- `tedi-modal-header` — `showClose: boolean = true` +- `tedi-modal-content` — scrollable body +- `tedi-modal-footer` — action buttons + +### Modal (template-based, deprecated) + +The `[(open)]` binding approach is deprecated. Use `ModalService.open()` for new code. + +```html + +

Title

+ Body + + + +
+``` + +### Dropdown +**Selector:** `tedi-dropdown` +**Model:** `value: string` +**Inputs:** +- `position: DropdownPosition = "bottom-start"` +- `preventOverflow: boolean = true` +- `appendTo: string` + +```html + + + +
  • Option A
  • +
  • Option B
  • +
    +
    +``` + +### Popover +**Selector:** `tedi-popover` +**Inputs:** +- `position: PopoverPosition = "top"` +- `dismissible: boolean = true` +- `withArrow: boolean = true` +- `lockScroll: boolean = false` +- `appendTo: string = "body"` + +### Tooltip +**Selector:** `tedi-tooltip` +**Inputs:** +- `position: TooltipPosition = "top"` +- `preventOverflow: boolean = true` +- `openWith: TooltipOpenWith = "both"` — hover, focus, or both +- `appendTo: string = "body"` + +```html + + + + + Tooltip text + +``` + +## Tags + +### Tag +**Selector:** `tedi-tag` +**Inputs:** +- `loading: boolean = false` +- `closable: boolean = false` +- `type: TagType = "primary"` +**Outputs:** +- `closed: Event` +**Slots:** default + +```html +Label +``` + +### StatusBadge +**Selector:** `tedi-status-badge` +**Inputs:** +- `text: string` +- `color: StatusBadgeColor = "neutral"` +- `variant: StatusBadgeVariant = "filled"` +- `size: StatusBadgeSize = "default"` +- `status: StatusBadgeStatus` +- `icon: string` + +```html + +``` + +--- + +# Community Components + +Import from `@tedi-design-system/angular/community`. These are community-contributed, have relaxed review standards, and are **not recommended** when a TEDI-Ready equivalent exists. + +## Buttons + +### FloatingButton +**Selector:** `[tedi-floating-button]` +- `variant: FloatingButtonVariant = "primary"` +- `size: FloatingButtonSize = "default"` +- `axis: FloatingButtonAxis = "horizontal"` + +## Cards + +### Accordion — **DEPRECATED** (use TEDI-Ready Accordion) +### Card +**Selector:** `tedi-card` +- `borderless: boolean`, `spacing: CardSpacing = "md"`, `accentBorder: CardAccentBorder`, `selected: boolean` +- Sub-components: `tedi-card-header`, `tedi-card-content`, `tedi-card-row` + +## Form + +### Checkbox +**Selector:** `tedi-checkbox` | ControlValueAccessor +- `inputId: string`, `value: string`, `size: CheckboxSize`, `hasError: boolean` +- Models: `checked: boolean | null`, `indeterminate: boolean`, `disabled: boolean` + +### CheckboxGroup / CheckboxCardGroup +**Selector:** `tedi-checkbox-group`, `tedi-checkbox-card-group` + +### Input — **DEPRECATED** (use TEDI-Ready TextField) + +### Radio / RadioGroup / RadioCardGroup +**Selector:** `tedi-radio`, `tedi-radio-group`, `tedi-radio-card-group` + +### Select / Multiselect +**Selector:** `tedi-select`, `tedi-multiselect` | ControlValueAccessor +- `inputId: string`, `label: string`, `clearable: boolean = true`, `state: InputState`, `size: InputSize` + +### Search +**Selector:** `tedi-search` | ControlValueAccessor +- `inputId: string`, `autocompleteOptions: AutocompleteOption[]`, `size: SearchSize`, `withButton: boolean` + +### Textarea +**Selector:** `[tedi-textarea]` (extends Input) +- `resizeX: boolean = false`, `resizeY: boolean = true` + +### FileDropzone +**Selector:** `tedi-file-dropzone` | ControlValueAccessor +- `accept: string`, `maxSize: number`, `multiple: boolean`, `mode: "append" | "replace"` + +### FormField / InputGroup +**Selector:** `tedi-form-field`, `tedi-input-group` + +## Helpers + +### ProgressBar +**Selector:** `tedi-progress-bar` +- `value: number = 0`, `direction: "horizontal" | "vertical"`, `small: boolean` + +## Navigation + +### Breadcrumbs +**Selector:** `tedi-breadcrumbs` +- `crumbs: Breadcrumb[]`, `shortCrumbs: boolean` | Breakpoint support + +### Pagination +**Selector:** `tedi-pagination` +- Models: `page: number = 1`, `pageSize: number = 50` +- `pageSizeOptions: number[]`, `length: number` + +### Tabs +**Selector:** `tedi-tabs` +- Sub-components: `[tedi-tab]` (`tabId: string`), `tedi-tab-content` (`tabId: string`) + +### TableOfContents +**Selector:** `tedi-table-of-contents` +- `heading: string`, `position: "default" | "fixed" | "sticky"`, `scrollAware: boolean` + +### VerticalStepper +**Selector:** `tedi-vertical-stepper` +- `compact: boolean`, `enumerated: boolean` +- Sub-component: `tedi-vertical-stepper-item` (`title: string`, `completed`, `error`, `selected`, `disabled`) + +## Overlay + +### Dropdown +**Selector:** `tedi-dropdown` +- `dropdownId: string`, `dropdownRole: "menu" | "listbox"` +- Sub-component: `[tedi-dropdown-item]` + +### Modal +**Selector:** `tedi-modal` +- Models: `maxWidth: ModalBreakpoint = "sm"`, `variant: "default" | "small"` +- Sub-components: `tedi-modal-header`, `tedi-modal-footer` + +## Tags + +### Tag — **DEPRECATED** (use TEDI-Ready Tag) +### StatusBadge — **DEPRECATED** (use TEDI-Ready StatusBadge) + +## Table + +### TableStyles +**Selector:** `tedi-table-styles` +- `size: "default" | "small"`, `verticalBorders: boolean`, `striped: boolean`, `clickable: boolean` diff --git a/skills/tedi-angular/references/forms.md b/skills/tedi-angular/references/forms.md new file mode 100644 index 000000000..6157b9e15 --- /dev/null +++ b/skills/tedi-angular/references/forms.md @@ -0,0 +1,145 @@ +# Form Controls + +TEDI form controls implement Angular's `ControlValueAccessor` interface, integrating seamlessly with `ReactiveFormsModule` and `FormsModule`. + +## Available Form Controls + +| Component | Selector | Value Type | +|-----------|----------|------------| +| TextFieldComponent | `input[tedi-text-field]` | `string` | +| NumberFieldComponent | `tedi-number-field` | `number` | +| CheckboxComponent | `input[tedi-checkbox]` | `boolean` | +| ToggleComponent | `tedi-toggle` | `boolean` | +| DatePickerComponent | `tedi-date-picker` | `Date \| null` | +| DropdownComponent | `tedi-dropdown` | `string` | + +## Basic Usage with Reactive Forms + +```typescript +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { + TextFieldComponent, + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + CheckboxComponent, +} from '@tedi-design-system/angular/tedi'; + +@Component({ + standalone: true, + imports: [ + ReactiveFormsModule, + TextFieldComponent, + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + CheckboxComponent, + ], + template: ` +
    + + Full name + + + Name is required + + + + + Email + + + + +
    + `, +}) +export class MyFormComponent { + form = new FormGroup({ + name: new FormControl('', Validators.required), + email: new FormControl(''), + agree: new FormControl(false), + }); +} +``` + +## Form Field Structure + +The recommended structure for a form field: + +```html + + Field label + + Error message + Help text + +``` + +`FormFieldComponent` wraps the input with optional label, icon, clear button, and feedback text. Key inputs: + +- `size: 'default' | 'small'` — field size +- `icon: string | FormFieldIcon` — icon name or config +- `clearable: boolean` — show clear button when value exists + +## Two-Way Binding (without forms) + +TEDI controls also support two-way binding via `model()` signals: + +```html + + + + +``` + +## Validation States + +Form fields automatically reflect validation state from the `FormControl`: + +```typescript +// The form field shows error styling when control is invalid + touched +this.emailControl = new FormControl('', [Validators.required, Validators.email]); +``` + +You can also set validation state explicitly on date-picker: + +```html + +``` + +States: `'default'`, `'error'`, `'valid'`. + +## Disabled State + +Both programmatic and form-level disable work: + +```typescript +// Via FormControl +this.control.disable(); + +// Via input + +``` + +The component combines native disabled state with form-disabled state internally. + +## Date Picker + +The date picker has extensive configuration: + +```html + +``` + +The `disabled` input accepts a `DatePickerMatcher` — a function `(date: Date) => boolean` that returns true for dates that should be disabled. diff --git a/skills/tedi-angular/references/theming.md b/skills/tedi-angular/references/theming.md new file mode 100644 index 000000000..881beb331 --- /dev/null +++ b/skills/tedi-angular/references/theming.md @@ -0,0 +1,104 @@ +# Theming + +TEDI uses design tokens from `@tedi-design-system/core` exposed as CSS custom properties. Components are styled with BEM classes using the `tedi-` prefix and `ViewEncapsulation.None`, so all styles are globally accessible and overridable. + +## Setup + +Import TEDI core styles in your global stylesheet: + +```scss +// styles.scss +@use '@tedi-design-system/core/scss' as tedi; +``` + +Configure the default theme via `provideTedi()`: + +```typescript +provideTedi({ + theme: 'default', // 'default' | 'dark' | custom string +}) +``` + +## Theme Switching + +Themes are applied as a CSS class on ``: `tedi-theme--default`, `tedi-theme--dark`. + +```typescript +import { ThemeService } from '@tedi-design-system/angular/tedi'; + +@Component({ ... }) +export class MyComponent { + private themeService = inject(ThemeService); + + setDarkTheme() { + this.themeService.theme.set('dark'); + } + + getCurrentTheme() { + return this.themeService.theme(); // reads current theme signal + } +} +``` + +The theme is persisted in a cookie (`tedi-theme`) and restored on page load. + +## 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 to stay consistent: + +```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 + +All TEDI components use BEM naming with the `tedi-` prefix. You can override styles by targeting BEM classes: + +```scss +// Override button primary color +.tedi-button--primary { + background-color: var(--my-brand-primary); +} + +// Override form field spacing +.tedi-form-field { + margin-bottom: var(--tedi-spacing-4); +} +``` + +Because components use `ViewEncapsulation.None`, standard CSS specificity rules apply. No `::ng-deep` or `:host` needed. + +## 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 +} +``` + +Then activate it: + +```typescript +this.themeService.theme.set('my-brand'); +// Adds class "tedi-theme--my-brand" to +``` diff --git a/tedi/components/form/checkbox/checkbox.stories.ts b/tedi/components/form/checkbox/checkbox.stories.ts index 4980f3190..172b4d99c 100644 --- a/tedi/components/form/checkbox/checkbox.stories.ts +++ b/tedi/components/form/checkbox/checkbox.stories.ts @@ -26,7 +26,7 @@ type StoryCheckboxComponent = CheckboxComponent & { * Zeroheight ↗ */ export default { - title: "TEDI-Ready/Components/Form/Checkbox", + title: "TEDI-Ready/Components/Form/Choicegroup/Checkbox", component: CheckboxComponent, decorators: [ moduleMetadata({ @@ -532,9 +532,6 @@ export const CheckboxCardsWithDescription: StoryObj = { }), }; -/** - * Checkbox cards with icons before the label text. - */ /** * All visual states of the checkbox card component for both primary and secondary variants. */ @@ -656,6 +653,9 @@ export const CheckboxCardStates: StoryObj = { }), }; +/** + * Checkbox cards with icons before the label text. + */ export const CheckboxCardsWithIcons: StoryObj = { render: (args) => ({ props: args, From 2e94ffc6f45f374508a38e13caf2c71de061aef1 Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 27 Mar 2026 14:12:04 +0200 Subject: [PATCH 04/18] feat(radio): new TEDI-ready component #7 --- .../contributing/references/best-practices.md | 6 +- .../contributing/references/new-component.md | 2 +- .../skills/contributing/references/stories.md | 81 +- skills/tedi-angular/references/components.md | 41 + tedi/components/form/index.ts | 2 + .../form/radio-card/radio-card.component.scss | 160 ++++ .../radio-card/radio-card.component.spec.ts | 71 ++ .../form/radio-card/radio-card.component.ts | 35 + .../form/radio/radio.component.scss | 87 ++ .../form/radio/radio.component.spec.ts | 41 + tedi/components/form/radio/radio.component.ts | 33 + tedi/components/form/radio/radio.stories.ts | 781 ++++++++++++++++++ 12 files changed, 1319 insertions(+), 21 deletions(-) create mode 100644 tedi/components/form/radio-card/radio-card.component.scss create mode 100644 tedi/components/form/radio-card/radio-card.component.spec.ts create mode 100644 tedi/components/form/radio-card/radio-card.component.ts create mode 100644 tedi/components/form/radio/radio.component.scss create mode 100644 tedi/components/form/radio/radio.component.spec.ts create mode 100644 tedi/components/form/radio/radio.component.ts create mode 100644 tedi/components/form/radio/radio.stories.ts diff --git a/.claude/skills/contributing/references/best-practices.md b/.claude/skills/contributing/references/best-practices.md index 705499173..fd0311ed9 100644 --- a/.claude/skills/contributing/references/best-practices.md +++ b/.claude/skills/contributing/references/best-practices.md @@ -223,16 +223,16 @@ export default { imports: [ComponentNameComponent, /* dependencies */], }), ], - parameters: { - status: { type: ['partiallyTediReady'] }, // or 'existsInTediReady' - }, + parameters: {}, argTypes: { + // Every public input/model must have an entry inputName: { description: 'What this input does', control: { type: 'radio' }, options: ['option1', 'option2'], table: { category: 'inputs', + type: { summary: 'TypeName' }, defaultValue: { summary: 'option1' }, }, }, diff --git a/.claude/skills/contributing/references/new-component.md b/.claude/skills/contributing/references/new-component.md index 458e9c39e..38dbeda68 100644 --- a/.claude/skills/contributing/references/new-component.md +++ b/.claude/skills/contributing/references/new-component.md @@ -46,7 +46,7 @@ Follow all patterns from best-practices: - Standalone, OnPush, ViewEncapsulation.None - Signal-based inputs (`input()`, `model()`, `output()`) - BEM SCSS with `tedi-` prefix, using design tokens -- ControlValueAccessor if it's a form control +- Form controls MUST implement `ControlValueAccessor` with `NG_VALUE_ACCESSOR` provider (using `forwardRef()` and `multi: true`) for reactive forms integration. Test with a host component using `ReactiveFormsModule` and `FormControl`. - Full WCAG compliance (roles, keyboard nav, focus, aria attributes) ## Step 5: Export diff --git a/.claude/skills/contributing/references/stories.md b/.claude/skills/contributing/references/stories.md index 0ba02f6a2..189ba4fc8 100644 --- a/.claude/skills/contributing/references/stories.md +++ b/.claude/skills/contributing/references/stories.md @@ -27,30 +27,67 @@ Rules: - **Same order** — export stories in the same top-to-bottom order as they appear in Figma. - **Same examples** — reproduce the exact content/data shown in Figma (labels, placeholder text, number of items). Do not invent different example data. - **Same variants** — if Figma shows 3 tabs with specific labels, use those exact labels. -- **States story** — if Figma has a states showcase (showing default, hover, active, focus, disabled side by side), create a `States` story that renders all states together using `storybook-addon-pseudo-states` parameters: +- **States story** — every component that can be activated or has visual states (hover, active, focus, disabled, selected, error, etc.) **must** have a `States` story. Use a table-like layout with `tedi-row`/`tedi-col` grid components, one row per state. Each row has a bold label in the first column and the component in the second column. Use `storybook-addon-pseudo-states` parameters for hover/active/focus. If the component has multiple variants, show them **side by side in columns** (not stacked vertically) — one column per variant with a bold header row, like a comparison table. + + Required imports: `RowComponent`, `ColComponent`, `TextComponent` from `@tedi-design-system/angular/tedi`. + + Define states as a constant and iterate with `*ngFor`: ```typescript + const PSEUDO_STATE = ['Default', 'Hover', 'Active', 'Focus', 'Disabled']; + export const States: StoryObj = { - render: () => ({ - template: ` -
    - Default - Hover - Active - Focus - Disabled -
    - `, - }), parameters: { pseudo: { - hover: 'tedi-component:nth-of-type(2)', - active: 'tedi-component:nth-of-type(3)', - focusVisible: 'tedi-component:nth-of-type(4)', + hover: '#Hover', + active: '#Active', + focusVisible: '#Focus', }, }, + render: () => ({ + props: { PSEUDO_STATE }, + template: ` + + + +

    {{ state }}

    +
    + + + +
    +
    + `, + }), }; ``` + Key rules: + - Each state's component must have `[id]="state"` so pseudo-state selectors (`#Hover`, `#Active`, `#Focus`) can target it. + - Include all relevant states for the component (e.g., `Selected`, `Error`, `Success` where applicable). + - Reference `text-field.stories.ts` as the canonical example. + +- **Variant comparison layout** — when a component has multiple variants (e.g., primary/secondary), always show them **side by side** in a `tedi-row` grid, not stacked vertically. Use a header row with bold labels for each variant column: + ```html + +

    Primary

    +

    Secondary

    + + +
    + ``` + For states with variants, use a 3-column layout (State | Primary | Secondary): + ```html + +

    State

    +

    Primary

    +

    Secondary

    + +
    + ``` + ### 3. Determine the Story Category Find where the component lives under `tedi/components/` and map to the Storybook title: @@ -85,7 +122,6 @@ export default { }), ], parameters: { - status: { type: ['partiallyTediReady'] }, design: { type: 'figma', url: 'https://www.figma.com/...' }, }, argTypes: { @@ -98,6 +134,7 @@ export default { - [ ] Every Figma section has a corresponding story export, in the same order - [ ] Example content (labels, data, item count) matches Figma exactly +- [ ] Every public input/model has a corresponding `argTypes` entry with description, control, type summary, and default value - [ ] `Default` story has all controls wired up via `args` - [ ] States story covers all visual states shown in Figma (default, hover, active, focus, disabled) - [ ] Reactive forms example included if the component implements ControlValueAccessor @@ -105,11 +142,21 @@ export default { ### 6. argTypes Convention +**Every public input/model must have an argTypes entry.** Do not skip any — all props must appear in the Storybook controls panel with correct typing and descriptions. + +Each entry must include: +- `description` — brief explanation of what the input controls +- `control` — appropriate control type (`'radio'`, `'select'`, `'boolean'`, `'text'`, `'number'`, `'object'`) +- `options` — for enum/union type inputs, list all possible values +- `table.category` — always `'inputs'` +- `table.type.summary` — the TypeScript type name (e.g., `'boolean'`, `'string'`, `'FilterVariant'`, `'FilterOption[]'`) +- `table.defaultValue.summary` — the default value + ```typescript argTypes: { inputName: { description: 'Brief description of what this input controls', - control: { type: 'radio' }, // or 'select', 'boolean', 'text', 'number' + control: { type: 'radio' }, // or 'select', 'boolean', 'text', 'number', 'object' options: ['value1', 'value2'], table: { category: 'inputs', diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index feb326c22..69c66ffe9 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -199,6 +199,47 @@ Composed of sub-components: ``` +### Radio +**Selector:** `input[type=radio][tedi-radio]` +**Inputs:** +- `size: RadioSize = "default"` — "default" or "large" +- `invalid: boolean = false` + +```html + +``` + +### RadioCard +**Selector:** `label[tedi-radio-card]` +**Inputs:** +- `variant: RadioCardVariant = "primary"` — "primary" or "secondary" +- `grouped: boolean = false` — join cards in a button-group layout + +```html + +
    + +
    + + +
    + + +
    +``` + ### Toggle **Selector:** `tedi-toggle` **Model:** `checked: boolean` diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 49eef9ab7..c400f22bb 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -1,5 +1,7 @@ export * from "./checkbox/checkbox.component"; export * from "./checkbox-card/checkbox-card.component"; +export * from "./radio/radio.component"; +export * from "./radio-card/radio-card.component"; export * from "./date-picker/date-picker.component"; export * from "./feedback-text/feedback-text.component"; export * from "./label/label.component"; diff --git a/tedi/components/form/radio-card/radio-card.component.scss b/tedi/components/form/radio-card/radio-card.component.scss new file mode 100644 index 000000000..3fd58e8ba --- /dev/null +++ b/tedi/components/form/radio-card/radio-card.component.scss @@ -0,0 +1,160 @@ +label[tedi-radio-card] { + --_card-bg: transparent; + --_card-text: inherit; + --_card-border: transparent; + + display: inline-flex; + gap: var(--form-checkbox-radio-card-inner-spacing); + align-items: flex-start; + padding: var(--form-checkbox-radio-card-checkbox-padding-y) var(--form-checkbox-radio-card-checkbox-padding-x); + font-family: var(--family-default); + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--_card-text); + cursor: pointer; + background-color: var(--_card-bg); + border: 1px solid var(--_card-border); + border-radius: var(--form-checkbox-radio-card-radius); + + [tedi-text], + tedi-icon { + color: inherit; + } + + input[tedi-radio], + tedi-icon { + flex-shrink: 0; + margin-top: calc(var(--form-checkbox-radio-card-checkbox-indicator-padding-y) - var(--form-checkbox-radio-card-checkbox-padding-y)); + } + + input[tedi-radio] { + &:not(:disabled):hover { + box-shadow: none; + } + } + + &:has(input:focus-visible) { + outline: 2px solid var(--form-input-border-active); + outline-offset: 1px; + + input[tedi-radio]:focus-visible { + outline: none; + } + } + + &:has(input:disabled) { + cursor: not-allowed; + } + + &.tedi-radio-card--grouped { + border-radius: 0; + + &:first-child { + border-top-left-radius: var(--form-checkbox-radio-card-radius); + border-bottom-left-radius: var(--form-checkbox-radio-card-radius); + } + + &:last-child { + border-top-right-radius: var(--form-checkbox-radio-card-radius); + border-bottom-right-radius: var(--form-checkbox-radio-card-radius); + } + + & + & { + margin-left: -1px; + } + + &:has(input:focus-visible) { + z-index: 5; + } + + &:has(input:checked) { + z-index: 1; + } + } + + &.tedi-radio-card--primary { + --_card-bg: var(--form-checkbox-radio-card-primary-default-background); + --_card-text: var(--form-checkbox-radio-card-primary-default-text); + --_card-border: var(--form-checkbox-radio-card-primary-default-border-separate); + + &.tedi-radio-card--grouped { + --_card-border: var(--form-checkbox-radio-card-primary-default-border-group); + } + + &:has(input:checked) { + --_card-bg: var(--form-checkbox-radio-card-primary-selected-background); + --_card-text: var(--form-checkbox-radio-card-primary-selected-text); + --_card-border: var(--form-checkbox-radio-card-primary-selected-border-separate); + + &.tedi-radio-card--grouped { + --_card-border: var(--form-checkbox-radio-card-primary-selected-border-group); + } + + input[tedi-radio]:not(:disabled) { + background-color: transparent; + border-color: var(--form-checkbox-radio-default-border-selected-inverted); + + &::before { + background: var(--form-checkbox-radio-default-check-indicator-default); + } + } + } + + &:has(input:hover:not(:disabled)) { + --_card-bg: var(--form-checkbox-radio-card-primary-hover-background); + --_card-text: var(--form-checkbox-radio-card-primary-hover-text); + --_card-border: var(--form-checkbox-radio-card-primary-hover-border); + } + + &:has(input:disabled:not(:checked)) { + --_card-bg: var(--form-checkbox-radio-card-primary-disabled-default-background); + --_card-text: var(--form-checkbox-radio-card-primary-disabled-default-text); + --_card-border: var(--form-checkbox-radio-card-primary-disabled-default-background); + } + + &:has(input:disabled:checked) { + --_card-bg: var(--form-checkbox-radio-card-primary-disabled-selected-background); + --_card-text: var(--form-checkbox-radio-card-primary-disabled-selected-text); + --_card-border: var(--form-checkbox-radio-card-primary-disabled-selected-background); + + input[tedi-radio] { + background-color: transparent; + border-color: var(--form-checkbox-radio-default-border-selected-inverted); + } + } + } + + &.tedi-radio-card--secondary { + --_card-bg: var(--form-checkbox-radio-card-secondary-default-background); + --_card-text: var(--form-checkbox-radio-card-secondary-default-text); + --_card-border: var(--form-checkbox-radio-card-secondary-default-border); + + &:has(input:checked) { + --_card-bg: var(--form-checkbox-radio-card-secondary-selected-background); + --_card-text: var(--form-checkbox-radio-card-secondary-selected-text); + --_card-border: var(--form-checkbox-radio-card-secondary-selected-border); + + box-shadow: inset 0 0 0 1px var(--_card-border); + } + + &:has(input:hover:not(:disabled)) { + --_card-bg: var(--form-checkbox-radio-card-secondary-hover-background); + --_card-text: var(--form-checkbox-radio-card-secondary-hover-text); + --_card-border: var(--form-checkbox-radio-card-secondary-hover-border); + } + + &:has(input:disabled:not(:checked)) { + --_card-bg: var(--form-checkbox-radio-card-secondary-disabled-default-background); + --_card-text: var(--form-checkbox-radio-card-secondary-disabled-default-text); + --_card-border: var(--form-checkbox-radio-card-secondary-disabled-default-border); + } + + &:has(input:disabled:checked) { + --_card-bg: var(--form-checkbox-radio-card-secondary-disabled-selected-background); + --_card-text: var(--form-checkbox-radio-card-secondary-disabled-selected-text); + --_card-border: var(--form-checkbox-radio-card-secondary-disabled-selected-border); + + box-shadow: inset 0 0 0 1px var(--_card-border); + } + } +} diff --git a/tedi/components/form/radio-card/radio-card.component.spec.ts b/tedi/components/form/radio-card/radio-card.component.spec.ts new file mode 100644 index 000000000..3ed8b68cf --- /dev/null +++ b/tedi/components/form/radio-card/radio-card.component.spec.ts @@ -0,0 +1,71 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + RadioCardComponent, + RadioCardVariant, +} from "./radio-card.component"; +import { RadioComponent } from "../radio/radio.component"; + +@Component({ + standalone: true, + imports: [RadioCardComponent, RadioComponent], + template: ` + + `, +}) +class TestHostComponent { + variant: RadioCardVariant = "primary"; + grouped = false; +} + +describe("RadioCardComponent", () => { + let fixture: ComponentFixture; + let labelElement: HTMLLabelElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + labelElement = fixture.nativeElement.querySelector("label"); + }); + + it("should create component", () => { + expect(labelElement).toBeTruthy(); + expect(labelElement.classList).toContain("tedi-radio-card"); + }); + + it("should apply primary class by default", () => { + expect(labelElement.classList).toContain("tedi-radio-card--primary"); + expect(labelElement.classList).not.toContain( + "tedi-radio-card--secondary" + ); + }); + + it("should apply secondary class", () => { + fixture.componentInstance.variant = "secondary"; + fixture.detectChanges(); + expect(labelElement.classList).toContain("tedi-radio-card--secondary"); + expect(labelElement.classList).not.toContain("tedi-radio-card--primary"); + }); + + it("should contain a radio input", () => { + const input = labelElement.querySelector('input[type="radio"]'); + expect(input).toBeTruthy(); + }); + + it("should not have grouped class by default", () => { + expect(labelElement.classList).not.toContain("tedi-radio-card--grouped"); + }); + + it("should apply grouped class", () => { + fixture.componentInstance.grouped = true; + fixture.detectChanges(); + expect(labelElement.classList).toContain("tedi-radio-card--grouped"); + }); +}); diff --git a/tedi/components/form/radio-card/radio-card.component.ts b/tedi/components/form/radio-card/radio-card.component.ts new file mode 100644 index 000000000..f38009291 --- /dev/null +++ b/tedi/components/form/radio-card/radio-card.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + input, + ViewEncapsulation, +} from "@angular/core"; + +export type RadioCardVariant = "primary" | "secondary"; + +@Component({ + standalone: true, + selector: "label[tedi-radio-card]", + template: "", + styleUrl: "./radio-card.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-radio-card", + "[class.tedi-radio-card--primary]": "variant() === 'primary'", + "[class.tedi-radio-card--secondary]": "variant() === 'secondary'", + "[class.tedi-radio-card--grouped]": "grouped()", + }, +}) +export class RadioCardComponent { + /** + * Visual variant of the card. + * @default primary + */ + readonly variant = input("primary"); + /** + * Whether the card is part of a button-group style layout. + * @default false + */ + readonly grouped = input(false); +} diff --git a/tedi/components/form/radio/radio.component.scss b/tedi/components/form/radio/radio.component.scss new file mode 100644 index 000000000..f6e728cdc --- /dev/null +++ b/tedi/components/form/radio/radio.component.scss @@ -0,0 +1,87 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +input[tedi-radio][type="radio"] { + --_radio-dot-size: calc(var(--form-checkbox-radio-size-responsive) * 0.67); + + position: relative; + width: var(--form-checkbox-radio-size-responsive); + height: var(--form-checkbox-radio-size-responsive); + padding: 0; + margin: 0; + vertical-align: middle; + appearance: none; + cursor: pointer; + background-color: var(--form-checkbox-radio-default-background-default); + border: 1px solid var(--form-checkbox-radio-default-border-default); + border-radius: var(--form-checkbox-radio-indicator-radius-radio); + + &::before { + position: absolute; + top: 50%; + left: 50%; + width: var(--_radio-dot-size); + height: var(--_radio-dot-size); + content: ""; + background: transparent; + border-radius: var(--form-checkbox-radio-indicator-radius-radio); + transform: translate(-50%, -50%); + transition: background-color 150ms ease; + } + + &:not(:disabled):hover { + border-color: var(--form-checkbox-radio-default-border-hover); + box-shadow: 0 0 0 1px var(--form-checkbox-radio-default-border-hover); + } + + &:not(:disabled):active { + background-color: var(--form-checkbox-radio-default-background-active); + border-color: var(--form-checkbox-radio-default-border-active); + box-shadow: none; + } + + &:not(:checked):disabled { + cursor: not-allowed; + background-color: var(--form-general-background-disabled); + border-color: var(--form-general-border-disabled); + } + + &:checked { + border-color: var(--form-checkbox-radio-default-border-selected); + + &::before { + background: var(--form-checkbox-radio-default-background-selected); + } + + &:disabled { + cursor: not-allowed; + border-color: var(--form-checkbox-radio-default-border-selected-disabled); + + &::before { + background: var(--form-checkbox-radio-default-background-selected-disabled); + } + } + } + + &:focus-visible { + outline-width: 2px; + outline-style: solid; + outline-offset: 2px; + } + + &:not(:checked, :disabled).tedi-radio--invalid, + &:user-invalid, + &.ng-invalid.ng-touched { + border-color: var(--form-general-feedback-error-border); + + &:hover { + border-color: var(--form-checkbox-radio-default-border-hover); + } + } + + &.tedi-radio--large { + --_radio-dot-size: calc(var(--form-checkbox-radio-size-large) * 0.67); + + width: var(--form-checkbox-radio-size-large); + height: var(--form-checkbox-radio-size-large); + } +} diff --git a/tedi/components/form/radio/radio.component.spec.ts b/tedi/components/form/radio/radio.component.spec.ts new file mode 100644 index 000000000..10e3ccbc1 --- /dev/null +++ b/tedi/components/form/radio/radio.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RadioComponent } from "./radio.component"; + +describe("RadioComponent", () => { + let fixture: ComponentFixture; + let element: HTMLInputElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RadioComponent], + }); + + fixture = TestBed.createComponent(RadioComponent); + element = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should create component", () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should not have large class by default", () => { + expect(element.classList).not.toContain("tedi-radio--large"); + }); + + it("should apply large class", () => { + fixture.componentRef.setInput("size", "large"); + fixture.detectChanges(); + expect(element.classList).toContain("tedi-radio--large"); + }); + + it("should not have invalid class by default", () => { + expect(element.classList).not.toContain("tedi-radio--invalid"); + }); + + it("should apply invalid class", () => { + fixture.componentRef.setInput("invalid", true); + fixture.detectChanges(); + expect(element.classList).toContain("tedi-radio--invalid"); + }); +}); diff --git a/tedi/components/form/radio/radio.component.ts b/tedi/components/form/radio/radio.component.ts new file mode 100644 index 000000000..6dc6a60db --- /dev/null +++ b/tedi/components/form/radio/radio.component.ts @@ -0,0 +1,33 @@ +import { + ChangeDetectionStrategy, + Component, + input, + ViewEncapsulation, +} from "@angular/core"; + +export type RadioSize = "default" | "large"; + +@Component({ + standalone: true, + selector: "input[type=radio][tedi-radio]", + template: "", + styleUrl: "./radio.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class.tedi-radio--large]": "size() === 'large'", + "[class.tedi-radio--invalid]": "invalid()", + }, +}) +export class RadioComponent { + /** + * Size of the radio. + * @default default + */ + readonly size = input("default"); + /** + * Is radio invalid? + * @default false + */ + readonly invalid = input(false); +} diff --git a/tedi/components/form/radio/radio.stories.ts b/tedi/components/form/radio/radio.stories.ts new file mode 100644 index 000000000..2a85eb99f --- /dev/null +++ b/tedi/components/form/radio/radio.stories.ts @@ -0,0 +1,781 @@ +import { + argsToTemplate, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; +import { RadioComponent } from "./radio.component"; +import { RadioCardComponent } from "../radio-card/radio-card.component"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { TextComponent } from "../../base/text/text.component"; +import { LabelComponent } from "../label/label.component"; +import { IconComponent } from "../../base/icon/icon.component"; +import { TooltipComponent } from "../../overlay/tooltip/tooltip.component"; +import { TooltipTriggerComponent } from "../../overlay/tooltip/tooltip-trigger/tooltip-trigger.component"; +import { TooltipContentComponent } from "../../overlay/tooltip/tooltip-content/tooltip-content.component"; +import { InfoButtonComponent } from "../../buttons/info-button/info-button.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; + +/** + * Figma ↗
    + * Zeroheight ↗ + */ +export default { + title: "TEDI-Ready/Components/Form/Choicegroup/Radio", + component: RadioComponent, + decorators: [ + moduleMetadata({ + imports: [ + RadioComponent, + RadioCardComponent, + RowComponent, + ColComponent, + TextComponent, + LabelComponent, + IconComponent, + TooltipComponent, + TooltipTriggerComponent, + TooltipContentComponent, + InfoButtonComponent, + FeedbackTextComponent, + ], + }), + ], + argTypes: { + size: { + control: "radio", + options: ["default", "large"], + description: "Size of the radio.", + table: { + type: { + summary: "RadioSize", + detail: "default \nlarge", + }, + defaultValue: { + summary: "default", + }, + }, + }, + invalid: { + control: "boolean", + description: "Is radio invalid?", + table: { + type: { + summary: "boolean", + }, + defaultValue: { + summary: "false", + }, + }, + }, + disabled: { + control: "boolean", + description: "Is radio disabled?", + table: { + type: { + summary: "boolean", + }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + size: "default", + invalid: false, + disabled: false, + }, + render: (args) => ({ + props: args, + template: ` + + `, + }), +}; + +/** + * Default size is used on desktop, large size is applied automatically on mobile screen sizes. + * Use in tables where the radio has no text. **Otherwise, prefer using default size.** + */ +export const Size: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +
    Default
    + +
    Large
    + +
    + `, + }), +}; + +export const Vertical: StoryObj = { + render: (args) => ({ + props: args, + template: ` +

    Label

    + + + + + + `, + }), +}; + +export const Horizontal: StoryObj = { + render: (args) => ({ + props: args, + template: ` +

    Label

    +
    + + + +
    + `, + }), +}; + +export const Separate: StoryObj = { + render: (args) => ({ + props: args, + template: ` + + +
    + + +
    +
    + + + + + + + Tooltip text + + +
    +
    +
    + +
    +
    + +

    + Description +

    +
    +
    +
    + `, + }), +}; + +export const Group: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +
    +

    Label

    + + + + + + +
    +
    +

    Label

    + + + + + + +
    +
    +

    Label

    +
    + + + +
    + +
    +
    +

    Label

    +
    + + + +
    + +
    +
    + `, + }), +}; + +/** + * All visual states of the radio component. + */ +export const States: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +
    Default
    + + +
    Selected
    + + +
    Error
    +
    + + +
    + +
    Disabled
    + + +
    Disabled selected
    + +
    + `, + }), +}; + +/** + * Radio cards with primary and secondary variants side by side. Primary uses a filled background when selected, secondary uses an outline border. + */ +export const RadioCards: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    Primary

    +

    Secondary

    + +
    + + + +
    +
    + +
    + + + +
    +
    +
    + `, + }), +}; + +/** + * Radio cards in a grouped layout, joined like a button group with shared borders and no gap. + */ +export const RadioCardsGrouped: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    Primary

    +

    Secondary

    + +
    + + + + +
    +
    + +
    + + + + +
    +
    +
    + `, + }), +}; + +/** + * Radio cards with a description below the label text. + */ +export const RadioCardsWithDescription: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    Primary

    +

    Secondary

    + +
    + + + +
    +
    + +
    + + + +
    +
    +
    + `, + }), +}; + +/** + * Grouped radio cards with description text. + */ +export const RadioCardsGroupedWithDescription: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    Primary

    +

    Secondary

    + +
    + + + +
    +
    + +
    + + + +
    +
    +
    + `, + }), +}; + +/** + * All visual states of the radio card component with primary and secondary variants side by side. + */ +export const RadioCardStates: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    State

    +

    Primary

    +

    Secondary

    + + Default + + + + + + + + Hover + + + + + + + + Selected + + + + + + + + Focus + + + + + + + + Disabled + + + + + + + + Disabled selected + + + + + + +
    + `, + }), +}; + +/** + * Radio cards with icons before the label text. + */ +export const RadioCardsWithIcons: StoryObj = { + render: (args) => ({ + props: args, + template: ` + +

    Primary

    +

    Secondary

    + +
    + + + +
    +
    + +
    + + + +
    +
    +

    Primary with description

    +

    Secondary with description

    + +
    + + + +
    +
    + +
    + + + +
    +
    +
    + `, + }), +}; From 4505c0c8929c493fbcf148173409e6b1b1eeb9cf Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 27 Mar 2026 15:03:05 +0200 Subject: [PATCH 05/18] feat(radio, checkbox): marked community radio and checkbox deprecated #7 --- .../checkbox-card-group/checkbox-card-group.component.ts | 7 +++++-- .../checkbox/checkbox-group/checkbox-group.component.ts | 3 +++ .../form/checkbox/checkbox/checkbox.component.ts | 7 +++++-- .../radio/radio-card-group/radio-card-group.component.ts | 4 ++++ .../form/radio/radio-group/radio-group.component.ts | 7 +++++-- community/components/form/radio/radio.stories.ts | 5 +++++ community/components/form/radio/radio/radio.component.ts | 3 +++ 7 files changed, 30 insertions(+), 6 deletions(-) diff --git a/community/components/form/checkbox/checkbox-card-group/checkbox-card-group.component.ts b/community/components/form/checkbox/checkbox-card-group/checkbox-card-group.component.ts index 43e00097c..52d0566e4 100644 --- a/community/components/form/checkbox/checkbox-card-group/checkbox-card-group.component.ts +++ b/community/components/form/checkbox/checkbox-card-group/checkbox-card-group.component.ts @@ -15,6 +15,9 @@ import { LabelComponent, FeedbackTextComponent } from "@tedi-design-system/angul import { CheckboxGroupComponent } from "../checkbox-group/checkbox-group.component"; import { CheckboxComponent } from "../checkbox/checkbox.component"; +/** + * @deprecated Use Checkbox with CheckboxCard label from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ standalone: true, selector: "tedi-checkbox-card-group", @@ -64,8 +67,8 @@ export class CheckboxCardGroupComponent ); private _controlDisabled = signal(false); - private _onChange: (val: string[]) => void = () => {}; - private _onTouched: (val: boolean) => void = () => {}; + private _onChange: (val: string[]) => void = () => { }; + private _onTouched: (val: boolean) => void = () => { }; override groupDisabled = computed(() => { return this.disabled() || this._controlDisabled(); diff --git a/community/components/form/checkbox/checkbox-group/checkbox-group.component.ts b/community/components/form/checkbox/checkbox-group/checkbox-group.component.ts index cd95588e7..8450cbdef 100644 --- a/community/components/form/checkbox/checkbox-group/checkbox-group.component.ts +++ b/community/components/form/checkbox/checkbox-group/checkbox-group.component.ts @@ -13,6 +13,9 @@ import { } from "@tedi-design-system/angular/tedi"; import { generateUUID } from "@tedi-design-system/angular/tedi"; +/** + * @deprecated Use Checkbox from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ standalone: true, selector: "tedi-checkbox-group", diff --git a/community/components/form/checkbox/checkbox/checkbox.component.ts b/community/components/form/checkbox/checkbox/checkbox.component.ts index 877c71fe1..ada2e7a50 100644 --- a/community/components/form/checkbox/checkbox/checkbox.component.ts +++ b/community/components/form/checkbox/checkbox/checkbox.component.ts @@ -23,6 +23,9 @@ import { generateUUID } from "@tedi-design-system/angular/tedi"; export type CheckboxSize = "default" | "large"; +/** + * @deprecated Use Checkbox from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ standalone: true, selector: "tedi-checkbox", @@ -84,8 +87,8 @@ export class CheckboxComponent implements ControlValueAccessor, OnInit { optional: true, }); - private _onChange: (val: boolean) => void = () => {}; - _onTouched: () => void = () => {}; + private _onChange: (val: boolean) => void = () => { }; + _onTouched: () => void = () => { }; feedbackTextId = computed(() => { if (this.feedbackText()) { diff --git a/community/components/form/radio/radio-card-group/radio-card-group.component.ts b/community/components/form/radio/radio-card-group/radio-card-group.component.ts index 93c882790..7abc016c1 100644 --- a/community/components/form/radio/radio-card-group/radio-card-group.component.ts +++ b/community/components/form/radio/radio-card-group/radio-card-group.component.ts @@ -9,6 +9,10 @@ import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { ChoiceGroupDirective } from "../../choicegroup/choicegroup.directive"; import { LabelComponent, FeedbackTextComponent } from "@tedi-design-system/angular/tedi"; import { RadioGroupComponent } from "../radio-group/radio-group.component"; + +/** + * @deprecated Use Radio with RadioCard label from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ standalone: true, selector: "tedi-radio-card-group", diff --git a/community/components/form/radio/radio-group/radio-group.component.ts b/community/components/form/radio/radio-group/radio-group.component.ts index 82819c9af..e3750c014 100644 --- a/community/components/form/radio/radio-group/radio-group.component.ts +++ b/community/components/form/radio/radio-group/radio-group.component.ts @@ -14,6 +14,9 @@ import { ComponentInputs, LabelComponent, FeedbackTextComponent } from "@tedi-de export type RadioGroupSize = "default" | "large"; +/** + * @deprecated Use Radio from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "tedi-radio-group", standalone: true, @@ -87,8 +90,8 @@ export class RadioGroupComponent implements ControlValueAccessor { }); groupValue = this._value.asReadonly(); - private _onChange: (val: RadioValue) => void = () => {}; - private _onTouched: (val: boolean) => void = () => {}; + private _onChange: (val: RadioValue) => void = () => { }; + private _onTouched: (val: boolean) => void = () => { }; writeValue(value: RadioValue | null): void { this._value.set(value); diff --git a/community/components/form/radio/radio.stories.ts b/community/components/form/radio/radio.stories.ts index 9400daefc..495d5f87b 100644 --- a/community/components/form/radio/radio.stories.ts +++ b/community/components/form/radio/radio.stories.ts @@ -17,6 +17,11 @@ export default { subcomponents: { RadioComponent, }, + parameters: { + status: { + type: ["existsInTediReady"], + }, + }, args: { size: "default", direction: "column", diff --git a/community/components/form/radio/radio/radio.component.ts b/community/components/form/radio/radio/radio.component.ts index d282c85fc..1072e056b 100644 --- a/community/components/form/radio/radio/radio.component.ts +++ b/community/components/form/radio/radio/radio.component.ts @@ -19,6 +19,9 @@ import { generateUUID } from "@tedi-design-system/angular/tedi"; export type RadioValue = string; +/** + * @deprecated Use Radio from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "tedi-radio", imports: [FeedbackTextComponent], From ef1d2ec78ceda123e243da3c09ecdd27a2d45629 Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 2 Apr 2026 16:55:07 +0300 Subject: [PATCH 06/18] feat(checkbox): changes from design review #222 --- .../checkbox-card-group.component.scss | 5 + .../checkbox-card-group.component.spec.ts | 50 ++ .../checkbox-card-group.component.ts | 18 + .../checkbox-card.component.html | 4 + .../checkbox-card.component.scss | 15 +- .../checkbox-card.component.spec.ts | 24 +- .../checkbox-card/checkbox-card.component.ts | 2 +- .../checkbox-group.component.html | 9 + .../checkbox-group.component.scss | 23 + .../checkbox-group.component.spec.ts | 104 +++ .../checkbox-group.component.ts | 33 + .../form/checkbox/checkbox.component.scss | 5 + .../form/checkbox/checkbox.stories.ts | 617 +++++++++--------- tedi/components/form/index.ts | 2 + 14 files changed, 603 insertions(+), 308 deletions(-) create mode 100644 tedi/components/form/checkbox-card-group/checkbox-card-group.component.scss create mode 100644 tedi/components/form/checkbox-card-group/checkbox-card-group.component.spec.ts create mode 100644 tedi/components/form/checkbox-card-group/checkbox-card-group.component.ts create mode 100644 tedi/components/form/checkbox-card/checkbox-card.component.html create mode 100644 tedi/components/form/checkbox-group/checkbox-group.component.html create mode 100644 tedi/components/form/checkbox-group/checkbox-group.component.scss create mode 100644 tedi/components/form/checkbox-group/checkbox-group.component.spec.ts create mode 100644 tedi/components/form/checkbox-group/checkbox-group.component.ts diff --git a/tedi/components/form/checkbox-card-group/checkbox-card-group.component.scss b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.scss new file mode 100644 index 000000000..84cc5c9e8 --- /dev/null +++ b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.scss @@ -0,0 +1,5 @@ +.tedi-checkbox-card-group { + display: flex; + flex-wrap: wrap; + gap: var(--form-checkbox-radio-card-gutter); +} diff --git a/tedi/components/form/checkbox-card-group/checkbox-card-group.component.spec.ts b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.spec.ts new file mode 100644 index 000000000..63f8b6996 --- /dev/null +++ b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.spec.ts @@ -0,0 +1,50 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { CheckboxCardGroupComponent } from "./checkbox-card-group.component"; +import { CheckboxCardComponent } from "../checkbox-card/checkbox-card.component"; +import { CheckboxComponent } from "../checkbox/checkbox.component"; + +@Component({ + standalone: true, + imports: [CheckboxCardGroupComponent, CheckboxCardComponent, CheckboxComponent], + template: ` + + + + + `, +}) +class TestHostComponent {} + +describe("CheckboxCardGroupComponent", () => { + let fixture: ComponentFixture; + let groupElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + groupElement = fixture.nativeElement.querySelector( + "tedi-checkbox-card-group" + ); + }); + + it("should create component", () => { + expect(groupElement).toBeTruthy(); + expect(groupElement.classList).toContain("tedi-checkbox-card-group"); + }); + + it("should project card content", () => { + const cards = groupElement.querySelectorAll("label[tedi-checkbox-card]"); + expect(cards.length).toBe(2); + }); +}); diff --git a/tedi/components/form/checkbox-card-group/checkbox-card-group.component.ts b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.ts new file mode 100644 index 000000000..3cbea865b --- /dev/null +++ b/tedi/components/form/checkbox-card-group/checkbox-card-group.component.ts @@ -0,0 +1,18 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + standalone: true, + selector: "tedi-checkbox-card-group", + template: "", + styleUrl: "./checkbox-card-group.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-checkbox-card-group", + }, +}) +export class CheckboxCardGroupComponent {} diff --git a/tedi/components/form/checkbox-card/checkbox-card.component.html b/tedi/components/form/checkbox-card/checkbox-card.component.html new file mode 100644 index 000000000..b09ab35fe --- /dev/null +++ b/tedi/components/form/checkbox-card/checkbox-card.component.html @@ -0,0 +1,4 @@ +
    + +
    + diff --git a/tedi/components/form/checkbox-card/checkbox-card.component.scss b/tedi/components/form/checkbox-card/checkbox-card.component.scss index 1c646efd7..34e048591 100644 --- a/tedi/components/form/checkbox-card/checkbox-card.component.scss +++ b/tedi/components/form/checkbox-card/checkbox-card.component.scss @@ -4,8 +4,9 @@ label[tedi-checkbox-card] { --_card-border: transparent; display: inline-flex; - gap: var(--form-checkbox-radio-card-inner-spacing); + flex-direction: column; align-items: flex-start; + min-height: var(--form-input-height); padding: var(--form-checkbox-radio-card-checkbox-padding-y) var(--form-checkbox-radio-card-checkbox-padding-x); font-family: var(--family-default); font-size: var(--body-regular-size); @@ -24,7 +25,17 @@ label[tedi-checkbox-card] { input[tedi-checkbox], tedi-icon { flex-shrink: 0; - margin-top: calc(var(--form-checkbox-radio-card-checkbox-indicator-padding-y) - var(--form-checkbox-radio-card-checkbox-padding-y)); + } + + .tedi-checkbox-card__content { + display: flex; + gap: var(--form-checkbox-radio-card-inner-spacing); + align-items: center; + } + + > .tedi-feedback-text { + padding-left: calc(var(--form-checkbox-radio-size-responsive) + var(--form-checkbox-radio-card-inner-spacing)); + color: inherit; } input[tedi-checkbox] { diff --git a/tedi/components/form/checkbox-card/checkbox-card.component.spec.ts b/tedi/components/form/checkbox-card/checkbox-card.component.spec.ts index 8dd7829a5..4320d15fd 100644 --- a/tedi/components/form/checkbox-card/checkbox-card.component.spec.ts +++ b/tedi/components/form/checkbox-card/checkbox-card.component.spec.ts @@ -5,19 +5,24 @@ import { CheckboxCardVariant, } from "./checkbox-card.component"; import { CheckboxComponent } from "../checkbox/checkbox.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; @Component({ standalone: true, - imports: [CheckboxCardComponent, CheckboxComponent], + imports: [CheckboxCardComponent, CheckboxComponent, FeedbackTextComponent], template: ` `, }) class TestHostComponent { variant: CheckboxCardVariant = "primary"; + showDescription = false; } describe("CheckboxCardComponent", () => { @@ -57,4 +62,21 @@ describe("CheckboxCardComponent", () => { const input = labelElement.querySelector('input[type="checkbox"]'); expect(input).toBeTruthy(); }); + + it("should project content into content wrapper", () => { + const content = labelElement.querySelector( + ".tedi-checkbox-card__content" + ); + expect(content).toBeTruthy(); + const input = content?.querySelector('input[type="checkbox"]'); + expect(input).toBeTruthy(); + }); + + it("should project feedback text as description", () => { + fixture.componentInstance.showDescription = true; + fixture.detectChanges(); + const feedbackText = labelElement.querySelector("tedi-feedback-text"); + expect(feedbackText).toBeTruthy(); + expect(feedbackText?.parentElement).toBe(labelElement); + }); }); diff --git a/tedi/components/form/checkbox-card/checkbox-card.component.ts b/tedi/components/form/checkbox-card/checkbox-card.component.ts index 077636779..6aac99124 100644 --- a/tedi/components/form/checkbox-card/checkbox-card.component.ts +++ b/tedi/components/form/checkbox-card/checkbox-card.component.ts @@ -10,7 +10,7 @@ export type CheckboxCardVariant = "primary" | "secondary"; @Component({ standalone: true, selector: "label[tedi-checkbox-card]", - template: "", + templateUrl: "./checkbox-card.component.html", styleUrl: "./checkbox-card.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/tedi/components/form/checkbox-group/checkbox-group.component.html b/tedi/components/form/checkbox-group/checkbox-group.component.html new file mode 100644 index 000000000..c4ef9e35c --- /dev/null +++ b/tedi/components/form/checkbox-group/checkbox-group.component.html @@ -0,0 +1,9 @@ +@if (label()) { +

    {{ label() }}

    +} +
    + +
    +
    + +
    diff --git a/tedi/components/form/checkbox-group/checkbox-group.component.scss b/tedi/components/form/checkbox-group/checkbox-group.component.scss new file mode 100644 index 000000000..4c61d2fa8 --- /dev/null +++ b/tedi/components/form/checkbox-group/checkbox-group.component.scss @@ -0,0 +1,23 @@ +.tedi-checkbox-group { + display: flex; + flex-direction: column; + gap: var(--form-checkbox-radio-label-gutter-y); + + &__checks { + display: flex; + flex-wrap: wrap; + gap: var(--form-checkbox-radio-gutter-y) var(--form-checkbox-radio-gutter-x); + + &--vertical { + flex-direction: column; + } + } + + &__subtexts { + padding-top: var(--form-field-inner-spacing-sm); + + &:empty { + display: none; + } + } +} diff --git a/tedi/components/form/checkbox-group/checkbox-group.component.spec.ts b/tedi/components/form/checkbox-group/checkbox-group.component.spec.ts new file mode 100644 index 000000000..5b74de949 --- /dev/null +++ b/tedi/components/form/checkbox-group/checkbox-group.component.spec.ts @@ -0,0 +1,104 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + CheckboxGroupComponent, + CheckboxGroupDirection, +} from "./checkbox-group.component"; +import { CheckboxComponent } from "../checkbox/checkbox.component"; +import { LabelComponent } from "../label/label.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; + +@Component({ + standalone: true, + imports: [ + CheckboxGroupComponent, + CheckboxComponent, + LabelComponent, + FeedbackTextComponent, + ], + template: ` + + + + @if (showFeedback) { + + } + + `, +}) +class TestHostComponent { + label?: string; + direction: CheckboxGroupDirection = "horizontal"; + showFeedback = false; +} + +describe("CheckboxGroupComponent", () => { + let fixture: ComponentFixture; + let groupElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + groupElement = fixture.nativeElement.querySelector("tedi-checkbox-group"); + }); + + it("should create component", () => { + expect(groupElement).toBeTruthy(); + expect(groupElement.classList).toContain("tedi-checkbox-group"); + }); + + it("should not render label when not provided", () => { + const label = groupElement.querySelector(".tedi-checkbox-group__label"); + expect(label).toBeFalsy(); + }); + + it("should render label when provided", () => { + fixture.componentInstance.label = "Group Label"; + fixture.detectChanges(); + const label = groupElement.querySelector(".tedi-checkbox-group__label"); + expect(label).toBeTruthy(); + expect(label?.textContent?.trim()).toBe("Group Label"); + }); + + it("should use horizontal direction by default", () => { + const checks = groupElement.querySelector(".tedi-checkbox-group__checks"); + expect(checks?.classList).not.toContain( + "tedi-checkbox-group__checks--vertical" + ); + }); + + it("should apply vertical direction class", () => { + fixture.componentInstance.direction = "vertical"; + fixture.detectChanges(); + const checks = groupElement.querySelector(".tedi-checkbox-group__checks"); + expect(checks?.classList).toContain( + "tedi-checkbox-group__checks--vertical" + ); + }); + + it("should project checkbox content into checks container", () => { + const checks = groupElement.querySelector(".tedi-checkbox-group__checks"); + const inputs = checks?.querySelectorAll('input[type="checkbox"]'); + expect(inputs?.length).toBe(2); + }); + + it("should project feedback text into subtexts container", () => { + fixture.componentInstance.showFeedback = true; + fixture.detectChanges(); + const subtexts = groupElement.querySelector( + ".tedi-checkbox-group__subtexts" + ); + const feedbackText = subtexts?.querySelector("tedi-feedback-text"); + expect(feedbackText).toBeTruthy(); + }); +}); diff --git a/tedi/components/form/checkbox-group/checkbox-group.component.ts b/tedi/components/form/checkbox-group/checkbox-group.component.ts new file mode 100644 index 000000000..c399d501d --- /dev/null +++ b/tedi/components/form/checkbox-group/checkbox-group.component.ts @@ -0,0 +1,33 @@ +import { + ChangeDetectionStrategy, + Component, + input, + ViewEncapsulation, +} from "@angular/core"; +import { TextComponent } from "../../base/text/text.component"; + +export type CheckboxGroupDirection = "horizontal" | "vertical"; + +@Component({ + standalone: true, + imports: [TextComponent], + selector: "tedi-checkbox-group", + templateUrl: "./checkbox-group.component.html", + styleUrl: "./checkbox-group.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-checkbox-group", + }, +}) +export class CheckboxGroupComponent { + /** + * Label text displayed above the checkbox group. + */ + readonly label = input(); + /** + * Layout direction of the checkboxes. + * @default horizontal + */ + readonly direction = input("horizontal"); +} diff --git a/tedi/components/form/checkbox/checkbox.component.scss b/tedi/components/form/checkbox/checkbox.component.scss index 099c1ac07..0fc473f0a 100644 --- a/tedi/components/form/checkbox/checkbox.component.scss +++ b/tedi/components/form/checkbox/checkbox.component.scss @@ -98,3 +98,8 @@ input[tedi-checkbox][type="checkbox"] { height: var(--form-checkbox-radio-size-large); } } + +label:has(input[tedi-checkbox]) + tedi-feedback-text { + padding-left: var(--form-checkbox-radio-size-responsive); + margin-left: var(--form-checkbox-radio-inner-spacing); +} diff --git a/tedi/components/form/checkbox/checkbox.stories.ts b/tedi/components/form/checkbox/checkbox.stories.ts index 172b4d99c..4a95d9e94 100644 --- a/tedi/components/form/checkbox/checkbox.stories.ts +++ b/tedi/components/form/checkbox/checkbox.stories.ts @@ -6,6 +6,8 @@ import { } from "@storybook/angular"; import { CheckboxComponent } from "./checkbox.component"; import { CheckboxCardComponent } from "../checkbox-card/checkbox-card.component"; +import { CheckboxGroupComponent } from "../checkbox-group/checkbox-group.component"; +import { CheckboxCardGroupComponent } from "../checkbox-card-group/checkbox-card-group.component"; import { RowComponent } from "../../helpers/grid/row/row.component"; import { ColComponent } from "../../helpers/grid/col/col.component"; import { TextComponent } from "../../base/text/text.component"; @@ -26,13 +28,15 @@ type StoryCheckboxComponent = CheckboxComponent & { * Zeroheight ↗ */ export default { - title: "TEDI-Ready/Components/Form/Choicegroup/Checkbox", + title: "TEDI-Ready/Components/Form/Checkbox", component: CheckboxComponent, decorators: [ moduleMetadata({ imports: [ CheckboxComponent, CheckboxCardComponent, + CheckboxGroupComponent, + CheckboxCardGroupComponent, RowComponent, ColComponent, TextComponent, @@ -91,20 +95,23 @@ export default { } as Meta; export const Default: StoryObj = - { - args: { - size: "default", - invalid: false, - disabled: false, - indeterminate: false, - }, - render: (args) => ({ - props: args, - template: ` - +{ + args: { + size: "default", + invalid: false, + disabled: false, + indeterminate: false, + }, + render: (args) => ({ + props: args, + template: ` + `, - }), - }; + }), +}; /** * Default size is used on desktop, large size is applied automatically on mobile screen sizes. @@ -128,21 +135,20 @@ export const Vertical: StoryObj = { render: (args) => ({ props: args, template: ` -

    Label

    - - + `, }), }; @@ -151,21 +157,20 @@ export const Horizontal: StoryObj = { render: (args) => ({ props: args, template: ` -

    Label

    -
    -
    + `, }), }; @@ -203,33 +208,34 @@ export const VerticalTree: StoryObj = { return { props: args, template: ` -

    Label

    - - - - - + + + + + + + + `, }; }, @@ -240,23 +246,31 @@ export const Separate: StoryObj = { props: args, template: ` - -
    - -