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
87 changes: 87 additions & 0 deletions packages/main/src/components/SelectDialog/SelectDialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,91 @@ const MultiSelectDialog = () => {
<br />
<br />

### Announcing search result count

A common a11y requirement is to announce the number of matches when the user filters the list (e.g. _"5 results available"_, _"No results found"_).

The framework's `InvisibleMessage.announce(...)` cannot be used here: its live region (`<ui5-announcement-area>`) is appended to `document.body`, outside the dialog. When the dialog is open, some screen readers do not announce updates from live regions rendered outside it, so the message is silently dropped. See [UI5/webcomponents#13613](https://github.com/UI5/webcomponents/issues/13613) for details.

The workaround is to render your own `aria-live="polite"` region **inside** the dialog's DOM via `createPortal`, and write the message into it from your search handler.

<Canvas of={ComponentStories.SearchResultAnnouncement} sourceState="none" />

<details>

<summary>Show Code</summary>

```tsx
const items = Array.from({ length: 40 }, (_unused, index) => ({
id: `P-${index.toString().padStart(3, '0')}`,
text: ['Gaming Laptop', 'Business Laptop', 'Gaming PC', 'Business PC'][index % 4],
}));

const liveRegionStyle: CSSProperties = {
position: 'absolute',
clip: 'rect(1px,1px,1px,1px)',
userSelect: 'none',
left: '-1000px',
top: '-1000px',
pointerEvents: 'none',
};

const SearchAnnouncementDialog = () => {
const [dialogEl, setDialogEl] = useState<DialogDomRef | null>(null);
const liveSpanRef = useRef<HTMLSpanElement | null>(null);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');

const filteredItems = useMemo(() => {
const query = searchValue.trim().toLowerCase();
if (!query) {
return items;
}
return items.filter((item) => item.id.toLowerCase().includes(query) || item.text.toLowerCase().includes(query));
}, [searchValue]);

const announceCount = (count: number) => {
const span = liveSpanRef.current;
if (!span) {
return;
}
span.textContent = count === 0 ? 'No results found' : `${count} results available`;
};

const handleSearch: SelectDialogPropTypes['onSearch'] = (event) => {
setSearchValue(event.detail.value);
announceCount(filteredItems.length);
};

const handleSearchReset = () => {
setSearchValue('');
announceCount(items.length);
};

return (
<>
<Button onClick={() => setOpen(true)}>Open SelectDialog</Button>
<SelectDialog
ref={setDialogEl}
headerText="Select Product"
open={open}
onClose={() => setOpen(false)}
onSearch={handleSearch}
onSearchReset={handleSearchReset}
>
{filteredItems.map((item) => (
<ListItemStandard key={item.id} description={item.id} text={item.text} />
))}
</SelectDialog>
{dialogEl && createPortal(<span ref={liveSpanRef} aria-live="polite" style={liveRegionStyle} />, dialogEl)}
</>
);
};
```

</details>

<br />
<br />

<Footer />
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import Pc2 from '@sb/demoImages/PC2.jpg';
import { isChromatic } from '@sb/utils.js';
import type { Meta, StoryObj } from '@storybook/react-vite';
import ListSelectionMode from '@ui5/webcomponents/dist/types/ListSelectionMode.js';
import { useEffect, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../../webComponents/Button/index.js';
import type { DialogDomRef } from '../../webComponents/Dialog/index.js';
import { Label } from '../../webComponents/Label/index.js';
import { ListItemStandard } from '../../webComponents/ListItemStandard/index.js';
import { Text } from '../../webComponents/Text/index.js';
import { FlexBox } from '../FlexBox/index.js';
import { SelectDialog } from './index.js';
import type { SelectDialogPropTypes } from './index.js';

const meta = {
title: 'Modals & Popovers / SelectDialog',
Expand Down Expand Up @@ -178,3 +182,73 @@ export const MultiSelect: Story = {
);
},
};

const announcementItems = Array.from({ length: 40 }, (_unused, index) => ({
id: `P-${index.toString().padStart(3, '0')}`,
text: ['Gaming Laptop', 'Business Laptop', 'Gaming PC', 'Business PC'][index % 4],
}));

const liveRegionStyle: CSSProperties = {
position: 'absolute',
clip: 'rect(1px,1px,1px,1px)',
userSelect: 'none',
left: '-1000px',
top: '-1000px',
pointerEvents: 'none',
};

export const SearchResultAnnouncement: Story = {
render: () => {
const [dialogEl, setDialogEl] = useState<DialogDomRef | null>(null);
const liveSpanRef = useRef<HTMLSpanElement | null>(null);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');

const filteredItems = useMemo(() => {
const query = searchValue.trim().toLowerCase();
if (!query) {
return announcementItems;
}
return announcementItems.filter(
(item) => item.id.toLowerCase().includes(query) || item.text.toLowerCase().includes(query),
);
}, [searchValue]);

const announceCount = (count: number) => {
const span = liveSpanRef.current;
if (!span) {
return;
}
span.textContent = count === 0 ? 'No results found' : `${count} results available`;
};

const handleSearch = (event: Parameters<NonNullable<SelectDialogPropTypes['onSearch']>>[0]) => {
setSearchValue(event.detail.value);
announceCount(filteredItems.length);
};

const handleSearchReset = () => {
setSearchValue('');
announceCount(announcementItems.length);
};

return (
<>
<Button onClick={() => setOpen(true)}>Open SelectDialog</Button>
<SelectDialog
ref={setDialogEl}
headerText="Select Product"
open={open}
onClose={() => setOpen(false)}
onSearch={handleSearch}
onSearchReset={handleSearchReset}
>
{filteredItems.map((item) => (
<ListItemStandard key={item.id} description={item.id} text={item.text} />
))}
</SelectDialog>
{dialogEl && createPortal(<span ref={liveSpanRef} aria-live="polite" style={liveRegionStyle} />, dialogEl)}
</>
);
},
};
Loading