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..7584bc09b --- /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. +- 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/index.ts b/tedi/components/form/index.ts index 644875c23..7dc2d64eb 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -1,8 +1,9 @@ export * from "./checkbox/checkbox.component"; -export * from "./date-picker/date-picker.component"; export * from "./feedback-text/feedback-text.component"; export * from "./label/label.component"; export * from "./number-field/number-field.component"; +export * from "./select"; export * from "./toggle/toggle.component"; +export * from "./date-picker/date-picker.component"; export * from "./form-field/form-field.component"; export * from "./text-field/text-field.component"; diff --git a/tedi/components/form/select/index.ts b/tedi/components/form/select/index.ts new file mode 100644 index 000000000..ed0b748d5 --- /dev/null +++ b/tedi/components/form/select/index.ts @@ -0,0 +1,2 @@ +export * from "./select.component"; +export * from "./select-templates.directive"; diff --git a/tedi/components/form/select/select-templates.directive.ts b/tedi/components/form/select/select-templates.directive.ts new file mode 100644 index 000000000..84c619692 --- /dev/null +++ b/tedi/components/form/select/select-templates.directive.ts @@ -0,0 +1,126 @@ +import { Directive, TemplateRef, inject } from "@angular/core"; + +/** + * Context provided to custom option templates. + */ +export interface SelectOptionContext { + /** The option item data */ + $implicit: T; + /** The option item data (explicit reference) */ + item: T; + /** Index of the option in the list */ + index: number; + /** Whether this option is currently selected */ + selected: boolean; + /** Whether this option is disabled */ + disabled: boolean; +} + +/** + * Context provided to custom label templates (for displaying selected value). + */ +export interface SelectLabelContext { + /** The selected item (single select) or items (multiple select) */ + $implicit: T | T[]; + /** The selected item(s) */ + item: T | T[]; + /** Function to clear a specific item (for multiple select) */ + clear: (item: T) => void; +} + +/** + * Context provided to custom value templates (for displaying selected value in trigger). + */ +export interface SelectValueContext { + /** The selected item data */ + $implicit: T; + /** The selected item data (explicit reference) */ + item: T; + /** The label string for the selected item */ + label: string; +} + +/** + * Directive for custom option template rendering in the dropdown. + * + * @example + * ```html + * + * + *
    + * {{ item.title }} + * {{ item.description }} + *
    + *
    + *
    + * ``` + */ +@Directive({ + selector: "[tediSelectOption]", + standalone: true, +}) +export class SelectOptionTemplateDirective { + template = inject>>(TemplateRef); + + static ngTemplateContextGuard( + _dir: SelectOptionTemplateDirective, + ctx: unknown + ): ctx is SelectOptionContext { + return true; + } +} + +/** + * Directive for custom label template rendering (selected value display). + * + * @example + * ```html + * + * + * {{ item.name }} ({{ item.code }}) + * + * + * ``` + */ +@Directive({ + selector: "[tediSelectLabel]", + standalone: true, +}) +export class SelectLabelTemplateDirective { + template = inject>>(TemplateRef); + + static ngTemplateContextGuard( + _dir: SelectLabelTemplateDirective, + ctx: unknown + ): ctx is SelectLabelContext { + return true; + } +} + +/** + * Directive for custom value template rendering (selected value display in trigger). + * Used for single-select to display custom content like colors, icons, etc. + * + * @example + * ```html + * + * + *
    + *
    + *
    + * ``` + */ +@Directive({ + selector: "[tediSelectValue]", + standalone: true, +}) +export class SelectValueTemplateDirective { + template = inject>>(TemplateRef); + + static ngTemplateContextGuard( + _dir: SelectValueTemplateDirective, + ctx: unknown + ): ctx is SelectValueContext { + return true; + } +} diff --git a/tedi/components/form/select/select.component.html b/tedi/components/form/select/select.component.html new file mode 100644 index 000000000..1e77877c5 --- /dev/null +++ b/tedi/components/form/select/select.component.html @@ -0,0 +1,254 @@ +@if (label()) { + +} +
    + @if (searchable()) { +
    + @if (!searchTerm() && selectedValues().length && !multiple()) { + + @if (valueTemplate(); as tpl) { + + } @else { + {{ selectedLabels().join(", ") }} + } + + } + @if (multiple() && selectedValues().length) { + + } + +
    + } @else { + + @if (selectedValues().length) { + @if (multiple()) { + + } @else { + @if (valueTemplate(); as tpl) { + + } @else { + {{ selectedLabels().join(", ") }} + } + } + } @else { + + {{ placeholder() }} + + } + + } + + @if (clearable() && selectedValues().length) { + + } + + +
    +@if (feedbackText(); as feedback) { + +} + + +
    + @if (multiRow()) { + @for (value of selectedValues(); track value) { + + {{ getLabel(value) }} + + } + } @else { + @for (value of selectedValues(); track value; let i = $index) { + @if (visibleTagsCount() === null || i < visibleTagsCount()!) { + + {{ getLabel(value) }} + + } + } + @if (hiddenTagsCount() > 0) { + +{{ hiddenTagsCount() }} + } + } +
    +
    + + +
    +
      + @if (filteredOptions().length) { + @if (multiple() && showSelectAll()) { +
    • + + {{ "select.select-all" | tediTranslate }} + +
    • + } + + @for (group of optionGroups(); track group.label) { + @if (group.label.length > 0) { + @if (multiple() && selectableGroups()) { +
    • + + {{ group.label }} + +
    • + } @else { + + } + } + + @for (option of group.options; track option.value; let i = $index) { +
    • + @if (optionTemplate(); as tpl) { + + } @else { + + {{ option.label }} + + } +
    • + } + } + } @else { +
    • + {{ noOptionsMessage() || ("select.no-options" | tediTranslate) }} +
    • + } +
    +
    +
    diff --git a/tedi/components/form/select/select.component.scss b/tedi/components/form/select/select.component.scss new file mode 100644 index 000000000..fd29f901a --- /dev/null +++ b/tedi/components/form/select/select.component.scss @@ -0,0 +1,326 @@ +@import "../../overlay/dropdown/dropdown-item/dropdown-item.component"; + +.tedi-input { + --_border-color: var(--form-input-border-default); + --_color: var(--form-input-text-filled); + --_background-color: var(--form-input-background-default); + --_placeholder-color: var(--form-input-text-placeholder); + --_border-radius: var(--form-field-radius); + --_font-size: var(--body-regular-size); + --_line-height: var(--body-regular-line-height); + --_padding-y: var(--form-field-padding-y-md-default); + --_padding-x: var(--form-field-padding-x-md-default); + --_search-input-min-width: 50px; + + min-height: var(--form-field-height); + padding: calc(var(--_padding-y) - var(--borders-01)) var(--_padding-x); + margin-bottom: 0; + font-family: var(--family-default); + font-size: var(--_font-size); + line-height: var(--_line-height); + color: var(--_color); + background-color: var(--_background-color); + border: var(--borders-01) solid var(--_border-color); + border-radius: var(--_border-radius); + + &:hover { + --_border-color: var(--form-input-border-hover); + } + + &:focus, + &:active, + &.tedi-select__trigger--search-focused { + border-color: var(--form-input-border-hover); + box-shadow: inset 0 0 0 1px var(--form-input-border-hover); + } + + &--disabled { + --_color: var(--form-input-text-disabled); + --_border-color: var(--form-input-border-disabled); + --_background-color: var(--form-input-background-disabled); + + pointer-events: none; + } + + &--error:not(.tedi-input--disabled) { + --_border-color: var(--form-general-feedback-error-border); + + &:focus, + &:active, + &.tedi-select__trigger--search-focused { + border-color: var(--form-general-feedback-error-border); + box-shadow: inset 0 0 0 1px var(--form-general-feedback-error-border); + } + } + + &--valid:not(.tedi-input--disabled) { + --_border-color: var(--form-general-feedback-success-border); + + &:focus, + &:active, + &.tedi-select__trigger--search-focused { + border-color: var(--form-general-feedback-success-border); + box-shadow: inset 0 0 0 1px var(--form-general-feedback-success-border); + } + } + + &--small { + --_padding-y: var(--form-field-padding-y-sm); + + min-height: var(--form-field-height-sm); + } +} + +.tedi-select { + display: block; + width: 100%; + + &__trigger { + display: flex; + justify-content: space-between; + width: 100%; + cursor: pointer; + } + + &__label { + flex-grow: 1; + overflow: hidden; + text-align: left; + cursor: default; + + &--placeholder { + color: var(--_placeholder-color); + pointer-events: none; + } + } + + &__clear { + flex-grow: 0; + padding: 0; + margin: 0; + color: var(--button-close-text-default); + cursor: pointer; + background: none; + border: none; + + &+.tedi-select__arrow { + border-left: 1px solid var(--general-border-primary); + } + } + + &__arrow { + flex-grow: 0; + padding-left: var(--form-field-inner-spacing); + margin-left: var(--form-field-inner-spacing); + cursor: default; + } + + &__dropdown { + display: flex; + flex-direction: column; + max-height: 100%; + margin-top: var(--form-field-outer-spacing); + margin-bottom: var(--form-field-outer-spacing); + background: var(--card-background-primary); + border-radius: var(--card-border-radius); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + } + + &__trigger--searchable { + cursor: text; + } + + &__search-wrapper { + position: relative; + display: flex; + flex-grow: 1; + flex-wrap: wrap; + gap: var(--form-field-inner-spacing); + align-items: center; + min-height: var(--_line-height); + overflow: hidden; + } + + &__selected-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + } + + &__search-input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: inherit; + background: transparent; + border: none; + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--form-input-text-placeholder); + } + + &--hidden { + color: transparent; + caret-color: var(--form-input-text-filled); + } + } + + &__search-input:not(&__search-input--hidden) { + position: relative; + flex: 1; + width: auto; + min-width: var(--_search-input-min-width); + height: auto; + } + + &__options { + flex: 1; + min-height: 0; + padding: 0; + margin: 0; + overflow-y: auto; + outline: none; + + .tedi-dropdown-item { + outline: none; + + &.cdk-option-active:not(.tedi-dropdown-item--disabled) { + outline: var(--borders-02) solid var(--tedi-primary-500); + outline-offset: calc(-1 * var(--borders-02)); + } + + &[aria-selected="true"], + &.tedi-dropdown-item--selected { + color: var(--dropdown-item-active-text); + background: var(--dropdown-item-active-background); + + .tedi-icon { + color: inherit; + } + } + } + } + + &__dropdown-item { + &--label { + display: none; + } + + &--custom-content:empty+&--label { + display: block; + } + } + + &__group-name { + display: block; + padding: var(--dropdown-group-label-padding-y) var(--dropdown-group-label-padding-x) var(--layout-grid-gutters-04); + font-size: var(--heading-subtitle-small-size); + font-weight: var(--heading-subtitle-small-weight); + line-height: var(--heading-subtitle-small-line-height); + text-transform: uppercase; + letter-spacing: 0; + + &--selectable { + padding: var(--dropdown-item-padding-y, 8px) var(--dropdown-item-padding-x); + font-size: var(--body-regular-size); + font-weight: var(--body-regular-weight); + line-height: var(--body-regular-line-height); + text-transform: none; + letter-spacing: inherit; + + &~.tedi-dropdown-item:not(.tedi-select__group-name) { + padding-left: var(--form-checkbox-radio-subitem-padding-left); + } + } + } + + &--multiselect { + .tedi-select__trigger { + align-items: flex-start; + } + } + + &__multiselect-container { + display: flex; + flex-wrap: wrap; + gap: var(--form-field-inner-spacing); + + &--single-row { + flex-wrap: nowrap; + overflow: hidden; + + .tedi-tag { + flex-shrink: 0; + + &__content { + white-space: nowrap; + } + } + } + } + + &__no-options { + color: var(--general-text-tertiary); + cursor: default; + + &:hover { + color: var(--general-text-tertiary); + background: var(--dropdown-item-default-background); + } + } + + &__dropdown:has(&__options--swatch-grid) { + width: fit-content; + } + + &__options--swatch-grid { + --tedi-swatch-size: 24px; + --tedi-swatch-gap: var(--layout-grid-gutters-04); + --tedi-swatch-columns: 11; + + display: grid; + grid-template-columns: repeat(auto-fit, var(--tedi-swatch-size)); + gap: var(--tedi-swatch-gap); + max-width: calc(var(--tedi-swatch-columns) * (var(--tedi-swatch-size) + var(--tedi-swatch-gap))); + padding: var(--dropdown-body-padding-y) var(--dropdown-body-padding-x); + + .tedi-dropdown-item { + display: flex; + align-items: center; + justify-content: center; + width: var(--tedi-swatch-size); + height: var(--tedi-swatch-size); + min-height: auto; + padding: var(--layout-grid-gutters-02); + color: inherit; + background: transparent; + border-radius: var(--card-radius-rounded); + + &.cdk-option-active:not(.tedi-dropdown-item--disabled) { + outline-offset: 0; + } + + &[aria-selected="true"], + &.tedi-dropdown-item--selected { + color: inherit; + background: transparent; + border: var(--borders-02) solid var(--card-border-selected); + } + + &:hover:not(.tedi-dropdown-item--disabled) { + background: transparent; + } + } + } +} diff --git a/tedi/components/form/select/select.component.spec.ts b/tedi/components/form/select/select.component.spec.ts new file mode 100644 index 000000000..c51909c36 --- /dev/null +++ b/tedi/components/form/select/select.component.spec.ts @@ -0,0 +1,1749 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { SelectComponent, SelectInputSize, SelectOption } from "./select.component"; +import { + SelectLabelTemplateDirective, + SelectOptionTemplateDirective, + SelectValueTemplateDirective, +} from "./select-templates.directive"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; +import { InputState } from "../form-field/form-field.component"; + +@Component({ + standalone: true, + template: ` + + @if (useOptionTemplate) { + + {{ item.name }} - {{ selected ? 'selected' : 'not selected' }} + + } + @if (useValueTemplate) { + + {{ item.name }} + + } + + `, + imports: [ + SelectComponent, + SelectOptionTemplateDirective, + SelectValueTemplateDirective, + ReactiveFormsModule, + ], +}) +class TestHostComponent { + inputId = "test-select"; + label = "Test Label"; + items: unknown[] = ["Option 1", "Option 2", "Option 3"]; + multiple = false; + searchable = false; + clearable = true; + showSelectAll = false; + selectableGroups = false; + groupBy: string | undefined = undefined; + bindLabel = "label"; + bindValue: string | undefined = undefined; + placeholder = "Select an option..."; + state: InputState = "default"; + size: SelectInputSize = "default"; + required = false; + clearableTags = false; + multiRow = false; + dropdownWidthRef: any = undefined; + maxDropdownHeight: number | undefined = undefined; + useOptionTemplate = false; + useValueTemplate = false; + control = new FormControl(null); +} + +describe("SelectComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let select: SelectComponent; + let hostEl: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + hostEl = fixture.nativeElement; + fixture.detectChanges(); + + const selectDebug = fixture.debugElement.query(By.directive(SelectComponent)); + select = selectDebug.componentInstance; + }); + + const getTrigger = () => hostEl.querySelector(".tedi-select__trigger") as HTMLElement; + const getSearchInput = () => hostEl.querySelector(".tedi-select__search-input") as HTMLInputElement; + const getLabel = () => hostEl.querySelector("[tedi-label]") as HTMLElement; + const getClearButton = () => hostEl.querySelector(".tedi-select__clear") as HTMLButtonElement; + const getDropdown = () => document.querySelector(".tedi-select__dropdown") as HTMLElement; + const getOptions = () => Array.from(document.querySelectorAll(".tedi-dropdown-item:not(.tedi-select__group-name):not(.tedi-select__no-options)")) as HTMLElement[]; + const getSelectAllOption = () => { + const items = document.querySelectorAll(".tedi-dropdown-item"); + return items[0] as HTMLElement; + }; + const getTags = () => Array.from(hostEl.querySelectorAll("tedi-tag")) as HTMLElement[]; + + describe("Initialization", () => { + it("should create the component", () => { + expect(select).toBeTruthy(); + }); + + it("should apply tedi-select host class", () => { + const selectEl = fixture.debugElement.query(By.directive(SelectComponent)).nativeElement; + expect(selectEl.classList).toContain("tedi-select"); + }); + + it("should render label when provided", () => { + const label = getLabel(); + expect(label).toBeTruthy(); + expect(label.textContent?.trim()).toBe("Test Label"); + }); + + it("should not render label when not provided", () => { + host.label = ""; + fixture.detectChanges(); + const label = getLabel(); + expect(label).toBeFalsy(); + }); + + it("should show placeholder when no value selected", () => { + const trigger = getTrigger(); + expect(trigger.textContent).toContain("Select an option..."); + }); + + it("should show required indicator on label", () => { + host.required = true; + fixture.detectChanges(); + const requiredIndicator = hostEl.querySelector(".tedi-label--required"); + expect(requiredIndicator).toBeTruthy(); + expect(requiredIndicator?.textContent).toBe("*"); + }); + + it("should apply small size class", () => { + host.size = "small"; + fixture.detectChanges(); + const trigger = getTrigger(); + expect(trigger.classList).toContain("tedi-input--small"); + }); + + it("should apply error state class", () => { + host.state = "error"; + fixture.detectChanges(); + const trigger = getTrigger(); + expect(trigger.classList).toContain("tedi-input--error"); + }); + + it("should apply valid state class", () => { + host.state = "valid"; + fixture.detectChanges(); + const trigger = getTrigger(); + expect(trigger.classList).toContain("tedi-input--valid"); + }); + + it("should apply multiselect class when multiple=true", () => { + host.multiple = true; + fixture.detectChanges(); + const selectEl = fixture.debugElement.query(By.directive(SelectComponent)).nativeElement; + expect(selectEl.classList).toContain("tedi-select--multiselect"); + }); + }); + + describe("ControlValueAccessor", () => { + it("writeValue should set single value", () => { + select.writeValue("Option 1"); + expect(select.selectedValues()).toEqual(["Option 1"]); + }); + + it("writeValue should set array of values for multiple", () => { + host.multiple = true; + fixture.detectChanges(); + select.writeValue(["Option 1", "Option 2"]); + expect(select.selectedValues()).toEqual(["Option 1", "Option 2"]); + }); + + it("writeValue should clear selection on null", () => { + select.writeValue("Option 1"); + select.writeValue(null); + expect(select.selectedValues()).toEqual([]); + }); + + it("writeValue should clear selection on empty array for multiple", () => { + host.multiple = true; + fixture.detectChanges(); + select.writeValue(["Option 1"]); + select.writeValue([]); + expect(select.selectedValues()).toEqual([]); + }); + + it("registerOnChange should call onChange when value changes", () => { + const spy = jest.fn(); + select.registerOnChange(spy); + + select.handleValueChange({ value: ["Option 1"] }); + + expect(spy).toHaveBeenCalledWith("Option 1"); + }); + + it("registerOnTouched should call onTouched on blur", () => { + const spy = jest.fn(); + select.registerOnTouched(spy); + + const trigger = getTrigger(); + trigger.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalled(); + }); + + it("setDisabledState should disable component", () => { + select.setDisabledState(true); + fixture.detectChanges(); + + expect(select.disabled()).toBe(true); + const trigger = getTrigger(); + expect(trigger.classList).toContain("tedi-input--disabled"); + }); + + it("should not open dropdown when disabled", () => { + select.setDisabledState(true); + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(false); + }); + }); + + describe("Single select", () => { + it("should open dropdown on trigger click", () => { + getTrigger().click(); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(true); + expect(getDropdown()).toBeTruthy(); + }); + + it("should select option on click", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1"]); + })); + + it("should close dropdown after selection", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + })); + + it("should display selected label in trigger", fakeAsync(() => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + tick(); + + const trigger = getTrigger(); + expect(trigger.textContent).toContain("Option 1"); + })); + + it("should replace previous selection", fakeAsync(() => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[1].click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 2"]); + })); + + it("should show clear button when value selected and clearable=true", fakeAsync(() => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + tick(); + + expect(getClearButton()).toBeTruthy(); + })); + + it("should not show clear button when clearable=false", fakeAsync(() => { + host.clearable = false; + host.control.setValue("Option 1"); + fixture.detectChanges(); + tick(); + + expect(getClearButton()).toBeFalsy(); + })); + + it("should clear selection on clear button click", fakeAsync(() => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + tick(); + + const clearBtn = getClearButton(); + clearBtn.click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([]); + expect(host.control.value).toBeNull(); + })); + }); + + describe("Multiple select", () => { + beforeEach(() => { + host.multiple = true; + fixture.detectChanges(); + }); + + it("should select multiple options", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + options[1].click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1", "Option 2"]); + })); + + it("should keep dropdown open after selection", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + + it("should display selected values as tags", fakeAsync(() => { + host.control.setValue(["Option 1", "Option 2"]); + fixture.detectChanges(); + tick(); + + const tags = getTags(); + expect(tags.length).toBe(2); + })); + + it("should deselect via tag close button when clearableTags=true", fakeAsync(() => { + host.clearableTags = true; + host.control.setValue(["Option 1", "Option 2"]); + fixture.detectChanges(); + tick(); + + const tags = getTags(); + const closeBtn = tags[0].querySelector("[tedi-closing-button]") as HTMLElement; + closeBtn?.click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 2"]); + })); + + it("should clear all selections on clear button click", fakeAsync(() => { + host.control.setValue(["Option 1", "Option 2"]); + fixture.detectChanges(); + tick(); + + getClearButton().click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([]); + expect(host.control.value).toEqual([]); + })); + + it("should deselect option when clicking selected option", fakeAsync(() => { + host.control.setValue(["Option 1", "Option 2"]); + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 2"]); + })); + }); + + describe("Searchable select", () => { + beforeEach(() => { + host.searchable = true; + fixture.detectChanges(); + }); + + it("should render search input when searchable=true", () => { + expect(getSearchInput()).toBeTruthy(); + }); + + it("should filter options when typing", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.value = "Option 1"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + expect(options.length).toBe(1); + expect(options[0].textContent).toContain("Option 1"); + })); + + it("should show no options message when filter matches nothing", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.value = "xyz"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + tick(); + + const noOptions = document.querySelector(".tedi-select__no-options"); + expect(noOptions).toBeTruthy(); + })); + + it("should clear search term after selection", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.value = "Option 1"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(select.searchTerm()).toBe(""); + })); + + it("should open dropdown when typing", fakeAsync(() => { + const input = getSearchInput(); + input.value = "O"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + + it("should have combobox role on search input", () => { + const input = getSearchInput(); + expect(input.getAttribute("role")).toBe("combobox"); + }); + }); + + describe("Grouped options", () => { + beforeEach(() => { + host.items = [ + { id: 1, name: "Apple", category: "Fruits" }, + { id: 2, name: "Banana", category: "Fruits" }, + { id: 3, name: "Carrot", category: "Vegetables" }, + { id: 4, name: "Broccoli", category: "Vegetables" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.groupBy = "category"; + fixture.detectChanges(); + }); + + it("should render group headers", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const groupHeaders = document.querySelectorAll(".tedi-select__group-name"); + expect(groupHeaders.length).toBe(2); + expect(groupHeaders[0].textContent).toContain("Fruits"); + expect(groupHeaders[1].textContent).toContain("Vegetables"); + })); + + it("should render options under their groups", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const options = getOptions(); + expect(options.length).toBe(4); + })); + + it("should select group options when selectableGroups=true", fakeAsync(() => { + host.multiple = true; + host.selectableGroups = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const groupHeaders = document.querySelectorAll(".tedi-select__group-name--selectable"); + expect(groupHeaders.length).toBe(2); + + (groupHeaders[0] as HTMLElement).click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([1, 2]); + })); + }); + + describe("Select all", () => { + beforeEach(() => { + host.multiple = true; + host.showSelectAll = true; + fixture.detectChanges(); + }); + + it("should show select all option", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const dropdown = getDropdown(); + expect(dropdown.textContent).toContain("Vali kõik"); + })); + + it("should select all options when clicking select all", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const selectAllOption = getSelectAllOption(); + selectAllOption.click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1", "Option 2", "Option 3"]); + })); + + it("should deselect all when all are selected", fakeAsync(() => { + host.control.setValue(["Option 1", "Option 2", "Option 3"]); + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const selectAllOption = getSelectAllOption(); + selectAllOption.click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([]); + })); + + it("allOptionsSelected should return true when all selected", () => { + host.control.setValue(["Option 1", "Option 2", "Option 3"]); + fixture.detectChanges(); + + expect(select.allOptionsSelected()).toBe(true); + }); + + it("allOptionsSelected should return false when not all selected", () => { + host.control.setValue(["Option 1"]); + fixture.detectChanges(); + + expect(select.allOptionsSelected()).toBe(false); + }); + }); + + describe("Keyboard navigation", () => { + it("Enter on trigger should open dropdown", () => { + const trigger = getTrigger(); + trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(true); + }); + + it("Space on trigger should open dropdown", () => { + const trigger = getTrigger(); + trigger.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(true); + }); + + it("ArrowDown on trigger should open dropdown", () => { + const trigger = getTrigger(); + trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(true); + }); + + it("Escape should close dropdown", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + + select.toggleIsOpen(true); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + })); + + describe("Searchable keyboard navigation", () => { + beforeEach(() => { + host.searchable = true; + fixture.detectChanges(); + }); + + it("ArrowDown should navigate to next option", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + })); + + it("Enter should select focused option", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues().length).toBeGreaterThan(0); + })); + + it("Escape should close dropdown", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + })); + + it("Tab should close dropdown", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + })); + }); + }); + + describe("Accessibility", () => { + it("trigger should have combobox role when not searchable", () => { + const trigger = getTrigger(); + expect(trigger.getAttribute("role")).toBe("combobox"); + }); + + it("trigger should not have combobox role when searchable", () => { + host.searchable = true; + fixture.detectChanges(); + + const trigger = getTrigger(); + expect(trigger.getAttribute("role")).toBeNull(); + }); + + it("trigger should have aria-expanded", () => { + const trigger = getTrigger(); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + + trigger.click(); + fixture.detectChanges(); + + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("trigger should have aria-haspopup", () => { + const trigger = getTrigger(); + expect(trigger.getAttribute("aria-haspopup")).toBe("listbox"); + }); + + it("trigger should have aria-controls referencing listbox", fakeAsync(() => { + const trigger = getTrigger(); + trigger.click(); + fixture.detectChanges(); + tick(); + + const ariaControls = trigger.getAttribute("aria-controls"); + expect(ariaControls).toBe("test-select-listbox"); + + const listbox = document.getElementById(ariaControls!); + expect(listbox).toBeTruthy(); + })); + + it("listbox should have aria-activedescendant", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const listbox = document.querySelector("[cdkListbox]") as HTMLElement; + expect(listbox).toBeTruthy(); + expect(listbox.getAttribute("role")).toBe("listbox"); + })); + + it("should mark active option on navigation", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + })); + }); + + describe("Custom templates", () => { + beforeEach(() => { + host.items = [ + { id: 1, name: "Item 1" }, + { id: 2, name: "Item 2" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + fixture.detectChanges(); + }); + + it("should render custom option template", fakeAsync(() => { + host.useOptionTemplate = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const customOption = document.querySelector(".custom-option"); + expect(customOption).toBeTruthy(); + expect(customOption?.textContent).toContain("Item 1 - not selected"); + })); + + it("should provide selected state in option template", fakeAsync(() => { + host.useOptionTemplate = true; + host.control.setValue(1); + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const customOptions = document.querySelectorAll(".custom-option"); + expect(customOptions[0].textContent).toContain("Item 1 - selected"); + expect(customOptions[1].textContent).toContain("Item 2 - not selected"); + })); + + it("should render custom value template", fakeAsync(() => { + host.useValueTemplate = true; + host.control.setValue(1); + fixture.detectChanges(); + tick(); + + const customValue = hostEl.querySelector(".custom-value"); + expect(customValue).toBeTruthy(); + expect(customValue?.textContent).toContain("Item 1"); + })); + + it("getOptionContext should return correct context", () => { + const option: SelectOption = { value: 1, label: "Test", disabled: false }; + const context = select.getOptionContext(option as SelectOption, 0); + + expect(context.index).toBe(0); + expect(context.selected).toBe(false); + expect(context.disabled).toBe(false); + }); + + it("getValueContext should return correct context", () => { + const option: SelectOption = { value: 1, label: "Test" }; + const context = select.getValueContext(option as SelectOption); + + expect(context.label).toBe("Test"); + }); + }); + + describe("Data binding", () => { + it("should work with primitive string array", () => { + host.items = ["A", "B", "C"]; + fixture.detectChanges(); + + expect(select.normalizedOptions().length).toBe(3); + expect(select.normalizedOptions()[0].label).toBe("A"); + expect(select.normalizedOptions()[0].value).toBe("A"); + }); + + it("should work with primitive number array", () => { + host.items = [1, 2, 3]; + fixture.detectChanges(); + + expect(select.normalizedOptions().length).toBe(3); + expect(select.normalizedOptions()[0].label).toBe("1"); + expect(select.normalizedOptions()[0].value).toBe(1); + }); + + it("should use bindLabel for object items", () => { + host.items = [{ name: "Apple" }, { name: "Banana" }]; + host.bindLabel = "name"; + fixture.detectChanges(); + + expect(select.normalizedOptions()[0].label).toBe("Apple"); + }); + + it("should use bindValue for object items", () => { + host.items = [ + { id: 1, name: "Apple" }, + { id: 2, name: "Banana" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + fixture.detectChanges(); + + expect(select.normalizedOptions()[0].value).toBe(1); + expect(select.normalizedOptions()[0].label).toBe("Apple"); + }); + + it("should use whole object as value when bindValue not set", () => { + const items = [ + { id: 1, name: "Apple" }, + { id: 2, name: "Banana" }, + ]; + host.items = items; + host.bindLabel = "name"; + host.bindValue = undefined; + fixture.detectChanges(); + + expect(select.normalizedOptions()[0].value).toBe(items[0]); + }); + + it("should handle empty items array", () => { + host.items = []; + fixture.detectChanges(); + + expect(select.normalizedOptions().length).toBe(0); + }); + }); + + describe("Dropdown behavior", () => { + it("should open dropdown on trigger click", () => { + getTrigger().click(); + fixture.detectChanges(); + + expect(select.isOpen()).toBe(true); + }); + + it("should close dropdown on outside click", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + + document.body.click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + })); + + it("should not close when clicking inside host element", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + + const outsideElement = document.createElement("div"); + document.body.appendChild(outsideElement); + + outsideElement.click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(false); + + document.body.removeChild(outsideElement); + })); + }); + + describe("Computed properties", () => { + it("selectedLabels should return labels of selected options", () => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + + expect(select.selectedLabels()).toEqual(["Option 1"]); + }); + + it("selectedOptions should return selected option objects", () => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + + const selected = select.selectedOptions(); + expect(selected.length).toBe(1); + expect(selected[0].label).toBe("Option 1"); + }); + + it("filteredOptions should filter by search term", () => { + host.searchable = true; + fixture.detectChanges(); + + select.searchTerm.set("Option 1"); + + expect(select.filteredOptions().length).toBe(1); + expect(select.filteredOptions()[0].label).toBe("Option 1"); + }); + + it("visibleSelectedValues should only include filtered options", fakeAsync(() => { + host.multiple = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + select.selectedValues.set(["Option 1", "Option 2"]); + fixture.detectChanges(); + tick(); + + select.searchTerm.set("Option 1"); + fixture.detectChanges(); + tick(); + + expect(select.visibleSelectedValues()).toEqual(["Option 1"]); + })); + + it("optionGroups should group options correctly", () => { + host.items = [ + { id: 1, name: "A1", group: "A" }, + { id: 2, name: "A2", group: "A" }, + { id: 3, name: "B1", group: "B" }, + ]; + host.bindLabel = "name"; + host.groupBy = "group"; + fixture.detectChanges(); + + const groups = select.optionGroups(); + expect(groups.length).toBe(2); + expect(groups[0].label).toBe("A"); + expect(groups[0].options.length).toBe(2); + expect(groups[1].label).toBe("B"); + expect(groups[1].options.length).toBe(1); + }); + + it("hiddenTagsCount should return correct count", fakeAsync(() => { + host.multiple = true; + fixture.detectChanges(); + tick(); + + select.selectedValues.set(["Option 1", "Option 2", "Option 3"]); + fixture.detectChanges(); + tick(); + + select.visibleTagsCount.set(1); + fixture.detectChanges(); + tick(); + + expect(select.hiddenTagsCount()).toBe(2); + })); + }); + + describe("Disabled options", () => { + beforeEach(() => { + host.items = [ + { id: 1, name: "Enabled", disabled: false }, + { id: 2, name: "Disabled", disabled: true }, + { id: 3, name: "Also Enabled", disabled: false }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + fixture.detectChanges(); + }); + + it("should mark disabled options", () => { + const options = select.normalizedOptions(); + expect(options[0].disabled).toBe(false); + expect(options[1].disabled).toBe(true); + expect(options[2].disabled).toBe(false); + }); + + it("should skip disabled options in keyboard navigation", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + expect(activeOption?.textContent).not.toContain("Disabled"); + })); + + it("select all should skip disabled options", fakeAsync(() => { + host.multiple = true; + host.showSelectAll = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const selectAllOption = getSelectAllOption(); + selectAllOption.click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([1, 3]); + expect(select.selectedValues()).not.toContain(2); + })); + }); + + describe("Helper methods", () => { + it("getLabel should return label for value", () => { + expect(select.getLabel("Option 1")).toBe("Option 1"); + }); + + it("getLabel should return stringified value if not found", () => { + expect(select.getLabel("NonExistent")).toBe("NonExistent"); + }); + + it("isOptionSelected should return true for selected value", () => { + host.control.setValue("Option 1"); + fixture.detectChanges(); + + expect(select.isOptionSelected("Option 1")).toBe(true); + expect(select.isOptionSelected("Option 2")).toBe(false); + }); + + it("isGroupSelected should return true when all group options selected", fakeAsync(() => { + host.items = [ + { id: 1, name: "A1", group: "A" }, + { id: 2, name: "A2", group: "A" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.groupBy = "group"; + host.multiple = true; + fixture.detectChanges(); + tick(); + + host.control.setValue([1, 2]); + fixture.detectChanges(); + tick(); + + expect(select.isGroupSelected("A")).toBe(true); + })); + + it("isGroupSelected should return false when not all group options selected", fakeAsync(() => { + host.items = [ + { id: 1, name: "A1", group: "A" }, + { id: 2, name: "A2", group: "A" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.groupBy = "group"; + host.multiple = true; + fixture.detectChanges(); + tick(); + + host.control.setValue([1]); + fixture.detectChanges(); + tick(); + + expect(select.isGroupSelected("A")).toBe(false); + })); + }); + + describe("Additional coverage", () => { + describe("groupBy as function", () => { + it("should use groupBy function when provided", () => { + host.items = [ + { id: 1, name: "Apple", type: "fruit" }, + { id: 2, name: "Carrot", type: "vegetable" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + // Use a function for groupBy + (host as any).groupBy = (item: any) => item.type.toUpperCase(); + fixture.detectChanges(); + + const options = select.normalizedOptions(); + expect(options[0].group).toBe("FRUIT"); + expect(options[1].group).toBe("VEGETABLE"); + }); + }); + + describe("Window resize", () => { + it("should reset visibleTagsCount on resize for single-row multiselect", () => { + host.multiple = true; + host.multiRow = false; + fixture.detectChanges(); + + select.selectedValues.set(["Option 1", "Option 2"]); + select.visibleTagsCount.set(1); + + expect(select.visibleTagsCount()).toBe(1); + + select.onWindowResize(); + + expect(select.visibleTagsCount()).toBeNull(); + }); + + it("should not reset visibleTagsCount when multiRow is true", () => { + host.multiple = true; + host.multiRow = true; + fixture.detectChanges(); + + select.selectedValues.set(["Option 1", "Option 2"]); + select.visibleTagsCount.set(1); + + select.onWindowResize(); + + expect(select.visibleTagsCount()).toBe(1); + }); + }); + + describe("Navigation with all disabled options", () => { + it("should not activate any option when all options are disabled", fakeAsync(() => { + host.items = [ + { id: 1, name: "Disabled 1", disabled: true }, + { id: 2, name: "Disabled 2", disabled: true }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeFalsy(); + })); + }); + + describe("Searchable keyboard - closed dropdown", () => { + beforeEach(() => { + host.searchable = true; + fixture.detectChanges(); + }); + + it("ArrowDown should open dropdown when closed", fakeAsync(() => { + expect(select.isOpen()).toBe(false); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + + it("ArrowUp should open dropdown when closed", fakeAsync(() => { + expect(select.isOpen()).toBe(false); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + + it("Space should open dropdown when closed", fakeAsync(() => { + expect(select.isOpen()).toBe(false); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + + it("Enter should open dropdown when closed", fakeAsync(() => { + expect(select.isOpen()).toBe(false); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + })); + }); + + describe("Keyboard selection via Enter", () => { + it("should select option via keyboard navigation in multiselect", fakeAsync(() => { + host.multiple = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues().length).toBeGreaterThan(0); + expect(select.isOpen()).toBe(true); + })); + }); + + describe("Multiselect searchable focus", () => { + it("should focus search input after selection in searchable multiselect", fakeAsync(() => { + host.multiple = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + const focusSpy = jest.spyOn(input, "focus"); + + const options = getOptions(); + options[0].click(); + fixture.detectChanges(); + tick(); + + expect(focusSpy).toHaveBeenCalled(); + })); + }); + + describe("getOriginalItem edge cases", () => { + it("should return option.value when bindValue is not set", () => { + const item = { name: "Test" }; + host.items = [item]; + host.bindLabel = "name"; + host.bindValue = undefined; + fixture.detectChanges(); + + const option = select.normalizedOptions()[0]; + const result = select.getOriginalItem(option); + + expect(result).toBe(item); + }); + + it("should return option as fallback when item not found", () => { + host.items = [{ id: 1, name: "Test" }]; + host.bindLabel = "name"; + host.bindValue = "id"; + fixture.detectChanges(); + + const fakeOption = { value: 999, label: "Fake" }; + const result = select.getOriginalItem(fakeOption); + + expect(result).toEqual(fakeOption); + }); + }); + + describe("setDropdownWidth edge cases", () => { + it("should set dropdownWidth to null when dropdownWidthRef is explicitly null", () => { + host.dropdownWidthRef = null; + fixture.detectChanges(); + + select.ngAfterContentChecked(); + + expect(select.dropdownWidth()).toBeNull(); + }); + + it("should set dropdownWidth based on host element width when dropdownWidthRef is undefined", () => { + host.dropdownWidthRef = undefined; + fixture.detectChanges(); + + select.ngAfterContentChecked(); + + expect(select.dropdownWidth()).toBeDefined(); + }); + }); + + describe("toggleGroupSelection deselect", () => { + it("should deselect group when all group options are selected", fakeAsync(() => { + host.items = [ + { id: 1, name: "A1", category: "A" }, + { id: 2, name: "A2", category: "A" }, + { id: 3, name: "B1", category: "B" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.groupBy = "category"; + host.multiple = true; + host.selectableGroups = true; + fixture.detectChanges(); + tick(); + + select.selectedValues.set([1, 2, 3]); + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const groupHeaders = document.querySelectorAll(".tedi-select__group-name--selectable"); + (groupHeaders[0] as HTMLElement).click(); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([3]); + })); + }); + + describe("Active option tracking", () => { + it("should show active state on navigated option", fakeAsync(() => { + host.multiple = true; + host.showSelectAll = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + })); + }); + + describe("deselect when disabled", () => { + it("should not deselect when component is disabled", fakeAsync(() => { + host.multiple = true; + host.clearableTags = true; + fixture.detectChanges(); + tick(); + + select.selectedValues.set(["Option 1", "Option 2"]); + fixture.detectChanges(); + tick(); + + select.setDisabledState(true); + fixture.detectChanges(); + tick(); + + const event = new Event("click"); + select.deselect(event, "Option 1"); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1", "Option 2"]); + })); + }); + + describe("handleValueChange", () => { + it("should handle group selection via listbox value change", fakeAsync(() => { + host.items = [ + { id: 1, name: "A1", category: "A" }, + { id: 2, name: "A2", category: "A" }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.groupBy = "category"; + host.multiple = true; + host.selectableGroups = true; + fixture.detectChanges(); + tick(); + + select.handleValueChange({ value: ["SELECT_GROUP_A"] }); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([1, 2]); + })); + }); + + describe("ArrowUp navigation", () => { + it("ArrowUp should navigate when open", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + })); + }); + + describe("Space key when dropdown is open", () => { + it("should allow typing space in search input when dropdown is open", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.isOpen()).toBe(true); + + const input = getSearchInput(); + const event = new KeyboardEvent("keydown", { key: " " }); + const preventDefaultSpy = jest.spyOn(event, "preventDefault"); + + input.dispatchEvent(event); + fixture.detectChanges(); + tick(); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + })); + }); + + describe("Wrapping navigation", () => { + it("should wrap navigation in listbox", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + })); + }); + + describe("Empty filtered options", () => { + it("should handle empty filtered options gracefully", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + select.searchTerm.set("xyz"); + fixture.detectChanges(); + tick(); + + expect(select.filteredOptions()).toEqual([]); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeFalsy(); + })); + }); + + describe("Recursive skip disabled", () => { + it("should skip multiple consecutive disabled options", fakeAsync(() => { + host.items = [ + { id: 1, name: "Enabled", disabled: false }, + { id: 2, name: "Disabled 1", disabled: true }, + { id: 3, name: "Disabled 2", disabled: true }, + { id: 4, name: "Also Enabled", disabled: false }, + ]; + host.bindLabel = "name"; + host.bindValue = "id"; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + const activeOption = document.querySelector(".cdk-option-active"); + expect(activeOption).toBeTruthy(); + expect(activeOption?.textContent).not.toContain("Disabled"); + })); + }); + }); + + describe("maxDropdownHeight", () => { + it("should use provided maxDropdownHeight value when set", fakeAsync(() => { + host.maxDropdownHeight = 200; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const dropdown = getDropdown(); + expect(dropdown.style.maxHeight).toBe("200px"); + })); + + it("should calculate maxHeight from viewport when maxDropdownHeight is not set", fakeAsync(() => { + host.maxDropdownHeight = undefined; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const dropdown = getDropdown(); + expect(dropdown.style.maxHeight).toBeTruthy(); + })); + }); + + describe("toggleIsOpen closing", () => { + it("should close dropdown when toggling an open non-searchable select", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + expect(select.isOpen()).toBe(true); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + expect(select.isOpen()).toBe(false); + })); + }); + + describe("search focus and blur", () => { + it("should set searchFocused to false and mark as touched on blur", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const searchInput = getSearchInput(); + searchInput.dispatchEvent(new Event("focus")); + fixture.detectChanges(); + expect(select.searchFocused()).toBe(true); + + searchInput.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + expect(select.searchFocused()).toBe(false); + })); + }); + + describe("template context guards", () => { + it("SelectOptionTemplateDirective ngTemplateContextGuard should return true", () => { + expect( + SelectOptionTemplateDirective.ngTemplateContextGuard({} as SelectOptionTemplateDirective, {}) + ).toBe(true); + }); + + it("SelectLabelTemplateDirective ngTemplateContextGuard should return true", () => { + expect( + SelectLabelTemplateDirective.ngTemplateContextGuard({} as SelectLabelTemplateDirective, {}) + ).toBe(true); + }); + + it("SelectValueTemplateDirective ngTemplateContextGuard should return true", () => { + expect( + SelectValueTemplateDirective.ngTemplateContextGuard({} as SelectValueTemplateDirective, {}) + ).toBe(true); + }); + }); + + describe("calculateVisibleTags", () => { + let clientWidthSpy: jest.SpyInstance; + let offsetWidthSpy: jest.SpyInstance; + + afterEach(() => { + clientWidthSpy?.mockRestore(); + offsetWidthSpy?.mockRestore(); + }); + + it("should calculate visible tags for single-row multiselect", fakeAsync(() => { + host.multiple = true; + host.multiRow = false; + host.clearableTags = true; + host.items = ["Tag 1", "Tag 2", "Tag 3", "Tag 4", "Tag 5"]; + host.control.setValue(["Tag 1", "Tag 2", "Tag 3", "Tag 4", "Tag 5"]); + + // Mock trigger clientWidth=300, arrow offsetWidth=24, each tag=60 + clientWidthSpy = jest.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) { + if (this.classList.contains("tedi-select__trigger")) return 300; + return 0; + }); + offsetWidthSpy = jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) { + if (this.classList.contains("tedi-select__arrow")) return 24; + if (this.tagName === "TEDI-TAG") return 60; + return 0; + }); + + fixture.detectChanges(); + tick(); + + expect(select.visibleTagsCount()).toBeGreaterThanOrEqual(1); + })); + + it("should show at least one tag even if none fit", fakeAsync(() => { + host.multiple = true; + host.multiRow = false; + host.clearableTags = true; + host.items = ["Tag 1", "Tag 2"]; + host.control.setValue(["Tag 1", "Tag 2"]); + + // Trigger very small, tags too wide + clientWidthSpy = jest.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (this: HTMLElement) { + if (this.classList.contains("tedi-select__trigger")) return 50; + return 0; + }); + offsetWidthSpy = jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) { + if (this.classList.contains("tedi-select__arrow")) return 24; + if (this.tagName === "TEDI-TAG") return 100; + return 0; + }); + + fixture.detectChanges(); + tick(); + + expect(select.visibleTagsCount()).toBe(1); + })); + }); +}); diff --git a/tedi/components/form/select/select.component.ts b/tedi/components/form/select/select.component.ts new file mode 100644 index 000000000..3ec96f8e1 --- /dev/null +++ b/tedi/components/form/select/select.component.ts @@ -0,0 +1,902 @@ +import { CdkOverlayOrigin, ConnectedPosition, OverlayModule } from "@angular/cdk/overlay"; +import { CdkListbox, CdkListboxModule } from "@angular/cdk/listbox"; +import { + AfterContentChecked, + AfterViewChecked, + ChangeDetectionStrategy, + Component, + contentChild, + effect, + ElementRef, + HostListener, + inject, + input, + NgZone, + signal, + viewChild, + viewChildren, + ViewEncapsulation, + forwardRef, + computed, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { IconComponent, TextComponent } from "../../base"; +import { ClosingButtonComponent } from "../../buttons"; +import { TediTranslationPipe } from "../../../services"; +import { ComponentInputs } from "../../../types"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; +import { LabelComponent } from "../label/label.component"; +import { TagComponent } from "../../tags/tag/tag.component"; +import { DropdownItemValueComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value.component"; +import { DropdownItemValueLabelComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component"; +import { + SelectOptionTemplateDirective, + SelectLabelTemplateDirective, + SelectValueTemplateDirective, + SelectOptionContext, + SelectValueContext, +} from "./select-templates.directive"; +import { InputSize, InputState } from "../form-field/form-field.component"; +export type SelectInputSize = Exclude; + +export interface SelectOption { + value: T; + label: string; + disabled?: boolean; + group?: string; + [key: string]: unknown; +} + +export interface SelectOptionGroup { + label: string; + options: SelectOption[]; +} + +export type GroupByFn = (item: T) => string | undefined; +export type CompareWithFn = (a: T, b: T) => boolean; + +export enum SpecialOptionControls { + SELECT_ALL = "SELECT_ALL", + SELECT_GROUP = "SELECT_GROUP_", +} + +@Component({ + selector: "tedi-select", + imports: [ + CommonModule, + OverlayModule, + CdkListboxModule, + ClosingButtonComponent, + IconComponent, + LabelComponent, + FeedbackTextComponent, + TextComponent, + TagComponent, + TediTranslationPipe, + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + ], + templateUrl: "./select.component.html", + styleUrl: "./select.component.scss", + standalone: true, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-select", + "[class.tedi-select--multiselect]": "multiple()", + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectComponent), + multi: true, + }, + ], +}) +export class SelectComponent implements AfterContentChecked, AfterViewChecked, ControlValueAccessor { + /** + * Unique identifier for the select input element. + * Used for label association and accessibility. + */ + inputId = input.required(); + + /** + * Label text displayed above the select. + */ + label = input(); + + /** + * Whether the field is required. + * @default false + */ + required = input(false); + + /** + * Placeholder text shown when no value is selected. + * @default "" + */ + placeholder = input(""); + + /** + * Visual state of the input. + * @default "default" + */ + state = input("default"); + + /** + * Size variant of the select. + * @default "default" + */ + size = input("default"); + + /** + * Whether to show a clear button when a value is selected. + * @default false + */ + clearable = input(false); + + /** + * Element reference used to determine dropdown width. + * When null, dropdown width matches the host element. + */ + dropdownWidthRef = input(); + + /** + * Configuration for the feedback text displayed below the select. + */ + feedbackText = input>(); + + /** + * Array of options to display in the dropdown. + * Can be an array of objects or primitive values. + * @default [] + */ + options = input([]); + + /** + * Property name to use as the display label for object items. + * @default "label" + */ + bindLabel = input("label"); + + /** + * Property name to use as the value for object items. + * When undefined, the entire object is used as the value. + */ + bindValue = input(undefined); + + /** + * Whether multiple items can be selected. + * @default false + */ + multiple = input(false); + + /** + * Property name or function used to group options. + * When a string, uses that property from the item. + * When a function, calls it with each item to determine the group. + */ + groupBy = input | undefined>(undefined); + + /** + * Whether to show a "Select All" option in multiselect mode. + * @default false + */ + showSelectAll = input(false); + + /** + * Whether group headers are selectable in multiselect mode. + * Clicking a group header selects/deselects all options in that group. + * @default false + */ + selectableGroups = input(false); + + /** + * Whether selected tags are individually removable in multiselect mode. + * @default false + */ + isTagRemovable = input(false); + + /** + * Whether selected tags wrap to multiple rows in multiselect mode. + * When false, overflow tags are hidden and a counter is shown. + * @default false + */ + multiRow = input(false); + + /** + * Function used to compare option values for equality. + * Used to determine which options are selected. + * @default (a, b) => a === b + */ + compareWith = input((a, b) => a === b); + + /** + * Property name to check for disabled state on items. + * @default "disabled" + */ + disabledKey = input("disabled"); + + /** + * Text displayed when no options match the search term. + */ + noOptionsMessage = input(); + + /** + * Maximum height of the dropdown menu in pixels. + * When not set, the dropdown height is calculated based on available viewport space. + */ + maxDropdownHeight = input(); + + /** + * Layout type for the dropdown options. + * - `"menu"` (default): vertical list of options. + * - `"grid"`: swatch grid layout for use with custom option templates + * (e.g. color or icon pickers). Customizable via `--tedi-swatch-size`, + * `--tedi-swatch-gap`, and `--tedi-swatch-columns` CSS properties. + * @default "menu" + */ + dropdownType = input<'menu' | 'grid'>('menu'); + + /** + * Whether the select has a search input for filtering options. + * @default false + */ + searchable = input(false); + + readonly SpecialOptionControls = SpecialOptionControls; + + readonly dropdownPositions: ConnectedPosition[] = [ + // Open below, expand downward + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + }, + // Fallback: open above, expand upward + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + }, + ]; + + listboxId = computed(() => this.inputId() + "-listbox"); + labelId = computed(() => this.inputId() + "-label"); + + isOpen = signal(false); + selectedValues = signal([]); + disabled = signal(false); + dropdownWidth = signal(null); + dropdownMaxHeight = signal(null); + visibleTagsCount = signal(null); + searchTerm = signal(""); + searchFocused = signal(false); + + hiddenTagsCount = computed(() => { + const visible = this.visibleTagsCount(); + const total = this.selectedValues().length; + if (visible === null || visible >= total) return 0; + return total - visible; + }); + + listboxRef = viewChild(CdkListbox, { read: ElementRef }); + cdkListboxRef = viewChild(CdkListbox); + triggerRef = viewChild(CdkOverlayOrigin, { read: ElementRef }); + searchInputRef = viewChild("searchInput"); + multiselectContainerRef = viewChild("multiselectContainer"); + tagRefs = viewChildren("tagElement", { read: ElementRef }); + hostRef = inject(ElementRef); + private ngZone = inject(NgZone); + + // Template queries for custom rendering + optionTemplate = contentChild(SelectOptionTemplateDirective); + labelTemplate = contentChild(SelectLabelTemplateDirective); + valueTemplate = contentChild(SelectValueTemplateDirective); + + normalizedOptions = computed[]>(() => { + const items = this.options(); + if (!items || items.length === 0) return []; + + return items.map((item) => { + if (typeof item === "string" || typeof item === "number") { + return { + value: item as unknown, + label: String(item), + disabled: false, + group: undefined, + } as SelectOption; + } + + const itemRecord = item as Record; + const bindLabel = this.bindLabel(); + const bindValue = this.bindValue(); + const disabledKey = this.disabledKey(); + const groupBy = this.groupBy(); + + const label = (itemRecord[bindLabel] as string) ?? String(item); + const value = bindValue ? itemRecord[bindValue] : item; + const disabled = !!itemRecord[disabledKey]; + let group: string | undefined; + + if (typeof groupBy === "string") { + group = itemRecord[groupBy] as string | undefined; + } else if (typeof groupBy === "function") { + group = groupBy(item); + } + + return { ...itemRecord, value, label, disabled, group } as SelectOption; + }); + }); + + filteredOptions = computed[]>(() => { + const options = this.normalizedOptions(); + const term = this.searchTerm().toLowerCase().trim(); + + if (!term) { + return options; + } + + return options.filter((option) => + option.label.toLowerCase().includes(term) + ); + }); + + optionGroups = computed[]>(() => { + const options = this.filteredOptions(); + const groups: SelectOptionGroup[] = []; + + options.forEach((option) => { + const groupLabel = option.group ?? ""; + const existingGroup = groups.find((g) => g.label === groupLabel); + + if (existingGroup) { + existingGroup.options.push(option); + } else { + groups.push({ label: groupLabel, options: [option] }); + } + }); + + return groups; + }); + + selectedOptions = computed[]>(() => { + const values = this.selectedValues(); + const options = this.normalizedOptions(); + const compareWith = this.compareWith(); + + return options.filter((option) => + values.some((val) => compareWith(option.value, val)) + ); + }); + + visibleSelectedValues = computed(() => { + const selected = this.selectedValues(); + const filtered = this.filteredOptions(); + const compareWith = this.compareWith(); + + return selected.filter((val) => + filtered.some((opt) => compareWith(opt.value, val)) + ); + }); + + selectedLabels = computed(() => { + return this.selectedOptions().map((option) => option.label); + }); + + allOptionsSelected = computed(() => { + const enabledOptions = this.normalizedOptions().filter((o) => !o.disabled); + const selected = this.selectedValues(); + const compareWith = this.compareWith(); + + return ( + enabledOptions.length > 0 && + enabledOptions.every((option) => + selected.some((val) => compareWith(option.value, val)) + ) + ); + }); + + ngAfterContentChecked(): void { + this.setDropdownWidth(); + } + + ngAfterViewChecked(): void { + if (this.multiple() && !this.multiRow()) { + this.calculateVisibleTags(); + } + } + + @HostListener("window:resize") + onWindowResize(): void { + this.setDropdownWidth(); + if (this.multiple() && !this.multiRow()) { + this.visibleTagsCount.set(null); + } + } + + @HostListener("document:click", ["$event"]) + onDocumentClick(event: MouseEvent): void { + if (!this.isOpen()) return; + + const target = event.target as HTMLElement; + const hostElement = this.hostRef.nativeElement; + const listboxElement = this.listboxRef()?.nativeElement; + + const clickedInside = hostElement.contains(target) || listboxElement?.contains(target); + + if (!clickedInside) { + this.isOpen.set(false); + this.searchTerm.set(""); + } + } + + focusListboxWhenVisible = effect(() => { + if (this.isOpen() && this.searchable() && this.searchInputRef()) { + this.searchInputRef()?.nativeElement.focus(); + } else if (this.isOpen() && this.listboxRef() && !this.searchable()) { + this.listboxRef()?.nativeElement.focus(); + } + }); + + constructor() { + /** + * Prevent CDK from resetting active item to the first selected option + * whenever [cdkListboxValue] changes in multiselect mode. CDK calls + * _setNextFocusToSelectedOption inside _setSelection, which moves focus + * away from the user's current position after each selection toggle. + */ + effect(() => { + const listbox = this.cdkListboxRef(); + if (listbox && this.multiple()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (listbox as any)._setNextFocusToSelectedOption = () => { }; + } + }); + } + + resetVisibleTagsOnSelectionChange = effect(() => { + this.selectedValues(); + this.visibleTagsCount.set(null); + }); + + toggleIsOpen(close?: boolean): void { + if (this.disabled()) return; + + if (close) { + this.closeDropdown(); + this.focusTrigger(); + } else { + const willOpen = !this.isOpen(); + if (willOpen) { + this.openDropdown(); + } else { + this.dropdownMaxHeight.set(null); + this.isOpen.set(false); + } + } + } + + onSearchInput(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchTerm.set(input.value); + + if (!this.isOpen()) { + this.openDropdown(); + } + } + + onSearchFocus(): void { + this.searchFocused.set(true); + } + + onSearchBlur(): void { + this.searchFocused.set(false); + this.onTouched(); + } + + onTriggerClick(): void { + if (this.searchable()) { + this.searchInputRef()?.nativeElement.focus(); + if (!this.isOpen()) { + this.openDropdown(); + } + } else { + this.toggleIsOpen(); + } + } + + onTriggerEnter(): void { + if (!this.isOpen()) { + this.openDropdown(); + } + } + + onSearchKeydown(event: KeyboardEvent): void { + event.stopPropagation(); + + switch (event.key) { + case "ArrowDown": + case "ArrowUp": + case "Home": + case "End": + event.preventDefault(); + if (this.isOpen()) { + this.forwardToCdkListbox(event.key); + } else { + this.openDropdown(); + } + break; + case "Enter": + event.preventDefault(); + if (this.isOpen()) { + this.forwardToCdkListbox(event.key); + } else { + this.openDropdown(); + } + break; + case " ": + if (!this.isOpen()) { + event.preventDefault(); + this.openDropdown(); + } + break; + case "Escape": + case "Tab": + if (this.isOpen()) { + this.closeDropdown(); + } + break; + } + } + + private static readonly KEY_CODES: Record = { + ArrowDown: 40, + ArrowUp: 38, + Home: 36, + End: 35, + Enter: 13, + " ": 32, + }; + + private forwardToCdkListbox(key: string): void { + const listbox = this.cdkListboxRef(); + if (listbox) { + const keyCode = SelectComponent.KEY_CODES[key] ?? 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (listbox as any)._handleKeydown(new KeyboardEvent("keydown", { key, keyCode, bubbles: true })); + } + } + + private openDropdown(): void { + this.calculateDropdownMaxHeight(); + this.isOpen.set(true); + } + + private calculateDropdownMaxHeight(): void { + const inputMaxHeight = this.maxDropdownHeight(); + + if (inputMaxHeight != null) { + this.dropdownMaxHeight.set(inputMaxHeight); + return; + } + + const trigger = this.triggerRef()?.nativeElement; + if (!trigger) { + this.dropdownMaxHeight.set(null); + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const margin = 16; // Margin from viewport edges + + // Calculate space below and above the trigger + const spaceBelow = viewportHeight - triggerRect.bottom - margin; + const spaceAbove = triggerRect.top - margin; + + // Use the larger available space + const maxHeight = Math.max(spaceBelow, spaceAbove); + + this.dropdownMaxHeight.set(maxHeight); + } + + private closeDropdown(): void { + this.isOpen.set(false); + this.searchTerm.set(""); + this.dropdownMaxHeight.set(null); + } + + handleValueChange(event: { value: readonly unknown[] }): void { + const values = event.value; + + const selectAllIndex = values.findIndex( + (v) => v === SpecialOptionControls.SELECT_ALL + ); + const selectGroupValue = values.find( + (v) => + typeof v === "string" && + v.startsWith(SpecialOptionControls.SELECT_GROUP) + ); + + if (selectAllIndex !== -1) { + this.toggleSelectAll(); + return; + } + + if (selectGroupValue) { + const groupLabel = (selectGroupValue as string).replace( + SpecialOptionControls.SELECT_GROUP, + "" + ); + this.toggleGroupSelection(groupLabel); + return; + } + + if (this.multiple()) { + let newSelection: unknown[]; + if (this.searchable() && this.searchTerm().trim()) { + const filtered = this.filteredOptions(); + const compareWith = this.compareWith(); + const hiddenSelected = this.selectedValues().filter( + (val) => !filtered.some((opt) => compareWith(opt.value, val)) + ); + newSelection = [...hiddenSelected, ...values]; + } else { + newSelection = [...values]; + } + this.selectedValues.set(newSelection); + this.onChange(newSelection); + this.searchTerm.set(""); + if (this.searchable()) { + this.searchInputRef()?.nativeElement.focus(); + } + } else { + const selected = values[0] ?? null; + this.selectedValues.set(selected ? [selected] : []); + this.onChange(selected); + this.toggleIsOpen(true); + } + + this.onTouched(); + } + + clear(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + this.selectedValues.set([]); + if (this.multiple()) { + this.onChange([]); + } else { + this.onChange(null); + } + this.onTouched(); + this.focusTrigger(); + } + + deselect(event: Event, value: unknown): void { + event.stopPropagation(); + event.preventDefault(); + + if (this.disabled()) return; + + const compareWith = this.compareWith(); + const newSelection = this.selectedValues().filter( + (v) => !compareWith(v, value) + ); + + this.selectedValues.set(newSelection); + this.onChange(newSelection); + this.onTouched(); + } + + isOptionSelected(optionValue: unknown): boolean { + const compareWith = this.compareWith(); + return this.selectedValues().some((val) => compareWith(val, optionValue)); + } + + getLabel(value: unknown): string { + const compareWith = this.compareWith(); + const option = this.normalizedOptions().find((o) => + compareWith(o.value, value) + ); + return option?.label ?? String(value); + } + + /** + * Get the original item from the items array for a given option. + * Used for custom templates that need access to the full item data. + */ + getOriginalItem(option: SelectOption): T { + const compareWith = this.compareWith(); + const bindValue = this.bindValue(); + + if (!bindValue) { + return option.value as T; + } + + // Find the original item by matching the value + const items = this.options(); + const found = items.find((item) => { + const itemRecord = item as Record; + return compareWith(itemRecord[bindValue], option.value); + }); + + return found ?? (option as unknown as T); + } + + /** Create context object for custom option templates. */ + getOptionContext(option: SelectOption, index: number): SelectOptionContext { + const item = this.getOriginalItem(option); + return { + $implicit: item, + item, + index, + selected: this.isOptionSelected(option.value), + disabled: option.disabled ?? false, + }; + } + + /** Create context object for custom value templates. */ + getValueContext(option: SelectOption): SelectValueContext { + const item = this.getOriginalItem(option); + return { $implicit: item, item, label: option.label }; + } + + isGroupSelected(groupLabel: string): boolean { + const group = this.optionGroups().find((g) => g.label === groupLabel); + if (!group) return false; + + const enabledGroupOptions = group.options.filter((o) => !o.disabled); + if (enabledGroupOptions.length === 0) return false; + + const compareWith = this.compareWith(); + const selected = this.selectedValues(); + + return enabledGroupOptions.every((option) => + selected.some((val) => compareWith(option.value, val)) + ); + } + + private focusTrigger(): void { + if (this.searchable()) { + this.searchInputRef()?.nativeElement.focus(); + } else { + this.triggerRef()?.nativeElement.focus(); + } + } + + private getAvailableTagWidth(): number { + const trigger = this.triggerRef()?.nativeElement; + if (!trigger) return 0; + + const triggerWidth = trigger.clientWidth; + if (triggerWidth === 0) return 0; + + const triggerStyle = getComputedStyle(trigger); + const padding = (parseFloat(triggerStyle.paddingLeft) || 0) + (parseFloat(triggerStyle.paddingRight) || 0); + + let nonTagWidth = 0; + const arrow: HTMLElement | null = trigger.querySelector(".tedi-select__arrow"); + const clear: HTMLElement | null = trigger.querySelector(".tedi-select__clear"); + const searchInput: HTMLElement | null = trigger.querySelector(".tedi-select__search-input"); + if (arrow) nonTagWidth += arrow.offsetWidth + (parseFloat(getComputedStyle(arrow).marginLeft) || 0) + (parseFloat(getComputedStyle(arrow).paddingLeft) || 0); + if (clear) nonTagWidth += clear.offsetWidth; + if (searchInput) nonTagWidth += parseFloat(getComputedStyle(searchInput).minWidth) || 0; + + return triggerWidth - padding - nonTagWidth; + } + + private calculateVisibleTags(): void { + const tags = this.tagRefs(); + if (tags.length === 0 || this.visibleTagsCount() !== null) return; + + const availableWidth = this.getAvailableTagWidth(); + if (availableWidth <= 0) return; + + const gap = 8; + const counterTagWidth = 40; + let usedWidth = 0; + let visibleCount = 0; + + for (let i = 0; i < tags.length; i++) { + const tagWidth = tags[i].nativeElement.offsetWidth; + const spaceNeeded = usedWidth + tagWidth + (visibleCount > 0 ? gap : 0); + const hasMoreItems = i < tags.length - 1; + const reservedSpace = hasMoreItems ? counterTagWidth + gap : 0; + + if (spaceNeeded + reservedSpace <= availableWidth) { + usedWidth = spaceNeeded; + visibleCount++; + } else { + break; + } + } + + if (visibleCount === 0 && tags.length > 0) { + visibleCount = 1; + } + + this.ngZone.run(() => { + this.visibleTagsCount.set(visibleCount); + }); + } + + private setDropdownWidth(): void { + const widthRef = this.dropdownWidthRef(); + if (widthRef === null) { + this.dropdownWidth.set(null); + return; + } + + const element = widthRef?.nativeElement ?? this.hostRef?.nativeElement; + const computedWidth = element?.getBoundingClientRect()?.width ?? 0; + this.dropdownWidth.set(computedWidth); + } + + private toggleSelectAll(): void { + const enabledOptions = this.normalizedOptions().filter((o) => !o.disabled); + + if (this.allOptionsSelected()) { + this.selectedValues.set([]); + this.onChange([]); + } else { + const allValues = enabledOptions.map((o) => o.value); + this.selectedValues.set(allValues); + this.onChange(allValues); + } + } + + private toggleGroupSelection(groupLabel: string): void { + const group = this.optionGroups().find((g) => g.label === groupLabel); + if (!group) return; + + const enabledGroupOptions = group.options.filter((o) => !o.disabled); + const groupValues = enabledGroupOptions.map((o) => o.value); + const isGroupSelected = this.isGroupSelected(groupLabel); + const compareWith = this.compareWith(); + + let newSelection: unknown[]; + + if (isGroupSelected) { + newSelection = this.selectedValues().filter( + (val) => !groupValues.some((gv) => compareWith(val, gv)) + ); + } else { + const currentSelected = new Set(this.selectedValues()); + groupValues.forEach((val) => currentSelected.add(val)); + newSelection = Array.from(currentSelected); + } + + this.selectedValues.set(newSelection); + this.onChange(newSelection); + } + + onChange: (value: unknown) => void = () => { }; + onTouched: () => void = () => { }; + + writeValue(value: unknown): void { + if (this.multiple()) { + this.selectedValues.set(Array.isArray(value) ? value : []); + } else { + this.selectedValues.set(value != null ? [value] : []); + } + } + + registerOnChange(fn: (value: unknown) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } +} diff --git a/tedi/components/form/select/select.stories.ts b/tedi/components/form/select/select.stories.ts new file mode 100644 index 000000000..53dcb65f8 --- /dev/null +++ b/tedi/components/form/select/select.stories.ts @@ -0,0 +1,856 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { JsonPipe } from "@angular/common"; +import { + FormGroup, + FormControl, + FormsModule, + ReactiveFormsModule, +} from "@angular/forms"; +import { SelectComponent } from "./select.component"; +import { + SelectOptionTemplateDirective, + SelectValueTemplateDirective, +} from "./select-templates.directive"; +import { IconComponent } from "../../base"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { + TextGroupComponent, + TextGroupLabelComponent, + TextGroupValueComponent, +} from "../../content/text-group"; +import { DropdownItemValueComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value.component"; +import { DropdownItemValueLabelComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component"; +import { DropdownItemValueMetaComponent } from "../../overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component"; +import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/vertical-spacing.directive"; +import { Component, inject } from "@angular/core"; +import { ToastService } from "../../../services/toast/toast.service"; + +/** + * Figma ↗
    + * Zeroheight ↗ + */ + +const simpleOptions = [ + { value: "tallinn", label: "Tallinn" }, + { value: "narva", label: "Narva" }, + { value: "tartu", label: "Tartu", disabled: true }, + { value: "elva", label: "Elva" }, + { value: "rakvere", label: "Rakvere" }, + { value: "haapsalu", label: "Haapsalu" }, +]; + +const meta: Meta = { + title: "TEDI-Ready/Components/Form/Select", + component: SelectComponent, + decorators: [ + moduleMetadata({ + imports: [ + SelectComponent, + SelectOptionTemplateDirective, + SelectValueTemplateDirective, + FormsModule, + ReactiveFormsModule, + JsonPipe, + TextGroupComponent, + TextGroupLabelComponent, + TextGroupValueComponent, + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + DropdownItemValueMetaComponent, + IconComponent, + ButtonComponent, + VerticalSpacingDirective, + ], + }), + ], + argTypes: { + inputId: { control: "text" }, + label: { control: "text" }, + required: { control: "boolean" }, + placeholder: { control: "text" }, + state: { control: "radio", options: ["error", "valid", "default"] }, + size: { control: "radio", options: ["small", "default"] }, + clearable: { control: "boolean" }, + multiple: { control: "boolean" }, + showSelectAll: { control: "boolean" }, + selectableGroups: { control: "boolean" }, + isTagRemovable: { control: "boolean" }, + multiRow: { control: "boolean" }, + searchable: { control: "boolean" }, + dropdownType: { + control: "radio", + options: ["menu", "grid"], + description: "Use \"grid\" for swatch-type selects with custom option templates (e.g. color or icon pickers).", + }, + options: { control: "object" }, + maxDropdownHeight: { + control: "number", + description: "Value in pixels. When not set, fits available viewport space.", + }, + }, + args: { + inputId: "select-1", + label: "Label", + required: false, + placeholder: "Select an option...", + state: "default", + size: "default", + clearable: false, + multiple: false, + showSelectAll: false, + selectableGroups: false, + isTagRemovable: false, + multiRow: false, + searchable: false, + dropdownType: "menu", + maxDropdownHeight: undefined, + options: simpleOptions as [], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), +}; + +export const Size: Story = { + render: () => ({ + props: { + options: simpleOptions, + }, + template: ` +
    + + +
    + `, + }), +}; + +export const Type: Story = { + render: () => ({ + props: { + options: simpleOptions, + feedbackText: { + type: "hint", + text: "Hint text", + position: "left", + }, + }, + template: ` +
    + + +
    + `, + }), +}; + +export const States: Story = { + parameters: { + pseudo: { + hover: "#states-hover .tedi-input", + focus: "#states-focus .tedi-input", + active: "#states-active .tedi-input", + }, + }, + render: () => ({ + props: { + options: simpleOptions, + errorFeedback: { type: "error", text: "Error text" }, + validFeedback: { type: "valid", text: "Valid text" }, + disabledControl: new FormControl({ value: "tallinn", disabled: true }), + }, + template: ` +
    + +
    + +
    +
    + +
    +
    + +
    + + + +
    + `, + }), +}; + +// ============ Value type ============ + +export const ValueType: Story = { + render: () => ({ + props: { + options: simpleOptions, + multiselectOptions: [ + "Tag 1", + "Tag 2", + "Tag 3", + "Tag 4", + "Tag 5", + "Tag 6", + "Tag 7", + "Tag 8", + "Tag 9", + "Tag 10", + ], + oneRowOptions: [ + "Longer text", + "Longer text on one row", + "Third option", + "Fourth option", + "Fifth option", + ], + colorOptions: [ + { id: 1, name: "Transparent", color: "transparent" }, + { id: 2, name: "White", color: "#ffffff" }, + { id: 3, name: "Red", color: "#f42a25" }, + { id: 4, name: "Magenta", color: "#e81e63" }, + { id: 5, name: "Purple", color: "#b21f7e" }, + { id: 6, name: "Violet", color: "#673ab7" }, + { id: 7, name: "Indigo", color: "#3f51b5" }, + { id: 8, name: "Blue", color: "#3f88c5" }, + { id: 9, name: "Light blue", color: "#03a9f3" }, + { id: 10, name: "Cyan", color: "#00bcd3" }, + { id: 11, name: "Teal", color: "#009688" }, + { id: 12, name: "Green", color: "#4caf50" }, + { id: 13, name: "Light green", color: "#8bc24a" }, + { id: 14, name: "Lime", color: "#ccdb39" }, + { id: 15, name: "Yellow", color: "#f2d611" }, + { id: 16, name: "Amber", color: "#ffc107" }, + { id: 17, name: "Orange", color: "#ff9800" }, + { id: 18, name: "Deep orange", color: "#ff5722" }, + { id: 19, name: "Grey", color: "#9e9e9e" }, + { id: 20, name: "Blue grey", color: "#607d8b" }, + { id: 21, name: "Brown", color: "#795548" }, + { id: 22, name: "Black", color: "#0d0d0d" }, + ], + iconOptions: [ + { id: 1, name: "Desktop", icon: "computer" }, + { id: 2, name: "Phone", icon: "smartphone" }, + { id: 3, name: "Tablet", icon: "tablet_mac" }, + { id: 4, name: "Watch", icon: "watch" }, + { id: 5, name: "TV", icon: "tv" }, + ], + form: new FormGroup({ + default: new FormControl("tallinn"), + multiselect: new FormControl([ + "Tag 1", + "Tag 2", + "Tag 3", + "Tag 4", + "Tag 5", + "Tag 6", + "Tag 7", + "Tag 8", + "Tag 9", + "Tag 10", + ]), + oneRow: new FormControl([ + "Longer text", + "Longer text on one row", + "Third option", + "Fourth option", + "Fifth option", + ]), + color: new FormControl(1), + icon: new FormControl(1), + }), + }, + template: ` +
    + + + + + +
    + + +
    +
    + +
    +
    +
    +
    +
    + + + + + + + + +
    + + `, + }), +}; + +export const Examples: Story = { + render: () => ({ + props: { + // Example 1 - Multiselect with Select All + selectAllOptions: [ + { id: 1, name: "Locations" }, + { id: 2, name: "Doctors" }, + { id: 3, name: "Hospitals" }, + ], + // Example 2 - Scrollable list + scrollableOptions: [ + "Emergency department", + "Internal medicine", + "Cardiology", + "Neurology", + "Orthopedics", + "Pediatrics", + "Psychiatry", + "Radiology", + "Surgery", + "Urology", + "Dermatology", + "Oncology", + "Gastroenterology", + "Pulmonology", + "Nephrology", + "Endocrinology", + "Rheumatology", + "Infectious diseases", + "Hematology", + "Allergy and immunology", + "Geriatrics", + "Neonatology", + "Palliative care", + "Physical medicine", + "Anesthesiology", + "Pathology", + "Nuclear medicine", + "Ophthalmology", + "Otolaryngology", + "Plastic surgery", + "Vascular surgery", + "Thoracic surgery", + "Colorectal surgery", + "Trauma surgery", + "Gynecology", + "Obstetrics", + "Reproductive medicine", + "Sports medicine", + "Pain management", + "Sleep medicine", + "Critical care", + ], + // Example 3 & 5 & 6 - Grouped options + groupedOptions: [ + { id: 1, name: "Emergency department", category: "Emergency" }, + { id: 2, name: "Urgent care", category: "Emergency" }, + { id: 3, name: "Internal medicine", category: "Internal" }, + { id: 4, name: "Cardiology", category: "Internal" }, + { id: 5, name: "Neurology", category: "Internal" }, + { id: 6, name: "General surgery", category: "Surgery" }, + { id: 7, name: "Orthopedic surgery", category: "Surgery" }, + { id: 8, name: "Neurosurgery", category: "Surgery" }, + ], + // Example 4 - Options with descriptions + descriptionOptions: [ + { + id: 1, + title: "Access to health data", + description: "Doctors will be able to see your health data", + }, + { + id: 2, + title: "Access to medications and health data", + description: + "Doctors will be able to see your medications and health data", + }, + { + id: 3, + title: "Access to all", + description: "Doctors will be able to see all your information", + }, + ], + // Example 7 - Options with horizontal meta + metaOptions: [ + { id: 1, name: "Tallinn", slots: 3 }, + { id: 2, name: "Tartu", slots: 4 }, + { id: 3, name: "Elva", slots: 7 }, + { id: 4, name: "Pärnu", slots: 2 }, + { id: 5, name: "Narva", slots: 5 }, + ], + // Multiselect with custom templates + permissionOptions: [ + { + id: 1, + title: "Read permissions", + description: "Can view documents and files", + }, + { + id: 2, + title: "Write permissions", + description: "Can create and edit documents", + }, + { + id: 3, + title: "Admin permissions", + description: "Full access to all features", + }, + ], + }, + template: ` +
    + + + + + + + + + {{ item.title }} + {{ item.description }} + + + + + + + + + + {{ item.name }} + {{ item.slots }} timeslots available + + + + + + + {{ item.title }} + + + + + + + {{ item.title }} + {{ item.description }} + + + +
    + `, + }), +}; + +@Component({ + selector: "storybook-select-reactive-forms-demo", + standalone: true, + imports: [ + SelectComponent, + SelectOptionTemplateDirective, + ReactiveFormsModule, + ButtonComponent, + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + DropdownItemValueMetaComponent, + VerticalSpacingDirective, + ], + template: ` +
    + + + + {{ item.name }} + {{ item.slots }} slots + + + + + + + + {{ item.title }} + {{ item.description }} + + + + + + + + {{ item.title }} + {{ item.description }} + + + + + +
    + `, +}) +class SelectReactiveFormsDemoComponent { + private readonly toastService = inject(ToastService); + + locationOptions = [ + { id: 1, name: "Tallinn", slots: 3 }, + { id: 2, name: "Tartu", slots: 5 }, + { id: 3, name: "Pärnu", slots: 2 }, + { id: 4, name: "Narva", slots: 4 }, + ]; + + accessOptions = [ + { id: 1, title: "Health data", description: "Access to health records" }, + { id: 2, title: "Medications", description: "Access to medication history" }, + { id: 3, title: "Lab results", description: "Access to laboratory results" }, + ]; + + permissionOptions = [ + { id: 1, title: "Read", description: "Can view documents" }, + { id: 2, title: "Write", description: "Can create and edit" }, + { id: 3, title: "Admin", description: "Full access" }, + ]; + + form = new FormGroup({ + location: new FormControl(1), + access: new FormControl(2), + permissions: new FormControl([1, 2]), + }); + + onSubmit(): void { + this.toastService.success("Success", "Form submitted successfully"); + } +} + +export const ReactiveForms: Story = { + render: () => ({ + moduleMetadata: { + imports: [SelectReactiveFormsDemoComponent], + }, + template: ``, + }), +}; diff --git a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts index 0d38ebf70..6e21c3b68 100644 --- a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts +++ b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts @@ -1,13 +1,15 @@ import { ChangeDetectionStrategy, Component, + computed, contentChildren, + forwardRef, inject, input, ViewEncapsulation, } from "@angular/core"; import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; -import { DropdownComponent } from "../dropdown.component"; +import { DROPDOWN_API, DROPDOWN_CONTENT_API } from "../dropdown.tokens"; export type DropdownRole = "menu" | "listbox"; @@ -20,8 +22,14 @@ export type DropdownRole = "menu" | "listbox"; changeDetection: ChangeDetectionStrategy.OnPush, host: { role: "presentation", - "[attr.aria-labelledby]": "dropdown.containerId() + '_trigger'", + "[attr.aria-labelledby]": "containerId() + '_trigger'", }, + providers: [ + { + provide: DROPDOWN_CONTENT_API, + useExisting: forwardRef(() => DropdownContentComponent), + }, + ], }) export class DropdownContentComponent { /** @@ -30,6 +38,7 @@ export class DropdownContentComponent { */ readonly dropdownRole = input("menu"); - readonly dropdown = inject(DropdownComponent); + private readonly dropdownApi = inject(DROPDOWN_API); + readonly containerId = computed(() => this.dropdownApi.containerId()); readonly items = contentChildren(DropdownItemComponent); } diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component.ts b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component.ts new file mode 100644 index 000000000..b880559c2 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component.ts @@ -0,0 +1,17 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + selector: "tedi-dropdown-item-value-label", + template: ``, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-dropdown-item-value__label", + }, +}) +export class DropdownItemValueLabelComponent {} diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component.ts b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component.ts new file mode 100644 index 000000000..7b9b28d3f --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component.ts @@ -0,0 +1,17 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + selector: "tedi-dropdown-item-value-meta", + template: ``, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-dropdown-item-value__meta", + }, +}) +export class DropdownItemValueMetaComponent {} diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html new file mode 100644 index 000000000..2aa9e5d09 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html @@ -0,0 +1,24 @@ +@if (type() === 'checkbox') { + +} @else if (type() === 'radio') { + +} + +
    + + +
    + diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss new file mode 100644 index 000000000..07d88ca8f --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss @@ -0,0 +1,145 @@ +.tedi-dropdown-item-value { + display: flex; + gap: var(--dropdown-item-inner-spacing); + align-items: center; + width: 100%; + + &--radio { + gap: var(--form-checkbox-radio-inner-spacing); + } + + &__checkbox, + &__radio { + flex-shrink: 0; + pointer-events: none; + } + + &__radio { + 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: var(--borders-01) solid var(--form-checkbox-radio-default-border-default); + border-radius: var(--form-checkbox-radio-indicator-radius-radio); + + &:checked { + background-color: var(--form-checkbox-radio-default-background-default); + border-color: var(--form-checkbox-radio-default-border-selected); + + &::before { + position: absolute; + top: 50%; + left: 50%; + width: calc(var(--form-checkbox-radio-size-responsive) * 0.625); + height: calc(var(--form-checkbox-radio-size-responsive) * 0.625); + content: ""; + background: var(--form-checkbox-radio-default-border-selected); + border-radius: 50%; + transform: translate(-50%, -50%); + } + } + + &:disabled { + cursor: not-allowed; + background-color: var(--form-input-background-disabled); + border-color: var(--form-input-border-disabled); + + &:checked { + background-color: var(--form-checkbox-radio-default-background-default); + border-color: var(--form-checkbox-radio-default-border-selected-disabled); + + &::before { + background: var(--form-checkbox-radio-default-border-selected-disabled); + } + } + } + } + + &__content { + display: flex; + flex: 1 0 0; + min-width: 0; + } + + &--horizontal &__content { + flex-direction: row; + gap: var(--dropdown-item-inner-spacing); + align-items: center; + justify-content: space-between; + } + + &--vertical &__content { + flex-direction: column; + align-items: flex-start; + } + + &__label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--dropdown-item-default-text); + } + + &__meta { + flex-shrink: 0; + font-size: var(--body-small-regular-size); + line-height: var(--body-small-regular-line-height); + color: var(--general-text-tertiary); + } + + &--vertical &__meta { + flex-shrink: 1; + } +} + +li[tedi-dropdown-item], +.tedi-dropdown-item { + + &[aria-selected="true"], + &.tedi-dropdown-item--selected { + + .tedi-dropdown-item-value__label, + .tedi-dropdown-item-value__meta { + color: inherit; + } + } + + &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { + + .tedi-dropdown-item-value__label, + .tedi-dropdown-item-value__meta { + color: inherit; + } + } + + &:has(.tedi-dropdown-item-value--checkbox, .tedi-dropdown-item-value--radio) { + + &[aria-selected="true"], + &.tedi-dropdown-item--selected { + color: var(--dropdown-item-default-text); + background: var(--dropdown-item-default-background); + + &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { + color: var(--dropdown-item-hover-text); + background: var(--dropdown-item-hover-background); + } + } + } + + &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { + + .tedi-dropdown-item-value__radio { + background-color: var(--form-checkbox-radio-default-background-hover); + border-color: var(--form-checkbox-radio-default-border-hover); + border-width: var(--borders-02); + } + } +} diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts new file mode 100644 index 000000000..f4730c3a3 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + input, + ViewEncapsulation, +} from "@angular/core"; +import { CheckboxComponent } from "../../../form/checkbox/checkbox.component"; + +export type DropdownItemValueType = "default" | "checkbox" | "radio"; +export type DropdownItemValueLayout = "horizontal" | "vertical"; + +@Component({ + selector: "tedi-dropdown-item-value", + templateUrl: "./dropdown-item-value.component.html", + styleUrl: "./dropdown-item-value.component.scss", + standalone: true, + imports: [CheckboxComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-dropdown-item-value", + "[class.tedi-dropdown-item-value--vertical]": "layout() === 'vertical'", + "[class.tedi-dropdown-item-value--horizontal]": "layout() === 'horizontal'", + "[class.tedi-dropdown-item-value--checkbox]": "type() === 'checkbox'", + "[class.tedi-dropdown-item-value--radio]": "type() === 'radio'", + }, +}) +export class DropdownItemValueComponent { + /** + * Type of item value - controls selection indicator + * - 'default': No selection indicator + * - 'checkbox': Shows checkbox (for multiselect) + * - 'radio': Shows radio button (for single select listbox) + * @default 'default' + */ + readonly type = input("default"); + + /** + * Layout: 'horizontal' (side-by-side) or 'vertical' (stacked) + * @default 'horizontal' + */ + readonly layout = input("horizontal"); + + /** + * Whether the item is selected (controls checkbox/radio state) + * @default false + */ + readonly selected = input(false); + + /** + * Whether the item is disabled + * @default false + */ + readonly disabled = input(false); +} diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts new file mode 100644 index 000000000..61c84c5e6 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts @@ -0,0 +1,304 @@ +import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; +import { DropdownItemValueComponent } from "./dropdown-item-value.component"; +import { DropdownItemValueLabelComponent } from "./dropdown-item-value-label.component"; +import { DropdownItemValueMetaComponent } from "./dropdown-item-value-meta.component"; +import { IconComponent } from "../../../base"; +import { VerticalSpacingDirective } from "../../../../directives/vertical-spacing/vertical-spacing.directive"; + +/** + * The DropdownItemValue component provides a reusable structure for rendering option content + * in both Select and Dropdown components. It supports built-in checkbox/radio indicators + * and flexible layouts for label and meta text. + * + * ## Usage + * + * Use this component inside dropdown items or select options to render structured content + * with optional selection indicators. + * + * ### Basic usage + * ```html + * + * Option 1 + * + * ``` + * + * ### With meta text + * ```html + * + * Tallinn + * 3 timeslots + * + * ``` + * + * ### With checkbox (multiselect) + * ```html + * + * Option 1 + * + * ``` + */ + +export default { + title: "TEDI-Ready/Components/Overlay/DropdownItemValue", + component: DropdownItemValueComponent, + decorators: [ + moduleMetadata({ + imports: [ + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + DropdownItemValueMetaComponent, + IconComponent, + VerticalSpacingDirective, + ], + }), + ], + argTypes: { + type: { + control: "radio", + options: ["default", "checkbox", "radio"], + description: "Type of selection indicator", + table: { + type: { summary: "DropdownItemValueType" }, + defaultValue: { summary: "default" }, + }, + }, + layout: { + control: "radio", + options: ["horizontal", "vertical"], + description: "Layout of label and meta content", + table: { + type: { summary: "DropdownItemValueLayout" }, + defaultValue: { summary: "horizontal" }, + }, + }, + selected: { + control: "boolean", + description: "Whether the item is selected (controls checkbox/radio state)", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disabled: { + control: "boolean", + description: "Whether the item is disabled", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + }, + args: { + type: "default", + layout: "horizontal", + selected: false, + disabled: false, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + + Option 1 + + `, + }), +}; + +export const WithMeta: Story = { + name: "With Meta (Horizontal)", + render: () => ({ + template: ` + + Tallinn + 3 timeslots available + + `, + }), +}; + +export const Vertical: Story = { + name: "Vertical Layout", + render: () => ({ + template: ` + + Access to health data + Doctors will be able to see your health data + + `, + }), +}; + +export const WithCheckbox: Story = { + name: "With Checkbox", + render: () => ({ + props: { + selected: false, + }, + template: ` +
    + + Unchecked option + + + Checked option + + + Disabled option + + + Disabled checked option + +
    + `, + }), +}; + +export const WithRadio: Story = { + name: "With Radio", + render: () => ({ + template: ` +
    + + Unselected option + + + Selected option + + + Disabled option + + + Disabled selected option + +
    + `, + }), +}; + +export const WithIcon: Story = { + name: "With Leading Icon", + render: () => ({ + template: ` +
    + + + Desktop + + + + Phone + + + + Tablet + +
    + `, + }), +}; + +export const WithIconAndMeta: Story = { + name: "With Icon and Meta", + render: () => ({ + template: ` +
    + + + Tallinn + 3 timeslots + + + + Tartu + 5 timeslots + +
    + `, + }), +}; + +export const CheckboxWithMeta: Story = { + name: "Checkbox with Meta (Vertical)", + render: () => ({ + template: ` +
    + + Access to health data + Doctors will be able to see your health data + + + Access to medications + Doctors will be able to see your medications + +
    + `, + }), +}; + +export const AllVariants: Story = { + name: "All Variants", + render: () => ({ + template: ` +
    +
    + Default (Label only) + + Option 1 + +
    + +
    + Horizontal (Label + Meta) + + Tallinn + 3 timeslots available + +
    + +
    + Vertical (Label + Description) + + Access to health data + Doctors will be able to see your health data + +
    + +
    + With Checkbox (Multiselect) + + Selected option + +
    + +
    + With Radio (Single select) + + Selected option + +
    + +
    + With Leading Icon + + + Desktop + +
    + +
    + Full Example (Checkbox + Icon + Vertical) + + + Admin permissions + Full access to all features and settings + +
    +
    + `, + }), +}; diff --git a/tedi/components/overlay/dropdown/dropdown-item-value/index.ts b/tedi/components/overlay/dropdown/dropdown-item-value/index.ts new file mode 100644 index 000000000..d0f3889c3 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/index.ts @@ -0,0 +1,3 @@ +export * from "./dropdown-item-value.component"; +export * from "./dropdown-item-value-label.component"; +export * from "./dropdown-item-value-meta.component"; diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.html b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.html new file mode 100644 index 000000000..365e0b586 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.html @@ -0,0 +1,9 @@ + + +@if (!customItemValue()) { + + + + + +} diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss index 946015a5e..5803c5125 100644 --- a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss @@ -1,4 +1,4 @@ -li[tedi-dropdown-item] { +@mixin dropdown-item-base { display: flex; gap: var(--dropdown-item-inner-spacing); align-items: center; @@ -9,7 +9,7 @@ li[tedi-dropdown-item] { cursor: pointer; background: var(--dropdown-item-default-background); - &:hover { + &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { color: var(--dropdown-item-hover-text); background: var(--dropdown-item-hover-background); } @@ -19,19 +19,21 @@ li[tedi-dropdown-item] { outline-offset: calc(-1 * var(--borders-02)); } - &[aria-selected="true"] { + &[aria-selected="true"], + &.tedi-dropdown-item--selected { color: var(--dropdown-item-active-text); background: var(--dropdown-item-active-background); - - &:focus-visible { - color: var(--dropdown-item-default-text); - background: var(--dropdown-item-default-background); - } } - &[aria-disabled="true"] { + &[aria-disabled="true"], + &.tedi-dropdown-item--disabled { color: var(--general-text-disabled); cursor: not-allowed; background: var(--dropdown-item-disabled-background); } } + +li[tedi-dropdown-item], +.tedi-dropdown-item { + @include dropdown-item-base; +} diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts index 13099b8d9..4b4c411a7 100644 --- a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts @@ -1,19 +1,27 @@ import { ChangeDetectionStrategy, Component, + contentChild, ElementRef, HostListener, inject, input, ViewEncapsulation, } from "@angular/core"; -import { DropdownComponent } from "../dropdown.component"; -import { DropdownContentComponent } from "../dropdown-content/dropdown-content.component"; +import { + DROPDOWN_API, + DROPDOWN_CONTENT_API, + DropdownApi, + DropdownContentApi, +} from "../dropdown.tokens"; +import { DropdownItemValueComponent } from "../dropdown-item-value/dropdown-item-value.component"; +import { DropdownItemValueLabelComponent } from "../dropdown-item-value/dropdown-item-value-label.component"; @Component({ selector: "li[tedi-dropdown-item]", standalone: true, - template: "", + imports: [DropdownItemValueComponent, DropdownItemValueLabelComponent], + templateUrl: "./dropdown-item.component.html", styleUrl: "./dropdown-item.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, @@ -35,8 +43,11 @@ export class DropdownItemComponent { readonly disabled = input(false); readonly host = inject>(ElementRef); - readonly dropdown = inject(DropdownComponent); - readonly dropdownContent = inject(DropdownContentComponent); + readonly dropdown = inject(DROPDOWN_API); + readonly dropdownContent = inject(DROPDOWN_CONTENT_API); + + /** Check if custom dropdown-item-value is provided */ + readonly customItemValue = contentChild(DropdownItemValueComponent); isSelected() { return this.dropdown.value() === this.value(); diff --git a/tedi/components/overlay/dropdown/dropdown.component.ts b/tedi/components/overlay/dropdown/dropdown.component.ts index ddbabf85e..8b58e15fa 100644 --- a/tedi/components/overlay/dropdown/dropdown.component.ts +++ b/tedi/components/overlay/dropdown/dropdown.component.ts @@ -20,6 +20,7 @@ import { import { DropdownTriggerDirective } from "./dropdown-trigger/dropdown-trigger.directive"; import { DropdownContentComponent } from "./dropdown-content/dropdown-content.component"; import { isPlatformBrowser } from "@angular/common"; +import { DROPDOWN_API } from "./dropdown.tokens"; export type DropdownPosition = `${NgxFloatUiPlacements}`; @@ -31,6 +32,12 @@ export type DropdownPosition = `${NgxFloatUiPlacements}`; styleUrl: "./dropdown.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: DROPDOWN_API, + useExisting: DropdownComponent, + }, + ], }) export class DropdownComponent implements AfterContentChecked, OnDestroy { /** Current value of dropdown (used with listbox) */ diff --git a/tedi/components/overlay/dropdown/dropdown.stories.ts b/tedi/components/overlay/dropdown/dropdown.stories.ts index 6e5f6b117..753af8db6 100644 --- a/tedi/components/overlay/dropdown/dropdown.stories.ts +++ b/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -9,7 +9,11 @@ import { DropdownRole, } from "./dropdown-content/dropdown-content.component"; import { DropdownItemComponent } from "./dropdown-item/dropdown-item.component"; +import { DropdownItemValueComponent } from "./dropdown-item-value/dropdown-item-value.component"; +import { DropdownItemValueLabelComponent } from "./dropdown-item-value/dropdown-item-value-label.component"; +import { DropdownItemValueMetaComponent } from "./dropdown-item-value/dropdown-item-value-meta.component"; import { ButtonComponent } from "../../buttons/button/button.component"; +import { IconComponent } from "../../base"; const POSITIONS: DropdownPosition[] = [ "auto", @@ -44,7 +48,11 @@ export default { DropdownTriggerDirective, DropdownContentComponent, DropdownItemComponent, + DropdownItemValueComponent, + DropdownItemValueLabelComponent, + DropdownItemValueMetaComponent, ButtonComponent, + IconComponent, ], }), ], @@ -156,7 +164,130 @@ export const Default: Story = {
  • Access to health data
  • Declaration of intent
  • -
  • Contacts
  • +
  • Contacts
  • +
    +
    + `, + }), +}; + +export const WithMeta: Story = { + name: "With Meta Text", + args: { + position: "bottom-start", + preventOverflow: true, + appendTo: "body", + dropdownRole: "listbox", + ariaHasPopup: "listbox", + }, + render: (args) => ({ + props: args, + template: ` + + + +
  • + + Tallinn + 3 timeslots + +
  • +
  • + + Tartu + 5 timeslots + +
  • +
  • + + Pärnu + 2 timeslots + +
  • +
    +
    + `, + }), +}; + +export const WithIcons: Story = { + name: "With Icons", + args: { + position: "bottom-start", + preventOverflow: true, + appendTo: "body", + dropdownRole: "menu", + ariaHasPopup: "menu", + }, + render: (args) => ({ + props: args, + template: ` + + + +
  • + + + Edit + +
  • +
  • + + + Duplicate + +
  • +
  • + + + Delete + +
  • +
    +
    + `, + }), +}; + +export const VerticalLayout: Story = { + name: "Vertical Layout", + args: { + position: "bottom-start", + preventOverflow: true, + appendTo: "body", + dropdownRole: "listbox", + ariaHasPopup: "listbox", + }, + render: (args) => ({ + props: args, + template: ` + + + +
  • + + Access to health data + Doctors will be able to see your health data + +
  • +
  • + + Access to medications + Doctors will be able to see your medications + +
  • +
  • + + Access to all + Doctors will be able to see all your information + +
  • `, diff --git a/tedi/components/overlay/dropdown/dropdown.tokens.ts b/tedi/components/overlay/dropdown/dropdown.tokens.ts new file mode 100644 index 000000000..86b59f1bb --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.tokens.ts @@ -0,0 +1,31 @@ +import { InjectionToken, Signal, WritableSignal } from "@angular/core"; + +export interface DropdownApi { + /** Current value of the dropdown. Used to track the selected option in listbox mode. */ + value: WritableSignal; + /** ID of the float-ui container element. Used to link trigger and content for accessibility. */ + containerId: WritableSignal; + /** Move focus to the next enabled item after the given element. */ + focusNextItem(fromEl: HTMLLIElement): void; + /** Move focus to the previous enabled item before the given element. */ + focusPrevItem(fromEl: HTMLLIElement): void; + /** Move focus to the first enabled item in the dropdown. */ + focusFirstItem(): void; + /** Move focus to the last enabled item in the dropdown. */ + focusLastItem(): void; + /** Close the dropdown and reset active item state. */ + hideDropdown(): void; + /** Reference to the dropdown trigger directive. Used to read trigger dimensions and return focus. */ + dropdownTrigger(): { host: { nativeElement: HTMLElement } } | undefined; +} + +export const DROPDOWN_API = new InjectionToken("DropdownApi"); + +export interface DropdownContentApi { + /** The ARIA role of the dropdown content. Determines keyboard interaction and accessibility semantics. */ + dropdownRole: Signal<"menu" | "listbox">; +} + +export const DROPDOWN_CONTENT_API = new InjectionToken( + "DropdownContentApi", +); diff --git a/tedi/components/overlay/dropdown/index.ts b/tedi/components/overlay/dropdown/index.ts index 05c50ff74..e821f7e86 100644 --- a/tedi/components/overlay/dropdown/index.ts +++ b/tedi/components/overlay/dropdown/index.ts @@ -1,4 +1,6 @@ +export * from "./dropdown.tokens"; export * from "./dropdown-content/dropdown-content.component"; export * from "./dropdown-item/dropdown-item.component"; +export * from "./dropdown-item-value"; export * from "./dropdown-trigger/dropdown-trigger.directive"; export * from "./dropdown.component"; diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index f4e0b44fd..dcb1544b1 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -39,7 +39,7 @@ export const translationsMap = { }, clear: { description: "For clearing a value", - components: ["TableFilter", "TextField"], + components: ["TableFilter", "TextField", "Select"], et: "Tühjenda", en: "Clear", ru: "Очистить", @@ -266,6 +266,13 @@ export const translationsMap = { en: "Select all", ru: "Выбрать все", }, + "select.search": { + description: "Placeholder text for search input in searchable select", + components: ["select"], + et: "Otsi...", + en: "Search...", + ru: "Искать...", + }, "stepper.completed": { description: "Label for screen-reader that this step is completed (visually hidden)",