feat(ui): drive mobile layouts from useIsMobile

This commit is contained in:
2026-04-21 22:46:07 -04:00
parent 16bbd9ab08
commit 7a917e5c22
16 changed files with 350 additions and 150 deletions

View File

@@ -1,3 +1,5 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -7,10 +9,19 @@ import {
CardTitle,
} from "@/components/ui/card";
import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo";
import { useIsMobile } from "@/hooks/use-mobile";
export default function CombinedDatePickerDemoPage() {
const isMobile = useIsMobile();
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">
<Badge variant="outline" className="w-fit">
Demo Route
@@ -19,7 +30,13 @@ export default function CombinedDatePickerDemoPage() {
<h1 className="text-3xl font-semibold tracking-tight">
Date &amp; Time Picker
</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
calendar disables dates before departure, and time fields switch
between 24-hour and 12-hour formats automatically.

View File

@@ -27,6 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useIsMobile } from "@/hooks/use-mobile";
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
import {
getAiDisabledMessage,
@@ -51,17 +52,14 @@ import { appendImagesDeduped } from "@/lib/multi-image";
import type { CalendarEvent } from "@/lib/types";
import {
APP_ACTION_BAR_CLASSES,
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
getAppHeaderSurfaceClasses,
getAppNavSurfaceClasses,
getAppSectionSurfaceClasses,
getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract";
import { useUserSettings } from "@/lib/user-settings";
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 getNavButtonClasses = (isActive: boolean) =>
@@ -83,6 +81,7 @@ const validateImageFile = (file: File): string | null => {
};
export default function HomePage() {
const isMobile = useIsMobile();
const [activeView, setActiveView] = useState<"list" | "settings">("list");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -428,6 +427,18 @@ export default function HomePage() {
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 (
<DragDropContainer
isDragOver={isDragOver}
@@ -435,8 +446,8 @@ export default function HomePage() {
onImport={handleImport}
onImageDrop={(file) => handleImagesSelect([file])}
>
<div className={APP_FRAME_CLASSES}>
<header className={APP_HEADER_SURFACE_CLASSES}>
<div className={appFrameClasses}>
<header className={appHeaderSurfaceClasses}>
<div className="flex min-w-0 flex-col">
<p className="font-mono text-[11px] uppercase text-muted-foreground">
Local Calendar
@@ -499,15 +510,15 @@ export default function HomePage() {
{activeView === "settings" ? (
<SettingsPanel
adminAiEnabled={adminAiEnabled}
className={APP_SECTION_SURFACE_CLASSES}
className={appSectionSurfaceClasses}
hasLoadedSettings={hasLoadedSettings}
onSettingsChange={updateSettings}
settings={settings}
/>
) : (
<section className="grid items-start gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<div className="order-1 lg:order-none space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}>
<section className={mainContentClasses}>
<div className="space-y-4">
<section className={appSectionSurfaceClasses}>
<div className="mb-4 space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
AI capture
@@ -543,8 +554,8 @@ export default function HomePage() {
</section>
</div>
<div className="order-2 lg:order-none space-y-4">
<section className={APP_SECTION_SURFACE_CLASSES}>
<div className="space-y-4">
<section className={appSectionSurfaceClasses}>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
@@ -593,7 +604,7 @@ export default function HomePage() {
)}
</main>
<nav className={APP_NAV_SURFACE_CLASSES}>
<nav className={appNavSurfaceClasses}>
<Button
type="button"
variant="ghost"

View File

@@ -19,6 +19,7 @@ import {
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { extractAllImagesFromClipboard } from "@/lib/clipboard-image";
import {
detectOs,
@@ -114,6 +115,7 @@ export const AIToolbar = ({
summaryUpdated,
events,
}: AIToolbarProps) => {
const isMobile = useIsMobile();
const examplePrompts = [
"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.",
@@ -281,7 +283,13 @@ export const AIToolbar = ({
</div>
</div>
) : 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="rounded-[10px] bg-card shadow focus-within:ring-[3px] focus-within:ring-ring/20">
<Textarea
@@ -347,42 +355,47 @@ export const AIToolbar = ({
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<HoverCard
openDelay={300}
closeDelay={100}
open={isPopoverOpen ? false : undefined}
>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<HoverCardTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden h-8 w-8 text-muted-foreground/70 hover:text-foreground md:inline-flex"
aria-label="Keyboard shortcuts"
>
<Info className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
</HoverCardTrigger>
<PopoverContent
{!isMobile ? (
<HoverCard
openDelay={300}
closeDelay={100}
open={isPopoverOpen ? false : undefined}
>
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<HoverCardTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground/70 hover:text-foreground"
aria-label="Keyboard shortcuts"
>
<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"
side="top"
sideOffset={6}
className="w-52 p-3"
>
<ShortcutsList os={os} />
</PopoverContent>
</Popover>
<HoverCardContent
align="start"
side="top"
sideOffset={6}
className="w-52 p-3"
>
<ShortcutsList os={os} />
</HoverCardContent>
</HoverCard>
</HoverCardContent>
</HoverCard>
) : null}
{events.length > 0 && (
<Button
@@ -453,7 +466,9 @@ export const AIToolbar = ({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
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) => (
<motion.div

View File

@@ -19,6 +19,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import {
type EventFormValues,
getDefaultEventFormValues,
@@ -45,6 +46,7 @@ export const EventDialog = ({
onSave,
onReset,
}: EventDialogProps) => {
const isMobile = useIsMobile();
const isAiDraft = dialogSource === "ai" && !editingId;
const titleText = editingId
? "Edit Event"
@@ -192,7 +194,11 @@ export const EventDialog = ({
/>
</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">
<Label htmlFor="event-location">Location</Label>
<Controller
@@ -329,7 +335,7 @@ export const EventDialog = ({
)}
</section>
<DialogFooter className="gap-2 sm:gap-0">
<DialogFooter className={isMobile ? "gap-2" : "gap-0"}>
<Button
type="button"
variant="ghost"

View File

@@ -1,7 +1,10 @@
"use client";
import { Sparkles, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useIsMobile } from "@/hooks/use-mobile";
import type { UserSettings } from "@/lib/user-settings";
import { cn } from "@/lib/utils";
@@ -22,6 +25,7 @@ export function SettingsPanel({
onSettingsChange,
settings,
}: SettingsPanelProps) {
const isMobile = useIsMobile();
const valuePrefix = hasLoadedSettings
? "Current preference"
: "Default value";
@@ -35,7 +39,12 @@ export function SettingsPanel({
return (
<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">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
Settings
@@ -51,7 +60,14 @@ export function SettingsPanel({
</Badge>
</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={settingRowClasses}>
<div className="flex items-start gap-3">
@@ -149,7 +165,12 @@ export function SettingsPanel({
{summaryDescription}
</p>
</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)]">
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Direct create preference

View File

@@ -12,6 +12,7 @@ import {
getDefaultClassNames,
} from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
function Calendar({
@@ -26,6 +27,7 @@ function Calendar({
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const isMobile = useIsMobile();
const defaultClassNames = getDefaultClassNames();
return (
@@ -46,7 +48,9 @@ function Calendar({
classNames={{
root: cn("w-fit", defaultClassNames.root),
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,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),

View File

@@ -20,6 +20,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useIsMobile } from "@/hooks/use-mobile";
interface DropdownItem {
disabled?: boolean;
@@ -28,6 +29,7 @@ interface DropdownItem {
}
function CalendarDropdown(props: DropdownProps) {
const isMobile = useIsMobile();
const { options, value, onChange, "aria-label": ariaLabel } = props;
const items: DropdownItem[] =
@@ -50,35 +52,35 @@ function CalendarDropdown(props: DropdownProps) {
return (
<>
<div className="relative flex items-center sm:hidden">
<select
aria-label={ariaLabel}
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
value={value?.toString() ?? ""}
onChange={onChange}
>
{options?.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
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" />
</Button>
</div>
<div className="hidden sm:block">
{isMobile ? (
<div className="relative flex items-center">
<select
aria-label={ariaLabel}
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
value={value?.toString() ?? ""}
onChange={onChange}
>
{options?.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
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" />
</Button>
</div>
) : (
<Combobox
aria-label={ariaLabel}
autoHighlight
@@ -105,7 +107,7 @@ function CalendarDropdown(props: DropdownProps) {
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)}
</>
);
}
@@ -139,6 +141,7 @@ export function DatePicker({
month: controlledMonth,
onMonthChange: controlledOnMonthChange,
}: DatePickerProps) {
const isMobile = useIsMobile();
const fallbackId = React.useId();
const id = providedId ?? fallbackId;
@@ -239,9 +242,21 @@ export function DatePicker({
<Popover open={open} onOpenChange={setOpen}>
{triggerContent}
<PopoverContent className="w-auto p-0" align="start">
<div className="flex max-sm:flex-col">
<div className="relative py-1 ps-1 max-sm:order-1 max-sm:border-t">
<div className="flex h-full flex-col sm:border-e sm:pe-3">
<div className={isMobile ? "flex flex-col" : "flex"}>
<div
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
className="w-full justify-start"
onClick={() => {
@@ -292,7 +307,7 @@ export function DatePicker({
</div>
</div>
<Calendar
className="max-sm:pb-3 sm:ps-2"
className={isMobile ? "pb-3" : "ps-2"}
mode="single"
captionLayout="dropdown"
components={{ Dropdown: CalendarDropdown }}

View File

@@ -4,6 +4,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
function Dialog({
@@ -54,13 +55,16 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const isMobile = useIsMobile();
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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,
)}
{...props}
@@ -91,11 +95,15 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
const isMobile = useIsMobile();
return (
<div
data-slot="dialog-footer"
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,
)}
{...props}

View File

@@ -4,37 +4,53 @@ import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
const inputGroupAddonVariants = cva(
"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",
{
defaultVariants: {
align: "inline-start",
},
variants: {
align: {
"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)]",
},
const inputGroupAddonBaseClasses =
"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";
const inputGroupRootBaseClasses =
"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";
const inputGroupTextBaseClasses =
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none [&_svg]:pointer-events-none [&_svg]:-mx-0.5";
const inputGroupAddonVariants = cva(inputGroupAddonBaseClasses, {
defaultVariants: {
align: "inline-start",
},
variants: {
align: {
"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({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
const isMobile = useIsMobile();
return (
<div
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,
)}
data-slot="input-group"
@@ -49,9 +65,15 @@ export function InputGroupAddon({
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
const isMobile = useIsMobile();
return (
<div
className={cn(inputGroupAddonVariants({ align }), className)}
className={cn(
inputGroupAddonVariants({ align }),
getAddonResponsiveClasses(isMobile),
className,
)}
data-align={align}
data-slot="input-group-addon"
{...props}
@@ -63,10 +85,13 @@ export function InputGroupText({
className,
...props
}: React.ComponentProps<"span">): React.ReactElement {
const isMobile = useIsMobile();
return (
<span
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,
)}
{...props}

View File

@@ -1,13 +1,19 @@
"use client";
import type * as React from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
const isMobile = useIsMobile();
return (
<textarea
data-slot="textarea"
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,
)}
{...props}

View File

@@ -1,16 +1,22 @@
import { cn } from "@/lib/utils";
export const APP_HEADER_SURFACE_CLASSES =
"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";
export const getAppHeaderSurfaceClasses = (isMobile: boolean) =>
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 =
"rounded-[10px] bg-card px-4 py-4 shadow sm:px-5";
export const getAppSectionSurfaceClasses = (isMobile: boolean) =>
cn("rounded-[10px] bg-card py-4 shadow", isMobile ? "px-4" : "px-5");
export const APP_ACTION_BAR_CLASSES =
"rounded-[10px] bg-secondary px-3 py-3 shadow-sm";
export const APP_NAV_SURFACE_CLASSES =
"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";
export const getAppNavSurfaceClasses = (isMobile: boolean) =>
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 =
"gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium shadow-sm";