feat: add Spinner and LoadingState components for unified loading UX

Introduces two reusable loading components to standardize loading states:
- Spinner: Base spinner with accessibility support (role, aria-label)
- LoadingState: Semantic loading indicator combining spinner and label

Updates loading patterns across the app:
- Full-screen/block loading now uses LoadingState (App, router, files, file)
- Inline button loading states show spinner + text with aria-busy
- Message list loading replaced with MessageSkeleton component
- Removes duplicate SpinnerIcon definition from PermissionFooter

Improves UX and accessibility:
- Loading screens centered and full-height
- Consistent ellipsis character (…)
- Semantic accessibility (role="status", aria-live, aria-busy)
- Adds CSS variables for banner styling
This commit is contained in:
weishu
2025-12-24 17:23:39 +08:00
parent 9b9f792ff6
commit 7f2a36117c
14 changed files with 317 additions and 31 deletions
@@ -7,6 +7,7 @@ import { HappyAssistantMessage } from '@/components/AssistantChat/messages/Assis
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/Spinner'
function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
if (props.count === 0) {
@@ -23,6 +24,28 @@ function NewMessagesIndicator(props: { count: number; onClick: () => void }) {
)
}
function MessageSkeleton() {
const rows = [
{ align: 'end', width: 'w-2/3', height: 'h-10' },
{ align: 'start', width: 'w-3/4', height: 'h-12' },
{ align: 'end', width: 'w-1/2', height: 'h-9' },
{ align: 'start', width: 'w-5/6', height: 'h-14' }
]
return (
<div role="status" aria-live="polite">
<span className="sr-only">Loading messages</span>
<div className="space-y-3 animate-pulse">
{rows.map((row, index) => (
<div key={`skeleton-${index}`} className={row.align === 'end' ? 'flex justify-end' : 'flex justify-start'}>
<div className={`${row.height} ${row.width} rounded-xl bg-[var(--app-subtle-bg)]`} />
</div>
))}
</div>
</div>
)
}
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
AssistantMessage: HappyAssistantMessage,
@@ -234,9 +257,7 @@ export function HappyThread(props: {
<div className="mx-auto w-full max-w-content min-w-0 p-3">
<div ref={topSentinelRef} className="h-px w-full" aria-hidden="true" />
{props.isLoadingMessages ? (
<div className="text-sm text-[var(--app-hint)]">
Loading...
</div>
<MessageSkeleton />
) : (
<>
{props.messagesWarning ? (
@@ -252,8 +273,17 @@ export function HappyThread(props: {
size="sm"
onClick={handleLoadMore}
disabled={props.isLoadingMoreMessages || props.isLoadingMessages}
aria-busy={props.isLoadingMoreMessages}
className="gap-2"
>
{props.isLoadingMoreMessages ? 'Loading...' : 'Load older'}
{props.isLoadingMoreMessages ? (
<>
<Spinner size="sm" label={null} className="text-current" />
Loading
</>
) : (
'Load older'
)}
</Button>
</div>
) : null}