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
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"bootstrap": "5.3.3",
"copyfiles": "2.4.1",
"cypress": "14.3.2",
"cypress-axe": "1.5.0",
"cypress-axe": "1.6.0",
"cypress-storybook": "1.0.0",
"eslint": "9.18.0",
"eslint-plugin-react": "7.37.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class PostTestButtonControl {
private readonly handleToggleClick = () => {
if (this.host && this.internalEl) {
const timestamp = Date.now();
this.internalEl.innerHTML = `<p>Controlled Text shown at ${timestamp}.</p>`;
this.internalEl.innerHTML = `<p>Text shown at ${timestamp}.</p>`;
}
};

Expand Down Expand Up @@ -56,15 +56,15 @@ export class PostTestButtonControl {
render() {
return (
<Host role="button" tabindex="0" onClick={this.handleToggleClick}>
Toggle Text
Button
<div
aria-live="assertive"
id={this.workaround === 'none' ? this.ariaControlsId : ''}
ref={el => {
if (el) this.internalEl = el as HTMLElement;
}}
>
<p>Controlled Text shown at xxxxxxxxxxx.</p>
<p>Text shown at xxxxxxxxxxx.</p>
</div>
</Host>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class PostTestButtonControl {
// private readonly handleToggleClick = () => {
// if (this.hostEl && this.internalEl) {
// const timestamp = Date.now();
// this.internalEl.innerHTML = `<p>Controlled Text shown at ${timestamp}.</p>`;
// this.internalEl.innerHTML = `<p>Text shown at ${timestamp}.</p>`;
// }
// };

Expand All @@ -24,9 +24,9 @@ export class PostTestButtonControl {
render() {
return (
<Host>
Toggle Text
Button
<div role="button" tabindex="0" aria-live="assertive" id="text2">
<p>Controlled Text shown at xxxxxxxxxxx.</p>
<p>Text shown at xxxxxxxxxxx.</p>
</div>
</Host>
);
Expand Down
1 change: 1 addition & 0 deletions packages/documentation/.storybook/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ addons.setConfig({
'utilities',
'templates',
'guidelines',
'accessibility-practices',
],
},

Expand Down
3 changes: 3 additions & 0 deletions packages/documentation/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const preview: Preview = {
// Category - Guidelines
'Guidelines',

// Category - Accessibility (INTERNAL ONLY)
'Accessibility Practices',

// Category - Misc
'Misc',
['Mission', 'Design Principles', 'Migration'],
Expand Down
5 changes: 4 additions & 1 deletion packages/documentation/.storybook/styles/manager.scss
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@

#misc,
[data-item-id^='health'],
:is(#raw-components, [data-parent-id='raw-components']):where([data-env='production'] *) {
:is(#raw-components, [data-parent-id='raw-components']):where([data-env='production'] *),
:is(#accessibility-practices, [data-parent-id='accessibility-practices']):where(
[data-env='production'] *
) {
display: none;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/documentation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"cypress": "14.3.2",
"cypress-axe": "1.5.0",
"cypress-axe": "1.6.0",
"eslint": "9.18.0",
"eslint-plugin-markdown": "5.1.0",
"eslint-plugin-mdx": "3.1.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Meta, Canvas, Source, Controls } from '@storybook/blocks';
import * as CCR from './aria-controls.stories';

<Meta of={CCR} />

# Aria-Controls

<div className="lead">
In the Light DOM, `aria-controls` is used to indicate that an element controls the visibility or
interaction of another element, by referencing its `id`.
</div>
<div>
This creates an accessibility relationship, helping assistive technologies understand the
interaction between the trigger and the controlled content.
</div>

### I. Referencing Within the Same DOM Tree βœ”οΈ

<ul>
<li>Light DOM β†’ Light DOM</li>

</ul>
<Canvas of={CCR.ExampleHTML} sourceState="shown" />

### II. Referencing From the LightDOM Into That Shadow DOM ❌

<ul>
<li>Light DOM β†’ Shadow DOM</li>
<li>Slotted Content β†’ Shadow DOM</li>
</ul>

The `aria-controls` attribute cannot reference elements inside a Shadow DOM from the Light DOM. This is because IDs inside Shadow roots are encapsulated and inaccessible across boundaries, preventing valid accessibility relationships.

##### Possible Workarounds

###### 1. `Element:ariaControlsElements`

<b>Light DOM β†’ Shadow DOM Example</b>
<Canvas of={CCR.Example2a} sourceState="shown" />
<div className="hide-col-default">
<Controls of={CCR.Example2a} />
</div>

<b>Slotted Content β†’ Shadow DOM Example</b>
<Canvas of={CCR.Example2b} sourceState="shown" />
<div className="hide-col-default">
<Controls of={CCR.Example2b} />
</div>

###### 2. Set `aria-labelledby`on the host (for SSR Components: to be tested)

An option is to set the following attributes directly on the host of the target component:

<ul>
<li> the `aria-labelledby`</li>
<li> a semantic `role` (e.g. `textbox`, `button`)</li>
<li> `tabindex="0"`</li>
</ul>

<b>Light DOM β†’ Shadow DOM Example</b>
<Canvas of={CCR.Example2b} sourceState="shown" />

<b>Slotted Content β†’ Shadow DOM Example</b>
<Canvas of={CCR.Example2b} sourceState="shown" />

### III. Referencing From Inside a Shadow DOM Out to the Light DOM ❌

<ul>
<li>Shadow DOM β†’ Light DOM</li>
<li>Shadow DOM β†’ Slotted Content</li>
</ul>

Similarly to the previous case, <b>referencing an element located outside a Shadow DOM from an element inside that Shadow DOM using standard `aria-labelledby` is not possible</b>. The strong encapsulation of the Shadow DOM prevents elements within it from directly accessing and referencing the IDs of elements in the Light DOM.

##### Possible Workaround

An option is to set the `id` directly on the host of the referencing element. Please note that, in this case all the visible text inside the referencing component will be assigned as the Accessible Name of the target element.

<Canvas of={CCR.Example2b} sourceState="shown" />

##### IV. Referencing From One Shadow DOM To Another Shadow DOM

<ul>
<li>Shadow DOM β†’ Other Shadow DOM</li>
</ul>

what happens

##### Possible Workaround

workaround text

<Canvas of={CCR.Example2b} sourceState="shown" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { StoryObj, Args } from '@storybook/web-components';
import { MetaExtended } from '@root/types';
import { html } from 'lit';
import '../../../../../../demo-components/demo-button.ts';

const meta: MetaExtended = {
id: '76ade552-2c03-4d6d-9dce-28daa346f3g3',
title:
'Accessibility Practices/Foundational Structure And Semantics/Reference Relationships/Crossing The Shadow Dom/Aria-Controls',
parameters: {
badges: [],
},
};

export default meta;

type Story = StoryObj;

export const ExampleHTML = () => {
return html`<span id="id_4">My Text</span>
<demo-button aria-labelledby-id="id_4">Demo Button</demo-button>`;
};

// Case: Referencing from Shadow DOM (Host Attribute) to Light DOM (Element)

export const Example2a: Story = {
argTypes: {
workaround: {
name: 'Workaround',
control: {
type: 'radio',
},
options: ['none', 'ariaControlsElements'],
},
},
args: {
workaround: 'none',
},
render: (args: Args) => html`
<post-test-button-control workaround="${args.workaround}" aria-controls-id="id_2">
Button
</post-test-button-control>
`,
};

export const Example2b: Story = {
argTypes: {
workaround: {
name: 'Workaround',
control: {
type: 'radio',
},
options: ['none', 'ariaControlsElements'],
},
},
args: {
workaround: 'none',
},
render: () => html`
<post-test-button-control2 id="toggle3"
><div
slot="control-slot"
role="button"
tabindex="0"
aria-expanded="false"
aria-controls="text3"
>
Button
</div>
</post-test-button-control2>
`,
};

// Case: Referencing from Shadow DOM (Host Attribute) to Slotted Text (Element)
export const Example3: Story = {
argTypes: {
workaround: {
name: 'Workaround',
control: {
type: 'radio',
},
options: ['none', 'ariaControlsElements'],
},
},
args: {
workaround: 'none',
},
render: (args: Args) => html`
<post-test-button-control workaround="${args.workaround}" id="toggle2" aria-controls="text2">
Button
</post-test-button-control>
`,
};

// Case: Referencing from Shadow DOM (Host Attribute) to Light DOM (Element) workaround with aria-controls directly set on host
export const Example4: Story = {
render: () => html`
<post-test-button aria-controls-id="controlled_id_4">Control Element</post-test-button>
<span id="controlled_id_4">Controlled Element 4</span>
`,
};

// Case: Referencing from Shadow Dom to Slotted Text (Light DOM) workaround with aria-controls directly set on host
export const Example5: Story = {
render: () => html`
<post-test-button aria-controls-id="controlled_id_5">
<span slot="control-slot" id="controlled_id_5">Controlled Element 5 (Slotted)</span>
</post-test-button>
`,
};

// Case: Standard Light DOM to Shadow DOM
export const Example6: Story = {
render: () => html`
<div class="btn btn-primary" aria-controls="controlled_id_6" role="button" tabindex="0">
<post-icon name="1022"></post-icon>
Control Element
</div>
<post-test-span2></post-test-span2>
`,
};

// Case: Shadow DOM to other Shadow Dom workaround
export const Example7: Story = {
render: () => html`
<post-test-span id="controlled_id_7_host"></post-test-span>
<post-test-button aria-controls="controlled_id_7"></post-test-button>
`,
};
Loading
Loading