fix(ui): make admin lists usable on mobile viewports

This commit is contained in:
Steven RYDELL
2026-07-30 19:54:34 +02:00
parent d1284de415
commit 1ab574a42a
6 changed files with 49 additions and 34 deletions
+3 -3
View File
@@ -408,9 +408,9 @@ function WizardShell({ children }: { children: React.ReactNode }) {
<header className="flex items-center px-6 py-4 border-b bg-background"> <header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo /> <DefaultLogo />
</header> </header>
<ScrollArea role="main" className="flex-1"> <ScrollArea role="main" className="min-w-0 flex-1">
<div className="p-6"> <div className="min-w-0 p-4 sm:p-6">
<div className="mx-auto max-w-3xl">{children}</div> <div className="mx-auto w-full min-w-0 max-w-3xl">{children}</div>
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
+3 -3
View File
@@ -743,7 +743,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition); const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto w-full min-w-0 max-w-4xl space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@@ -845,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Card> </Card>
))} ))}
<div className="flex items-center justify-between pt-2 pb-8"> <div className="flex flex-col-reverse gap-3 pt-2 pb-8 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
{canDelete && ( {canDelete && (
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}> <Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
@@ -854,7 +854,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-3">
{isDirty && ( {isDirty && (
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span> <span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
)} )}
+26 -19
View File
@@ -1392,13 +1392,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
} }
return ( return (
<div className="relative space-y-4"> <div className="relative min-w-0 space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-3">
<div> <div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1> <h1 className="text-2xl font-bold tracking-tight truncate">{list.title}</h1>
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>} {list.subtitle && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{list.subtitle}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{hasMassActions && selectedIds.size > 0 && ( {hasMassActions && selectedIds.size > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -1559,16 +1559,18 @@ export function DynamicList({ viewName }: DynamicListProps) {
</Card> </Card>
)} )}
<div className="rounded-lg border bg-background shadow-sm"> <div className="min-w-0 rounded-lg border bg-background shadow-sm">
{/* The scroll container must clip with the parent's inner radius {/* The scroll container must clip with the parent's inner radius
(outer radius minus the 1px border), otherwise filled header rows (outer radius minus the 1px border), otherwise filled header rows
paint square corners behind the rounded border. */} paint square corners behind the rounded border.
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]"> `w-max min-w-full` keeps the table at least as wide as the card,
<table className="w-full text-sm"> but lets wide column sets scroll horizontally inside this wrapper. */}
<div className="overflow-x-auto overscroll-x-contain rounded-[calc(var(--radius-lg)-1px)] [-webkit-overflow-scrolling:touch]">
<table className="w-max min-w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted"> <tr className="border-b bg-muted">
{hasMassActions && ( {hasMassActions && (
<th className="w-10 px-3 py-3"> <th className="w-10 px-3 py-3 whitespace-nowrap">
<Checkbox <Checkbox
checked={items.length > 0 && selectedIds.size === items.length} checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
@@ -1577,7 +1579,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
</th> </th>
)} )}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground"> <th
key={col.name}
className="px-3 py-3 text-left font-medium text-muted-foreground whitespace-nowrap"
>
<div className="flex items-center"> <div className="flex items-center">
{col.label} {col.label}
{renderSortIndicator(col.name)} {renderSortIndicator(col.name)}
@@ -1585,7 +1590,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
</th> </th>
))} ))}
{hasItemActions && ( {hasItemActions && (
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground"> <th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground whitespace-nowrap">
{t('list.actions', 'Actions')} {t('list.actions', 'Actions')}
</th> </th>
)} )}
@@ -1620,7 +1625,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
> >
{hasMassActions && ( {hasMassActions && (
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedIds.has(itemId)} checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)} onCheckedChange={() => toggleSelectItem(itemId)}
@@ -1629,7 +1634,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
</td> </td>
)} )}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<td key={col.name} className="px-3 py-2"> <td key={col.name} className="px-3 py-2 whitespace-nowrap">
{isWebApplications && col.name === 'enabled' && !fields[col.name] ? ( {isWebApplications && col.name === 'enabled' && !fields[col.name] ? (
item.enabled === true ? ( item.enabled === true ? (
<Check className="h-4 w-4 text-green-600" /> <Check className="h-4 w-4 text-green-600" />
@@ -1664,7 +1669,9 @@ export function DynamicList({ viewName }: DynamicListProps) {
)} )}
</td> </td>
))} ))}
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>} {hasItemActions && (
<td className="px-3 py-2 text-right whitespace-nowrap">{renderItemActions(item)}</td>
)}
</tr> </tr>
); );
}) })
@@ -1675,8 +1682,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
</div> </div>
{items.length > 0 && ( {items.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground">
<div> <div className="min-w-0">
{total !== null {total !== null
? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', { ? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', {
from: rangeStart, from: rangeStart,
@@ -1688,7 +1695,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
count: items.length, count: items.length,
})} })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
<Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}> <Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}>
{t('list.previous', 'Previous')} {t('list.previous', 'Previous')}
</Button> </Button>
+10 -2
View File
@@ -18,8 +18,16 @@ const ScrollArea = React.forwardRef<
viewportClassName?: string; viewportClassName?: string;
} }
>(({ className, viewportClassName, children, ...props }, ref) => ( >(({ className, viewportClassName, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}> <ScrollAreaPrimitive.Root ref={ref} className={cn('relative min-w-0 overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className={cn('h-full w-full rounded-[inherit]', viewportClassName)}> {/*
Radix wraps children in a `display: table` div with an intrinsic min-width.
That lets wide tables expand the whole shell and clip under overflow-x:hidden,
so horizontal scroll never reaches the table's own overflow-x-auto wrapper.
Force the inner wrapper to shrink to the viewport width instead.
*/}
<ScrollAreaPrimitive.Viewport
className={cn('h-full w-full rounded-[inherit] [&>div]:!block [&>div]:!min-w-0', viewportClassName)}
>
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />
+3 -3
View File
@@ -126,8 +126,8 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
const isBlock = isBlockType(field.type, value); const isBlock = isBlockType(field.type, value);
return ( return (
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}> <div className={isBlock ? 'space-y-1' : 'flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2'}>
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]"> <dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 sm:min-w-[140px]">
{label} {label}
{field.description && ( {field.description && (
<TooltipProvider> <TooltipProvider>
@@ -144,7 +144,7 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
</TooltipProvider> </TooltipProvider>
)} )}
</dt> </dt>
<dd className="text-sm min-w-0"> <dd className="text-sm min-w-0 break-words">
<ViewValue type={field.type} value={value} schema={schema} /> <ViewValue type={field.type} value={value} schema={schema} />
</dd> </dd>
</div> </div>
+4 -4
View File
@@ -315,14 +315,14 @@ export default function AdminPanel() {
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<TopBar /> <TopBar />
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 min-w-0 flex-1">
<Sidebar /> <Sidebar />
<ScrollArea <ScrollArea
role="main" role="main"
className={`flex-1 bg-content-background transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`} className={`min-w-0 flex-1 bg-content-background transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
> >
<div className="p-6"> <div className="min-w-0 p-4 sm:p-6">
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto w-full min-w-0 max-w-7xl">
{/* Keyed on the active account: forces MainContent (and every {/* Keyed on the active account: forces MainContent (and every
view it renders) to fully remount on switch, so view it renders) to fully remount on switch, so
account-scoped views can't keep showing stale data fetched account-scoped views can't keep showing stale data fetched