Skip to content

Commit 482cc22

Browse files
fix: isolate pdisk preview rendering and preserve keyboard focus
1 parent e7d9b3d commit 482cc22

43 files changed

Lines changed: 359 additions & 155 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/DiskStateProgressBar/DiskStateProgressBar.scss‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@use './diskStateColors';
1+
@use '../../styles/mixins';
22

33
.storage-disk-progress-bar {
44
$block: &;
@@ -31,7 +31,22 @@
3131
border-radius: $outer-border-radius;
3232
background-color: var(--entity-state-background-color);
3333

34-
@include diskStateColors.disk-state-colors($block);
34+
@include mixins.entity-state-colors($block);
35+
36+
// SolidRed severity (6): solid red background for critical errors in State mode
37+
&_solidred {
38+
--entity-state-font-color: var(--g-color-text-light-primary);
39+
--entity-state-border-color: var(--g-color-base-danger-heavy);
40+
--entity-state-background-color: var(--g-color-base-danger-heavy);
41+
--entity-state-fill-color: var(--g-color-base-danger-heavy);
42+
--entity-state-icon-color: var(--g-color-text-light-primary);
43+
--entity-state-stripe-color: var(--g-color-text-dark-hint);
44+
45+
&#{$block}_highlighted {
46+
--entity-state-background-color: var(--g-color-base-danger-heavy-hover);
47+
--entity-state-border-color: var(--g-color-base-danger-heavy-hover);
48+
}
49+
}
3550

