Accessibility: Fix overflowing layout on small zoomed screen (#109880)

* fixes the layout in a slightly naive way
does work in both chrome and firefox

* make panel and query sections individually have full viewport height

* allow wrapping in dashboard controls, and align time picker section correctly when wrapped

* use more fixed minimum widths and allow horizontal scroll for overflow

* remove collapsing when sizes are fixed, and fix inverted collapse state logic

* use new wrapper for reflow layout media query setup

replace the magic numbers with theme breakpoints

apply global styles conditionally and locally

fix left to right splitter collapse state so it's removed in small size

added betterer exception that will be removed in the next commit

* moved component definition outside of non-react class so react hook lint rule recognizes it's not a class component (betterer fixes)

* remove unused import

* nit fix

* move disabling useSnapperSplitter logic into the hook

simplify reflow hook to only use height, and use a fixed height unrelated to shared width breakpoints

* remove global style overrides

* prevent scrolling in editor

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
This commit is contained in:
Luminessa Starlight 2025-09-03 05:22:00 -04:00 committed by GitHub
parent 1758fa8fbb
commit 8dd82c34f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 86 additions and 19 deletions

View File

@ -1750,9 +1750,6 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],
"public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx:5381": [
[0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"]
],

View File

@ -15,12 +15,15 @@ import { getDashboardSceneFor, getLibraryPanelBehavior } from '../utils/utils';
import { PanelEditor } from './PanelEditor';
import { SaveLibraryVizPanelModal } from './SaveLibraryVizPanelModal';
import { useSnappingSplitter } from './splitter/useSnappingSplitter';
import { scrollReflowMediaCondition, useScrollReflowLimit } from './useScrollReflowLimit';
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
const dashboard = getDashboardSceneFor(model);
const { optionsPane } = model.useState();
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
const [isInitiallyCollapsed, setIsCollapsed] = useEditPaneCollapsed();
const isScrollingLayout = useScrollReflowLimit();
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
@ -28,8 +31,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
dragPosition: 'end',
initialSize: 330,
usePixels: true,
collapsed: isCollapsed,
collapsed: isInitiallyCollapsed,
collapseBelowPixels: 250,
disabled: isScrollingLayout,
});
useEffect(() => {
@ -80,17 +84,20 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
const { controls } = dashboard.useState();
const styles = useStyles2(getStyles);
const isScrollingLayout = useScrollReflowLimit();
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
direction: 'column',
dragPosition: 'start',
initialSize: 0.5,
collapseBelowPixels: 150,
disabled: isScrollingLayout,
});
containerProps.className = cx(containerProps.className, styles.container);
if (!dataPane) {
if (!dataPane && !isScrollingLayout) {
primaryProps.style.flexGrow = 1;
}
@ -102,7 +109,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
</div>
)}
<div {...containerProps}>
<div {...primaryProps}>
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
<VizWrapper panel={panel} tableView={tableView} />
</div>
{showLibraryPanelSaveModal && libraryPanel && (
@ -123,7 +130,10 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
{dataPane && (
<>
<div {...splitterProps} />
<div {...secondaryProps}>
<div
{...secondaryProps}
className={cx(secondaryProps.className, isScrollingLayout && styles.fullSizeEditor)}
>
{splitterState.collapsed && (
<div className={styles.expandDataPane}>
<Button
@ -163,6 +173,7 @@ function VizWrapper({ panel, tableView }: VizWrapperProps) {
}
function getStyles(theme: GrafanaTheme2) {
const scrollReflowMediaQuery = '@media ' + scrollReflowMediaCondition;
return {
pageContainer: css({
display: 'grid',
@ -171,6 +182,9 @@ function getStyles(theme: GrafanaTheme2) {
gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr',
height: '100%',
[scrollReflowMediaQuery]: {
gridTemplateColumns: `100%`,
},
}),
pageContainerWithControls: css({
gridTemplateAreas: `
@ -196,6 +210,14 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%',
height: '100%',
overflow: 'unset',
[scrollReflowMediaQuery]: {
height: 'auto',
display: 'grid',
gridTemplateColumns: 'minmax(470px, 1fr) 330px',
gridTemplateRows: '1fr',
gap: theme.spacing(1),
width: '100%',
},
}),
body: css({
label: 'body',
@ -248,5 +270,11 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%',
paddingLeft: theme.spacing(2),
}),
fixedSizeViz: css({
height: '100vh',
}),
fullSizeEditor: css({
height: 'max-content',
}),
};
}

View File

@ -32,6 +32,7 @@ import { getAllPanelPluginMeta } from 'app/features/panel/state/util';
import { PanelOptions } from './PanelOptions';
import { PanelVizTypePicker } from './PanelVizTypePicker';
import { INTERACTION_EVENT_NAME, INTERACTION_ITEM } from './interaction';
import { useScrollReflowLimit } from './useScrollReflowLimit';
export interface PanelOptionsPaneState extends SceneObjectState {
isVizPickerOpen?: boolean;
@ -132,16 +133,14 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
const hasFieldConfig = !isSearching && !panel.getPlugin()?.fieldConfigRegistry.isEmpty();
const [isSearchingOptions, setIsSearchingOptions] = useToggle(false);
const onlyOverrides = listMode === OptionFilter.Overrides;
const isScrollingLayout = useScrollReflowLimit();
return (
<>
{!isVizPickerOpen && (
<>
<div className={styles.top}>
<Field
label={t('dashboard.panel-edit.visualization-button-label', 'Visualization')}
className={styles.vizField}
>
<Field label={t('dashboard.panel-edit.visualization-button-label', 'Visualization')} noMargin>
<Stack gap={1}>
<VisualizationButton pluginId={pluginId} onOpen={model.onToggleVizPicker} />
<Button
@ -178,7 +177,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
/>
)}
</div>
<ScrollContainer>
<ScrollContainer minHeight={isScrollingLayout ? 'max-content' : 0}>
<PanelOptions panel={panel} searchQuery={searchQuery} listMode={listMode} data={data} />
</ScrollContainer>
</>
@ -209,9 +208,6 @@ function getStyles(theme: GrafanaTheme2) {
searchWrapper: css({
padding: theme.spacing(2, 2, 2, 0),
}),
vizField: css({
marginBottom: theme.spacing(0),
}),
rotateIcon: css({
rotate: '180deg',
}),

View File

@ -16,6 +16,9 @@ export interface UseSnappingSplitterOptions {
handleSize?: ComponentSize;
usePixels?: boolean;
collapseBelowPixels: number;
/* Disables the splitter, hiding all of its styles */
disabled?: boolean;
}
interface PaneState {
@ -31,6 +34,7 @@ export function useSnappingSplitter({
collapsed,
handleSize,
usePixels,
disabled,
}: UseSnappingSplitterOptions) {
const [state, setState] = useState<PaneState>({
collapsed: collapsed ?? false,
@ -91,6 +95,26 @@ export function useSnappingSplitter({
onSizeChanged,
});
// This does cause the loss of the adjustment position when toggling disabled on and off again.
// Fixing this properly would require changing how useSplitter works to not both pass and
// adjust styles directly on the element by ref. That causes a React conflict.
if (disabled) {
containerProps.className = '';
primaryProps.className = '';
primaryProps.style = {};
secondaryProps.className = '';
secondaryProps.style = {};
splitterProps.style.display = 'none';
return {
containerProps,
primaryProps,
secondaryProps,
splitterProps,
splitterState: { collapsed: false },
onToggleCollapse,
};
}
// This is to allow resizing it beyond the content dimensions
secondaryProps.style.overflow = 'hidden';
secondaryProps.style.minWidth = 'unset';

View File

@ -0,0 +1,15 @@
import { useMedia } from 'react-use';
/**
* Media query body "(max-height: 540px)" which matches screens small enough we have zoom reflow
* problems.
* 540px is one of the round screen size numbers that's about what we want.
*/
export const scrollReflowMediaCondition = '(max-height: 540px)';
/**
* @returns {boolean} true when the screen is small enough to need zoom reflow handling
*/
export function useScrollReflowLimit(): boolean {
return useMedia(scrollReflowMediaCondition);
}

View File

@ -147,10 +147,10 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
{editPanel && <PanelEditControls panelEditor={editPanel} />}
</Stack>
{!hideTimeControls && (
<Stack justifyContent={'flex-end'}>
<div className={styles.timeControlStack}>
<timePicker.Component model={timePicker} />
<refreshPicker.Component model={refreshPicker} />
</Stack>
</div>
)}
<Stack>
<DropdownVariableControls dashboard={dashboard} />
@ -181,7 +181,7 @@ function getStyles(theme: GrafanaTheme2) {
gap: theme.spacing(1),
padding: theme.spacing(2),
flexDirection: 'row',
flexWrap: 'nowrap',
flexWrap: 'wrap-reverse',
position: 'relative',
width: '100%',
marginLeft: 'auto',
@ -198,5 +198,12 @@ function getStyles(theme: GrafanaTheme2) {
background: 'unset',
position: 'unset',
}),
timeControlStack: css({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: theme.spacing(1),
marginLeft: 'auto',
}),
};
}