feat: ui for approve hold request, still need submit
This commit is contained in:
parent
ebe686b47e
commit
27c29949da
915
package-lock.json
generated
915
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,10 @@
|
|||||||
"@payloadcms/next": "3.31.0",
|
"@payloadcms/next": "3.31.0",
|
||||||
"@payloadcms/payload-cloud": "3.31.0",
|
"@payloadcms/payload-cloud": "3.31.0",
|
||||||
"@payloadcms/richtext-lexical": "3.31.0",
|
"@payloadcms/richtext-lexical": "3.31.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.11",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
|
"@radix-ui/react-popover": "^1.1.11",
|
||||||
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
"@radix-ui/react-separator": "^1.1.4",
|
"@radix-ui/react-separator": "^1.1.4",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.4",
|
"@radix-ui/react-tabs": "^1.1.4",
|
||||||
@ -31,11 +34,12 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"framer-motion": "^12.7.4",
|
"framer-motion": "^12.7.4",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"lucide-react": "^0.488.0",
|
"lucide-react": "^0.488.0",
|
||||||
"motion": "^12.7.4",
|
"motion": "^12.9.2",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"payload": "3.31.0",
|
"payload": "3.31.0",
|
||||||
|
|||||||
@ -12,7 +12,6 @@ const requestHold = async (props: Props) => {
|
|||||||
|
|
||||||
const headers = await nextHeaders()
|
const headers = await nextHeaders()
|
||||||
const authResponse = await payload.auth({ headers })
|
const authResponse = await payload.auth({ headers })
|
||||||
console.log(authResponse.user);
|
|
||||||
|
|
||||||
if (!authResponse.user?.id) return
|
if (!authResponse.user?.id) return
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { HoldRequest } from "@/payload-types";
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
|
|
||||||
const HoldRequests: CollectionConfig = {
|
const HoldRequests: CollectionConfig = {
|
||||||
|
|||||||
178
src/components/Manage/ApproveHoldRequestModal.tsx
Normal file
178
src/components/Manage/ApproveHoldRequestModal.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogDescription,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Book, Copy, HoldRequest, Repository, User } from '@/payload-types'
|
||||||
|
import { findCopiesOfBookInRepository } from '@/serverActions/FindCopiesOnHoldRequest'
|
||||||
|
import { Variants, Transition } from 'motion/react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Input } from '../ui/input'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { Select } from '@headlessui/react'
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
copyId: z.string(),
|
||||||
|
untilDate: z.string().date(),
|
||||||
|
})
|
||||||
|
.required()
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
holdRequest: HoldRequest
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: () => void
|
||||||
|
}
|
||||||
|
const ApproveHoldRequestModal = (props: Props) => {
|
||||||
|
const { holdRequest, isOpen, onOpenChange } = props
|
||||||
|
|
||||||
|
const customVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
scale: 0.9,
|
||||||
|
filter: 'blur(10px)',
|
||||||
|
y: '100%',
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
scale: 1,
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const customTransition: Transition = {
|
||||||
|
type: 'spring',
|
||||||
|
bounce: 0,
|
||||||
|
duration: 0.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hold = holdRequest as HoldRequest
|
||||||
|
const book = hold.book as Book
|
||||||
|
const repository = hold.repository as Repository
|
||||||
|
const userRequested = hold.userRequested as User
|
||||||
|
|
||||||
|
const [availableCopies, setAvailableCopies] = useState<Copy[]>([])
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
copyId: undefined,
|
||||||
|
untilDate: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || availableCopies.length) return
|
||||||
|
|
||||||
|
findCopiesOfBookInRepository({
|
||||||
|
bookId: book.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const copies = response?.docs || []
|
||||||
|
const availableCopies = copies.filter((c) => !c.holdRequests?.docs?.length)
|
||||||
|
console.log(availableCopies)
|
||||||
|
setAvailableCopies(availableCopies)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
availableCopies
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
.finally(() => {})
|
||||||
|
}, [availableCopies, setAvailableCopies, isOpen])
|
||||||
|
|
||||||
|
const onSubmit = (values: z.infer<typeof formSchema>) => {
|
||||||
|
const acceptHoldPayload = {
|
||||||
|
copyId: parseInt(values.copyId, 10),
|
||||||
|
untilDate: values.untilDate,
|
||||||
|
}
|
||||||
|
console.log(acceptHoldPayload)
|
||||||
|
onOpenChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
variants={customVariants}
|
||||||
|
transition={customTransition}
|
||||||
|
>
|
||||||
|
<DialogContent className="w-full max-w-md bg-white p-6 dark:bg-zinc-900">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-zinc-900 dark:text-white">
|
||||||
|
Hold a copy for {`${userRequested.firstName} ${userRequested.lastName}`}?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-zinc-600 dark:text-zinc-400">
|
||||||
|
Select which physical copy, and until when, you would like to hold.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="copyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="overflow-hidden">
|
||||||
|
<FormLabel>Copy</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} className="w-full">
|
||||||
|
<option>Please Select a Copy</option>
|
||||||
|
{availableCopies.map((c) => {
|
||||||
|
console.log(c)
|
||||||
|
return (
|
||||||
|
<option key={c.id + '_copy'} value={c.id}>
|
||||||
|
{c.label || `[${repository.abbreviation}] ${book.title}`}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="untilDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="sm:w-auto w-full block">
|
||||||
|
<FormLabel>Until</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4 inline-flex items-center justify-center self-end rounded-lg bg-black px-4 py-2 text-sm font-medium text-zinc-50 dark:bg-white dark:text-zinc-900"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Approve Hold
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<DialogClose />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApproveHoldRequestModal
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import { PaginatedDocs } from 'payload'
|
import { PaginatedDocs } from 'payload'
|
||||||
import { Author, Book, HoldRequest, Repository } from '@/payload-types'
|
import { Author, Book, HoldRequest, Repository, User } from '@/payload-types'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import ApproveHoldRequestModal from './ApproveHoldRequestModal'
|
||||||
|
import { DialogTrigger } from '../ui/dialog'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repos: PaginatedDocs<Repository> | null
|
repos: PaginatedDocs<Repository> | null
|
||||||
@ -9,6 +12,8 @@ type Props = {
|
|||||||
const HoldRequestNotifications = (props: Props) => {
|
const HoldRequestNotifications = (props: Props) => {
|
||||||
const { repos } = props
|
const { repos } = props
|
||||||
|
|
||||||
|
const [openedModalId, setOpenedModalId] = useState<number | null>(null)
|
||||||
|
|
||||||
const totalHoldNotifications = repos?.docs.flatMap((r) => r.holdRequests?.docs).length || 0
|
const totalHoldNotifications = repos?.docs.flatMap((r) => r.holdRequests?.docs).length || 0
|
||||||
|
|
||||||
const holdRequestsByRepoElements = repos?.docs.map((r) => {
|
const holdRequestsByRepoElements = repos?.docs.map((r) => {
|
||||||
@ -18,6 +23,9 @@ const HoldRequestNotifications = (props: Props) => {
|
|||||||
const hold = h as HoldRequest
|
const hold = h as HoldRequest
|
||||||
const book = hold.book as Book
|
const book = hold.book as Book
|
||||||
const authors = book.authors as Author[]
|
const authors = book.authors as Author[]
|
||||||
|
const dateRequested = hold.dateRequested ? new Date(hold.dateRequested) : new Date()
|
||||||
|
const userRequested = hold.userRequested as User
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
|
<li key={hold.id} className="col-span-1 rounded-lg shadow-sm border border-accent">
|
||||||
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
||||||
@ -37,12 +45,12 @@ const HoldRequestNotifications = (props: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<span className="mr-0.5 text-xs">Requested </span>
|
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
|
||||||
{!!hold.dateRequested && (
|
{dateRequested.toLocaleDateString()}
|
||||||
<time className="inline-flex shrink-0 items-center rounded-full bg-background/20 px-1.5 py-0.5 text-xs font-medium text-amber-500 ring-1 ring-amber-600/20 ring-inset">
|
</time>
|
||||||
{new Date(hold.dateRequested).toLocaleDateString()}
|
<span className="text-xs">
|
||||||
</time>
|
{`${userRequested.firstName} ${userRequested.lastName}`}
|
||||||
)}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -57,15 +65,24 @@ const HoldRequestNotifications = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-3 justify-around">
|
<div className="flex gap-2 justify-around">
|
||||||
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-foreground/5 hover:bg-red-400/10 cursor-pointer hover:scale-105">
|
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-bl-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-foreground/5 hover:bg-red-400/10 cursor-pointer hover:scale-105">
|
||||||
<Image width={24} height={24} src="/images/reject.svg" alt="approve hold" />
|
<Image width={24} height={24} src="/images/reject.svg" alt="approve hold" />
|
||||||
<span>Decline</span>
|
<span>Decline</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105">
|
<Button
|
||||||
|
className="inline-flex flex-1 items-center justify-center gap-3 rounded-br-lg border border-transparent py-4 text-sm font-semibold text-foreground bg-emerald-400/30 hover:bg-emerald-300/60 cursor-pointer hover:scale-105"
|
||||||
|
onClick={() => setOpenedModalId(hold.id)}
|
||||||
|
>
|
||||||
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
|
<Image width={24} height={24} src="/images/approve.svg" alt="approve hold" />
|
||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<ApproveHoldRequestModal
|
||||||
|
isOpen={openedModalId === hold.id}
|
||||||
|
onOpenChange={setOpenedModalId}
|
||||||
|
holdRequest={hold}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useGlobal } from '@/providers/GlobalProvider'
|
||||||
|
|
||||||
export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) {
|
export function LoginForm({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { setUser } = useGlobal()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@ -38,6 +40,8 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
|
|||||||
})
|
})
|
||||||
const user = await loginReq.json()
|
const user = await loginReq.json()
|
||||||
|
|
||||||
|
setUser(user.user)
|
||||||
|
|
||||||
if (user.token) router.push('/')
|
if (user.token) router.push('/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
|
|||||||
76
src/components/ui/combobox.tsx
Normal file
76
src/components/ui/combobox.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value: any
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options: Option[]
|
||||||
|
placeholder: string
|
||||||
|
}
|
||||||
|
export function Combobox(props: Props) {
|
||||||
|
const { options, placeholder } = props
|
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [value, setValue] = React.useState<any>()
|
||||||
|
|
||||||
|
console.log(options)
|
||||||
|
console.log(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{value ? options.find((o) => o.value === value)?.label : placeholder || 'Select Option'}
|
||||||
|
<ChevronsUpDown className="opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={placeholder || 'Select Option'} className="h-9" />
|
||||||
|
<CommandList className="z-50">
|
||||||
|
<CommandEmpty>No Copies found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((o) => (
|
||||||
|
<CommandItem
|
||||||
|
key={o.value}
|
||||||
|
value={o.value}
|
||||||
|
onSelect={(currentValue: any) => {
|
||||||
|
setValue(currentValue === value ? undefined : currentValue)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
<Check
|
||||||
|
className={cn('ml-auto', value === o.value ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
335
src/components/ui/dialog.tsx
Normal file
335
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
'use client';
|
||||||
|
import { AnimatePresence, motion, Transition, Variants } from 'motion/react';
|
||||||
|
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { usePreventScroll } from '@/hooks/usePreventScroll';
|
||||||
|
|
||||||
|
const DialogContext = createContext<{
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
dialogRef: React.RefObject<HTMLDialogElement | null>;
|
||||||
|
variants: Variants;
|
||||||
|
transition?: Transition;
|
||||||
|
ids: {
|
||||||
|
dialog: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
onAnimationComplete: (definition: string) => void;
|
||||||
|
handleTrigger: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const defaultVariants: Variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.9,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTransition: Transition = {
|
||||||
|
ease: 'easeOut',
|
||||||
|
duration: 0.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variants?: Variants;
|
||||||
|
transition?: Transition;
|
||||||
|
className?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
open?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
children,
|
||||||
|
variants = defaultVariants,
|
||||||
|
transition = defaultTransition,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
}: DialogProps) {
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
|
||||||
|
defaultOpen || false
|
||||||
|
);
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const isOpen = open !== undefined ? open : uncontrolledOpen;
|
||||||
|
|
||||||
|
// prevent scroll when dialog is open on iOS
|
||||||
|
usePreventScroll({
|
||||||
|
isDisabled: !isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setIsOpen = React.useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
setUncontrolledOpen(value);
|
||||||
|
onOpenChange?.(value);
|
||||||
|
},
|
||||||
|
[onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.addEventListener('cancel', handleCancel);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener('cancel', handleCancel);
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
};
|
||||||
|
}, [dialogRef, isOpen, setIsOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && dialogRef.current) {
|
||||||
|
dialogRef.current.showModal();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleTrigger = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAnimationComplete = (definition: string) => {
|
||||||
|
if (definition === 'exit' && !isOpen) {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseId = useId();
|
||||||
|
const ids = {
|
||||||
|
dialog: `motion-ui-dialog-${baseId}`,
|
||||||
|
title: `motion-ui-dialog-title-${baseId}`,
|
||||||
|
description: `motion-ui-dialog-description-${baseId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider
|
||||||
|
value={{
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
dialogRef,
|
||||||
|
variants,
|
||||||
|
transition,
|
||||||
|
ids,
|
||||||
|
onAnimationComplete,
|
||||||
|
handleTrigger,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogTriggerProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogTrigger({ children, className }: DialogTriggerProps) {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogTrigger must be used within Dialog');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={context.handleTrigger}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium',
|
||||||
|
'transition-colors focus-visible:ring-2 focus-visible:outline-hidden',
|
||||||
|
'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogPortalProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
container?: HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
children,
|
||||||
|
container = typeof window !== 'undefined' ? document.body : null,
|
||||||
|
}: DialogPortalProps) {
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [portalContainer, setPortalContainer] =
|
||||||
|
React.useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
setPortalContainer(container || document.body);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, [container]);
|
||||||
|
|
||||||
|
if (!mounted || !portalContainer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(children, portalContainer);
|
||||||
|
}
|
||||||
|
export type DialogContentProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
container?: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogContent({ children, className, container }: DialogContentProps) {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogContent must be used within Dialog');
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
dialogRef,
|
||||||
|
variants,
|
||||||
|
transition,
|
||||||
|
ids,
|
||||||
|
onAnimationComplete,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<AnimatePresence mode='wait'>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.dialog
|
||||||
|
key={ids.dialog}
|
||||||
|
ref={dialogRef as React.RefObject<HTMLDialogElement>}
|
||||||
|
id={ids.dialog}
|
||||||
|
aria-labelledby={ids.title}
|
||||||
|
aria-describedby={ids.description}
|
||||||
|
aria-modal='true'
|
||||||
|
role='dialog'
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === dialogRef.current) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initial='initial'
|
||||||
|
animate='animate'
|
||||||
|
exit='exit'
|
||||||
|
variants={variants}
|
||||||
|
transition={transition}
|
||||||
|
onAnimationComplete={onAnimationComplete}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-lg border border-zinc-200 p-0 shadow-lg dark:border dark:border-zinc-700',
|
||||||
|
'backdrop:bg-black/50 backdrop:backdrop-blur-xs',
|
||||||
|
'open:flex open:flex-col',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='w-full'>{children}</div>
|
||||||
|
</motion.dialog>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <DialogPortal container={container}>{content}</DialogPortal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogHeaderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogHeader({ children, className }: DialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5', className)}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogTitleProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogTitle({ children, className }: DialogTitleProps) {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogTitle must be used within Dialog');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
id={context.ids.title}
|
||||||
|
className={cn('text-base font-medium', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogDescriptionProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogDescription({ children, className }: DialogDescriptionProps) {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogDescription must be used within Dialog');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
id={context.ids.description}
|
||||||
|
className={cn('text-base text-zinc-500', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogCloseProps = {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DialogClose({ className, children, disabled }: DialogCloseProps) {
|
||||||
|
const context = useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogClose must be used within Dialog');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => context.setIsOpen(false)}
|
||||||
|
type='button'
|
||||||
|
aria-label='Close dialog'
|
||||||
|
className={cn(
|
||||||
|
'absolute top-4 right-4 rounded-xs opacity-70 transition-opacity',
|
||||||
|
'hover:opacity-100 focus:ring-2 focus:outline-hidden',
|
||||||
|
'focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children || <X className='h-4 w-4' />}
|
||||||
|
<span className='sr-only'>Close</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogClose,
|
||||||
|
};
|
||||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
360
src/hooks/usePreventScroll.tsx
Normal file
360
src/hooks/usePreventScroll.tsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import { useEffect, useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
function isMac(): boolean | undefined {
|
||||||
|
return testPlatform(/^Mac/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPhone(): boolean | undefined {
|
||||||
|
return testPlatform(/^iPhone/);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPad(): boolean | undefined {
|
||||||
|
return (
|
||||||
|
testPlatform(/^iPad/) ||
|
||||||
|
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
|
||||||
|
(isMac() && navigator.maxTouchPoints > 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIOS(): boolean | undefined {
|
||||||
|
return isIPhone() || isIPad();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPlatform(re: RegExp): boolean | undefined {
|
||||||
|
return typeof window !== 'undefined' && window.navigator != null
|
||||||
|
? re.test(window.navigator.platform)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEYBOARD_BUFFER = 24;
|
||||||
|
|
||||||
|
export const useIsomorphicLayoutEffect =
|
||||||
|
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
|
interface PreventScrollOptions {
|
||||||
|
/** Whether the scroll lock is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
focusCallback?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chain(...callbacks: any[]): (...args: any[]) => void {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
for (let callback of callbacks) {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const visualViewport = typeof document !== 'undefined' && window.visualViewport;
|
||||||
|
|
||||||
|
export function isScrollable(node: Element): boolean {
|
||||||
|
let style = window.getComputedStyle(node);
|
||||||
|
return /(auto|scroll)/.test(
|
||||||
|
style.overflow + style.overflowX + style.overflowY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollParent(node: Element): Element {
|
||||||
|
if (isScrollable(node)) {
|
||||||
|
node = node.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (node && !isScrollable(node)) {
|
||||||
|
node = node.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node || document.scrollingElement || document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML input types that do not cause the software keyboard to appear.
|
||||||
|
const nonTextInputTypes = new Set([
|
||||||
|
'checkbox',
|
||||||
|
'radio',
|
||||||
|
'range',
|
||||||
|
'color',
|
||||||
|
'file',
|
||||||
|
'image',
|
||||||
|
'button',
|
||||||
|
'submit',
|
||||||
|
'reset',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
|
||||||
|
let preventScrollCount = 0;
|
||||||
|
let restore: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevents scrolling on the document body on mount, and
|
||||||
|
* restores it on unmount. Also ensures that content does not
|
||||||
|
* shift due to the scrollbars disappearing.
|
||||||
|
*/
|
||||||
|
export function usePreventScroll(options: PreventScrollOptions = {}) {
|
||||||
|
let { isDisabled } = options;
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preventScrollCount++;
|
||||||
|
if (preventScrollCount === 1) {
|
||||||
|
if (isIOS()) {
|
||||||
|
restore = preventScrollMobileSafari();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
preventScrollCount--;
|
||||||
|
if (preventScrollCount === 0) {
|
||||||
|
restore?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isDisabled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile Safari is a whole different beast. Even with overflow: hidden,
|
||||||
|
// it still scrolls the page in many situations:
|
||||||
|
//
|
||||||
|
// 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.
|
||||||
|
// 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of
|
||||||
|
// it, so it becomes scrollable.
|
||||||
|
// 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.
|
||||||
|
// This may cause even fixed position elements to scroll off the screen.
|
||||||
|
// 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always
|
||||||
|
// scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.
|
||||||
|
//
|
||||||
|
// In order to work around these cases, and prevent scrolling without jankiness, we do a few things:
|
||||||
|
//
|
||||||
|
// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling
|
||||||
|
// on the window.
|
||||||
|
// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the
|
||||||
|
// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.
|
||||||
|
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
|
||||||
|
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
|
||||||
|
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
|
||||||
|
// into view ourselves, without scrolling the whole page.
|
||||||
|
// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
|
||||||
|
// same visually, but makes the actual scroll position always zero. This is required to make all of the
|
||||||
|
// above work or Safari will still try to scroll the page when focusing an input.
|
||||||
|
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
|
||||||
|
// to navigate to an input with the next/previous buttons that's outside a modal.
|
||||||
|
function preventScrollMobileSafari() {
|
||||||
|
let scrollable: Element;
|
||||||
|
let lastY = 0;
|
||||||
|
let onTouchStart = (e: TouchEvent) => {
|
||||||
|
// Store the nearest scrollable parent element from the element that the user touched.
|
||||||
|
scrollable = getScrollParent(e.target as Element);
|
||||||
|
if (
|
||||||
|
scrollable === document.documentElement &&
|
||||||
|
scrollable === document.body
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastY = e.changedTouches[0].pageY;
|
||||||
|
};
|
||||||
|
|
||||||
|
let onTouchMove = (e: TouchEvent) => {
|
||||||
|
// Prevent scrolling the window.
|
||||||
|
if (
|
||||||
|
!scrollable ||
|
||||||
|
scrollable === document.documentElement ||
|
||||||
|
scrollable === document.body
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent scrolling up when at the top and scrolling down when at the bottom
|
||||||
|
// of a nested scrollable area, otherwise mobile Safari will start scrolling
|
||||||
|
// the window instead. Unfortunately, this disables bounce scrolling when at
|
||||||
|
// the top but it's the best we can do.
|
||||||
|
let y = e.changedTouches[0].pageY;
|
||||||
|
let scrollTop = scrollable.scrollTop;
|
||||||
|
let bottom = scrollable.scrollHeight - scrollable.clientHeight;
|
||||||
|
|
||||||
|
if (bottom === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastY = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
let onTouchEnd = (e: TouchEvent) => {
|
||||||
|
let target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Apply this change if we're not already focused on the target element
|
||||||
|
if (isInput(target) && target !== document.activeElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Apply a transform to trick Safari into thinking the input is at the top of the page
|
||||||
|
// so it doesn't try to scroll it into view. When tapping on an input, this needs to
|
||||||
|
// be done before the "focus" event, so we have to focus the element ourselves.
|
||||||
|
target.style.transform = 'translateY(-2000px)';
|
||||||
|
target.focus();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
target.style.transform = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let onFocus = (e: FocusEvent) => {
|
||||||
|
let target = e.target as HTMLElement;
|
||||||
|
if (isInput(target)) {
|
||||||
|
// Transform also needs to be applied in the focus event in cases where focus moves
|
||||||
|
// other than tapping on an input directly, e.g. the next/previous buttons in the
|
||||||
|
// software keyboard. In these cases, it seems applying the transform in the focus event
|
||||||
|
// is good enough, whereas when tapping an input, it must be done before the focus event. 🤷♂️
|
||||||
|
target.style.transform = 'translateY(-2000px)';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
target.style.transform = '';
|
||||||
|
|
||||||
|
// This will have prevented the browser from scrolling the focused element into view,
|
||||||
|
// so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
|
||||||
|
if (visualViewport) {
|
||||||
|
if (visualViewport.height < window.innerHeight) {
|
||||||
|
// If the keyboard is already visible, do this after one additional frame
|
||||||
|
// to wait for the transform to be removed.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollIntoView(target);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Otherwise, wait for the visual viewport to resize before scrolling so we can
|
||||||
|
// measure the correct position to scroll to.
|
||||||
|
visualViewport.addEventListener(
|
||||||
|
'resize',
|
||||||
|
() => scrollIntoView(target),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let onWindowScroll = () => {
|
||||||
|
// Last resort. If the window scrolled, scroll it back to the top.
|
||||||
|
// It should always be at the top because the body will have a negative margin (see below).
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record the original scroll position so we can restore it.
|
||||||
|
// Then apply a negative margin to the body to offset it by the scroll position. This will
|
||||||
|
// enable us to scroll the window to the top, which is required for the rest of this to work.
|
||||||
|
let scrollX = window.pageXOffset;
|
||||||
|
let scrollY = window.pageYOffset;
|
||||||
|
|
||||||
|
let restoreStyles = chain(
|
||||||
|
setStyle(
|
||||||
|
document.documentElement,
|
||||||
|
'paddingRight',
|
||||||
|
`${window.innerWidth - document.documentElement.clientWidth}px`
|
||||||
|
)
|
||||||
|
// setStyle(document.documentElement, 'overflow', 'hidden'),
|
||||||
|
// setStyle(document.body, 'marginTop', `-${scrollY}px`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scroll to the top. The negative margin on the body will make this appear the same.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
let removeEvents = chain(
|
||||||
|
addEvent(document, 'touchstart', onTouchStart, {
|
||||||
|
passive: false,
|
||||||
|
capture: true,
|
||||||
|
}),
|
||||||
|
addEvent(document, 'touchmove', onTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
capture: true,
|
||||||
|
}),
|
||||||
|
addEvent(document, 'touchend', onTouchEnd, {
|
||||||
|
passive: false,
|
||||||
|
capture: true,
|
||||||
|
}),
|
||||||
|
addEvent(document, 'focus', onFocus, true),
|
||||||
|
addEvent(window, 'scroll', onWindowScroll)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Restore styles and scroll the page back to where it was.
|
||||||
|
restoreStyles();
|
||||||
|
removeEvents();
|
||||||
|
window.scrollTo(scrollX, scrollY);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets a CSS property on an element, and returns a function to revert it to the previous value.
|
||||||
|
function setStyle(
|
||||||
|
element: HTMLElement,
|
||||||
|
style: keyof React.CSSProperties,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310
|
||||||
|
// @ts-ignore
|
||||||
|
let cur = element.style[style];
|
||||||
|
// @ts-ignore
|
||||||
|
element.style[style] = value;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// @ts-ignore
|
||||||
|
element.style[style] = cur;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds an event listener to an element, and returns a function to remove it.
|
||||||
|
function addEvent<K extends keyof GlobalEventHandlersEventMap>(
|
||||||
|
target: EventTarget,
|
||||||
|
event: K,
|
||||||
|
handler: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
) {
|
||||||
|
// @ts-ignore
|
||||||
|
target.addEventListener(event, handler, options);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// @ts-ignore
|
||||||
|
target.removeEventListener(event, handler, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollIntoView(target: Element) {
|
||||||
|
let root = document.scrollingElement || document.documentElement;
|
||||||
|
while (target && target !== root) {
|
||||||
|
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
|
||||||
|
let scrollable = getScrollParent(target);
|
||||||
|
if (
|
||||||
|
scrollable !== document.documentElement &&
|
||||||
|
scrollable !== document.body &&
|
||||||
|
scrollable !== target
|
||||||
|
) {
|
||||||
|
let scrollableTop = scrollable.getBoundingClientRect().top;
|
||||||
|
let targetTop = target.getBoundingClientRect().top;
|
||||||
|
let targetBottom = target.getBoundingClientRect().bottom;
|
||||||
|
// Buffer is needed for some edge cases
|
||||||
|
const keyboardHeight =
|
||||||
|
scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
|
||||||
|
|
||||||
|
if (targetBottom > keyboardHeight) {
|
||||||
|
scrollable.scrollTop += targetTop - scrollableTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
target = scrollable.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInput(target: Element) {
|
||||||
|
return (
|
||||||
|
(target instanceof HTMLInputElement &&
|
||||||
|
!nonTextInputTypes.has(target.type)) ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
(target instanceof HTMLElement && target.isContentEditable)
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/serverActions/FindCopiesOnHoldRequest.ts
Normal file
56
src/serverActions/FindCopiesOnHoldRequest.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { getPayload, PaginatedDocs } from 'payload'
|
||||||
|
import config from '@/payload.config'
|
||||||
|
import { Copy } from '@/payload-types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bookId: number,
|
||||||
|
repositoryId: number
|
||||||
|
}
|
||||||
|
export const findCopiesOfBookInRepository = async (props: Props): Promise<PaginatedDocs<Copy> | null> => {
|
||||||
|
const { bookId, repositoryId } = props
|
||||||
|
|
||||||
|
const payloadConfig = await config
|
||||||
|
const payload = await getPayload({ config: payloadConfig })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const findResponse = (await payload.find({
|
||||||
|
collection: 'copies',
|
||||||
|
depth: 2,
|
||||||
|
limit: 25,
|
||||||
|
overrideAccess: false,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
'repository.id': {
|
||||||
|
equals: repositoryId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'book.id': {
|
||||||
|
equals: bookId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
label: true,
|
||||||
|
repository: true,
|
||||||
|
book: true,
|
||||||
|
holdRequests: true,
|
||||||
|
},
|
||||||
|
joins: {
|
||||||
|
holdRequests: {
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})) as PaginatedDocs<Copy>
|
||||||
|
|
||||||
|
return findResponse
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user