diff --git a/package-lock.json b/package-lock.json index 32bb60b9..26b0d9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3640,6 +3640,36 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -7179,6 +7209,17 @@ "dev": true, "license": "MIT" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -7594,6 +7635,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -14309,6 +14359,15 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", @@ -15673,10 +15732,13 @@ "@popperjs/core": "^2.11.8", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "dayjs": "^1.11.13", "dompurify": "^3.2.6", "echarts": "^5.6.0", @@ -15694,6 +15756,7 @@ "react-quill-new": "^3.6.0", "react-resizable": "^3.0.5", "styled-components": "^6.1.19", + "tailwind-merge": "^2.4.0", "tailwindcss": "^4.1.11" }, "devDependencies": { diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index 6ad68e00..f24e13d0 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -43,9 +43,12 @@ "@popperjs/core": "^2.11.8", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", + "class-variance-authority": "^0.7.1", + "date-fns": "^3.6.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", "dompurify": "^3.2.6", @@ -64,7 +67,8 @@ "react-quill-new": "^3.6.0", "react-resizable": "^3.0.5", "styled-components": "^6.1.19", - "tailwindcss": "^4.1.11" + "tailwindcss": "^4.1.11", + "tailwind-merge": "^2.4.0" }, "devDependencies": { "@types/feather-icons": "^4.29.4", diff --git a/packages/frappe-ui-react/src/components/card/card.stories.tsx b/packages/frappe-ui-react/src/components/card/card.stories.tsx new file mode 100644 index 00000000..b564fa6c --- /dev/null +++ b/packages/frappe-ui-react/src/components/card/card.stories.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "./card"; +import { Button } from "../button"; + +export default { + title: "Components/Card", + component: Card, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Card component for grouping related content. Includes header, title, description, content, and footer.", + }, + }, + }, +}; + +export const Basic = () => ( + + + Card Title + This is a card description. + + +

This is the main content of the card.

+
+ + + +
+); diff --git a/packages/frappe-ui-react/src/components/card/card.tsx b/packages/frappe-ui-react/src/components/card/card.tsx new file mode 100644 index 00000000..55d0127c --- /dev/null +++ b/packages/frappe-ui-react/src/components/card/card.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; + +export const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +export const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +export const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; + +export const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; + +export const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +export const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; diff --git a/packages/frappe-ui-react/src/components/comments/comment.stories.tsx b/packages/frappe-ui-react/src/components/comments/comment.stories.tsx new file mode 100644 index 00000000..a6066531 --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/comment.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Comments from './comments'; +import type { CommentData } from './types'; + +const meta: Meta = { + title: 'Components/Comment System', + component: Comments, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; + +const initialComments: CommentData[] = [ + { + id: 1, + author: { + name: 'Kanchan Chauhan', + avatarUrl: 'https://i.pravatar.cc/40?img=1', + }, + timestamp: '1 month ago', + text: 'Hi @Devarshi Sathiya, did you check the assignment sent over by the candidate?', + replies: [ + { + id: 2, + author: { + name: 'Devarshi Sathiya', + avatarUrl: 'https://i.pravatar.cc/40?img=2', + }, + timestamp: '1 month ago', + text: 'Minor Update: Seems the assignment submission was not made properly adhering the guidelines, have asked the candidate to use our portal instead.', + replies: [ + { + id: 3, + author: { + name: 'Niraj Gautam', + avatarUrl: 'https://i.pravatar.cc/40?img=3', + }, + timestamp: '1 month ago', + text: 'Sure, works for me. Thanks!', + replies: [], + }, + ], + }, + ], + }, + { + id: 4, + author: { + name: 'Niraj Gautam', + avatarUrl: 'https://i.pravatar.cc/40?img=3', + }, + timestamp: '1 month ago', + text: 'Hey @Devarshi Sathiya, can you tell me what happened to my assignment submission? Seems to be pending for a while.', + replies: [ + { + id: 5, + author: { + name: 'Devarshi Sathiya', + avatarUrl: 'https://i.pravatar.cc/40?img=2', + }, + timestamp: '1 month ago', + text: "Yes, I am currently checking it. It seems to be well done, I'll submit over the full report by the end of the day.", + replies: [], + }, + ], + }, +]; + +export const FullThread: StoryObj = { + name: 'Full Interactive Comment Thread', + render: () => , +}; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/comments/comment.tsx b/packages/frappe-ui-react/src/components/comments/comment.tsx new file mode 100644 index 00000000..ab7d74f4 --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/comment.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useState } from "react"; +import CommentForm from "./commentForm"; +import type { CommentData } from "./types"; +import { Avatar } from "../avatar"; +import { Button } from "../button"; +import { MessageSquareText, ReplyIcon } from "lucide-react"; + +interface CommentProps { + comment: CommentData; + onAddReply: (parentId: number, text: string) => void; + handleEditComment: (id: number, text: string) => void; +} + +function parseMentions(text: string): React.ReactNode[] { + const mentionRegex = /(@\w+\s\w+)/g; + const parts = text.split(mentionRegex); + + return parts.map((part, index) => + mentionRegex.test(part) ? ( + + {part} + + ) : ( + <> + + + ) + ); +} + +function Comment({ comment, onAddReply, handleEditComment }: CommentProps) { + const [isReplying, setIsReplying] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const handleReplySubmit = useCallback((text: string): void => { + if (isReplying) { + onAddReply(comment.id, text); + setIsReplying(false); + }else{ + handleEditComment(comment.id, text); + setIsEditing(false); + } + }, [comment.id, handleEditComment, isReplying, onAddReply]); + + return ( +
+
+
+ + + {comment.author.name} commented +  ยท {comment.timestamp} + + +
+ + + +
+
+
+
+
+
+
+ {parseMentions(comment.text)} +
+
+
+
+ + {isReplying && ( +
+ +
+ )} + + {isEditing && ( +
+ +
+ )} + + {comment.replies && + comment.replies.length > 0 && + comment.replies.map((reply, index) => ( +
+
+
+ +
+
+ + +
+ ))} +
+ ); +} + +export default Comment; diff --git a/packages/frappe-ui-react/src/components/comments/commentForm.tsx b/packages/frappe-ui-react/src/components/comments/commentForm.tsx new file mode 100644 index 00000000..1c75a58b --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/commentForm.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useState } from 'react'; +import TextEditor from '../textEditor'; +interface CommentFormProps { + onSubmit: (text: string) => void; + buttonText: string; + value?: string; +} + +function CommentForm({ onSubmit, buttonText, value }: CommentFormProps) { + const [text, setText] = useState(value || ''); + const isButtonDisabled = text.length === 0; + + const handleSubmit = useCallback((e: React.FormEvent): void => { + e.preventDefault(); + if (isButtonDisabled){ + return; + } + onSubmit(text); + setText(''); + }, [isButtonDisabled, onSubmit, text]); + + return ( +
+ { + console.log(value.slice(3, -4)) + setText(value.slice(3, -4)) + } + } + /> + + + ); +} + +export default CommentForm; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/comments/comments.tsx b/packages/frappe-ui-react/src/components/comments/comments.tsx new file mode 100644 index 00000000..01ed2fce --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/comments.tsx @@ -0,0 +1,138 @@ +import { useCallback, useState } from "react"; +import { MessageCircle } from "lucide-react"; + +import Comment from "./comment"; +import CommentForm from "./commentForm"; +import type { CommentData } from "./types"; + +const CURRENT_USER = { + name: "Current User", + avatarUrl: "https://i.pravatar.cc/40?img=4", +}; + +function addReplyToTree( + nodes: CommentData[], + parentId: number, + newReply: CommentData +): CommentData[] { + return nodes.map((node) => { + if (node.id === parentId) { + return { + ...node, + replies: [...node.replies, newReply], + }; + } + + if (node.replies && node.replies.length > 0) { + return { + ...node, + replies: addReplyToTree(node.replies, parentId, newReply), + }; + } + return node; + }); +} +type CommentsProp = { + initialComments: CommentData[]; + onCommentAdded?: (comment: CommentData) => void; + onCommentEdited?: (comment: CommentData) => void; + onCommentReplied?: (comment: CommentData, parentId: number) => void; +}; + +function Comments({ + initialComments = [], + onCommentAdded, + onCommentEdited, + onCommentReplied, +}: CommentsProp) { + const [comments, setComments] = useState(initialComments); + + const handleAddComment = useCallback( + (text: string): void => { + const newComment: CommentData = { + id: Date.now(), + author: CURRENT_USER, + timestamp: "Just now", + text: text, + replies: [], + }; + setComments([...comments, newComment]); + if (onCommentAdded) { + onCommentAdded(newComment); + } + }, + [comments, onCommentAdded] + ); + + const handleEditComment = useCallback( + (id: number, text: string): void => { + setComments((currentComments) => { + return currentComments.map((comment) => { + if (comment.id === id) { + if (onCommentEdited) { + onCommentEdited({ ...comment, text }); + } + return { ...comment, text }; + } + return comment; + }); + }); + }, + [onCommentEdited] + ); + + const handleAddReply = useCallback( + (parentId: number, text: string): void => { + const newReply: CommentData = { + id: Date.now(), + author: CURRENT_USER, + timestamp: "Just now", + text: text, + replies: [], + }; + + if (onCommentReplied) { + onCommentReplied(newReply, parentId); + } + + setComments((currentComments) => + addReplyToTree(currentComments, parentId, newReply) + ); + }, + [onCommentReplied] + ); + + return ( +
+

Comments

+ + {comments.length === 0 && ( + + )} + +
+ {comments.map((comment, index) => ( +
+
+
+ +
+
+ +
+ ))} +
+
+ ); +} + +export default Comments; diff --git a/packages/frappe-ui-react/src/components/comments/index.ts b/packages/frappe-ui-react/src/components/comments/index.ts new file mode 100644 index 00000000..e4fd5ce8 --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/index.ts @@ -0,0 +1,2 @@ +export { default as Comments } from "./comments"; +export * from './types'; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/comments/types.ts b/packages/frappe-ui-react/src/components/comments/types.ts new file mode 100644 index 00000000..0c774495 --- /dev/null +++ b/packages/frappe-ui-react/src/components/comments/types.ts @@ -0,0 +1,12 @@ +export interface User { + name: string; + avatarUrl: string; +} + +export interface CommentData { + id: number; + author: User; + timestamp: string; + text: string; + replies: CommentData[]; +} \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/confirmationDialog/confirmationDialog.stories.tsx b/packages/frappe-ui-react/src/components/confirmationDialog/confirmationDialog.stories.tsx new file mode 100644 index 00000000..66a84211 --- /dev/null +++ b/packages/frappe-ui-react/src/components/confirmationDialog/confirmationDialog.stories.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ConfirmationDialog } from './'; +import { Button } from '../button'; + + +const meta: Meta = { + title: 'Components/ConfirmationDialog', + component: ConfirmationDialog, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + isOpen: { + control: 'boolean', + description: 'Controls if the dialog is open or closed.', + table: { + category: 'State', + }, + }, + isLoading: { + control: 'boolean', + description: 'Shows a loading spinner on the delete button.', + table: { + category: 'State', + }, + }, + title: { + control: 'text', + description: 'The main title of the dialog.', + table: { + category: 'Content', + }, + }, + description: { + control: 'text', + description: 'The body text/description of the dialog.', + table: { + category: 'Content', + }, + }, + onDelete: { + action: 'onDelete', + description: 'Callback fired when the "Delete" button is clicked.', + table: { + category: 'Events', + }, + }, + onCancel: { + action: 'onCancel', + description: + 'Callback fired when the "Cancel" button is clicked or the dialog is closed (e.g., by clicking overlay).', + table: { + category: 'Events', + }, + }, + }, + args: { + isOpen: false, + isLoading: false, + title: 'Are you absolutely sure?', + description: + 'This action cannot be undone. This will permanently delete this item and all of its associated data.', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Interactive Example', + render: (args) => { + const [isOpen, setIsOpen] = useState(args.isOpen || false); + const [isLoading, setIsLoading] = useState(args.isLoading || false); + + const handleCancel = () => { + setIsOpen(false); + setIsLoading(false); + args.onCancel?.(); + }; + + const handleDelete = () => { + setIsLoading(true); + args.onDelete?.(); + setTimeout(() => { + setIsLoading(false); + setIsOpen(false); + console.log('Item deleted'); + }, 2000); + }; + + return ( + <> + + + + ); + }, + + args: { + isOpen: false, + isLoading: false, + }, +}; + +export const DefaultOpen: Story = { + name: 'Open', + args: { + isOpen: true, + isLoading: false, + title: 'Confirm Deletion', + description: + 'Are you sure you want to delete this resource? This process is irreversible.', + }, +}; + +export const LoadingState: Story = { + name: 'Loading', + args: { + isOpen: true, + isLoading: true, + title: 'Deleting Resource...', + description: + 'Please wait while the resource is being deleted. This may take a moment.', + }, +}; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/confirmationDialog/index.tsx b/packages/frappe-ui-react/src/components/confirmationDialog/index.tsx new file mode 100644 index 00000000..53f2fe9c --- /dev/null +++ b/packages/frappe-ui-react/src/components/confirmationDialog/index.tsx @@ -0,0 +1,70 @@ +/** + * External dependencies. + */ +import { LoaderCircle, Trash2, X } from "lucide-react"; +/** + * External dependencies. + */ +import { Button } from "../button"; +import { Dialog } from "../dialog"; +/** + * The resource delete allocation alert dialog. + * + * Why not use react-alert-dialog? + * The above package was creating issues for form dynamic field selection, also it has bugs in recent versions: https://github.com/shadmergeClassNames-ui/ui/issues/1655 so for now I have used dialog only. + * + * @param props.onDelete The function to be called when delete dialog is clicked. + * @param props.isOpen The state to open the dialog. + * @param props.isLoading The state to show the loader. + * @param props.onOpen The function to open the dialog. + * @param props.onCancel The function to cancel the dialog. + * @returns React.FC + */ +export const ConfirmationDialog = ({ + onDelete, + isOpen, + isLoading, + onCancel, + title, + description, +}: { + onDelete: () => void; + isOpen: boolean; + isLoading: boolean; + onOpen: () => void; + onCancel: () => void; + buttonClassName?: string; + title: string; + description: string; +}) => { + return ( + !open && onCancel()} + options={{ + title, + message: description, + }} + actions={ +
+ + +
+ } + > +
+ ); +}; diff --git a/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx b/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx index c79b7934..a1ffb074 100644 --- a/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx +++ b/packages/frappe-ui-react/src/components/dialog/dialog.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import Dialog from "./dialog"; import { Button } from "../button"; import { Dropdown } from "../dropdown"; -import { Autocomplete, AutocompleteOption } from "../autoComplete"; -import { DialogOptions } from "./types"; +import { Autocomplete, type AutocompleteOption } from "../autoComplete"; +import { type DialogOptions } from "./types"; const meta: Meta = { title: "Components/Dialog", diff --git a/packages/frappe-ui-react/src/components/dialog/types.ts b/packages/frappe-ui-react/src/components/dialog/types.ts index 36da537e..dd1af43f 100644 --- a/packages/frappe-ui-react/src/components/dialog/types.ts +++ b/packages/frappe-ui-react/src/components/dialog/types.ts @@ -34,11 +34,13 @@ export interface DialogOptions { name: string; appearance?: "info" | "success" | "warning" | "danger"; }; + extraDialogPositionClasses?: string; actions?: DialogAction[]; } export interface DialogProps { open: boolean; + sheetClasses?: string; onOpenChange: (open: boolean) => void; options?: DialogOptions; disableOutsideClickToClose?: boolean; diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx index c0d0913d..179f76a9 100644 --- a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx +++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx @@ -3,7 +3,7 @@ import { action } from "storybook/actions"; import Dropdown from "./dropdown"; import { Button } from "../button"; -import { DropdownOptions } from "./types"; +import type { DropdownOptions } from "./types"; export default { title: "Components/Dropdown", diff --git a/packages/frappe-ui-react/src/components/hoverCard/index.tsx b/packages/frappe-ui-react/src/components/hoverCard/index.tsx new file mode 100644 index 00000000..cd33559d --- /dev/null +++ b/packages/frappe-ui-react/src/components/hoverCard/index.tsx @@ -0,0 +1,28 @@ +/* + * External dependencies. + */ +import { Root, Trigger, Content, type HoverCardContentProps } from "@radix-ui/react-hover-card"; + +/** + * Internal dependencies. + */ +import { mergeClassNames } from "../../utils"; + +const HoverCard = Root; + +const HoverCardTrigger = Trigger; + +const HoverCardContent = ({ className = "", align = "center", sideOffset = 4, ...props }: HoverCardContentProps, ref: React.Ref) => ( + +); + +export { HoverCard, HoverCardTrigger, HoverCardContent }; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index dc7053e8..740334d6 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -47,3 +47,5 @@ export { export { default as keyboardShortcut } from "./keyboardShortcut"; export { default as LoadingIndicator } from "./loadingIndicator"; export { default as LoadingText } from "./loadingText"; +export * from './sheet'; +export * from './comments'; diff --git a/packages/frappe-ui-react/src/components/sheet/index.tsx b/packages/frappe-ui-react/src/components/sheet/index.tsx new file mode 100644 index 00000000..f640f822 --- /dev/null +++ b/packages/frappe-ui-react/src/components/sheet/index.tsx @@ -0,0 +1,110 @@ +/** + * External dependencies. + */ +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { mergeClassNames } from "../../utils"; + +const sheetVariants = cva( + "fixed z-51 gap-4 bg-surface-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const Sheet = SheetPrimitive.Root; +const SheetTrigger = SheetPrimitive.Trigger; +const SheetClose = SheetPrimitive.Close; + +const SheetContent =({ side = "right", className, children, ...props }: SheetContentProps, ref?: React.Ref) => ( + + + + {children} + + + Close + + + +); + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); + +const SheetTitle = ({ className, ...props }: SheetPrimitive.DialogTitleProps, ref?: React.Ref) => ( + +); + +const SheetDescription = ({ className, ...props }: SheetPrimitive.DialogDescriptionProps, ref?: React.Ref) => ( + +); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/sheet/sheet.stories.tsx b/packages/frappe-ui-react/src/components/sheet/sheet.stories.tsx new file mode 100644 index 00000000..c9716f0e --- /dev/null +++ b/packages/frappe-ui-react/src/components/sheet/sheet.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; + +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, +} from './'; +import { Button } from '../button'; +import FormLabel from '../formLabel'; +import { TextInput } from '../textInput'; + +const meta: Meta = { + title: 'Components/Sheet', + component: SheetContent, + tags: ['autodocs'], + argTypes: { + side: { + control: 'radio', + options: ['top', 'bottom', 'left', 'right'], + description: 'Which side the sheet appears from.', + }, + }, + parameters: { + component: null, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default (Right Side)', + args: { + side: 'right', + }, + render: (args) => ( + + + + + + + Edit Profile + + Make changes to your profile here. Click save when you're done. + + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ ), +}; + + +export const LeftSide: Story = { + ...Default, + name: 'From Left', + args: { + side: 'left', + }, +}; + +export const TopSide: Story = { + ...Default, + name: 'From Top', + args: { + side: 'top', + }, +}; + +export const BottomSide: Story = { + ...Default, + name: 'From Bottom', + args: { + side: 'bottom', + }, +}; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/components/taskStatus/index.tsx b/packages/frappe-ui-react/src/components/taskStatus/index.tsx new file mode 100644 index 00000000..22373ed2 --- /dev/null +++ b/packages/frappe-ui-react/src/components/taskStatus/index.tsx @@ -0,0 +1,39 @@ +const statusVariants: Record = { + Open: "bg-blue-100 text-blue-500 hover:bg-blue-200", + Working: "bg-orange-500/20 text-orange-500 hover:bg-orange-500/30", + "Pending Review": "bg-orange-100 text-orange-400 hover:bg-orange-500/20", + Overdue: "bg-red-500/20 text-red-500 hover:bg-red-500/20", + Template: "bg-slate-200 text-slate-900 hover:bg-slate-200", + Completed: "bg-green-600/20 text-green-600 hover:bg-green-600/20", + Cancelled: "bg-red-500/20 text-red-500 hover:bg-red-500/20", +}; + +export type TaskStatusProps = + | "Open" + | "Working" + | "Pending Review" + | "Overdue" + | "Template" + | "Completed" + | "Cancelled"; + +interface TaskStatusComponentProps { + status: TaskStatusProps; +} + +const TaskStatus = ({ status }: TaskStatusComponentProps) => { + const variantClass = statusVariants[status] || statusVariants["Open"]; + + return ( +
+ {status} +
+ ); +}; + +export default TaskStatus; diff --git a/packages/frappe-ui-react/src/components/taskStatus/taskStatus.stories.tsx b/packages/frappe-ui-react/src/components/taskStatus/taskStatus.stories.tsx new file mode 100644 index 00000000..012fde5e --- /dev/null +++ b/packages/frappe-ui-react/src/components/taskStatus/taskStatus.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TaskStatus, { type TaskStatusProps } from "./index"; + +export default { + title: "Components/TaskStatus", + component: TaskStatus, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + status: { + control: { + type: "select", + options: [ + "Open", + "Working", + "Pending Review", + "Overdue", + "Template", + "Completed", + "Cancelled", + ], + }, + description: "Status variant for the task", + }, + }, +} as Meta; + +const Template: StoryObj<{ status: TaskStatusProps }> = { + render: (args) => ( +
+ +
+ ), +}; + +export const Open = { + ...Template, + args: { status: "Open" }, +}; +export const Working = { + ...Template, + args: { status: "Working" }, +}; +export const PendingReview = { + ...Template, + args: { status: "Pending Review" }, +}; +export const Overdue = { + ...Template, + args: { status: "Overdue" }, +}; +export const TemplateStatus = { + ...Template, + args: { status: "Template" }, +}; +export const Completed = { + ...Template, + args: { status: "Completed" }, +}; +export const Cancelled = { + ...Template, + args: { status: "Cancelled" }, +}; + +export const AllVariants = { + render: () => ( +
+ {[ + "Open", + "Working", + "Pending Review", + "Overdue", + "Template", + "Completed", + "Cancelled", + ].map((status) => ( + + ))} +
+ ), +}; diff --git a/packages/frappe-ui-react/src/components/textEditor/index.tsx b/packages/frappe-ui-react/src/components/textEditor/index.tsx index ff4a03ab..bc2c9c83 100644 --- a/packages/frappe-ui-react/src/components/textEditor/index.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/index.tsx @@ -25,6 +25,7 @@ export interface TextEditorProps extends ReactQuill.ReactQuillProps { onChange: (value: string) => void; value?: string; placeholder?: string; + editingAreaRef?: React.RefObject; } Quill.register("modules/imageResize", ImageResize); diff --git a/packages/frappe-ui-react/src/components/textarea/textarea.tsx b/packages/frappe-ui-react/src/components/textarea/textarea.tsx index 453676eb..f33a36b6 100644 --- a/packages/frappe-ui-react/src/components/textarea/textarea.tsx +++ b/packages/frappe-ui-react/src/components/textarea/textarea.tsx @@ -16,6 +16,8 @@ const Textarea = forwardRef( rows = 3, htmlId, placeholder, + extraClasses = '', + ...props }, ref ) => { @@ -62,7 +64,7 @@ const Textarea = forwardRef( const textColor = disabled ? "text-ink-gray-5" : "text-ink-gray-8"; - return `resize-y transition-colors w-full block outline-none ${sizeClasses} ${paddingClasses} ${variantClasses} ${textColor}`; + return `resize-y transition-colors w-full block outline-none ${sizeClasses} ${paddingClasses} ${variantClasses} ${textColor} ${extraClasses}`; }, [size, disabled, variant]); const labelClasses = useMemo(() => { @@ -117,7 +119,8 @@ const Textarea = forwardRef( id={htmlId} value={value} onChange={handleChange} - data-testid="textarea" + data-testid="textarea" + {...props} />
); diff --git a/packages/frappe-ui-react/src/components/textarea/types.ts b/packages/frappe-ui-react/src/components/textarea/types.ts index 05ab9b69..4d6af6e9 100644 --- a/packages/frappe-ui-react/src/components/textarea/types.ts +++ b/packages/frappe-ui-react/src/components/textarea/types.ts @@ -12,5 +12,7 @@ export interface TextareaProps { debounce?: number; rows?: number; onChange?: (event: React.ChangeEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + extraClasses?: string; htmlId?: string; } diff --git a/packages/frappe-ui-react/src/components/typography/index.stories.tsx b/packages/frappe-ui-react/src/components/typography/index.stories.tsx new file mode 100644 index 00000000..63c0bdc2 --- /dev/null +++ b/packages/frappe-ui-react/src/components/typography/index.stories.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import Typography from "./index"; + +export default { + title: "Components/Typography", + component: Typography, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Typography component for headings, paragraphs, and text variants. Supports custom tags and class names.", + }, + }, + }, + args: { + variant: "p", + children: "Sample text", + className: "", + as: undefined, + }, + argTypes: { + variant: { + control: "select", + options: [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "large", + "small", + "muted", + ], + description: "Typography variant (heading, paragraph, etc)", + }, + children: { + control: "text", + description: "Content to render inside the typography tag.", + }, + className: { + control: "text", + description: "Additional CSS classes.", + }, + as: { + control: "text", + description: "Custom tag to render (e.g. 'div').", + }, + }, +}; + +export const Headings = () => ( + <> + Heading 1 + Heading 2 + Heading 3 + Heading 4 + Heading 5 + Heading 6 + +); + +export const Paragraphs = () => ( + <> + This is a paragraph. + This is a large paragraph. + This is a muted paragraph. + This is a small text. + +); + +export const CustomTag = () => ( + + Custom Tag Example (div) + +); diff --git a/packages/frappe-ui-react/src/components/typography/index.tsx b/packages/frappe-ui-react/src/components/typography/index.tsx new file mode 100644 index 00000000..b927aa68 --- /dev/null +++ b/packages/frappe-ui-react/src/components/typography/index.tsx @@ -0,0 +1,70 @@ +import React, { ElementType, HTMLAttributes } from "react"; + +type Variant = + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "p" + | "large" + | "small" + | "muted"; + +export type TypographyProps = { + variant?: Variant; + children: React.ReactNode; + className?: string; + as?: ElementType; +}; + +const tags: Record = { + h1: "h1", + h2: "h2", + h3: "h3", + h4: "h4", + h5: "h5", + h6: "h6", + p: "p", + large: "p", + muted: "p", + small: "span", +}; + +const sizes: Record = { + h1: "text-3xl font-bold ", + h2: "text-2xl font-bold", + h3: "text-xl font-bold ", + h4: "text-lg font-bold ", + h5: "text-md font-bold", + h6: "text-base font-bold ", + p: "text-sm font-normal", + large: "text-lg sm:text-md font-bold", + muted: "text-muted-foreground text-sm font-normal", + small: "text-xs font-normal", +}; + +const Typography = ({ + variant = "p", + children, + className, + as, + ...props +}: TypographyProps & HTMLAttributes) => { + const sizeClasses = sizes[variant]; + const Tag = as || tags[variant]; + + return ( + + {children} + + ); +}; + +export default Typography; diff --git a/packages/frappe-ui-react/src/utils/date.ts b/packages/frappe-ui-react/src/utils/date.ts new file mode 100644 index 00000000..faae9ffd --- /dev/null +++ b/packages/frappe-ui-react/src/utils/date.ts @@ -0,0 +1,153 @@ +import { + format, + formatISO, + isToday, + isYesterday, + parseISO, + startOfWeek, + differenceInMinutes, + differenceInHours, + differenceInDays, +} from "date-fns"; + +export const Months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +export const getUTCDateTime = (date: string | Date = ""): Date => { + if (!date) { + return parseISO(formatISO(new Date(), { representation: "complete" })); + } + if (typeof date == "string") { + date = new Date(date + "T00:00:00"); + } + return date; +}; + +export const getNextDate = (startDate: string, weeks: number) => { + const start = startOfWeek(getUTCDateTime(startDate), { + weekStartsOn: 1, + }); + + start.setDate(start.getDate() + weeks * 7); + + return getFormatedDate(start); +}; + +export const getMonthKey = (dateString: string) => { + const date = getUTCDateTime(dateString); + return `${Months[date.getMonth()]} ${ + date.getDate() < 10 ? "0" + date.getDate() : date.getDate() + }`; +}; + +export const getMonthYearKey = (dateString: string) => { + const date = getUTCDateTime(dateString); + return `${Months[date.getMonth()]} ${date.getFullYear()}`; +}; + +export const getDayDiff = (startString: string, endString: string): number => { + const start = getUTCDateTime(startString); + const end = getUTCDateTime(endString); + + return Math.abs(end.getTime() - start.getTime()) / (1000 * 3600 * 24); +}; + +export const checkInRange = ( + start: string, + weeks: number, + dateString: string +) => { + const startDate = getFormatedDate( + startOfWeek(getUTCDateTime(start), { + weekStartsOn: 1, + }) + ); + + const endDate = getNextDate(startDate, weeks); + return startDate <= dateString && dateString <= endDate; +}; + +export const getFormatedDate = (date: string | Date): string => { + date = getUTCDateTime(date); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export const getTodayDate = (): string => { + const today = new Date(); + return getFormatedDate(today); +}; + +export const prettyDate = (dateString: string, isLong: boolean = false) => { + const date = getUTCDateTime(dateString); + const month = date.toLocaleString("default", { month: "short" }); + const dayOfMonth = date.getDate(); + const dayOfWeek = date.toLocaleString("default", { + weekday: !isLong ? "short" : "long", + }); + return { date: `${month} ${dayOfMonth}`, day: dayOfWeek }; +}; + +export const getDateFromDateAndTimeString = ( + dateTimeString: string +): string => { + // Split the date and time parts exa: '2024-05-08 00:00:00' + const parts = dateTimeString.split(" "); + return parts[0]; +}; + +export const normalizeDate = (date: string): string => { + const parts = date.split("-"); + if (parts.length === 3) { + let [year, month, day] = parts; + if (year.length < 4) { + year = getUTCDateTime().getFullYear().toString(); + } + if (month.length === 1) { + month = `0${month}`; + } + if (day.length === 1) { + day = `0${day}`; + } + return `${year}-${month}-${day}`; + } + return getFormatedDate(getUTCDateTime()); +}; + +export const getDisplayDate = (date: Date): string => { + const utcDate = getUTCDateTime(date); + if (isToday(utcDate)) return "Today"; + if (isYesterday(utcDate)) return "Yesterday"; + return format(utcDate, "MMM d"); +}; + +export const formatDate = (date: string | Date): string => { + const dateObj = typeof date === "string" ? new Date(date) : date; + const now = new Date(); + + const diffMins = differenceInMinutes(now, dateObj); + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = differenceInHours(now, dateObj); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = differenceInDays(now, dateObj); + if (diffDays < 7) return `${diffDays}d ago`; + + return format(dateObj, "P"); +}; \ No newline at end of file diff --git a/packages/frappe-ui-react/src/utils/index.ts b/packages/frappe-ui-react/src/utils/index.ts index 930359c2..3a2a7573 100644 --- a/packages/frappe-ui-react/src/utils/index.ts +++ b/packages/frappe-ui-react/src/utils/index.ts @@ -2,3 +2,5 @@ export { default as noop } from "./noop"; export * from "./debounce"; export * from "./fileUploadHandler"; export * from "./htmlAttrsToJsx"; +export * from './mergeClassnames'; +export * from './date'; diff --git a/packages/frappe-ui-react/src/utils/mergeClassnames.ts b/packages/frappe-ui-react/src/utils/mergeClassnames.ts new file mode 100644 index 00000000..086f326c --- /dev/null +++ b/packages/frappe-ui-react/src/utils/mergeClassnames.ts @@ -0,0 +1,6 @@ +import { twMerge } from "tailwind-merge"; +import { type ClassValue, clsx} from "clsx"; + +export function mergeClassNames(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file