base
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { CloseIcon } from "../icons/actions/close-icon";
|
||||
import { Popover } from "./root";
|
||||
|
||||
// cannot use satifies here because base-ui does not have portable types.
|
||||
const meta: Meta<typeof Popover> = {
|
||||
title: "Components/Popover",
|
||||
component: Popover,
|
||||
subcomponents: {
|
||||
PopoverButton: Popover.Button,
|
||||
PopoverPanel: Popover.Panel,
|
||||
},
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
children: null,
|
||||
open: undefined,
|
||||
onOpenChange: () => {},
|
||||
},
|
||||
render(args) {
|
||||
const [{ open }, updateArgs] = useArgs();
|
||||
const setOpen = (value: boolean | undefined) => updateArgs({ open: value });
|
||||
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Popover
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Popover Title</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This is the popover content. You can put any content here.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
render() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setOpen(true)} className="bg-blue-500 rounded-sm px-3 py-1.5 text-13 text-on-color">
|
||||
Open
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)} className="bg-gray-500 rounded-sm px-3 py-1.5 text-13 text-on-color">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Controlled Popover
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-13 font-semibold">Controlled State</h3>
|
||||
<button onClick={() => setOpen(false)} className="hover:bg-gray-100 rounded-full p-1">
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-2 text-13">Current state: {open ? "Open" : "Closed"}</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SideTop: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Above
|
||||
</Popover.Button>
|
||||
<Popover.Panel side="top" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Top Positioned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover appears above the button.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SideBottom: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Below
|
||||
</Popover.Button>
|
||||
<Popover.Panel side="bottom" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Bottom Positioned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover appears below the button.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SideLeft: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Left
|
||||
</Popover.Button>
|
||||
<Popover.Panel side="left" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Left Positioned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover appears to the left of the button.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SideRight: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Right
|
||||
</Popover.Button>
|
||||
<Popover.Panel side="right" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Right Positioned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover appears to the right of the button.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AlignStart: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Align Start
|
||||
</Popover.Button>
|
||||
<Popover.Panel align="start" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Start Aligned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover is aligned to the start.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AlignEnd: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Align End
|
||||
</Popover.Button>
|
||||
<Popover.Panel align="end" className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">End Aligned</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover is aligned to the end.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomOffset: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Custom Offset
|
||||
</Popover.Button>
|
||||
<Popover.Panel sideOffset={20} className="border-gray-200 shadow-lg w-64 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Custom Side Offset</h3>
|
||||
<p className="text-gray-600 mt-2 text-13">This popover has a custom side offset of 20px.</p>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithForm: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open ?? false);
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
alert("Form submitted!");
|
||||
setOpen(false);
|
||||
};
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Open Form
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="border-gray-200 shadow-lg w-72 rounded-lg border bg-white p-4">
|
||||
<h3 className="text-13 font-semibold">Quick Form</h3>
|
||||
<form onSubmit={handleSubmit} className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="name" className="text-gray-700 block text-11 font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
className="border-gray-300 mt-1 w-full rounded-sm border px-2 py-1.5 text-13"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="text-gray-700 block text-11 font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="border-gray-300 mt-1 w-full rounded-sm border px-2 py-1.5 text-13"
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 rounded-sm px-3 py-1.5 text-11"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-500 hover:bg-blue-600 rounded-sm px-3 py-1.5 text-11 text-on-color"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithList: Story = {
|
||||
render(args) {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
return (
|
||||
<Popover {...args} open={open} onOpenChange={setOpen}>
|
||||
<Popover.Button className="bg-blue-500 hover:bg-blue-600 rounded-sm px-4 py-2 text-on-color">
|
||||
Show Options
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="border-gray-200 shadow-lg w-56 rounded-lg border bg-white">
|
||||
<div className="p-2">
|
||||
<h3 className="text-gray-500 px-2 py-1.5 text-11 font-semibold">Options</h3>
|
||||
<button className="hover:bg-gray-100 w-full rounded-sm px-2 py-1.5 text-left text-13">Option 1</button>
|
||||
<button className="hover:bg-gray-100 w-full rounded-sm px-2 py-1.5 text-left text-13">Option 2</button>
|
||||
<button className="hover:bg-gray-100 w-full rounded-sm px-2 py-1.5 text-left text-13">Option 3</button>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorPicker: Story = {
|
||||
render() {
|
||||
const [selectedColor, setSelectedColor] = useState("#3b82f6");
|
||||
const colors = ["#ef4444", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280", "#000000", "#ffffff"];
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Button className="border-gray-300 hover:bg-gray-50 flex items-center gap-2 rounded-sm border bg-white px-4 py-2">
|
||||
<div className="h-4 w-4 rounded-sm" style={{ backgroundColor: selectedColor }} />
|
||||
<span className="text-13">Pick Color</span>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="border-gray-200 shadow-lg w-48 rounded-lg border bg-white p-3">
|
||||
<h3 className="mb-2 text-11 font-semibold">Select Color</h3>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className="h-8 w-8 rounded-sm border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor: selectedColor === color ? "#000" : "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { Popover as BasePopover } from "@base-ui-components/react/popover";
|
||||
import type { TPlacement, TSide, TAlign } from "../utils/placement";
|
||||
import { convertPlacementToSideAndAlign } from "../utils/placement";
|
||||
|
||||
export interface PopoverContentProps extends React.ComponentProps<typeof BasePopover.Popup> {
|
||||
placement?: TPlacement;
|
||||
align?: TAlign;
|
||||
sideOffset?: BasePopover.Positioner.Props["sideOffset"];
|
||||
side?: TSide;
|
||||
containerRef?: React.RefObject<HTMLElement>;
|
||||
positionerClassName?: string;
|
||||
}
|
||||
|
||||
// PopoverContent component
|
||||
const PopoverContent = memo(function PopoverContent({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
side = "bottom",
|
||||
align = "center",
|
||||
sideOffset = 8,
|
||||
containerRef,
|
||||
positionerClassName,
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
// side and align calculations
|
||||
const { finalSide, finalAlign } = useMemo(() => {
|
||||
if (placement) {
|
||||
const converted = convertPlacementToSideAndAlign(placement);
|
||||
return { finalSide: converted.side, finalAlign: converted.align };
|
||||
}
|
||||
return { finalSide: side, finalAlign: align };
|
||||
}, [placement, side, align]);
|
||||
|
||||
return (
|
||||
<PopoverPortal container={containerRef?.current}>
|
||||
<PopoverPositioner side={finalSide} sideOffset={sideOffset} align={finalAlign} className={positionerClassName}>
|
||||
<BasePopover.Popup data-slot="popover-content" className={className} {...props}>
|
||||
{children}
|
||||
</BasePopover.Popup>
|
||||
</PopoverPositioner>
|
||||
</PopoverPortal>
|
||||
);
|
||||
});
|
||||
|
||||
// wrapper components
|
||||
const PopoverTrigger = memo(function PopoverTrigger(props: React.ComponentProps<typeof BasePopover.Trigger>) {
|
||||
return <BasePopover.Trigger data-slot="popover-trigger" {...props} />;
|
||||
});
|
||||
|
||||
const PopoverPortal = memo(function PopoverPortal(props: React.ComponentProps<typeof BasePopover.Portal>) {
|
||||
return <BasePopover.Portal data-slot="popover-portal" {...props} />;
|
||||
});
|
||||
|
||||
const PopoverPositioner = memo(function PopoverPositioner(props: React.ComponentProps<typeof BasePopover.Positioner>) {
|
||||
return <BasePopover.Positioner data-slot="popover-positioner" {...props} />;
|
||||
});
|
||||
|
||||
// compound components
|
||||
const Popover = Object.assign(
|
||||
memo(function Popover(props: React.ComponentProps<typeof BasePopover.Root>) {
|
||||
return <BasePopover.Root data-slot="popover" {...props} />;
|
||||
}),
|
||||
{
|
||||
Button: PopoverTrigger,
|
||||
Panel: PopoverContent,
|
||||
}
|
||||
);
|
||||
|
||||
// display names
|
||||
PopoverContent.displayName = "PopoverContent";
|
||||
Popover.displayName = "Popover";
|
||||
PopoverPortal.displayName = "PopoverPortal";
|
||||
PopoverTrigger.displayName = "PopoverTrigger";
|
||||
PopoverPositioner.displayName = "PopoverPositioner";
|
||||
|
||||
export { Popover };
|
||||
Reference in New Issue
Block a user