feat(ui): drive mobile layouts from useIsMobile
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,10 +9,19 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo";
|
import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
||||||
export default function CombinedDatePickerDemoPage() {
|
export default function CombinedDatePickerDemoPage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-10 sm:px-6 lg:px-8">
|
<div
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-10"
|
||||||
|
: "mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-8 py-10"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Badge variant="outline" className="w-fit">
|
<Badge variant="outline" className="w-fit">
|
||||||
Demo Route
|
Demo Route
|
||||||
@@ -19,7 +30,13 @@ export default function CombinedDatePickerDemoPage() {
|
|||||||
<h1 className="text-3xl font-semibold tracking-tight">
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
Date & Time Picker
|
Date & Time Picker
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground sm:text-base">
|
<p
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "max-w-2xl text-sm text-muted-foreground"
|
||||||
|
: "max-w-2xl text-base text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
Inline date input paired with a locale-aware time picker. The return
|
Inline date input paired with a locale-aware time picker. The return
|
||||||
calendar disables dates before departure, and time fields switch
|
calendar disables dates before departure, and time fields switch
|
||||||
between 24-hour and 12-hour formats automatically.
|
between 24-hour and 12-hour formats automatically.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
|
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
|
||||||
import {
|
import {
|
||||||
getAiDisabledMessage,
|
getAiDisabledMessage,
|
||||||
@@ -51,17 +52,14 @@ import { appendImagesDeduped } from "@/lib/multi-image";
|
|||||||
import type { CalendarEvent } from "@/lib/types";
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
APP_ACTION_BAR_CLASSES,
|
APP_ACTION_BAR_CLASSES,
|
||||||
APP_HEADER_SURFACE_CLASSES,
|
getAppHeaderSurfaceClasses,
|
||||||
APP_NAV_SURFACE_CLASSES,
|
getAppNavSurfaceClasses,
|
||||||
APP_SECTION_SURFACE_CLASSES,
|
getAppSectionSurfaceClasses,
|
||||||
getConnectionBadgeClasses,
|
getConnectionBadgeClasses,
|
||||||
} from "@/lib/ui-shell-contract";
|
} from "@/lib/ui-shell-contract";
|
||||||
import { useUserSettings } from "@/lib/user-settings";
|
import { useUserSettings } from "@/lib/user-settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const APP_FRAME_CLASSES =
|
|
||||||
"mx-auto flex min-h-screen w-full max-w-6xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
|
|
||||||
|
|
||||||
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
|
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
|
||||||
|
|
||||||
const getNavButtonClasses = (isActive: boolean) =>
|
const getNavButtonClasses = (isActive: boolean) =>
|
||||||
@@ -83,6 +81,7 @@ const validateImageFile = (file: File): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [activeView, setActiveView] = useState<"list" | "settings">("list");
|
const [activeView, setActiveView] = useState<"list" | "settings">("list");
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -428,6 +427,18 @@ export default function HomePage() {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appFrameClasses = cn(
|
||||||
|
"mx-auto flex min-h-screen w-full max-w-6xl flex-col",
|
||||||
|
isMobile ? "px-4 pb-24 pt-4" : "px-8 py-4",
|
||||||
|
);
|
||||||
|
const appHeaderSurfaceClasses = getAppHeaderSurfaceClasses(isMobile);
|
||||||
|
const appSectionSurfaceClasses = getAppSectionSurfaceClasses(isMobile);
|
||||||
|
const appNavSurfaceClasses = getAppNavSurfaceClasses(isMobile);
|
||||||
|
const mainContentClasses = cn(
|
||||||
|
"grid items-start gap-4",
|
||||||
|
isMobile ? "grid-cols-1" : "grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContainer
|
<DragDropContainer
|
||||||
isDragOver={isDragOver}
|
isDragOver={isDragOver}
|
||||||
@@ -435,8 +446,8 @@ export default function HomePage() {
|
|||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onImageDrop={(file) => handleImagesSelect([file])}
|
onImageDrop={(file) => handleImagesSelect([file])}
|
||||||
>
|
>
|
||||||
<div className={APP_FRAME_CLASSES}>
|
<div className={appFrameClasses}>
|
||||||
<header className={APP_HEADER_SURFACE_CLASSES}>
|
<header className={appHeaderSurfaceClasses}>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
<p className="font-mono text-[11px] uppercase text-muted-foreground">
|
||||||
Local Calendar
|
Local Calendar
|
||||||
@@ -499,15 +510,15 @@ export default function HomePage() {
|
|||||||
{activeView === "settings" ? (
|
{activeView === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
adminAiEnabled={adminAiEnabled}
|
adminAiEnabled={adminAiEnabled}
|
||||||
className={APP_SECTION_SURFACE_CLASSES}
|
className={appSectionSurfaceClasses}
|
||||||
hasLoadedSettings={hasLoadedSettings}
|
hasLoadedSettings={hasLoadedSettings}
|
||||||
onSettingsChange={updateSettings}
|
onSettingsChange={updateSettings}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
|
<section className={mainContentClasses}>
|
||||||
<div className="order-1 lg:order-none space-y-4">
|
<div className="space-y-4">
|
||||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
<section className={appSectionSurfaceClasses}>
|
||||||
<div className="mb-4 space-y-1">
|
<div className="mb-4 space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
AI capture
|
AI capture
|
||||||
@@ -543,8 +554,8 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="order-2 lg:order-none space-y-4">
|
<div className="space-y-4">
|
||||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
<section className={appSectionSurfaceClasses}>
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
@@ -593,7 +604,7 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav className={APP_NAV_SURFACE_CLASSES}>
|
<nav className={appNavSurfaceClasses}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
|
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
|
||||||
import {
|
import {
|
||||||
detectOs,
|
detectOs,
|
||||||
@@ -114,6 +115,7 @@ export const AIToolbar = ({
|
|||||||
summaryUpdated,
|
summaryUpdated,
|
||||||
events,
|
events,
|
||||||
}: AIToolbarProps) => {
|
}: AIToolbarProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const examplePrompts = [
|
const examplePrompts = [
|
||||||
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
|
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
|
||||||
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
|
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
|
||||||
@@ -281,7 +283,13 @@ export const AIToolbar = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isAuthenticated ? (
|
) : isAuthenticated ? (
|
||||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "grid gap-3"
|
||||||
|
: "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20">
|
<div className="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20">
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -347,42 +355,47 @@ export const AIToolbar = ({
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<HoverCard
|
{!isMobile ? (
|
||||||
openDelay={300}
|
<HoverCard
|
||||||
closeDelay={100}
|
openDelay={300}
|
||||||
open={isPopoverOpen ? false : undefined}
|
closeDelay={100}
|
||||||
>
|
open={isPopoverOpen ? false : undefined}
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
>
|
||||||
<HoverCardTrigger asChild>
|
<Popover
|
||||||
<PopoverTrigger asChild>
|
open={isPopoverOpen}
|
||||||
<Button
|
onOpenChange={setIsPopoverOpen}
|
||||||
variant="ghost"
|
>
|
||||||
size="icon"
|
<HoverCardTrigger asChild>
|
||||||
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
|
<PopoverTrigger asChild>
|
||||||
aria-label="Keyboard shortcuts"
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Info className="h-3.5 w-3.5" />
|
size="icon"
|
||||||
</Button>
|
className="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
|
||||||
</PopoverTrigger>
|
aria-label="Keyboard shortcuts"
|
||||||
</HoverCardTrigger>
|
>
|
||||||
<PopoverContent
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
className="w-52 p-3"
|
||||||
|
>
|
||||||
|
<ShortcutsList os={os} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<HoverCardContent
|
||||||
align="start"
|
align="start"
|
||||||
side="top"
|
side="top"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className="w-52 p-3"
|
className="w-52 p-3"
|
||||||
>
|
>
|
||||||
<ShortcutsList os={os} />
|
<ShortcutsList os={os} />
|
||||||
</PopoverContent>
|
</HoverCardContent>
|
||||||
</Popover>
|
</HoverCard>
|
||||||
<HoverCardContent
|
) : null}
|
||||||
align="start"
|
|
||||||
side="top"
|
|
||||||
sideOffset={6}
|
|
||||||
className="w-52 p-3"
|
|
||||||
>
|
|
||||||
<ShortcutsList os={os} />
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
|
|
||||||
{events.length > 0 && (
|
{events.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -453,7 +466,9 @@ export const AIToolbar = ({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 4 }}
|
exit={{ opacity: 0, y: 4 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="mt-3 grid gap-2 sm:grid-cols-2"
|
className={
|
||||||
|
isMobile ? "mt-3 grid gap-2" : "mt-3 grid gap-2 grid-cols-2"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{imagePreviews.map((preview, index) => (
|
{imagePreviews.map((preview, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
type EventFormValues,
|
type EventFormValues,
|
||||||
getDefaultEventFormValues,
|
getDefaultEventFormValues,
|
||||||
@@ -45,6 +46,7 @@ export const EventDialog = ({
|
|||||||
onSave,
|
onSave,
|
||||||
onReset,
|
onReset,
|
||||||
}: EventDialogProps) => {
|
}: EventDialogProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isAiDraft = dialogSource === "ai" && !editingId;
|
const isAiDraft = dialogSource === "ai" && !editingId;
|
||||||
const titleText = editingId
|
const titleText = editingId
|
||||||
? "Edit Event"
|
? "Edit Event"
|
||||||
@@ -192,7 +194,11 @@ export const EventDialog = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div
|
||||||
|
className={
|
||||||
|
isMobile ? "grid grid-cols-1 gap-3" : "grid grid-cols-2 gap-3"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="event-location">Location</Label>
|
<Label htmlFor="event-location">Location</Label>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -329,7 +335,7 @@ export const EventDialog = ({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className={isMobile ? "gap-2" : "gap-0"}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Sparkles, Zap } from "lucide-react";
|
import { Sparkles, Zap } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { UserSettings } from "@/lib/user-settings";
|
import type { UserSettings } from "@/lib/user-settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ export function SettingsPanel({
|
|||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
settings,
|
settings,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const valuePrefix = hasLoadedSettings
|
const valuePrefix = hasLoadedSettings
|
||||||
? "Current preference"
|
? "Current preference"
|
||||||
: "Default value";
|
: "Default value";
|
||||||
@@ -35,7 +39,12 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={className}>
|
<section className={className}>
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"gap-4",
|
||||||
|
isMobile ? "flex flex-col" : "flex items-start justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
||||||
Settings
|
Settings
|
||||||
@@ -51,7 +60,14 @@ export function SettingsPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-5 grid gap-4",
|
||||||
|
isMobile
|
||||||
|
? "grid-cols-1"
|
||||||
|
: "grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className={settingRowClasses}>
|
<div className={settingRowClasses}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -149,7 +165,12 @@ export function SettingsPanel({
|
|||||||
{summaryDescription}
|
{summaryDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
<dl
|
||||||
|
className={cn(
|
||||||
|
"grid gap-3",
|
||||||
|
isMobile ? "grid-cols-1" : "grid-cols-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="rounded-[8px] bg-secondary p-3 shadow-[inset_0_0_0_1px_var(--color-border)]">
|
<div className="rounded-[8px] bg-secondary p-3 shadow-[inset_0_0_0_1px_var(--color-border)]">
|
||||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Direct create preference
|
Direct create preference
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getDefaultClassNames,
|
getDefaultClassNames,
|
||||||
} from "react-day-picker";
|
} from "react-day-picker";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
@@ -26,6 +27,7 @@ function Calendar({
|
|||||||
}: React.ComponentProps<typeof DayPicker> & {
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const defaultClassNames = getDefaultClassNames();
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,7 +48,9 @@ function Calendar({
|
|||||||
classNames={{
|
classNames={{
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
months: cn(
|
months: cn(
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
isMobile
|
||||||
|
? "relative flex flex-col gap-4"
|
||||||
|
: "relative flex flex-row gap-4",
|
||||||
defaultClassNames.months,
|
defaultClassNames.months,
|
||||||
),
|
),
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
|
||||||
interface DropdownItem {
|
interface DropdownItem {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -28,6 +29,7 @@ interface DropdownItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDropdown(props: DropdownProps) {
|
function CalendarDropdown(props: DropdownProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { options, value, onChange, "aria-label": ariaLabel } = props;
|
const { options, value, onChange, "aria-label": ariaLabel } = props;
|
||||||
|
|
||||||
const items: DropdownItem[] =
|
const items: DropdownItem[] =
|
||||||
@@ -50,35 +52,35 @@ function CalendarDropdown(props: DropdownProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center sm:hidden">
|
{isMobile ? (
|
||||||
<select
|
<div className="relative flex items-center">
|
||||||
aria-label={ariaLabel}
|
<select
|
||||||
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
|
aria-label={ariaLabel}
|
||||||
value={value?.toString() ?? ""}
|
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
|
||||||
onChange={onChange}
|
value={value?.toString() ?? ""}
|
||||||
>
|
onChange={onChange}
|
||||||
{options?.map((option) => (
|
>
|
||||||
<option
|
{options?.map((option) => (
|
||||||
key={option.value}
|
<option
|
||||||
value={option.value}
|
key={option.value}
|
||||||
disabled={option.disabled}
|
value={option.value}
|
||||||
>
|
disabled={option.disabled}
|
||||||
{option.label}
|
>
|
||||||
</option>
|
{option.label}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
<Button
|
</select>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="pointer-events-none h-8 w-full justify-between gap-2 px-2 font-medium"
|
size="sm"
|
||||||
tabIndex={-1}
|
className="pointer-events-none h-8 w-full justify-between gap-2 px-2 font-medium"
|
||||||
>
|
tabIndex={-1}
|
||||||
{selectedItem?.label}
|
>
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
{selectedItem?.label}
|
||||||
</Button>
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="hidden sm:block">
|
) : (
|
||||||
<Combobox
|
<Combobox
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
autoHighlight
|
autoHighlight
|
||||||
@@ -105,7 +107,7 @@ function CalendarDropdown(props: DropdownProps) {
|
|||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,7 @@ export function DatePicker({
|
|||||||
month: controlledMonth,
|
month: controlledMonth,
|
||||||
onMonthChange: controlledOnMonthChange,
|
onMonthChange: controlledOnMonthChange,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const fallbackId = React.useId();
|
const fallbackId = React.useId();
|
||||||
const id = providedId ?? fallbackId;
|
const id = providedId ?? fallbackId;
|
||||||
|
|
||||||
@@ -239,9 +242,21 @@ export function DatePicker({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
{triggerContent}
|
{triggerContent}
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
<div className="flex max-sm:flex-col">
|
<div className={isMobile ? "flex flex-col" : "flex"}>
|
||||||
<div className="relative py-1 ps-1 max-sm:order-1 max-sm:border-t">
|
<div
|
||||||
<div className="flex h-full flex-col sm:border-e sm:pe-3">
|
className={
|
||||||
|
isMobile
|
||||||
|
? "relative order-1 border-t py-1 ps-1"
|
||||||
|
: "relative py-1 ps-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? "flex h-full flex-col"
|
||||||
|
: "flex h-full flex-col border-e pe-3"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -292,7 +307,7 @@ export function DatePicker({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Calendar
|
<Calendar
|
||||||
className="max-sm:pb-3 sm:ps-2"
|
className={isMobile ? "pb-3" : "ps-2"}
|
||||||
mode="single"
|
mode="single"
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
components={{ Dropdown: CalendarDropdown }}
|
components={{ Dropdown: CalendarDropdown }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -54,13 +55,16 @@ function DialogContent({
|
|||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 shadow-xl duration-200 sm:max-w-lg",
|
"bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-5 rounded-[10px] p-6 shadow-xl duration-200",
|
||||||
|
isMobile ? undefined : "max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -91,11 +95,15 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
isMobile
|
||||||
|
? "flex flex-col-reverse gap-2"
|
||||||
|
: "flex flex-row justify-end gap-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,37 +4,53 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const inputGroupAddonVariants = cva(
|
const inputGroupAddonBaseClasses =
|
||||||
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
|
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80";
|
||||||
{
|
|
||||||
defaultVariants: {
|
const inputGroupRootBaseClasses =
|
||||||
align: "inline-start",
|
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[[data-size=sm]_textarea]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_textarea]:pe-1.5 has-[[data-align=block-start],[data-align=block-end]]:**:[textarea]:h-auto";
|
||||||
},
|
|
||||||
variants: {
|
const inputGroupTextBaseClasses =
|
||||||
align: {
|
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none [&_svg]:pointer-events-none [&_svg]:-mx-0.5";
|
||||||
"block-end":
|
|
||||||
"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
const inputGroupAddonVariants = cva(inputGroupAddonBaseClasses, {
|
||||||
"block-start":
|
defaultVariants: {
|
||||||
"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
align: "inline-start",
|
||||||
"inline-end":
|
},
|
||||||
"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
|
variants: {
|
||||||
"inline-start":
|
align: {
|
||||||
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
|
"block-end":
|
||||||
},
|
"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
|
||||||
|
"inline-start":
|
||||||
|
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
|
function getAddonResponsiveClasses(isMobile: boolean) {
|
||||||
|
return isMobile
|
||||||
|
? "in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5"
|
||||||
|
: "in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4";
|
||||||
|
}
|
||||||
|
|
||||||
export function InputGroup({
|
export function InputGroup({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">): React.ReactElement {
|
}: React.ComponentProps<"div">): React.ReactElement {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
|
inputGroupRootBaseClasses,
|
||||||
|
isMobile ? "text-base" : "text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-slot="input-group"
|
data-slot="input-group"
|
||||||
@@ -49,9 +65,15 @@ export function InputGroupAddon({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> &
|
}: React.ComponentProps<"div"> &
|
||||||
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
|
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
className={cn(
|
||||||
|
inputGroupAddonVariants({ align }),
|
||||||
|
getAddonResponsiveClasses(isMobile),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
data-align={align}
|
data-align={align}
|
||||||
data-slot="input-group-addon"
|
data-slot="input-group-addon"
|
||||||
{...props}
|
{...props}
|
||||||
@@ -63,10 +85,13 @@ export function InputGroupText({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">): React.ReactElement {
|
}: React.ComponentProps<"span">): React.ReactElement {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5",
|
inputGroupTextBaseClasses,
|
||||||
|
getAddonResponsiveClasses(isMobile),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-[8px] bg-secondary px-3 py-2 text-base shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex field-sizing-content min-h-16 w-full rounded-[8px] bg-secondary px-3 py-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
isMobile ? "text-base" : "text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const APP_HEADER_SURFACE_CLASSES =
|
export const getAppHeaderSurfaceClasses = (isMobile: boolean) =>
|
||||||
"mb-6 flex min-h-14 items-center justify-between gap-3 bg-background/95 px-4 py-3 shadow-[inset_0_-1px_0_0_var(--color-border)] sm:px-6";
|
cn(
|
||||||
|
"mb-6 flex min-h-14 items-center justify-between gap-3 bg-background/95 py-3 shadow-[inset_0_-1px_0_0_var(--color-border)]",
|
||||||
|
isMobile ? "px-4" : "px-6",
|
||||||
|
);
|
||||||
|
|
||||||
export const APP_SECTION_SURFACE_CLASSES =
|
export const getAppSectionSurfaceClasses = (isMobile: boolean) =>
|
||||||
"rounded-[10px] bg-card px-4 py-4 shadow sm:px-5";
|
cn("rounded-[10px] bg-card py-4 shadow", isMobile ? "px-4" : "px-5");
|
||||||
|
|
||||||
export const APP_ACTION_BAR_CLASSES =
|
export const APP_ACTION_BAR_CLASSES =
|
||||||
"rounded-[10px] bg-secondary px-3 py-3 shadow-sm";
|
"rounded-[10px] bg-secondary px-3 py-3 shadow-sm";
|
||||||
|
|
||||||
export const APP_NAV_SURFACE_CLASSES =
|
export const getAppNavSurfaceClasses = (isMobile: boolean) =>
|
||||||
"fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-[10px] bg-card/95 px-3 py-2 shadow-lg sm:inset-x-6 lg:hidden";
|
cn(
|
||||||
|
"mx-auto max-w-3xl items-center justify-between rounded-[10px] bg-card/95 px-3 py-2 shadow-lg",
|
||||||
|
isMobile ? "fixed inset-x-4 bottom-4 flex" : "hidden",
|
||||||
|
);
|
||||||
|
|
||||||
const CONNECTION_BADGE_BASE_CLASSES =
|
const CONNECTION_BADGE_BASE_CLASSES =
|
||||||
"gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm";
|
"gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm";
|
||||||
|
|||||||
@@ -159,10 +159,11 @@ describe("Event count badge – positioning contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AI capture redesign", () => {
|
describe("AI capture redesign", () => {
|
||||||
test("desktop composer treats prompt and attachments as peer panels", () => {
|
test("composer layout is driven by useIsMobile instead of Tailwind breakpoint classes", () => {
|
||||||
const source = readToolbarSource();
|
const source = readToolbarSource();
|
||||||
|
|
||||||
expect(source).toContain("lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
|
expect(source).toContain("useIsMobile");
|
||||||
|
expect(source).not.toContain("lg:grid-cols-");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => {
|
test("attachments panel is a first-class surfaced region, not an inline footer affordance", () => {
|
||||||
@@ -191,8 +192,8 @@ const ATTACH_BTN_CLASSES = "gap-1.5 text-xs";
|
|||||||
/** Generate button: right side, primary variant, labeled */
|
/** Generate button: right side, primary variant, labeled */
|
||||||
const GENERATE_BTN_CLASSES = "gap-1.5 text-xs";
|
const GENERATE_BTN_CLASSES = "gap-1.5 text-xs";
|
||||||
|
|
||||||
/** Info popover trigger: hidden on mobile, small on desktop so it stays secondary */
|
/** Info popover trigger: compact affordance that only renders in desktop branches */
|
||||||
const INFO_TRIGGER_CLASSES = "hidden h-6 w-6 md:inline-flex";
|
const INFO_TRIGGER_CLASSES = "h-8 w-8 text-muted-foreground/70 hover:text-foreground";
|
||||||
|
|
||||||
describe("Composer footer bar – layout contract", () => {
|
describe("Composer footer bar – layout contract", () => {
|
||||||
test("footer row uses justify-between so Attach sits left and Generate sits right", () => {
|
test("footer row uses justify-between so Attach sits left and Generate sits right", () => {
|
||||||
@@ -223,16 +224,15 @@ describe("Composer footer bar – layout contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Info popover trigger – size contract", () => {
|
describe("Info popover trigger – size contract", () => {
|
||||||
test("info trigger is hidden on mobile so keyboard-only guidance does not appear in touch layouts", () => {
|
test("info trigger is guarded by useIsMobile so keyboard-only guidance stays out of touch layouts", () => {
|
||||||
const resolved = cn(INFO_TRIGGER_CLASSES);
|
const source = readToolbarSource();
|
||||||
expect(resolved).toContain("hidden");
|
expect(source).toContain("!isMobile ? (");
|
||||||
expect(resolved).toContain("md:inline-flex");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("info trigger is small (h-6 w-6) so it doesn't compete with Generate", () => {
|
test("info trigger stays visually secondary when rendered on desktop", () => {
|
||||||
const resolved = cn(INFO_TRIGGER_CLASSES);
|
const resolved = cn(INFO_TRIGGER_CLASSES);
|
||||||
expect(resolved).toContain("h-6");
|
expect(resolved).toContain("h-8");
|
||||||
expect(resolved).toContain("w-6");
|
expect(resolved).toContain("w-8");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,7 +328,7 @@ describe("Multi-image strip – layout contract", () => {
|
|||||||
|
|
||||||
describe("AI textarea – prompt input spacing contract", () => {
|
describe("AI textarea – prompt input spacing contract", () => {
|
||||||
const TEXTAREA_BASE =
|
const TEXTAREA_BASE =
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm";
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
const AI_TEXTAREA_OVERRIDE =
|
const AI_TEXTAREA_OVERRIDE =
|
||||||
"wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic";
|
"wrap-anywhere field-sizing-content resize-none w-full min-h-[2.5rem] max-h-64 overflow-y-auto bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-1 text-sm placeholder:text-muted-foreground/60 placeholder:italic";
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
describe("home page hierarchy", () => {
|
describe("home page hierarchy", () => {
|
||||||
test("desktop layout defines aligned AI and timeline top-row sections", () => {
|
test("desktop layout is selected with useIsMobile rather than Tailwind breakpoint classes", () => {
|
||||||
const source = readFileSync("src/app/page.tsx", "utf8");
|
const source = readFileSync("src/app/page.tsx", "utf8");
|
||||||
|
|
||||||
expect(source).toContain(
|
expect(source).toContain("useIsMobile");
|
||||||
"lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]",
|
expect(source).toContain("grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]");
|
||||||
);
|
|
||||||
expect(source).toContain("AI capture");
|
expect(source).toContain("AI capture");
|
||||||
expect(source).toContain("Event timeline");
|
expect(source).toContain("Event timeline");
|
||||||
});
|
});
|
||||||
@@ -19,10 +18,10 @@ describe("home page hierarchy", () => {
|
|||||||
expect(source).not.toContain("New Event</button>");
|
expect(source).not.toContain("New Event</button>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile layout keeps capture before timeline and keeps manual create secondary", () => {
|
test("mobile layout keeps capture before timeline without order utility breakpoints", () => {
|
||||||
const source = readFileSync("src/app/page.tsx", "utf8");
|
const source = readFileSync("src/app/page.tsx", "utf8");
|
||||||
|
|
||||||
expect(source).toContain("order-1 lg:order-none");
|
expect(source).not.toContain("order-1 lg:order-none");
|
||||||
expect(source).toContain("Import");
|
expect(source).toContain("Import");
|
||||||
expect(source).toContain("Manual create");
|
expect(source).toContain("Manual create");
|
||||||
});
|
});
|
||||||
|
|||||||
58
tests/mobile-hook-adoption.test.ts
Normal file
58
tests/mobile-hook-adoption.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const RESPONSIVE_PREFIX_PATTERN = /\b(?:max-sm|sm:|md:|lg:|xl:|2xl:)/;
|
||||||
|
|
||||||
|
const HOOK_DRIVEN_FILES = [
|
||||||
|
"src/app/page.tsx",
|
||||||
|
"src/app/demo/combined-date-picker/page.tsx",
|
||||||
|
"src/components/ai-toolbar.tsx",
|
||||||
|
"src/components/event-dialog.tsx",
|
||||||
|
"src/components/settings-panel.tsx",
|
||||||
|
"src/components/ui/calendar.tsx",
|
||||||
|
"src/components/ui/date-picker.tsx",
|
||||||
|
"src/components/ui/dialog.tsx",
|
||||||
|
"src/components/ui/input-group.tsx",
|
||||||
|
"src/components/ui/textarea.tsx",
|
||||||
|
"src/lib/ui-shell-contract.ts",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIRECT_HOOK_FILES = [
|
||||||
|
"src/app/page.tsx",
|
||||||
|
"src/app/demo/combined-date-picker/page.tsx",
|
||||||
|
"src/components/ai-toolbar.tsx",
|
||||||
|
"src/components/event-dialog.tsx",
|
||||||
|
"src/components/settings-panel.tsx",
|
||||||
|
"src/components/ui/calendar.tsx",
|
||||||
|
"src/components/ui/date-picker.tsx",
|
||||||
|
"src/components/ui/dialog.tsx",
|
||||||
|
"src/components/ui/input-group.tsx",
|
||||||
|
"src/components/ui/textarea.tsx",
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOOLEAN_HELPER_FILES = [
|
||||||
|
"src/lib/ui-shell-contract.ts",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("mobile hook adoption", () => {
|
||||||
|
test("responsive source files stop using Tailwind breakpoint prefixes for mobile behavior", () => {
|
||||||
|
for (const filePath of HOOK_DRIVEN_FILES) {
|
||||||
|
const source = readFileSync(filePath, "utf8");
|
||||||
|
expect(source).not.toMatch(RESPONSIVE_PREFIX_PATTERN);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile-responsive component files explicitly depend on the shared useIsMobile hook", () => {
|
||||||
|
for (const filePath of DIRECT_HOOK_FILES) {
|
||||||
|
const source = readFileSync(filePath, "utf8");
|
||||||
|
expect(source).toContain("useIsMobile");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("utility files accept isMobile booleans instead of embedding breakpoint strings", () => {
|
||||||
|
for (const filePath of BOOLEAN_HELPER_FILES) {
|
||||||
|
const source = readFileSync(filePath, "utf8");
|
||||||
|
expect(source).toContain("isMobile");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ import { cn } from "@/lib/utils";
|
|||||||
// The base Textarea classes (copied from the component — this is the source of
|
// The base Textarea classes (copied from the component — this is the source of
|
||||||
// truth we are locking down).
|
// truth we are locking down).
|
||||||
const TEXTAREA_BASE =
|
const TEXTAREA_BASE =
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm";
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
|
|
||||||
describe("Textarea – placeholder spacing (base defaults)", () => {
|
describe("Textarea – placeholder spacing (base defaults)", () => {
|
||||||
test("base className includes horizontal padding px-3 so placeholder is not flush", () => {
|
test("base className includes horizontal padding px-3 so placeholder is not flush", () => {
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
APP_ACTION_BAR_CLASSES,
|
APP_ACTION_BAR_CLASSES,
|
||||||
APP_HEADER_SURFACE_CLASSES,
|
getAppHeaderSurfaceClasses,
|
||||||
APP_NAV_SURFACE_CLASSES,
|
getAppNavSurfaceClasses,
|
||||||
APP_SECTION_SURFACE_CLASSES,
|
getAppSectionSurfaceClasses,
|
||||||
getConnectionBadgeClasses,
|
getConnectionBadgeClasses,
|
||||||
} from "@/lib/ui-shell-contract";
|
} from "@/lib/ui-shell-contract";
|
||||||
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
|
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
|
||||||
|
|
||||||
describe("app shell surfaces", () => {
|
describe("app shell surfaces", () => {
|
||||||
test("header surface is a thin structural bar instead of a glass panel", () => {
|
test("header surface is a thin structural bar instead of a glass panel", () => {
|
||||||
expect(APP_HEADER_SURFACE_CLASSES).toContain("min-h-14");
|
const mobileHeaderClasses = getAppHeaderSurfaceClasses(true);
|
||||||
expect(APP_HEADER_SURFACE_CLASSES).toContain("shadow-[inset_0_-1px_0_0_var(--color-border)]");
|
|
||||||
expect(APP_HEADER_SURFACE_CLASSES).not.toContain("glass-surface");
|
expect(mobileHeaderClasses).toContain("min-h-14");
|
||||||
|
expect(mobileHeaderClasses).toContain("shadow-[inset_0_-1px_0_0_var(--color-border)]");
|
||||||
|
expect(mobileHeaderClasses).not.toContain("glass-surface");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("section and action surfaces use tokenized shell classes instead of frozen light-mode shadows", () => {
|
test("section and action surfaces use tokenized shell classes instead of frozen light-mode shadows", () => {
|
||||||
expect(APP_SECTION_SURFACE_CLASSES).not.toContain("glass-panel");
|
const mobileSectionClasses = getAppSectionSurfaceClasses(true);
|
||||||
|
const mobileNavClasses = getAppNavSurfaceClasses(true);
|
||||||
|
|
||||||
|
expect(mobileSectionClasses).not.toContain("glass-panel");
|
||||||
expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle");
|
expect(APP_ACTION_BAR_CLASSES).not.toContain("glass-subtle");
|
||||||
expect(APP_NAV_SURFACE_CLASSES).not.toContain("glass-surface");
|
expect(mobileNavClasses).not.toContain("glass-surface");
|
||||||
expect(APP_SECTION_SURFACE_CLASSES).toContain("shadow");
|
expect(mobileSectionClasses).toContain("shadow");
|
||||||
expect(APP_SECTION_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)");
|
expect(mobileSectionClasses).not.toContain("rgba(0,0,0,0.08)");
|
||||||
expect(APP_ACTION_BAR_CLASSES).toContain("shadow-sm");
|
expect(APP_ACTION_BAR_CLASSES).toContain("shadow-sm");
|
||||||
expect(APP_ACTION_BAR_CLASSES).not.toContain("rgba(0,0,0,0.08)");
|
expect(APP_ACTION_BAR_CLASSES).not.toContain("rgba(0,0,0,0.08)");
|
||||||
expect(APP_NAV_SURFACE_CLASSES).toContain("shadow-lg");
|
expect(mobileNavClasses).toContain("shadow-lg");
|
||||||
expect(APP_NAV_SURFACE_CLASSES).not.toContain("rgba(0,0,0,0.08)");
|
expect(mobileNavClasses).not.toContain("rgba(0,0,0,0.08)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop nav surface is suppressed when useIsMobile resolves false", () => {
|
||||||
|
expect(getAppNavSurfaceClasses(false)).toContain("hidden");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user