{option.icon && (
-
- {option.icon({ active: isActive() })}
+
+ {option.icon({ active: selectionCardGroup.isOptionActive(option) })}
)}
-
{option.title}
+
+ {option.title}
+
{option.description && (
-
{option.description}
+
+ {option.description}
+
)}
) : (
-
{option.title}
+
+ {option.title}
+
{option.description && (
-
{option.description}
+
+ {option.description}
+
)}
)}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index f88b83514..ca3524a60 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -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(
diff --git a/frontend-modern/src/components/shared/selectionCardGroupModel.ts b/frontend-modern/src/components/shared/selectionCardGroupModel.ts
new file mode 100644
index 000000000..e63263657
--- /dev/null
+++ b/frontend-modern/src/components/shared/selectionCardGroupModel.ts
@@ -0,0 +1,105 @@
+import type { JSX } from 'solid-js';
+
+export type SelectionCardTone = 'accent' | 'success';
+export type SelectionCardGroupVariant = 'compact' | 'detail';
+
+export interface SelectionCardOption
{
+ value: T;
+ title: string;
+ description?: string;
+ icon?: (props: { active: boolean }) => JSX.Element;
+ tone?: SelectionCardTone;
+ disabled?: boolean;
+}
+
+export interface SelectionCardGroupProps {
+ options: SelectionCardOption[];
+ value: T;
+ onChange: (value: T) => void;
+ class?: string;
+ variant?: SelectionCardGroupVariant;
+ disabled?: boolean;
+}
+
+const groupClassByVariant: Record = {
+ 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';
+}
diff --git a/frontend-modern/src/components/shared/useSelectionCardGroupState.ts b/frontend-modern/src/components/shared/useSelectionCardGroupState.ts
new file mode 100644
index 000000000..a88129ba7
--- /dev/null
+++ b/frontend-modern/src/components/shared/useSelectionCardGroupState.ts
@@ -0,0 +1,33 @@
+import { createMemo } from 'solid-js';
+import {
+ resolveSelectionCardGroupVariant,
+ resolveSelectionCardTone,
+ type SelectionCardGroupProps,
+ type SelectionCardOption,
+} from './selectionCardGroupModel';
+
+export function useSelectionCardGroupState(
+ props: SelectionCardGroupProps,
+) {
+ const variant = createMemo(() => resolveSelectionCardGroupVariant(props.variant));
+
+ const isOptionActive = (option: SelectionCardOption) => option.value === props.value;
+ const isOptionDisabled = (option: SelectionCardOption) => Boolean(props.disabled || option.disabled);
+ const getOptionTone = (option: SelectionCardOption) => resolveSelectionCardTone(option.tone);
+
+ const handleOptionClick = (option: SelectionCardOption) => {
+ if (isOptionDisabled(option)) {
+ return;
+ }
+
+ props.onChange(option.value);
+ };
+
+ return {
+ getOptionTone,
+ handleOptionClick,
+ isOptionActive,
+ isOptionDisabled,
+ variant,
+ };
+}
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index 8029571c4..588e8d516 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -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',
);