diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts index db15876012d..f0e91d43162 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard.ts @@ -43,10 +43,14 @@ import { } from './dashboard_chart_view'; import {ResizeHandle} from '../../../widgets/resize_handle'; import {Card} from '../../../widgets/card'; -import {getDefaultChartLabel} from '../query_builder/nodes/visualisation_node'; +import { + ChartType, + getDefaultChartLabel, +} from '../query_builder/nodes/visualisation_node'; import {Popup, PopupPosition} from '../../../widgets/popup'; import {renderChartConfigPopup} from '../query_builder/charts/chart_config_popup'; import {RoundActionButton} from '../query_builder/widgets'; +import {renderChartTypePickerGrid} from '../query_builder/charts/chart_type_picker'; // Default dimensions for dashboard chart cards (in pixels). const DEFAULT_CHART_WIDTH = 400; @@ -205,26 +209,26 @@ export class Dashboard implements m.ClassComponent { return m( '.pf-dashboard__add-button', m( - PopupMenu, + Popup, { trigger: RoundActionButton({ icon: 'add', title: 'Add item', }), + fitContent: true, }, - m(MenuItem, { - label: 'Chart', - icon: 'bar_chart', - disabled: lastSource === undefined, - onclick: () => { - if (lastSource !== undefined) { - this.addChartForSource(attrs, lastSource); - } - }, - }), + lastSource !== undefined + ? renderChartTypePickerGrid((chartType: ChartType) => { + this.addChartForSource(attrs, lastSource, chartType); + }) + : m( + '.pf-chart-type-picker__empty', + 'Add a data source first to create charts', + ), m(MenuItem, { label: 'Label', icon: 'text_fields', + className: 'pf-dismiss-popup-group', onclick: () => { this.addLabel(attrs); }, @@ -232,6 +236,7 @@ export class Dashboard implements m.ClassComponent { m(MenuItem, { label: 'Segment Divider', icon: 'horizontal_rule', + className: 'pf-dismiss-popup-group', onclick: () => { this.addDivider(attrs); }, @@ -1171,24 +1176,31 @@ export class Dashboard implements m.ClassComponent { ), ), ), - m(Button, { - label: 'Add Chart', - icon: 'bar_chart', - compact: true, - className: 'pf-dashboard__add-chart-btn', - onclick: () => { - this.addChartForSource(attrs, source); + m( + Popup, + { + trigger: m(Button, { + label: 'Add Chart', + icon: 'bar_chart', + compact: true, + className: 'pf-dashboard__add-chart-btn', + }), + fitContent: true, }, - }), + renderChartTypePickerGrid((chartType: ChartType) => { + this.addChartForSource(attrs, source, chartType); + }), + ), ]; } private addChartForSource( attrs: DashboardAttrs, source: DashboardDataSource, + chartType: ChartType, ): void { const items = [...attrs.items]; - const newConfig = createDefaultChartConfig(source.columns); + const newConfig = createDefaultChartConfig(source.columns, chartType); const candidate = getNextItemPosition(items); const pos = findNonOverlappingPosition( candidate.x, diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts index 7bf25588d93..18b363c823c 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/dashboard/dashboard_chart_view.ts @@ -441,11 +441,12 @@ function buildWhereClause( */ export function createDefaultChartConfig( columns: ReadonlyArray<{name: string}>, + chartType: ChartType = 'bar', ): ChartConfig { const column = columns.length > 0 ? columns[0].name : ''; return { id: generateChartId(), column, - chartType: 'bar', + chartType, }; } diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts new file mode 100644 index 00000000000..b2dfda9f986 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_type_picker.ts @@ -0,0 +1,269 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {ChartType} from '../nodes/visualisation_node'; +import {CHART_TYPES, ChartTypeDefinition} from '../nodes/chart_type_registry'; + +// SVG preview renderers for each chart type. Each returns a small schematic +// SVG that gives users a visual sense of what the chart looks like. +const CHART_PREVIEW_RENDERERS: Record m.Children> = { + bar: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + m('rect', {x: 6, y: 28, width: 10, height: 16, rx: 1, opacity: 0.5}), + m('rect', {x: 20, y: 12, width: 10, height: 32, rx: 1, opacity: 0.7}), + m('rect', {x: 34, y: 20, width: 10, height: 24, rx: 1, opacity: 0.85}), + m('rect', {x: 48, y: 6, width: 10, height: 38, rx: 1}), + ), + + histogram: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + m('rect', {x: 4, y: 30, width: 8, height: 14, opacity: 0.4}), + m('rect', {x: 12, y: 22, width: 8, height: 22, opacity: 0.55}), + m('rect', {x: 20, y: 10, width: 8, height: 34, opacity: 0.75}), + m('rect', {x: 28, y: 6, width: 8, height: 38, opacity: 0.9}), + m('rect', {x: 36, y: 14, width: 8, height: 30, opacity: 0.7}), + m('rect', {x: 44, y: 26, width: 8, height: 18, opacity: 0.5}), + m('rect', {x: 52, y: 34, width: 8, height: 10, opacity: 0.35}), + ), + + line: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'none', stroke: 'currentColor'}, + m('polyline', { + 'points': '6,38 16,28 26,32 36,16 46,20 58,8', + 'stroke-width': '2.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + m('circle', { + cx: 6, + cy: 38, + r: 2.5, + fill: 'currentColor', + stroke: 'none', + }), + m('circle', { + cx: 26, + cy: 32, + r: 2.5, + fill: 'currentColor', + stroke: 'none', + }), + m('circle', { + cx: 46, + cy: 20, + r: 2.5, + fill: 'currentColor', + stroke: 'none', + }), + m('circle', { + cx: 58, + cy: 8, + r: 2.5, + fill: 'currentColor', + stroke: 'none', + }), + ), + + scatter: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + m('circle', {cx: 10, cy: 34, r: 3.5, opacity: 0.7}), + m('circle', {cx: 18, cy: 26, r: 2.5, opacity: 0.6}), + m('circle', {cx: 28, cy: 18, r: 4, opacity: 0.8}), + m('circle', {cx: 36, cy: 30, r: 3, opacity: 0.65}), + m('circle', {cx: 44, cy: 12, r: 3.5, opacity: 0.75}), + m('circle', {cx: 52, cy: 22, r: 2.5, opacity: 0.85}), + m('circle', {cx: 22, cy: 38, r: 2, opacity: 0.5}), + m('circle', {cx: 48, cy: 36, r: 2, opacity: 0.55}), + ), + + pie: () => + m( + 'svg', + {viewBox: '0 0 64 48'}, + // Larger slice (~60%) + m('path', { + d: 'M32,24 L32,8 A16,16 0 1,1 18.1,36.1 Z', + fill: 'currentColor', + opacity: 0.8, + }), + // Smaller slice (~40%) + m('path', { + d: 'M32,24 L18.1,36.1 A16,16 0 0,1 32,8 Z', + fill: 'currentColor', + opacity: 0.45, + }), + ), + + treemap: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + m('rect', {x: 4, y: 4, width: 34, height: 24, rx: 2, opacity: 0.8}), + m('rect', {x: 40, y: 4, width: 20, height: 24, rx: 2, opacity: 0.55}), + m('rect', {x: 4, y: 30, width: 20, height: 14, rx: 2, opacity: 0.65}), + m('rect', {x: 26, y: 30, width: 16, height: 14, rx: 2, opacity: 0.4}), + m('rect', {x: 44, y: 30, width: 16, height: 14, rx: 2, opacity: 0.5}), + ), + + boxplot: () => + m( + 'svg', + {viewBox: '0 0 64 48', stroke: 'currentColor', fill: 'currentColor'}, + // Whisker lines + m('line', {'x1': 20, 'y1': 6, 'x2': 20, 'y2': 14, 'stroke-width': 1.5}), + m('line', {'x1': 20, 'y1': 34, 'x2': 20, 'y2': 42, 'stroke-width': 1.5}), + // Caps + m('line', {'x1': 15, 'y1': 6, 'x2': 25, 'y2': 6, 'stroke-width': 1.5}), + m('line', {'x1': 15, 'y1': 42, 'x2': 25, 'y2': 42, 'stroke-width': 1.5}), + // Box + m('rect', { + 'x': 13, + 'y': 14, + 'width': 14, + 'height': 20, + 'rx': 1, + 'fill': 'none', + 'stroke-width': 1.5, + }), + // Median line + m('line', {'x1': 13, 'y1': 22, 'x2': 27, 'y2': 22, 'stroke-width': 2}), + // Second boxplot (shorter) + m('line', {'x1': 44, 'y1': 12, 'x2': 44, 'y2': 18, 'stroke-width': 1.5}), + m('line', {'x1': 44, 'y1': 36, 'x2': 44, 'y2': 42, 'stroke-width': 1.5}), + m('line', {'x1': 39, 'y1': 12, 'x2': 49, 'y2': 12, 'stroke-width': 1.5}), + m('line', {'x1': 39, 'y1': 42, 'x2': 49, 'y2': 42, 'stroke-width': 1.5}), + m('rect', { + 'x': 37, + 'y': 18, + 'width': 14, + 'height': 18, + 'rx': 1, + 'fill': 'none', + 'stroke-width': 1.5, + }), + m('line', {'x1': 37, 'y1': 28, 'x2': 51, 'y2': 28, 'stroke-width': 2}), + ), + + heatmap: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + // 4x3 grid of cells with varying opacity + m('rect', {x: 4, y: 4, width: 13, height: 12, rx: 1, opacity: 0.9}), + m('rect', {x: 19, y: 4, width: 13, height: 12, rx: 1, opacity: 0.4}), + m('rect', {x: 34, y: 4, width: 13, height: 12, rx: 1, opacity: 0.7}), + m('rect', {x: 49, y: 4, width: 13, height: 12, rx: 1, opacity: 0.3}), + m('rect', {x: 4, y: 18, width: 13, height: 12, rx: 1, opacity: 0.5}), + m('rect', {x: 19, y: 18, width: 13, height: 12, rx: 1, opacity: 0.8}), + m('rect', {x: 34, y: 18, width: 13, height: 12, rx: 1, opacity: 0.35}), + m('rect', {x: 49, y: 18, width: 13, height: 12, rx: 1, opacity: 0.65}), + m('rect', {x: 4, y: 32, width: 13, height: 12, rx: 1, opacity: 0.25}), + m('rect', {x: 19, y: 32, width: 13, height: 12, rx: 1, opacity: 0.6}), + m('rect', {x: 34, y: 32, width: 13, height: 12, rx: 1, opacity: 0.85}), + m('rect', {x: 49, y: 32, width: 13, height: 12, rx: 1, opacity: 0.45}), + ), + + cdf: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'none', stroke: 'currentColor'}, + m('polyline', { + 'points': '6,42 14,40 22,36 28,28 34,18 40,12 48,9 56,8', + 'stroke-width': '2.5', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + }), + // Dashed 50% line + m('line', { + 'x1': 4, + 'y1': 24, + 'x2': 60, + 'y2': 24, + 'stroke-width': 1, + 'stroke-dasharray': '3,3', + 'opacity': 0.4, + }), + ), + + scorecard: () => + m( + 'svg', + {viewBox: '0 0 64 48', fill: 'currentColor'}, + m( + 'text', + { + 'x': 32, + 'y': 28, + 'text-anchor': 'middle', + 'dominant-baseline': 'central', + 'font-size': '18', + 'font-weight': 'bold', + }, + '42', + ), + m( + 'text', + { + 'x': 32, + 'y': 41, + 'text-anchor': 'middle', + 'font-size': '7', + 'opacity': 0.5, + }, + 'value', + ), + ), +}; + +/** + * Renders a grid of chart type cards. Each card fires `onSelect` when clicked + * and has the `pf-dismiss-popup-group` class so it auto-closes a parent Popup. + */ +export function renderChartTypePickerGrid( + onSelect: (type: ChartType) => void, +): m.Children { + return m( + '.pf-chart-type-picker', + CHART_TYPES.map((def) => renderChartTypeCard(def, onSelect)), + ); +} + +function renderChartTypeCard( + def: ChartTypeDefinition, + onSelect: (type: ChartType) => void, +): m.Children { + const description = def.description; + + return m( + 'button.pf-chart-type-picker__card.pf-dismiss-popup-group', + { + key: def.type, + title: description, + onclick: () => onSelect(def.type), + }, + [ + m('.pf-chart-type-picker__preview', CHART_PREVIEW_RENDERERS[def.type]()), + m('.pf-chart-type-picker__label', def.label), + ], + ); +} diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts index 6f7375b3c7d..bfce4561bab 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/charts/chart_view.ts @@ -37,6 +37,7 @@ import {QueryExecutionService} from '../query_execution_service'; import {EmptyState} from '../../../../widgets/empty_state'; import {Card} from '../../../../widgets/card'; import {AddItemPlaceholder} from '../widgets'; +import {renderChartTypePickerGrid} from './chart_type_picker'; export interface ChartViewAttrs { trace: Trace; @@ -175,14 +176,20 @@ export class ChartView implements m.ClassComponent { title: 'No charts configured', fillHeight: true, }, - m(Button, { - label: 'Add first chart', - icon: 'add', - onclick: () => { - attrs.node.addChart(); - attrs.onFilterChange?.(); + m( + Popup, + { + trigger: m(Button, { + label: 'Add first chart', + icon: 'add', + }), + fitContent: true, }, - }), + renderChartTypePickerGrid((chartType) => { + attrs.node.addChart(chartType); + attrs.onFilterChange?.(); + }), + ), ), ); } @@ -210,15 +217,25 @@ export class ChartView implements m.ClassComponent { toolbar, m(`.pf-chart-view__charts.${gridClass}`, [ ...configs.map((config) => this.renderSingleChart(attrs, config)), - m(AddItemPlaceholder, { - key: 'add-chart', - label: 'Add Chart', - icon: 'add', - onclick: () => { - attrs.node.addChart(); - attrs.onFilterChange?.(); + m( + Popup, + { + key: 'add-chart', + trigger: m( + 'span', + m(AddItemPlaceholder, { + label: 'Add Chart', + icon: 'add', + }), + ), + showArrow: false, + fitContent: true, }, - }), + renderChartTypePickerGrid((chartType) => { + attrs.node.addChart(chartType); + attrs.onFilterChange?.(); + }), + ), ]), ]); } diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts index b48880df974..4cf26b89172 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/chart_type_registry.ts @@ -78,6 +78,9 @@ export interface ChartTypeDefinition { * Example: Scatter plot bubble size. */ readonly supportsSizeColumn: boolean; + + /** Short description shown on hover in the chart type picker. */ + readonly description: string; } /** @@ -98,6 +101,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Compare categories using vertical or horizontal bars', }, { type: 'histogram', @@ -110,6 +114,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Show distribution of numeric values across bins', }, { type: 'line', @@ -122,6 +127,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: true, supportsGroupColumn: true, supportsSizeColumn: false, + description: 'Plot trends with connected data points over a numeric axis', }, { type: 'scatter', @@ -134,6 +140,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: true, supportsGroupColumn: true, supportsSizeColumn: true, + description: 'Reveal correlations between two numeric variables', }, { type: 'pie', @@ -146,6 +153,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Show proportions of a whole as slices', }, { type: 'treemap', @@ -158,6 +166,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: true, supportsSizeColumn: false, + description: 'Display hierarchical data as nested rectangles by size', }, { type: 'boxplot', @@ -170,6 +179,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: true, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Summarize data spread with quartiles and outliers', }, { type: 'heatmap', @@ -182,6 +192,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: true, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Visualize magnitude across two dimensions using color', }, { type: 'cdf', @@ -194,6 +205,8 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: true, supportsSizeColumn: false, + description: + 'Cumulative distribution — proportion of values below a threshold', }, { type: 'scorecard', @@ -206,6 +219,7 @@ export const CHART_TYPES: readonly ChartTypeDefinition[] = [ supportsYColumn: false, supportsGroupColumn: false, supportsSizeColumn: false, + description: 'Display a single aggregated number prominently', }, ] as const; diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts index e928908f439..f108cdec0d6 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/visualisation_node.ts @@ -43,6 +43,8 @@ import {Button} from '../../../../widgets/button'; import {Icon} from '../../../../widgets/icon'; import {Card} from '../../../../widgets/card'; import {classNames} from '../../../../base/classnames'; +import {renderChartTypePickerGrid} from '../charts/chart_type_picker'; +import {Popup} from '../../../../widgets/popup'; /** * Chart type options. @@ -488,14 +490,23 @@ export class VisualisationNode implements QueryNode { title: 'Charts', content: m('.pf-chart-cards-container', [ ...chartCards, - m(AddItemPlaceholder, { - key: 'add-chart', - label: 'Add Chart', - icon: 'add', - onclick: () => { - this.addChart(); + m( + Popup, + { + key: 'add-chart', + trigger: m( + 'span', + m(AddItemPlaceholder, { + label: 'Add Chart', + icon: 'add', + }), + ), + fitContent: true, }, - }), + renderChartTypePickerGrid((chartType: ChartType) => { + this.addChart(chartType); + }), + ), ]), }); @@ -526,7 +537,7 @@ export class VisualisationNode implements QueryNode { * Prefers string/categorical columns for bar charts, and avoids * columns already used by other charts when possible. */ - addChart(): void { + addChart(chartType: ChartType = 'bar'): void { // Get columns already used by existing charts const usedColumns = new Set( this.state.chartConfigs.map((c) => c.column).filter(Boolean), @@ -534,13 +545,13 @@ export class VisualisationNode implements QueryNode { // Find a good default column: // 1. Prefer unused columns - // 2. Prefer non-numeric columns (better for bar charts) + // 2. Prefer non-numeric columns (better for aggregation chart types) // 3. Fall back to first available column const availableCols = this.sourceCols; const unusedCols = availableCols.filter((c) => !usedColumns.has(c.name)); const colsToCheck = unusedCols.length > 0 ? unusedCols : availableCols; - // Prefer string/categorical columns for bar charts + // Prefer string/categorical columns for aggregation charts const stringCol = colsToCheck.find( (c) => c.column.type === undefined || !isQuantitativeType(c.column.type), ); @@ -549,7 +560,7 @@ export class VisualisationNode implements QueryNode { const newChart: ChartConfig = { id: generateChartId(), column: defaultColumn, - chartType: 'bar', + chartType, }; this.state.chartConfigs.push(newChart); this.state.onchange?.(); diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts index 269afa486b1..2094724a4cf 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts +++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/widgets.ts @@ -754,7 +754,7 @@ export class OutlinedMultiSelect export interface AddItemPlaceholderAttrs { label: string; icon?: string; - onclick: () => void; + onclick?: () => void; } export class AddItemPlaceholder diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss b/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss index d4b10f2cce8..88904f57e0f 100644 --- a/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss +++ b/ui/src/plugins/dev.perfetto.DataExplorer/styles.scss @@ -608,3 +608,74 @@ padding: 16px 8px; } } + +// Chart type picker — visual grid of chart types shown when adding a chart. +.pf-chart-type-picker { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 4px; + + &__card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 6px; + border: 1px solid var(--pf-color-border); + border-radius: 6px; + background: var(--pf-color-surface); + cursor: pointer; + transition: + border-color 0.15s, + box-shadow 0.15s, + background 0.15s; + // Reset button defaults + font: inherit; + color: inherit; + + &:hover { + border-color: var(--pf-color-primary); + background: var(--pf-color-surface-highlight); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + } + + &:focus-visible { + outline: 2px solid var(--pf-color-primary); + outline-offset: 2px; + } + + &:active { + transform: scale(0.97); + } + } + + &__preview { + width: 48px; + height: 36px; + color: var(--pf-color-primary); + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 100%; + height: 100%; + } + } + + &__label { + font-size: 11px; + font-weight: 500; + text-align: center; + color: var(--pf-color-text); + white-space: nowrap; + } + + &__empty { + padding: 12px 16px; + font-size: 12px; + color: var(--pf-color-text-muted); + text-align: center; + } +}