Split status badge runtime owners

This commit is contained in:
rcourtman
2026-03-23 09:32:11 +00:00
parent 40aab3ac74
commit 36e224d07d
7 changed files with 196 additions and 51 deletions
@@ -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,
@@ -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'",
@@ -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(() => (
<StatusBadge
isEnabled={false}
disabled={true}
onToggle={onToggle}
titleEnabled="On"
titleDisabled="Off"
titleWhenDisabled="Locked"
/>
));
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(() => <StatusBadge isEnabled={true} onToggle={onToggle} />);
fireEvent.click(screen.getByRole('button', { name: 'Enabled' }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
@@ -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 (
<button
type="button"
class={`${baseClasses} ${stateClasses} ${disabledClasses}`.trim()}
onClick={() => {
if (disabled) return;
onToggle?.();
}}
disabled={disabled}
aria-pressed={isEnabled}
title={title}
class={getStatusBadgeClass(size, props.isEnabled, state.isDisabled())}
onClick={state.handleClick}
disabled={props.disabled}
aria-pressed={props.isEnabled}
title={getStatusBadgeTitle(props, state.isDisabled())}
>
{isEnabled ? labelEnabled : labelDisabled}
{getStatusBadgeLabel(props)}
</button>
);
}
@@ -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<StatusBadgeSize, string> = {
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 ?? '');
}
@@ -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,
};
}
@@ -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',
);