From fa892942edcb2a2306eef9df1ceddd390312fa89 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 16 Oct 2026 16:02:41 +0300 Subject: [PATCH 1/2] fix(dropdown): align dropdown with design review #328 - Dropdown: add width, maxHeight and hideOnScroll inputs; never wider than the viewport minus the page side padding - DropdownContent: add divided and tree variant, tedi-dropdown-header slot and tedi-dropdown-separator - DropdownItem: add indent and isParent (tree lines), pressed state, Figma focus ring, icons inherit text colour on hover/active/selected/disabled - DropdownItem: announce checkbox/radio items as menuitemcheckbox/ menuitemradio with aria-checked, or aria-selected in a multiselectable listbox - DropdownItem: item without a value is never selected - DropdownItemValue: add icon input (16px, secondary), make checkbox/radio indicator inert to fix nested-interactive - Stories: Estonian content, new Position, With Active Item, With Action, With Checkbox, With Indented Items, With Radio, Custom Width, Max Height, Hide On Scroll, Divided, With Separator and Custom Content stories, DropdownItemValue stories aligned with Figma, mobile layouts, visual tests --- package-lock.json | 8 +- package.json | 2 +- .../dropdown-content.component.html | 4 +- .../dropdown-content.component.scss | 8 + .../dropdown-content.component.ts | 29 +- .../dropdown-header.directive.spec.ts | 148 +++ .../dropdown-header.directive.ts | 51 ++ .../overlay/dropdown/dropdown-header/index.ts | 1 + .../dropdown-item-value.component.html | 13 +- .../dropdown-item-value.component.scss | 3 +- .../dropdown-item-value.component.spec.ts | 51 +- .../dropdown-item-value.component.ts | 39 +- .../dropdown-item-value.stories.ts | 824 +++++++++++++---- .../dropdown-item.component.scss | 116 ++- .../dropdown-item/dropdown-item.component.ts | 101 ++- .../dropdown-separator.component.scss | 6 + .../dropdown-separator.component.spec.ts | 131 +++ .../dropdown-separator.component.ts | 34 + .../dropdown/dropdown-separator/index.ts | 1 + .../dropdown-trigger.directive.ts | 6 + .../overlay/dropdown/dropdown.component.html | 4 + .../overlay/dropdown/dropdown.component.scss | 22 + .../dropdown/dropdown.component.spec.ts | 467 +++++++++- .../overlay/dropdown/dropdown.component.ts | 53 +- .../overlay/dropdown/dropdown.stories.ts | 851 +++++++++++++++--- .../overlay/dropdown/dropdown.tokens.ts | 10 + src/tedi/components/overlay/dropdown/index.ts | 2 + 27 files changed, 2610 insertions(+), 375 deletions(-) create mode 100644 src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.spec.ts create mode 100644 src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.ts create mode 100644 src/tedi/components/overlay/dropdown/dropdown-header/index.ts create mode 100644 src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.scss create mode 100644 src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.spec.ts create mode 100644 src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.ts create mode 100644 src/tedi/components/overlay/dropdown/dropdown-separator/index.ts diff --git a/package-lock.json b/package-lock.json index 1e55c2aba..e06bc9bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-semantic-version", "dependencies": { "@tanstack/angular-table": "^8.21.4", - "@tedi-design-system/core": "^6.9.0" + "@tedi-design-system/core": "6.9.1" }, "devDependencies": { "@angular-devkit/build-angular": "20.3.27", @@ -12913,9 +12913,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.9.0.tgz", - "integrity": "sha512-+RPnP8d9xcgabfFODJRd472JPzRIT8OR6XMd67RtbKyLOMNHKl8RwT22DCKcMnH4FEMv/v7Uay1jwgWL9Wp/Rg==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.9.1.tgz", + "integrity": "sha512-aGkCPrzUYh8mNwV7EBaUkOUvDlk0oONga+0adli5nxFNfxU1THHEpzt9IIH2YK6n3NHU9Dta2De5RwD7CorvFw==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 30cd3af46..c1e808137 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@tanstack/angular-table": "^8.21.4", - "@tedi-design-system/core": "^6.9.0" + "@tedi-design-system/core": "6.9.1" }, "devDependencies": { "@angular-devkit/build-angular": "20.3.27", diff --git a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html index 774a6bc8d..bd9856f4b 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html +++ b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.html @@ -1,3 +1,4 @@ +
    - +
