Skip to content

Commit 67d797c

Browse files
authored
docs(SelectDialog): add example for announcing search result count (UI5#8718)
1 parent dfa4e06 commit 67d797c

2 files changed

Lines changed: 162 additions & 1 deletion

File tree

packages/main/src/components/SelectDialog/SelectDialog.mdx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,91 @@ const MultiSelectDialog = () => {
172172
<br />
173173
<br />
174174

175+
### Announcing search result count
176+
177+
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"_).
178+
179+
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.
180+
181+
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.
182+
183+
<Canvas of={ComponentStories.SearchResultAnnouncement} sourceState="none" />
184+
185+
<details>
186+
187+
<summary>Show Code</summary>
188+
189+
```tsx
190+
const items = Array.from({ length: 40 }, (_unused, index) => ({
191+
id: `P-${index.toString().padStart(3, '0')}`,
192+
text: ['Gaming Laptop', 'Business Laptop', 'Gaming PC', 'Business PC'][index % 4],
193+
}));
194+
195+
const liveRegionStyle: CSSProperties = {
196+
position: 'absolute',
197+
clip: 'rect(1px,1px,1px,1px)',
198+
userSelect: 'none',
199+
left: '-1000px',
200+
top: '-1000px',
201+
pointerEvents: 'none',
202+
};
203+
204+
const SearchAnnouncementDialog = () => {
205+
const [dialogEl, setDialogEl] = useState<DialogDomRef | null>(null);
206+
const liveSpanRef = useRef<HTMLSpanElement | null>(null);
207+
const [open, setOpen] = useState(false);
208+
const [searchValue, setSearchValue] = useState('');
209+
210+
const filteredItems = useMemo(() => {
211+
const query = searchValue.trim().toLowerCase();
212+
if (!query) {
213+
return items;
214+
}
215+
return items.filter((item) => item.id.toLowerCase().includes(query) || item.text.toLowerCase().includes(query));
216+
}, [searchValue]);
217+
218+
const announceCount = (count: number) => {
219+
const span = liveSpanRef.current;
220+
if (!span) {
221+
return;
222+
}
223+
span.textContent = count === 0 ? 'No results found' : `${count} results available`;
224+
};
225+
226+
const handleSearch: SelectDialogPropTypes['onSearch'] = (event) => {
227+
setSearchValue(event.detail.value);
228+
announceCount(filteredItems.length);
229+
};
230+
231+
const handleSearchReset = () => {
232+
setSearchValue('');
233+
announceCount(items.length);
234+
};
235+
236+
return (
237+
<>
238+
<Button onClick={() => setOpen(true)}>Open SelectDialog</Button>
239+
<SelectDialog
240+
ref={setDialogEl}
241+
headerText="Select Product"
242+
open={open}
243+
onClose={() => setOpen(false)}
244+
onSearch={handleSearch}
245+
onSearchReset={handleSearchReset}
246+
>
247+
{filteredItems.map((item) => (
248+
<ListItemStandard key={item.id} description={item.id} text={item.text} />
249+
))}
250+
</SelectDialog>
251+
{dialogEl && createPortal(<span ref={liveSpanRef} aria-live="polite" style={liveRegionStyle} />, dialogEl)}
252+
</>
253+
);
254+
};
255+
```
256+
257+
</details>
258+
259+
<br />
260+
<br />
261+
175262
<Footer />

packages/main/src/components/SelectDialog/SelectDialog.stories.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import Pc2 from '@sb/demoImages/PC2.jpg';
55
import { isChromatic } from '@sb/utils.js';
66
import type { Meta, StoryObj } from '@storybook/react-vite';
77
import ListSelectionMode from '@ui5/webcomponents/dist/types/ListSelectionMode.js';
8-
import { useEffect, useRef, useState } from 'react';
8+
import type { CSSProperties } from 'react';
9+
import { useEffect, useMemo, useRef, useState } from 'react';
10+
import { createPortal } from 'react-dom';
911
import { Button } from '../../webComponents/Button/index.js';
12+
import type { DialogDomRef } from '../../webComponents/Dialog/index.js';
1013
import { Label } from '../../webComponents/Label/index.js';
1114
import { ListItemStandard } from '../../webComponents/ListItemStandard/index.js';
1215
import { Text } from '../../webComponents/Text/index.js';
1316
import { FlexBox } from '../FlexBox/index.js';
1417
import { SelectDialog } from './index.js';
18+
import type { SelectDialogPropTypes } from './index.js';
1519

1620
const meta = {
1721
title: 'Modals & Popovers / SelectDialog',
@@ -178,3 +182,73 @@ export const MultiSelect: Story = {
178182
);
179183
},
180184
};
185+
186+
const announcementItems = Array.from({ length: 40 }, (_unused, index) => ({
187+
id: `P-${index.toString().padStart(3, '0')}`,
188+
text: ['Gaming Laptop', 'Business Laptop', 'Gaming PC', 'Business PC'][index % 4],
189+
}));
190+
191+
const liveRegionStyle: CSSProperties = {
192+
position: 'absolute',
193+
clip: 'rect(1px,1px,1px,1px)',
194+
userSelect: 'none',
195+
left: '-1000px',
196+
top: '-1000px',
197+
pointerEvents: 'none',
198+
};
199+
200+
export const SearchResultAnnouncement: Story = {
201+
render: () => {
202+
const [dialogEl, setDialogEl] = useState<DialogDomRef | null>(null);
203+
const liveSpanRef = useRef<HTMLSpanElement | null>(null);
204+
const [open, setOpen] = useState(false);
205+
const [searchValue, setSearchValue] = useState('');
206+
207+
const filteredItems = useMemo(() => {
208+
const query = searchValue.trim().toLowerCase();
209+
if (!query) {
210+
return announcementItems;
211+
}
212+
return announcementItems.filter(
213+
(item) => item.id.toLowerCase().includes(query) || item.text.toLowerCase().includes(query),
214+
);
215+
}, [searchValue]);
216+
217+
const announceCount = (count: number) => {
218+
const span = liveSpanRef.current;
219+
if (!span) {
220+
return;
221+
}
222+
span.textContent = count === 0 ? 'No results found' : `${count} results available`;
223+
};
224+
225+
const handleSearch = (event: Parameters<NonNullable<SelectDialogPropTypes['onSearch']>>[0]) => {
226+
setSearchValue(event.detail.value);
227+
announceCount(filteredItems.length);
228+
};
229+
230+
const handleSearchReset = () => {
231+
setSearchValue('');
232+
announceCount(announcementItems.length);
233+
};
234+
235+
return (
236+
<>
237+
<Button onClick={() => setOpen(true)}>Open SelectDialog</Button>
238+
<SelectDialog
239+
ref={setDialogEl}
240+
headerText="Select Product"
241+
open={open}
242+
onClose={() => setOpen(false)}
243+
onSearch={handleSearch}
244+
onSearchReset={handleSearchReset}
245+
>
246+
{filteredItems.map((item) => (
247+
<ListItemStandard key={item.id} description={item.id} text={item.text} />
248+
))}
249+
</SelectDialog>
250+
{dialogEl && createPortal(<span ref={liveSpanRef} aria-live="polite" style={liveRegionStyle} />, dialogEl)}
251+
</>
252+
);
253+
},
254+
};

0 commit comments

Comments
 (0)