mobile responsive

This commit is contained in:
gosangam
2026-10-16 15:23:58 +05:30
parent fcbffead8a
commit 1e551764e1
10 changed files with 363 additions and 156 deletions
+3 -3
View File
@@ -48,7 +48,7 @@ export function ListShell({
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-foreground">
<h1 className="text-xl font-semibold text-foreground sm:text-2xl">
{title}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{total.toLocaleString()}
@@ -58,7 +58,7 @@ export function ListShell({
</div>
{onQueryChange ? (
<div className="relative max-w-md">
<div className="relative w-full sm:max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={raw}
@@ -77,7 +77,7 @@ export function ListShell({
{children}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
Page {list.page} of {list.totalPages}
{list.loading ? " · loading…" : ""}
+21 -1
View File
@@ -10,6 +10,12 @@ export type MenuItem = {
destructive?: boolean;
};
// Menu geometry, for keeping it on screen: w-44, rows of py-2 text-sm.
const MENU_WIDTH = 176;
const MENU_ROW = 36;
const MENU_GAP = 8;
/**
* A kebab (⋯) trigger with a small dropdown of actions — keeps secondary /
* destructive row actions hidden until opened. The menu is fixed-positioned
@@ -50,7 +56,21 @@ export function RowMenu({ items }: { items: MenuItem[] }) {
function toggle() {
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
const vw = window.innerWidth;
const vh = window.innerHeight;
// Right-aligned to the button, but kept on screen: on a phone the
// button can sit at the left of a card, where a right-aligned menu
// would hang off the edge. Near the bottom it opens upwards instead.
const right = Math.max(
MENU_GAP,
Math.min(vw - r.right, vw - MENU_WIDTH - MENU_GAP),
);
const height = items.length * MENU_ROW + 8;
const top =
r.bottom + 4 + height > vh - MENU_GAP
? Math.max(MENU_GAP, r.top - 4 - height)
: r.bottom + 4;
setPos({ top, right });
}
setOpen((v) => !v);
}
+11 -5
View File
@@ -4,7 +4,11 @@ import * as React from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
/** A minimal centered modal dialog. Closes on overlay click and Escape. */
/**
* A minimal modal dialog. Closes on overlay click and Escape. Centred from sm
* up; on a phone it rises from the bottom as a full-width sheet. Either way
* it never outgrows the screen — the body scrolls inside it.
*/
export function Modal({
open,
onClose,
@@ -29,14 +33,14 @@ export function Modal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-xl"
className="flex max-h-[92dvh] w-full max-w-lg flex-col rounded-t-2xl border border-border bg-card shadow-xl sm:max-h-[85dvh] sm:rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5 sm:py-4">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<Button
variant="ghost"
@@ -48,7 +52,9 @@ export function Modal({
<X className="size-4" />
</Button>
</div>
<div className="px-5 py-4">{children}</div>
<div className="min-h-0 overflow-y-auto px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-5">
{children}
</div>
</div>
</div>
);
+76 -6
View File
@@ -1,17 +1,58 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* The panel's data table.
*
* From md up it's an ordinary table. Below xl, long values (emails, URLs)
* may wrap anywhere so the columns fit a tablet; if they still can't, the
* table scrolls sideways inside its card as a last resort.
*
* On a phone (below md) each row becomes a stacked card: one line per cell, labelled
* with its column heading. The labels are read from the table's own <th>s
* and stamped onto the cells as `data-label`, so pages need no extra markup
* — every table in the panel gets the card layout for free. Cells under an
* empty heading (an actions column) simply go unlabelled.
*/
export function Table({ children }: { children: React.ReactNode }) {
const ref = React.useRef<HTMLTableElement>(null);
// Runs after every render: rows arrive and change with the data.
React.useLayoutEffect(() => {
const table = ref.current;
if (!table) return;
const heads = Array.from(table.querySelectorAll("thead th")).map(
(th) => th.textContent?.trim() ?? "",
);
table.querySelectorAll("tbody tr").forEach((tr) => {
let col = 0;
Array.from(tr.children).forEach((cell) => {
const td = cell as HTMLTableCellElement;
const label = td.colSpan > 1 ? "" : (heads[col] ?? "");
if (label) td.dataset.label = label;
else delete td.dataset.label;
col += td.colSpan || 1;
});
});
});
return (
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<table className="w-full border-collapse text-sm">{children}</table>
<div className="rounded-xl border border-border bg-card md:overflow-x-auto md:overscroll-x-contain">
<table
ref={ref}
className="w-full border-collapse text-sm max-md:block"
>
{children}
</table>
</div>
);
}
export function THead({ children }: { children: React.ReactNode }) {
return (
<thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground max-md:hidden">
{children}
</thead>
);
@@ -24,7 +65,11 @@ export function TH({
children?: React.ReactNode;
className?: string;
}) {
return <th className={cn("px-4 py-3 font-medium", className)}>{children}</th>;
return (
<th className={cn("whitespace-nowrap px-4 py-3 font-medium", className)}>
{children}
</th>
);
}
export function TR({
@@ -35,7 +80,14 @@ export function TR({
className?: string;
}) {
return (
<tr className={cn("border-b border-border last:border-0 align-top", className)}>
<tr
className={cn(
"border-b border-border last:border-0 align-top",
// Phone: a card per row.
"max-md:block max-md:px-4 max-md:py-3",
className,
)}
>
{children}
</tr>
);
@@ -48,5 +100,23 @@ export function TD({
children?: React.ReactNode;
className?: string;
}) {
return <td className={cn("px-4 py-3 text-foreground", className)}>{children}</td>;
return (
<td
className={cn(
"px-4 py-3 text-foreground",
// Phone: one labelled block per cell — the label pinned on the left,
// the cell's own content flowing beside it exactly as it would in the
// table (stacked lines stay stacked). Alignment classes meant for a
// column (text-right on actions) give way to the card's flow.
"max-md:relative max-md:block max-md:px-0 max-md:py-1.5 max-md:text-left",
"max-md:data-label:pl-28",
"max-md:data-label:before:absolute max-md:data-label:before:left-0 max-md:data-label:before:top-2 max-md:data-label:before:w-24 max-md:data-label:before:text-[11px] max-md:data-label:before:font-medium max-md:data-label:before:uppercase max-md:data-label:before:leading-4 max-md:data-label:before:tracking-wide max-md:data-label:before:text-muted-foreground max-md:data-label:before:content-[attr(data-label)]",
// Long values (emails, URLs) wrap rather than widen the table or card.
"max-xl:[overflow-wrap:anywhere]",
className,
)}
>
{children}
</td>
);
}