Skip to content
Closed
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
71 changes: 64 additions & 7 deletions src/cdk-experimental/accordion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
booleanAttribute,
computed,
WritableSignal,
Renderer2,
} from '@angular/core';
import {_IdGenerator} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
Expand Down Expand Up @@ -45,7 +46,7 @@ import {
'class': 'cdk-accordion-panel',
'role': 'region',
'[attr.id]': 'pattern.id()',
'[attr.aria-labelledby]': 'pattern.accordionTrigger()?.id()',
'[attr.aria-labelledby]': 'pattern.accordionTrigger()?.visuallyHiddenId()',
'[attr.inert]': 'pattern.hidden() ? true : null',
},
})
Expand All @@ -71,7 +72,7 @@ export class CdkAccordionPanel {
});

constructor() {
// Connect the panel's hidden state to the DeferredContentAware's visibility.
/** Connect the panel's hidden state to the DeferredContentAware's visibility. */
afterRenderEffect(() => {
this._deferredContentAware.contentVisible.set(!this.pattern.hidden());
});
Expand All @@ -93,7 +94,6 @@ export class CdkAccordionPanel {
'[attr.aria-expanded]': 'pattern.expanded()',
'[attr.aria-controls]': 'pattern.controls()',
'[attr.aria-disabled]': 'pattern.disabled()',
'[attr.inert]': 'hardDisabled() ? true : null',
'[attr.disabled]': 'hardDisabled() ? true : null',
'[attr.tabindex]': 'pattern.tabindex()',
'(keydown)': 'pattern.onKeydown($event)',
Expand All @@ -105,9 +105,14 @@ export class CdkAccordionTrigger {
/** A global unique identifier for the trigger. */
private readonly _id = inject(_IdGenerator).getId('cdk-accordion-trigger-');

/** A computed signal to generate a consistent ID for the visually hidden label. */
private readonly _visuallyHiddenId = inject(_IdGenerator).getId('cdk-accordion-label-');

/** A reference to the trigger element. */
private readonly _elementRef = inject(ElementRef);

private readonly _renderer = inject(Renderer2);

/** The parent CdkAccordionGroup. */
private readonly _accordionGroup = inject(CdkAccordionGroup);

Expand All @@ -130,12 +135,62 @@ export class CdkAccordionTrigger {
/** The UI pattern instance for this trigger. */
readonly pattern: AccordionTriggerPattern = new AccordionTriggerPattern({
id: () => this._id,
visuallyHiddenId: () => this._visuallyHiddenId,
value: this.value,
disabled: this.disabled,
element: () => this._elementRef.nativeElement,
accordionGroup: computed(() => this._accordionGroup.pattern),
accordionPanel: this.accordionPanel,
});

/**
* The computed label value of this Accordion Trigger to be passed to a visually hidden
* span that is accessible to screen readers whether the button is disabled or not.
*/
readonly visuallyHiddenLabel = computed(() => {
let buttonText = '';
const buttonElement = this._elementRef.nativeElement;
for (const node of Array.from(buttonElement.childNodes)) {
if (node instanceof Node && node.nodeType === Node.TEXT_NODE) {
buttonText += (node as Text).textContent?.trim() + ' ';
}
}

/** Combine all parts into the final label. */
return `${buttonText.trim()}`.trim();
});

constructor() {
afterRenderEffect(() => {
const buttonElement = this._elementRef.nativeElement;
const parentElement = this._renderer.parentNode(buttonElement);

if (parentElement) {
/** Create the span and attach it to the DOM only once. */
if (!this._visuallyHiddenSpan) {
this._visuallyHiddenSpan = this._renderer.createElement('span');
this._renderer.addClass(this._visuallyHiddenSpan, 'cdk-visually-hidden');
this._renderer.setAttribute(
this._visuallyHiddenSpan,
'id',
this.pattern.visuallyHiddenId(),
);
this._renderer.setAttribute(this._visuallyHiddenSpan, 'tabindex', '-1');
this._renderer.insertBefore(parentElement, this._visuallyHiddenSpan, buttonElement);
}

/** Update its text content whenever the signal changes. */
this._renderer.setProperty(
this._visuallyHiddenSpan,
'textContent',
this.visuallyHiddenLabel(),
);
}
});
}

/** Add a private property to store a reference to the span. */
private _visuallyHiddenSpan!: HTMLElement;
}

/**
Expand Down Expand Up @@ -180,18 +235,20 @@ export class CdkAccordionGroup {
/** The UI pattern instance for this accordion group. */
readonly pattern: AccordionGroupPattern = new AccordionGroupPattern({
...this,
// TODO(ok7sai): Consider making `activeItem` an internal state in the pattern and call
// `setDefaultState` in the CDK.
/**
* TODO(ok7sai): Consider making `activeItem` an internal state in the pattern and call
* `setDefaultState` in the CDK.
*/
activeItem: signal(undefined),
items: computed(() => this._triggers().map(trigger => trigger.pattern)),
expandedIds: this.value,
// TODO(ok7sai): Investigate whether an accordion should support horizontal mode.
/** TODO(ok7sai): Investigate whether an accordion should support horizontal mode. */
orientation: () => 'vertical',
element: () => this._elementRef.nativeElement,
});

constructor() {
// Effect to link triggers with their corresponding panels and update the group's items.
/** Effect to link triggers with their corresponding panels and update the group's items. */
afterRenderEffect(() => {
const triggers = this._triggers();
const panels = this._panels();
Expand Down
7 changes: 7 additions & 0 deletions src/cdk-experimental/ui-patterns/accordion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export type AccordionTriggerInputs = Omit<ListNavigationItem & ListFocusItem, 'i

/** The accordion panel controlled by this trigger. */
accordionPanel: SignalLike<AccordionPanelPattern | undefined>;

/**
* The id of the visually hidden span associated with the Accordion Trigger to be
* referenced by screen readers at all times for consistent accessibility.
*/
visuallyHiddenId: SignalLike<string>;
};

export interface AccordionTriggerPattern extends AccordionTriggerInputs {}
Expand Down Expand Up @@ -118,6 +124,7 @@ export class AccordionTriggerPattern {
this.value = inputs.value;
this.accordionGroup = inputs.accordionGroup;
this.accordionPanel = inputs.accordionPanel;
this.visuallyHiddenId = inputs.visuallyHiddenId;
this.expansionControl = new ExpansionControl({
...inputs,
expansionId: inputs.value,
Expand Down
Loading