mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Split status badge runtime owners
This commit is contained in:
@@ -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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user