Split selection card group owners

This commit is contained in:
rcourtman
2026-03-23 09:42:29 +00:00
parent 04290d0573
commit 30ac658d71
7 changed files with 285 additions and 104 deletions
@@ -279,6 +279,14 @@ owns variant resolution plus disabled selection/change runtime, and
variant class catalog, compact-label policy, and segmented button class
selection. Future filter-button-group work should extend those owners instead
of pushing label truncation or segmented variant policy back into the shell.
The shared selection-card primitive now follows that same owner split.
`frontend-modern/src/components/shared/SelectionCardGroup.tsx` stays the render
shell, `frontend-modern/src/components/shared/useSelectionCardGroupState.ts`
owns variant resolution plus disabled selection/change runtime, and
`frontend-modern/src/components/shared/selectionCardGroupModel.ts` owns the
tone fallback, group/button class catalog, and title/description presentation
policy. Future selection-card-group work should extend those owners instead of
pushing tone or active-card presentation logic back into the shell.
The shared dialog now follows that same owner split.
`frontend-modern/src/components/shared/Dialog.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDialogState.ts` owns focus trap,
@@ -1,12 +1,37 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SelectionCardGroup } from './SelectionCardGroup';
import selectionCardGroupSource from './SelectionCardGroup.tsx?raw';
import selectionCardGroupModelSource from './selectionCardGroupModel.ts?raw';
import selectionCardGroupStateSource from './useSelectionCardGroupState.ts?raw';
describe('SelectionCardGroup', () => {
afterEach(() => {
cleanup();
});
it('keeps shell, runtime, and model owners split', () => {
expect(selectionCardGroupSource).toContain('useSelectionCardGroupState');
expect(selectionCardGroupSource).toContain('getSelectionCardGroupClass');
expect(selectionCardGroupSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupSource).toContain('getSelectionCardTitleClass');
expect(selectionCardGroupSource).not.toContain('resolveSelectionCardTone');
expect(selectionCardGroupSource).not.toContain('props.onChange(option.value)');
expect(selectionCardGroupSource).not.toContain('groupClassByVariant');
expect(selectionCardGroupStateSource).toContain('export function useSelectionCardGroupState');
expect(selectionCardGroupStateSource).toContain('createMemo');
expect(selectionCardGroupStateSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupStateSource).toContain('props.disabled || option.disabled');
expect(selectionCardGroupStateSource).toContain('props.onChange(option.value)');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardGroupVariant');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupModelSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupModelSource).toContain('getSelectionCardTitleClass');
expect(selectionCardGroupModelSource).toContain("compact: 'grid grid-cols-2 gap-2'");
});
it('routes compact card selection changes through the shared primitive', () => {
const onChange = vi.fn();
@@ -32,6 +57,28 @@ describe('SelectionCardGroup', () => {
expect(onChange).toHaveBeenCalledWith('openai');
});
it('blocks disabled selection changes in the runtime owner', () => {
const onChange = vi.fn();
render(() => (
<SelectionCardGroup
options={[
{ value: 'stable', title: 'Stable' },
{ value: 'rc', title: 'Release Candidate', disabled: true },
]}
value="stable"
onChange={onChange}
variant="detail"
/>
));
const rcButton = screen.getByRole('button', { name: /release candidate/i });
expect(rcButton).toBeDisabled();
fireEvent.click(rcButton);
expect(onChange).not.toHaveBeenCalled();
});
it('supports detail cards with success tone styling', () => {
render(() => (
<SelectionCardGroup
@@ -1,131 +1,89 @@
import { For, type JSX } from 'solid-js';
import { For } from 'solid-js';
import {
getSelectionCardButtonClass,
getSelectionCardDescriptionClass,
getSelectionCardGroupClass,
getSelectionCardIconContainerClass,
getSelectionCardTitleClass,
type SelectionCardGroupProps,
} from './selectionCardGroupModel';
import { useSelectionCardGroupState } from './useSelectionCardGroupState';
export type SelectionCardTone = 'accent' | 'success';
type SelectionCardGroupVariant = 'compact' | 'detail';
export interface SelectionCardOption<T extends string | number> {
value: T;
title: string;
description?: string;
icon?: (props: { active: boolean }) => JSX.Element;
tone?: SelectionCardTone;
disabled?: boolean;
}
interface SelectionCardGroupProps<T extends string | number> {
options: SelectionCardOption<T>[];
value: T;
onChange: (value: T) => void;
class?: string;
variant?: SelectionCardGroupVariant;
disabled?: boolean;
}
const groupClassByVariant: Record<SelectionCardGroupVariant, string> = {
compact: 'grid grid-cols-2 gap-2',
detail: 'grid grid-cols-1 gap-3',
};
function activeCardClass(tone: SelectionCardTone): string {
if (tone === 'success') {
return 'border-green-500 bg-green-50 dark:bg-green-900';
}
return 'border-blue-500 bg-blue-50 dark:bg-blue-900';
}
function inactiveCardClass(variant: SelectionCardGroupVariant): string {
if (variant === 'compact') {
return 'border-border hover:border-blue-300';
}
return 'border-border hover:border-border';
}
function buttonClass(
variant: SelectionCardGroupVariant,
tone: SelectionCardTone,
active: boolean,
disabled: boolean,
): string {
const base =
variant === 'detail'
? 'p-4 rounded-md border-2 transition-all text-left'
: 'p-3 rounded-md border-2 transition-all text-center';
return [
base,
active ? activeCardClass(tone) : inactiveCardClass(variant),
disabled ? 'disabled:opacity-50 disabled:cursor-not-allowed' : '',
].join(' ');
}
function iconContainerClass(tone: SelectionCardTone, active: boolean): string {
const activeClass =
tone === 'success' ? 'bg-green-100 dark:bg-green-800' : 'bg-blue-100 dark:bg-blue-800';
return ['p-2 rounded-md', active ? activeClass : 'bg-surface-alt'].join(' ');
}
function titleClass(
variant: SelectionCardGroupVariant,
tone: SelectionCardTone,
active: boolean,
): string {
if (variant === 'compact') {
return 'text-sm font-medium text-base-content';
}
if (!active) {
return 'text-sm font-semibold text-base-content';
}
return tone === 'success'
? 'text-sm font-semibold text-green-900 dark:text-green-100'
: 'text-sm font-semibold text-blue-900 dark:text-blue-100';
}
function descriptionClass(variant: SelectionCardGroupVariant): string {
return variant === 'compact' ? 'text-xs text-slate-500 mt-0.5' : 'text-xs text-muted';
}
export type {
SelectionCardGroupProps,
SelectionCardGroupVariant,
SelectionCardOption,
SelectionCardTone,
} from './selectionCardGroupModel';
export function SelectionCardGroup<T extends string | number>(props: SelectionCardGroupProps<T>) {
const variant = () => props.variant ?? 'detail';
const selectionCardGroup = useSelectionCardGroupState(props);
return (
<div
class={`${groupClassByVariant[variant()]} ${props.class ?? ''}`.trim()}
class={getSelectionCardGroupClass(selectionCardGroup.variant(), props.class)}
role="group"
aria-label="Selection Cards"
>
<For each={props.options}>
{(option) => {
const isActive = () => option.value === props.value;
const isDisabled = () => props.disabled || option.disabled || false;
const tone = () => option.tone ?? 'accent';
return (
<button
type="button"
onClick={() => props.onChange(option.value)}
class={buttonClass(variant(), tone(), isActive(), isDisabled())}
aria-pressed={isActive()}
disabled={isDisabled()}
onClick={() => selectionCardGroup.handleOptionClick(option)}
class={getSelectionCardButtonClass(
selectionCardGroup.variant(),
selectionCardGroup.getOptionTone(option),
selectionCardGroup.isOptionActive(option),
selectionCardGroup.isOptionDisabled(option),
)}
aria-pressed={selectionCardGroup.isOptionActive(option)}
disabled={selectionCardGroup.isOptionDisabled(option)}
>
{variant() === 'detail' ? (
{selectionCardGroup.variant() === 'detail' ? (
<div class="flex items-center gap-3">
{option.icon && (
<div class={iconContainerClass(tone(), isActive())}>
{option.icon({ active: isActive() })}
<div
class={getSelectionCardIconContainerClass(
selectionCardGroup.getOptionTone(option),
selectionCardGroup.isOptionActive(option),
)}
>
{option.icon({ active: selectionCardGroup.isOptionActive(option) })}
</div>
)}
<div>
<p class={titleClass(variant(), tone(), isActive())}>{option.title}</p>
<p
class={getSelectionCardTitleClass(
selectionCardGroup.variant(),
selectionCardGroup.getOptionTone(option),
selectionCardGroup.isOptionActive(option),
)}
>
{option.title}
</p>
{option.description && (
<p class={descriptionClass(variant())}>{option.description}</p>
<p class={getSelectionCardDescriptionClass(selectionCardGroup.variant())}>
{option.description}
</p>
)}
</div>
</div>
) : (
<div>
<div class={titleClass(variant(), tone(), isActive())}>{option.title}</div>
<div
class={getSelectionCardTitleClass(
selectionCardGroup.variant(),
selectionCardGroup.getOptionTone(option),
selectionCardGroup.isOptionActive(option),
)}
>
{option.title}
</div>
{option.description && (
<div class={descriptionClass(variant())}>{option.description}</div>
<div class={getSelectionCardDescriptionClass(selectionCardGroup.variant())}>
{option.description}
</div>
)}
</div>
)}
@@ -60,6 +60,7 @@ import infrastructureSummaryTableStateSource from '@/components/shared/useInfras
import monitoredSystemLimitWarningBannerSource from '@/components/shared/MonitoredSystemLimitWarningBanner.tsx?raw';
import monitoredSystemLimitWarningBannerModelSource from '@/components/shared/monitoredSystemLimitWarningBannerModel.ts?raw';
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
import selectionCardGroupModelSource from '@/components/shared/selectionCardGroupModel.ts?raw';
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
@@ -87,6 +88,7 @@ import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
import trialBannerStateSource from '@/components/shared/useTrialBannerState.ts?raw';
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import monitoredSystemLimitWarningBannerStateSource from '@/components/shared/useMonitoredSystemLimitWarningBannerState.ts?raw';
import selectionCardGroupStateSource from '@/components/shared/useSelectionCardGroupState.ts?raw';
import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw';
import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw';
import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterfaceUrlFieldState.ts?raw';
@@ -152,9 +154,20 @@ describe('shared primitive guardrails', () => {
});
it('routes selectable settings cards through SelectionCardGroup', () => {
expect(selectionCardGroupSource).toContain(
"type SelectionCardGroupVariant = 'compact' | 'detail'",
);
expect(selectionCardGroupSource).toContain('useSelectionCardGroupState');
expect(selectionCardGroupSource).toContain('getSelectionCardGroupClass');
expect(selectionCardGroupSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupSource).toContain('getSelectionCardTitleClass');
expect(selectionCardGroupSource).not.toContain('resolveSelectionCardTone');
expect(selectionCardGroupSource).not.toContain('props.onChange(option.value)');
expect(selectionCardGroupStateSource).toContain('export function useSelectionCardGroupState');
expect(selectionCardGroupStateSource).toContain('createMemo');
expect(selectionCardGroupStateSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupStateSource).toContain('props.onChange(option.value)');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardGroupVariant');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupModelSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupModelSource).toContain("compact: 'grid grid-cols-2 gap-2'");
expect(aiSettingsDialogsSource).toContain('SelectionCardGroup');
expect(aiSettingsDialogsSource).toContain('variant="compact"');
expect(aiSettingsDialogsSource).not.toContain(
@@ -0,0 +1,105 @@
import type { JSX } from 'solid-js';
export type SelectionCardTone = 'accent' | 'success';
export type SelectionCardGroupVariant = 'compact' | 'detail';
export interface SelectionCardOption<T extends string | number> {
value: T;
title: string;
description?: string;
icon?: (props: { active: boolean }) => JSX.Element;
tone?: SelectionCardTone;
disabled?: boolean;
}
export interface SelectionCardGroupProps<T extends string | number> {
options: SelectionCardOption<T>[];
value: T;
onChange: (value: T) => void;
class?: string;
variant?: SelectionCardGroupVariant;
disabled?: boolean;
}
const groupClassByVariant: Record<SelectionCardGroupVariant, string> = {
compact: 'grid grid-cols-2 gap-2',
detail: 'grid grid-cols-1 gap-3',
};
export function resolveSelectionCardGroupVariant(
variant: SelectionCardGroupVariant | undefined,
): SelectionCardGroupVariant {
return variant ?? 'detail';
}
export function resolveSelectionCardTone(tone: SelectionCardTone | undefined): SelectionCardTone {
return tone ?? 'accent';
}
export function getSelectionCardGroupClass(
variant: SelectionCardGroupVariant,
className?: string,
): string {
return `${groupClassByVariant[variant]} ${className ?? ''}`.trim();
}
function getSelectionCardActiveClass(tone: SelectionCardTone): string {
if (tone === 'success') {
return 'border-green-500 bg-green-50 dark:bg-green-900';
}
return 'border-blue-500 bg-blue-50 dark:bg-blue-900';
}
function getSelectionCardInactiveClass(variant: SelectionCardGroupVariant): string {
if (variant === 'compact') {
return 'border-border hover:border-blue-300';
}
return 'border-border hover:border-border';
}
export function getSelectionCardButtonClass(
variant: SelectionCardGroupVariant,
tone: SelectionCardTone,
active: boolean,
disabled: boolean,
): string {
const base =
variant === 'detail'
? 'p-4 rounded-md border-2 transition-all text-left'
: 'p-3 rounded-md border-2 transition-all text-center';
return [
base,
active ? getSelectionCardActiveClass(tone) : getSelectionCardInactiveClass(variant),
disabled ? 'disabled:opacity-50 disabled:cursor-not-allowed' : '',
].join(' ');
}
export function getSelectionCardIconContainerClass(
tone: SelectionCardTone,
active: boolean,
): string {
const activeClass =
tone === 'success' ? 'bg-green-100 dark:bg-green-800' : 'bg-blue-100 dark:bg-blue-800';
return ['p-2 rounded-md', active ? activeClass : 'bg-surface-alt'].join(' ');
}
export function getSelectionCardTitleClass(
variant: SelectionCardGroupVariant,
tone: SelectionCardTone,
active: boolean,
): string {
if (variant === 'compact') {
return 'text-sm font-medium text-base-content';
}
if (!active) {
return 'text-sm font-semibold text-base-content';
}
return tone === 'success'
? 'text-sm font-semibold text-green-900 dark:text-green-100'
: 'text-sm font-semibold text-blue-900 dark:text-blue-100';
}
export function getSelectionCardDescriptionClass(variant: SelectionCardGroupVariant): string {
return variant === 'compact' ? 'text-xs text-slate-500 mt-0.5' : 'text-xs text-muted';
}
@@ -0,0 +1,33 @@
import { createMemo } from 'solid-js';
import {
resolveSelectionCardGroupVariant,
resolveSelectionCardTone,
type SelectionCardGroupProps,
type SelectionCardOption,
} from './selectionCardGroupModel';
export function useSelectionCardGroupState<T extends string | number>(
props: SelectionCardGroupProps<T>,
) {
const variant = createMemo(() => resolveSelectionCardGroupVariant(props.variant));
const isOptionActive = (option: SelectionCardOption<T>) => option.value === props.value;
const isOptionDisabled = (option: SelectionCardOption<T>) => Boolean(props.disabled || option.disabled);
const getOptionTone = (option: SelectionCardOption<T>) => resolveSelectionCardTone(option.tone);
const handleOptionClick = (option: SelectionCardOption<T>) => {
if (isOptionDisabled(option)) {
return;
}
props.onChange(option.value);
};
return {
getOptionTone,
handleOptionClick,
isOptionActive,
isOptionDisabled,
variant,
};
}
@@ -63,6 +63,8 @@ import infrastructureSummaryTableRowSource from '@/components/shared/Infrastruct
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw';
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
import selectionCardGroupModelSource from '@/components/shared/selectionCardGroupModel.ts?raw';
import sharedInfrastructureSummaryTableModelSource from '@/components/shared/infrastructureSummaryTableModel.ts?raw';
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
import activeUseTrialNudgeStateSource from '@/components/shared/useActiveUseTrialNudgeState.ts?raw';
@@ -89,6 +91,7 @@ import trialBannerStateSource from '@/components/shared/useTrialBannerState.ts?r
import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw';
import monitoredSystemLimitWarningBannerStateSource from '@/components/shared/useMonitoredSystemLimitWarningBannerState.ts?raw';
import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw';
import selectionCardGroupStateSource from '@/components/shared/useSelectionCardGroupState.ts?raw';
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw';
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
@@ -2865,6 +2868,20 @@ describe('frontend resource type boundaries', () => {
expect(statusBadgeModelSource).toContain('getStatusBadgeLabel');
expect(statusBadgeModelSource).toContain('getStatusBadgeTitle');
expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'");
expect(selectionCardGroupSource).toContain('useSelectionCardGroupState');
expect(selectionCardGroupSource).toContain('getSelectionCardGroupClass');
expect(selectionCardGroupSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupSource).toContain('getSelectionCardTitleClass');
expect(selectionCardGroupSource).not.toContain('resolveSelectionCardTone');
expect(selectionCardGroupSource).not.toContain('props.onChange(option.value)');
expect(selectionCardGroupStateSource).toContain('createMemo');
expect(selectionCardGroupStateSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupStateSource).toContain('props.disabled || option.disabled');
expect(selectionCardGroupStateSource).toContain('props.onChange(option.value)');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardGroupVariant');
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardTone');
expect(selectionCardGroupModelSource).toContain('getSelectionCardButtonClass');
expect(selectionCardGroupModelSource).toContain("compact: 'grid grid-cols-2 gap-2'");
expect(monitoredSystemLimitWarningBannerSource).toContain(
'useMonitoredSystemLimitWarningBannerState',
);