Skip to content
Draft
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
5 changes: 4 additions & 1 deletion goldens/aria/accordion/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ export class AccordionPanel {
readonly element: HTMLElement;
expand(): void;
readonly id: _angular_core.InputSignal<string>;
readonly label: _angular_core.InputSignal<string | undefined>;
readonly _labelControl: LabelControl;
readonly labelledBy: _angular_core.InputSignal<string[]>;
_pattern?: AccordionTriggerPattern;
toggle(): void;
readonly visible: _angular_core.Signal<boolean>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "label": { "alias": "label"; "required": false; "isSignal": true; }; "labelledBy": { "alias": "labelledBy"; "required": false; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionPanel, never>;
}
Expand Down
16 changes: 16 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,22 @@ export interface HasElement {
element: HTMLElement;
}

// @public
export class LabelControl {
constructor(inputs: LabelControlInputs);
// (undocumented)
readonly inputs: LabelControlInputs;
readonly label: SignalLike<string | undefined>;
readonly labelledBy: SignalLike<string | undefined>;
}

// @public
export interface LabelControlInputs {
defaultLabelledBy: SignalLike<string | undefined>;
label: SignalLike<string | undefined>;
labelledBy: SignalLike<string[] | undefined>;
}

// @public (undocumented)
export function linkedSignal<T>(sourceFn: () => T): WritableSignalLike<T>;

Expand Down
5 changes: 4 additions & 1 deletion goldens/aria/tabs/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export class TabPanel implements OnInit, OnDestroy {
constructor();
readonly element: HTMLElement;
readonly id: _angular_core.InputSignal<string>;
readonly label: _angular_core.InputSignal<string | undefined>;
readonly _labelControl: LabelControl;
readonly labelledBy: _angular_core.InputSignal<string[]>;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
Expand All @@ -84,7 +87,7 @@ export class TabPanel implements OnInit, OnDestroy {
readonly value: _angular_core.InputSignal<string>;
readonly visible: _angular_core.Signal<boolean>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabPanel, "[ngTabPanel]", ["ngTabPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabPanel, "[ngTabPanel]", ["ngTabPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "label": { "alias": "label"; "required": false; "isSignal": true; }; "labelledBy": { "alias": "labelledBy"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": true; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TabPanel, never>;
}
Expand Down
20 changes: 18 additions & 2 deletions src/aria/accordion/accordion-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core';
import {_IdGenerator} from '@angular/cdk/a11y';
import {DeferredContentAware, AccordionTriggerPattern} from '../private';
import {AccordionTriggerPattern, DeferredContentAware, LabelControl} from '../private';

/**
* The content panel of an accordion item that is conditionally visible.
Expand Down Expand Up @@ -43,7 +43,8 @@ import {DeferredContentAware, AccordionTriggerPattern} from '../private';
host: {
'role': 'region',
'[attr.id]': 'id()',
'[attr.aria-labelledby]': '_pattern?.id()',
'[attr.aria-label]': '_labelControl.label()',
'[attr.aria-labelledby]': '_labelControl.labelledBy()',
'[attr.inert]': '!visible() ? true : null',
},
})
Expand All @@ -57,9 +58,18 @@ export class AccordionPanel {
/** The DeferredContentAware host directive. */
private readonly _deferredContentAware = inject(DeferredContentAware);

/** Controls label for this tabpanel. */
readonly _labelControl: LabelControl;

/** A global unique identifier for the panel. */
readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true));

/** The (optional) label for the accordion panel. */
readonly label = input<string | undefined>(undefined);

/** The (optional) labelledBy ids for the accordion panel. */
readonly labelledBy = input<string[]>([]);

/** Whether the accordion panel is visible. True if the associated trigger is expanded. */
readonly visible = computed(() => this._pattern?.expanded() === true);

Expand All @@ -71,6 +81,12 @@ export class AccordionPanel {
_pattern?: AccordionTriggerPattern;

constructor() {
this._labelControl = new LabelControl({
defaultLabelledBy: computed(() => this._pattern?.id()),
label: this.label,
labelledBy: this.labelledBy,
});

// Connect the panel's hidden state to the DeferredContentAware's visibility.
afterRenderEffect({
write: () => {
Expand Down
1 change: 1 addition & 0 deletions src/aria/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ts_project(
deps = [
"//:node_modules/@angular/core",
"//src/aria/private/accordion",
"//src/aria/private/behaviors/label",
"//src/aria/private/behaviors/signal-like",
"//src/aria/private/combobox",
"//src/aria/private/deferred-content",
Expand Down
35 changes: 19 additions & 16 deletions src/aria/private/behaviors/label/label.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,19 @@
*/

import {signal, WritableSignalLike} from '../signal-like/signal-like';
import {LabelControl, LabelControlInputs, LabelControlOptionalInputs} from './label';
import {LabelControl, LabelControlInputs} from './label';

// This is a helper type for the initial values passed to the setup function.
type TestInputs = Partial<{
label: string | undefined;
defaultLabelledBy: string[];
defaultLabelledBy: string;
labelledBy: string[];
}>;

type TestLabelControlInputs = LabelControlInputs & Required<LabelControlOptionalInputs>;

// This is a helper type to make all properties of LabelControlInputs writable signals.
type WritableLabelControlInputs = {
[K in keyof TestLabelControlInputs]: WritableSignalLike<
TestLabelControlInputs[K] extends {(): infer T} ? T : never
[K in keyof LabelControlInputs]: WritableSignalLike<
LabelControlInputs[K] extends {(): infer T} ? T : never
>;
};

Expand All @@ -30,7 +28,7 @@ function getLabelControl(initialValues: TestInputs = {}): {
inputs: WritableLabelControlInputs;
} {
const inputs: WritableLabelControlInputs = {
defaultLabelledBy: signal(initialValues.defaultLabelledBy ?? []),
defaultLabelledBy: signal(initialValues.defaultLabelledBy),
label: signal(initialValues.label),
labelledBy: signal(initialValues.labelledBy ?? []),
};
Expand All @@ -47,6 +45,11 @@ describe('LabelControl', () => {
expect(control.label()).toBe('My Label');
});

it('should return the user-provided label even ifdefaultLabelledBy is provided ', () => {
const {control} = getLabelControl({defaultLabelledBy: 'default-id', label: 'My Label'});
expect(control.label()).toBe('My Label');
});

it('should return undefined if no label is provided', () => {
const {control} = getLabelControl();
expect(control.label()).toBeUndefined();
Expand All @@ -62,27 +65,27 @@ describe('LabelControl', () => {
});

describe('#labelledBy', () => {
it('should return user-provided labelledBy even if a label is provided', () => {
it('should return user-provided labelledBy ids even if a label is provided', () => {
const {control} = getLabelControl({
label: 'My Label',
defaultLabelledBy: ['default-id'],
labelledBy: ['user-id'],
defaultLabelledBy: 'default-id',
labelledBy: ['user-id', 'user-id-2'],
});
expect(control.labelledBy()).toEqual(['user-id']);
expect(control.labelledBy()).toEqual('user-id user-id-2');
});

it('should return defaultLabelledBy if no user-provided labelledBy exists', () => {
const {control} = getLabelControl({defaultLabelledBy: ['default-id']});
expect(control.labelledBy()).toEqual(['default-id']);
const {control} = getLabelControl({defaultLabelledBy: 'default-id'});
expect(control.labelledBy()).toEqual('default-id');
});

it('should update when label changes from undefined to a string', () => {
const {control, inputs} = getLabelControl({
defaultLabelledBy: ['default-id'],
defaultLabelledBy: 'default-id',
});
expect(control.labelledBy()).toEqual(['default-id']);
expect(control.labelledBy()).toEqual('default-id');
inputs.label.set('A wild label appears');
expect(control.labelledBy()).toEqual([]);
expect(control.labelledBy()).toEqual(undefined);
});
});
});
44 changes: 21 additions & 23 deletions src/aria/private/behaviors/label/label.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we stop the "I want this in my own style" kind of refactoring? I don't see the core functionality has changed after touching almost every single line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the PR comments for the 3 benefits in usage. If we go to the trouble of creating a utility, it should be easy and clear in its usage. Otherwise, could literally just do the logic more simply in the pattern. In this case, it turned out to be premature re-factoring as the LabelControl was not used in any beneficial way previously, just adding to burden.

Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,42 @@
*/
import {computed, SignalLike} from '../signal-like/signal-like';

/** Represents the required inputs for the label control. */
/** The required inputs for the label control. */
export interface LabelControlInputs {
/** The default `aria-labelledby` ids. */
defaultLabelledBy: SignalLike<string[]>;
}
/** The default `aria-labelledby` id to use if no other inputs specified. */
defaultLabelledBy: SignalLike<string | undefined>;

/** Represents the optional inputs for the label control. */
export interface LabelControlOptionalInputs {
/** The `aria-label`. */
label?: SignalLike<string | undefined>;
/** The `aria-label` to use instead of the default id. */
label: SignalLike<string | undefined>;

/** The user-provided `aria-labelledby` ids. */
labelledBy?: SignalLike<string[]>;
/** The `aria-labelledby` id(s) to use instead of the default id (or label). */
labelledBy: SignalLike<string[] | undefined>;
}

/** Controls label and description of an element. */
/** Controls label for an element. */
export class LabelControl {
/** The `aria-label`. */
readonly label = computed(() => this.inputs.label?.());
/** Use this value to set the `aria-label` attribute on the element. */
readonly label = computed(() => this.inputs.label());

/** The `aria-labelledby` ids. */
/** Use this value to set the `aria-labelledby` attribute on the element. */
readonly labelledBy = computed(() => {
const label = this.label();
const labelledBy = this.inputs.labelledBy?.();
const defaultLabelledBy = this.inputs.defaultLabelledBy();
const label = this.label();
const labelledBy = this.inputs.labelledBy();

// Always use any specified labelledby ids.
if (labelledBy && labelledBy.length > 0) {
return labelledBy;
return labelledBy.join(' ');
}

// If an aria-label is provided by developers, do not set aria-labelledby with the
// defaultLabelledBy value because if both attributes are set, aria-labelledby will be used.
if (label) {
return [];
// If an aria-label is provided, do not set aria-labelledby with the defaultLabelledBy value
// because if both attributes are set, aria-labelledby will be used.
if (!label && defaultLabelledBy) {
return defaultLabelledBy;
}

return defaultLabelledBy;
return undefined;
});

constructor(readonly inputs: LabelControlInputs & LabelControlOptionalInputs) {}
constructor(readonly inputs: LabelControlInputs) {}
}
1 change: 1 addition & 0 deletions src/aria/private/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './listbox/listbox';
export * from './listbox/option';
export * from './listbox/combobox-listbox';
export * from './menu/menu';
export * from './behaviors/label/label';
export * from './behaviors/signal-like/signal-like';
export * from './tabs/tabs';
export * from './toolbar/toolbar';
Expand Down
1 change: 0 additions & 1 deletion src/aria/private/tabs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ ts_project(
"//:node_modules/@angular/core",
"//src/aria/private/behaviors/event-manager",
"//src/aria/private/behaviors/expansion",
"//src/aria/private/behaviors/label",
"//src/aria/private/behaviors/list-focus",
"//src/aria/private/behaviors/list-navigation",
"//src/aria/private/behaviors/signal-like",
Expand Down
20 changes: 18 additions & 2 deletions src/aria/tabs/tab-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
input,
signal,
} from '@angular/core';
import {TabPattern, TabPanelPattern, DeferredContentAware} from '../private';
import {DeferredContentAware, LabelControl, TabPattern, TabPanelPattern} from '../private';
import {TABS} from './tab-tokens';

/**
Expand Down Expand Up @@ -49,7 +49,8 @@ import {TABS} from './tab-tokens';
'[attr.id]': '_pattern.id()',
'[attr.tabindex]': '_pattern.tabIndex()',
'[attr.inert]': '!visible() ? true : null',
'[attr.aria-labelledby]': '_pattern.labelledBy()',
'[attr.aria-label]': '_labelControl.label()',
'[attr.aria-labelledby]': '_labelControl.labelledBy()',
},
hostDirectives: [
{
Expand All @@ -71,9 +72,18 @@ export class TabPanel implements OnInit, OnDestroy {
/** The parent Tabs. */
private readonly _tabs = inject(TABS);

/** Controls label for this tabpanel. */
readonly _labelControl: LabelControl;

/** A global unique identifier for the tab. */
readonly id = input(inject(_IdGenerator).getId('ng-tabpanel-', true));

/** The (optional) label for the accordion panel. */
readonly label = input<string | undefined>(undefined);

/** The (optional) labelledBy ids for the accordion panel. */
readonly labelledBy = input<string[]>([]);

/** The Tab UIPattern associated with the tabpanel */
readonly _tabPattern: WritableSignal<TabPattern | undefined> = signal(undefined);

Expand All @@ -90,6 +100,12 @@ export class TabPanel implements OnInit, OnDestroy {
});

constructor() {
this._labelControl = new LabelControl({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should put this in the UI pattern to keep the directive a thin layer between the framework and the core library.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the reason I pulled it out is we don't have to bring back AccordionPanelPattern just to serve as a wrapper for this utility.

defaultLabelledBy: computed(() => this._pattern?.labelledBy()),
label: this.label,
labelledBy: this.labelledBy,
});

afterRenderEffect({
write: () => {
this._deferredContentAware.contentVisible.set(this.visible());
Expand Down
Loading