Skip to content

Commit

Permalink
feat(routes/show): add star-based score input
Browse files Browse the repository at this point in the history
This patch replaces the simple `<Input type='number' />` previously used
to submit scores with a star-based selector that uses a radio group and
`peer-` modifiers under the hood for CSS-only functionality.

Note that this selector will not work without JS, as the Radix radio
group component does not work without JS either. But the CSS-only
styling should be incredibly performant.
  • Loading branch information
nicholaschiang committed Jul 23, 2023
1 parent 557649f commit df8fdfb
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 9 deletions.
85 changes: 76 additions & 9 deletions app/routes/shows.$showId/rate-and-review.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useForm } from '@conform-to/react'
import { parse } from '@conform-to/zod'
import * as RadioGroup from '@radix-ui/react-radio-group'
import {
Form as RemixForm,
useActionData,
useNavigation,
} from '@remix-run/react'
import { type ActionArgs, json, redirect } from '@vercel/remix'
import { StarHalf } from 'lucide-react'
import * as React from 'react'
import { z } from 'zod'

import {
Expand All @@ -18,22 +21,25 @@ import {
FormMessage,
} from 'components/form'
import { Button } from 'components/ui/button'
import { Input } from 'components/ui/input'
import { Textarea } from 'components/ui/textarea'

import { prisma } from 'db.server'
import { log } from 'log.server'
import { getUserId } from 'session.server'
import { cn } from 'utils/cn'

import { Section } from './section'

const schema = z.object({
score: z.preprocess(
(score) => Number(score),
z
.number()
.lte(1, 'Score cannot be larger than 1.0')
.gte(0, 'Score cannot be negative'),
.number({
invalid_type_error: 'Please select a score',
required_error: 'Please select a score',
})
.max(5, 'Score cannot be larger than 5')
.min(0.5, 'Score cannot be less than 0.5'),
),
content: z.string().trim().min(1, 'Required').min(10, 'Too short'),
})
Expand All @@ -46,12 +52,11 @@ export async function action({ request, params }: ActionArgs) {
if (!submission.value || submission.intent !== 'submit')
return json(submission, { status: 400 })
const userId = await getUserId(request)
if (userId == null)
return redirect(`/login?redirectTo=/shows/${showId}#rate-and-review`)
if (userId == null) return redirect(`/login?redirectTo=/shows/${showId}`)
log.info('creating review... %o', submission.value)
const review = await prisma.review.create({
data: {
score: submission.value.score,
score: submission.value.score / 5,
content: submission.value.content,
author: { connect: { id: userId } },
show: { connect: { id: showId } },
Expand All @@ -61,6 +66,68 @@ export async function action({ request, params }: ActionArgs) {
return redirect(`/shows/${showId}`)
}

const ScoreInput = React.forwardRef<
React.ElementRef<typeof RadioGroup.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroup.Root>
>(({ className, ...props }, ref) => (
<RadioGroup.Root
className={cn(
'flex items-center flex-row-reverse group justify-end relative w-min',
className,
)}
{...props}
orientation='horizontal'
ref={ref}
>
{[5, 4, 3, 2, 1].map((value) => (
<Star value={value} key={value} />
))}
</RadioGroup.Root>
))
ScoreInput.displayName = RadioGroup.Root.displayName

function Star({ value }: { value: number }) {
return (
<>
<StarSide right value={value.toString()} />
<StarSide left value={(value - 0.5).toString()} />
</>
)
}

function StarSide({
left,
right,
value,
}: {
left?: boolean
right?: boolean
value: string
}) {
return (
<RadioGroup.Item
className={cn(
'overflow-hidden peer text-gray-300 dark:text-gray-600',
'group-hover:aria-checked:text-gray-300 dark:group-hover:aria-checked:text-gray-600 group-hover:peer-aria-checked:text-gray-300 dark:group-hover:peer-aria-checked:text-gray-600',
'aria-checked:text-gray-900 dark:aria-checked:text-gray-100 peer-aria-checked:text-gray-900 dark:peer-aria-checked:text-gray-100',
'hover:!text-gray-900 dark:hover:!text-gray-100 peer-hover:!text-gray-900 dark:peer-hover:!text-gray-100',
right && 'pr-0.5 first-of-type:pr-0',
left && 'pl-0.5 last-of-type:pl-0',
)}
value={value}
>
<RadioGroup.Indicator />
<StarHalf
className={cn(
'w-6 h-6',
right && '-ml-3 -scale-x-100',
left && '-mr-3',
)}
/>
</RadioGroup.Item>
)
}

export function RateAndReview() {
const lastSubmission = useActionData<typeof action>()
const [form, { score, content }] = useForm({
Expand All @@ -83,7 +150,7 @@ export function RateAndReview() {
{score.error && <FormMessage>{score.error}</FormMessage>}
</FormLabelWrapper>
<FormControl asChild>
<Input type='number' max={1} min={0} />
<ScoreInput required />
</FormControl>
</FormField>
<FormField name={content.name}>
Expand All @@ -92,7 +159,7 @@ export function RateAndReview() {
{content.error && <FormMessage>{content.error}</FormMessage>}
</FormLabelWrapper>
<FormControl asChild>
<Textarea />
<Textarea required />
</FormControl>
</FormField>
<FormSubmit asChild>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-portal": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.6",
"@remix-run/express": "^1.19.1",
Expand Down
100 changes: 100 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit df8fdfb

Please sign in to comment.