Repository: elastic/kibana
Pull request #216592: [ObsUX][A11y] Add announcement keyboard elements improvements
Author: login: MiriamAparicio
Labels: name: release_note:skip
description: Skip the PR/issue when compiling release notes
name: v9.0.0
name: Team:obs-ux-infra_services - DEPRECATED
description: DEPRECATED - Use Team:obs-presentation.
name: backport:version
description: Backport to applied version labels
name: v8.18.0
name: v9.1.0
name: v8.19.0
Created: 2025-04-01T08:48:54Z | Merged: 2025-04-02T11:24:26Z

Closes https://github.com/elastic/kibana/issues/216029

## Summary

Some elements on the Infrastructure Inventory were not accessible or their interaction with the keyboard was not properly announced to the users, see issue for steps to reproduce, use a screen reader to test.

### What was done

#### Groupby buttons 

- Replace link element by button
- Add announcement when interacting with the button, i.e., when clicking/selecting the `All` button nothing changes

https://github.com/user-attachments/assets/da7a973d-26a4-4288-8aea-e1dd7a43ea6f

#### Legend options

- Replace `EuiColorPickerSwatch` button by and icon, the button didn't have any action, it only shows the selected color for minimum and maximum for the selected color palette
- Fix ui alignment of checkbox elements

Before

<img width="452" alt="Screenshot 2025-04-01 at 08 58 40" src="https://github.com/user-attachments/assets/66d7e733-8f95-4d76-9eb2-549b8421b888" />

After

<img width="585" alt="Screenshot 2025-04-01 at 08 54 31" src="https://github.com/user-attachments/assets/825a9f84-ddaf-43dc-b136-e268592818c1" />






<!--ONMERGE {"backportTargets":["8.18","8.x","9.0"]} ONMERGE-->


Diff:
diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
index 75d409695d33e..af651fd6565e6 100644
--- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
+++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
@@ -5,8 +5,9 @@
  * 2.0.
  */
 
