Use full-width workloads filter deck

This commit is contained in:
rcourtman 2026-04-30 14:02:33 +01:00
parent c7164c2906
commit fc0bcd3204
7 changed files with 110 additions and 46 deletions

View file

@ -369,7 +369,11 @@ work extends shared components instead of creating new local variants.
filter surfaces may opt into the `PageControls` stacked action layout so
display, chart, column, and reset actions occupy their own compact grouped
row instead of splitting the filter row into left/right zones or creating a
full-width form-like divider. Narrow consumers such as
full-width form-like divider. When a dense surface would otherwise read as a
small form floating in a mostly empty full-width panel, it should route the
filter rows and trailing actions through `PageControls` `controlDeckClass`
so the controls read as one compact command deck instead of unrelated
left-aligned fragments. Narrow consumers such as
`ColumnPicker` must opt into their panel width through that primitive rather
than layering competing width classes page by page.
4. Add guardrail tests when a new shared pattern is introduced.

View file

@ -200,10 +200,11 @@ regression protection.
17. Extend workload control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts` and `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` and `frontend-modern/src/components/Workloads/useWorkloadsFilterState.ts`, rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`
Workloads filter presentation may consume frontend-primitives responsive
toggle controls, but the workload-owned state model must remain the source
of truth for active filter counting, reset behavior, and mobile collapse so
changing between wide toggle and narrow select presentation does not add a
second filter state path.
toggle controls and the shared compact control-deck wrapper, but the
workload-owned state model must remain the source of truth for active
filter counting, reset behavior, and mobile collapse so changing between
wide toggle and narrow select presentation does not add a second filter
state path or layout-measurement path on the workload hot path.
19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Workloads/thresholdSliderModel.ts` and `frontend-modern/src/components/Workloads/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Workloads/ThresholdSlider.tsx`
20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`
21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`

View file

@ -77,7 +77,9 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
}}
showFilters={showToolbarFilters()}
actionsLayout="stacked"
toolbarActionsClass="page-controls-toolbar-actions inline-flex max-w-full flex-wrap items-center gap-2 rounded-md bg-surface-hover p-0.5"
controlDeckClass="workloads-filter-control-deck inline-flex max-w-full flex-col items-start gap-1 rounded-md border border-border-subtle bg-surface-alt/60 p-1"
filterControlsClass="page-controls-filter-controls inline-flex max-w-full min-w-0"
toolbarActionsClass="page-controls-toolbar-actions inline-flex max-w-full flex-wrap items-center gap-2 pt-0.5"
toolbarTrailing={
<>
<GroupedTableModeSegmentedControl
@ -94,8 +96,8 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
</>
}
>
<div class="workloads-filter-control-stack flex w-full min-w-0 flex-col gap-1.5">
<div class="workloads-filter-primary-controls flex min-w-0 flex-wrap items-center gap-1.5 xl:flex-col xl:items-start">
<div class="workloads-filter-control-stack inline-flex max-w-full min-w-0 flex-col items-start gap-1">
<div class="workloads-filter-primary-controls flex min-w-0 flex-wrap items-center gap-1 xl:flex-col xl:items-start">
<LabeledFilterToggleGroup
id="workloads-type-filter"
label="Type"
@ -117,7 +119,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
</div>
<Show when={hasSecondaryFilters()}>
<div class="workloads-filter-secondary-controls flex min-w-0 flex-wrap items-center gap-2 rounded-md bg-surface-alt/60 p-1">
<div class="workloads-filter-secondary-controls flex max-w-full min-w-0 flex-wrap items-center gap-1">
<Show when={props.hostFilter}>
{(hostFilter) => (
<LabeledFilterSelect

View file

@ -169,10 +169,16 @@ describe('WorkloadsFilter', () => {
const { container } = render(() => <WorkloadsFilter {...props} />);
expect(screen.getByTestId('column-picker')).toBeInTheDocument();
const primaryControls = container.querySelector('.workloads-filter-primary-controls');
const secondaryControls = container.querySelector('.workloads-filter-secondary-controls');
const primaryControls = container.querySelector<HTMLElement>(
'.workloads-filter-primary-controls',
);
const secondaryControls = container.querySelector<HTMLElement>(
'.workloads-filter-secondary-controls',
);
const controlDeck = container.querySelector<HTMLElement>('.workloads-filter-control-deck');
expect(primaryControls).not.toBeNull();
expect(secondaryControls).not.toBeNull();
expect(controlDeck).not.toBeNull();
expect(primaryControls!).toContainElement(screen.getByRole('group', { name: 'Type' }));
expect(primaryControls!).toContainElement(screen.getByRole('group', { name: 'Status' }));
expect(secondaryControls!).toContainElement(
@ -187,10 +193,13 @@ describe('WorkloadsFilter', () => {
expect(secondaryControls!.compareDocumentPosition(primaryControls!)).toBe(
Node.DOCUMENT_POSITION_PRECEDING,
);
const toolbarActions = container.querySelector('.page-controls-toolbar-actions');
const toolbarActions = container.querySelector<HTMLElement>('.page-controls-toolbar-actions');
expect(toolbarActions).not.toBeNull();
expect(toolbarActions!).toHaveClass('rounded-md');
expect(toolbarActions!).toHaveClass('bg-surface-hover');
expect(controlDeck!).toHaveClass('inline-flex');
expect(controlDeck!).toHaveClass('bg-surface-alt/60');
expect(controlDeck!).toContainElement(toolbarActions!);
expect(controlDeck!).toContainElement(primaryControls!);
expect(toolbarActions!).toHaveClass('pt-0.5');
expect(toolbarActions!).not.toHaveClass('ml-auto');
expect(toolbarActions!).not.toHaveClass('border-t');
expect(toolbarActions!).toContainElement(screen.getByText('Grouped'));

View file

@ -130,6 +130,8 @@ describe('page controls guardrails', () => {
expect(pageControlsSource).toContain('page-controls-filter-controls');
expect(pageControlsSource).toContain('page-controls-toolbar-actions ml-auto');
expect(pageControlsSource).toContain('actionsLayout?:');
expect(pageControlsSource).toContain('controlDeckClass?:');
expect(pageControlsSource).toContain('const toolbarControls = () => (');
expect(pageControlsSource).toContain("actionsLayout() === 'stacked'");
expect(pageControlsSource).toContain(
'shrink-0 flex-wrap items-center justify-end gap-2 self-start',
@ -149,6 +151,8 @@ describe('page controls guardrails', () => {
expect(workloadsFilterSource).toContain('WORKLOAD_STATUS_FILTER_OPTIONS');
expect(workloadsFilterSource).toContain('workloads-filter-primary-controls');
expect(workloadsFilterSource).toContain('workloads-filter-secondary-controls');
expect(workloadsFilterSource).toContain('workloads-filter-control-deck');
expect(workloadsFilterSource).toContain('controlDeckClass=');
expect(workloadsFilterSource).toContain('actionsLayout="stacked"');
expect(workloadsFilterSource).toContain('page-controls-toolbar-actions inline-flex');
expect(workloadsFilterSource).toContain('xl:flex-col xl:items-start');

View file

@ -132,4 +132,39 @@ describe('PageControls', () => {
expect(filterControls!).not.toContainElement(columnsButton);
expect(screen.queryByTestId('desktop-utility')).not.toBeInTheDocument();
});
it('can wrap filters and trailing actions in one compact control deck', () => {
render(() => (
<PageControls
search={<div data-testid="search">Search</div>}
showFilters={true}
actionsLayout="stacked"
controlDeckClass="page-controls-control-deck"
filterControlsClass="page-controls-filter-controls compact-filters"
toolbarActionsClass="page-controls-toolbar-actions compact-actions"
toolbarTrailing={<div data-testid="toolbar-trailing">Grouped List</div>}
columnVisibility={{
availableToggles: () => [{ id: 'subject', label: 'Subject' }],
isHiddenByUser: () => false,
toggle: vi.fn(),
resetToDefaults: vi.fn(),
}}
>
<div>Filters</div>
</PageControls>
));
const deck = screen
.getByText('Filters', { selector: 'div' })
.closest('.page-controls-control-deck');
const trailing = screen.getByTestId('toolbar-trailing');
const columnsButton = screen.getByRole('button', { name: /columns/i });
expect(deck).not.toBeNull();
expect(deck!).toContainElement(screen.getByText('Filters', { selector: 'div' }));
expect(deck!).toContainElement(trailing);
expect(deck!).toContainElement(columnsButton);
expect(deck!.querySelector('.page-controls-filter-controls')).toHaveClass('compact-filters');
expect(deck!.querySelector('.page-controls-toolbar-actions')).toHaveClass('compact-actions');
});
});

View file

@ -48,6 +48,7 @@ interface PageControlsProps extends JSX.HTMLAttributes<HTMLDivElement> {
resetAction?: PageControlsResetAction;
mobileFilters?: PageControlsMobileFilters;
actionsLayout?: 'inline' | 'stacked';
controlDeckClass?: string;
filterControlsClass?: string;
toolbarActionsClass?: string;
}
@ -71,6 +72,7 @@ export const PageControls: Component<PageControlsProps> = (props) => {
'resetAction',
'mobileFilters',
'actionsLayout',
'controlDeckClass',
'filterControlsClass',
'toolbarActionsClass',
'class',
@ -108,6 +110,42 @@ export const PageControls: Component<PageControlsProps> = (props) => {
(actionsLayout() === 'stacked'
? 'page-controls-toolbar-actions inline-flex w-full flex-wrap items-center gap-2 border-t border-border-subtle pt-2'
: 'page-controls-toolbar-actions ml-auto inline-flex shrink-0 flex-wrap items-center justify-end gap-2 self-start');
const toolbarControls = () => (
<>
<div class={filterControlsClass()}>{local.children}</div>
<div class={toolbarActionsClass()}>
<Show when={local.toolbarTrailing}>{local.toolbarTrailing}</Show>
<Show when={activeUtilityActions()}>
<FilterDivider />
{activeUtilityActions()}
</Show>
<Show when={showColumnVisibility()}>
<FilterDivider />
<ColumnPicker
columns={local.columnVisibility!.availableToggles()}
isHidden={local.columnVisibility!.isHiddenByUser}
onToggle={local.columnVisibility!.toggle}
onReset={local.columnVisibility!.resetToDefaults}
/>
</Show>
<Show when={showResetAction()}>
<FilterDivider />
<FilterActionButton
onClick={() => local.resetAction?.onClick()}
title={local.resetAction?.title}
class={local.resetAction?.class}
>
{local.resetAction?.icon}
{local.resetAction?.label ?? 'Reset'}
</FilterActionButton>
</Show>
</div>
</>
);
return (
<FilterHeader
@ -125,38 +163,9 @@ export const PageControls: Component<PageControlsProps> = (props) => {
class={local.class}
>
<Show when={hasTrailingActions()}>
<div class={filterControlsClass()}>{local.children}</div>
<div class={toolbarActionsClass()}>
<Show when={local.toolbarTrailing}>{local.toolbarTrailing}</Show>
<Show when={activeUtilityActions()}>
<FilterDivider />
{activeUtilityActions()}
</Show>
<Show when={showColumnVisibility()}>
<FilterDivider />
<ColumnPicker
columns={local.columnVisibility!.availableToggles()}
isHidden={local.columnVisibility!.isHiddenByUser}
onToggle={local.columnVisibility!.toggle}
onReset={local.columnVisibility!.resetToDefaults}
/>
</Show>
<Show when={showResetAction()}>
<FilterDivider />
<FilterActionButton
onClick={() => local.resetAction?.onClick()}
title={local.resetAction?.title}
class={local.resetAction?.class}
>
{local.resetAction?.icon}
{local.resetAction?.label ?? 'Reset'}
</FilterActionButton>
</Show>
</div>
<Show when={local.controlDeckClass} fallback={toolbarControls()}>
{(controlDeckClass) => <div class={controlDeckClass()}>{toolbarControls()}</div>}
</Show>
</Show>
<Show when={!hasTrailingActions()}>{local.children}</Show>