Pulse/frontend-modern/src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx
rcourtman 41004ebcea feat(thresholds): add collapsible accordion sections and UX improvements
- Add CollapsibleSection component with animated expand/collapse
- Wrap all 6 resource sections (Nodes, VMs, PBS, Storage, Backups, Snapshots) with accordion UI
- Add section icons and resource counts in headers
- Add expand all / collapse all buttons for quick navigation
- Make help banner dismissible with localStorage persistence
- Add Ctrl/Cmd+F keyboard shortcut to focus search
- Add keyboard shortcut hint badge on search input
- Add icons to tab navigation for quick identification
- Improve mobile tab labels with shorter text on small screens
- Create reusable components: ThresholdBadge, ResourceCard, GlobalDefaultsRow
- Create useCollapsedSections hook with localStorage persistence
- Default less-used sections (Storage, Backups, Snapshots, PBS) to collapsed
2025-12-18 15:47:44 +00:00

300 lines
12 KiB
TypeScript

/**
* CollapsibleSection Component
*
* A reusable accordion-style section with animated expand/collapse.
* Used to organize resource groups in the thresholds page.
*/
import { Component, Show, createSignal, createEffect, JSX } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import Settings from 'lucide-solid/icons/settings';
export interface CollapsibleSectionProps {
/** Unique identifier for the section */
id: string;
/** Section title */
title: string;
/** Number of resources in this section */
resourceCount?: number;
/** Whether the section is currently collapsed */
collapsed?: boolean;
/** Callback when collapse state changes */
onToggle?: (collapsed: boolean) => void;
/** Section content (children) */
children: JSX.Element;
/** Action buttons to show in the header (e.g., "Edit Defaults") */
headerActions?: JSX.Element;
/** Icon to show before the title */
icon?: JSX.Element;
/** Subtitle or description */
subtitle?: string;
/** Message to show when section is empty */
emptyMessage?: string;
/** Whether to show a visual indicator for global disable state */
isGloballyDisabled?: boolean;
/** Whether the section has unsaved changes */
hasChanges?: boolean;
/** Test ID for e2e testing */
testId?: string;
}
export const CollapsibleSection: Component<CollapsibleSectionProps> = (props) => {
// Local collapsed state if not controlled externally
const [localCollapsed, setLocalCollapsed] = createSignal(props.collapsed ?? false);
// Sync with external collapsed state
createEffect(() => {
if (props.collapsed !== undefined) {
setLocalCollapsed(props.collapsed);
}
});
const isCollapsed = () => {
return props.collapsed !== undefined ? props.collapsed : localCollapsed();
};
const handleToggle = () => {
const newState = !isCollapsed();
setLocalCollapsed(newState);
props.onToggle?.(newState);
};
const isEmpty = () => props.resourceCount === 0;
const showEmpty = () => isCollapsed() === false && isEmpty() && props.emptyMessage;
return (
<div
class={`
rounded-lg border transition-all duration-200
${props.isGloballyDisabled
? 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50 opacity-60'
: 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'
}
${props.hasChanges ? 'ring-2 ring-blue-400 ring-opacity-50' : ''}
`}
data-testid={props.testId || `section-${props.id}`}
>
{/* Section Header */}
<button
type="button"
onClick={handleToggle}
class={`
w-full flex items-center justify-between gap-3 px-4 py-3
text-left cursor-pointer select-none
hover:bg-gray-50 dark:hover:bg-gray-700/50
transition-colors duration-150
${isCollapsed() ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-700'}
`}
aria-expanded={!isCollapsed()}
aria-controls={`section-content-${props.id}`}
>
{/* Left side: Chevron + Icon + Title + Count */}
<div class="flex items-center gap-3 min-w-0">
{/* Expand/Collapse chevron */}
<div class="flex-shrink-0 text-gray-400 dark:text-gray-500 transition-transform duration-200">
<Show when={isCollapsed()} fallback={<ChevronDown class="w-5 h-5" />}>
<ChevronRight class="w-5 h-5" />
</Show>
</div>
{/* Optional icon */}
<Show when={props.icon}>
<div class="flex-shrink-0 text-gray-500 dark:text-gray-400">
{props.icon}
</div>
</Show>
{/* Title and count */}
<div class="min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">
{props.title}
</h3>
<Show when={props.resourceCount !== undefined}>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{props.resourceCount}
</span>
</Show>
<Show when={props.isGloballyDisabled}>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
Disabled
</span>
</Show>
<Show when={props.hasChanges}>
<span class="flex-shrink-0 w-2 h-2 rounded-full bg-blue-500" title="Unsaved changes" />
</Show>
</div>
<Show when={props.subtitle}>
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
{props.subtitle}
</p>
</Show>
</div>
</div>
{/* Right side: Header actions */}
<div
class="flex items-center gap-2 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{props.headerActions}
</div>
</button>
{/* Section Content */}
<div
id={`section-content-${props.id}`}
class={`
overflow-hidden transition-all duration-200 ease-in-out
${isCollapsed() ? 'max-h-0 opacity-0' : 'max-h-[5000px] opacity-100'}
`}
>
<div class="p-4">
<Show when={showEmpty()}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>{props.emptyMessage}</p>
</div>
</Show>
<Show when={!isEmpty() || !props.emptyMessage}>
{props.children}
</Show>
</div>
</div>
</div>
);
};
/**
* A button for section header actions (e.g., "Edit Defaults")
*/
export interface SectionActionButtonProps {
label: string;
onClick: () => void;
icon?: JSX.Element;
variant?: 'default' | 'primary' | 'danger';
disabled?: boolean;
title?: string;
}
export const SectionActionButton: Component<SectionActionButtonProps> = (props) => {
const variantClasses = {
default: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700',
primary: 'text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30',
danger: 'text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30',
};
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
props.onClick();
}}
disabled={props.disabled}
title={props.title}
class={`
inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium
transition-colors duration-150
${variantClasses[props.variant || 'default']}
${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Show when={props.icon}>
{props.icon}
</Show>
{props.label}
</button>
);
};
/**
* Nested group header within a section (e.g., node name grouping VMs)
*/
export interface NestedGroupHeaderProps {
title: string;
subtitle?: string;
count?: number;
collapsed?: boolean;
onToggle?: () => void;
actions?: JSX.Element;
status?: 'online' | 'offline' | 'unknown';
href?: string;
}
export const NestedGroupHeader: Component<NestedGroupHeaderProps> = (props) => {
const statusColors = {
online: 'bg-green-500',
offline: 'bg-red-500',
unknown: 'bg-gray-400',
};
return (
<div
class={`
flex items-center justify-between gap-3 px-3 py-2 -mx-3 rounded-md
${props.onToggle ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : ''}
transition-colors duration-150
`}
onClick={props.onToggle}
>
<div class="flex items-center gap-2 min-w-0">
<Show when={props.onToggle}>
<div class="flex-shrink-0 text-gray-400">
<Show when={props.collapsed} fallback={<ChevronDown class="w-4 h-4" />}>
<ChevronRight class="w-4 h-4" />
</Show>
</div>
</Show>
<Show when={props.status}>
<span
class={`flex-shrink-0 w-2 h-2 rounded-full ${statusColors[props.status!]}`}
title={props.status}
/>
</Show>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Show
when={props.href}
fallback={
<span class="font-medium text-gray-800 dark:text-gray-200 truncate">
{props.title}
</span>
}
>
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 truncate"
onClick={(e) => e.stopPropagation()}
>
{props.title}
</a>
</Show>
<Show when={props.count !== undefined}>
<span class="text-xs text-gray-500 dark:text-gray-400">
({props.count})
</span>
</Show>
</div>
<Show when={props.subtitle}>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{props.subtitle}
</p>
</Show>
</div>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{props.actions}
</div>
</Show>
</div>
);
};
export default CollapsibleSection;