mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 19:13:45 +08:00
Dashboard: New UX for switching layouts (#102268)
* Layout switching * Update * Update * Update * Update * Update * unline styles * fixing lint issue
This commit is contained in:
@ -61,6 +61,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: theme.spacing(2),
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
cursor: 'pointer',
|
||||
margin: '3px 0' /* Space for box-shadow when focused */,
|
||||
|
||||
':checked': {
|
||||
@ -100,6 +101,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `${theme.spacing(2)} auto`,
|
||||
gap: theme.spacing(1),
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
description: css({
|
||||
fontSize: theme.typography.size.sm,
|
||||
|
@ -6,7 +6,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { DashboardLayoutSelector } from '../scene/layouts-shared/DashboardLayoutSelector';
|
||||
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
|
||||
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
|
||||
|
||||
export class DashboardEditableElement implements EditableDashboardElement {
|
||||
@ -41,24 +41,14 @@ export class DashboardEditableElement implements EditableDashboardElement {
|
||||
title: t('dashboard.options.description', 'Description'),
|
||||
render: () => <DashboardDescriptionInput dashboard={dashboard} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.layout.common.layout', 'Layout'),
|
||||
render: () => <DashboardLayoutSelector layoutManager={body} />,
|
||||
})
|
||||
);
|
||||
|
||||
if (body.getOptions) {
|
||||
for (const option of body.getOptions()) {
|
||||
editPaneHeaderOptions.addItem(option);
|
||||
}
|
||||
}
|
||||
|
||||
return editPaneHeaderOptions;
|
||||
}, [body, dashboard]);
|
||||
}, [dashboard]);
|
||||
|
||||
return [dashboardOptions];
|
||||
const layoutCategory = useLayoutCategory(body);
|
||||
|
||||
return [dashboardOptions, layoutCategory];
|
||||
}
|
||||
|
||||
public renderActions(): ReactNode {
|
||||
|
@ -51,11 +51,12 @@ export class DefaultGridLayoutManager
|
||||
return t('dashboard.default-layout.name', 'Custom');
|
||||
},
|
||||
get description() {
|
||||
return t('dashboard.default-layout.description', 'Manually size and position panels');
|
||||
return t('dashboard.default-layout.description', 'Position and size each panel individually');
|
||||
},
|
||||
id: 'default-grid',
|
||||
createFromLayout: DefaultGridLayoutManager.createFromLayout,
|
||||
kind: 'GridLayout',
|
||||
isGridLayout: true,
|
||||
};
|
||||
|
||||
public readonly descriptor = DefaultGridLayoutManager.descriptor;
|
||||
|
@ -26,15 +26,15 @@ export class ResponsiveGridLayoutManager
|
||||
|
||||
public static readonly descriptor: LayoutRegistryItem = {
|
||||
get name() {
|
||||
return t('dashboard.responsive-layout.name', 'Auto');
|
||||
return t('dashboard.responsive-layout.name', 'Auto grid');
|
||||
},
|
||||
get description() {
|
||||
return t('dashboard.responsive-layout.description', 'Automatically positions panels into a grid.');
|
||||
return t('dashboard.responsive-layout.description', 'Panels resize to fit and form uniform grids');
|
||||
},
|
||||
id: 'responsive-grid',
|
||||
createFromLayout: ResponsiveGridLayoutManager.createFromLayout,
|
||||
|
||||
kind: 'ResponsiveGridLayout',
|
||||
isGridLayout: true,
|
||||
};
|
||||
|
||||
public readonly descriptor = ResponsiveGridLayoutManager.descriptor;
|
||||
|
@ -12,7 +12,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
|
||||
|
||||
import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
|
||||
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
|
||||
import { DashboardLayoutSelector } from '../layouts-shared/DashboardLayoutSelector';
|
||||
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
|
||||
import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
@ -32,20 +32,8 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
|
||||
title: t('dashboard.rows-layout.option.height', 'Height'),
|
||||
render: () => <RowHeightSelect row={model} />,
|
||||
})
|
||||
)
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.layout.common.layout', 'Layout'),
|
||||
render: () => <DashboardLayoutSelector layoutManager={layout} />,
|
||||
})
|
||||
);
|
||||
|
||||
if (layout.getOptions) {
|
||||
for (const option of layout.getOptions()) {
|
||||
editPaneHeaderOptions.addItem(option);
|
||||
}
|
||||
}
|
||||
|
||||
editPaneHeaderOptions
|
||||
.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
@ -61,13 +49,15 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
|
||||
);
|
||||
|
||||
return editPaneHeaderOptions;
|
||||
}, [layout, model]);
|
||||
}, [model]);
|
||||
|
||||
const layoutCategory = useLayoutCategory(layout);
|
||||
|
||||
const conditionalRenderingOptions = useMemo(() => {
|
||||
return useConditionalRenderingEditor(model.state.conditionalRendering);
|
||||
}, [model]);
|
||||
|
||||
const editOptions = [rowOptions];
|
||||
const editOptions = [rowOptions, layoutCategory];
|
||||
|
||||
if (conditionalRenderingOptions) {
|
||||
editOptions.push(conditionalRenderingOptions);
|
||||
|
@ -29,12 +29,12 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
return t('dashboard.rows-layout.name', 'Rows');
|
||||
},
|
||||
get description() {
|
||||
return t('dashboard.rows-layout.description', 'Rows layout');
|
||||
return t('dashboard.rows-layout.description', 'Collapsable panel groups with headings');
|
||||
},
|
||||
id: 'rows-layout',
|
||||
createFromLayout: RowsLayoutManager.createFromLayout,
|
||||
|
||||
kind: 'RowsLayout',
|
||||
isGridLayout: false,
|
||||
};
|
||||
|
||||
public readonly descriptor = RowsLayoutManager.descriptor;
|
||||
|
@ -30,11 +30,12 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
return t('dashboard.tabs-layout.name', 'Tabs');
|
||||
},
|
||||
get description() {
|
||||
return t('dashboard.tabs-layout.description', 'Tabs layout');
|
||||
return t('dashboard.tabs-layout.description', 'Organize panels into horizontal tabs');
|
||||
},
|
||||
id: 'tabs-layout',
|
||||
createFromLayout: TabsLayoutManager.createFromLayout,
|
||||
kind: 'TabsLayout',
|
||||
isGridLayout: false,
|
||||
};
|
||||
|
||||
public readonly descriptor = TabsLayoutManager.descriptor;
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Select } from '@grafana/ui';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { RadioButtonDot, Stack, useStyles2, Text } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
@ -10,53 +12,171 @@ import { isLayoutParent } from '../types/LayoutParent';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
|
||||
import { layoutRegistry } from './layoutRegistry';
|
||||
import { findParentLayout } from './utils';
|
||||
|
||||
export interface Props {
|
||||
layoutManager: DashboardLayoutManager;
|
||||
}
|
||||
|
||||
export function DashboardLayoutSelector({ layoutManager }: Props) {
|
||||
const options = useMemo(() => {
|
||||
const parentLayout = findParentLayout(layoutManager);
|
||||
const parentLayoutId = parentLayout?.descriptor.id;
|
||||
const isGridLayout = layoutManager.descriptor.isGridLayout;
|
||||
const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
|
||||
|
||||
return layoutRegistry
|
||||
.list()
|
||||
.filter((layout) => layout.id !== parentLayoutId)
|
||||
.map((layout) => ({
|
||||
label: layout.name,
|
||||
value: layout,
|
||||
}));
|
||||
}, [layoutManager]);
|
||||
|
||||
const currentLayoutId = layoutManager.descriptor.id;
|
||||
const currentOption = options.find((option) => option.value.id === currentLayoutId);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
value={currentOption}
|
||||
onChange={(option) => {
|
||||
if (option.value?.id !== currentOption?.value.id) {
|
||||
changeLayoutTo(layoutManager, option.value!);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div role="radiogroup" className={styles.radioGroup}>
|
||||
{options.map((opt) => {
|
||||
switch (opt.id) {
|
||||
case 'rows-layout':
|
||||
return (
|
||||
<LayoutRadioButton
|
||||
label={opt.name}
|
||||
id={opt.id}
|
||||
description={opt.description!}
|
||||
isSelected={layoutManager.descriptor.id === opt.id}
|
||||
onSelect={() => changeLayoutTo(layoutManager, opt)}
|
||||
>
|
||||
<div className={styles.rowsLayoutViz}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div style={{ gridColumn: 'span 3', fontSize: '6px' }}>⌄ .-.-.-.-.-</div>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div style={{ gridColumn: 'span 3', fontSize: '6px' }}>⌄ .-.-.-.-.-</div>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
</div>
|
||||
</LayoutRadioButton>
|
||||
);
|
||||
case 'tabs-layout':
|
||||
return (
|
||||
<LayoutRadioButton
|
||||
label={opt.name}
|
||||
id={opt.id}
|
||||
description={opt.description!}
|
||||
isSelected={layoutManager.descriptor.id === opt.id}
|
||||
onSelect={() => changeLayoutTo(layoutManager, opt)}
|
||||
>
|
||||
<Stack direction="column" gap={0.5} height={'100%'}>
|
||||
<div className={styles.tabsBar}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div className={cx(styles.tab, styles.tabActive)}>-.-.-</div>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div className={styles.tab}>-.-.-</div>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div className={styles.tab}>-.-.-</div>
|
||||
</div>
|
||||
<div className={styles.tabsVizTabContent}>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
</div>
|
||||
</Stack>
|
||||
</LayoutRadioButton>
|
||||
);
|
||||
case 'responsive-grid':
|
||||
return (
|
||||
<LayoutRadioButton
|
||||
label={opt.name}
|
||||
id={opt.id}
|
||||
description={opt.description!}
|
||||
isSelected={layoutManager.descriptor.id === opt.id}
|
||||
onSelect={() => changeLayoutTo(layoutManager, opt)}
|
||||
>
|
||||
<div className={styles.autoGridViz}>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
</div>
|
||||
</LayoutRadioButton>
|
||||
);
|
||||
case 'custom-grid':
|
||||
default:
|
||||
return (
|
||||
<LayoutRadioButton
|
||||
label={opt.name}
|
||||
id={opt.id}
|
||||
description={opt.description!}
|
||||
isSelected={layoutManager.descriptor.id === opt.id}
|
||||
onSelect={() => changeLayoutTo(layoutManager, opt)}
|
||||
>
|
||||
<div className={styles.customGridViz}>
|
||||
<GridCell colSpan={2} />
|
||||
<div className={styles.customGridVizInner}>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
</div>
|
||||
<GridCell />
|
||||
<GridCell colSpan={2} />
|
||||
</div>
|
||||
</LayoutRadioButton>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutRadioButtonProps {
|
||||
label: string;
|
||||
id: string;
|
||||
description: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function LayoutRadioButton({ label, id, description, isSelected, children, onSelect }: LayoutRadioButtonProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
// This outer div is just so that the radio dot can be outside the
|
||||
// label (as the RadioButtonDot has a label element and they can't nest)
|
||||
<div className={styles.radioButtonOuter}>
|
||||
<label
|
||||
htmlFor={`layout-${id}`}
|
||||
tabIndex={0}
|
||||
className={cx(styles.radioButton, isSelected && styles.radioButtonActive)}
|
||||
>
|
||||
{children}
|
||||
<Stack direction="column" gap={1} justifyContent="space-between" grow={1}>
|
||||
<Text weight="medium">{label}</Text>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</label>
|
||||
<div className={styles.radioDot}>
|
||||
<RadioButtonDot id={`layout-${id}`} name={'layout'} label={<></>} onChange={onSelect} checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridCell({ colSpan = 1 }: { colSpan?: number }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return <div className={styles.gridCell} style={{ gridColumn: `span ${colSpan}` }}></div>;
|
||||
}
|
||||
|
||||
export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
|
||||
return useMemo(() => {
|
||||
const categoryName = layoutManager.descriptor.isGridLayout
|
||||
? t('dashboard.layout.common.grid', 'Grid')
|
||||
: t('dashboard.layout.common.layout', 'Layout');
|
||||
|
||||
const layoutCategory = new OptionsPaneCategoryDescriptor({
|
||||
title: 'Layout',
|
||||
title: categoryName,
|
||||
id: 'layout-options',
|
||||
isOpenDefault: true,
|
||||
});
|
||||
|
||||
layoutCategory.addItem(
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.layout.common.layout', 'Layout'),
|
||||
title: '',
|
||||
skipField: true,
|
||||
render: () => <DashboardLayoutSelector layoutManager={layoutManager} />,
|
||||
})
|
||||
);
|
||||
@ -77,3 +197,100 @@ function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescript
|
||||
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
radioButtonOuter: css({
|
||||
position: 'relative',
|
||||
}),
|
||||
radioDot: css({
|
||||
position: 'absolute',
|
||||
top: theme.spacing(0.5),
|
||||
right: theme.spacing(0),
|
||||
}),
|
||||
radioGroup: css({
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
radioButton: css({
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1.5),
|
||||
padding: theme.spacing(1),
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `80px 1fr`,
|
||||
gridTemplateRows: '70px',
|
||||
}),
|
||||
radioButtonActive: css({
|
||||
border: `1px solid ${theme.colors.primary.border}`,
|
||||
}),
|
||||
gridCell: css({
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
}),
|
||||
tab: css({
|
||||
width: theme.spacing(2),
|
||||
height: theme.spacing(1),
|
||||
fontSize: '5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
tabActive: css({
|
||||
'&:before': {
|
||||
content: '" "',
|
||||
position: 'absolute',
|
||||
height: 1,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: theme.colors.gradients.brandHorizontal,
|
||||
},
|
||||
}),
|
||||
tabsBar: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(0.5),
|
||||
borderBottom: `1px solid ${theme.colors.border.medium}`,
|
||||
}),
|
||||
rowsLayoutViz: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gridTemplateRows: '10px 1fr 10px 1fr',
|
||||
gap: '4px',
|
||||
height: '100%',
|
||||
}),
|
||||
tabsVizTabContent: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gridTemplateRows: '1fr',
|
||||
gap: '4px',
|
||||
flexGrow: 1,
|
||||
}),
|
||||
autoGridViz: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gridTemplateRows: 'repeat(2, 1fr)',
|
||||
gap: '4px',
|
||||
height: '100%',
|
||||
}),
|
||||
customGridViz: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gridTemplateRows: 'repeat(2, 1fr)',
|
||||
gap: '4px',
|
||||
height: '100%',
|
||||
}),
|
||||
customGridVizInner: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(1, 1fr)',
|
||||
gridTemplateRows: 'repeat(2, 1fr)',
|
||||
gap: '4px',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SceneGridRow } from '@grafana/scenes';
|
||||
|
||||
import { NewObjectAddedToCanvasEvent } from '../../edit-pane/shared';
|
||||
@ -31,13 +32,20 @@ export function addNewTabTo(layout: DashboardLayoutManager): TabItem {
|
||||
}
|
||||
|
||||
export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGridRow {
|
||||
if (layout instanceof RowsLayoutManager) {
|
||||
/**
|
||||
* If new layouts feature is disabled we add old school rows to the custom grid layout
|
||||
*/
|
||||
if (!config.featureToggles.dashboardNewLayouts) {
|
||||
if (layout instanceof DefaultGridLayoutManager) {
|
||||
const row = layout.addNewRow();
|
||||
layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true);
|
||||
return row;
|
||||
} else {
|
||||
throw new Error('New dashboard layouts feature not enabled but new layout found');
|
||||
}
|
||||
}
|
||||
|
||||
if (layout instanceof DefaultGridLayoutManager) {
|
||||
if (layout instanceof RowsLayoutManager) {
|
||||
const row = layout.addNewRow();
|
||||
layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true);
|
||||
return row;
|
||||
@ -48,6 +56,9 @@ export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGrid
|
||||
return addNewRowTo(currentTab.state.layout);
|
||||
}
|
||||
|
||||
// If we want to add a row and current layout is custom grid or auto we migrate to rows layout
|
||||
// And wrap current layout in a row
|
||||
|
||||
const layoutParent = layout.parent!;
|
||||
if (!isLayoutParent(layoutParent)) {
|
||||
throw new Error('Parent layout is not a LayoutParent');
|
||||
|
@ -23,4 +23,9 @@ export interface LayoutRegistryItem<S = {}> extends RegistryItem {
|
||||
* Schema kind of layout
|
||||
*/
|
||||
kind?: DashboardV2Spec['layout']['kind'];
|
||||
|
||||
/**
|
||||
* Is grid layout (that contains panels)
|
||||
*/
|
||||
isGridLayout: boolean;
|
||||
}
|
||||
|
@ -1066,7 +1066,7 @@
|
||||
}
|
||||
},
|
||||
"default-layout": {
|
||||
"description": "Manually size and position panels",
|
||||
"description": "Position and size each panel individually",
|
||||
"item-options": {
|
||||
"repeat": {
|
||||
"direction": {
|
||||
@ -1215,6 +1215,7 @@
|
||||
"copy-or-duplicate": "Copy or Duplicate",
|
||||
"delete": "Delete",
|
||||
"duplicate": "Duplicate",
|
||||
"grid": "Grid",
|
||||
"layout": "Layout"
|
||||
}
|
||||
},
|
||||
@ -1238,8 +1239,8 @@
|
||||
}
|
||||
},
|
||||
"responsive-layout": {
|
||||
"description": "Automatically positions panels into a grid.",
|
||||
"name": "Auto",
|
||||
"description": "Panels resize to fit and form uniform grids",
|
||||
"name": "Auto grid",
|
||||
"options": {
|
||||
"columns": "Columns",
|
||||
"fixed": "Fixed: {{size}}px",
|
||||
@ -1251,7 +1252,7 @@
|
||||
}
|
||||
},
|
||||
"rows-layout": {
|
||||
"description": "Rows layout",
|
||||
"description": "Collapsable panel groups with headings",
|
||||
"name": "Rows",
|
||||
"option": {
|
||||
"height": "Height",
|
||||
@ -1286,7 +1287,7 @@
|
||||
}
|
||||
},
|
||||
"tabs-layout": {
|
||||
"description": "Tabs layout",
|
||||
"description": "Organize panels into horizontal tabs",
|
||||
"menu": {
|
||||
"move-tab": "Move tab"
|
||||
},
|
||||
|
Reference in New Issue
Block a user