mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add brewery discovery & detail pages with location-based search (#253)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import SidebarCard from './SidebarCard';
|
||||
|
||||
const sidebarCardDescription = `Shared card shell used by the brewery detail page's sidebar sections (Location, Details, Beer styles, Community) — an uppercase eyebrow label over arbitrary content.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Breweries/SidebarCard',
|
||||
component: SidebarCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: sidebarCardDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SidebarCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Details',
|
||||
children: <p className="text-sm m-0">Founded 2014</p>,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Details')).toBeInTheDocument();
|
||||
await expect(canvas.getByText('Founded 2014')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface SidebarCardProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body gap-3 p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
|
||||
{title}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SidebarCard;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import StarRating from './StarRating';
|
||||
|
||||
const starRatingDescription = `Dual-purpose star control built on daisyUI's \`rating\` component. Read-only (no \`onChange\`) when used for an average or a display rating; interactive otherwise — clicking the currently-selected star clears the rating, since daisyUI radios can't do that natively.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Breweries/StarRating',
|
||||
component: StarRating,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: starRatingDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof StarRating>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: { value: 4, size: 'sm' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole('radio')).not.toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText('4 out of 5 stars')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: { value: 0, size: 'lg', onChange: fn() },
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
|
||||
await expect(args.onChange).toHaveBeenCalledWith(3);
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractiveClearsOnRepeatClick: Story = {
|
||||
args: { value: 3, size: 'lg', onChange: fn() },
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
|
||||
await expect(args.onChange).toHaveBeenCalledWith(0);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
onChange?: (value: number) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<NonNullable<StarRatingProps['size']>, string> = {
|
||||
xs: 'rating-xs',
|
||||
sm: 'rating-sm',
|
||||
md: 'rating-md',
|
||||
lg: 'rating-lg',
|
||||
xl: 'rating-xl',
|
||||
};
|
||||
|
||||
/** Read-only when `onChange` is omitted, interactive otherwise. Clicking the
|
||||
* currently-selected star clears the rating (daisyUI radios can't do this natively). */
|
||||
const StarRating = ({ value, max = 5, size = 'sm', onChange, label }: StarRatingProps) => {
|
||||
const name = useId();
|
||||
const stars = Array.from({ length: max }, (_, i) => i + 1);
|
||||
|
||||
if (!onChange) {
|
||||
return (
|
||||
<div
|
||||
className={`rating ${SIZE_CLASS[size]}`}
|
||||
aria-label={label ?? `${value} out of ${max} stars`}
|
||||
>
|
||||
{stars.map((n) => (
|
||||
<div
|
||||
key={n}
|
||||
className="mask mask-star bg-warning"
|
||||
aria-label={`${n} star`}
|
||||
aria-current={n === value ? 'true' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rating ${SIZE_CLASS[size]}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
className="rating-hidden"
|
||||
checked={value === 0}
|
||||
onChange={() => onChange(0)}
|
||||
aria-label="Clear rating"
|
||||
/>
|
||||
{stars.map((n) => (
|
||||
<input
|
||||
key={n}
|
||||
type="radio"
|
||||
name={name}
|
||||
className="mask mask-star bg-warning"
|
||||
checked={value === n}
|
||||
onChange={() => onChange(n)}
|
||||
onClick={() => {
|
||||
if (value === n) onChange(0);
|
||||
}}
|
||||
aria-label={`Rate ${n} of ${max}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarRating;
|
||||
Reference in New Issue
Block a user