Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/tedi/components/filter/filter.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<ng-content select="[tedi-dropdown-header]" />
<ng-content />
<ul
class="tedi-dropdown-content__list"
[class.tedi-dropdown-content__list--plain]="!isWidget()"
[id]="containerId()"
[attr.role]="dropdownRole()"
[attr.aria-labelledby]="containerId() + '_trigger'"
[attr.aria-multiselectable]="multiselectable() || null"
>
<ng-content select="li[tedi-dropdown-item]" />
<ng-content select="li[tedi-dropdown-item], li[tedi-dropdown-separator]" />
</ul>
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,12 @@
// `list` mode writes out role="list" rather than relying on the <ul>.
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
contentChild,
contentChildren,
ElementRef,
forwardRef,
Expand All @@ -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",
Expand All @@ -28,6 +31,7 @@ export type { DropdownRole };
host: {
class: "tedi-dropdown-content",
role: "presentation",
"[class.tedi-dropdown-content--divided]": "divided()",
},
providers: [
{
Expand Down Expand Up @@ -56,10 +60,33 @@ export class DropdownContentComponent {
*/
readonly dropdownRole = input<DropdownRole>("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<DropdownVariant>("default");

readonly host = inject<ElementRef<HTMLElement>>(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"),
);
}
Original file line number Diff line number Diff line change
@@ -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: `
<tedi-dropdown>
<button tedi-dropdown-trigger>Trigger</button>

<tedi-dropdown-content>
<div tedi-dropdown-header>
<input aria-label="Search" />
</div>
<li tedi-dropdown-item value="a">Item A</li>
<li tedi-dropdown-item value="b">Item B</li>
</tedi-dropdown-content>
</tedi-dropdown>
`,
imports: [
DropdownComponent,
DropdownTriggerDirective,
DropdownContentComponent,
DropdownItemComponent,
DropdownHeaderDirective,
],
})
class TestHostComponent {}

describe("DropdownHeaderDirective", () => {
let fixture: ComponentFixture<TestHostComponent>;
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);
}));
});
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>>(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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./dropdown-header.directive";
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
type="checkbox"
tedi-checkbox
class="tedi-dropdown-item-value__checkbox"
tabindex="-1"
inert
aria-hidden="true"
[checked]="selected()"
[indeterminate]="indeterminate()"
Expand All @@ -14,13 +14,22 @@
<input
type="radio"
class="tedi-dropdown-item-value__radio"
tabindex="-1"
inert
aria-hidden="true"
[checked]="selected()"
[disabled]="disabled()"
(click)="onIndicatorClick($event)"
/>
}
@if (resolvedIcon(); as icon) {
<tedi-icon
[name]="icon.name"
[size]="icon.size"
[color]="icon.color"
[type]="icon.type ?? 'outlined'"
[variant]="icon.variant ?? 'outlined'"
/>
}
<ng-content select="tedi-icon, [tediIcon]" />
<div class="tedi-dropdown-item-value__content">
<ng-content select="tedi-dropdown-item-value-label" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading