diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2a9743cff..3646d9919 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -263,6 +263,14 @@ plus the synthetic toggle change-event runtime, and track/knob/container class policy, and the canonical toggle event type. Future toggle work should extend those owners instead of pushing synthetic event behavior or size/class policy back into the shell. +The shared status badge now follows that same owner split. +`frontend-modern/src/components/shared/StatusBadge.tsx` stays the render shell, +`frontend-modern/src/components/shared/useStatusBadgeState.ts` owns disabled +gating and click runtime, and +`frontend-modern/src/components/shared/statusBadgeModel.ts` owns size padding, +label/title fallback policy, and status-badge class selection. Future status +badge work should extend those owners instead of pushing label/title policy or +disabled click handling 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, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index cd965ffd3..9f6f2a5fa 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -39,6 +39,8 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw'; import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw'; +import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw'; +import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw'; import toggleSource from '@/components/shared/Toggle.tsx?raw'; import toggleModelSource from '@/components/shared/toggleModel.ts?raw'; import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw'; @@ -76,6 +78,7 @@ import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState. import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw'; import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw'; +import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw'; import toggleStateSource from '@/components/shared/useToggleState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; @@ -305,6 +308,27 @@ describe('shared primitive guardrails', () => { expect(toggleModelSource).toContain('ToggleChangeEvent'); }); + it('keeps status badge on shell, runtime, and model owners', () => { + expect(statusBadgeSource).toContain('useStatusBadgeState'); + expect(statusBadgeSource).toContain('getStatusBadgeClass'); + expect(statusBadgeSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeSource).not.toContain('cursor-not-allowed'); + expect(statusBadgeSource).not.toContain('props.onToggle?.()'); + expect(statusBadgeSource).not.toContain('labelEnabled ??'); + + expect(statusBadgeStateSource).toContain('export function useStatusBadgeState'); + expect(statusBadgeStateSource).toContain('Boolean(props.disabled)'); + expect(statusBadgeStateSource).toContain('props.onToggle?.()'); + expect(statusBadgeStateSource).toContain('if (isDisabled())'); + + expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE'); + expect(statusBadgeModelSource).toContain('getStatusBadgeClass'); + expect(statusBadgeModelSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeModelSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'"); + }); + it('routes settings info callouts through CalloutCard', () => { expect(calloutCardSource).toContain( "type CalloutTone = 'danger' | 'info' | 'success' | 'warning'", diff --git a/frontend-modern/src/components/shared/StatusBadge.test.tsx b/frontend-modern/src/components/shared/StatusBadge.test.tsx new file mode 100644 index 000000000..12302ad6a --- /dev/null +++ b/frontend-modern/src/components/shared/StatusBadge.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StatusBadge } from './StatusBadge'; +import statusBadgeSource from './StatusBadge.tsx?raw'; +import statusBadgeModelSource from './statusBadgeModel.ts?raw'; +import statusBadgeStateSource from './useStatusBadgeState.ts?raw'; + +describe('StatusBadge', () => { + afterEach(() => { + cleanup(); + }); + + it('keeps status badge on shell, runtime, and model owners', () => { + expect(statusBadgeSource).toContain('useStatusBadgeState'); + expect(statusBadgeSource).toContain('getStatusBadgeClass'); + expect(statusBadgeSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeSource).not.toContain('cursor-not-allowed'); + expect(statusBadgeSource).not.toContain('props.onToggle?.()'); + expect(statusBadgeSource).not.toContain('labelEnabled ??'); + + expect(statusBadgeStateSource).toContain('export function useStatusBadgeState'); + expect(statusBadgeStateSource).toContain('Boolean(props.disabled)'); + expect(statusBadgeStateSource).toContain('props.onToggle?.()'); + expect(statusBadgeStateSource).toContain('if (isDisabled())'); + + expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE'); + expect(statusBadgeModelSource).toContain('getStatusBadgeClass'); + expect(statusBadgeModelSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeModelSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'"); + }); + + it('renders label/title policy and blocks toggles when disabled', () => { + const onToggle = vi.fn(); + + render(() => ( + + )); + + const button = screen.getByRole('button', { name: 'Disabled' }); + expect(button).toHaveAttribute('title', 'Locked'); + fireEvent.click(button); + expect(onToggle).not.toHaveBeenCalled(); + }); + + it('allows toggles when enabled and not disabled', () => { + const onToggle = vi.fn(); + + render(() => ); + + fireEvent.click(screen.getByRole('button', { name: 'Enabled' })); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend-modern/src/components/shared/StatusBadge.tsx b/frontend-modern/src/components/shared/StatusBadge.tsx index be58cbc64..2bb26b69d 100644 --- a/frontend-modern/src/components/shared/StatusBadge.tsx +++ b/frontend-modern/src/components/shared/StatusBadge.tsx @@ -1,62 +1,27 @@ import { JSX } from 'solid-js'; +import { + getStatusBadgeClass, + getStatusBadgeLabel, + getStatusBadgeTitle, + resolveStatusBadgeSize, + type StatusBadgeProps, +} from '@/components/shared/statusBadgeModel'; +import { useStatusBadgeState } from '@/components/shared/useStatusBadgeState'; -interface StatusBadgeProps { - isEnabled: boolean; - disabled?: boolean; - size?: 'sm' | 'md'; - onToggle?: () => void; - labelEnabled?: string; - labelDisabled?: string; - titleEnabled?: string; - titleDisabled?: string; - titleWhenDisabled?: string; -} - -/** - * StatusBadge - A shared component for enabled/disabled status badges - * Used throughout the app for consistent styling of status indicators - */ export function StatusBadge(props: StatusBadgeProps): JSX.Element { - const { - isEnabled, - disabled = false, - size = 'sm', - onToggle, - labelEnabled = 'Enabled', - labelDisabled = 'Disabled', - titleEnabled, - titleDisabled, - titleWhenDisabled, - } = props; - - const basePadding = size === 'md' ? 'px-2.5 py-1' : 'px-2 py-0.5'; - const baseClasses = `inline-flex items-center justify-center ${basePadding} text-xs font-medium rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-blue-400`; - const stateClasses = isEnabled - ? 'bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-500 dark:text-blue-300 dark:hover:bg-blue-500' - : 'text-muted hover:bg-surface-hover'; - const disabledClasses = disabled - ? 'opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent' - : ''; - - const title = disabled - ? (titleWhenDisabled ?? titleDisabled ?? titleEnabled ?? '') - : isEnabled - ? (titleEnabled ?? '') - : (titleDisabled ?? ''); + const state = useStatusBadgeState(props); + const size = resolveStatusBadgeSize(props.size); return ( ); } diff --git a/frontend-modern/src/components/shared/statusBadgeModel.ts b/frontend-modern/src/components/shared/statusBadgeModel.ts new file mode 100644 index 000000000..ec3366d9c --- /dev/null +++ b/frontend-modern/src/components/shared/statusBadgeModel.ts @@ -0,0 +1,53 @@ +export type StatusBadgeSize = 'sm' | 'md'; + +export interface StatusBadgeProps { + isEnabled: boolean; + disabled?: boolean; + size?: StatusBadgeSize; + onToggle?: () => void; + labelEnabled?: string; + labelDisabled?: string; + titleEnabled?: string; + titleDisabled?: string; + titleWhenDisabled?: string; +} + +const STATUS_BADGE_BASE_CLASS = + 'inline-flex items-center justify-center text-xs font-medium rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-blue-400'; +const STATUS_BADGE_PADDING_BY_SIZE: Record = { + sm: 'px-2 py-0.5', + md: 'px-2.5 py-1', +}; +const STATUS_BADGE_ENABLED_CLASS = + 'bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-500 dark:text-blue-300 dark:hover:bg-blue-500'; +const STATUS_BADGE_DISABLED_STATE_CLASS = 'text-muted hover:bg-surface-hover'; +const STATUS_BADGE_INTERACTION_DISABLED_CLASS = + 'opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent'; + +export function resolveStatusBadgeSize(size: StatusBadgeSize | undefined): StatusBadgeSize { + return size ?? 'sm'; +} + +export function getStatusBadgeClass( + size: StatusBadgeSize, + isEnabled: boolean, + disabled: boolean, +): string { + return [ + STATUS_BADGE_BASE_CLASS, + STATUS_BADGE_PADDING_BY_SIZE[size], + isEnabled ? STATUS_BADGE_ENABLED_CLASS : STATUS_BADGE_DISABLED_STATE_CLASS, + disabled ? STATUS_BADGE_INTERACTION_DISABLED_CLASS : '', + ].join(' ').trim(); +} + +export function getStatusBadgeLabel(props: StatusBadgeProps): string { + return props.isEnabled ? (props.labelEnabled ?? 'Enabled') : (props.labelDisabled ?? 'Disabled'); +} + +export function getStatusBadgeTitle(props: StatusBadgeProps, disabled: boolean): string { + if (disabled) { + return props.titleWhenDisabled ?? props.titleDisabled ?? props.titleEnabled ?? ''; + } + return props.isEnabled ? (props.titleEnabled ?? '') : (props.titleDisabled ?? ''); +} diff --git a/frontend-modern/src/components/shared/useStatusBadgeState.ts b/frontend-modern/src/components/shared/useStatusBadgeState.ts new file mode 100644 index 000000000..141b39185 --- /dev/null +++ b/frontend-modern/src/components/shared/useStatusBadgeState.ts @@ -0,0 +1,15 @@ +import type { StatusBadgeProps } from '@/components/shared/statusBadgeModel'; + +export function useStatusBadgeState(props: StatusBadgeProps) { + const isDisabled = () => Boolean(props.disabled); + + return { + handleClick: () => { + if (isDisabled()) { + return; + } + props.onToggle?.(); + }, + isDisabled, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 13a3f4be8..e51243d15 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -44,6 +44,8 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw'; import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw'; import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw'; +import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw'; +import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw'; import toggleSource from '@/components/shared/Toggle.tsx?raw'; import toggleModelSource from '@/components/shared/toggleModel.ts?raw'; import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw'; @@ -76,6 +78,7 @@ import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState. import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw'; import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw'; +import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw'; import toggleStateSource from '@/components/shared/useToggleState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; @@ -2830,6 +2833,21 @@ describe('frontend resource type boundaries', () => { expect(toggleModelSource).toContain('getToggleTrackClass'); expect(toggleModelSource).toContain('getToggleKnobClass'); expect(toggleModelSource).toContain('ToggleChangeEvent'); + expect(statusBadgeSource).toContain('useStatusBadgeState'); + expect(statusBadgeSource).toContain('getStatusBadgeClass'); + expect(statusBadgeSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeSource).not.toContain('cursor-not-allowed'); + expect(statusBadgeSource).not.toContain('props.onToggle?.()'); + expect(statusBadgeSource).not.toContain('labelEnabled ??'); + expect(statusBadgeStateSource).toContain('Boolean(props.disabled)'); + expect(statusBadgeStateSource).toContain('props.onToggle?.()'); + expect(statusBadgeStateSource).toContain('if (isDisabled())'); + expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE'); + expect(statusBadgeModelSource).toContain('getStatusBadgeClass'); + expect(statusBadgeModelSource).toContain('getStatusBadgeLabel'); + expect(statusBadgeModelSource).toContain('getStatusBadgeTitle'); + expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'"); expect(monitoredSystemLimitWarningBannerSource).toContain( 'useMonitoredSystemLimitWarningBannerState', );