@bearmenu/ui 0.1.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-2CUFS5LF.js → chunk-2BMJRXQX.js} +1 -1
- package/dist/chunk-HLQ2IRE7.js +279 -0
- package/dist/chunk-IQASCH2V.js +25 -0
- package/dist/{chunk-UXAYXRFN.js → chunk-QJ54Z7TC.js} +15 -1
- package/dist/{chunk-X6GATUI2.js → chunk-UISA4CIG.js} +1 -0
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.d.ts +9 -7
- package/dist/components/calendar.js +153 -39
- package/dist/components/card.d.ts +2 -1
- package/dist/components/card.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/checkbox.js +3 -25
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/multi-select-menu.d.ts +26 -0
- package/dist/components/multi-select-menu.js +71 -0
- package/dist/components/pagination.js +1 -1
- package/dist/components/phone-frame.d.ts +17 -0
- package/dist/components/phone-frame.js +41 -0
- package/dist/components/place-about-tab.d.ts +17 -0
- package/dist/components/place-about-tab.js +6 -0
- package/dist/components/place-card.d.ts +17 -0
- package/dist/components/place-card.js +145 -0
- package/dist/components/place-details-mobile.d.ts +18 -0
- package/dist/components/place-details-mobile.js +187 -0
- package/dist/components/place-types.d.ts +145 -0
- package/dist/components/place-types.js +1 -0
- package/dist/components/search-bar.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.js +1 -1
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/sonner.js +11 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/toaster.js +1 -1
- package/package.json +28 -9
- package/src/components/accordion.stories.tsx +51 -0
- package/src/components/alert-dialog.stories.tsx +45 -0
- package/src/components/alert.stories.tsx +85 -0
- package/src/components/aspect-ratio.stories.tsx +47 -0
- package/src/components/avatar.stories.tsx +66 -0
- package/src/components/badge.stories.tsx +60 -0
- package/src/components/breadcrumb.stories.tsx +63 -0
- package/src/components/button.stories.tsx +115 -0
- package/src/components/button.tsx +1 -1
- package/src/components/calendar.stories.tsx +47 -0
- package/src/components/calendar.tsx +160 -40
- package/src/components/card.stories.tsx +72 -0
- package/src/components/card.tsx +13 -1
- package/src/components/carousel.stories.tsx +62 -0
- package/src/components/checkbox.stories.tsx +54 -0
- package/src/components/checkbox.tsx +1 -1
- package/src/components/collapsible.stories.tsx +34 -0
- package/src/components/command.stories.tsx +64 -0
- package/src/components/currency-input.stories.tsx +50 -0
- package/src/components/dialog.stories.tsx +75 -0
- package/src/components/drawer.stories.tsx +47 -0
- package/src/components/dropdown-menu.stories.tsx +64 -0
- package/src/components/dropdown-menu.tsx +1 -1
- package/src/components/empty-state.stories.tsx +67 -0
- package/src/components/form.stories.tsx +73 -0
- package/src/components/haptic-button.stories.tsx +41 -0
- package/src/components/input-otp.stories.tsx +42 -0
- package/src/components/input.stories.tsx +41 -0
- package/src/components/label.stories.tsx +35 -0
- package/src/components/markdown-renderer.stories.tsx +45 -0
- package/src/components/multi-select-combobox.stories.tsx +78 -0
- package/src/components/multi-select-menu.tsx +96 -0
- package/src/components/pagination.stories.tsx +48 -0
- package/src/components/phone-frame.stories.tsx +51 -0
- package/src/components/phone-frame.test.tsx +82 -0
- package/src/components/phone-frame.tsx +59 -0
- package/src/components/place-about-tab.stories.tsx +164 -0
- package/src/components/place-about-tab.test.tsx +325 -0
- package/src/components/place-about-tab.tsx +474 -0
- package/src/components/place-card.stories.tsx +120 -0
- package/src/components/place-card.test.tsx +187 -0
- package/src/components/place-card.tsx +187 -0
- package/src/components/place-details-mobile.tsx +251 -0
- package/src/components/place-types.ts +127 -0
- package/src/components/popover.stories.tsx +42 -0
- package/src/components/progress.stories.tsx +35 -0
- package/src/components/radio-group.stories.tsx +46 -0
- package/src/components/rating.stories.tsx +45 -0
- package/src/components/scroll-area.stories.tsx +48 -0
- package/src/components/search-bar.stories.tsx +92 -0
- package/src/components/searchable-select.stories.tsx +55 -0
- package/src/components/select.stories.tsx +92 -0
- package/src/components/separator.stories.tsx +33 -0
- package/src/components/sheet.stories.tsx +95 -0
- package/src/components/skeleton-card.stories.tsx +53 -0
- package/src/components/skeleton.stories.tsx +42 -0
- package/src/components/skeleton.tsx +1 -0
- package/src/components/slider.stories.tsx +27 -0
- package/src/components/sliding-panels.stories.tsx +68 -0
- package/src/components/sonner.tsx +17 -2
- package/src/components/sticky-container.stories.tsx +33 -0
- package/src/components/switch.stories.tsx +35 -0
- package/src/components/table.stories.tsx +60 -0
- package/src/components/tabs.stories.tsx +53 -0
- package/src/components/tabs.tsx +1 -1
- package/src/components/text.stories.tsx +82 -0
- package/src/components/textarea.stories.tsx +35 -0
- package/src/components/toast.stories.tsx +63 -0
- package/src/components/toggle-group.stories.tsx +60 -0
- package/src/components/toggle.stories.tsx +48 -0
- package/src/components/tooltip.stories.tsx +71 -0
- package/src/globals.css +14 -2
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Label } from "./label";
|
|
3
|
+
import { Input } from "./input";
|
|
4
|
+
import { Checkbox } from "./checkbox";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/Label",
|
|
8
|
+
component: Label,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof Label>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
args: { children: "Label" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const WithInput: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<div className="grid w-full max-w-sm gap-1.5">
|
|
22
|
+
<Label htmlFor="username">Username</Label>
|
|
23
|
+
<Input id="username" placeholder="Enter username" />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const WithCheckbox: Story = {
|
|
29
|
+
render: () => (
|
|
30
|
+
<div className="flex items-center gap-2">
|
|
31
|
+
<Checkbox id="terms" />
|
|
32
|
+
<Label htmlFor="terms">Accept terms and conditions</Label>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MarkdownRenderer } from "./markdown-renderer";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Components/MarkdownRenderer",
|
|
6
|
+
component: MarkdownRenderer,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
} satisfies Meta<typeof MarkdownRenderer>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<MarkdownRenderer
|
|
16
|
+
content={`# Heading 1
|
|
17
|
+
## Heading 2
|
|
18
|
+
### Heading 3
|
|
19
|
+
|
|
20
|
+
This is a paragraph with **bold** and *italic* text.
|
|
21
|
+
|
|
22
|
+
- List item 1
|
|
23
|
+
- List item 2
|
|
24
|
+
- List item 3
|
|
25
|
+
|
|
26
|
+
1. Ordered item
|
|
27
|
+
2. Another item
|
|
28
|
+
|
|
29
|
+
> This is a blockquote
|
|
30
|
+
|
|
31
|
+
\`inline code\` and a code block:
|
|
32
|
+
|
|
33
|
+
\`\`\`
|
|
34
|
+
const hello = "world";
|
|
35
|
+
\`\`\`
|
|
36
|
+
|
|
37
|
+
| Column 1 | Column 2 |
|
|
38
|
+
|----------|----------|
|
|
39
|
+
| Cell 1 | Cell 2 |
|
|
40
|
+
| Cell 3 | Cell 4 |
|
|
41
|
+
`}
|
|
42
|
+
className="prose dark:prose-invert max-w-none"
|
|
43
|
+
/>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { MultiSelectCombobox } from "./multi-select-combobox";
|
|
4
|
+
import { Label } from "./label";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/MultiSelectCombobox",
|
|
8
|
+
component: MultiSelectCombobox,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof MultiSelectCombobox>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
const cuisines = [
|
|
16
|
+
{ value: "italian", label: "Italian" },
|
|
17
|
+
{ value: "japanese", label: "Japanese" },
|
|
18
|
+
{ value: "mexican", label: "Mexican" },
|
|
19
|
+
{ value: "indian", label: "Indian" },
|
|
20
|
+
{ value: "thai", label: "Thai" },
|
|
21
|
+
{ value: "french", label: "French" },
|
|
22
|
+
{ value: "chinese", label: "Chinese" },
|
|
23
|
+
{ value: "korean", label: "Korean" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function MultiSelectDemo() {
|
|
27
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
28
|
+
return (
|
|
29
|
+
<div className="w-[350px] space-y-2">
|
|
30
|
+
<Label>Cuisines</Label>
|
|
31
|
+
<MultiSelectCombobox
|
|
32
|
+
options={cuisines}
|
|
33
|
+
selected={selected}
|
|
34
|
+
onSelectionChange={setSelected}
|
|
35
|
+
placeholder="Select cuisines..."
|
|
36
|
+
searchPlaceholder="Search cuisines..."
|
|
37
|
+
/>
|
|
38
|
+
<p className="text-sm text-muted-foreground">
|
|
39
|
+
Selected: {selected.length ? selected.join(", ") : "none"}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: () => <MultiSelectDemo />,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function PreselectedDemo() {
|
|
50
|
+
const [selected, setSelected] = useState(["italian", "japanese", "thai"]);
|
|
51
|
+
return (
|
|
52
|
+
<div className="w-[350px]">
|
|
53
|
+
<MultiSelectCombobox
|
|
54
|
+
options={cuisines}
|
|
55
|
+
selected={selected}
|
|
56
|
+
onSelectionChange={setSelected}
|
|
57
|
+
placeholder="Select cuisines..."
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Preselected: Story = {
|
|
64
|
+
render: () => <PreselectedDemo />,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Disabled: Story = {
|
|
68
|
+
render: () => (
|
|
69
|
+
<div className="w-[350px]">
|
|
70
|
+
<MultiSelectCombobox
|
|
71
|
+
options={cuisines}
|
|
72
|
+
selected={["italian"]}
|
|
73
|
+
onSelectionChange={() => {}}
|
|
74
|
+
disabled
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Checkbox } from "./checkbox";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
7
|
+
|
|
8
|
+
export interface MultiSelectMenuOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: React.ReactNode;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MultiSelectMenuProps {
|
|
15
|
+
options: MultiSelectMenuOption[];
|
|
16
|
+
selected: string[];
|
|
17
|
+
onSelectedChange: (values: string[]) => void;
|
|
18
|
+
/** Trigger element rendered via Radix `asChild` — typically a Button. */
|
|
19
|
+
trigger: React.ReactNode;
|
|
20
|
+
/** Optional heading rendered above the option list. */
|
|
21
|
+
label?: string;
|
|
22
|
+
align?: "start" | "center" | "end";
|
|
23
|
+
/** Pixel width of the popover content. Defaults to 220. */
|
|
24
|
+
width?: number;
|
|
25
|
+
/** className for the popover content. */
|
|
26
|
+
contentClassName?: string;
|
|
27
|
+
emptyText?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function MultiSelectMenu({
|
|
31
|
+
options,
|
|
32
|
+
selected,
|
|
33
|
+
onSelectedChange,
|
|
34
|
+
trigger,
|
|
35
|
+
label,
|
|
36
|
+
align = "end",
|
|
37
|
+
width = 220,
|
|
38
|
+
contentClassName,
|
|
39
|
+
emptyText = "No options",
|
|
40
|
+
}: MultiSelectMenuProps) {
|
|
41
|
+
const selectedSet = React.useMemo(() => new Set(selected), [selected]);
|
|
42
|
+
|
|
43
|
+
function handleToggle(value: string) {
|
|
44
|
+
const next = new Set(selectedSet);
|
|
45
|
+
if (next.has(value)) {
|
|
46
|
+
next.delete(value);
|
|
47
|
+
} else {
|
|
48
|
+
next.add(value);
|
|
49
|
+
}
|
|
50
|
+
onSelectedChange(Array.from(next));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Popover>
|
|
55
|
+
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
56
|
+
<PopoverContent
|
|
57
|
+
align={align}
|
|
58
|
+
className={cn("p-2", contentClassName)}
|
|
59
|
+
style={{ width }}
|
|
60
|
+
>
|
|
61
|
+
{label && (
|
|
62
|
+
<div className="text-foreground px-2 py-1.5 text-sm font-semibold">
|
|
63
|
+
{label}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
<div role="group" aria-label={label} className="flex flex-col gap-0.5">
|
|
67
|
+
{options.length === 0 ? (
|
|
68
|
+
<p className="text-muted-foreground px-2 py-1.5 text-sm">{emptyText}</p>
|
|
69
|
+
) : (
|
|
70
|
+
options.map((option) => {
|
|
71
|
+
const checked = selectedSet.has(option.value);
|
|
72
|
+
return (
|
|
73
|
+
<label
|
|
74
|
+
key={option.value}
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm select-none",
|
|
77
|
+
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
78
|
+
option.disabled && "pointer-events-none opacity-50",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<Checkbox
|
|
82
|
+
checked={checked}
|
|
83
|
+
onCheckedChange={() => handleToggle(option.value)}
|
|
84
|
+
disabled={option.disabled}
|
|
85
|
+
aria-label={typeof option.label === "string" ? option.label : option.value}
|
|
86
|
+
/>
|
|
87
|
+
<span className="flex-1">{option.label}</span>
|
|
88
|
+
</label>
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</PopoverContent>
|
|
94
|
+
</Popover>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import {
|
|
3
|
+
Pagination,
|
|
4
|
+
PaginationContent,
|
|
5
|
+
PaginationEllipsis,
|
|
6
|
+
PaginationItem,
|
|
7
|
+
PaginationLink,
|
|
8
|
+
PaginationNext,
|
|
9
|
+
PaginationPrevious,
|
|
10
|
+
} from "./pagination";
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Components/Pagination",
|
|
14
|
+
component: Pagination,
|
|
15
|
+
tags: ["autodocs"],
|
|
16
|
+
} satisfies Meta<typeof Pagination>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<Pagination>
|
|
24
|
+
<PaginationContent>
|
|
25
|
+
<PaginationItem>
|
|
26
|
+
<PaginationPrevious href="#" />
|
|
27
|
+
</PaginationItem>
|
|
28
|
+
<PaginationItem>
|
|
29
|
+
<PaginationLink href="#">1</PaginationLink>
|
|
30
|
+
</PaginationItem>
|
|
31
|
+
<PaginationItem>
|
|
32
|
+
<PaginationLink href="#" isActive>
|
|
33
|
+
2
|
|
34
|
+
</PaginationLink>
|
|
35
|
+
</PaginationItem>
|
|
36
|
+
<PaginationItem>
|
|
37
|
+
<PaginationLink href="#">3</PaginationLink>
|
|
38
|
+
</PaginationItem>
|
|
39
|
+
<PaginationItem>
|
|
40
|
+
<PaginationEllipsis />
|
|
41
|
+
</PaginationItem>
|
|
42
|
+
<PaginationItem>
|
|
43
|
+
<PaginationNext href="#" />
|
|
44
|
+
</PaginationItem>
|
|
45
|
+
</PaginationContent>
|
|
46
|
+
</Pagination>
|
|
47
|
+
),
|
|
48
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PhoneFrame } from "./phone-frame";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Components/PhoneFrame",
|
|
6
|
+
component: PhoneFrame,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
argTypes: {
|
|
9
|
+
width: { control: { type: "range", min: 160, max: 360, step: 10 } },
|
|
10
|
+
},
|
|
11
|
+
} satisfies Meta<typeof PhoneFrame>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
args: { width: 220 },
|
|
18
|
+
render: (args) => (
|
|
19
|
+
<PhoneFrame {...args}>
|
|
20
|
+
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-amber-100 to-amber-300 text-sm font-medium text-amber-950">
|
|
21
|
+
Mobile preview
|
|
22
|
+
</div>
|
|
23
|
+
</PhoneFrame>
|
|
24
|
+
),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const Wide: Story = {
|
|
28
|
+
args: { width: 320 },
|
|
29
|
+
render: (args) => (
|
|
30
|
+
<PhoneFrame {...args}>
|
|
31
|
+
<div className="flex h-full w-full items-center justify-center bg-slate-900 text-white">
|
|
32
|
+
320px wide
|
|
33
|
+
</div>
|
|
34
|
+
</PhoneFrame>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const ScrollableContent: Story = {
|
|
39
|
+
args: { width: 240 },
|
|
40
|
+
render: (args) => (
|
|
41
|
+
<PhoneFrame {...args}>
|
|
42
|
+
<div className="h-full w-full overflow-y-auto bg-white p-3">
|
|
43
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
44
|
+
<div key={i} className="border-b py-2 text-xs text-slate-700">
|
|
45
|
+
Item {i + 1}
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</PhoneFrame>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { PhoneFrame } from "./phone-frame";
|
|
4
|
+
|
|
5
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("PhoneFrame", () => {
|
|
8
|
+
it("renders children inside the frame", () => {
|
|
9
|
+
render(
|
|
10
|
+
<PhoneFrame>
|
|
11
|
+
<div data-testid="inner-content">Page content</div>
|
|
12
|
+
</PhoneFrame>
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByTestId("inner-content")).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText("Page content")).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("applies the default width of 220 when width prop is omitted", () => {
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<PhoneFrame>
|
|
21
|
+
<span>child</span>
|
|
22
|
+
</PhoneFrame>
|
|
23
|
+
);
|
|
24
|
+
// The outermost div receives the inline style width
|
|
25
|
+
const outerDiv = container.firstChild as HTMLElement;
|
|
26
|
+
expect(outerDiv.style.width).toBe("220px");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("applies a custom width when width prop is provided", () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<PhoneFrame width={320}>
|
|
32
|
+
<span>child</span>
|
|
33
|
+
</PhoneFrame>
|
|
34
|
+
);
|
|
35
|
+
const outerDiv = container.firstChild as HTMLElement;
|
|
36
|
+
expect(outerDiv.style.width).toBe("320px");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("calculates screen height from iPhone 17 Pro Max aspect ratio", () => {
|
|
40
|
+
// aspect = 956/440 ≈ 2.1727; width=220 → height = round(220 * 956/440) = round(477.8) = 478
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<PhoneFrame width={220}>
|
|
43
|
+
<span>child</span>
|
|
44
|
+
</PhoneFrame>
|
|
45
|
+
);
|
|
46
|
+
// The inner screen div has the computed height as inline style
|
|
47
|
+
const screenDiv = container.querySelector<HTMLElement>(".rounded-\\[2rem\\]");
|
|
48
|
+
expect(screenDiv).not.toBeNull();
|
|
49
|
+
expect(screenDiv!.style.height).toBe("478px");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not assign an ARIA role — the frame is purely presentational", () => {
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<PhoneFrame>
|
|
55
|
+
<span>child</span>
|
|
56
|
+
</PhoneFrame>
|
|
57
|
+
);
|
|
58
|
+
const outerDiv = container.firstChild as HTMLElement;
|
|
59
|
+
expect(outerDiv.getAttribute("role")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies a custom className to the outer container", () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<PhoneFrame className="my-custom-class">
|
|
65
|
+
<span>child</span>
|
|
66
|
+
</PhoneFrame>
|
|
67
|
+
);
|
|
68
|
+
const outerDiv = container.firstChild as HTMLElement;
|
|
69
|
+
expect(outerDiv.className).toContain("my-custom-class");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders multiple children correctly", () => {
|
|
73
|
+
render(
|
|
74
|
+
<PhoneFrame>
|
|
75
|
+
<div data-testid="a">A</div>
|
|
76
|
+
<div data-testid="b">B</div>
|
|
77
|
+
</PhoneFrame>
|
|
78
|
+
);
|
|
79
|
+
expect(screen.getByTestId("a")).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByTestId("b")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const SCREEN_ASPECT = 956 / 440; // iPhone 17 Pro Max
|
|
6
|
+
|
|
7
|
+
export interface PhoneFrameProps {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Outer frame width in pixels. Height derives from iPhone 17 Pro Max aspect. Default 220. */
|
|
11
|
+
width?: number;
|
|
12
|
+
/**
|
|
13
|
+
* When set, children are rendered at this virtual viewport width and CSS-scaled
|
|
14
|
+
* to fit the actual frame. Use this to render mobile UIs at native sizing
|
|
15
|
+
* (e.g. 390 for iPhone 13/14/15) while displaying inside a smaller frame.
|
|
16
|
+
*/
|
|
17
|
+
mobileViewportWidth?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const FRAME_PADDING = 6; // matches `p-1.5` (1.5 * 4 = 6px each side)
|
|
21
|
+
|
|
22
|
+
export function PhoneFrame({
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
width = 220,
|
|
26
|
+
mobileViewportWidth,
|
|
27
|
+
}: PhoneFrameProps) {
|
|
28
|
+
const screenHeight = Math.round(width * SCREEN_ASPECT);
|
|
29
|
+
const innerWidth = width - FRAME_PADDING * 2;
|
|
30
|
+
const scale = mobileViewportWidth ? innerWidth / mobileViewportWidth : 1;
|
|
31
|
+
const viewportHeight = mobileViewportWidth ? screenHeight / scale : screenHeight;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={cn("relative mx-auto shrink-0", className)} style={{ width }}>
|
|
35
|
+
<div className="rounded-[2.5rem] border-[1.5px] border-white/15 bg-black/80 p-1.5 shadow-2xl shadow-black/40">
|
|
36
|
+
<div className="absolute top-[10px] left-1/2 -translate-x-1/2 w-[68px] h-[20px] bg-black rounded-full z-10" />
|
|
37
|
+
<div
|
|
38
|
+
className="rounded-[2rem] overflow-hidden bg-background relative"
|
|
39
|
+
style={{ height: screenHeight }}
|
|
40
|
+
>
|
|
41
|
+
{mobileViewportWidth ? (
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
width: mobileViewportWidth,
|
|
45
|
+
height: viewportHeight,
|
|
46
|
+
transform: `scale(${scale})`,
|
|
47
|
+
transformOrigin: "top left",
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
children
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { PlaceAboutTab } from "./place-about-tab";
|
|
3
|
+
import type { PlaceAboutTabLabels, PlacePreview } from "./place-types";
|
|
4
|
+
|
|
5
|
+
const labels: PlaceAboutTabLabels = {
|
|
6
|
+
overview: "Overview",
|
|
7
|
+
description: "Description",
|
|
8
|
+
whatPeopleSay: "What people say",
|
|
9
|
+
contactAndLocation: "Contact & Location",
|
|
10
|
+
address: "Address",
|
|
11
|
+
instagram: "Instagram",
|
|
12
|
+
facebook: "Facebook",
|
|
13
|
+
todayHours: "Today's hours",
|
|
14
|
+
statusOpen: "Open now",
|
|
15
|
+
statusClosed: "Closed",
|
|
16
|
+
typicalBusyness: "Typical busyness",
|
|
17
|
+
cuisines: "Cuisines",
|
|
18
|
+
amenities: "Amenities",
|
|
19
|
+
paymentOptions: "Payment options",
|
|
20
|
+
accessibility: "Accessibility",
|
|
21
|
+
parking: "Parking",
|
|
22
|
+
nearbyLandmarks: "Nearby landmarks",
|
|
23
|
+
neighborhood: "Neighborhood",
|
|
24
|
+
businessInformation: "Business information",
|
|
25
|
+
type: "Type",
|
|
26
|
+
status: "Status",
|
|
27
|
+
amenityLabels: {
|
|
28
|
+
wifi: "Wi-Fi",
|
|
29
|
+
outdoor_seating: "Outdoor seating",
|
|
30
|
+
dine_in: "Dine in",
|
|
31
|
+
delivery: "Delivery",
|
|
32
|
+
},
|
|
33
|
+
paymentLabels: {
|
|
34
|
+
credit_card: "Credit card",
|
|
35
|
+
cash: "Cash",
|
|
36
|
+
},
|
|
37
|
+
accessibilityLabels: {
|
|
38
|
+
wheelchair_accessible_entrance: "Wheelchair accessible entrance",
|
|
39
|
+
},
|
|
40
|
+
parkingLabels: {
|
|
41
|
+
free_parking: "Free parking",
|
|
42
|
+
},
|
|
43
|
+
typeLabels: {
|
|
44
|
+
restaurant: "Restaurant",
|
|
45
|
+
cafe: "Cafe",
|
|
46
|
+
},
|
|
47
|
+
statusLabels: {
|
|
48
|
+
operational: "Operational",
|
|
49
|
+
closed_temporarily: "Closed temporarily",
|
|
50
|
+
closed_permanently: "Closed permanently",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const richPlace: PlacePreview = {
|
|
55
|
+
id: "1",
|
|
56
|
+
slug: "casa-gri",
|
|
57
|
+
name: "Casa Gri",
|
|
58
|
+
description: "A modern bistro serving European plates with a Mediterranean accent.",
|
|
59
|
+
generative_summary:
|
|
60
|
+
"Beloved bistro known for its seasonal small plates, natural wines, and warm patio.",
|
|
61
|
+
review_summary:
|
|
62
|
+
"Diners praise the friendly service and the lamb shoulder; some note that reservations are essential on weekends.",
|
|
63
|
+
open_now: true,
|
|
64
|
+
hours: [{ opens_at: 11, closes_at: 23 }],
|
|
65
|
+
location: { address: "Strada Mihai Eminescu 15, București" },
|
|
66
|
+
phone: "+40 21 555 0123",
|
|
67
|
+
website_url: "https://casagri.example.com",
|
|
68
|
+
socials: {
|
|
69
|
+
instagram_url: "https://instagram.com/casagri",
|
|
70
|
+
facebook_url: "https://facebook.com/casagri",
|
|
71
|
+
},
|
|
72
|
+
cuisines: ["mediterranean", "european", "wine bar"],
|
|
73
|
+
amenities: ["wifi", "outdoor_seating", "dine_in", "delivery"],
|
|
74
|
+
payment_options: ["credit_card", "cash"],
|
|
75
|
+
accessibility_features: ["wheelchair_accessible_entrance"],
|
|
76
|
+
parking_options: ["free_parking"],
|
|
77
|
+
landmarks: [
|
|
78
|
+
{ name: "Cișmigiu Park", distance_meters: 350 },
|
|
79
|
+
{ name: "Piața Universității", distance_meters: 1200 },
|
|
80
|
+
],
|
|
81
|
+
areas: [{ name: "Sector 1" }, { name: "Centru" }],
|
|
82
|
+
type: "restaurant",
|
|
83
|
+
business_status: "operational",
|
|
84
|
+
busyness: [
|
|
85
|
+
{ hour: 11, score: 20 },
|
|
86
|
+
{ hour: 12, score: 65 },
|
|
87
|
+
{ hour: 13, score: 90 },
|
|
88
|
+
{ hour: 14, score: 80 },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const meta = {
|
|
93
|
+
title: "Components/PlaceAboutTab",
|
|
94
|
+
component: PlaceAboutTab,
|
|
95
|
+
tags: ["autodocs"],
|
|
96
|
+
args: { labels, place: richPlace },
|
|
97
|
+
} satisfies Meta<typeof PlaceAboutTab>;
|
|
98
|
+
|
|
99
|
+
export default meta;
|
|
100
|
+
type Story = StoryObj<typeof meta>;
|
|
101
|
+
|
|
102
|
+
export const Default: Story = {
|
|
103
|
+
decorators: [
|
|
104
|
+
(Story) => (
|
|
105
|
+
<div className="w-[640px] mx-auto">
|
|
106
|
+
<Story />
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const Minimal: Story = {
|
|
113
|
+
args: {
|
|
114
|
+
place: {
|
|
115
|
+
id: "2",
|
|
116
|
+
slug: "tiny",
|
|
117
|
+
name: "Tiny Place",
|
|
118
|
+
description: "Just enough to be discoverable.",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
decorators: [
|
|
122
|
+
(Story) => (
|
|
123
|
+
<div className="w-[640px] mx-auto">
|
|
124
|
+
<Story />
|
|
125
|
+
</div>
|
|
126
|
+
),
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const WithSlots: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
mapSlot: (
|
|
133
|
+
<div className="h-[200px] rounded-lg bg-muted flex items-center justify-center text-muted-foreground text-sm">
|
|
134
|
+
[Map slot]
|
|
135
|
+
</div>
|
|
136
|
+
),
|
|
137
|
+
busynessSlot: (
|
|
138
|
+
<div className="h-[120px] rounded-lg bg-muted flex items-center justify-center text-muted-foreground text-sm">
|
|
139
|
+
[Busyness chart slot]
|
|
140
|
+
</div>
|
|
141
|
+
),
|
|
142
|
+
onLinkClick: (kind, url) => console.log("link click", kind, url),
|
|
143
|
+
},
|
|
144
|
+
decorators: [
|
|
145
|
+
(Story) => (
|
|
146
|
+
<div className="w-[640px] mx-auto">
|
|
147
|
+
<Story />
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const Closed: Story = {
|
|
154
|
+
args: {
|
|
155
|
+
place: { ...richPlace, open_now: false, business_status: "closed_temporarily" },
|
|
156
|
+
},
|
|
157
|
+
decorators: [
|
|
158
|
+
(Story) => (
|
|
159
|
+
<div className="w-[640px] mx-auto">
|
|
160
|
+
<Story />
|
|
161
|
+
</div>
|
|
162
|
+
),
|
|
163
|
+
],
|
|
164
|
+
};
|