-import { EuiLink, EuiToolTip, useEuiFontSize } from '@elastic/eui';
-import React from 'react';
+import { EuiButtonEmpty, EuiScreenReaderOnly, EuiToolTip, useEuiFontSize } from '@elastic/eui';
+import React, { useCallback, useState } from 'react';
+import { i18n } from '@kbn/i18n';
 import styled from '@emotion/styled';
 import type {
   InfraWaffleMapGroup,
@@ -20,43 +21,72 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
-  public render() {
-    const { group, isChild } = this.props;
-    const linkStyle = {
-      fontSize: isChild ? '0.85em' : '1em',
-    };
-    return (
+export const GroupName: React.FC<Props> = ({ onDrilldown, group, isChild, options }) => {
+  const [a11yAnnouncement, setA11yAnnouncement] = useState('');
+
+  const handleClick = useCallback(
+    (event: React.MouseEvent<HTMLButtonElement>) => {
+      event.preventDefault();
+
+      if (options.groupBy.length === 0) {
+        setA11yAnnouncement(
+          i18n.translate('xpack.infra.inventory.groupBy.noChangeMessage', {
+            defaultMessage: 'No changes were made when selecting {group}.',
+            values: { group: group.name },
+          })
+        );
+        return;
+      }
+
+      setA11yAnnouncement(
+        i18n.translate('xpack.infra.inventory.groupBy.groupingByMessage', {
+          defaultMessage: 'Grouping by {group}...',
+          values: { group: group.name },
+        })
+      );
+
+      const currentPath =
+        isChild && options.groupBy.length > 1 ? options.groupBy[1] : options.groupBy[0];
+      onDrilldown(`${currentPath.field}: "${group.name}"`);
+    },
+    [group.name, isChild, onDrilldown, options.groupBy]
+  );
+
+  const buttonStyle = {
+    fontSize: isChild ? '0.85em' : '1em',
+  };
+
+  return (
+    <>
+      <EuiScreenReaderOnly>
+        <div aria-live="polite" role="status">
+          {a11yAnnouncement}
+        </div>
+      </EuiScreenReaderOnly>
+
       <GroupNameContainer>
         <Inner isChild={isChild}>
           <Name>
             <EuiToolTip position="top" content={group.name}>
-              <EuiLink
-                style={linkStyle}
-                onClickCapture={this.handleClick}
-                data-test-subj="groupNameLink"
+              <EuiButtonEmpty
+                aria-label={i18n.translate('xpack.infra.inventory.groupBySelectorButtonLabel', {
+                  defaultMessage: 'Group by {group}',
+                  values: { group: group.name },
+                })}
+                style={buttonStyle}
+                onClick={handleClick}
+                data-test-subj="groupNameButton"
               >
                 {group.name}
-              </EuiLink>
+              </EuiButtonEmpty>
             </EuiToolTip>
           </Name>
           <Count>{group.count}</Count>
         </Inner>
       </GroupNameContainer>
-    );
-  }
-
-  private handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
-    event.preventDefault();
-    const { groupBy } = this.props.options;
-    // When groupBy is empty that means there is nothing todo so let's just do nothing.
-    if (groupBy.length === 0) {
-      return;
-    }
-    const currentPath = this.props.isChild && groupBy.length > 1 ? groupBy[1] : groupBy[0];
-    this.props.onDrilldown(`${currentPath.field}: "${this.props.group.name}"`);
-  };
-}
+    </>
+  );
+};
 
 const GroupNameContainer = styled.div`
   position: relative;
diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx
index 6b1cb1a02676f..a6a61f621af75 100644
--- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx
+++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx
@@ -259,10 +259,6 @@ export const LegendControls = ({
             checked={draftLegend.reverseColors}
             onChange={handleReverseColors}
             compressed
-            style={{
-              position: 'relative',
-              top: '8px',
-            }}
           />
         </EuiFormRow>
         <EuiFormRow
@@ -281,10 +277,6 @@ export const LegendControls = ({
             checked={draftAuto}
             onChange={handleAutoChange}
             compressed
-            style={{
-              position: 'relative',
-              top: '8px',
-            }}
           />
         </EuiFormRow>
         <EuiFormRow
diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx
index c2d70ee8ff757..0ef0926fa714e 100644
--- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx
+++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx
@@ -5,7 +5,8 @@
  * 2.0.
  */
 
-import { EuiColorPickerSwatch, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { EuiText, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
 import React from 'react';
 
 export interface Props {
@@ -17,7 +18,15 @@ export const SwatchLabel = ({ label, color }: Props) => {
   return (
     <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
       <EuiFlexItem grow={false}>
-        <EuiColorPickerSwatch color={color} />
+        <EuiIcon
+          type="stopFilled"
+          color={color}
+          size="xl"
+          aria-label={i18n.translate('xpack.infra.legendControls.iconColorLabel', {
+            defaultMessage: '{label} color',
+            values: { label },
+          })}
+        />
       </EuiFlexItem>
       <EuiFlexItem>
         <EuiText size="xs">
diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts
index fd9b8be0cc0b0..fa8c1ca77a4bd 100644
--- a/x-pack/test/functional/page_objects/infra_home_page.ts
+++ b/x-pack/test/functional/page_objects/infra_home_page.ts
@@ -129,8 +129,8 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
       await comboBox.setElement(groupByCustomField, field);
       await testSubjects.click('groupByCustomFieldAddButton');
       await this.waitForLoading();
-      const groupNameLinks = await testSubjects.findAll('groupNameLink');
-      return Promise.all(groupNameLinks.map(async (link) => link.getVisibleText()));
+      const groupNameButtons = await testSubjects.findAll('groupNameButton');
+      return Promise.all(groupNameButtons.map((link) => link.getVisibleText()));
     },
 
     async enterSearchTerm(query: string) {


Reviews:
user: login: rmyz
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T09:41:48Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T11:33:02Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T11:39:58Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T11:46:41Z
user: login: MiriamAparicio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:23:57Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:55:07Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:56:45Z
user: login: alexwizp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:58:28Z
user: login: alexwizp
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:59:35Z
user: login: MiriamAparicio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T09:27:13Z

Review comments:
diff_hunk: @@ -20,41 +21,89 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
+export class GroupName extends React.PureComponent<Props, { a11yAnnouncement: string }> {
+  constructor(props: Props) {
+    super(props);
+    this.state = { a11yAnnouncement: '' };
+  }
+
   public render() {
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: alexwizp
body: I see `GroupName` component has only `render` method, do you mind to refactror that to functional style?
created_at: 2025-04-01T11:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,41 +21,89 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
+export class GroupName extends React.PureComponent<Props, { a11yAnnouncement: string }> {
+  constructor(props: Props) {
+    super(props);
+    this.state = { a11yAnnouncement: '' };
+  }
+
   public render() {
     const { group, isChild } = this.props;
-    const linkStyle = {
+    const { a11yAnnouncement } = this.state;
+    const buttonStyle = {
       fontSize: isChild ? '0.85em' : '1em',
     };
+
     return (
-      <GroupNameContainer>
-        <Inner isChild={isChild}>
-          <Name>
-            <EuiToolTip position="top" content={group.name}>
-              <EuiLink
-                style={linkStyle}
-                onClickCapture={this.handleClick}
-                data-test-subj="groupNameLink"
-              >
-                {group.name}
-              </EuiLink>
-            </EuiToolTip>
-          </Name>
-          <Count>{group.count}</Count>
-        </Inner>
-      </GroupNameContainer>
+      <>
+        <EuiScreenReaderOnly>
+          <div aria-live="polite" role="status">
+            {a11yAnnouncement}
+          </div>
+        </EuiScreenReaderOnly>
+
+        <GroupNameContainer>
+          <Inner isChild={isChild}>
+            <Name>
+              <EuiToolTip position="top" content={group.name}>
+                <EuiButtonEmpty
+                  aria-label={i18n.translate('xpack.infra.inventory.groupBySelectorButtonLabel', {
+                    defaultMessage: 'Group by {group}',
+                    values: { group: group.name },
+                  })}
+                  style={buttonStyle}
+                  onClick={this.handleClick}
+                  data-test-subj="groupNameButton"
+                >
+                  {group.name}
+                </EuiButtonEmpty>
+              </EuiToolTip>
+            </Name>
+            <Count>{group.count}</Count>
+          </Inner>
+        </GroupNameContainer>
+      </>
     );
   }
 
   private handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
-    const { groupBy } = this.props.options;
-    // When groupBy is empty that means there is nothing todo so let's just do nothing.
+    const {
+      options: { groupBy },
+      group,
+    } = this.props;
+
     if (groupBy.length === 0) {
+      this.setState(
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: alexwizp
body: If we really need a role="status" element for rules, I think it should be updated once and maintain the actual status of the rules filter. Using `setTimeout` with returning value to `''` seems unnecessary to me.
created_at: 2025-04-01T11:39:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,8 +129,8 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
       await comboBox.setElement(groupByCustomField, field);
       await testSubjects.click('groupByCustomFieldAddButton');
       await this.waitForLoading();
-      const groupNameLinks = await testSubjects.findAll('groupNameLink');
-      return Promise.all(groupNameLinks.map(async (link) => link.getVisibleText()));
+      const groupNameButtons = await testSubjects.findAll('groupNameButton');
+      return Promise.all(groupNameButtons.map(async (link) => link.getVisibleText()));
path: x-pack/test/functional/page_objects/infra_home_page.ts
user: login: alexwizp
body: nit: extra `async`
`
return Promise.all(groupNameButtons.map((link) => link.getVisibleText()));`
created_at: 2025-04-01T11:46:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,8 +129,8 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
       await comboBox.setElement(groupByCustomField, field);
       await testSubjects.click('groupByCustomFieldAddButton');
       await this.waitForLoading();
-      const groupNameLinks = await testSubjects.findAll('groupNameLink');
-      return Promise.all(groupNameLinks.map(async (link) => link.getVisibleText()));
+      const groupNameButtons = await testSubjects.findAll('groupNameButton');
+      return Promise.all(groupNameButtons.map(async (link) => link.getVisibleText()));
path: x-pack/test/functional/page_objects/infra_home_page.ts
user: login: MiriamAparicio
body: Good catch, removing
created_at: 2025-04-01T13:23:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,43 +21,69 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
-  public render() {
-    const { group, isChild } = this.props;
-    const linkStyle = {
-      fontSize: isChild ? '0.85em' : '1em',
-    };
-    return (
+export const GroupName: React.FC<Props> = ({ onDrilldown, group, isChild, options }) => {
+  const [a11yAnnouncement, setA11yAnnouncement] = useState('');
+
+  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    if (options.groupBy.length === 0) {
+      setA11yAnnouncement(
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: alexwizp
body: that message also looks extra to me, but it's not a blocker

@L1nBra, @bhavyarm  any thoughts?
created_at: 2025-04-02T08:55:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,43 +21,69 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
-  public render() {
-    const { group, isChild } = this.props;
-    const linkStyle = {
-      fontSize: isChild ? '0.85em' : '1em',
-    };
-    return (
+export const GroupName: React.FC<Props> = ({ onDrilldown, group, isChild, options }) => {
+  const [a11yAnnouncement, setA11yAnnouncement] = useState('');
+
+  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    if (options.groupBy.length === 0) {
+      setA11yAnnouncement(
+        i18n.translate('xpack.infra.inventory.groupBy.noChangeMessage', {
+          defaultMessage: 'No changes were made when selecting {group}.',
+          values: { group: group.name },
+        })
+      );
+      return;
+    }
+
+    setA11yAnnouncement(
+      i18n.translate('xpack.infra.inventory.groupBy.grouppingByMessage', {
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: alexwizp
body: typo
`grouppingByMessage -> groupingByMessage`
created_at: 2025-04-02T08:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,43 +21,69 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
-  public render() {
-    const { group, isChild } = this.props;
-    const linkStyle = {
-      fontSize: isChild ? '0.85em' : '1em',
-    };
-    return (
+export const GroupName: React.FC<Props> = ({ onDrilldown, group, isChild, options }) => {
+  const [a11yAnnouncement, setA11yAnnouncement] = useState('');
+
+  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: alexwizp
body: nit: please wrap it into `useCallback`
created_at: 2025-04-02T08:58:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,43 +21,69 @@ interface Props {
   options: InfraWaffleMapOptions;
 }
 
-export class GroupName extends React.PureComponent<Props, {}> {
-  public render() {
-    const { group, isChild } = this.props;
-    const linkStyle = {
-      fontSize: isChild ? '0.85em' : '1em',
-    };
-    return (
+export const GroupName: React.FC<Props> = ({ onDrilldown, group, isChild, options }) => {
+  const [a11yAnnouncement, setA11yAnnouncement] = useState('');
+
+  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    if (options.groupBy.length === 0) {
+      setA11yAnnouncement(
path: x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/group_name.tsx
user: login: MiriamAparicio
body: I added this message because in the issue said that when group by 'All' is clicked no annoucement is done that nothing changed in the page
created_at: 2025-04-02T09:27:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: elasticmachine
created_at: 2025-04-01T08:48:57Z
body: Pinging @elastic/obs-ux-infra_services-team (Team:obs-ux-infra_services)
author_association: CONTRIBUTOR
user: login: elasticmachine
created_at: 2025-04-02T11:17:31Z
body: ## :green_heart: Build Succeeded
* [Buildkite Build](https://buildkite.com/elastic/kibana-pull-request/builds/289383)
* Commit: 598e8083962b9b8d2b335bed81a00399fc0158d9

### Metrics [[docs](https://www.elastic.co/guide/en/kibana/master/ci-metrics.html)]
#### Async chunks
> Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app


| id | [before](https://github.com/elastic/kibana/commit/30ad456181fa745d8e1ac6e1c93742ea9a4022ec) | [after](https://github.com/elastic/kibana/commit/598e8083962b9b8d2b335bed81a00399fc0158d9) | diff |
| --- | --- | --- | --- |
| `infra` | 1.2MB | 1.2MB | +512.0B |



### History
* :green_heart: [Build #288904](https://buildkite.com/elastic/kibana-pull-request/builds/288904) succeeded 9c1e6b8a936b2c5192fa26ca32325bdf20434ac2

<!--buildkite-pr-comment-kibana-pull-request
{"builds":[{"buildStatus":{"state":"passed","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289383","number":289383,"commit":"598e8083962b9b8d2b335bed81a00399fc0158d9"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/288904","number":288904,"commit":"9c1e6b8a936b2c5192fa26ca32325bdf20434ac2"}],"number":289383}
buildkite-pr-comment-->
author_association: CONTRIBUTOR
user: login: kibanamachine
created_at: 2025-04-02T11:24:47Z
body: Starting backport for target branches: 8.18, 8.x, 9.0

https://github.com/elastic/kibana/actions/runs/14218425949
author_association: CONTRIBUTOR
user: login: kibanamachine
created_at: 2025-04-02T11:31:21Z
body: ## 💔 Some backports could not be created

| Status | Branch | Result |
|:------:|:------:|:------|
|❌|8.18|Backport failed because of merge conflicts|
|❌|8.x|Backport failed because of merge conflicts|
|✅|9.0|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/216800">](https://github.com/elastic/kibana/pull/216800)|

Note: Successful backport PRs will be merged automatically after passing CI.

### Manual backport
To create the backport manually run:
```
node scripts/backport --pr 216592
```

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport)

<!--- Backport version: 9.6.6 -->
author_association: CONTRIBUTOR
user: login: MiriamAparicio
created_at: 2025-04-03T11:05:09Z
body: ## 💚 All backports created successfully

| Status | Branch | Result |
|:------:|:------:|:------|
|✅|8.18|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/216987">](https://github.com/elastic/kibana/pull/216987)|

Note: Successful backport PRs will be merged automatically after passing CI.

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport)

<!--- Backport version: 9.6.6 -->
author_association: CONTRIBUTOR
user: login: MiriamAparicio
created_at: 2025-04-03T11:10:14Z
body: ## 💚 All backports created successfully

| Status | Branch | Result |
|:------:|:------:|:------|
|✅|8.x|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/216988">](https://github.com/elastic/kibana/pull/216988)|

Note: Successful backport PRs will be merged automatically after passing CI.

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport)

<!--- Backport version: 9.6.6 -->
author_association: CONTRIBUTOR