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: `
+
+
+
`,
}),
};
-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) {
+