From 738f4f6439afcd1f5d8bfd5d2b7304c8b6a03f83 Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 9 Mar 2026 15:30:53 +0200 Subject: [PATCH 1/3] feat(select): new tedi-ready component #15 --- .stylelintrc.json | 2 +- .../form/select/multiselect.stories.ts | 5 + .../form/select/select.component.ts | 10 +- .../components/form/select/select.stories.ts | 5 + setup-jest.ts | 28 + tedi/components/form/index.ts | 3 +- tedi/components/form/select/index.ts | 2 + .../form/select/select-templates.directive.ts | 126 ++ .../form/select/select.component.html | 283 +++ .../form/select/select.component.scss | 270 +++ .../form/select/select.component.spec.ts | 1679 +++++++++++++++++ .../form/select/select.component.ts | 884 +++++++++ tedi/components/form/select/select.stories.ts | 647 +++++++ .../dropdown-content.component.ts | 15 +- .../dropdown-item-value-label.component.ts | 17 + .../dropdown-item-value-meta.component.ts | 17 + .../dropdown-item-value.component.html | 24 + .../dropdown-item-value.component.scss | 129 ++ .../dropdown-item-value.component.ts | 55 + .../dropdown-item-value.stories.ts | 304 +++ .../dropdown/dropdown-item-value/index.ts | 3 + .../dropdown-item.component.html | 11 + .../dropdown-item.component.scss | 15 +- .../dropdown-item/dropdown-item.component.ts | 21 +- .../overlay/dropdown/dropdown.component.ts | 7 + .../overlay/dropdown/dropdown.stories.ts | 133 +- .../overlay/dropdown/dropdown.tokens.ts | 22 + tedi/components/overlay/dropdown/index.ts | 2 + tedi/services/translation/translations.ts | 9 +- 29 files changed, 4708 insertions(+), 20 deletions(-) create mode 100644 tedi/components/form/select/index.ts create mode 100644 tedi/components/form/select/select-templates.directive.ts create mode 100644 tedi/components/form/select/select.component.html create mode 100644 tedi/components/form/select/select.component.scss create mode 100644 tedi/components/form/select/select.component.spec.ts create mode 100644 tedi/components/form/select/select.component.ts create mode 100644 tedi/components/form/select/select.stories.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-label.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value-meta.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item-value/index.ts create mode 100644 tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.html create mode 100644 tedi/components/overlay/dropdown/dropdown.tokens.ts diff --git a/.stylelintrc.json b/.stylelintrc.json index 1b294f597..9bf4fccb9 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -6,7 +6,7 @@ ], "rules": { "selector-class-pattern": [ - "^(tedi-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$", + "^((tedi|cdk)-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$", { "message": "Class selector must start with 'tedi-' prefix and follow BEM naming (e.g., .tedi-button, .tedi-button__icon, .tedi-button--primary). Selector: \"%s\"", "resolveNestedSelectors": true diff --git a/community/components/form/select/multiselect.stories.ts b/community/components/form/select/multiselect.stories.ts index 25e0a760e..459def54e 100644 --- a/community/components/form/select/multiselect.stories.ts +++ b/community/components/form/select/multiselect.stories.ts @@ -15,6 +15,11 @@ import { const meta: Meta = { title: "Community/Form/Select/Multiselect", component: MultiselectComponent, + parameters: { + status: { + type: ["existsInTediReady", "deprecated"], + }, + }, decorators: [ moduleMetadata({ imports: [ diff --git a/community/components/form/select/select.component.ts b/community/components/form/select/select.component.ts index efcb73ee0..f4e7f9a50 100644 --- a/community/components/form/select/select.component.ts +++ b/community/components/form/select/select.component.ts @@ -36,6 +36,9 @@ import { } from "@tedi-design-system/angular/tedi"; import { CardComponent, CardContentComponent } from "../../../components/cards"; +/** + * @deprecated Use Select from TEDI-ready instead. This component will be removed from future versions. + */ @Component({ selector: "tedi-select", imports: [ @@ -70,8 +73,7 @@ import { CardComponent, CardContentComponent } from "../../../components/cards"; ], }) export class SelectComponent - implements AfterContentChecked, ControlValueAccessor -{ + implements AfterContentChecked, ControlValueAccessor { /** * The id of the select input (for label association). * @default "" @@ -205,8 +207,8 @@ export class SelectComponent } // ControlValueAccessor implementation - onChange: (value: string | null) => void = () => {}; - onTouched: () => void = () => {}; + onChange: (value: string | null) => void = () => { }; + onTouched: () => void = () => { }; writeValue(value: string): void { this.selectedOptions.set(value ? [value] : []); diff --git a/community/components/form/select/select.stories.ts b/community/components/form/select/select.stories.ts index abe34bc4e..0cadd0973 100644 --- a/community/components/form/select/select.stories.ts +++ b/community/components/form/select/select.stories.ts @@ -23,6 +23,11 @@ import { IconComponent } from "@tedi-design-system/angular/tedi"; const meta: Meta = { title: "Community/Form/Select/Single Select", component: SelectComponent, + parameters: { + status: { + type: ["existsInTediReady", "deprecated"], + }, + }, decorators: [ moduleMetadata({ imports: [ diff --git a/setup-jest.ts b/setup-jest.ts index f1b434f22..ce207d677 100644 --- a/setup-jest.ts +++ b/setup-jest.ts @@ -1,3 +1,31 @@ import { setupZoneTestEnv } from "jest-preset-angular/setup-env/zone"; setupZoneTestEnv(); + +// Mock scrollIntoView which is not implemented in jsdom +Element.prototype.scrollIntoView = jest.fn(); + +// Mock window.matchMedia which is not implemented in jsdom +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +// Suppress CSS parsing errors from jsdom (it doesn't support all modern CSS features) +const originalConsoleError = console.error; +console.error = (...args: unknown[]) => { + const message = args[0]?.toString() ?? ""; + if (message.includes("Could not parse CSS stylesheet")) { + return; + } + originalConsoleError.apply(console, args); +}; diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 9f93af829..0957c2b98 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -1,6 +1,7 @@ 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"; 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..6f43a560a --- /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..593a7e957 --- /dev/null +++ b/tedi/components/form/select/select.component.html @@ -0,0 +1,283 @@ +@let listboxId = inputId() + "-listbox"; +@let labelId = inputId() + "-label"; + +@if (label()) { + +} +
+ @if (searchable()) { +
+ @if (!searchTerm() && selectedValues().length && !multiple()) { + + @if (valueTemplate(); as tpl) { + + } @else { + {{ selectedLabels().join(", ") }} + } + + } + @if (multiple() && selectedValues().length) { +
+ @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() }} + } + } +
+ } + +
+ } @else { + + @if (selectedValues().length) { + @if (multiple()) { +
+ @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() }} + } + } +
+ } @else { + @if (valueTemplate(); as tpl) { + + } @else { + {{ selectedLabels().join(", ") }} + } + } + } @else { + + {{ placeholder() }} + + } +
+ } + + @if (clearable() && selectedValues().length) { + + } + + +
+@if (feedbackText(); as feedback) { + +} + + +
+
    + @if (filteredOptions().length) { + @if (multiple() && selectAll()) { +
  • + + {{ "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 { +
  • + {{ notFoundText() || ("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..9925d7efc --- /dev/null +++ b/tedi/components/form/select/select.component.scss @@ -0,0 +1,270 @@ +@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); + --_form-field-outer-gap: var(--form-field-outer-spacing); + + padding: var(--_padding-y) var(--_padding-x); + margin-bottom: var(--_form-field-outer-gap); + font-family: var(--family-default); + font-size: var(--_font-size); + line-height: var(--_line-height); + color: var(--_color); + background-color: var(--_background-color); + border: 1px 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); + } +} + +.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: 1.5em; + 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: 50px; + height: auto; + } + + &__options { + flex: 1; + min-height: 0; + padding: 0; + margin: 0; + overflow-y: auto; + + &:focus .cdk-option-active { + color: var(--dropdown-item-hover-text); + background: var(--dropdown-item-hover-background); + } + + .tedi-dropdown-item--selected { + * { + color: inherit !important; + } + } + + .tedi-dropdown-item--focused:not(.tedi-dropdown-item--disabled) { + outline: var(--borders-02) solid var(--tedi-primary-500); + outline-offset: calc(-1 * var(--borders-02)); + } + } + + &__dropdown-item { + &--label { + display: none; + } + + &--custom-content:empty+&--label { + display: block; + } + } + + &__group-name { + display: block; + padding: var(--dropdown-group-label-padding-y, 8px) var(--dropdown-group-label-padding-x, 12px) var(--layout-grid-gutters-04, 4px); + 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-bottom: var(--dropdown-group-label-padding-y, 8px); + + &~.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); + } + } +} 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..68cd71394 --- /dev/null +++ b/tedi/components/form/select/select.component.spec.ts @@ -0,0 +1,1679 @@ +/* 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, SelectOption, InputState, InputSize } from "./select.component"; +import { + SelectOptionTemplateDirective, + SelectValueTemplateDirective, +} from "./select-templates.directive"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +@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; + selectAll = false; + selectableGroups = false; + groupBy: string | undefined = undefined; + bindLabel = "label"; + bindValue: string | undefined = undefined; + placeholder = "Select an option..."; + state: InputState = "default"; + size: InputSize = "default"; + required = false; + clearableTags = false; + multiRow = false; + dropdownWidthRef: any = 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 = () => document.querySelector(`[id$="-option-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.selectAll = 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(); + + expect(select.focusedOptionIndex()).toBe(1); + })); + + it("ArrowUp should navigate to previous option", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + select.focusedOptionIndex.set(1); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + })); + + it("Enter should select focused option", fakeAsync(() => { + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1"]); + })); + + 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("search input should have aria-activedescendant", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + const activeDescendant = input.getAttribute("aria-activedescendant"); + expect(activeDescendant).toBe("test-select-option-0"); + })); + + it("focusedOptionId should return correct option ID", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionId()).toBe("test-select-option-0"); + + select.focusedOptionIndex.set(1); + expect(select.focusedOptionId()).toBe("test-select-option-1"); + })); + + it("focusedOptionId should return null when no option focused", () => { + expect(select.focusedOptionId()).toBeNull(); + }); + }); + + 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(); + + expect(select.focusedOptionIndex()).toBe(0); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(2); + })); + + it("select all should skip disabled options", fakeAsync(() => { + host.multiple = true; + host.selectAll = 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 navigate 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 initialIndex = select.focusedOptionIndex(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(initialIndex); + })); + }); + + 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 toggle selectAll via keyboard in multiselect", fakeAsync(() => { + host.multiple = true; + host.selectAll = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + // Focus should be on selectAll (index 0) + expect(select.focusedOptionIndex()).toBe(0); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + // All options should be selected + expect(select.selectedValues()).toEqual(["Option 1", "Option 2", "Option 3"]); + })); + + it("should toggle group selection via keyboard", 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; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([1, 2]); + })); + + it("should toggle option in multiselect via keyboard", fakeAsync(() => { + host.multiple = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual(["Option 1"]); + expect(select.isOpen()).toBe(true); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + fixture.detectChanges(); + tick(); + + expect(select.selectedValues()).toEqual([]); + })); + }); + + 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("isOptionFocused edge cases", () => { + it("should return false when focused option type does not match", fakeAsync(() => { + host.multiple = true; + host.selectAll = true; + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + + expect(select.isOptionFocused("option", "Option 1")).toBe(false); + expect(select.isOptionFocused("group", undefined, "SomeGroup")).toBe(false); + })); + }); + + 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 via CDK", () => { + it("should handle group selection via CDK 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 to previous option when open", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + select.focusedOptionIndex.set(1); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + })); + }); + + 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("Index wrapping in navigation", () => { + it("should wrap from last to first option on ArrowDown", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + select.focusedOptionIndex.set(2); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + })); + + it("should wrap from first to last option on ArrowUp", fakeAsync(() => { + host.searchable = true; + fixture.detectChanges(); + tick(); + + getTrigger().click(); + fixture.detectChanges(); + tick(); + + select.focusedOptionIndex.set(0); + fixture.detectChanges(); + tick(); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(2); + })); + }); + + 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.flatFilteredOptions()).toEqual([]); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(0); + })); + }); + + describe("Recursive skip disabled", () => { + it("should recursively 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(); + + expect(select.focusedOptionIndex()).toBe(0); + + const input = getSearchInput(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + fixture.detectChanges(); + tick(); + + expect(select.focusedOptionIndex()).toBe(3); + })); + }); + }); +}); diff --git a/tedi/components/form/select/select.component.ts b/tedi/components/form/select/select.component.ts new file mode 100644 index 000000000..8880dbafc --- /dev/null +++ b/tedi/components/form/select/select.component.ts @@ -0,0 +1,884 @@ +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"; + +export type InputState = "default" | "error" | "valid"; +export type InputSize = "default" | "small"; + +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 interface NavigableOption { + type: "selectAll" | "group" | "option"; + value?: unknown; + disabled?: boolean; + groupLabel?: string; +} + +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 { + inputId = input.required(); + label = input(); + required = input(false); + placeholder = input(""); + state = input("default"); + size = input("default"); + clearable = input(true); + dropdownWidthRef = input(); + feedbackText = input>(); + items = input([]); + bindLabel = input("label"); + bindValue = input(undefined); + multiple = input(false); + groupBy = input | undefined>(undefined); + selectAll = input(false); + selectableGroups = input(false); + clearableTags = input(false); + multiRow = input(false); + compareWith = input((a, b) => a === b); + disabledKey = input("disabled"); + notFoundText = input(); + 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", + }, + ]; + + isOpen = signal(false); + selectedValues = signal([]); + disabled = signal(false); + dropdownWidth = signal(null); + dropdownMaxHeight = signal(null); + visibleTagsCount = signal(null); + searchTerm = signal(""); + focusedOptionIndex = signal(-1); + 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 }); + 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.items(); + 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; + }); + + flatFilteredOptions = computed(() => { + if (this.filteredOptions().length === 0) return []; + + const result: NavigableOption[] = []; + + if (this.multiple() && this.selectAll()) { + result.push({ type: "selectAll" }); + } + + for (const group of this.optionGroups()) { + if (group.label.length > 0 && this.multiple() && this.selectableGroups()) { + result.push({ type: "group", groupLabel: group.label }); + } + for (const option of group.options) { + result.push({ type: "option", value: option.value, disabled: option.disabled }); + } + } + + return result; + }); + + 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); + }); + + /** Returns the ID of the currently focused option for aria-activedescendant. */ + focusedOptionId = computed(() => { + const index = this.focusedOptionIndex(); + if (index < 0) return null; + return `${this.inputId()}-option-${index}`; + }); + + 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(""); + } + } + + private markNextItem(): void { + this.stepToItem(1); + } + + private markPreviousItem(): void { + this.stepToItem(-1); + } + + private stepToItem(step: number): void { + const options = this.flatFilteredOptions(); + if (options.length === 0 || options.every((x) => x.type === "option" && x.disabled)) { + return; + } + + let index = this.focusedOptionIndex(); + index = this.getNextItemIndex(step, index, options.length); + this.focusedOptionIndex.set(index); + + const opt = options[index]; + if (opt.type === "option" && opt.disabled) { + this.stepToItem(step); + return; + } + + this.scrollToFocusedOption(); + } + + private getNextItemIndex(step: number, currentIndex: number, length: number): number { + if (step > 0) { + return currentIndex >= length - 1 ? 0 : currentIndex + 1; + } + return currentIndex <= 0 ? length - 1 : currentIndex - 1; + } + + private initFocusedOptionIndex(): void { + const options = this.flatFilteredOptions(); + const firstSelectableIndex = options.findIndex( + (opt) => opt.type !== "option" || !opt.disabled + ); + this.focusedOptionIndex.set(firstSelectableIndex >= 0 ? firstSelectableIndex : 0); + } + + 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(); + } + }); + + resetVisibleTagsOnSelectionChange = effect(() => { + this.selectedValues(); + this.visibleTagsCount.set(null); + }); + + resetFocusedOptionIndexOnClose = effect(() => { + if (!this.isOpen()) { + this.focusedOptionIndex.set(-1); + } + }); + + toggleIsOpen(close?: boolean): void { + if (this.disabled()) return; + + if (close) { + this.closeDropdown(); + this.focusTrigger(); + } else { + const willOpen = !this.isOpen(); + if (willOpen) { + this.calculateDropdownMaxHeight(); + } else { + this.dropdownMaxHeight.set(null); + } + this.isOpen.set(willOpen); + } + } + + onSearchInput(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchTerm.set(input.value); + + if (!this.isOpen()) { + this.openDropdown(); + } + + this.initFocusedOptionIndex(); + } + + 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": + event.preventDefault(); + if (this.isOpen()) { + this.markNextItem(); + } else { + this.openDropdown(); + } + break; + case "ArrowUp": + event.preventDefault(); + if (this.isOpen()) { + this.markPreviousItem(); + } else { + this.openDropdown(); + } + break; + case " ": + if (!this.isOpen()) { + event.preventDefault(); + this.openDropdown(); + } + break; + case "Enter": + event.preventDefault(); + if (this.isOpen() && this.focusedOptionIndex() >= 0) { + this.selectFocusedOption(); + } else if (!this.isOpen()) { + this.openDropdown(); + } + break; + case "Escape": + case "Tab": + if (this.isOpen()) { + this.closeDropdown(); + } + break; + } + } + + private openDropdown(): void { + this.calculateDropdownMaxHeight(); + this.isOpen.set(true); + this.initFocusedOptionIndex(); + } + + private calculateDropdownMaxHeight(): void { + 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); + } + + private selectFocusedOption(): void { + const options = this.flatFilteredOptions(); + const index = this.focusedOptionIndex(); + if (index < 0 || index >= options.length) return; + + const option = options[index]; + + if (option.type === "selectAll") { + this.toggleSelectAll(); + } else if (option.type === "group") { + this.toggleGroupSelection(option.groupLabel!); + } else if (!option.disabled) { + if (this.multiple()) { + const compareWith = this.compareWith(); + const isSelected = this.selectedValues().some((val) => + compareWith(val, option.value) + ); + let newValues: unknown[]; + if (isSelected) { + newValues = this.selectedValues().filter( + (val) => !compareWith(val, option.value) + ); + } else { + newValues = [...this.selectedValues(), option.value]; + } + this.selectedValues.set(newValues); + this.onChange(newValues); + this.searchTerm.set(""); + } else { + this.selectedValues.set([option.value]); + this.onChange(option.value); + this.toggleIsOpen(true); + } + this.onTouched(); + } + } + + private scrollToFocusedOption(): void { + const listbox = this.listboxRef()?.nativeElement; + if (!listbox) return; + + const items = listbox.querySelectorAll(".tedi-dropdown-item"); + const index = this.focusedOptionIndex(); + if (index >= 0 && items[index]) { + items[index].scrollIntoView({ block: "nearest" }); + } + } + + 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()) { + this.selectedValues.set([...values]); + this.onChange([...values]); + 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.items(); + 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 }; + } + + isOptionFocused(type: "selectAll" | "group" | "option", value?: unknown, groupLabel?: string): boolean { + const index = this.focusedOptionIndex(); + const options = this.flatFilteredOptions(); + if (index < 0 || index >= options.length) return false; + + const focused = options[index]; + if (focused.type !== type) return false; + + if (type === "selectAll") return true; + if (type === "group") return focused.groupLabel === groupLabel; + if (type === "option") { + const compareWith = this.compareWith(); + return compareWith(focused.value, value); + } + + return false; + } + + /** Returns the element ID for an option based on its position in flatFilteredOptions. */ + getOptionId(type: "selectAll" | "group" | "option", value?: unknown, groupLabel?: string): string { + const options = this.flatFilteredOptions(); + const compareWith = this.compareWith(); + + const index = options.findIndex((opt) => { + if (opt.type !== type) return false; + if (type === "selectAll") return true; + if (type === "group") return opt.groupLabel === groupLabel; + return compareWith(opt.value, value); + }); + + return `${this.inputId()}-option-${index}`; + } + + 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 calculateVisibleTags(): void { + const container = this.multiselectContainerRef()?.nativeElement; + const tags = this.tagRefs(); + + if (!container || tags.length === 0) { + return; + } + + if (this.visibleTagsCount() !== null) { + return; + } + + const containerWidth = container.offsetWidth; + const gap = 8; + const counterTagWidth = 40; + let usedWidth = 0; + let visibleCount = 0; + + for (let i = 0; i < tags.length; i++) { + const tagEl = tags[i].nativeElement; + const tagWidth = tagEl.offsetWidth; + + // Check if this tag fits + const spaceNeeded = usedWidth + tagWidth + (visibleCount > 0 ? gap : 0); + + // Reserve space for counter tag if there are more items + const hasMoreItems = i < tags.length - 1; + const reservedSpace = hasMoreItems ? counterTagWidth + gap : 0; + + if (spaceNeeded + reservedSpace <= containerWidth) { + usedWidth = spaceNeeded; + visibleCount++; + } else { + break; + } + } + + // Ensure at least one tag is shown + 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..3c6caa913 --- /dev/null +++ b/tedi/components/form/select/select.stories.ts @@ -0,0 +1,647 @@ +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 { AlertComponent } from "../../notifications"; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +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, + AlertComponent, + ], + }), + ], + 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" }, + selectAll: { control: "boolean" }, + selectableGroups: { control: "boolean" }, + clearableTags: { control: "boolean" }, + multiRow: { control: "boolean" }, + searchable: { control: "boolean" }, + }, + args: { + inputId: "select-1", + label: "Label", + required: false, + placeholder: "Select an option...", + state: "default", + size: "default", + clearable: true, + multiple: false, + selectAll: false, + selectableGroups: false, + clearableTags: false, + multiRow: false, + searchable: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const simpleOptions = ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"]; + +export const Default: Story = { + render: (args) => ({ + props: { + ...args, + options: simpleOptions, + }, + 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: ` +
+ + +
+ `, + }), +}; + +// ============ 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: "Cyan", color: "#59ced9" }, + { id: 2, name: "Blue", color: "#3b82f6" }, + { id: 3, name: "Green", color: "#22c55e" }, + { id: 4, name: "Red", color: "#ef4444" }, + { id: 5, name: "Purple", color: "#a855f7" }, + ], + 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("Option 1"), + 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 }} + + + +
+ `, + }), +}; + +export const ReactiveForms: Story = { + render: () => ({ + props: { + 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]), + }), + submitted: false, + onSubmit(form: FormGroup, context: { submitted: boolean }) { + context.submitted = true; + }, + }, + template: ` +
+ Form submitted + + + + + {{ item.name }} + {{ item.slots }} slots + + + + + + + + {{ item.title }} + {{ item.description }} + + + + + + + + {{ item.title }} + {{ item.description }} + + + + + + +
+ Form values: +
{{ form.value | json }}
+
+ +
+ `, + }), +}; 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..1b4c453ae --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss @@ -0,0 +1,129 @@ +tedi-dropdown-item-value, +.tedi-dropdown-item-value { + display: flex; + gap: var(--dropdown-item-inner-spacing, 8px); + align-items: center; + width: 100%; + + &__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: 1px solid var(--form-checkbox-radio-default-border-default); + border-radius: 50%; + + &:checked { + background-color: var(--form-checkbox-radio-default-background-selected); + border-color: var(--form-checkbox-radio-default-border-selected); + + &::before { + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + content: ""; + background: var(--form-checkbox-radio-default-check-indicator-default); + border-radius: 50%; + transform: translate(-50%, -50%); + } + } + + &:disabled { + cursor: not-allowed; + background-color: var(--form-general-background-disabled); + border-color: var(--form-general-border-disabled); + + &:checked { + background-color: var(--form-checkbox-radio-default-background-selected-disabled); + border-color: 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, 8px); + 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); + } + } + } +} 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..bec15b882 --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.html @@ -0,0 +1,11 @@ + + + + +@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..e979c8c8b 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,7 +19,8 @@ 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); @@ -29,9 +30,15 @@ li[tedi-dropdown-item] { } } - &[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..3fff2556e --- /dev/null +++ b/tedi/components/overlay/dropdown/dropdown.tokens.ts @@ -0,0 +1,22 @@ +import { InjectionToken, Signal, WritableSignal } from "@angular/core"; + +export interface DropdownApi { + value: WritableSignal; + containerId: WritableSignal; + focusNextItem(fromEl: HTMLLIElement): void; + focusPrevItem(fromEl: HTMLLIElement): void; + focusFirstItem(): void; + focusLastItem(): void; + hideDropdown(): void; + dropdownTrigger(): { host: { nativeElement: HTMLElement } } | undefined; +} + +export const DROPDOWN_API = new InjectionToken("DropdownApi"); + +export interface DropdownContentApi { + 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)", From c8e9a79de8e1b88640b51bc0069eec78fc363808 Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 10 Mar 2026 09:54:32 +0200 Subject: [PATCH 2/3] feat(select): added descriptions to select inputs #15 --- .../form/select/select.component.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tedi/components/form/select/select.component.ts b/tedi/components/form/select/select.component.ts index 8880dbafc..f39d38df8 100644 --- a/tedi/components/form/select/select.component.ts +++ b/tedi/components/form/select/select.component.ts @@ -103,27 +103,138 @@ export enum SpecialOptionControls { ], }) 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 true + */ clearable = input(true); + + /** + * 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 items to display as options. + * Can be an array of objects or primitive values. + * @default [] + */ items = 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 + */ selectAll = 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 can be individually removed in multiselect mode. + * @default false + */ clearableTags = 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. + */ notFoundText = input(); + + /** + * Whether the select has a search input for filtering options. + * @default false + */ searchable = input(false); readonly SpecialOptionControls = SpecialOptionControls; From e481a5466316d1c7474bf62d8dcf9947fa102e3b Mon Sep 17 00:00:00 2001 From: m2rt Date: Thu, 12 Mar 2026 17:02:10 +0200 Subject: [PATCH 3/3] feat(select): changes from code review #15 --- .../form/select/select.component.html | 123 +++++++----------- .../form/select/select.component.scss | 4 +- .../form/select/select.component.spec.ts | 12 +- .../form/select/select.component.ts | 11 +- tedi/components/form/select/select.stories.ts | 12 +- .../dropdown-item-value.component.scss | 5 +- 6 files changed, 73 insertions(+), 94 deletions(-) diff --git a/tedi/components/form/select/select.component.html b/tedi/components/form/select/select.component.html index 593a7e957..84dd40a85 100644 --- a/tedi/components/form/select/select.component.html +++ b/tedi/components/form/select/select.component.html @@ -1,9 +1,6 @@ -@let listboxId = inputId() + "-listbox"; -@let labelId = inputId() + "-label"; - @if (label()) {