mobile responsive
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user