3651
&_expert-mode {
3752
--entity-state-compact-background-color: var(--entity-state-background-color);

‎src/components/DiskStateProgressBar/DiskStateProgressBar.tsx‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,8 @@ interface DiskStateProgressBarProps {
3333
overflowVisible?: boolean;
3434
}
3535

36-
export function getDiskStateColorMods(tone: DiskBarTone, highlighted?: boolean) {
37-
return {
38-
[tone === 'LightGrey' ? 'light-grey' : tone.toLowerCase()]: true,
39-
highlighted,
40-
};
36+
function getToneModifier(tone: DiskBarTone) {
37+
return tone === 'LightGrey' ? 'light-grey' : tone.toLowerCase();
4138
}
4239

4340
export function DiskStateProgressBar({
@@ -69,10 +66,11 @@ export function DiskStateProgressBar({
6966
inactive,
7067
striped,
7168
filled,
69+
highlighted,
7270
'all-mode-has-issues': mode === 'all' && strongFill,
7371
'legend-inactive': borderless,
7472
'overlap-icon-at-top-left': overflowVisible,
75-
...getDiskStateColorMods(tone, highlighted),
73+
[getToneModifier(tone)]: true,
7674
};
7775

7876
if (mode) {

‎src/components/DiskStateProgressBar/diskStateColors.scss‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎src/containers/Storage/PDisk/PDisk.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ interface PDiskProps {
172172
onHidePopup?: VoidFunction;
173173
className?: string;
174174
progressBarClassName?: string;
175+
linkRef?: React.Ref<HTMLAnchorElement>;
175176
viewContext?: StorageViewContext;
176177
width?: number;
177178
delayOpen?: number;
@@ -192,6 +193,7 @@ export const PDisk = ({
192193
onHidePopup,
193194
className,
194195
progressBarClassName,
196+
linkRef,
195197
viewContext,
196198
width,
197199
delayOpen = DISKS_POPUP_DEBOUNCE_TIMEOUT,
@@ -278,7 +280,12 @@ export const PDisk = ({
278280
delayOpen={delayOpen}
279281
delayClose={delayClose}
280282
>
281-
<InternalLink to={pDiskPath} aria-label={accessibleName} className={b('content')}>
283+
<InternalLink
284+
to={pDiskPath}
285+
innerRef={linkRef}
286+
aria-label={accessibleName}
287+
className={b('content')}
288+
>
282289
<DiskStateProgressBar
283290
allocation={allocatedPercent}
284291
tone={tone}

‎src/containers/Storage/PDisks/PDisksPreview.scss‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
@use '../../../components/DiskStateProgressBar/diskStateColors';
1+
@use '../../../styles/mixins';
22

33
.ydb-storage-pdisks-preview {
44
&__color {
5-
@include diskStateColors.disk-state-colors(&);
5+
@include mixins.entity-state-colors(&);
66
}
77

88
&__control {

‎src/containers/Storage/PDisks/PDisksPreview.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import type {PDisk} from '../PDisk';
88
import {PDisksPreview} from './PDisksPreview';
99

1010
let mockInverted = false;
11+
const mockDetailsById = new Map<string, React.ComponentProps<typeof PDisk>>();
1112
let mockDetailsProps: React.ComponentProps<typeof PDisk>;
1213

1314
jest.mock('../../../utils/hooks/useSetting', () => ({useSetting: () => [mockInverted]}));
1415
jest.mock('../../../store', () => ({singleClusterMode: true}));
1516
jest.mock('../PDisk', () => ({
1617
PDisk: (props: React.ComponentProps<typeof PDisk>) => {
1718
mockDetailsProps = props;
19+
mockDetailsById.set(props.data?.StringifiedId || '', props);
1820
return <button>Expanded PDisk</button>;
1921
},
2022
}));
@@ -93,3 +95,32 @@ test('closing details clears highlight and ignores callbacks from the previous e
9395
act(() => mockDetailsProps.onShowPopup?.());
9496
expect(mockDetailsProps.highlighted).toBe(true);
9597
});
98+
99+
test('collapsing another group and its delayed hide callback preserve the highlighted owner', () => {
100+
const secondDisk = {...pDisk, PDiskId: 2, StringifiedId: '1-2'};
101+
render(<PDisksPreview pDisks={[pDisk, secondDisk]} vDisks={vDisks} />);
102+
fireEvent.click(screen.getByRole('button', {name: 'Show PDisk 1-1 details'}));
103+
fireEvent.click(screen.getByRole('button', {name: 'Show PDisk 1-2 details'}));
104+
const firstHide = mockDetailsById.get('1-1')?.onHidePopup;
105+
act(() => mockDetailsById.get('1-2')?.onShowPopup?.());
106+
act(() => firstHide?.());
107+
expect(mockDetailsById.get('1-2')?.highlighted).toBe(true);
108+
fireEvent.click(screen.getByRole('button', {name: 'Hide PDisk 1-1 details'}));
109+
expect(mockDetailsById.get('1-2')?.highlighted).toBe(true);
110+
});
111+
112+
test('exposes expanded state and a collapse button associated with the disk controls', () => {
113+
render(<PDisksPreview pDisks={[pDisk]} vDisks={vDisks} />);
114+
const preview = screen.getByRole('button', {name: 'Show PDisk 1-1 details'});
115+
expect(preview).toHaveAttribute('aria-expanded', 'false');
116+
fireEvent.click(preview);
117+
const collapse = screen.getByRole('button', {name: 'Hide PDisk 1-1 details'});
118+
expect(collapse).toHaveAttribute('aria-expanded', 'true');
119+
const content = document.getElementById(collapse.getAttribute('aria-controls') || '');
120+
expect(content).toContainElement(screen.getByRole('button', {name: 'Expanded PDisk'}));
121+
fireEvent.click(collapse);
122+
expect(screen.getByRole('button', {name: 'Show PDisk 1-1 details'})).toHaveAttribute(
123+
'aria-expanded',
124+
'false',
125+
);
126+
});

‎src/containers/Storage/PDisks/PDisksPreview.tsx‎

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import React from 'react';
22

3-
import {Button, Flex, PortalProvider} from '@gravity-ui/uikit';
3+
import {ChevronUp} from '@gravity-ui/icons';
4+
import {Button, Flex, Icon, PortalProvider} from '@gravity-ui/uikit';
45
import {createPortal} from 'react-dom';
56

6-
import {getDiskStateColorMods} from '../../../components/DiskStateProgressBar/DiskStateProgressBar';
77
import {SETTING_KEYS} from '../../../store/reducers/settings/constants';
88
import {cn} from '../../../utils/cn';
9-
import {
10-
getDefaultDiskDisplayState,
11-
getDefaultPDiskDisplayState,
12-
} from '../../../utils/disks/displayState';
13-
import {getDiskBarTone} from '../../../utils/disks/getDiskBarTone';
14-
import type {DiskBarTone, PreparedPDisk, PreparedVDisk} from '../../../utils/disks/types';
9+
import {DONOR_COLOR} from '../../../utils/disks/constants';
10+
import {getDataSeverityColor} from '../../../utils/disks/helpers';
11+
import type {PreparedPDisk, PreparedVDisk} from '../../../utils/disks/types';
1512
import {useSetting} from '../../../utils/hooks/useSetting';
1613
import {isNumeric} from '../../../utils/utils';
1714
import {PDisk} from '../PDisk';
@@ -62,13 +59,16 @@ interface PDiskPreviewItemProps extends PDiskPreviewSvgProps {
6259
id: string;
6360
expandedWidth: number;
6461
highlightedDisk?: string;
65-
setHighlightedDisk: (id?: string) => void;
62+
setHighlightedDisk: React.Dispatch<React.SetStateAction<string | undefined>>;
6663
}
6764

65+
// The preview uses ordinary prepared-data severity, independently of Expert mode display states.
66+
type PreviewTone = ReturnType<typeof getDataSeverityColor> | typeof DONOR_COLOR;
67+
6868
interface VDiskPath {
6969
key: string;
7070
path: string;
71-
tone: DiskBarTone;
71+
tone: PreviewTone;
7272
inactive: boolean;
7373
}
7474

@@ -86,8 +86,8 @@ function getFilledSize(percent: unknown, size: number, inverted = false) {
8686
return (filledPercent / 100) * size;
8787
}
8888

89-
function getBarClassName(element: string, tone: DiskBarTone, highlighted?: boolean) {
90-
return b('color', getDiskStateColorMods(tone, highlighted), b(element));
89+
function getBarClassName(element: string, tone: PreviewTone, highlighted?: boolean) {
90+
return b('color', {[tone.toLowerCase()]: true, highlighted}, b(element));
9191
}
9292

9393
function getPDiskKey(pDisk: PreparedPDisk, index: number) {
@@ -102,10 +102,7 @@ function getVDiskPaths(vDisks: PreparedVDisk[], viewContext?: StorageViewContext
102102
const row = index % VDISKS_WITHOUT_PDISK_COLUMN;
103103
const x = VDISK_X + column * (VDISK_SIZE + VDISK_COLUMN_GAP);
104104
const y = row * (VDISK_SIZE + VDISK_GAP);
105-
const tone = getDiskBarTone({
106-
severity: getDefaultDiskDisplayState(vDisk, vDisk.DonorMode).severity,
107-
isDonor: vDisk.DonorMode,
108-
});
105+
const tone = vDisk.DonorMode ? DONOR_COLOR : getDataSeverityColor(vDisk.Severity);
109106
const inactive = !isVdiskActive(vDisk, viewContext);
110107
const key = `${tone}-${inactive}`;
111108
const rectPath = `M${x} ${y}h${VDISK_SIZE}v${VDISK_SIZE}h-${VDISK_SIZE}z`;
@@ -143,9 +140,8 @@ function PDiskPreviewSvg({
143140
highlighted,
144141
inverted,
145142
}: PDiskPreviewSvgProps) {
146-
const displayState = getDefaultPDiskDisplayState(pDisk);
147-
const pDiskTone = getDiskBarTone({severity: displayState.severity});
148-
const pDiskFilledHeight = getFilledSize(displayState.allocatedPercent, PDISK_HEIGHT, inverted);
143+
const pDiskTone = getDataSeverityColor(pDisk.Severity);
144+
const pDiskFilledHeight = getFilledSize(pDisk.AllocatedPercent, PDISK_HEIGHT, inverted);
149145
const pDiskFillY = inverted ? PDISK_Y : PDISK_Y + PDISK_HEIGHT - pDiskFilledHeight;
150146
const vDiskPaths = React.useMemo(
151147
() => getVDiskPaths(vDisks, viewContext),
@@ -227,14 +223,28 @@ function PDiskPreviewItem({
227223
highlightedDisk,
228224
setHighlightedDisk,
229225
}: PDiskPreviewItemProps) {
226+
const detailsId = React.useId();
230227
const [detailsOpened, setDetailsOpened] = React.useState(false);
231228
const [previewHovered, setPreviewHovered] = React.useState(false);
232229
const [previewFocused, setPreviewFocused] = React.useState(false);
233230
const popupContainerRef = React.useRef<HTMLElement | null>(null);
234231
const detailsRef = React.useRef<HTMLDivElement>(null);
232+
const diskLinkRef = React.useRef<HTMLAnchorElement>(null);
233+
const previewRef = React.useRef<HTMLButtonElement>(null);
234+
const transferFocusRef = React.useRef(false);
235235
const popupHostRef = React.useRef<HTMLDivElement>(null);
236236
const [popupAvailableHeight, setPopupAvailableHeight] = React.useState(0);
237237

238+
React.useLayoutEffect(() => {
239+
if (!transferFocusRef.current) {
240+
return;
241+
}
242+
transferFocusRef.current = false;
243+
// Transfer keyboard focus after React replaces the activated control.
244+
const target = detailsOpened ? diskLinkRef.current : previewRef.current;
245+
target?.focus({preventScroll: true});
246+
}, [detailsOpened]);
247+
238248
React.useLayoutEffect(() => {
239249
const container = popupContainerRef.current;
240250
const details = detailsRef.current;
@@ -264,15 +274,26 @@ function PDiskPreviewItem({
264274
window.removeEventListener('resize', updatePopupHeight);
265275
};
266276
}, [detailsOpened, highlightedDisk]);
277+
const clearOwnHighlight = React.useCallback(() => {
278+
setHighlightedDisk((current) =>
279+
current === id || vDisks.some((disk) => disk.StringifiedId === current)
280+
? undefined
281+
: current,
282+
);
283+
}, [id, vDisks, setHighlightedDisk]);
267284
const detailsVersionRef = React.useRef(0);
268285
const detailsVersion = detailsVersionRef.current;
269286
const setDetailsHighlightedDisk = React.useCallback(
270287
(diskId?: string) => {
271288
if (detailsVersionRef.current === detailsVersion) {
272-
setHighlightedDisk(diskId);
289+
if (diskId === undefined) {
290+
clearOwnHighlight();
291+
} else {
292+
setHighlightedDisk(diskId);
293+
}
273294
}
274295
},
275-
[detailsVersion, setHighlightedDisk],
296+
[detailsVersion, setHighlightedDisk, clearOwnHighlight],
276297
);
277298

278299
const closeDetails = React.useCallback(() => {
@@ -281,8 +302,8 @@ function PDiskPreviewItem({
281302
setPreviewHovered(false);
282303
setPreviewFocused(false);
283304
setDetailsOpened(false);
284-
setHighlightedDisk(undefined);
285-
}, [setHighlightedDisk]);
305+
clearOwnHighlight();
306+
}, [clearOwnHighlight]);
286307

287308
const handleOpenedDetailsClick = React.useCallback(
288309
(event: React.MouseEvent<unknown>) => {
@@ -295,6 +316,7 @@ function PDiskPreviewItem({
295316

296317
event.preventDefault();
297318
event.stopPropagation();
319+
transferFocusRef.current = event.detail === 0;
298320
closeDetails();
299321
},
300322
[closeDetails],
@@ -305,12 +327,14 @@ function PDiskPreviewItem({
305327
<Button
306328
view="flat"
307329
className={b('control')}
330+
ref={previewRef}
308331
onMouseEnter={() => setPreviewHovered(true)}
309332
onMouseLeave={() => setPreviewHovered(false)}
310333
onFocus={() => setPreviewFocused(true)}
311334
onBlur={() => setPreviewFocused(false)}
312335
onClick={(event) => {
313336
event.stopPropagation();
337+
transferFocusRef.current = event.detail === 0;
314338
// Keep popup wheel events in the table's native scroll container.
315339
let container = event.currentTarget.parentElement;
316340
while (
@@ -322,6 +346,7 @@ function PDiskPreviewItem({
322346
popupContainerRef.current = container;
323347
setDetailsOpened(true);
324348
}}
349+
aria-expanded={false}
325350
aria-label={i18n('action_show-pdisks-details', {id})}
326351
>
327352
<PDiskPreviewSvg
@@ -360,19 +385,30 @@ function PDiskPreviewItem({
360385
ref={detailsRef}
361386
onClickCapture={handleOpenedDetailsClick}
362387
>
363-
<PDisk
364-
data={pDisk}
365-
inactive={!isPdiskActive(pDisk, viewContext)}
366-
vDisks={vDisks}
367-
viewContext={viewContext}
368-
width={expandedWidth}
369-
showPopup={highlightedDisk === id}
370-
onShowPopup={() => setDetailsHighlightedDisk(id)}
371-
onHidePopup={() => setDetailsHighlightedDisk(undefined)}
372-
highlighted={highlightedDisk === id}
373-
highlightedDisk={highlightedDisk}
374-
setHighlightedDisk={setDetailsHighlightedDisk}
375-
/>
388+
<Button
389+
view="flat"
390+
aria-label={i18n('action_hide-pdisks-details', {id})}
391+
aria-expanded={true}
392+
aria-controls={detailsId}
393+
>
394+
<Icon data={ChevronUp} size={16} />
395+
</Button>
396+
<div id={detailsId}>
397+
<PDisk
398+
linkRef={diskLinkRef}
399+
data={pDisk}
400+
inactive={!isPdiskActive(pDisk, viewContext)}
401+
vDisks={vDisks}
402+
viewContext={viewContext}
403+
width={expandedWidth}
404+
showPopup={highlightedDisk === id}
405+
onShowPopup={() => setDetailsHighlightedDisk(id)}
406+
onHidePopup={() => setDetailsHighlightedDisk(undefined)}
407+
highlighted={highlightedDisk === id}
408+
highlightedDisk={highlightedDisk}
409+
setHighlightedDisk={setDetailsHighlightedDisk}
410+
/>
411+
</div>
376412
</Flex>
377413
</PortalProvider>
378414
</React.Fragment>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"action_show-pdisks-details": "Show PDisk {{id}} details"
2+
"action_show-pdisks-details": "Show PDisk {{id}} details",
3+
"action_hide-pdisks-details": "Hide PDisk {{id}} details"
34
}

0 commit comments

Comments
 (0)