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
+16 -1
View File
@@ -80,6 +80,21 @@ function DiffDisplay(props: { diffContent: string }) {
)
}
function FileContentSkeleton() {
const widths = ['w-full', 'w-11/12', 'w-5/6', 'w-3/4', 'w-2/3', 'w-4/5']
return (
<div role="status" aria-live="polite">
<span className="sr-only">Loading file</span>
<div className="animate-pulse space-y-2 rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3">
{Array.from({ length: 12 }).map((_, index) => (
<div key={`file-skeleton-${index}`} className={`h-3 ${widths[index % widths.length]} rounded bg-[var(--app-subtle-bg)]`} />
))}
</div>
</div>
)
}
function resolveLanguage(path: string): string | undefined {
const parts = path.split('.')
if (parts.length <= 1) return undefined
@@ -229,7 +244,7 @@ export default function FilePage() {
{missingPath ? (
<div className="text-sm text-[var(--app-hint)]">No file path provided.</div>
) : loading ? (
<div className="text-sm text-[var(--app-hint)]">Loading file...</div>
<FileContentSkeleton />
) : fileError ? (
<div className="text-sm text-[var(--app-hint)]">{fileError}</div>
) : binaryFile ? (
+23 -2
View File
@@ -210,6 +210,27 @@ function SearchResultRow(props: {
)
}
function FileListSkeleton(props: { label: string; rows?: number }) {
const titleWidths = ['w-1/3', 'w-1/2', 'w-2/3', 'w-2/5', 'w-3/5']
const subtitleWidths = ['w-1/2', 'w-2/3', 'w-3/4', 'w-1/3']
const rows = props.rows ?? 6
return (
<div className="p-3 animate-pulse space-y-3" role="status" aria-live="polite">
<span className="sr-only">{props.label}</span>
{Array.from({ length: rows }).map((_, index) => (
<div key={`skeleton-row-${index}`} className="flex items-center gap-3">
<div className="h-6 w-6 rounded bg-[var(--app-subtle-bg)]" />
<div className="flex-1 space-y-2">
<div className={`h-3 ${titleWidths[index % titleWidths.length]} rounded bg-[var(--app-subtle-bg)]`} />
<div className={`h-2 ${subtitleWidths[index % subtitleWidths.length]} rounded bg-[var(--app-subtle-bg)]`} />
</div>
</div>
))}
</div>
)
}
export default function FilesPage() {
const { api } = useAppContext()
const navigate = useNavigate()
@@ -311,10 +332,10 @@ export default function FilesPage() {
</div>
) : null}
{gitLoading ? (
<div className="p-6 text-sm text-[var(--app-hint)]">Loading Git status...</div>
<FileListSkeleton label="Loading Git status…" />
) : shouldSearch ? (
searchResults.isLoading ? (
<div className="p-6 text-sm text-[var(--app-hint)]">Loading files...</div>
<FileListSkeleton label="Loading files…" />
) : searchResults.error ? (
<div className="p-6 text-sm text-[var(--app-hint)]">{searchResults.error}</div>
) : searchResults.files.length === 0 ? (