mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type LoadingStateProps = {
|
||||
label?: string
|
||||
className?: string
|
||||
spinnerSize?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
label = 'Loading…',
|
||||
className,
|
||||
spinnerSize = 'md'
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex items-center gap-2 text-[var(--app-hint)]', className)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Spinner size={spinnerSize} label={null} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ApiClient } from '@/api/client'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
|
||||
type LoginPromptProps = {
|
||||
onLogin: (token: string) => void
|
||||
@@ -72,9 +73,17 @@ export function LoginPrompt(props: LoginPromptProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !accessToken.trim()}
|
||||
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
aria-busy={isLoading}
|
||||
className="w-full py-2.5 rounded-lg bg-[var(--app-button)] text-[var(--app-button-text)] font-medium disabled:opacity-50 hover:opacity-90 transition-opacity inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { Machine } from '@/types/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
|
||||
import { useRecentPaths } from '@/hooks/useRecentPaths'
|
||||
@@ -117,7 +118,7 @@ export function NewSession(props: {
|
||||
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
|
||||
>
|
||||
{props.isLoading && (
|
||||
<option value="">Loading machines...</option>
|
||||
<option value="">Loading machines…</option>
|
||||
)}
|
||||
{!props.isLoading && props.machines.length === 0 && (
|
||||
<option value="">No machines available</option>
|
||||
@@ -212,8 +213,17 @@ export function NewSession(props: {
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
aria-busy={isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPending ? 'Creating...' : 'Create'}
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SpinnerProps = {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export function Spinner({
|
||||
size = 'md',
|
||||
className,
|
||||
label
|
||||
}: SpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6'
|
||||
}
|
||||
const effectiveLabel = label === undefined ? 'Loading' : label
|
||||
const accessibilityProps = effectiveLabel === null
|
||||
? { 'aria-hidden': true }
|
||||
: { role: 'status', 'aria-label': effectiveLabel }
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={cn(sizeClasses[size], 'animate-spin text-[var(--app-hint)]', className)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
{...accessibilityProps}
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
|
||||
export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) {
|
||||
const isOnline = useOnlineStatus()
|
||||
@@ -9,9 +10,9 @@ export function SyncingBanner({ isSyncing }: { isSyncing: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bg-[var(--app-button)] text-[var(--app-button-text)] text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2">
|
||||
<span className="inline-block animate-spin">↻</span>
|
||||
Syncing...
|
||||
<div className="fixed top-0 left-0 right-0 bg-[var(--app-banner-bg)] text-[var(--app-banner-text)] text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2 border-b border-[var(--app-divider)]">
|
||||
<Spinner size="sm" label={null} className="text-[var(--app-banner-text)]" />
|
||||
Syncing…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { isAskUserQuestionToolName, parseAskUserQuestionInput, type AskUserQuestionQuestion } from '@/components/ToolCard/askUserQuestion'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
|
||||
function SelectionMark(props: { checked: boolean; mode: 'single' | 'multi' }) {
|
||||
const mark = props.mode === 'multi'
|
||||
@@ -381,8 +382,17 @@ export function AskUserQuestionFooter(props: {
|
||||
size="sm"
|
||||
disabled={props.disabled || loading}
|
||||
onClick={submit}
|
||||
aria-busy={loading}
|
||||
className="gap-2"
|
||||
>
|
||||
{loading ? 'Submitting…' : 'Submit'}
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner size="sm" label={null} className="text-[var(--app-button-text)]" />
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
'Submit'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiClient } from '@/api/client'
|
||||
import type { SessionMetadataSummary } from '@/types/api'
|
||||
import type { ChatToolCall, ToolPermission } from '@/chat/types'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
@@ -63,15 +64,6 @@ function formatPermissionSummary(permission: ToolPermission, toolName: string, t
|
||||
return 'Permission'
|
||||
}
|
||||
|
||||
function SpinnerIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg className={props.className ?? 'h-4 w-4 animate-spin'} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionRowButton(props: {
|
||||
label: string
|
||||
tone: 'allow' | 'deny' | 'neutral'
|
||||
@@ -91,12 +83,13 @@ function PermissionRowButton(props: {
|
||||
type="button"
|
||||
className={`${base} ${tone}`}
|
||||
disabled={props.disabled}
|
||||
aria-busy={props.loading === true}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span className="flex-1">{props.label}</span>
|
||||
{props.loading ? (
|
||||
<span className="ml-2 shrink-0">
|
||||
<SpinnerIcon />
|
||||
<Spinner size="sm" label={null} className="text-current" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user