diff --git a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss index 15dc4c6b1..40d59d9b0 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss +++ b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss @@ -17,4 +17,12 @@ // `list` mode writes out role="list" rather than relying on the
    . list-style: none; } + + .tedi-dropdown-header { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + } + + &--divided li[tedi-dropdown-item]:not(:last-child) { + border-bottom: 1px solid var(--general-border-primary); + } } \ No newline at end of file diff --git a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts index 7c1b27368..b31e9e97f 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + contentChild, contentChildren, ElementRef, forwardRef, @@ -10,13 +11,15 @@ import { ViewEncapsulation, } from "@angular/core"; import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; +import { DropdownHeaderDirective } from "../dropdown-header/dropdown-header.directive"; import { DROPDOWN_API, DROPDOWN_CONTENT_API, DropdownRole, + DropdownVariant, } from "../dropdown.tokens"; -export type { DropdownRole }; +export type { DropdownRole, DropdownVariant }; @Component({ selector: "tedi-dropdown-content", @@ -28,6 +31,7 @@ export type { DropdownRole }; host: { class: "tedi-dropdown-content", role: "presentation", + "[class.tedi-dropdown-content--divided]": "divided()", }, providers: [ { @@ -56,10 +60,33 @@ export class DropdownContentComponent { */ readonly dropdownRole = input("menu"); + /** + * Draws a line between every item. For lines between groups only, use + * `li[tedi-dropdown-separator]` instead. + * @default false + */ + readonly divided = input(false); + + /** + * Visual variant. `tree` draws connector lines from indented items + * (`indent`) to their parent; mark a parent sitting on the line with + * `isParent`. + * @default default + */ + readonly variant = input("default"); + readonly host = inject>(ElementRef); private readonly dropdownApi = inject(DROPDOWN_API); readonly containerId = computed(() => this.dropdownApi.containerId()); readonly items = contentChildren(DropdownItemComponent); + readonly header = contentChild(DropdownHeaderDirective); readonly isWidget = computed(() => this.dropdownRole() !== "list"); + + /** A listbox of checkbox items allows more than one selected option. */ + readonly multiselectable = computed( + () => + this.dropdownRole() === "listbox" && + this.items().some((item) => item.checkable()?.type === "checkbox"), + ); } diff --git a/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.spec.ts b/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.spec.ts new file mode 100644 index 000000000..13840e8ba --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.spec.ts @@ -0,0 +1,148 @@ +import { Component } from "@angular/core"; +import { + ComponentFixture, + TestBed, + fakeAsync, + flush, +} from "@angular/core/testing"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { By } from "@angular/platform-browser"; +import { DropdownComponent } from "../dropdown.component"; +import { DropdownTriggerDirective } from "../dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "../dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; +import { DropdownHeaderDirective } from "./dropdown-header.directive"; + +@Component({ + standalone: true, + template: ` + + + + +
    + +
    +
  • Item A
  • +
  • Item B
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + DropdownHeaderDirective, + ], +}) +class TestHostComponent {} + +describe("DropdownHeaderDirective", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Element.prototype as any).scrollIntoView = jest.fn(); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + fixture = TestBed.createComponent(TestHostComponent); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + }); + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + const open = () => { + dropdown.showDropdown(); + fixture.detectChanges(); + flush(); + }; + + const getInput = () => + overlayContainerElement.querySelector("input") as HTMLInputElement; + const getItems = () => + Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + const getTrigger = () => + fixture.nativeElement.querySelector( + "[tedi-dropdown-trigger]", + ) as HTMLButtonElement; + const keydown = (el: HTMLElement, init: KeyboardEventInit) => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }); + el.dispatchEvent(event); + fixture.detectChanges(); + return event; + }; + + it("renders above the list, outside the widget", fakeAsync(() => { + open(); + + const header = overlayContainerElement.querySelector( + "[tedi-dropdown-header]", + ) as HTMLElement; + expect(header.classList).toContain("tedi-dropdown-header"); + expect(header.nextElementSibling?.tagName).toBe("UL"); + expect(header.closest("[role='menu']")).toBeNull(); + })); + + it("focuses the header control on open", fakeAsync(() => { + open(); + + expect(document.activeElement).toBe(getInput()); + })); + + it("moves to the first item on ArrowDown", fakeAsync(() => { + open(); + + const event = keydown(getInput(), { key: "ArrowDown" }); + + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(getItems()[0]); + })); + + it("returns to the header on ArrowUp from the first item", fakeAsync(() => { + open(); + keydown(getInput(), { key: "ArrowDown" }); + + keydown(getItems()[0], { key: "ArrowUp" }); + + expect(document.activeElement).toBe(getInput()); + })); + + it.each([{ key: "Escape" }, { key: "Tab", shiftKey: true }])( + "closes and focuses the trigger on %p", + fakeAsync((init: KeyboardEventInit) => { + open(); + + const event = keydown(getInput(), init); + + expect(event.defaultPrevented).toBe(true); + expect(dropdown.isOpen()).toBe(false); + expect(document.activeElement).toBe(getTrigger()); + }), + ); + + it("leaves other keys to the control", fakeAsync(() => { + open(); + + expect(keydown(getInput(), { key: "a" }).defaultPrevented).toBe(false); + expect(keydown(getInput(), { key: "Tab" }).defaultPrevented).toBe(false); + expect(dropdown.isOpen()).toBe(true); + })); +}); diff --git a/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.ts b/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.ts new file mode 100644 index 000000000..dad98cd02 --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-header/dropdown-header.directive.ts @@ -0,0 +1,51 @@ +import { Directive, ElementRef, inject } from "@angular/core"; +import { DROPDOWN_API } from "../dropdown.tokens"; +import { getFocusableElements } from "../../../../utils/elements.util"; + +/** + * Content above the items, such as a search field that filters them. It is not + * part of the `menu` / `listbox`, so its controls keep their own roles and keys. + * + * When it holds a focusable control, opening the dropdown focuses that control; + * ArrowDown moves to the first item, ArrowUp on the first item comes back, and + * Escape or Shift+Tab closes the dropdown and returns focus to the trigger. + */ +@Directive({ + selector: "[tedi-dropdown-header]", + standalone: true, + host: { + class: "tedi-dropdown-header", + "(keydown)": "onKeydown($event)", + }, +}) +export class DropdownHeaderDirective { + private readonly host = inject>(ElementRef); + private readonly dropdown = inject(DROPDOWN_API); + + /** Focuses the first focusable control. Returns whether there was one. */ + focus(): boolean { + const target = getFocusableElements(this.host.nativeElement)[0]; + target?.focus(); + return !!target; + } + + onKeydown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + this.dropdown.focusFirstItem(); + break; + + case "Escape": + event.preventDefault(); + this.dropdown.closeAndFocusTrigger(); + break; + + case "Tab": + if (!event.shiftKey) break; + event.preventDefault(); + this.dropdown.closeAndFocusTrigger(); + break; + } + } +} diff --git a/src/tedi/components/overlay/dropdown/dropdown-header/index.ts b/src/tedi/components/overlay/dropdown/dropdown-header/index.ts new file mode 100644 index 000000000..a3d6aab8e --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-header/index.ts @@ -0,0 +1 @@ +export * from "./dropdown-header.directive"; diff --git a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html index 2310d77db..05e259091 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html +++ b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.html @@ -3,7 +3,7 @@ type="checkbox" tedi-checkbox class="tedi-dropdown-item-value__checkbox" - tabindex="-1" + inert aria-hidden="true" [checked]="selected()" [indeterminate]="indeterminate()" @@ -14,13 +14,22 @@ } +@if (resolvedIcon(); as icon) { + +}
    diff --git a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss index c237f7241..449007e7a 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss +++ b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.scss @@ -127,7 +127,8 @@ li[tedi-dropdown-item], } } - &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { + &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]), + &:active:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { .tedi-dropdown-item-value__label, .tedi-dropdown-item-value__meta { diff --git a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.spec.ts b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.spec.ts index 18e68fd36..ceb8e6e6f 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.spec.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.spec.ts @@ -18,7 +18,7 @@ describe("DropdownItemValueComponent", () => { expect(fixture.componentInstance).toBeTruthy(); }); - it("should render a presentational, aria-hidden, non-tabbable checkbox", () => { + it("should render a presentational, aria-hidden, non-focusable checkbox", () => { fixture.componentRef.setInput("type", "checkbox"); fixture.detectChanges(); @@ -27,10 +27,10 @@ describe("DropdownItemValueComponent", () => { ).nativeElement as HTMLInputElement; expect(input.getAttribute("aria-hidden")).toBe("true"); - expect(input.getAttribute("tabindex")).toBe("-1"); + expect(input.hasAttribute("inert")).toBe(true); }); - it("should render a presentational, aria-hidden, non-tabbable radio", () => { + it("should render a presentational, aria-hidden, non-focusable radio", () => { fixture.componentRef.setInput("type", "radio"); fixture.detectChanges(); @@ -39,6 +39,49 @@ describe("DropdownItemValueComponent", () => { ).nativeElement as HTMLInputElement; expect(input.getAttribute("aria-hidden")).toBe("true"); - expect(input.getAttribute("tabindex")).toBe("-1"); + expect(input.hasAttribute("inert")).toBe(true); + }); + + describe("icon", () => { + const getIcon = () => + fixture.debugElement.query(By.css(".tedi-icon"))?.nativeElement as + HTMLElement | undefined; + + it("renders no icon by default", () => { + expect(getIcon()).toBeUndefined(); + }); + + it("renders a named icon at 16px in the secondary colour", () => { + fixture.componentRef.setInput("icon", "download"); + fixture.detectChanges(); + + const icon = getIcon()!; + expect(icon.textContent?.trim()).toBe("download"); + expect(icon.classList).toContain("tedi-icon--color-secondary"); + expect(icon.style.getPropertyValue("--_tedi-icon-size")).toBe( + "var(--icon-02)", + ); + expect(fixture.componentInstance.resolvedIcon()).toEqual({ + name: "download", + size: 16, + color: "secondary", + }); + }); + + it("lets an icon object override the defaults", () => { + fixture.componentRef.setInput("icon", { + name: "delete", + size: 18, + color: "danger", + }); + fixture.detectChanges(); + + expect(getIcon()!.classList).toContain("tedi-icon--color-danger"); + expect(fixture.componentInstance.resolvedIcon()).toEqual({ + name: "delete", + size: 18, + color: "danger", + }); + }); }); }); diff --git a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts index 3b11efb64..0564c226c 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.component.ts @@ -1,20 +1,36 @@ import { ChangeDetectionStrategy, Component, + computed, input, ViewEncapsulation, } from "@angular/core"; import { CheckboxComponent } from "../../../form/checkbox/checkbox.component"; +import { + IconColor, + IconComponent, + IconSize, + IconType, + IconVariant, +} from "../../../base/icon/icon.component"; export type DropdownItemValueType = "default" | "checkbox" | "radio"; export type DropdownItemValueLayout = "horizontal" | "vertical"; +export interface DropdownItemValueIcon { + name: string; + size?: IconSize; + color?: IconColor; + type?: IconType; + variant?: IconVariant; +} + @Component({ selector: "tedi-dropdown-item-value", templateUrl: "./dropdown-item-value.component.html", styleUrl: "./dropdown-item-value.component.scss", standalone: true, - imports: [CheckboxComponent], + imports: [CheckboxComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { @@ -59,6 +75,27 @@ export class DropdownItemValueComponent { */ readonly disabled = input(false); + /** + * Leading icon: a Material Symbols name, or an object to override its size, + * colour, type or variant. Rendered at 16px in the secondary colour; it + * follows the item's text colour on hover, active, selected and disabled. + * For anything custom, project a `tedi-icon` instead. + */ + readonly icon = input(); + + readonly resolvedIcon = computed(() => { + const icon = this.icon(); + if (!icon) return null; + const { name, ...options } = + typeof icon === "string" ? { name: icon } : icon; + return { + size: 16 as IconSize, + color: "secondary" as IconColor, + ...options, + name, + }; + }); + /** * The checkbox/radio only pictures the item's state — the item itself owns the * interaction. Cancelling the default action keeps the browser from toggling the diff --git a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts index 51517cfd0..4ccfa1ad0 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-item-value/dropdown-item-value.stories.ts @@ -1,41 +1,239 @@ +import { + Component, + forwardRef, + input, + linkedSignal, + signal, + ViewEncapsulation, +} from "@angular/core"; 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"; +import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; +import { + DROPDOWN_API, + DROPDOWN_CONTENT_API, + DropdownApi, + DropdownContentApi, + DropdownRole, + DropdownVariant, +} from "../dropdown.tokens"; +import { RowComponent } from "../../../helpers/grid/row/row.component"; +import { ColComponent } from "../../../helpers/grid/col/col.component"; + +const noop = () => undefined; /** - * 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 - * - * ``` + * Renders real `li[tedi-dropdown-item]`s outside an open dropdown, so their states can be shown + * side by side. Stands in for the dropdown as a `listbox`; `selectedValue` sets which item looks + * selected. + */ +@Component({ + selector: "ul[app-dropdown-item-showcase]", + standalone: true, + template: "", + encapsulation: ViewEncapsulation.None, + host: { role: "listbox", "aria-label": "Näidis" }, + providers: [ + { + provide: DROPDOWN_API, + useExisting: forwardRef(() => DropdownItemShowcaseComponent), + }, + { + provide: DROPDOWN_CONTENT_API, + useExisting: forwardRef(() => DropdownItemShowcaseComponent), + }, + ], +}) +class DropdownItemShowcaseComponent implements DropdownApi, DropdownContentApi { + /** Value of the item shown as selected. */ + readonly selectedValue = input(); + readonly variant = input("default"); + + // Read-only: clicking an item in a listbox selects it, which would change what the story shows. + readonly value = Object.assign( + linkedSignal(() => this.selectedValue()), + { set: noop, update: noop }, + ); + + readonly dropdownRole = signal("listbox"); + readonly isWidget = signal(true); + readonly containerId = signal("dropdown-item-showcase"); + + focusNextItem = noop; + focusPrevItem = noop; + focusFirstItem = noop; + focusLastItem = noop; + hideDropdown = noop; + closeAndFocusTrigger = noop; + tabOutOfDropdown = noop; + dropdownTrigger = noop; +} + +const MENU_STYLE = + "display: flex; flex-direction: column; width: 100%; max-width: 304px; padding: 0; margin: 0; overflow: hidden; " + + "background: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); " + + "border-radius: var(--form-select-area-radius); box-shadow: 0 1px 5px 0 var(--tedi-alpha-20)"; + +const STATES = [ + "Default", + "Hover", + "Active", + "Selected", + "Disabled", + "Focus", +] as const; + +// "Show code" snippets. The stories render items in a stand-in menu so they can be shown side by +// side; these show how the same items are used in a real dropdown. +const SOURCE = { + items: ` + + +
  • + + Juurdepääs terviseandmetele + +
  • +
  • + + 2nd level + +
  • +
    +
    `, + + checkbox: ` + + +
  • + + Asukohad + +
  • + @for (city of cities; track city) { +
  • + + {{ city }} + +
  • + } +
    +
    `, + + radio: ` + + + @for (option of cities; track option) { +
  • + + {{ option }} + +
  • + } +
    +
    `, + + icon: ` + + +
  • + + Laadi alla + +
  • +
  • + + Kontaktid + + + + +
  • +
    +
    `, + + description: ` + + +
  • + + Juurdepääs terviseandmetele + Arstid näevad sinu terviseandmeid + +
  • +
  • + + Tallinn + 3 vaba aega saadaval + +
  • +
    +
    `, + + tree: ` + + +
  • + + Parent + +
  • +
  • + + Child 1 + +
  • +
    +
    `, + + states: ` + + +
  • + + Tekst + +
  • +
  • + + Tekst + +
  • +
    +
    `, + + position: ` + + +
  • + + Top + +
  • +
  • + + Middle + +
  • +
  • + + Bottom + +
  • +
    +
    `, +}; + +const sourceParams = (code: string) => ({ + docs: { source: { language: "html", code } }, +}); + +/** + * Figma ↗
    + * Zeroheight ↗ */ export default { @@ -48,7 +246,10 @@ export default { DropdownItemValueLabelComponent, DropdownItemValueMetaComponent, IconComponent, - VerticalSpacingDirective, + DropdownItemComponent, + DropdownItemShowcaseComponent, + RowComponent, + ColComponent, ], }), ], @@ -65,7 +266,7 @@ export default { layout: { control: "radio", options: ["horizontal", "vertical"], - description: "Layout of label and meta content", + description: "Whether the label and meta sit side by side or stacked.", table: { type: { summary: "DropdownItemValueLayout" }, defaultValue: { summary: "horizontal" }, @@ -73,8 +274,15 @@ export default { }, selected: { control: "boolean", - description: - "Whether the item is selected (controls checkbox/radio state)", + description: "Whether the checkbox or radio is checked.", + table: { + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + indeterminate: { + control: "boolean", + description: "Whether the checkbox is partly checked.", table: { type: { summary: "boolean" }, defaultValue: { summary: "false" }, @@ -82,12 +290,20 @@ export default { }, disabled: { control: "boolean", - description: "Whether the item is disabled", + description: "Whether the item is disabled.", table: { type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, + icon: { + control: "text", + description: + "Leading icon name, or an object to override its size, colour, type or variant.", + table: { + type: { summary: "string | DropdownItemValueIcon" }, + }, + }, clipContent: { control: "boolean", description: @@ -102,6 +318,7 @@ export default { type: "default", layout: "horizontal", selected: false, + indeterminate: false, disabled: false, clipContent: true, }, @@ -110,210 +327,433 @@ export default { type Story = StoryObj; export const Default: Story = { - parameters: { chromatic: { disableSnapshot: true } }, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + story: ` +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. + +Used in [Dropdown](/docs/tedi-ready-components-overlay-dropdown--docs), +[Select](/docs/tedi-ready-components-form-select--docs), +[Search](/docs/tedi-ready-components-form-search--docs) and +[Popover](/docs/tedi-ready-components-overlay-popover--docs) — see their stories for +examples in context. +`, + }, + }, + }, render: (args) => ({ props: args, template: ` - - Option 1 + + Valik 1 `, }), }; -export const WithMeta: Story = { - parameters: { chromatic: { disableSnapshot: true } }, - name: "With Meta (Horizontal)", +/** + * Plain items, and a nested set indented by level. + */ +export const Items: Story = { + parameters: sourceParams(SOURCE.items), render: () => ({ + props: { + menuStyle: MENU_STYLE, + items: ["Juurdepääs terviseandmetele", "Tahteavaldus", "Kontaktid"], + levels: [ + { label: "1st level", indent: 0 }, + { label: "2nd level", indent: 2 }, + { label: "3rd level", indent: 4 }, + { label: "3rd level", indent: 4 }, + { label: "4th level", indent: 6 }, + ], + }, template: ` - - Tallinn - 3 timeslots available - +
    +
      + @for (item of items; track item) { +
    • + + {{ item }} + +
    • + } +
    +
      + @for (level of levels; track $index) { +
    • + + {{ level.label }} + +
    • + } +
    +
    `, }), }; -export const Vertical: Story = { - parameters: { chromatic: { disableSnapshot: true } }, - name: "Vertical Layout", - render: () => ({ - template: ` - - Access to health data - Doctors will be able to see your health data - - `, - }), -}; +const CHECKBOX_GROUPS = [ + { label: "Asukohad", children: ["Tallinn", "Tartu"] }, + { label: "Arstid", children: ["Mari Allikas", "Tõnu Liblikas"] }, + { label: "Haiglad", children: ["PERH", "ITK", "Fertilitas"] }, +]; +const CHECKBOX_LEVELS = ["1st level", "2nd level", "3rd level", "4th level"]; + +/** + * Checkbox items for multi-select: groups whose checkbox reflects their items (checked, or + * indeterminate when only some are), a flat list, and a nested set indented by level. + */ export const WithCheckbox: Story = { - name: "With Checkbox", - render: () => ({ - props: { - selected: false, - }, - template: ` -
    - - Unchecked option - - - Checked option - - - Disabled option - - - Disabled checked option - -
    - `, - }), + parameters: sourceParams(SOURCE.checkbox), + render: () => { + const checked = signal(["Tartu"]); + const isChecked = (key: string) => checked().includes(key); + + return { + props: { + menuStyle: MENU_STYLE, + groups: CHECKBOX_GROUPS, + levels: CHECKBOX_LEVELS, + isChecked, + allChecked: (keys: string[]) => keys.every(isChecked), + someChecked: (keys: string[]) => + keys.some(isChecked) && !keys.every(isChecked), + toggle: (key: string) => + checked.update((current) => + current.includes(key) + ? current.filter((k) => k !== key) + : [...current, key], + ), + toggleAll: (keys: string[]) => { + const on = !keys.every(isChecked); + checked.update((current) => + on + ? [...current, ...keys.filter((k) => !current.includes(k))] + : current.filter((k) => !keys.includes(k)), + ); + }, + }, + template: ` +
    +
      + @for (group of groups; track group.label) { +
    • + + {{ group.label }} + +
    • + @for (child of group.children; track child) { +
    • + + {{ child }} + +
    • + } + } +
    +
      + @for (group of groups; track group.label) { +
    • + + {{ group.label }} + +
    • + } +
    +
      + @for (level of levels; track $index) { +
    • + + {{ level }} + +
    • + } +
    +
    + `, + }; + }, }; export const WithRadio: Story = { - name: "With Radio", + parameters: sourceParams(SOURCE.radio), + render: () => { + const selected = signal("Tallinn"); + + return { + props: { + menuStyle: MENU_STYLE, + cities: ["Tallinn", "Tartu", "Elva", "Rakvere"], + selected, + }, + template: ` +
      + @for (city of cities; track city) { +
    • + + {{ city }} + +
    • + } +
    + `, + }; + }, +}; + +export const WithIcon: Story = { + parameters: sourceParams(SOURCE.icon), render: () => ({ + props: { + menuStyle: MENU_STYLE, + links: ["Juurdepääs terviseandmetele", "Tahteavaldus", "Kontaktid"], + actions: [ + { icon: "download", label: "Laadi alla" }, + { icon: "add", label: "Lisa" }, + { icon: "delete", label: "Kustuta" }, + ], + }, template: ` -
    - - Unselected option - - - Selected option - - - Disabled option - - - Disabled selected option - +
    +
      + @for (link of links; track link) { +
    • + + {{ link }} + + + + +
    • + } +
    +
      + @for (action of actions; track action.label) { +
    • + + {{ action.label }} + +
    • + } +
    `, }), }; -export const WithIcon: Story = { - parameters: { chromatic: { disableSnapshot: true } }, - name: "With Leading Icon", +export const WithDescription: Story = { + parameters: sourceParams(SOURCE.description), render: () => ({ + props: { + menuStyle: MENU_STYLE, + accessOptions: [ + { + value: "selected", + label: "Juurdepääs terviseandmetele", + description: "Arstid näevad sinu terviseandmeid", + }, + { + value: "medications", + label: "Juurdepääs ravimitele ja terviseandmetele", + description: "Arstid näevad sinu ravimeid ja terviseandmeid", + }, + { + value: "all", + label: "Juurdepääs kõigele", + description: + "Arstid näevad kõiki sinu andmeid, sh terviseteatist ja muud meditsiinilist teavet", + }, + ], + locations: [ + { label: "Tallinn", meta: "3 vaba aega saadaval" }, + { label: "Tartu", meta: "4 vaba aega saadaval" }, + { label: "Elva", meta: "7 vaba aega saadaval" }, + { label: "Rakvere", meta: "3 vaba aega saadaval" }, + ], + }, template: ` -
    - - - Desktop - - - - Phone - - - - Tablet - +
    +
      + @for (option of accessOptions; track option.value) { +
    • + + {{ option.label }} + {{ option.description }} + +
    • + } +
    +
      + @for (location of locations; track location.label) { +
    • + + {{ location.label }} + {{ location.meta }} + +
    • + } +
    `, }), }; -export const WithIconAndMeta: Story = { - name: "With Icon and Meta", +/** + * Tree variant: indented items draw connector lines to their parent. Left: the parent sits at + * the top level; right: each parent is indented onto the line (`[indent]="1"` + `isParent`), so + * the line branches from the parent's bullet. + */ +export const WithTree: Story = { + parameters: sourceParams(SOURCE.tree), render: () => ({ + props: { + menuStyle: MENU_STYLE, + children: ["Child 1", "Child 2", "Child 3"], + groups: ["first", "second"], + }, template: ` -
    - - - Tallinn - 3 timeslots - - - - Tartu - 5 timeslots - +
    +
      +
    • + + Parent + +
    • + @for (child of children; track child) { +
    • + + {{ child }} + +
    • + } +
    +
      + @for (group of groups; track group) { +
    • + + Parent + +
    • + @for (child of children; track child) { +
    • + + {{ child }} + +
    • + } + } +
    `, }), }; -export const CheckboxWithMeta: Story = { - name: "Checkbox with Meta (Vertical)", +/** + * The first and last items follow the dropdown's rounded corners; items in between stay square. + * Shown with all items hovered. + */ +export const Position: Story = { + parameters: { + ...sourceParams(SOURCE.position), + pseudo: { + hover: ["#Position-top", "#Position-middle", "#Position-bottom"], + }, + }, render: () => ({ + props: { + positions: [ + { id: "top", label: "Top" }, + { id: "middle", label: "Middle" }, + { id: "bottom", label: "Bottom" }, + ], + }, 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 - -
    +
      + @for (position of positions; track position.id) { +
    • + + {{ position.label }} + +
    • + } +
    `, }), }; -export const AllVariants: Story = { - name: "All Variants", +export const States: Story = { + parameters: { + ...sourceParams(SOURCE.states), + pseudo: { + hover: ["#Hover-label", "#Hover-icon", "#Hover-meta"], + active: ["#Active-label", "#Active-icon", "#Active-meta"], + focusVisible: ["#Focus-label", "#Focus-icon", "#Focus-meta"], + }, + }, render: () => ({ + props: { states: STATES }, 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 - -
    -
    + + @for (state of states; track state) { + {{ state }} + +
      +
    • + + Tekst + +
    • +
    +
    + +
      +
    • + + Tekst + +
    • +
    +
    + +
      +
    • + + Tekst + Tekst + +
    • +
    +
    + } +
    `, }), }; diff --git a/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss b/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss index aae5fb9b6..1467f6f0c 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss +++ b/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss @@ -11,20 +11,128 @@ // A selected item keeps its active styling on hover — hover must not override // the active state, so selected items are excluded from the hover rule. - &:hover:not(.tedi-dropdown-item--disabled, [aria-disabled="true"], .tedi-dropdown-item--selected, [aria-selected="true"]) { + &:hover:not( + .tedi-dropdown-item--disabled, + [aria-disabled="true"], + .tedi-dropdown-item--selected, + [aria-selected="true"] + ) { color: var(--dropdown-item-hover-text); background: var(--dropdown-item-hover-background); + + .tedi-icon { + color: inherit; + } } &:focus-visible { - outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline: var(--tedi-borders-02) solid transparent; outline-offset: calc(-1 * var(--tedi-borders-02)); + border-radius: var(--dropdown-item-radius); + box-shadow: + inset 0 0 0 var(--tedi-borders-02) + var(--general-effect-colors-focus-outset-outer-shadow), + inset 0 0 0 var(--tedi-borders-03) + var(--general-effect-colors-focus-outset-inner-shadow); } &[aria-selected="true"], &.tedi-dropdown-item--selected { color: var(--dropdown-item-active-text); background: var(--dropdown-item-active-background); + + .tedi-icon { + color: inherit; + } + } + + &:active:not(.tedi-dropdown-item--disabled, [aria-disabled="true"]) { + color: var(--dropdown-item-active-text); + background: var(--dropdown-item-active-background); + + .tedi-icon { + color: inherit; + } + } + + &.tedi-dropdown-item--indent { + padding-left: calc( + var(--_tedi-dropdown-item-indent) + var(--dropdown-item-padding-x) + ); + } + + &.tedi-dropdown-item--tree-item, + &.tedi-dropdown-item--tree-parent { + --_tedi-dropdown-tree-line: var(--tedi-borders-02); // trunk and branch thickness + --_tedi-dropdown-tree-branch: 0.75rem; // branch length, also the gap before the text + --_tedi-dropdown-tree-bullet: 0.5rem; // parent bullet diameter + --_tedi-dropdown-tree-color: var(--navigation-vertical-tree-neutral-default); + --_tedi-dropdown-tree-x: calc(var(--dropdown-item-padding-x) * 1.5); + + position: relative; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: var(--_tedi-dropdown-tree-x); + width: var(--_tedi-dropdown-tree-line); + content: ""; + background-color: var(--_tedi-dropdown-tree-color); + } + } + + &.tedi-dropdown-item--indent:is( + .tedi-dropdown-item--tree-item, + .tedi-dropdown-item--tree-parent + ) { + padding-left: calc( + var(--dropdown-item-padding-x) + var(--_tedi-dropdown-tree-branch) + + var(--_tedi-dropdown-item-indent) + ); + } + + &.tedi-dropdown-item--tree-item::after { + position: absolute; + top: calc(50% - var(--_tedi-dropdown-tree-line) / 2); + left: var(--_tedi-dropdown-tree-x); + width: var(--_tedi-dropdown-tree-branch); + height: var(--_tedi-dropdown-tree-line); + content: ""; + background-color: var(--_tedi-dropdown-tree-color); + border-radius: 0 var(--button-radius-sm) var(--button-radius-sm) 0; + } + + // The last item of a group ends the trunk at its branch. A following parent starts a new group. + &.tedi-dropdown-item--tree-item:not( + :has(+ .tedi-dropdown-item--tree-item:not(.tedi-dropdown-item--tree-parent)) + )::before { + bottom: calc(50% - var(--_tedi-dropdown-tree-line) / 2); + } + + // A parent starts the trunk from its bullet. + &.tedi-dropdown-item--tree-parent { + &::before { + top: 50%; + } + + &::after { + position: absolute; + top: 50%; + left: calc( + var(--_tedi-dropdown-tree-x) + + ( + var(--_tedi-dropdown-tree-line) - var(--_tedi-dropdown-tree-bullet) + ) / + 2 + ); + width: var(--_tedi-dropdown-tree-bullet); + height: var(--_tedi-dropdown-tree-bullet); + content: ""; + background-color: var(--_tedi-dropdown-tree-color); + border-radius: 50%; + transform: translateY(-50%); + } } &[aria-disabled="true"], @@ -32,6 +140,10 @@ color: var(--general-text-disabled); cursor: not-allowed; background: var(--dropdown-item-disabled-background); + + .tedi-icon { + color: inherit; + } } } diff --git a/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts b/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts index 0b0478528..7f4bb0447 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.ts @@ -2,6 +2,7 @@ import { afterRenderEffect, ChangeDetectionStrategy, Component, + computed, contentChild, DestroyRef, ElementRef, @@ -34,8 +35,13 @@ const INTERACTIVE_CONTENT_SELECTOR = "a[href], button"; host: { "[attr.role]": "hostRole()", "[attr.aria-selected]": "hostAriaSelected()", + "[attr.aria-checked]": "hostAriaChecked()", "[attr.aria-disabled]": "hostAriaDisabled()", "[attr.tabindex]": "hostTabindex()", + "[class.tedi-dropdown-item--indent]": "indentVar()", + "[class.tedi-dropdown-item--tree-item]": "isTree() && !!indentVar()", + "[class.tedi-dropdown-item--tree-parent]": "isTree() && isParent()", + "[style.--_tedi-dropdown-item-indent]": "indentVar()", }, }) export class DropdownItemComponent { @@ -76,6 +82,27 @@ export class DropdownItemComponent { */ readonly closeOnSelect = input(true); + /** + * Indentation level in rem, for nested / hierarchical items, e.g. children + * under a "select all" item. `0` or unset means no indentation. + */ + readonly indent = input(); + + /** + * Marks the item as a parent in a `tree` dropdown: it gets a bullet on the + * connector line, and the line starts from it. Use with `indent` so it + * lines up with its children. Ignored outside the `tree` variant. + * @default false + */ + readonly isParent = input(false); + + readonly isTree = computed(() => this.dropdownContent.variant() === "tree"); + + readonly indentVar = computed(() => { + const indent = this.indent(); + return indent && indent > 0 ? `${indent}rem` : null; + }); + /** * Fires when the item is activated via click or keyboard (Enter / Space). * Use to react to selection without depending on click event ordering with @@ -90,6 +117,24 @@ export class DropdownItemComponent { /** Check if custom dropdown-item-value is provided */ readonly customItemValue = contentChild(DropdownItemValueComponent); + /** + * Type and state of a checkbox / radio `tedi-dropdown-item-value`, or `null` + * for other content. The item exposes this state to screen readers, because + * the checkbox / radio itself is hidden from them. + */ + readonly checkable = computed(() => { + const itemValue = this.customItemValue(); + const type = itemValue?.type(); + if (type !== "checkbox" && type !== "radio") return null; + + const mixed = type === "checkbox" && itemValue!.indeterminate(); + return { + type, + selected: itemValue!.selected(), + checked: mixed ? "mixed" : String(itemValue!.selected()), + }; + }); + private readonly renderer = inject(Renderer2); private readonly destroyRef = inject(DestroyRef); @@ -127,22 +172,31 @@ export class DropdownItemComponent { control.setAttribute("role", role); - // The control carries `role="option"` in a listbox, so the selection - // state has to travel with it instead of staying on the presentational - // host `li`. - if (this.dropdownContent.dropdownRole() === "listbox") { - control.setAttribute("aria-selected", String(this.isSelected())); - } else { - control.removeAttribute("aria-selected"); + // The control carries the item role, so the selection state has to + // travel with it instead of staying on the presentational host `li`. + const selected = this.ariaSelected(); + const checked = this.ariaChecked(); + for (const [name, value] of [ + ["aria-selected", selected], + ["aria-checked", checked], + ] as const) { + if (value === null) { + control.removeAttribute(name); + } else { + control.setAttribute(name, value); + } } }); } /** The widget role for this item, or `null` in a plain `list`. */ - itemRole(): "menuitem" | "option" | null { + itemRole(): + "menuitem" | "menuitemcheckbox" | "menuitemradio" | "option" | null { switch (this.dropdownContent.dropdownRole()) { - case "menu": - return "menuitem"; + case "menu": { + const type = this.checkable()?.type; + return type ? `menuitem${type}` : "menuitem"; + } case "listbox": return "option"; default: @@ -157,11 +211,25 @@ export class DropdownItemComponent { } hostAriaSelected() { - if (this.controlOwnsSemantics()) return null; + return this.controlOwnsSemantics() ? null : this.ariaSelected(); + } + + hostAriaChecked() { + return this.controlOwnsSemantics() ? null : this.ariaChecked(); + } + + /** `aria-selected` of an option: a checkbox / radio's own state, else the dropdown value. */ + private ariaSelected(): string | null { + if (this.dropdownContent.dropdownRole() !== "listbox") return null; + + return String(this.checkable()?.selected ?? this.isSelected()); + } + + /** `aria-checked` of a `menuitemcheckbox` / `menuitemradio`. */ + private ariaChecked(): string | null { + if (this.dropdownContent.dropdownRole() !== "menu") return null; - return this.dropdownContent.dropdownRole() === "listbox" - ? this.isSelected() - : null; + return this.checkable()?.checked ?? null; } hostAriaDisabled() { @@ -191,7 +259,10 @@ export class DropdownItemComponent { } isSelected() { - return this.dropdown.value() === this.value(); + // An item without a value is never selected; otherwise it would match an + // unset dropdown value (`undefined === undefined`). + const value = this.value(); + return value !== undefined && this.dropdown.value() === value; } /** diff --git a/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.scss b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.scss new file mode 100644 index 000000000..6f6975780 --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.scss @@ -0,0 +1,6 @@ +.tedi-dropdown-separator { + height: 0; + margin: 0; + list-style: none; + border-top: 1px solid var(--general-border-primary); +} diff --git a/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.spec.ts b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.spec.ts new file mode 100644 index 000000000..321dff705 --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.spec.ts @@ -0,0 +1,131 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { OverlayContainer } from "@angular/cdk/overlay"; +import { By } from "@angular/platform-browser"; +import { DropdownComponent } from "../dropdown.component"; +import { DropdownTriggerDirective } from "../dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "../dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "../dropdown-item/dropdown-item.component"; +import { DropdownRole } from "../dropdown.tokens"; +import { DropdownSeparatorComponent } from "./dropdown-separator.component"; + +@Component({ + standalone: true, + template: ` + + + + +
  • Item A
  • +
  • +
  • Item B
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + DropdownSeparatorComponent, + ], +}) +class TestHostComponent { + role: DropdownRole = "menu"; + divided = false; +} + +describe("DropdownSeparatorComponent", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Element.prototype as any).scrollIntoView = jest.fn(); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + fixture = TestBed.createComponent(TestHostComponent); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + }); + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + const open = () => { + dropdown.showDropdown(); + fixture.detectChanges(); + }; + + const getSeparator = () => + document.querySelector("li[tedi-dropdown-separator]") as HTMLLIElement; + + it("renders between the items in projection order", () => { + open(); + + const children = Array.from( + overlayContainerElement.querySelector(".tedi-dropdown-content__list")! + .children, + ); + expect(children.map((el) => el.textContent?.trim() || "—")).toEqual([ + "Item A", + "—", + "Item B", + ]); + expect(getSeparator().classList).toContain("tedi-dropdown-separator"); + }); + + it("is a separator in a menu", () => { + open(); + + expect(getSeparator().getAttribute("role")).toBe("separator"); + expect(getSeparator().hasAttribute("aria-hidden")).toBe(false); + }); + + it.each(["listbox", "list"])( + "is hidden from assistive technology in a %s", + (role) => { + fixture.componentInstance.role = role; + fixture.detectChanges(); + open(); + + expect(getSeparator().getAttribute("role")).toBe("none"); + expect(getSeparator().getAttribute("aria-hidden")).toBe("true"); + }, + ); + + it("is skipped by keyboard navigation", () => { + open(); + const [first, second] = Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + + dropdown.focusNextItem(first); + + expect(document.activeElement).toBe(second); + }); + + it("adds the divided modifier to the content", () => { + const content = () => + overlayContainerElement.querySelector( + ".tedi-dropdown-content", + ) as HTMLElement; + + open(); + expect(content().classList).not.toContain("tedi-dropdown-content--divided"); + + dropdown.hideDropdown(); + fixture.componentInstance.divided = true; + fixture.detectChanges(); + open(); + expect(content().classList).toContain("tedi-dropdown-content--divided"); + }); +}); diff --git a/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.ts b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.ts new file mode 100644 index 000000000..15d70bd8f --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-separator/dropdown-separator.component.ts @@ -0,0 +1,34 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + ViewEncapsulation, +} from "@angular/core"; +import { DROPDOWN_CONTENT_API } from "../dropdown.tokens"; + +/** + * A line between groups of dropdown items. Exposed as a `separator` in a + * `menu`; a `listbox` or `list` allows no such child, so there it is hidden + * from assistive technology and stays purely visual. + */ +@Component({ + selector: "li[tedi-dropdown-separator]", + standalone: true, + template: "", + styleUrl: "./dropdown-separator.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-dropdown-separator", + "[attr.role]": "isMenu() ? 'separator' : 'none'", + "[attr.aria-hidden]": "isMenu() ? null : 'true'", + }, +}) +export class DropdownSeparatorComponent { + private readonly dropdownContent = inject(DROPDOWN_CONTENT_API); + + readonly isMenu = computed( + () => this.dropdownContent.dropdownRole() === "menu", + ); +} diff --git a/src/tedi/components/overlay/dropdown/dropdown-separator/index.ts b/src/tedi/components/overlay/dropdown/dropdown-separator/index.ts new file mode 100644 index 000000000..0ebfcea6b --- /dev/null +++ b/src/tedi/components/overlay/dropdown/dropdown-separator/index.ts @@ -0,0 +1 @@ +export * from "./dropdown-separator.component"; diff --git a/src/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts b/src/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts index dcd66e015..fbb0dc9fc 100644 --- a/src/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts +++ b/src/tedi/components/overlay/dropdown/dropdown-trigger/dropdown-trigger.directive.ts @@ -18,6 +18,12 @@ export type DropdownTriggerAriaHasPopup = const FOCUSABLE_SELECTOR = "button, a[href], [tabindex]"; +/** + * Opens the dropdown. Place it on a ` + + +
  • Parent
  • +
  • Child
  • +
    + + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +class SizingHostComponent { + width: DropdownWidth = "auto"; + maxHeight?: number | string; + indent?: number = 1; +} + +describe("DropdownComponent sizing", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [SizingHostComponent] }); + fixture = TestBed.createComponent(SizingHostComponent); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + }); + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + const open = () => { + dropdown.showDropdown(); + fixture.detectChanges(); + }; + + const getPanel = () => + overlayContainerElement.querySelector( + ".tedi-dropdown__panel", + ) as HTMLElement; + + const getItems = () => + Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + + it("leaves the panel sized by content when width is auto", () => { + open(); + + expect(getPanel().style.width).toBe(""); + expect( + getPanel().classList.contains("tedi-dropdown__panel--fixed-width"), + ).toBe(false); + }); + + it.each([ + [320, "320px"], + ["320", "320px"], + ["20rem", "20rem"], + ])("sets an explicit panel width for %p", (width, expected) => { + fixture.componentInstance.width = width; + fixture.detectChanges(); + open(); + + expect(getPanel().style.width).toBe(expected); + expect( + getPanel().classList.contains("tedi-dropdown__panel--fixed-width"), + ).toBe(true); + }); + + it("matches the trigger width when width is trigger", () => { + fixture.componentInstance.width = "trigger"; + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector( + "[tedi-dropdown-trigger]", + ) as HTMLElement; + Object.defineProperty(trigger, "offsetWidth", { value: 150 }); + open(); + + expect(getPanel().style.width).toBe("150px"); + }); + + it("treats an unset bound width as auto", () => { + fixture.componentInstance.width = undefined as unknown as DropdownWidth; + fixture.detectChanges(); + + expect(() => open()).not.toThrow(); + expect(getPanel().style.width).toBe(""); + }); + + it("leaves the panel height unrestricted by default", () => { + open(); + + expect( + getPanel().classList.contains("tedi-dropdown__panel--max-height"), + ).toBe(false); + expect( + getPanel().style.getPropertyValue("--_tedi-dropdown-max-height"), + ).toBe(""); + }); + + it.each([ + [240, "240px"], + ["240", "240px"], + ["20rem", "20rem"], + ])("limits the panel height for %p", (maxHeight, expected) => { + fixture.componentInstance.maxHeight = maxHeight; + fixture.detectChanges(); + open(); + + expect( + getPanel().classList.contains("tedi-dropdown__panel--max-height"), + ).toBe(true); + expect( + getPanel().style.getPropertyValue("--_tedi-dropdown-max-height"), + ).toBe(expected); + }); + + it("indents an item by the given rem", () => { + open(); + const [parent, child] = getItems(); + + expect(child.classList.contains("tedi-dropdown-item--indent")).toBe(true); + expect(child.style.getPropertyValue("--_tedi-dropdown-item-indent")).toBe( + "1rem", + ); + expect(parent.classList.contains("tedi-dropdown-item--indent")).toBe(false); + }); + + it.each([0, undefined])("does not indent for %p", (indent) => { + fixture.componentInstance.indent = indent; + fixture.detectChanges(); + open(); + + expect(getItems()[1].classList.contains("tedi-dropdown-item--indent")).toBe( + false, + ); + }); +}); + +@Component({ + standalone: true, + template: ` + + + + +
  • Parent
  • +
  • Child
  • +
  • Plain
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +class TreeHostComponent { + variant: DropdownVariant = "tree"; +} + +describe("DropdownComponent tree variant", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TreeHostComponent] }); + fixture = TestBed.createComponent(TreeHostComponent); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + }); + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + const openAndGetItems = () => { + dropdown.showDropdown(); + fixture.detectChanges(); + return Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + }; + + it("marks indented items and parents in a tree", () => { + const [parent, child, plain] = openAndGetItems(); + + expect(parent.classList).toContain("tedi-dropdown-item--tree-parent"); + expect(child.classList).toContain("tedi-dropdown-item--tree-item"); + expect(child.classList).not.toContain("tedi-dropdown-item--tree-parent"); + expect(plain.classList).not.toContain("tedi-dropdown-item--tree-item"); + }); + + it("adds no tree classes outside the tree variant", () => { + fixture.componentInstance.variant = "default"; + fixture.detectChanges(); + const [parent, child] = openAndGetItems(); + + expect(parent.classList).not.toContain("tedi-dropdown-item--tree-parent"); + expect(child.classList).not.toContain("tedi-dropdown-item--tree-item"); + }); +}); + +@Component({ + standalone: true, + template: ` + + + + +
  • No value
  • +
  • Item A
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +class SelectionHostComponent { + value?: string; +} + +describe("DropdownItemComponent selection", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [SelectionHostComponent] }); + fixture = TestBed.createComponent(SelectionHostComponent); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + fixture.detectChanges(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + }); + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + const openAndGetSelected = () => { + dropdown.showDropdown(); + fixture.detectChanges(); + return Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ).map((li) => li.getAttribute("aria-selected")); + }; + + it("selects nothing when neither the dropdown nor the items have a value", () => { + expect(openAndGetSelected()).toEqual(["false", "false"]); + }); + + it("never selects an item without a value", () => { + fixture.componentInstance.value = "a"; + fixture.detectChanges(); + + expect(openAndGetSelected()).toEqual(["false", "true"]); + }); +}); + +@Component({ + standalone: true, + template: ` + + + + +
  • + + Checked + +
  • +
  • + Unchecked +
  • +
  • + + Mixed + +
  • +
  • + + Radio + +
  • +
  • Plain
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + DropdownItemValueComponent, + ], +}) +class CheckableHostComponent { + role: DropdownRole = "menu"; +} + +describe("DropdownItemComponent checkable state", () => { + let fixture: ComponentFixture; + let dropdown: DropdownComponent; + let overlayContainerElement: HTMLElement; + + const open = (role: DropdownRole) => { + fixture = TestBed.createComponent(CheckableHostComponent); + fixture.componentInstance.role = role; + fixture.detectChanges(); + overlayContainerElement = + TestBed.inject(OverlayContainer).getContainerElement(); + dropdown = fixture.debugElement.query(By.directive(DropdownComponent)) + .componentInstance as DropdownComponent; + dropdown.showDropdown(); + fixture.detectChanges(); + }; + + const getItems = () => + Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ) as HTMLLIElement[]; + + afterEach(() => { + dropdown.hideDropdown(); + overlayContainerElement.innerHTML = ""; + }); + + it("announces checkbox and radio items in a menu as checkable", () => { + open("menu"); + const [checked, unchecked, mixed, radio, plain] = getItems(); + + expect(checked.getAttribute("role")).toBe("menuitemcheckbox"); + expect(checked.getAttribute("aria-checked")).toBe("true"); + expect(unchecked.getAttribute("aria-checked")).toBe("false"); + expect(mixed.getAttribute("aria-checked")).toBe("mixed"); + expect(radio.getAttribute("role")).toBe("menuitemradio"); + expect(radio.getAttribute("aria-checked")).toBe("true"); + expect(plain.getAttribute("role")).toBe("menuitem"); + expect(plain.getAttribute("aria-checked")).toBeNull(); + }); + + it("uses the checkbox state for options in a multiselectable listbox", () => { + open("listbox"); + const [checked, unchecked] = getItems(); + + expect( + overlayContainerElement + .querySelector('[role="listbox"]') + ?.getAttribute("aria-multiselectable"), + ).toBe("true"); + expect(checked.getAttribute("role")).toBe("option"); + expect(checked.getAttribute("aria-selected")).toBe("true"); + expect(checked.getAttribute("aria-checked")).toBeNull(); + expect(unchecked.getAttribute("aria-selected")).toBe("false"); + }); + + it("adds no state in a plain list", () => { + open("list"); + const host = fixture.nativeElement as HTMLElement; + const checked = host.querySelector("li[tedi-dropdown-item]") as HTMLElement; + + expect(checked.getAttribute("role")).toBeNull(); + expect(checked.getAttribute("aria-checked")).toBeNull(); + expect( + host + .querySelector(".tedi-dropdown-content__list") + ?.getAttribute("aria-multiselectable"), + ).toBeNull(); + }); +}); diff --git a/src/tedi/components/overlay/dropdown/dropdown.component.ts b/src/tedi/components/overlay/dropdown/dropdown.component.ts index c4440ed8a..167b4a0c0 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.component.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.component.ts @@ -29,6 +29,18 @@ export type DropdownPosition = OverlayPosition; let dropdownIdCounter = 0; +/** + * Width of the dropdown panel: `auto` sizes to content (never narrower than the + * trigger), `trigger` matches the trigger, a number is px, a string any CSS width. + */ +export type DropdownWidth = "auto" | "trigger" | number | string; + +/** Numbers and numeric strings (e.g. `"240"`) are px; other strings are CSS lengths. */ +const toCssLength = (value: number | string): string => { + const length = typeof value === "number" ? String(value) : value.trim(); + return /^\d+(\.\d+)?$/.test(length) ? `${length}px` : length; +}; + @Component({ standalone: true, selector: "tedi-dropdown", @@ -77,6 +89,22 @@ export class DropdownComponent implements OnDestroy { */ readonly hideOnScroll = input(false); + /** + * Width of the dropdown panel. `auto` sizes to content, never narrower than + * the trigger; `trigger` matches the trigger width; a number is px; a string + * is any CSS width (e.g. `"20rem"`). + * @default auto + */ + readonly width = input("auto"); + + /** + * Limits the height of the dropdown panel; the items scroll when they are + * taller. A number is px, a string is any CSS length (e.g. `"20rem"`). Prefer + * rem, so the maximum height scales with the user's font size. A header + * (`tedi-dropdown-header`) stays in place above the scrolling items. + */ + readonly maxHeight = input(); + readonly dropdownTrigger = contentChild.required(DropdownTriggerDirective); readonly dropdownContent = contentChild.required(DropdownContentComponent); @@ -103,6 +131,21 @@ export class DropdownComponent implements OnDestroy { return w ? `${w}px` : null; }); + /** Panel maximum height, or `null` when unrestricted. */ + readonly panelMaxHeight = computed(() => { + const maxHeight = this.maxHeight(); + if (maxHeight == null || maxHeight === "") return null; + return toCssLength(maxHeight); + }); + + /** Explicit panel width, or `null` when sized by content. */ + readonly panelWidth = computed(() => { + const width = this.width(); + if (width == null || width === "auto" || width === "") return null; + if (width === "trigger") return this.triggerWidthVar(); + return toCssLength(width); + }); + private readonly platformId = inject(PLATFORM_ID); private readonly document = inject(DOCUMENT); private readonly renderer = inject(Renderer2); @@ -155,6 +198,8 @@ export class DropdownComponent implements OnDestroy { // Deferred so the overlay content is attached before focusing setTimeout(() => { + if (this.dropdownContent().header()?.focus()) return; + if (initialFocus === "first") { this.focusFirstItem(); } else if (initialFocus === "last") { @@ -315,7 +360,11 @@ export class DropdownComponent implements OnDestroy { if (fromIndex === -1) return; const prevIndex = this.getPrevEnabledIndex(fromIndex); - if (prevIndex == null) return; + if (prevIndex == null) { + // Up from the first item goes back to a header control, e.g. a search field. + this.dropdownContent().header()?.focus(); + return; + } const items = this.dropdownContent().items(); this.activeIndex.set(prevIndex); @@ -360,7 +409,7 @@ export class DropdownComponent implements OnDestroy { return getFocusableElements(this.dropdownContent().host.nativeElement); } - private closeAndFocusTrigger() { + closeAndFocusTrigger() { this.moveFocusOut(this.dropdownTrigger().focusableElement); } diff --git a/src/tedi/components/overlay/dropdown/dropdown.stories.ts b/src/tedi/components/overlay/dropdown/dropdown.stories.ts index 6816abacb..03dafc022 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.stories.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -1,4 +1,3 @@ -import { Component, ViewEncapsulation } from "@angular/core"; import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; import { DropdownComponent, DropdownPosition } from "./dropdown.component"; import { @@ -10,22 +9,20 @@ import { DropdownRole, } from "./dropdown-content/dropdown-content.component"; import { DropdownItemComponent } from "./dropdown-item/dropdown-item.component"; +import { DropdownSeparatorComponent } from "./dropdown-separator/dropdown-separator.component"; +import { DropdownHeaderDirective } from "./dropdown-header/dropdown-header.directive"; +import { SearchComponent } from "../../form/search/search.component"; +import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { TextComponent } from "../../base/text/text.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"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; import { expect, userEvent, waitFor, within } from "storybook/test"; -@Component({ - selector: "app-demo-button", - standalone: true, - imports: [ButtonComponent], - template: ``, - encapsulation: ViewEncapsulation.None, -}) -class DemoWrappingButtonComponent {} - const POSITIONS: DropdownPosition[] = [ "auto", "auto-start", @@ -44,8 +41,24 @@ const POSITIONS: DropdownPosition[] = [ "left-end", ]; +const POSITION_EXAMPLES: { position: DropdownPosition; label: string }[] = [ + { position: "bottom-start", label: "Bottom start" }, + { position: "bottom", label: "Bottom center" }, + { position: "bottom-end", label: "Bottom end" }, + { position: "top-start", label: "Top start" }, + { position: "top", label: "Top center" }, + { position: "top-end", label: "Top end" }, + { position: "right-start", label: "Right start" }, + { position: "right", label: "Right center" }, + { position: "right-end", label: "Right end" }, + { position: "left-start", label: "Left start" }, + { position: "left", label: "Left center" }, + { position: "left-end", label: "Left end" }, +]; + /** - * Figma ↗
    + * Dropdown Figma ↗
    + * DropdownItem Figma ↗
    * Zeroheight ↗ */ @@ -59,19 +72,26 @@ export default { DropdownTriggerDirective, DropdownContentComponent, DropdownItemComponent, + DropdownSeparatorComponent, + DropdownHeaderDirective, + SearchComponent, + SeparatorComponent, + TextComponent, DropdownItemValueComponent, DropdownItemValueLabelComponent, DropdownItemValueMetaComponent, ButtonComponent, IconComponent, - DemoWrappingButtonComponent, + RowComponent, + ColComponent, ], }), ], argTypes: { value: { control: "text", - description: "Current value of dropdown (used with listbox)", + description: + "Selected value in a `listbox` dropdown. Supports two-way binding.", table: { category: "dropdown", type: { summary: "string" }, @@ -80,8 +100,7 @@ export default { position: { control: "select", options: POSITIONS, - description: - "The position of the dropdown relative to the trigger element.", + description: "Position of the panel relative to the trigger.", table: { category: "dropdown", type: { summary: "DropdownPosition" }, @@ -91,7 +110,7 @@ export default { preventOverflow: { control: "boolean", description: - "Should position to opposite direction when overflowing screen?", + "Whether the panel flips to the opposite side when it would overflow the screen.", table: { category: "dropdown", type: { summary: "boolean" }, @@ -100,24 +119,77 @@ export default { }, hideOnScroll: { control: "boolean", - description: "Does the dropdown hide when the page scrolls?", + description: + "Whether the dropdown closes when the page or a scrollable ancestor scrolls.", table: { category: "dropdown", type: { summary: "boolean" }, defaultValue: { summary: "false" }, }, }, + offset: { + control: "number", + description: "Gap between the trigger and the panel, in px.", + table: { + category: "dropdown", + type: { summary: "number" }, + defaultValue: { summary: "4" }, + }, + }, + width: { + control: "text", + description: + "Width of the panel: `auto` fits the content (at least the trigger width), `trigger` matches the trigger, a number is px, a string is any CSS width.", + table: { + category: "dropdown", + type: { + summary: "DropdownWidth", + detail: "auto \ntrigger \nnumber \nstring", + }, + defaultValue: { summary: "auto" }, + }, + }, dropdownRole: { control: "radio", options: ["menu", "listbox", "list"], description: - "How the panel is exposed to assistive technology: `menu` for actions, `listbox` for selectable options, `list` for a plain list of links. `menu` and `listbox` are composite widgets (one tab stop, arrow-key navigation, `menuitem` / `option` items); `list` adds no roles or key handling, so projected links stay links and stay in the tab order. Every role moves focus into the panel when it opens, so the panel is not read last because of where the overlay renders.", + "Accessibility role of the panel: `menu` for actions, `listbox` for selectable options, `list` for links (they stay links and keep their tab stops).", table: { category: "dropdown-content", type: { summary: "DropdownRole", detail: "menu \nlistbox \nlist" }, defaultValue: { summary: "menu" }, }, }, + maxHeight: { + control: "text", + description: + "Limits the height of the dropdown panel; the items scroll when they are taller. A number is px, a string is any CSS length (e.g. `20rem`). Prefer rem, so the maximum height scales with the user's font size. A `tedi-dropdown-header` stays in place above the scrolling items.", + table: { + category: "dropdown", + type: { summary: "number | string" }, + }, + }, + divided: { + control: "boolean", + description: + "Whether to draw a line between items. For lines between groups only, use `li[tedi-dropdown-separator]`.", + table: { + category: "dropdown-content", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + variant: { + control: "radio", + options: ["default", "tree"], + description: + "`tree` draws connector lines from indented items to their parent.", + table: { + category: "dropdown-content", + type: { summary: "DropdownVariant", detail: "default \ntree" }, + defaultValue: { summary: "default" }, + }, + }, ariaHaspopup: { control: "radio", options: ["menu", "listbox", "dialog", "true", "false"], @@ -134,14 +206,15 @@ export default { }, itemValue: { name: "value", - description: "Item value", + description: + "Value of the item. In a `listbox`, the item matching the dropdown's `value` is selected.", table: { category: "dropdown-item", type: { summary: "string" }, }, }, disabled: { - description: "Is item disabled?", + description: "Whether the item is disabled.", table: { category: "dropdown-item", type: { summary: "boolean" }, @@ -149,7 +222,7 @@ export default { }, closeOnSelect: { description: - "Whether activating this item closes the dropdown. Set `false` for items that should keep the dropdown open after selection (e.g. multi-select checkboxes).", + "Whether selecting the item closes the dropdown. Set `false` for multi-select items.", control: "boolean", table: { category: "dropdown-item", @@ -167,6 +240,25 @@ export default { defaultValue: { summary: "true" }, }, }, + indent: { + description: + 'Indentation level in rem, for nested / hierarchical items, e.g. children under a "select all" item.', + control: "number", + table: { + category: "dropdown-item", + type: { summary: "number" }, + }, + }, + isParent: { + control: "boolean", + description: + "Whether the item is a parent in a `tree` dropdown: it gets a bullet and starts the connector line.", + table: { + category: "dropdown-item", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, interactiveContent: { description: 'Whether the projected content is itself the interactive control, e.g. a button. In a `menu` or `listbox` the item role and the roving tabindex move onto that element, so assistive technology reports one control per item. For navigation links use `dropdownRole="list"` instead, where a widget role would replace the link role.', @@ -199,61 +291,142 @@ export const Default: Story = { position: "bottom-start", preventOverflow: true, hideOnScroll: false, + offset: 4, dropdownRole: "menu", ariaHaspopup: "menu", }, render: (args) => ({ props: args, template: ` - - -
  • Access to health data
  • -
  • Declaration of intent
  • -
  • Contacts
  • +
  • Juurdepääs terviseandmetele
  • +
  • Juurdepääs isikuandmetele
  • +
  • Juurdepääs kontaktandmetele
  • `, }), }; -export const WithMeta: Story = { +export const Position: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: (args) => ({ + props: { + ...args, + examples: POSITION_EXAMPLES, + }, + template: ` + + @for (example of examples; track example.position) { + + + + +
  • Juurdepääs terviseandmetele
  • +
  • Juurdepääs isikuandmetele
  • +
  • Juurdepääs kontaktandmetele
  • +
    +
    +
    + } +
    + `, + }), + args: { + preventOverflow: true, + dropdownRole: "menu", + ariaHaspopup: "menu", + }, + argTypes: { + position: { table: { disable: true } }, + }, +}; + +/** + * In a `listbox` dropdown the item whose `value` matches the dropdown's `value` is shown as + * active. Bind `[(value)]` to keep the selection in sync with the trigger label. + */ +export const WithActiveItem: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + lang: "EST", + languages: ["EST", "ENG", "RUS"], + sort: "Uuemad ees", + sortOptions: [ + "Uuemad ees", + "Vanemad ees", + "Taotluse nimi A–Ü", + "Taotluse nimi Ü–A", + ], + }, + template: ` +
    +
    + + + + @for (option of languages; track option) { +
  • {{ option }}
  • + } +
    +
    +
    +
    + + + + @for (option of sortOptions; track option) { +
  • {{ option }}
  • + } +
    +
    +
    +
    + `, + }), +}; + +export const WithAction: Story = { parameters: { chromatic: { disableSnapshot: true } }, - name: "With Meta Text", args: { position: "bottom-start", preventOverflow: true, - dropdownRole: "listbox", - ariaHaspopup: "listbox", + dropdownRole: "menu", + ariaHaspopup: "menu", }, render: (args) => ({ props: args, template: ` - -
  • - - Tallinn - 3 timeslots - -
  • -
  • - - Tartu - 5 timeslots - -
  • -
  • - - Pärnu - 2 timeslots - -
  • +
  • Loo kontakt
  • +
  • Loo rakendus
  • +
  • Loo arve
  • `, @@ -262,7 +435,6 @@ export const WithMeta: Story = { export const WithIcons: Story = { parameters: { chromatic: { disableSnapshot: true } }, - name: "With Icons", args: { position: "bottom-start", preventOverflow: true, @@ -273,67 +445,395 @@ export const WithIcons: Story = { props: args, template: ` -
  • - - - Edit + + Laadi alla
  • - - - Duplicate + + Lisa
  • - - - Delete + + Kustuta + +
  • +
    +
    + `, + }), +}; + +type LocationOption = { id: string; label: string; selected: boolean }; + +const countSelected = (options: LocationOption[]) => + options.filter((option) => option.selected).length; + +const toggleOption = (options: LocationOption[], id: string) => { + const target = options.find((option) => option.id === id); + if (target) target.selected = !target.selected; +}; + +export const WithCheckbox: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + locations: [ + { id: "tallinn", label: "Tallinn", selected: true }, + { id: "tartu", label: "Tartu", selected: true }, + { id: "parnu", label: "Pärnu", selected: false }, + ] as LocationOption[], + countSelected, + toggleOption, + }, + template: ` + + + + @for (location of locations; track location.id) { +
  • + + {{ location.label }} + +
  • + } +
    +
    + `, + }), +}; + +export const WithIndentedItems: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + locations: [ + { id: "tallinn", label: "Tallinn", selected: false }, + { id: "tartu", label: "Tartu", selected: true }, + { id: "parnu", label: "Pärnu", selected: false }, + ] as LocationOption[], + countSelected, + toggleOption, + toggleAll(options: LocationOption[]) { + const select = countSelected(options) !== options.length; + options.forEach((option) => (option.selected = select)); + }, + }, + template: ` + + + +
  • + + Kõik asukohad
  • + @for (location of locations; track location.id) { +
  • + + {{ location.label }} + +
  • + } +
    +
    + `, + }), +}; + +export const WithRadio: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + city: "Tallinn", + cities: ["Tallinn", "Tartu", "Elva", "Rakvere"], + }, + template: ` + + + + @for (option of cities; track option) { +
  • + + {{ option }} + +
  • + }
    `, }), }; -export const VerticalLayout: Story = { +export const CustomWidth: Story = { parameters: { chromatic: { disableSnapshot: true } }, - name: "Vertical Layout", args: { - position: "bottom-start", - preventOverflow: true, - dropdownRole: "listbox", - ariaHaspopup: "listbox", + width: "326", }, render: (args) => ({ - props: args, + props: { + ...args, + items: ["Juurdepääs terviseandmetele", "Tahteavaldused", "Kontaktid"], + }, template: ` - - - -
  • - - Access to health data - Doctors will be able to see your health data + + @for (item of items; track item) { +
  • + + {{ item }} + + +
  • + } +
    +
    + `, + }), +}; + +/** + * Set `maxHeight` to limit the height of the dropdown's scrollable body. When the content is taller, + * the items scroll; accepts a `number` (px) or any CSS length string (e.g. `'20rem'`, `'50vh'`). + * Prefer rem, so the maximum height scales with the user's font size. + */ +export const MaxHeight: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + institutions: [ + "Fertilitas", + "Ida-Tallinna Keskhaigla", + "Lääne-Tallinna Keskhaigla", + "Põhja-Eesti Regionaalhaigla", + "Tallinna Lastehaigla", + "Tartu Ülikooli Kliinikum", + "Pärnu Haigla", + "Narva Haigla", + "Viljandi Haigla", + "Kuressaare Haigla", + ], + }, + template: ` + + + + @for (institution of institutions; track institution) { +
  • {{ institution }}
  • + } +
    +
    + `, + }), +}; + +/** + * With `hideOnScroll`, scrolling the page or any scrollable ancestor closes the dropdown; scrolling + * the dropdown's own items keeps it open. + */ +export const HideOnScroll: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + institutions: [ + "Fertilitas", + "Ida-Tallinna Keskhaigla", + "Lääne-Tallinna Keskhaigla", + "Põhja-Eesti Regionaalhaigla", + "Tallinna Lastehaigla", + "Tartu Ülikooli Kliinikum", + "Pärnu Haigla", + "Narva Haigla", + "Viljandi Haigla", + "Kuressaare Haigla", + ], + }, + template: ` +
    +
    + + + + @for (institution of institutions; track institution) { +
  • {{ institution }}
  • + } +
    +
    +
    +
    + `, + }), +}; + +export const WithDecription: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + props: { + access: "health", + accessOptions: [ + { + value: "health", + label: "Juurdepääs terviseandmetele", + description: "Arstid saavad näha teie terviseandmeid", + }, + { + value: "medications", + label: "Juurdepääs ravimitele ja terviseandmetele", + description: "Arstid saavad näha teie ravimeid ja terviseandmeid", + }, + { + value: "all", + label: "Juurdepääs kõigele", + description: + "Arstid saavad näha kogu teie teavet, sealhulgas tervise deklaratsiooni ja muid meditsiinilisi andmeid", + }, + ], + locations: [ + { value: "tallinn", label: "Tallinn", meta: "3 aega vaba" }, + { value: "tartu", label: "Tartu", meta: "4 aega vaba" }, + { value: "elva", label: "Elva", meta: "7 aega vaba" }, + { value: "rakvere", label: "Rakvere", meta: "3 aega vaba" }, + ], + }, + template: ` +
    +
    + + + + @for (option of accessOptions; track option.value) { +
  • + + {{ option.label }} + {{ option.description }} + +
  • + } +
    +
    +
    +
    + + + + @for (location of locations; track location.value) { +
  • + + {{ location.label }} + {{ location.meta }} + +
  • + } +
    +
    +
    +
    + `, + }), +}; + +export const Divided: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + template: ` + + + +
  • Profiil
  • +
  • Juurdepääsud
  • +
  • Arveldamine
  • +
  • + + Logi välja
  • -
  • - - Access to medications - Doctors will be able to see your medications + + + `, + }), +}; + +/** + * `li[tedi-dropdown-separator]` draws a line between groups of items. It is announced as a + * separator in a `menu` and skipped by arrow-key navigation. + */ +export const WithSeparator: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => ({ + template: ` + + + +
  • + + Redigeeri
  • -
  • - - Access to all - Doctors will be able to see all your information +
  • + + Kopeeri + +
  • +
  • +
  • + + Arhiiv + +
  • +
  • + + Kustuta
  • @@ -342,31 +842,55 @@ export const VerticalLayout: Story = { }), }; +type Representative = { name: string; code: string }; + /** - * When the trigger sits on a component that wraps its own ` + +
    + +
    + @for (rep of filterRepresentatives(representatives, query); track rep.name) { +
  • + + {{ rep.name }} + + {{ rep.code }} + +
  • + }
    `, @@ -393,16 +917,11 @@ export const KeepOpenOnSelect: Story = { props: { ...args, filters: [ - { id: "active", label: "Active", selected: true }, - { id: "inactive", label: "Inactive", selected: false }, - { id: "archived", label: "Archived", selected: false }, - { id: "drafts", label: "Drafts", selected: true, disabled: true }, - ] as Array<{ - id: string; - label: string; - selected: boolean; - disabled?: boolean; - }>, + { id: "active", label: "Aktiivne", selected: true }, + { id: "inactive", label: "Mitteaktiivne", selected: false }, + { id: "archived", label: "Arhiveeritud", selected: false }, + { id: "drafts", label: "Mustandid", selected: true }, + ] as Array<{ id: string; label: string; selected: boolean }>, toggleFilter( filters: Array<{ id: string; selected: boolean }>, id: string, @@ -415,22 +934,17 @@ export const KeepOpenOnSelect: Story = { @for (filter of filters; track filter.id) {
  • - + {{ filter.label }}
  • @@ -468,11 +982,11 @@ export const OpenForVisualTest: Story = { tags: ["!dev", "!autodocs"], render: vrRender(` - + -
  • Access to health data
  • -
  • Declaration of intent
  • -
  • Contacts
  • +
  • Juurdepääs terviseandmetele
  • +
  • Tahteavaldus
  • +
  • Kontaktid
  • `), @@ -484,24 +998,21 @@ export const OpenWithIconsForVisualTest: Story = { tags: ["!dev", "!autodocs"], render: vrRender(` - +
  • - - - Edit + + Muuda
  • - - - Duplicate + + Tee koopia
  • - - - Delete + + Kustuta
  • @@ -512,7 +1023,8 @@ export const OpenWithIconsForVisualTest: Story = { /** * Visual-regression only. A `listbox` menu with meta text: the meta column is what sets the - * item's two-line height and the panel's width. + * item's two-line height and the panel's width. The first item is selected, which also shows + * its corners clipped by the panel. */ export const OpenListboxMetaForVisualTest: Story = { tags: ["!dev", "!autodocs"], @@ -535,25 +1047,25 @@ export const OpenListboxMetaForVisualTest: Story = { }, }, render: vrRender(` - - + +
  • Tallinn - 3 timeslots + 3 vaba aega
  • Tartu - 5 timeslots + 5 vaba aega
  • Pärnu - 2 timeslots + 2 vaba aega
  • @@ -564,7 +1076,7 @@ export const OpenListboxMetaForVisualTest: Story = { /** * Visual-regression only. A long list is where the panel's max height and its scrollbar show, - * and `clipContent` is what decides whether a long label truncates or wraps. + * and the long label shows how labels wrap at a fixed width. */ export const OpenScrollingForVisualTest: Story = { tags: ["!dev", "!autodocs"], @@ -587,7 +1099,7 @@ export const OpenScrollingForVisualTest: Story = { }, }, render: vrRender(` - +
  • @@ -607,3 +1119,48 @@ export const OpenScrollingForVisualTest: Story = { `), play: vrPlay, }; + +/** Visual-regression only. `divided` draws its lines from the content, so only an open menu shows them. */ +export const OpenDividedForVisualTest: Story = { + tags: ["!dev", "!autodocs"], + render: vrRender(` + + + +
  • Profiil
  • +
  • Juurdepääsud
  • +
  • Arveldamine
  • +
  • + + Logi välja + +
  • +
    +
    +`), + play: vrPlay, +}; + +/** + * Visual-regression only. A header (here a search field) sits above the items with its own + * padding, and a separator splits the items into groups; both show only in an open menu. + */ +export const OpenWithHeaderForVisualTest: Story = { + tags: ["!dev", "!autodocs"], + render: vrRender(` + + + +
    + +
    +
  • Lauri Lepp
  • +
  • Mart Mardivere
  • +
  • +
  • Madis Mets
  • +
  • Kalle Kaasik
  • +
    +
    +`), + play: vrPlay, +}; diff --git a/src/tedi/components/overlay/dropdown/dropdown.tokens.ts b/src/tedi/components/overlay/dropdown/dropdown.tokens.ts index d3470fa53..573bc7fdc 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.tokens.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.tokens.ts @@ -13,6 +13,12 @@ import { InjectionToken, Signal, WritableSignal } from "@angular/core"; */ export type DropdownRole = "menu" | "listbox" | "list"; +/** + * Visual variant of the dropdown content. `tree` draws connector lines from + * indented items to their parent. + */ +export type DropdownVariant = "default" | "tree"; + export interface DropdownApi { /** Current value of the dropdown. Used to track the selected option in listbox mode. */ value: WritableSignal; @@ -28,6 +34,8 @@ export interface DropdownApi { focusLastItem(): void; /** Close the dropdown and reset active item state. */ hideDropdown(): void; + /** Move focus to the trigger, then close the dropdown. */ + closeAndFocusTrigger(): void; /** Close the dropdown and move focus to the element before (Shift+Tab) or after (Tab) the trigger in document tab order, so tabbing exits the menu as if it weren't there. */ tabOutOfDropdown(shiftKey: boolean): void; /** Reference to the dropdown trigger directive. `host` is the element the directive sits on (used for positioning and dimensions); `focus()` moves focus to the actual interactive trigger element, which may be a button nested inside a wrapping component; `focusableElement` is that same interactive element. */ @@ -47,6 +55,8 @@ export interface DropdownContentApi { dropdownRole: Signal; /** Whether the content is a composite widget (`menu`, `listbox`) rather than a plain `list`. Widgets own focus management and key handling; a plain list leaves both to the items' own controls. */ isWidget: Signal; + /** Visual variant. `tree` makes indented items draw connector lines to their parent. */ + variant: Signal; } export const DROPDOWN_CONTENT_API = new InjectionToken( diff --git a/src/tedi/components/overlay/dropdown/index.ts b/src/tedi/components/overlay/dropdown/index.ts index e821f7e86..edfa66aca 100644 --- a/src/tedi/components/overlay/dropdown/index.ts +++ b/src/tedi/components/overlay/dropdown/index.ts @@ -2,5 +2,7 @@ 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-separator"; +export * from "./dropdown-header"; export * from "./dropdown-trigger/dropdown-trigger.directive"; export * from "./dropdown.component"; From cb6440eb3dbf10ddbe63e0657873909d455b18a7 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 16 Oct 2026 16:19:04 +0300 Subject: [PATCH 2/2] fix(dropdown): add CR fixes #328 --- .../filter/filter.component.spec.ts | 4 +- .../dropdown/dropdown.component.spec.ts | 39 +++++++++++++++++++ .../overlay/dropdown/dropdown.component.ts | 4 +- .../overlay/dropdown/dropdown.stories.ts | 2 +- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/tedi/components/filter/filter.component.spec.ts b/src/tedi/components/filter/filter.component.spec.ts index 5a0fa648e..01110ce55 100644 --- a/src/tedi/components/filter/filter.component.spec.ts +++ b/src/tedi/components/filter/filter.component.spec.ts @@ -853,12 +853,12 @@ describe("FilterComponent", () => { expect(options.nativeElement.getAttribute("tabindex")).toBe("0"); }); - it("should not have tabbable checkbox inputs inside dropdown-item-value", () => { + it("should not have focusable checkbox inputs inside dropdown-item-value", () => { const checkboxes = fixture.debugElement.queryAll( By.css(".tedi-filter-dropdown__options input[type='checkbox']"), ); checkboxes.forEach((cb) => { - expect(cb.nativeElement.getAttribute("tabindex")).toBe("-1"); + expect(cb.nativeElement.hasAttribute("inert")).toBe(true); }); }); }); diff --git a/src/tedi/components/overlay/dropdown/dropdown.component.spec.ts b/src/tedi/components/overlay/dropdown/dropdown.component.spec.ts index 859a29ce2..70b8ab8b4 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.component.spec.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.component.spec.ts @@ -1609,6 +1609,27 @@ class SelectionHostComponent { value?: string; } +@Component({ + standalone: true, + template: ` + + + + +
  • Item A
  • +
  • No value
  • +
    +
    + `, + imports: [ + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +class UnsetValueHostComponent {} + describe("DropdownItemComponent selection", () => { let fixture: ComponentFixture; let dropdown: DropdownComponent; @@ -1647,6 +1668,24 @@ describe("DropdownItemComponent selection", () => { expect(openAndGetSelected()).toEqual(["false", "true"]); }); + + it("starts on the first item, not a value-less one, when no value is set", () => { + const unsetFixture = TestBed.createComponent(UnsetValueHostComponent); + unsetFixture.detectChanges(); + const unsetDropdown = unsetFixture.debugElement.query( + By.directive(DropdownComponent), + ).componentInstance as DropdownComponent; + + unsetDropdown.showDropdown(); + unsetFixture.detectChanges(); + + const tabindexes = Array.from( + overlayContainerElement.querySelectorAll("li[tedi-dropdown-item]"), + ).map((li) => li.getAttribute("tabindex")); + unsetDropdown.hideDropdown(); + + expect(tabindexes).toEqual(["0", "-1"]); + }); }); @Component({ diff --git a/src/tedi/components/overlay/dropdown/dropdown.component.ts b/src/tedi/components/overlay/dropdown/dropdown.component.ts index 167b4a0c0..fad13c23b 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.component.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.component.ts @@ -309,9 +309,7 @@ export class DropdownComponent implements OnDestroy { setActiveToSelectedOrFirst() { const items = this.dropdownContent().items(); - const selectedIndex = items.findIndex( - (item) => item.value() === this.value(), - ); + const selectedIndex = items.findIndex((item) => item.isSelected()); if (selectedIndex !== -1 && !items[selectedIndex].disabled()) { this.activeIndex.set(selectedIndex); diff --git a/src/tedi/components/overlay/dropdown/dropdown.stories.ts b/src/tedi/components/overlay/dropdown/dropdown.stories.ts index 03dafc022..c9a420438 100644 --- a/src/tedi/components/overlay/dropdown/dropdown.stories.ts +++ b/src/tedi/components/overlay/dropdown/dropdown.stories.ts @@ -702,7 +702,7 @@ export const HideOnScroll: Story = { }), }; -export const WithDecription: Story = { +export const WithDescription: Story = { parameters: { chromatic: { disableSnapshot: true } }, render: () => ({ props: {