diff --git a/app/registry/components.py b/app/registry/components.py index e842486..c814738 100644 --- a/app/registry/components.py +++ b/app/registry/components.py @@ -37,6 +37,10 @@ "files": ["components/ui/accordion.py"], "dependencies": ["button", "hugeicon", "base_ui"], }, + "attachment": { + "files": ["components/ui/attachment.py"], + "dependencies": ["button", "twmerge"], + }, "autocomplete": { "files": ["components/ui/autocomplete.py"], "dependencies": ["twmerge"], @@ -53,6 +57,10 @@ "files": ["components/ui/breadcrumb.py"], "dependencies": [], }, + "bubble": { + "files": ["components/ui/bubble.py"], + "dependencies": ["twmerge"], + }, "button": { "files": ["components/ui/button.py"], "dependencies": ["others_icons", "component"], @@ -117,6 +125,10 @@ "files": ["components/ui/metric.py"], "dependencies": ["twmerge", "component"], }, + "message": { + "files": ["components/ui/message.py"], + "dependencies": ["twmerge"], + }, "popover": { "files": ["components/ui/popover.py"], "dependencies": ["twmerge", "base_ui"], diff --git a/app/templates/docsidebar.py b/app/templates/docsidebar.py index a23e1a2..b13ed51 100644 --- a/app/templates/docsidebar.py +++ b/app/templates/docsidebar.py @@ -28,6 +28,8 @@ } """ +NEW_COMP = ["Attachment", "Bubble", "Marker", "Message", "Shimmer", "Scroll Fade"] + @dataclass class SidebarSection: @@ -49,7 +51,7 @@ class SidebarSection: def create_menu_item(data: dict): """Create a single menu item.""" - return button( + link = ( rx.el.a( rx.el.p(data["title"], class_name="cursor-pointer"), to=f"/{data['url']}", @@ -61,14 +63,45 @@ def create_menu_item(data: dict): href=f"/{data['url']}", text_decoration="none", reload_document=True, + ) + ) + + return button( + link, + rx.cond( + data["title"] in NEW_COMP, + rx.el.div(class_name="size-2 rounded-full bg-blue-500"), ), variant="ghost", size="sm", - class_name="w-fit", + class_name="w-fit flex items-center", id=data["url"], ) +# def create_menu_item(data: dict): +# """Create a single menu item.""" + +# return button( +# rx.el.a( +# rx.el.p(data["title"], class_name="cursor-pointer"), +# to=f"/{data['url']}", +# text_decoration="none", +# ) +# if data["url"] != "llms.txt" +# else rx.el.a( +# rx.el.p(data["title"], class_name="cursor-pointer"), +# href=f"/{data['url']}", +# text_decoration="none", +# reload_document=True, +# ), +# variant="ghost", +# size="sm", +# class_name="w-fit", +# id=data["url"], +# ) + + def create_sidebar_menu_items(routes: List[dict]): """Create menu items from routes.""" return rx.el.div( diff --git a/app/www/anatomy.py b/app/www/anatomy.py index 569aa8a..654b90a 100644 --- a/app/www/anatomy.py +++ b/app/www/anatomy.py @@ -1,6 +1,21 @@ # app/www/anatomy.py ANATOMY = { + "bubble": """bubble.root( + bubble.content(), + bubble.reactions(), +)""", + "attachment": """attachment.root( + attachment.media(), + attachment.content( + attachment.title(), + attachment.description(), + ), + attachment.actions( + attachment.action() + ), +) +""", "accordion": """accordion.root( accordion.item( accordion.header( @@ -44,6 +59,16 @@ ), ), )""", + "message": """message.group( + message.root( + message.avatar(), + message.content( + message.header(), + message.footer(), + ), + ), +) +""", "button": """button()""", "card": """card.root( card.header( diff --git a/app/www/library/examples/attachment_group.py b/app/www/library/examples/attachment_group.py new file mode 100644 index 0000000..6603416 --- /dev/null +++ b/app/www/library/examples/attachment_group.py @@ -0,0 +1,48 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment + +items = [ + {"name": "briefing-notes.pdf", "meta": "PDF · 1.4 MB", "type": "file"}, + { + "name": "workspace.png", + "meta": "PNG · 820 KB", + "src": "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80", + "type": "image", + }, + {"name": "customers.csv", "meta": "CSV · 18 KB", "type": "file"}, + {"name": "renderer.tsx", "meta": "TSX · 12 KB", "type": "file"}, +] + + +def attachment_group_demo(): + return rx.el.div( + attachment.group( + rx.foreach( + items, + lambda item: attachment.root( + rx.cond( + item["type"] == "image", + attachment.media( + rx.el.img(src=item["src"], alt=item["name"]), + variant="image", + ), + attachment.media(hi("File02Icon")), + ), + attachment.content( + attachment.title(item["name"]), + attachment.description(item["meta"]), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label=f"Remove {item['name']}" + ) + ), + class_name="w-64", + ), + ), + class_name="full", + ), + class_name="mx-auto w-full max-w-sm py-12", + ) diff --git a/app/www/library/examples/attachment_with_image.py b/app/www/library/examples/attachment_with_image.py new file mode 100644 index 0000000..7735574 --- /dev/null +++ b/app/www/library/examples/attachment_with_image.py @@ -0,0 +1,61 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment + +images = [ + { + "name": "workspace.png", + "meta": "PNG · 820 KB", + "src": "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80", + "alt": "Workspace", + }, + { + "name": "desk-reference.jpg", + "meta": "JPG · 1.1 MB", + "src": "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80", + "alt": "Desk", + }, + { + "name": "office-reference.jpg", + "meta": "JPG · 940 KB", + "src": "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80", + "alt": "Office", + }, +] + + +def attachment_image_demo(): + return rx.el.div( + attachment.group( + rx.foreach( + images, + lambda image: attachment.root( + attachment.trigger( + link=True, + href=image["src"], + target="_blank", + rel="noreferrer", + aria_label=f"Open {image['name']}", + ), + attachment.media( + rx.el.img(src=image["src"], alt=image["alt"]), + variant="image", + ), + attachment.content( + attachment.title(image["name"]), + attachment.description(image["meta"]), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), + aria_label=f"Remove {image['name']}", + ) + ), + orientation="vertical", + ), + ), + class_name="w-full", + ), + class_name="mx-auto w-full max-w-sm py-12", + ) diff --git a/app/www/library/examples/attachment_with_sizes.py b/app/www/library/examples/attachment_with_sizes.py new file mode 100644 index 0000000..dd6a80a --- /dev/null +++ b/app/www/library/examples/attachment_with_sizes.py @@ -0,0 +1,33 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment + + +def attachment_sizes_demo(): + return rx.el.div( + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Default attachment"), + attachment.description("PDF · 2.4 MB"), + ), + size="default", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Small attachment"), + attachment.description("PDF · 2.4 MB"), + ), + size="sm", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Extra small attachment"), + ), + size="xs", + ), + class_name="mx-auto w-full max-w-sm py-12 flex flex-col gap-y-4", + ) diff --git a/app/www/library/examples/attachment_with_states.py b/app/www/library/examples/attachment_with_states.py new file mode 100644 index 0000000..68651e6 --- /dev/null +++ b/app/www/library/examples/attachment_with_states.py @@ -0,0 +1,78 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment +from components.ui.spinner import spinner + + +def attachment_states_demo(): + return rx.el.div( + attachment.root( + attachment.media(hi("Clock01Icon")), + attachment.content( + attachment.title("selected-file.pdf"), + attachment.description("Ready to upload"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove selected-file.pdf" + ) + ), + state="idle", + ), + attachment.root( + attachment.media(spinner()), + attachment.content( + attachment.title( + "design-system.zip", + class_name="shimmer", + ), + attachment.description("Uploading · 64%"), + ), + attachment.actions( + attachment.action(hi("Cancel01Icon"), aria_label="Cancel upload") + ), + state="uploading", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("market-research.pdf"), + attachment.description("Processing document"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove market-research.pdf" + ) + ), + state="processing", + ), + attachment.root( + attachment.media(hi("FileExclamationPointIcon")), + attachment.content( + attachment.title("financial-model.xlsx"), + attachment.description("Upload failed. Try again."), + ), + attachment.actions( + attachment.action(hi("RefreshIcon"), aria_label="Retry upload"), + attachment.action( + hi("Cancel01Icon"), aria_label="Remove financial-model.xlsx" + ), + ), + state="error", + ), + attachment.root( + attachment.media(hi("Tick02Icon")), + attachment.content( + attachment.title("uploaded-report.pdf"), + attachment.description("Uploaded · 1.8 MB"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove uploaded-report.pdf" + ) + ), + state="done", + ), + class_name="w-full mx-auto max-w-sm py-12 flex flex-col gap-y-4", + ) diff --git a/app/www/library/examples/attachment_with_trigger.py b/app/www/library/examples/attachment_with_trigger.py new file mode 100644 index 0000000..331a429 --- /dev/null +++ b/app/www/library/examples/attachment_with_trigger.py @@ -0,0 +1,39 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment +from components.ui.dialog import dialog + + +def attachment_trigger_dialog_demo(): + return rx.el.div( + dialog.root( + attachment.root( + attachment.media(hi("File01Icon")), + attachment.content( + attachment.title("research-summary.pdf"), + attachment.description("Open preview dialog"), + ), + attachment.actions( + attachment.action(hi("Copy01Icon"), aria_label="Copy link"), + attachment.action( + hi("Cancel01Icon"), aria_label="Remove research-summary.pdf" + ), + ), + dialog.trigger(attachment.trigger(link=False)), + class_name="w-full", + ), + dialog.portal( + dialog.backdrop(class_name="backdrop-blur-[3px]"), + dialog.popup( + dialog.title("research-summary.pdf"), + dialog.description( + "The attachment trigger fills the card and opens the dialog, " + "while the actions stay independently clickable above it." + ), + class_name="max-w-md rounded-2xl", + ), + ), + ), + class_name="mx-auto w-full max-w-sm py-12", + ) diff --git a/app/www/library/examples/bubble_group.py b/app/www/library/examples/bubble_group.py new file mode 100644 index 0000000..56ea8d8 --- /dev/null +++ b/app/www/library/examples/bubble_group.py @@ -0,0 +1,39 @@ +import reflex as rx + +from components.ui.bubble import bubble + + +def bubble_group_demo(): + return rx.el.div( + bubble.root( + bubble.content("Can you tell me what's the issue?"), + variant="muted", + ), + bubble.group( + bubble.root( + bubble.content("You tell me!"), + align="end", + ), + bubble.root( + bubble.content("It worked yesterday. You broke it!"), + align="end", + ), + bubble.root( + bubble.content("Find the bug and fix it."), + bubble.reactions( + rx.el.span("👀"), + aria_label="Reactions: eyes", + align="start", + ), + align="end", + ), + ), + bubble.root( + bubble.content( + "Want me to diff yesterday's you against today's you? " + "It's a bit embarrassing." + ), + variant="muted", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/bubble_link_button.py b/app/www/library/examples/bubble_link_button.py new file mode 100644 index 0000000..c2492a9 --- /dev/null +++ b/app/www/library/examples/bubble_link_button.py @@ -0,0 +1,50 @@ +import reflex as rx + +from components.ui.bubble import bubble + + +def bubble_link_button_demo(): + return rx.el.div( + bubble.root( + bubble.content("How can I help you today?"), + variant="muted", + ), + bubble.group( + bubble.root( + bubble.content( + rx.el.button( + "I forgot my password", + on_click=rx.toast("You clicked forgot password"), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content( + rx.el.button( + "I need help with my subscription", + on_click=rx.toast("You clicked help with subscription"), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content( + rx.el.button( + "Something else. Talk to a human.", + on_click=rx.toast( + "You clicked something else. Talk to a human." + ), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/bubble_reactions.py b/app/www/library/examples/bubble_reactions.py new file mode 100644 index 0000000..fc8029a --- /dev/null +++ b/app/www/library/examples/bubble_reactions.py @@ -0,0 +1,60 @@ +import reflex as rx + +from components.ui.bubble import bubble + + +def bubble_reactions_demo(): + return rx.el.div( + bubble.root( + bubble.content("I don't need tests, I know my code works."), + bubble.reactions( + rx.el.span("👍"), + rx.el.span("😮"), + align="start", + role="img", + aria_label="Reactions: thumbs up, surprised", + ), + variant="muted", + align="end", + ), + bubble.root( + bubble.content( + "Bold. Fine I'll add some tests. I'll let you know when they're done." + ), + bubble.reactions( + rx.el.span("👀"), + rx.el.span("🚀"), + rx.el.span("+2"), + role="img", + aria_label="Reactions: eyes, rocket, and 2 more", + ), + variant="muted", + ), + bubble.root( + bubble.content( + "Tests passed on the first try. All 142 of them. Looking good!" + ), + bubble.reactions( + rx.el.span("🎉"), + rx.el.span("👏"), + side="top", + align="start", + role="img", + aria_label="Reactions: party popper, clapping hands", + ), + variant="default", + align="end", + ), + bubble.root( + bubble.content("Are you sure I can run this command?"), + bubble.reactions( + rx.el.button( + "Yes, run it", + on_click=rx.toast.success("You clicked yes, running command..."), + class_name="px-2 py-0.5 text-xs hover:bg-accent rounded-md", + ), + ), + variant="destructive", + ), + class_name="flex w-full max-w-sm flex-col gap-12 py-12", + ) diff --git a/app/www/library/examples/bubble_show_more.py b/app/www/library/examples/bubble_show_more.py new file mode 100644 index 0000000..b275cdf --- /dev/null +++ b/app/www/library/examples/bubble_show_more.py @@ -0,0 +1,44 @@ +import reflex as rx +from reflex.experimental import ClientStateVar + +from components.icons.hugeicon import hi +from components.ui.bubble import bubble +from components.ui.collapsible import collapsible + +open_var = ClientStateVar.create("open_var", False) +text_val = "The accessibility review found two focus states that were visually too subtle in dark mode.\n\nI checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.\n\nThe dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.\n\nI also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later." + + +def bubble_collapsible_demo(): + return rx.el.div( + bubble.root( + bubble.content("How can I help you today?"), + variant="muted", + ), + bubble.root( + bubble.content( + collapsible.root( + rx.el.div( + rx.cond(open_var.value, text_val, f"{text_val[:180]}..."), + class_name="whitespace-pre-line", + ), + collapsible.trigger( + rx.el.button( + rx.cond(open_var.value, "Show less", "Show more"), + rx.cond( + open_var.value, + hi("ArrowUp01Icon"), + hi("ArrowDown01Icon"), + ), + class_name="flex flex-row items-center gap-1 p-0 text-muted-foreground hover:underline", + ), + ), + open=open_var.value, + on_open_change=open_var.set_value, + ), + ), + variant="muted", + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/bubble_with_align.py b/app/www/library/examples/bubble_with_align.py new file mode 100644 index 0000000..079b611 --- /dev/null +++ b/app/www/library/examples/bubble_with_align.py @@ -0,0 +1,22 @@ +import reflex as rx + +from components.ui.bubble import bubble + + +def bubble_alignment_demo(): + return rx.el.div( + bubble.root( + bubble.content( + "This bubble is aligned to the start. This is the default alignment." + ), + variant="muted", + align="start", + ), + bubble.root( + bubble.content( + "This bubble is aligned to the end. Use this for user messages." + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/bubble_with_popover.py b/app/www/library/examples/bubble_with_popover.py new file mode 100644 index 0000000..affd5e3 --- /dev/null +++ b/app/www/library/examples/bubble_with_popover.py @@ -0,0 +1,49 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.bubble import bubble +from components.ui.button import button +from components.ui.popover import popover + + +def bubble_popover_demo(): + return rx.el.div( + bubble.root( + bubble.content("Run the build script."), + align="end", + ), + bubble.root( + bubble.content("Failed to run the command."), + bubble.reactions( + popover.root( + popover.trigger( + render_=button( + hi("InformationCircleIcon"), + variant="ghost", + aria_label="Show error details", + class_name="w-6 h-6 aria-expanded:text-destructive", + ) + ), + popover.portal( + popover.backdrop(), + popover.positioner( + popover.popup( + popover.header( + popover.title( + "Command failed with exit code 1", + class_name="text-sm", + ), + popover.description( + "ENOENT: no such file or directory, open pnpm-lock.yaml", + class_name="text-sm", + ), + ), + ), + ), + ), + ) + ), + variant="destructive", + ), + class_name="flex w-full max-w-sm flex-col gap-4 py-12", + ) diff --git a/app/www/library/examples/bubble_with_tooltip.py b/app/www/library/examples/bubble_with_tooltip.py new file mode 100644 index 0000000..6d2a51d --- /dev/null +++ b/app/www/library/examples/bubble_with_tooltip.py @@ -0,0 +1,43 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.bubble import bubble +from components.ui.button import button +from components.ui.tooltip import tooltip + + +def bubble_tooltip_demo(): + return rx.el.div( + bubble.root( + bubble.content("Did you remove the stale route?"), + variant="secondary", + ), + bubble.root( + bubble.content("Yes, removed it from the registry."), + bubble.reactions( + tooltip.provider( + tooltip.root( + tooltip.trigger( + render_=button( + hi("Tick02Icon", class_name="size-4"), + variant="ghost", + class_name="w-6 h-6", + ) + ), + tooltip.portal( + tooltip.positioner( + tooltip.popup( + "Read on Jan 5, 2026 at 4:32 PM", + tooltip.arrow(), + ), + side="bottom", + ) + ), + ), + delay=0, + ) + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-4 py-12", + ) diff --git a/app/www/library/examples/bubble_with_variants.py b/app/www/library/examples/bubble_with_variants.py new file mode 100644 index 0000000..ab897f3 --- /dev/null +++ b/app/www/library/examples/bubble_with_variants.py @@ -0,0 +1,64 @@ +import reflex as rx + +from components.ui.bubble import bubble + + +def bubble_with_variants(): + return rx.el.div( + bubble.root( + bubble.content("This is the default primary bubble."), + variant="default", + ), + bubble.root( + bubble.content("This is the secondary variant."), + variant="secondary", + align="end", + ), + bubble.root( + bubble.content( + "This one is muted. It uses a lower emphasis color for the chat bubble." + ), + bubble.reactions( + rx.el.span("👍"), + role="img", + aria_label="Reaction: thumbs up", + ), + variant="muted", + ), + bubble.root( + bubble.content( + "This one is tinted. The tint is a softer color derived from the primary color." + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content("We can also use an outlined variant."), + variant="outline", + ), + bubble.root( + bubble.content("Or a destructive variant with a reaction."), + bubble.reactions( + rx.el.span("🔥"), + role="img", + aria_label="Reaction: fire", + ), + variant="destructive", + align="end", + ), + bubble.root( + bubble.content( + rx.markdown( + """ + Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed. + + This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render `code` in it. + + Ghost bubbles are full width and can take the full width of the container. + """ + ) + ), + variant="ghost", + ), + class_name="flex w-full max-w-sm flex-col gap-12 py-12", + ) diff --git a/app/www/library/examples/message_with_actions.py b/app/www/library/examples/message_with_actions.py new file mode 100644 index 0000000..cac38a2 --- /dev/null +++ b/app/www/library/examples/message_with_actions.py @@ -0,0 +1,67 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.bubble import bubble +from components.ui.button import button +from components.ui.message import message + + +def message_with_actions(): + return rx.el.div( + message.root( + message.content( + bubble.root( + bubble.content( + "The install failure is coming from the workspace package." + ), + variant="muted", + ), + message.footer( + button( + hi("Copy01Icon"), + variant="ghost", + size="sm", + aria_label="Copy", + title="Copy", + ), + button( + hi("ThumbsUpIcon"), + variant="ghost", + size="sm", + aria_label="Like", + title="Like", + ), + button( + hi("ThumbsDownIcon"), + variant="ghost", + size="sm", + aria_label="Dislike", + title="Dislike", + ), + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content("Okay drop me a link. Taking a look..."), + ), + message.footer( + rx.el.span( + "Failed to send", + class_name="font-normal text-destructive", + ), + button( + hi("Refresh03Icon"), + variant="ghost", + size="sm", + title="Retry", + aria_label="Retry", + ), + class_name="gap-2", + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/message_with_attachments.py b/app/www/library/examples/message_with_attachments.py new file mode 100644 index 0000000..15c7ab4 --- /dev/null +++ b/app/www/library/examples/message_with_attachments.py @@ -0,0 +1,67 @@ +import reflex as rx + +from components.icons.hugeicon import hi +from components.ui.attachment import attachment +from components.ui.bubble import bubble +from components.ui.message import message + + +def message_with_attachment(): + return rx.el.div( + message.root( + message.content( + attachment.root( + attachment.media( + rx.el.img( + src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80", + alt="Workspace", + ), + variant="image", + ), + orientation="vertical", + ), + bubble.root( + bubble.content( + "Here's the image. Can you add it to the PDF? " + "Use it for the cover page." + ), + ), + ), + align="end", + ), + message.root( + message.content( + bubble.root( + bubble.content( + "Done. Here's the PDF with the image added as the cover page." + ), + variant="muted", + ), + attachment.root( + attachment.media( + hi("File02Icon"), + ), + attachment.content( + attachment.title("sales-dashboard.pdf"), + attachment.description("PDF · 2.4 MB"), + ), + attachment.actions( + attachment.action( + hi("Download02Icon"), + title="Download", + aria_label="Download", + ), + ), + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content("Thanks. Looks good."), + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/app/www/library/examples/message_with_avatar.py b/app/www/library/examples/message_with_avatar.py new file mode 100644 index 0000000..f1f59cb --- /dev/null +++ b/app/www/library/examples/message_with_avatar.py @@ -0,0 +1,62 @@ +import reflex as rx + +from components.ui.avatar import avatar +from components.ui.bubble import bubble +from components.ui.message import message + + +def message_with_avatar(): + return rx.el.div( + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/03.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.root( + bubble.content("The build failed during dependency installation."), + variant="muted", + ), + ), + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/01.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.root( + bubble.content("Can you share the exact error?"), + ), + ), + align="end", + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/03.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.group( + bubble.root( + bubble.content("Here's the error from the logs"), + variant="muted", + ), + bubble.root( + bubble.content( + "Something went wrong with the build. The libraries are not " + "installed correctly. Try running the build again." + ), + variant="muted", + ), + ), + ), + ), + class_name="flex w-full max-w-sm flex-col gap-6 py-12", + ) diff --git a/app/www/library/examples/message_with_group.py b/app/www/library/examples/message_with_group.py new file mode 100644 index 0000000..a8a18e0 --- /dev/null +++ b/app/www/library/examples/message_with_group.py @@ -0,0 +1,38 @@ +import reflex as rx + +from components.ui.avatar import avatar +from components.ui.bubble import bubble +from components.ui.message import message + + +def message_with_group(): + return rx.el.div( + message.group( + message.root( + message.avatar(), + message.content( + bubble.root( + bubble.content("I checked the registry addresses."), + variant="muted", + ), + ), + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/02.png", alt="@avatar"), + avatar.fallback("CN"), + ), + ), + message.content( + bubble.root( + bubble.content( + "The component and example JSON now live under the UI registry." + ), + variant="muted", + ), + ), + ), + ), + class_name="flex w-full max-w-sm flex-col gap-6 py-12", + ) diff --git a/app/www/library/examples/message_with_header_footer.py b/app/www/library/examples/message_with_header_footer.py new file mode 100644 index 0000000..a797f83 --- /dev/null +++ b/app/www/library/examples/message_with_header_footer.py @@ -0,0 +1,35 @@ +import reflex as rx + +from components.ui.bubble import bubble +from components.ui.message import message + + +def message_header_footer(): + return rx.el.div( + message.root( + message.content( + message.header("Olivia"), + bubble.root( + bubble.content("I already checked the logs."), + variant="muted", + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content( + "Send the report to the team. Ping @lineindent if you need help." + ), + ), + message.footer( + rx.el.div( + "Read ", + rx.el.span("Yesterday", class_name="font-normal"), + ), + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) diff --git a/assets/avatars/01.png b/assets/avatars/01.png new file mode 100644 index 0000000..c190101 Binary files /dev/null and b/assets/avatars/01.png differ diff --git a/assets/avatars/02.png b/assets/avatars/02.png new file mode 100644 index 0000000..b2aae01 Binary files /dev/null and b/assets/avatars/02.png differ diff --git a/assets/avatars/03.png b/assets/avatars/03.png new file mode 100644 index 0000000..f04b6b0 Binary files /dev/null and b/assets/avatars/03.png differ diff --git a/assets/avatars/04.png b/assets/avatars/04.png new file mode 100644 index 0000000..1129539 Binary files /dev/null and b/assets/avatars/04.png differ diff --git a/assets/docs/components/attachment.md b/assets/docs/components/attachment.md new file mode 100644 index 0000000..d826589 --- /dev/null +++ b/assets/docs/components/attachment.md @@ -0,0 +1,696 @@ + + +# Attachment + +Displays a file or image attachment with media, metadata, upload state, and actions. + +# Installation + +Copy the following code into your app directory. + +### CLI + +```bash +buridan add component attachment +``` + +### Manual Installation + +```python +"""Attachment component — file and image attachment previews for chat UI.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..ui.button import button +from ..utils.twmerge import cn + +AttachmentOrientation = Literal["horizontal", "vertical"] +AttachmentSize = Literal["default", "sm", "xs"] +AttachmentState = Literal["idle", "uploading", "processing", "error", "done"] +AttachmentMediaVariant = Literal["icon", "image"] + + +class ClassNames: + ROOT_BASE = ( + "group/attachment relative flex w-full max-w-full min-w-0 shrink-0 flex-wrap " + "rounded-2xl border border-input bg-card text-card-foreground transition-colors " + "focus-within:ring-1 focus-within:ring-ring/30 " + "has-[>a,>button]:hover:bg-muted/50 " + "data-[state=error]:border-destructive/30 " + "data-[state=idle]:border-dashed" + ) + + SIZES: dict[str, str] = { + "default": ( + "gap-2 text-sm " + "has-data-[slot=attachment-content]:px-2.5 " + "has-data-[slot=attachment-content]:py-2 " + "has-data-[slot=attachment-media]:p-2" + ), + "sm": ( + "gap-2.5 text-xs " + "has-data-[slot=attachment-content]:px-2 " + "has-data-[slot=attachment-content]:py-1.5 " + "has-data-[slot=attachment-media]:p-1.5" + ), + "xs": ( + "gap-1.5 rounded-xl text-xs " + "has-data-[slot=attachment-content]:px-1.5 " + "has-data-[slot=attachment-content]:py-1 " + "has-data-[slot=attachment-media]:p-1" + ), + } + + ORIENTATIONS: dict[str, str] = { + "horizontal": "min-w-40 items-center", + "vertical": "w-24 flex-col has-data-[slot=attachment-content]:w-30", + } + + MEDIA_BASE = ( + "relative flex aspect-square w-10 shrink-0 items-center justify-center " + "overflow-hidden rounded-lg bg-muted text-foreground " + "group-data-[orientation=vertical]/attachment:w-full " + "group-data-[size=sm]/attachment:w-8 " + "group-data-[size=xs]/attachment:w-7 " + "group-data-[size=xs]/attachment:rounded-md " + "group-data-[state=error]/attachment:bg-destructive/10 " + "group-data-[state=error]/attachment:text-destructive " + "[&_svg]:pointer-events-none " + "[&_svg:not([class*='size-'])]:size-4 " + "group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 " + "group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5" + ) + + MEDIA_VARIANTS: dict[str, str] = { + "icon": "", + "image": ( + "opacity-60 " + "group-data-[state=done]/attachment:opacity-100 " + "group-data-[state=idle]/attachment:opacity-100 " + "*:[img]:aspect-square *:[img]:w-full *:[img]:object-cover" + ), + } + + CONTENT = ( + "max-w-full min-w-0 flex-1 leading-tight " + "group-data-[orientation=vertical]/attachment:px-1" + ) + + TITLE = ( + "block max-w-full min-w-0 truncate font-medium " + "group-data-[state=processing]/attachment:shimmer " + "group-data-[state=uploading]/attachment:shimmer" + ) + + DESCRIPTION = ( + "mt-0.5 block min-w-0 max-w-full truncate text-xs text-muted-foreground " + "group-data-[state=error]/attachment:text-destructive/80" + ) + + ACTIONS = ( + "relative z-20 flex shrink-0 items-center " + "group-data-[orientation=vertical]/attachment:absolute " + "group-data-[orientation=vertical]/attachment:top-3 " + "group-data-[orientation=vertical]/attachment:right-3 " + "group-data-[orientation=vertical]/attachment:gap-1" + ) + + TRIGGER = "absolute inset-0 z-10 outline-none" + + GROUP = ( + "flex scroll-fade-x min-w-0 snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 " + "overflow-x-auto overscroll-x-contain py-1 " + "*:data-[slot=attachment]:flex-none " + "*:data-[slot=attachment]:snap-start" + ) + + +def attachment_root( + *children, + orientation: AttachmentOrientation = "horizontal", + size: AttachmentSize = "default", + state: AttachmentState = "done", + class_name: str = "", + **props, +) -> rx.Component: + """ + Root attachment container. + + orientation: horizontal (default) | vertical + size: default | sm | xs + state: idle | uploading | processing | error | done (default) + """ + return rx.el.div( + *children, + data_slot="attachment", + data_state=state, + data_size=size, + data_orientation=orientation, + class_name=cn( + ClassNames.ROOT_BASE, + ClassNames.SIZES.get(size, ""), + ClassNames.ORIENTATIONS.get(orientation, ""), + class_name, + ), + **props, + ) + + +def attachment_media( + *children, + variant: AttachmentMediaVariant = "icon", + class_name: str = "", + **props, +) -> rx.Component: + """Icon or image preview slot.""" + return rx.el.div( + *children, + data_slot="attachment-media", + data_variant=variant, + class_name=cn( + ClassNames.MEDIA_BASE, + ClassNames.MEDIA_VARIANTS.get(variant, ""), + class_name, + ), + **props, + ) + + +def attachment_content(*children, class_name: str = "", **props) -> rx.Component: + """Text content area — holds title and description.""" + return rx.el.div( + *children, + data_slot="attachment-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def attachment_title(*children, class_name: str = "", **props) -> rx.Component: + """Filename or attachment title. Shimmers during uploading/processing.""" + return rx.el.span( + *children, + data_slot="attachment-title", + class_name=cn(ClassNames.TITLE, class_name), + **props, + ) + + +def attachment_description(*children, class_name: str = "", **props) -> rx.Component: + """File type, size, or other metadata.""" + return rx.el.span( + *children, + data_slot="attachment-description", + class_name=cn(ClassNames.DESCRIPTION, class_name), + **props, + ) + + +def attachment_actions(*children, class_name: str = "", **props) -> rx.Component: + """ + Row of action buttons. + In vertical orientation, absolutely positioned top-right. + """ + return rx.el.div( + *children, + data_slot="attachment-actions", + class_name=cn(ClassNames.ACTIONS, class_name), + **props, + ) + + +def attachment_action(*children, class_name: str = "", **props) -> rx.Component: + """Individual action button.""" + props.setdefault("variant", "ghost") + props.setdefault("size", "icon-xs") + return button( + *children, + data_slot="attachment-action", + class_name=cn(class_name), + **props, + ) + + +def attachment_trigger( + *children, link: bool = False, class_name: str = "", **props +) -> rx.Component: + + component_fn = rx.el.a if link else rx.el.button + + props.setdefault("data_slot", "attachment-trigger") + props.setdefault("class_name", cn(ClassNames.TRIGGER, class_name)) + + if not link: + props.setdefault("type", "button") + + return component_fn(*children, **props) + + +def attachment_group(*children, class_name: str = "", **props) -> rx.Component: + """ + Horizontal scrolling row of attachments. + Snaps to each attachment on scroll. + """ + return rx.el.div( + *children, + data_slot="attachment-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +class Attachment(ComponentNamespace): + """Attachment namespace.""" + + root = staticmethod(attachment_root) + media = staticmethod(attachment_media) + content = staticmethod(attachment_content) + title = staticmethod(attachment_title) + description = staticmethod(attachment_description) + actions = staticmethod(attachment_actions) + action = staticmethod(attachment_action) + trigger = staticmethod(attachment_trigger) + group = staticmethod(attachment_group) + + class_names = ClassNames + + +attachment = Attachment() +``` + + +# Usage + + +```python +from components.ui.attachment import Attachment +``` + + +# Anatomy + +Use the following composition to build an `Attachment` component. + + +```python +attachment.root( + attachment.media(), + attachment.content( + attachment.title(), + attachment.description(), + ), + attachment.actions( + attachment.action() + ), +) +``` + + +# Features + +- Icon and image media through `attachment.media` +- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress +- Three sizes and horizontal or vertical orientation +- A full-card `attachment.trigger` that opens a link or dialog while the actions stay independently clickable +- Scrollable, snapping `attachment.group` with an edge fade +- Customizable styling through the `class_name` prop on every part + +# Examples + +## Image + +Set `variant="image"` on `attachment.media` and render an `rx.el.img()` inside it. Use `orientation="vertical"` to stack the media above the content. + + +```python +def attachment_image_demo(): + return rx.el.div( + attachment.group( + rx.foreach( + images, + lambda image: attachment.root( + attachment.trigger( + link=True, + href=image["src"], + target="_blank", + rel="noreferrer", + aria_label=f"Open {image['name']}", + ), + attachment.media( + rx.el.img(src=image["src"], alt=image["alt"]), + variant="image", + ), + attachment.content( + attachment.title(image["name"]), + attachment.description(image["meta"]), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), + aria_label=f"Remove {image['name']}", + ) + ), + orientation="vertical", + ), + ), + class_name="w-full", + ), + class_name="mx-auto w-full max-w-sm py-12", + ) +``` + + +## States + +Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment. + + +```python +def attachment_states_demo(): + return rx.el.div( + attachment.root( + attachment.media(hi("Clock01Icon")), + attachment.content( + attachment.title("selected-file.pdf"), + attachment.description("Ready to upload"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove selected-file.pdf" + ) + ), + state="idle", + ), + attachment.root( + attachment.media(spinner()), + attachment.content( + attachment.title( + "design-system.zip", + class_name="shimmer", + ), + attachment.description("Uploading · 64%"), + ), + attachment.actions( + attachment.action(hi("Cancel01Icon"), aria_label="Cancel upload") + ), + state="uploading", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("market-research.pdf"), + attachment.description("Processing document"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove market-research.pdf" + ) + ), + state="processing", + ), + attachment.root( + attachment.media(hi("FileExclamationPointIcon")), + attachment.content( + attachment.title("financial-model.xlsx"), + attachment.description("Upload failed. Try again."), + ), + attachment.actions( + attachment.action(hi("RefreshIcon"), aria_label="Retry upload"), + attachment.action( + hi("Cancel01Icon"), aria_label="Remove financial-model.xlsx" + ), + ), + state="error", + ), + attachment.root( + attachment.media(hi("Tick02Icon")), + attachment.content( + attachment.title("uploaded-report.pdf"), + attachment.description("Uploaded · 1.8 MB"), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label="Remove uploaded-report.pdf" + ) + ), + state="done", + ), + class_name="w-full mx-auto max-w-sm py-12 flex flex-col gap-y-4", + ) +``` + + + +## Sizes + +Use `size` to switch between `default`, `sm`, and `xs`. + + +```python +def attachment_sizes_demo(): + return rx.el.div( + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Default attachment"), + attachment.description("PDF · 2.4 MB"), + ), + size="default", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Small attachment"), + attachment.description("PDF · 2.4 MB"), + ), + size="sm", + ), + attachment.root( + attachment.media(hi("File02Icon")), + attachment.content( + attachment.title("Extra small attachment"), + ), + size="xs", + ), + class_name="mx-auto w-full max-w-sm py-12 flex flex-col gap-y-4", + ) +``` + + +## Group + +Wrap attachments in `attachment.group` to lay them out in a horizontally scrollable, snapping row with an edge fade. + + +```python +def attachment_group_demo(): + return rx.el.div( + attachment.group( + rx.foreach( + items, + lambda item: attachment.root( + rx.cond( + item["type"] == "image", + attachment.media( + rx.el.img(src=item["src"], alt=item["name"]), + variant="image", + ), + attachment.media(hi("File02Icon")), + ), + attachment.content( + attachment.title(item["name"]), + attachment.description(item["meta"]), + ), + attachment.actions( + attachment.action( + hi("Cancel01Icon"), aria_label=f"Remove {item['name']}" + ) + ), + class_name="w-64", + ), + ), + class_name="full", + ), + class_name="mx-auto w-full max-w-sm py-12", + ) +``` + + +## Trigger + +Add an `attachment.trigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable. + + +```python +def attachment_trigger_dialog_demo(): + return rx.el.div( + dialog.root( + attachment.root( + attachment.media(hi("File01Icon")), + attachment.content( + attachment.title("research-summary.pdf"), + attachment.description("Open preview dialog"), + ), + attachment.actions( + attachment.action(hi("Copy01Icon"), aria_label="Copy link"), + attachment.action( + hi("Cancel01Icon"), aria_label="Remove research-summary.pdf" + ), + ), + dialog.trigger(attachment.trigger(link=False)), + class_name="w-full", + ), + dialog.portal( + dialog.backdrop(class_name="backdrop-blur-[3px]"), + dialog.popup( + dialog.title("research-summary.pdf"), + dialog.description( + "The attachment trigger fills the card and opens the dialog, " + "while the actions stay independently clickable above it." + ), + class_name="max-w-md rounded-2xl", + ), + ), + ), + class_name="mx-auto w-full max-w-sm py-12", + ) +``` + + + +# Accessibility + +`attachment.action` renders a `Button`, and `attachment.trigger` renders either a real `rx.el.button()` or a `rx.el.a()` if the `link` prop is set to `True`. Follow the guidance below so both are operable and announced. + +## Label icon-only actions + +`attachment.action` is usually icon-only, so give each one an `aria-label` describing the action and its target. + +```python +attachment.action( + hi("Cancel01Icon"), aria_label="Remove market-research.pdf" +) +``` + +## Label the trigger + +`attachment.trigger` overlays the entire attachment with a clickable surface. + +Use `aria_label` to describe what activating the attachment does. This is required when the trigger has no visible text. + +### Link trigger (opens a URL) + +```python +attachment.trigger( + link=True, + href=url, + target="_blank", + rel="noreferrer", + aria_label="Open workspace.png", +) +``` + +### Button trigger (interactive action) + +```python +attachment.trigger( + on_click=handle_open, + aria_label="Open attachment preview", +) +``` + + +The trigger sits behind the actions in the stacking order, so an `attachment.action` and the `attachment.trigger` never trap each other — both remain separately focusable and clickable. + +## Keyboard scrolling + +An `attachment.group` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`. + +## Meaning beyond color + +The `error` state uses a destructive color. Keep the failure reason in `attachment.description` so the state is not conveyed by color alone. + +# API Reference + +## attachment.root + +The root attachment container. + +| Prop | Type | Default | Description | +| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- | +| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. | +| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. | +| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. | +| `class_name` | `string` | - | Additional classes to apply to the root element. | + +## attachment.media + +The media slot for an icon or image preview. + +| Prop | Type | Default | Description | +| ----------- | ------------------- | -------- | ---------------------------------------------- | +| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an ``. | +| `class_name` | `string` | - | Additional classes to apply to the media slot. | + +## attachment.content + +Wraps the title and description. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the content slot. | + +## attachment.title + +The attachment name. Shimmers while the attachment is `uploading` or `processing`. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the title. | + +## attachment.description + +Secondary metadata such as the file type, size, or upload status. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the description. | + +## attachment.actions + +A container for one or more actions, aligned to the end of the attachment. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the actions. | + +## attachment.action + +An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props. + +| Prop | Type | Default | Description | +| ---------- | ------------------------------------- | ----------- | ---------------------------------------- | +| `size` | `Button["size"]` | `"icon-xs"` | The button size. | +| `class_name` | `string` | - | Additional classes to apply to the actions. | + +## attachment.trigger + +A full-card overlay that activates the attachment. Renders a `rx.el.button` by default or a `rx.el.a` when `link=True`. + +| Prop | Type | Default | Description | +| ------------ | --------------------- | ------- | ----------------------------------------------------------------------------- | +| `link` | `bool` | `False` | If set, renders an anchor (`rx.el.a`) instead of a button. | +| `aria_label` | `str \| None` | `None` | Accessibility label for screen readers. Required when no visible text exists. | +| `class_name` | `str` | `""` | Additional CSS classes applied to the trigger. | + + +## attachment.group + +Lays out attachments in a horizontally scrollable, snapping row. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group. | diff --git a/assets/docs/components/bubble.md b/assets/docs/components/bubble.md new file mode 100644 index 0000000..bff3a66 --- /dev/null +++ b/assets/docs/components/bubble.md @@ -0,0 +1,732 @@ + + +# Bubble + +Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content. + +The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions. + +For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message). + +# Installation + +Copy the following code into your app directory. + +### CLI + +```bash +buridan add component bubble +``` + +### Manual Installation + +```python +"""Bubble component — chat message bubble with variants, content, and reactions slots.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..utils.twmerge import cn + +BubbleVariant = Literal[ + "default", "secondary", "muted", "tinted", "outline", "ghost", "destructive" +] +BubbleAlign = Literal["start", "end"] +BubbleSide = Literal["top", "bottom"] + + +class ClassNames: + GROUP = "flex min-w-0 flex-col gap-2" + + VARIANTS: dict[str, str] = { + "default": ( + "*:data-[slot=bubble-content]:bg-primary " + "*:data-[slot=bubble-content]:text-primary-foreground" + ), + "secondary": ( + "*:data-[slot=bubble-content]:bg-secondary " + "*:data-[slot=bubble-content]:text-secondary-foreground" + ), + "muted": ("*:data-[slot=bubble-content]:bg-muted"), + "tinted": ( + "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] " + "*:data-[slot=bubble-content]:text-foreground " + "dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)]" + ), + "outline": ( + "*:data-[slot=bubble-content]:border-border " + "*:data-[slot=bubble-content]:bg-background" + ), + "ghost": ( + "border-none " + "*:data-[slot=bubble-content]:rounded-none " + "*:data-[slot=bubble-content]:bg-transparent " + "*:data-[slot=bubble-content]:p-0" + ), + "destructive": ( + "*:data-[slot=bubble-content]:bg-destructive/10 " + "*:data-[slot=bubble-content]:text-destructive " + "dark:*:data-[slot=bubble-content]:bg-destructive/20" + ), + } + + ROOT = ( + "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 " + "group-data-[align=end]/message:self-end " + "data-[align=end]:self-end " + "data-[variant=ghost]:max-w-full" + ) + + CONTENT = ( + "w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent " + "px-3 py-2.5 text-sm leading-relaxed break-words " + "group-data-[align=end]/bubble:self-end" + ) + + REACTIONS_BASE = ( + "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 " + "rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0" + ) + + REACTIONS_SIDE: dict[str, str] = { + "top": "top-0 -translate-y-3/4", + "bottom": "bottom-0 translate-y-3/4", + } + + REACTIONS_ALIGN: dict[str, str] = { + "start": "left-3", + "end": "right-3", + } + + +def bubble_group(*children, class_name: str = "", **props) -> rx.Component: + """Vertical stack of bubbles.""" + return rx.el.div( + *children, + data_slot="bubble-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +def bubble_root( + *children, + variant: BubbleVariant = "default", + align: BubbleAlign = "start", + class_name: str = "", + **props, +) -> rx.Component: + """ + Bubble root container. + + Variants: default, secondary, muted, tinted, outline, ghost, destructive + Align: start (incoming), end (outgoing) + """ + return rx.el.div( + *children, + data_slot="bubble", + data_variant=variant, + data_align=align, + class_name=cn( + ClassNames.ROOT, + ClassNames.VARIANTS.get(variant, ""), + class_name, + ), + **props, + ) + + +def bubble_content(*children, class_name: str = "", **props) -> rx.Component: + """The bubble content pill — rounded, padded, colored by the parent variant.""" + return rx.el.div( + *children, + data_slot="bubble-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def bubble_reactions( + *children, + side: BubbleSide = "bottom", + align: BubbleAlign = "end", + class_name: str = "", + **props, +) -> rx.Component: + """ + Emoji reactions overlay — positioned relative to the bubble. + + Side: top | bottom (default: bottom) + Align: start | end (default: end) + """ + return rx.el.div( + *children, + data_slot="bubble-reactions", + data_align=align, + data_side=side, + class_name=cn( + ClassNames.REACTIONS_BASE, + ClassNames.REACTIONS_SIDE.get(side, ""), + ClassNames.REACTIONS_ALIGN.get(align, ""), + class_name, + ), + **props, + ) + + +class Bubble(ComponentNamespace): + """Bubble namespace.""" + + group = staticmethod(bubble_group) + root = staticmethod(bubble_root) + content = staticmethod(bubble_content) + reactions = staticmethod(bubble_reactions) + + class_names = ClassNames + + +bubble = Bubble() +``` + + +# Usage + + +```python +from components.ui.bubble import Bubble +``` + + +# Anatomy +Use the following composition to build a `Bubble` component. + + +```python +bubble.root( + bubble.content(), + bubble.reactions(), +) +``` + + +# Features + +- Seven visual variants, from a strong primary bubble to unframed ghost content +- Start and end alignment for sender and receiver bubbles +- Reactions that anchor to the bubble edge with configurable side and alignment +- Bubbles size to their content, up to 80% of the container width +- Polymorphic content via `render` for link and button bubbles +- Customizable styling through the `class_name` prop on every part + +# Examples + +## Variants + +Use `variant` to change the visual treatment of the bubble. + + +```python +def bubble_with_variants(): + return rx.el.div( + bubble.root( + bubble.content("This is the default primary bubble."), + variant="default", + ), + bubble.root( + bubble.content("This is the secondary variant."), + variant="secondary", + align="end", + ), + bubble.root( + bubble.content( + "This one is muted. It uses a lower emphasis color for the chat bubble." + ), + bubble.reactions( + rx.el.span("👍"), + role="img", + aria_label="Reaction: thumbs up", + ), + variant="muted", + ), + bubble.root( + bubble.content( + "This one is tinted. The tint is a softer color derived from the primary color." + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content("We can also use an outlined variant."), + variant="outline", + ), + bubble.root( + bubble.content("Or a destructive variant with a reaction."), + bubble.reactions( + rx.el.span("🔥"), + role="img", + aria_label="Reaction: fire", + ), + variant="destructive", + align="end", + ), + bubble.root( + bubble.content( + rx.markdown( + """ + Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed. + + This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render `code` in it. + + Ghost bubbles are full width and can take the full width of the container. + """ + ) + ), + variant="ghost", + ), + class_name="flex w-full max-w-sm flex-col gap-12 py-12", + ) +``` + + + +| Variant | Description | +| ------------- | ------------------------------------------------------ | +| `default` | A strong primary bubble, usually for the current user. | +| `secondary` | The standard neutral bubble for conversation content. | +| `muted` | A lower-emphasis bubble for quiet supporting content. | +| `tinted` | A subtle primary-tinted bubble. | +| `outline` | A bordered bubble for secondary or rich content. | +| `ghost` | Unframed content for assistant text or rich content. | +| `destructive` | A destructive bubble for error or failed actions. | + +A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row. + +## Alignment + +Use `align` on `bubble.root` to align the bubble to the start or end of the conversation. + + +```python +def bubble_alignment_demo(): + return rx.el.div( + bubble.root( + bubble.content( + "This bubble is aligned to the start. This is the default alignment." + ), + variant="muted", + align="start", + ), + bubble.root( + bubble.content( + "This bubble is aligned to the end. Use this for user messages." + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +| align | Description | +| ------- | -------------------------------------------------- | +| `start` | Align the bubble to the start of the conversation. | +| `end` | Align the bubble to the end of the conversation. | + +**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `message.root` component to automatically align the bubble to the start or end of the conversation. + +## Bubble Group + +Use `bubble.group` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `bubble.root` component itself, not the `bubble.group` component. + +```composition +bubble.group +├── bubble.root +│ └── bubble.content +└── bubble.root + └── bubble.content +``` + + +```python +def bubble_group_demo(): + return rx.el.div( + bubble.root( + bubble.content("Can you tell me what's the issue?"), + variant="muted", + ), + bubble.group( + bubble.root( + bubble.content("You tell me!"), + align="end", + ), + bubble.root( + bubble.content("It worked yesterday. You broke it!"), + align="end", + ), + bubble.root( + bubble.content("Find the bug and fix it."), + bubble.reactions( + rx.el.span("👀"), + aria_label="Reactions: eyes", + align="start", + ), + align="end", + ), + ), + bubble.root( + bubble.content( + "Want me to diff yesterday's you against today's you? " + "It's a bit embarrassing." + ), + variant="muted", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +## Links and Buttons + +You can turn a bubble into a link or button by using the passing the interactive elements directly into the `bubble.content` slot. The `bubble.content` accepts `*children` so simply placing a button or link will render that component. + + +```python +def bubble_link_button_demo(): + return rx.el.div( + bubble.root( + bubble.content("How can I help you today?"), + variant="muted", + ), + bubble.group( + bubble.root( + bubble.content( + rx.el.button( + "I forgot my password", + on_click=rx.toast("You clicked forgot password"), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content( + rx.el.button( + "I need help with my subscription", + on_click=rx.toast("You clicked help with subscription"), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + bubble.root( + bubble.content( + rx.el.button( + "Something else. Talk to a human.", + on_click=rx.toast( + "You clicked something else. Talk to a human." + ), + class_name="w-full text-left", + ) + ), + variant="tinted", + align="end", + ), + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +## Reactions + +Use `bubble.reactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason. + + +```python +def bubble_reactions_demo(): + return rx.el.div( + bubble.root( + bubble.content("I don't need tests, I know my code works."), + bubble.reactions( + rx.el.span("👍"), + rx.el.span("😮"), + align="start", + role="img", + aria_label="Reactions: thumbs up, surprised", + ), + variant="muted", + align="end", + ), + bubble.root( + bubble.content( + "Bold. Fine I'll add some tests. I'll let you know when they're done." + ), + bubble.reactions( + rx.el.span("👀"), + rx.el.span("🚀"), + rx.el.span("+2"), + role="img", + aria_label="Reactions: eyes, rocket, and 2 more", + ), + variant="muted", + ), + bubble.root( + bubble.content( + "Tests passed on the first try. All 142 of them. Looking good!" + ), + bubble.reactions( + rx.el.span("🎉"), + rx.el.span("👏"), + side="top", + align="start", + role="img", + aria_label="Reactions: party popper, clapping hands", + ), + variant="default", + align="end", + ), + bubble.root( + bubble.content("Are you sure I can run this command?"), + bubble.reactions( + rx.el.button( + "Yes, run it", + on_click=rx.toast.success("You clicked yes, running command..."), + class_name="px-2 py-0.5 text-xs hover:bg-accent rounded-md", + ), + ), + variant="destructive", + ), + class_name="flex w-full max-w-sm flex-col gap-12 py-12", + ) +``` + + + +## Show More / Collapsible + +Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `collapsible.trigger` component to trigger the collapsible content. + + +```python +def bubble_collapsible_demo(): + return rx.el.div( + bubble.root( + bubble.content("How can I help you today?"), + variant="muted", + ), + bubble.root( + bubble.content( + collapsible.root( + rx.el.div( + rx.cond(open_var.value, text_val, f"{text_val[:180]}..."), + class_name="whitespace-pre-line", + ), + collapsible.trigger( + rx.el.button( + rx.cond(open_var.value, "Show less", "Show more"), + rx.cond( + open_var.value, + hi("ArrowUp01Icon"), + hi("ArrowDown01Icon"), + ), + class_name="flex flex-row items-center gap-1 p-0 text-muted-foreground hover:underline", + ), + ), + open=open_var.value, + on_open_change=open_var.set_value, + ), + ), + variant="muted", + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +## Tooltip + +Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read. + + +```python +def bubble_tooltip_demo(): + return rx.el.div( + bubble.root( + bubble.content("Did you remove the stale route?"), + variant="secondary", + ), + bubble.root( + bubble.content("Yes, removed it from the registry."), + bubble.reactions( + tooltip.provider( + tooltip.root( + tooltip.trigger( + render_=button( + hi("Tick02Icon", class_name="size-4"), + variant="ghost", + class_name="w-6 h-6", + ) + ), + tooltip.portal( + tooltip.positioner( + tooltip.popup( + "Read on Jan 5, 2026 at 4:32 PM", + tooltip.arrow(), + ), + side="bottom", + ) + ), + ), + delay=0, + ) + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-4 py-12", + ) +``` + + +## Popover + +Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action. + + +```python +def bubble_popover_demo(): + return rx.el.div( + bubble.root( + bubble.content("Run the build script."), + align="end", + ), + bubble.root( + bubble.content("Failed to run the command."), + bubble.reactions( + popover.root( + popover.trigger( + render_=button( + hi("InformationCircleIcon"), + variant="ghost", + aria_label="Show error details", + class_name="w-6 h-6 aria-expanded:text-destructive", + ) + ), + popover.portal( + popover.backdrop(), + popover.positioner( + popover.popup( + popover.header( + popover.title( + "Command failed with exit code 1", + class_name="text-sm", + ), + popover.description( + "ENOENT: no such file or directory, open pnpm-lock.yaml", + class_name="text-sm", + ), + ), + ), + ), + ), + ) + ), + variant="destructive", + ), + class_name="flex w-full max-w-sm flex-col gap-4 py-12", + ) +``` + + +# Accessibility + +`bubble.root` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below. + +## Labeling Reactions + +Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria_label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria_hidden` is needed. + +```python +bubble.reactions( + rx.el.span("👍"), + rx.el.span("🔥"), + rx.el.span("+8"), + role="img", + aria_label="Reactions: thumbs up, fire, and 8 more" +) +``` + +When reactions are interactive, render buttons instead and give icon-only buttons an `aria_label`. + +```python +bubble.reactions( + button( + ..., + aria_label="Thumbs up", + variant="secondary", + size="sm" + ) +) +``` + +## Interactive Bubbles + +When a bubble is clickable, render it as a real `` or ``. `bubble.-*` content accept `*children` so simply passing in the interactive component will get rendered. `bubble.content` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed. + +```python +bubble.root( + bubble.content( + "I forgot my password", + rx.el.button(type="button", on_click=on_reply) + ), + variant="muted", + align="end" +) +``` + +## Meaning Beyond Color + +Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment. + +# API Reference + +## bubble.root + +The root bubble wrapper. + +| Prop | Type | Default | Description | +| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ | +| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. | +| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. | +| `class_name` | `string` | - | Additional classes to apply to the root element. | + +## bubble.content + +The bubble content wrapper. + +| Prop | Type | Default | Description | +| ----------- | -------------------------- | ------- | --------------------------------------------------------- | +| `*children` | `rx.Component` | - | Render the content as a different element such as a link. | +| `class_name` | `string` | - | Additional classes to apply to the content element. | + +## bubble.reactions + +Displays overlapped reactions for a bubble. + +| Prop | Type | Default | Description | +| ----------- | ------------------- | ---------- | ------------------------------------------------ | +| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. | +| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. | +| `class_name` | `string` | - | Additional classes to apply to the reaction row. | + +## bubble.group + +Groups consecutive bubbles from the same sender. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ---------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group root. | diff --git a/assets/docs/components/button.md b/assets/docs/components/button.md index e952522..c093a36 100644 --- a/assets/docs/components/button.md +++ b/assets/docs/components/button.md @@ -30,7 +30,7 @@ LiteralButtonVariant = Literal[ "primary", "destructive", "outline", "secondary", "ghost", "link", "dark" ] LiteralButtonSize = Literal[ - "xs", "sm", "md", "lg", "xl", "icon-xs", "icon-sm", "icon-md", "icon-lg", "icon-xl" + "default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg" ] DEFAULT_CLASS_NAME = ( @@ -45,31 +45,28 @@ BUTTON_VARIANTS = { "variant": { "default": "bg-primary text-primary-foreground hover:bg-primary/90", "destructive": ( - "bg-[var(--destructive)] text-white hover:bg-[var(--destructive)]/90 " - "focus-visible:ring-[var(--destructive)]/20 " - "dark:focus-visible:ring-[var(--destructive)]/40 " - "dark:bg-[var(--destructive)]/60" + "bg-destructive/10 text-destructive hover:bg-destructive/20 " + "focus-visible:border-destructive/40 focus-visible:ring-destructive/20 " + "dark:bg-destructive/20 dark:hover:bg-destructive/30" ), "outline": ( "border border-input bg-background shadow-xs text-foreground " - "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)] " - "dark:bg-[var(--input)]/30 dark:border-input " - "dark:hover:bg-[var(--input)]/50" - ), - "secondary": ("bg-secondary text-secondary-foreground hover:bg-secondary/80"), - "ghost": ( "hover:bg-accent hover:text-accent-foreground " - "dark:hover:bg-[var(--accent)]/50" + "dark:bg-input/30 dark:hover:bg-input/50" ), + "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80", + "ghost": "hover:bg-accent hover:text-accent-foreground dark:hover:bg-muted/50", "link": "text-primary underline-offset-4 hover:underline", }, "size": { - "default": "h-9 px-4 py-2 has-[>svg]:px-3", - "sm": "h-8 rounded-radius gap-1.5 px-2 has-[>svg]:px-2.5", - "lg": "h-10 rounded-radius px-6 has-[>svg]:px-4", - "icon": "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", + "default": "h-8 gap-1.5 px-2.5 has-[>svg]:px-2.5", + "xs": "h-6 rounded-[min(calc(var(--radius) * 0.8),10px)] gap-1 px-2 text-xs", + "sm": "h-7 rounded-[min(calc(var(--radius) * 0.8),12px)] gap-1 px-2.5 text-[0.8rem]", + "lg": "h-9 gap-1.5 px-2.5", + "icon": "size-8", + "icon-xs": "size-6 rounded-[min(calc(var(--radius) * 0.8),10px)]", + "icon-sm": "size-7 rounded-[min(calc(var(--radius) * 0.8),12px)]", + "icon-lg": "size-9", }, } diff --git a/assets/docs/components/collapsible.md b/assets/docs/components/collapsible.md index aee8295..465adcc 100644 --- a/assets/docs/components/collapsible.md +++ b/assets/docs/components/collapsible.md @@ -32,7 +32,7 @@ class ClassNames: ROOT = "flex flex-col justify-center text-secondary-12" TRIGGER = "group flex items-center gap-2" - PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm transition-all ease-out data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm data-[ending-style]:h-0 data-[starting-style]:h-0" class CollapsibleBaseComponent(BaseUIComponent): diff --git a/assets/docs/components/marker.md b/assets/docs/components/marker.md index 0d6ee7d..0ad13ea 100644 --- a/assets/docs/components/marker.md +++ b/assets/docs/components/marker.md @@ -118,6 +118,14 @@ marker.root( ``` +# Usage + + +```python +from components.ui.marker import Marker +``` + + # Examples ## Variants diff --git a/assets/docs/components/message.md b/assets/docs/components/message.md new file mode 100644 index 0000000..a7cb897 --- /dev/null +++ b/assets/docs/components/message.md @@ -0,0 +1,554 @@ + + +# Message + +Displays a message in a conversation, with optional avatar, header, footer, and alignment. + +> **Note:** The Message component is a fully custom implementation with no external dependencies. + +The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface. + +For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component. + +# Installation + +Copy the following code into your app directory. + +### CLI + +```bash +buridan add component message +``` + +### Manual Installation + +```python +"""Message component — chat bubble layout with avatar, content, header and footer slots.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..utils.twmerge import cn + +MessageAlign = Literal["start", "end"] + + +class ClassNames: + GROUP = "flex min-w-0 flex-col gap-2" + + ROOT = ( + "group/message relative flex w-full min-w-0 gap-2 text-sm " + "data-[align=end]:flex-row-reverse" + ) + + AVATAR = ( + "flex w-fit min-w-8 shrink-0 items-center justify-center " + "self-end overflow-hidden rounded-full bg-muted " + "group-has-data-[slot=message-footer]/message:-translate-y-8" + ) + + CONTENT = ( + "flex w-full min-w-0 flex-col gap-2.5 break-words " + "group-data-[align=end]/message:*:data-slot:self-end" + ) + + HEADER = ( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium " + "text-muted-foreground " + "group-has-data-[variant=ghost]/message:px-0" + ) + + FOOTER = ( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium " + "text-muted-foreground " + "group-has-data-[variant=ghost]/message:px-0 " + "group-data-[align=end]/message:justify-end" + ) + + +def message_group(*children, class_name: str = "", **props) -> rx.Component: + """Vertical stack of message rows.""" + return rx.el.div( + *children, + data_slot="message-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +def message_root( + *children, + align: MessageAlign = "start", + class_name: str = "", + **props, +) -> rx.Component: + """Single message row. Use align='end' for outgoing messages.""" + return rx.el.div( + *children, + data_slot="message", + data_align=align, + class_name=cn(ClassNames.ROOT, class_name), + **props, + ) + + +def message_avatar(*children, class_name: str = "", **props) -> rx.Component: + """Avatar slot — anchored to bottom of message, shifts up when footer is present.""" + return rx.el.div( + *children, + data_slot="message-avatar", + class_name=cn(ClassNames.AVATAR, class_name), + **props, + ) + + +def message_content(*children, class_name: str = "", **props) -> rx.Component: + """Content area — holds bubbles, header, and footer.""" + return rx.el.div( + *children, + data_slot="message-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def message_header(*children, class_name: str = "", **props) -> rx.Component: + """Sender name or timestamp above the bubble.""" + return rx.el.div( + *children, + data_slot="message-header", + class_name=cn(ClassNames.HEADER, class_name), + **props, + ) + + +def message_footer(*children, class_name: str = "", **props) -> rx.Component: + """Actions or reactions below the bubble.""" + return rx.el.div( + *children, + data_slot="message-footer", + class_name=cn(ClassNames.FOOTER, class_name), + **props, + ) + + +class Message(ComponentNamespace): + """Message namespace.""" + + group = staticmethod(message_group) + root = staticmethod(message_root) + avatar = staticmethod(message_avatar) + content = staticmethod(message_content) + header = staticmethod(message_header) + footer = staticmethod(message_footer) + + class_names = ClassNames + + +message = Message() +``` + + +# Usage + + +```python +from components.ui.message import Message +``` + + +**Note:** `Message` owns the row layout—avatar, alignment, header, and footer. +Render the visible message surface inside it with +[`Bubble`](/docs/components/bubble). For the scroll container around a +conversation, use [`MessageScroller`](/docs/components/message-scroller). + +# Anatomy +Use the following composition to build a `Message` component. + + +```python +message.group( + message.root( + message.avatar(), + message.content( + message.header(), + message.footer(), + ), + ), +) +``` + + +# Examples + +## Avatar + +Use `message.avatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message. + + +```python +def message_with_avatar(): + return rx.el.div( + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/03.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.root( + bubble.content("The build failed during dependency installation."), + variant="muted", + ), + ), + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/01.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.root( + bubble.content("Can you share the exact error?"), + ), + ), + align="end", + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/03.png", alt="@avatar"), + avatar.fallback("R"), + ), + ), + message.content( + bubble.group( + bubble.root( + bubble.content("Here's the error from the logs"), + variant="muted", + ), + bubble.root( + bubble.content( + "Something went wrong with the build. The libraries are not " + "installed correctly. Try running the build again." + ), + variant="muted", + ), + ), + ), + ), + class_name="flex w-full max-w-sm flex-col gap-6 py-12", + ) +``` + + +| align | Description | +| ------- | --------------------------------------------------- | +| `start` | Align the message to the start of the conversation. | +| `end` | Align the message to the end of the conversation. | + +## Group + +Use `message.group` to stack consecutive messages from the same sender. Render an empty `message.avatar` on the earlier messages to keep them aligned with the avatar on the last one. + + +```python +def message_with_group(): + return rx.el.div( + message.group( + message.root( + message.avatar(), + message.content( + bubble.root( + bubble.content("I checked the registry addresses."), + variant="muted", + ), + ), + ), + message.root( + message.avatar( + avatar.root( + avatar.image(src="/avatars/02.png", alt="@avatar"), + avatar.fallback("CN"), + ), + ), + message.content( + bubble.root( + bubble.content( + "The component and example JSON now live under the UI registry." + ), + variant="muted", + ), + ), + ), + ), + class_name="flex w-full max-w-sm flex-col gap-6 py-12", + ) +``` + + +## Header and Footer + +Use `message.header` for a sender name and `message.footer` for metadata such as a delivery or read status. + + +```python +def message_header_footer(): + return rx.el.div( + message.root( + message.content( + message.header("Olivia"), + bubble.root( + bubble.content("I already checked the logs."), + variant="muted", + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content( + "Send the report to the team. Ping @lineindent if you need help." + ), + ), + message.footer( + rx.el.div( + "Read ", + rx.el.span("Yesterday", class_name="font-normal"), + ), + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +## Actions + +Place message-level actions in `message.footer`, such as copy, retry, or feedback buttons. + + +```python +def message_with_actions(): + return rx.el.div( + message.root( + message.content( + bubble.root( + bubble.content( + "The install failure is coming from the workspace package." + ), + variant="muted", + ), + message.footer( + button( + hi("Copy01Icon"), + variant="ghost", + size="sm", + aria_label="Copy", + title="Copy", + ), + button( + hi("ThumbsUpIcon"), + variant="ghost", + size="sm", + aria_label="Like", + title="Like", + ), + button( + hi("ThumbsDownIcon"), + variant="ghost", + size="sm", + aria_label="Dislike", + title="Dislike", + ), + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content("Okay drop me a link. Taking a look..."), + ), + message.footer( + rx.el.span( + "Failed to send", + class_name="font-normal text-destructive", + ), + button( + hi("Refresh03Icon"), + variant="ghost", + size="sm", + title="Retry", + aria_label="Retry", + ), + class_name="gap-2", + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +## Attachment + +Use the [`Attachment`](/docs/components/attachment) with the messages to displays a file or image attachment with media, metadata, upload state, and actions. + + +```python +def message_with_attachment(): + return rx.el.div( + message.root( + message.content( + attachment.root( + attachment.media( + rx.el.img( + src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80", + alt="Workspace", + ), + variant="image", + ), + orientation="vertical", + ), + bubble.root( + bubble.content( + "Here's the image. Can you add it to the PDF? " + "Use it for the cover page." + ), + ), + ), + align="end", + ), + message.root( + message.content( + bubble.root( + bubble.content( + "Done. Here's the PDF with the image added as the cover page." + ), + variant="muted", + ), + attachment.root( + attachment.media( + hi("File02Icon"), + ), + attachment.content( + attachment.title("sales-dashboard.pdf"), + attachment.description("PDF · 2.4 MB"), + ), + attachment.actions( + attachment.action( + hi("Download02Icon"), + title="Download", + aria_label="Download", + ), + ), + ), + ), + ), + message.root( + message.content( + bubble.root( + bubble.content("Thanks. Looks good."), + ), + ), + align="end", + ), + class_name="flex w-full max-w-sm flex-col gap-8 py-12", + ) +``` + + +# Accessibility + +`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it. + +## Label icon-only actions + +Action buttons in `message.footer` are usually icon-only, so give each one an `aria-label`. + +```python +message.footer( + button( + hi("Refresh03Icon"), + variant="ghost", + size="sm", + title="Retry", + aria_label="Retry", + ), +) +``` + +## Status updates + +For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears. + +```python +message.root( + message.content( + marker.root( + marker.icon(spinner()), + marker.content("Compacting conversation"), + role="status", + ), + ), +) +``` + +# API Reference + +## message.root + +The message row wrapper. + +| Prop | Type | Default | Description | +| ----------- | ------------------ | --------- | ------------------------------------------------- | +| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. | +| `class_name` | `string` | - | Additional classes to apply to the row. | + +## message.group + +Groups consecutive messages from the same sender. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ---------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group root. | + +## message.avatar + +The avatar slot, aligned to the bottom of the message. When the message has a `message.footer`, the avatar shifts up to stay aligned with the message surface instead of the footer. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the avatar slot. | + +### message.content + +Wraps the header, message surface, and footer. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the content slot. | + +### message.header + +Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the header. | + +### message.footer + +Displays content below the message, such as status or actions. Aligns to the message side. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the footer. | diff --git a/assets/docs/components/tooltip.md b/assets/docs/components/tooltip.md index f4beb97..bf94282 100644 --- a/assets/docs/components/tooltip.md +++ b/assets/docs/components/tooltip.md @@ -40,7 +40,7 @@ class ClassNames: """Class names for tooltip components.""" TRIGGER = "inline-flex items-center justify-center" - POPUP = "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" + POPUP = "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-radius bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" ARROW = "data-[side=bottom]:top-[-7.5px] data-[side=left]:right-[-12.5px] data-[side=left]:rotate-90 data-[side=right]:left-[-12.5px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-7.5px] data-[side=top]:rotate-180" diff --git a/assets/fuse/searchList.json b/assets/fuse/searchList.json index 0fb90bc..65225cc 100644 --- a/assets/fuse/searchList.json +++ b/assets/fuse/searchList.json @@ -74,6 +74,11 @@ "title": "Accordion", "url": "docs/components/accordion" }, + { + "section": "Components", + "title": "Attachment", + "url": "docs/components/attachment" + }, { "section": "Components", "title": "Autocomplete", @@ -94,6 +99,11 @@ "title": "Breadcrumb", "url": "docs/components/breadcrumb" }, + { + "section": "Components", + "title": "Bubble", + "url": "docs/components/bubble" + }, { "section": "Components", "title": "Button", @@ -164,6 +174,11 @@ "title": "Menu", "url": "docs/components/menu" }, + { + "section": "Components", + "title": "Message", + "url": "docs/components/message" + }, { "section": "Components", "title": "Popover", diff --git a/assets/llms.txt b/assets/llms.txt index ead87fd..7b3f32a 100644 --- a/assets/llms.txt +++ b/assets/llms.txt @@ -19,10 +19,12 @@ ## Components - [Accordion](https://buridan.reflex.run/docs/components/accordion): The Accordion component. +- [Attachment](https://buridan.reflex.run/docs/components/attachment): The Attachment component. - [Autocomplete](https://buridan.reflex.run/docs/components/autocomplete): The Autocomplete component. - [Avatar](https://buridan.reflex.run/docs/components/avatar): The Avatar component. - [Badge](https://buridan.reflex.run/docs/components/badge): The Badge component. - [Breadcrumb](https://buridan.reflex.run/docs/components/breadcrumb): The Breadcrumb component. +- [Bubble](https://buridan.reflex.run/docs/components/bubble): The Bubble component. - [Button](https://buridan.reflex.run/docs/components/button): The Button component. - [Chart](https://buridan.reflex.run/docs/components/chart): The Chart component. - [Checkbox](https://buridan.reflex.run/docs/components/checkbox): The Checkbox component. @@ -37,6 +39,7 @@ - [Kbd](https://buridan.reflex.run/docs/components/kbd): The Kbd component. - [Marker](https://buridan.reflex.run/docs/components/marker): The Marker component. - [Menu](https://buridan.reflex.run/docs/components/menu): The Menu component. +- [Message](https://buridan.reflex.run/docs/components/message): The Message component. - [Popover](https://buridan.reflex.run/docs/components/popover): The Popover component. - [Scroll Area](https://buridan.reflex.run/docs/components/scroll-area): The Scroll Area component. - [Select](https://buridan.reflex.run/docs/components/select): The Select component. diff --git a/assets/sitemap.xml b/assets/sitemap.xml index a087202..4fde5e6 100644 --- a/assets/sitemap.xml +++ b/assets/sitemap.xml @@ -16,10 +16,12 @@ https://buridan.reflex.run/docs/resources/state https://buridan.reflex.run/docs/resources/theming https://buridan.reflex.run/docs/components/accordion + https://buridan.reflex.run/docs/components/attachment https://buridan.reflex.run/docs/components/autocomplete https://buridan.reflex.run/docs/components/avatar https://buridan.reflex.run/docs/components/badge https://buridan.reflex.run/docs/components/breadcrumb + https://buridan.reflex.run/docs/components/bubble https://buridan.reflex.run/docs/components/button https://buridan.reflex.run/docs/components/chart https://buridan.reflex.run/docs/components/checkbox @@ -34,6 +36,7 @@ https://buridan.reflex.run/docs/components/kbd https://buridan.reflex.run/docs/components/marker https://buridan.reflex.run/docs/components/menu + https://buridan.reflex.run/docs/components/message https://buridan.reflex.run/docs/components/popover https://buridan.reflex.run/docs/components/scroll-area https://buridan.reflex.run/docs/components/select @@ -54,4 +57,6 @@ https://buridan.reflex.run/docs/charts/pie-chart https://buridan.reflex.run/docs/charts/radar-chart https://buridan.reflex.run/docs/charts/scatter-chart + https://buridan.reflex.run/docs/utilities/scroll-fade + https://buridan.reflex.run/docs/utilities/shimmer \ No newline at end of file diff --git a/assets/social/attachment.webp b/assets/social/attachment.webp new file mode 100644 index 0000000..1dfaf45 Binary files /dev/null and b/assets/social/attachment.webp differ diff --git a/assets/social/bubble.webp b/assets/social/bubble.webp new file mode 100644 index 0000000..09b4580 Binary files /dev/null and b/assets/social/bubble.webp differ diff --git a/assets/social/message.webp b/assets/social/message.webp new file mode 100644 index 0000000..0f5c760 Binary files /dev/null and b/assets/social/message.webp differ diff --git a/components/ui/attachment.py b/components/ui/attachment.py new file mode 100644 index 0000000..c9c22d8 --- /dev/null +++ b/components/ui/attachment.py @@ -0,0 +1,262 @@ +"""Attachment component — file and image attachment previews for chat UI.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..ui.button import button +from ..utils.twmerge import cn + +AttachmentOrientation = Literal["horizontal", "vertical"] +AttachmentSize = Literal["default", "sm", "xs"] +AttachmentState = Literal["idle", "uploading", "processing", "error", "done"] +AttachmentMediaVariant = Literal["icon", "image"] + + +class ClassNames: + ROOT_BASE = ( + "group/attachment relative flex w-full max-w-full min-w-0 shrink-0 flex-wrap " + "rounded-2xl border border-input bg-card text-card-foreground transition-colors " + "focus-within:ring-1 focus-within:ring-ring/30 " + "has-[>a,>button]:hover:bg-muted/50 " + "data-[state=error]:border-destructive/30 " + "data-[state=idle]:border-dashed" + ) + + SIZES: dict[str, str] = { + "default": ( + "gap-2 text-sm " + "has-data-[slot=attachment-content]:px-2.5 " + "has-data-[slot=attachment-content]:py-2 " + "has-data-[slot=attachment-media]:p-2" + ), + "sm": ( + "gap-2.5 text-xs " + "has-data-[slot=attachment-content]:px-2 " + "has-data-[slot=attachment-content]:py-1.5 " + "has-data-[slot=attachment-media]:p-1.5" + ), + "xs": ( + "gap-1.5 rounded-xl text-xs " + "has-data-[slot=attachment-content]:px-1.5 " + "has-data-[slot=attachment-content]:py-1 " + "has-data-[slot=attachment-media]:p-1" + ), + } + + ORIENTATIONS: dict[str, str] = { + "horizontal": "min-w-40 items-center", + "vertical": "w-24 flex-col has-data-[slot=attachment-content]:w-30", + } + + MEDIA_BASE = ( + "relative flex aspect-square w-10 shrink-0 items-center justify-center " + "overflow-hidden rounded-lg bg-muted text-foreground " + "group-data-[orientation=vertical]/attachment:w-full " + "group-data-[size=sm]/attachment:w-8 " + "group-data-[size=xs]/attachment:w-7 " + "group-data-[size=xs]/attachment:rounded-md " + "group-data-[state=error]/attachment:bg-destructive/10 " + "group-data-[state=error]/attachment:text-destructive " + "[&_svg]:pointer-events-none " + "[&_svg:not([class*='size-'])]:size-4 " + "group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 " + "group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5" + ) + + MEDIA_VARIANTS: dict[str, str] = { + "icon": "", + "image": ( + "opacity-60 " + "group-data-[state=done]/attachment:opacity-100 " + "group-data-[state=idle]/attachment:opacity-100 " + "*:[img]:aspect-square *:[img]:w-full *:[img]:object-cover" + ), + } + + CONTENT = ( + "max-w-full min-w-0 flex-1 leading-tight " + "group-data-[orientation=vertical]/attachment:px-1" + ) + + TITLE = ( + "block max-w-full min-w-0 truncate font-medium " + "group-data-[state=processing]/attachment:shimmer " + "group-data-[state=uploading]/attachment:shimmer" + ) + + DESCRIPTION = ( + "mt-0.5 block min-w-0 max-w-full truncate text-xs text-muted-foreground " + "group-data-[state=error]/attachment:text-destructive/80" + ) + + ACTIONS = ( + "relative z-20 flex shrink-0 items-center " + "group-data-[orientation=vertical]/attachment:absolute " + "group-data-[orientation=vertical]/attachment:top-3 " + "group-data-[orientation=vertical]/attachment:right-3 " + "group-data-[orientation=vertical]/attachment:gap-1" + ) + + TRIGGER = "absolute inset-0 z-10 outline-none" + + GROUP = ( + "flex scroll-fade-x min-w-0 snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 " + "overflow-x-auto overscroll-x-contain py-1 " + "*:data-[slot=attachment]:flex-none " + "*:data-[slot=attachment]:snap-start" + ) + + +def attachment_root( + *children, + orientation: AttachmentOrientation = "horizontal", + size: AttachmentSize = "default", + state: AttachmentState = "done", + class_name: str = "", + **props, +) -> rx.Component: + """ + Root attachment container. + + orientation: horizontal (default) | vertical + size: default | sm | xs + state: idle | uploading | processing | error | done (default) + """ + return rx.el.div( + *children, + data_slot="attachment", + data_state=state, + data_size=size, + data_orientation=orientation, + class_name=cn( + ClassNames.ROOT_BASE, + ClassNames.SIZES.get(size, ""), + ClassNames.ORIENTATIONS.get(orientation, ""), + class_name, + ), + **props, + ) + + +def attachment_media( + *children, + variant: AttachmentMediaVariant = "icon", + class_name: str = "", + **props, +) -> rx.Component: + """Icon or image preview slot.""" + return rx.el.div( + *children, + data_slot="attachment-media", + data_variant=variant, + class_name=cn( + ClassNames.MEDIA_BASE, + ClassNames.MEDIA_VARIANTS.get(variant, ""), + class_name, + ), + **props, + ) + + +def attachment_content(*children, class_name: str = "", **props) -> rx.Component: + """Text content area — holds title and description.""" + return rx.el.div( + *children, + data_slot="attachment-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def attachment_title(*children, class_name: str = "", **props) -> rx.Component: + """Filename or attachment title. Shimmers during uploading/processing.""" + return rx.el.span( + *children, + data_slot="attachment-title", + class_name=cn(ClassNames.TITLE, class_name), + **props, + ) + + +def attachment_description(*children, class_name: str = "", **props) -> rx.Component: + """File type, size, or other metadata.""" + return rx.el.span( + *children, + data_slot="attachment-description", + class_name=cn(ClassNames.DESCRIPTION, class_name), + **props, + ) + + +def attachment_actions(*children, class_name: str = "", **props) -> rx.Component: + """ + Row of action buttons. + In vertical orientation, absolutely positioned top-right. + """ + return rx.el.div( + *children, + data_slot="attachment-actions", + class_name=cn(ClassNames.ACTIONS, class_name), + **props, + ) + + +def attachment_action(*children, class_name: str = "", **props) -> rx.Component: + """Individual action button.""" + props.setdefault("variant", "ghost") + props.setdefault("size", "icon-xs") + return button( + *children, + data_slot="attachment-action", + class_name=cn(class_name), + **props, + ) + + +def attachment_trigger( + *children, link: bool = False, class_name: str = "", **props +) -> rx.Component: + + component_fn = rx.el.a if link else rx.el.button + + props.setdefault("data_slot", "attachment-trigger") + props.setdefault("class_name", cn(ClassNames.TRIGGER, class_name)) + + if not link: + props.setdefault("type", "button") + + return component_fn(*children, **props) + + +def attachment_group(*children, class_name: str = "", **props) -> rx.Component: + """ + Horizontal scrolling row of attachments. + Snaps to each attachment on scroll. + """ + return rx.el.div( + *children, + data_slot="attachment-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +class Attachment(ComponentNamespace): + """Attachment namespace.""" + + root = staticmethod(attachment_root) + media = staticmethod(attachment_media) + content = staticmethod(attachment_content) + title = staticmethod(attachment_title) + description = staticmethod(attachment_description) + actions = staticmethod(attachment_actions) + action = staticmethod(attachment_action) + trigger = staticmethod(attachment_trigger) + group = staticmethod(attachment_group) + + class_names = ClassNames + + +attachment = Attachment() diff --git a/components/ui/bubble.py b/components/ui/bubble.py new file mode 100644 index 0000000..23ff265 --- /dev/null +++ b/components/ui/bubble.py @@ -0,0 +1,167 @@ +"""Bubble component — chat message bubble with variants, content, and reactions slots.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..utils.twmerge import cn + +BubbleVariant = Literal[ + "default", "secondary", "muted", "tinted", "outline", "ghost", "destructive" +] +BubbleAlign = Literal["start", "end"] +BubbleSide = Literal["top", "bottom"] + + +class ClassNames: + GROUP = "flex min-w-0 flex-col gap-2" + + VARIANTS: dict[str, str] = { + "default": ( + "*:data-[slot=bubble-content]:bg-primary " + "*:data-[slot=bubble-content]:text-primary-foreground" + ), + "secondary": ( + "*:data-[slot=bubble-content]:bg-secondary " + "*:data-[slot=bubble-content]:text-secondary-foreground" + ), + "muted": ("*:data-[slot=bubble-content]:bg-muted"), + "tinted": ( + "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] " + "*:data-[slot=bubble-content]:text-foreground " + "dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)]" + ), + "outline": ( + "*:data-[slot=bubble-content]:border-border " + "*:data-[slot=bubble-content]:bg-background" + ), + "ghost": ( + "border-none " + "*:data-[slot=bubble-content]:rounded-none " + "*:data-[slot=bubble-content]:bg-transparent " + "*:data-[slot=bubble-content]:p-0" + ), + "destructive": ( + "*:data-[slot=bubble-content]:bg-destructive/10 " + "*:data-[slot=bubble-content]:text-destructive " + "dark:*:data-[slot=bubble-content]:bg-destructive/20" + ), + } + + ROOT = ( + "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 " + "group-data-[align=end]/message:self-end " + "data-[align=end]:self-end " + "data-[variant=ghost]:max-w-full" + ) + + CONTENT = ( + "w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent " + "px-3 py-2.5 text-sm leading-relaxed break-words " + "group-data-[align=end]/bubble:self-end" + ) + + REACTIONS_BASE = ( + "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 " + "rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0" + ) + + REACTIONS_SIDE: dict[str, str] = { + "top": "top-0 -translate-y-3/4", + "bottom": "bottom-0 translate-y-3/4", + } + + REACTIONS_ALIGN: dict[str, str] = { + "start": "left-3", + "end": "right-3", + } + + +def bubble_group(*children, class_name: str = "", **props) -> rx.Component: + """Vertical stack of bubbles.""" + return rx.el.div( + *children, + data_slot="bubble-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +def bubble_root( + *children, + variant: BubbleVariant = "default", + align: BubbleAlign = "start", + class_name: str = "", + **props, +) -> rx.Component: + """ + Bubble root container. + + Variants: default, secondary, muted, tinted, outline, ghost, destructive + Align: start (incoming), end (outgoing) + """ + return rx.el.div( + *children, + data_slot="bubble", + data_variant=variant, + data_align=align, + class_name=cn( + ClassNames.ROOT, + ClassNames.VARIANTS.get(variant, ""), + class_name, + ), + **props, + ) + + +def bubble_content(*children, class_name: str = "", **props) -> rx.Component: + """The bubble content pill — rounded, padded, colored by the parent variant.""" + return rx.el.div( + *children, + data_slot="bubble-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def bubble_reactions( + *children, + side: BubbleSide = "bottom", + align: BubbleAlign = "end", + class_name: str = "", + **props, +) -> rx.Component: + """ + Emoji reactions overlay — positioned relative to the bubble. + + Side: top | bottom (default: bottom) + Align: start | end (default: end) + """ + return rx.el.div( + *children, + data_slot="bubble-reactions", + data_align=align, + data_side=side, + class_name=cn( + ClassNames.REACTIONS_BASE, + ClassNames.REACTIONS_SIDE.get(side, ""), + ClassNames.REACTIONS_ALIGN.get(align, ""), + class_name, + ), + **props, + ) + + +class Bubble(ComponentNamespace): + """Bubble namespace.""" + + group = staticmethod(bubble_group) + root = staticmethod(bubble_root) + content = staticmethod(bubble_content) + reactions = staticmethod(bubble_reactions) + + class_names = ClassNames + + +bubble = Bubble() diff --git a/components/ui/button.py b/components/ui/button.py index 106d88b..9fa7a89 100644 --- a/components/ui/button.py +++ b/components/ui/button.py @@ -11,7 +11,7 @@ "primary", "destructive", "outline", "secondary", "ghost", "link", "dark" ] LiteralButtonSize = Literal[ - "xs", "sm", "md", "lg", "xl", "icon-xs", "icon-sm", "icon-md", "icon-lg", "icon-xl" + "default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg" ] DEFAULT_CLASS_NAME = ( @@ -26,31 +26,28 @@ "variant": { "default": "bg-primary text-primary-foreground hover:bg-primary/90", "destructive": ( - "bg-[var(--destructive)] text-white hover:bg-[var(--destructive)]/90 " - "focus-visible:ring-[var(--destructive)]/20 " - "dark:focus-visible:ring-[var(--destructive)]/40 " - "dark:bg-[var(--destructive)]/60" + "bg-destructive/10 text-destructive hover:bg-destructive/20 " + "focus-visible:border-destructive/40 focus-visible:ring-destructive/20 " + "dark:bg-destructive/20 dark:hover:bg-destructive/30" ), "outline": ( "border border-input bg-background shadow-xs text-foreground " - "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)] " - "dark:bg-[var(--input)]/30 dark:border-input " - "dark:hover:bg-[var(--input)]/50" - ), - "secondary": ("bg-secondary text-secondary-foreground hover:bg-secondary/80"), - "ghost": ( "hover:bg-accent hover:text-accent-foreground " - "dark:hover:bg-[var(--accent)]/50" + "dark:bg-input/30 dark:hover:bg-input/50" ), + "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80", + "ghost": "hover:bg-accent hover:text-accent-foreground dark:hover:bg-muted/50", "link": "text-primary underline-offset-4 hover:underline", }, "size": { - "default": "h-9 px-4 py-2 has-[>svg]:px-3", - "sm": "h-8 rounded-radius gap-1.5 px-2 has-[>svg]:px-2.5", - "lg": "h-10 rounded-radius px-6 has-[>svg]:px-4", - "icon": "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", + "default": "h-8 gap-1.5 px-2.5 has-[>svg]:px-2.5", + "xs": "h-6 rounded-[min(calc(var(--radius) * 0.8),10px)] gap-1 px-2 text-xs", + "sm": "h-7 rounded-[min(calc(var(--radius) * 0.8),12px)] gap-1 px-2.5 text-[0.8rem]", + "lg": "h-9 gap-1.5 px-2.5", + "icon": "size-8", + "icon-xs": "size-6 rounded-[min(calc(var(--radius) * 0.8),10px)]", + "icon-sm": "size-7 rounded-[min(calc(var(--radius) * 0.8),12px)]", + "icon-lg": "size-9", }, } diff --git a/components/ui/collapsible.py b/components/ui/collapsible.py index 1e84ced..e8d2efc 100644 --- a/components/ui/collapsible.py +++ b/components/ui/collapsible.py @@ -13,7 +13,7 @@ class ClassNames: ROOT = "flex flex-col justify-center text-secondary-12" TRIGGER = "group flex items-center gap-2" - PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm transition-all ease-out data-[ending-style]:h-0 data-[starting-style]:h-0" + PANEL = "flex h-[var(--collapsible-panel-height)] flex-col justify-end overflow-hidden text-sm data-[ending-style]:h-0 data-[starting-style]:h-0" class CollapsibleBaseComponent(BaseUIComponent): diff --git a/components/ui/message.py b/components/ui/message.py new file mode 100644 index 0000000..a67c35a --- /dev/null +++ b/components/ui/message.py @@ -0,0 +1,125 @@ +"""Message component — chat bubble layout with avatar, content, header and footer slots.""" + +from typing import Literal + +import reflex as rx +from reflex.components.component import ComponentNamespace + +from ..utils.twmerge import cn + +MessageAlign = Literal["start", "end"] + + +class ClassNames: + GROUP = "flex min-w-0 flex-col gap-2" + + ROOT = ( + "group/message relative flex w-full min-w-0 gap-2 text-sm " + "data-[align=end]:flex-row-reverse" + ) + + AVATAR = ( + "flex w-fit min-w-8 shrink-0 items-center justify-center " + "self-end overflow-hidden rounded-full bg-muted " + "group-has-data-[slot=message-footer]/message:-translate-y-8" + ) + + CONTENT = ( + "flex w-full min-w-0 flex-col gap-2.5 break-words " + "group-data-[align=end]/message:*:data-slot:self-end" + ) + + HEADER = ( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium " + "text-muted-foreground " + "group-has-data-[variant=ghost]/message:px-0" + ) + + FOOTER = ( + "flex max-w-full min-w-0 items-center px-3 text-xs font-medium " + "text-muted-foreground " + "group-has-data-[variant=ghost]/message:px-0 " + "group-data-[align=end]/message:justify-end" + ) + + +def message_group(*children, class_name: str = "", **props) -> rx.Component: + """Vertical stack of message rows.""" + return rx.el.div( + *children, + data_slot="message-group", + class_name=cn(ClassNames.GROUP, class_name), + **props, + ) + + +def message_root( + *children, + align: MessageAlign = "start", + class_name: str = "", + **props, +) -> rx.Component: + """Single message row. Use align='end' for outgoing messages.""" + return rx.el.div( + *children, + data_slot="message", + data_align=align, + class_name=cn(ClassNames.ROOT, class_name), + **props, + ) + + +def message_avatar(*children, class_name: str = "", **props) -> rx.Component: + """Avatar slot — anchored to bottom of message, shifts up when footer is present.""" + return rx.el.div( + *children, + data_slot="message-avatar", + class_name=cn(ClassNames.AVATAR, class_name), + **props, + ) + + +def message_content(*children, class_name: str = "", **props) -> rx.Component: + """Content area — holds bubbles, header, and footer.""" + return rx.el.div( + *children, + data_slot="message-content", + class_name=cn(ClassNames.CONTENT, class_name), + **props, + ) + + +def message_header(*children, class_name: str = "", **props) -> rx.Component: + """Sender name or timestamp above the bubble.""" + return rx.el.div( + *children, + data_slot="message-header", + class_name=cn(ClassNames.HEADER, class_name), + **props, + ) + + +def message_footer(*children, class_name: str = "", **props) -> rx.Component: + """Actions or reactions below the bubble.""" + return rx.el.div( + *children, + data_slot="message-footer", + class_name=cn(ClassNames.FOOTER, class_name), + **props, + ) + + +class Message(ComponentNamespace): + """Message namespace.""" + + group = staticmethod(message_group) + root = staticmethod(message_root) + avatar = staticmethod(message_avatar) + content = staticmethod(message_content) + header = staticmethod(message_header) + footer = staticmethod(message_footer) + + class_names = ClassNames + + +message = Message() diff --git a/components/ui/message_scroller.py b/components/ui/message_scroller.py new file mode 100644 index 0000000..428c027 --- /dev/null +++ b/components/ui/message_scroller.py @@ -0,0 +1,384 @@ +""" +MessageScroller component — auto-scrolling chat message container. + +A self-contained JS implementation ported from @shadcn/react/message-scroller. +Handles auto-scroll-to-bottom, scroll mode tracking, resize observation, +scroll anchor items, and an animated scroll-to-bottom button. +""" + +import reflex as rx +from reflex.components.component import ComponentNamespace +from reflex.utils.imports import ImportVar + +from ..utils.twmerge import cn + + +class ClassNames: + ROOT = "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden" + VIEWPORT = ( + "size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain " + "scrollbar-thin scrollbar-gutter-stable " + "data-[autoscrolling=true]:scrollbar-none" + ) + CONTENT = "flex h-max min-h-full flex-col gap-6" + ITEM = "min-w-0 shrink-0" + BUTTON = ( + "absolute left-1/2 -translate-x-1/2 bottom-4 " + "flex items-center justify-center " + "size-8 rounded-full border border-border bg-background text-foreground shadow-md " + "transition-[transform,opacity] duration-200 " + "data-[active=false]:pointer-events-none " + "data-[active=false]:scale-95 " + "data-[active=false]:opacity-0 " + "data-[active=true]:scale-100 " + "data-[active=true]:opacity-100 " + "hover:bg-muted" + ) + + +BURIDAN_MESSAGE_SCROLLER_JS = """ +(function () { + const EDGE_THRESHOLD = 8; // px from bottom to count as "at end" + const AUTOSCROLL_RESET_MS = 180; + + // ── helpers ──────────────────────────────────────────────────────────────── + function atEnd(viewport) { + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= EDGE_THRESHOLD; + } + + function atStart(viewport) { + return viewport.scrollTop <= EDGE_THRESHOLD; + } + + function scrollableState(viewport) { + return { + start: viewport.scrollTop > EDGE_THRESHOLD, + end: !atEnd(viewport), + }; + } + + // ── BuridanMessageScrollerRoot ──────────────────────────────────────────── + function BuridanMessageScrollerRoot({ + autoScroll = false, + defaultScrollPosition = "end", + rootClass, + viewportClass, + contentClass, + children, + }) { + const rootRef = React.useRef(null); + const viewportRef = React.useRef(null); + const contentRef = React.useRef(null); + + // mode: "following-bottom" | "free-scrolling" + const modeRef = React.useRef(autoScroll ? "following-bottom" : "free-scrolling"); + const autoscrollingRef = React.useRef(false); + const autoscrollTimerRef = React.useRef(null); + const initializedRef = React.useRef(false); + + const [showButton, setShowButton] = React.useState(false); + + // ── scroll state sync ────────────────────────────────────────────────── + function syncState() { + const vp = viewportRef.current; + if (!vp) return; + const state = scrollableState(vp); + setShowButton(state.end); + + // Update data attributes for CSS selectors + const root = rootRef.current; + if (root) { + root.toggleAttribute("data-scrollable-start", state.start); + root.toggleAttribute("data-scrollable-end", state.end); + } + } + + // ── auto-scroll to bottom ────────────────────────────────────────────── + function scrollToEnd(behavior = "auto") { + const vp = viewportRef.current; + if (!vp) return; + + // Mark as autoscrolling so scrollbar hides during the scroll + autoscrollingRef.current = true; + vp.setAttribute("data-autoscrolling", "true"); + + vp.scrollTo({ top: vp.scrollHeight, behavior }); + + if (autoscrollTimerRef.current) clearTimeout(autoscrollTimerRef.current); + autoscrollTimerRef.current = setTimeout(() => { + autoscrollingRef.current = false; + vp.removeAttribute("data-autoscrolling"); + syncState(); + }, AUTOSCROLL_RESET_MS); + } + + function scrollToStart(behavior = "auto") { + const vp = viewportRef.current; + if (!vp) return; + vp.scrollTo({ top: 0, behavior }); + } + + // ── user scroll intent — switches to free-scrolling ──────────────────── + function handleUserScroll() { + if (autoscrollingRef.current) return; + const vp = viewportRef.current; + if (!vp) return; + if (atEnd(vp)) { + modeRef.current = "following-bottom"; + } else { + modeRef.current = "free-scrolling"; + } + syncState(); + } + + // ── content mutation observer — fires when messages are added ────────── + React.useLayoutEffect(() => { + const content = contentRef.current; + if (!content) return; + + const observer = new MutationObserver(() => { + if (!initializedRef.current) return; + if (modeRef.current === "following-bottom" && autoScroll) { + scrollToEnd("auto"); + } else { + syncState(); + } + }); + + observer.observe(content, { childList: true, subtree: false }); + return () => observer.disconnect(); + }, [autoScroll]); + + // ── resize observer on viewport ──────────────────────────────────────── + React.useEffect(() => { + const vp = viewportRef.current; + if (!vp || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(() => { + if (modeRef.current === "following-bottom" && autoScroll) { + scrollToEnd("auto"); + } else { + syncState(); + } + }); + + observer.observe(vp); + return () => observer.disconnect(); + }, [autoScroll]); + + // ── initial scroll position ──────────────────────────────────────────── + React.useLayoutEffect(() => { + const vp = viewportRef.current; + if (!vp || initializedRef.current) return; + + if (defaultScrollPosition === "end") { + vp.scrollTop = vp.scrollHeight; + } else if (defaultScrollPosition === "start") { + vp.scrollTop = 0; + } + + initializedRef.current = true; + syncState(); + }, [defaultScrollPosition]); + + // ── expose scroll methods on root element for imperative use ─────────── + React.useEffect(() => { + const root = rootRef.current; + if (!root) return; + root.__scrollToEnd = (b) => scrollToEnd(b ?? "smooth"); + root.__scrollToStart = (b) => scrollToStart(b ?? "smooth"); + }); + + return ( + + { + const scrollKeys = new Set(["ArrowDown","ArrowUp","End","Home","PageDown","PageUp"," "]); + if (scrollKeys.has(e.key)) handleUserScroll(); + }} + > + + {children} + + + + {/* Scroll-to-bottom button */} + { + modeRef.current = "following-bottom"; + scrollToEnd("smooth"); + }} + className={[ + "absolute left-1/2 -translate-x-1/2 bottom-4", + "flex items-center justify-center", + "size-8 rounded-full border border-border bg-background text-foreground shadow-md", + "transition-[transform,opacity] duration-200", + "data-[active=false]:pointer-events-none", + "data-[active=false]:scale-95", + "data-[active=false]:opacity-0", + "data-[active=true]:scale-100", + "data-[active=true]:opacity-100", + "hover:bg-muted cursor-pointer", + ].join(" ")} + > + + + + + Scroll to end + + + + ); + } + + // ── BuridanMessageScrollerItem ───────────────────────────────────────────── + function BuridanMessageScrollerItem({ + messageId, + scrollAnchor = false, + itemClass, + children, + }) { + return ( + + {children} + + ); + } + + if (typeof window !== "undefined") { + window.BuridanMessageScrollerRoot = BuridanMessageScrollerRoot; + window.BuridanMessageScrollerItem = BuridanMessageScrollerItem; + } + + // SSR-safe wrappers + function BuridanMessageScrollerRootSSR(props) { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { setMounted(true); }, []); + if (!mounted || typeof BuridanMessageScrollerRoot === "undefined") return null; + return React.createElement(BuridanMessageScrollerRoot, props); + } + + function BuridanMessageScrollerItemSSR(props) { + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => { setMounted(true); }, []); + if (!mounted || typeof BuridanMessageScrollerItem === "undefined") return null; + return React.createElement(BuridanMessageScrollerItem, props); + } + + if (typeof window !== "undefined") { + window.BuridanMessageScrollerRootSSR = BuridanMessageScrollerRootSSR; + window.BuridanMessageScrollerItemSSR = BuridanMessageScrollerItemSSR; + } +})(); +""" + + +class MessageScrollerRoot(rx.Component): + """ + Auto-scrolling chat container. + + Props: + auto_scroll — bool, auto-scroll to bottom when new messages arrive (default False) + default_scroll_position — "start" | "end" (default "end") + """ + + tag = "BuridanMessageScrollerRootSSR" + is_default = False + + auto_scroll: rx.Var[bool] + default_scroll_position: rx.Var[str] + root_class: rx.Var[str] + viewport_class: rx.Var[str] + content_class: rx.Var[str] + + def add_custom_code(self) -> list[str]: + return [BURIDAN_MESSAGE_SCROLLER_JS] + + def add_imports(self) -> dict: + return {"react": ImportVar(tag="React", is_default=True)} + + @classmethod + def create(cls, *children, **props): + props.setdefault("auto_scroll", False) + props.setdefault("default_scroll_position", "end") + props["root_class"] = cn(ClassNames.ROOT, props.pop("root_class", "")) + props["viewport_class"] = cn( + ClassNames.VIEWPORT, props.pop("viewport_class", "") + ) + props["content_class"] = cn(ClassNames.CONTENT, props.pop("content_class", "")) + return super().create(*children, **props) + + +class MessageScrollerItem(rx.Component): + """ + A single item in the scroller. + + Props: + message_id — str, unique ID for visibility tracking + scroll_anchor — bool, marks this item as a scroll anchor point (default False) + """ + + tag = "BuridanMessageScrollerItemSSR" + is_default = False + + message_id: rx.Var[str] + scroll_anchor: rx.Var[bool] + item_class: rx.Var[str] + + def add_custom_code(self) -> list[str]: + return [BURIDAN_MESSAGE_SCROLLER_JS] + + def add_imports(self) -> dict: + return {"react": ImportVar(tag="React", is_default=True)} + + @classmethod + def create(cls, *children, **props): + props.setdefault("scroll_anchor", False) + props["item_class"] = cn(ClassNames.ITEM, props.pop("item_class", "")) + return super().create(*children, **props) + + +class MessageScroller(ComponentNamespace): + """MessageScroller namespace.""" + + root = staticmethod(MessageScrollerRoot.create) + item = staticmethod(MessageScrollerItem.create) + + class_names = ClassNames + + +message_scroller = MessageScroller() diff --git a/components/ui/tooltip.py b/components/ui/tooltip.py index 7de203a..b5e40df 100644 --- a/components/ui/tooltip.py +++ b/components/ui/tooltip.py @@ -21,7 +21,7 @@ class ClassNames: """Class names for tooltip components.""" TRIGGER = "inline-flex items-center justify-center" - POPUP = "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" + POPUP = "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-radius bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95" ARROW = "data-[side=bottom]:top-[-7.5px] data-[side=left]:right-[-12.5px] data-[side=left]:rotate-90 data-[side=right]:left-[-12.5px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-7.5px] data-[side=top]:rotate-180" diff --git a/docs/components/attachment.md b/docs/components/attachment.md new file mode 100644 index 0000000..187377a --- /dev/null +++ b/docs/components/attachment.md @@ -0,0 +1,202 @@ +--- +title: "Attachment" +description: "Displays a file or image attachment with media, metadata, upload state, and actions." +order: 0 +--- + +# Attachment + +Displays a file or image attachment with media, metadata, upload state, and actions. + +# Installation + +Copy the following code into your app directory. + +--INSTALL(attachment)-- + +# Usage + +--USAGE(attachment)-- + +# Anatomy + +Use the following composition to build an `Attachment` component. + +--ANATOMY(attachment)-- + +# Features + +- Icon and image media through `attachment.media` +- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress +- Three sizes and horizontal or vertical orientation +- A full-card `attachment.trigger` that opens a link or dialog while the actions stay independently clickable +- Scrollable, snapping `attachment.group` with an edge fade +- Customizable styling through the `class_name` prop on every part + +# Examples + +## Image + +Set `variant="image"` on `attachment.media` and render an `rx.el.img()` inside it. Use `orientation="vertical"` to stack the media above the content. + +--DEMO(attachment_image_demo)-- + +## States + +Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment. + +--DEMO(attachment_states_demo)-- + + +## Sizes + +Use `size` to switch between `default`, `sm`, and `xs`. + +--DEMO(attachment_sizes_demo)-- + +## Group + +Wrap attachments in `attachment.group` to lay them out in a horizontally scrollable, snapping row with an edge fade. + +--DEMO(attachment_group_demo)-- + +## Trigger + +Add an `attachment.trigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable. + +--DEMO(attachment_trigger_dialog_demo)-- + + +# Accessibility + +`attachment.action` renders a `Button`, and `attachment.trigger` renders either a real `rx.el.button()` or a `rx.el.a()` if the `link` prop is set to `True`. Follow the guidance below so both are operable and announced. + +## Label icon-only actions + +`attachment.action` is usually icon-only, so give each one an `aria-label` describing the action and its target. + +```python +attachment.action( + hi("Cancel01Icon"), aria_label="Remove market-research.pdf" +) +``` + +## Label the trigger + +`attachment.trigger` overlays the entire attachment with a clickable surface. + +Use `aria_label` to describe what activating the attachment does. This is required when the trigger has no visible text. + +### Link trigger (opens a URL) + +```python +attachment.trigger( + link=True, + href=url, + target="_blank", + rel="noreferrer", + aria_label="Open workspace.png", +) +``` + +### Button trigger (interactive action) + +```python +attachment.trigger( + on_click=handle_open, + aria_label="Open attachment preview", +) +``` + + +The trigger sits behind the actions in the stacking order, so an `attachment.action` and the `attachment.trigger` never trap each other — both remain separately focusable and clickable. + +## Keyboard scrolling + +An `attachment.group` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`. + +## Meaning beyond color + +The `error` state uses a destructive color. Keep the failure reason in `attachment.description` so the state is not conveyed by color alone. + +# API Reference + +## attachment.root + +The root attachment container. + +| Prop | Type | Default | Description | +| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- | +| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. | +| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. | +| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. | +| `class_name` | `string` | - | Additional classes to apply to the root element. | + +## attachment.media + +The media slot for an icon or image preview. + +| Prop | Type | Default | Description | +| ----------- | ------------------- | -------- | ---------------------------------------------- | +| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an ``. | +| `class_name` | `string` | - | Additional classes to apply to the media slot. | + +## attachment.content + +Wraps the title and description. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the content slot. | + +## attachment.title + +The attachment name. Shimmers while the attachment is `uploading` or `processing`. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the title. | + +## attachment.description + +Secondary metadata such as the file type, size, or upload status. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the description. | + +## attachment.actions + +A container for one or more actions, aligned to the end of the attachment. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the actions. | + +## attachment.action + +An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props. + +| Prop | Type | Default | Description | +| ---------- | ------------------------------------- | ----------- | ---------------------------------------- | +| `size` | `Button["size"]` | `"icon-xs"` | The button size. | +| `class_name` | `string` | - | Additional classes to apply to the actions. | + +## attachment.trigger + +A full-card overlay that activates the attachment. Renders a `rx.el.button` by default or a `rx.el.a` when `link=True`. + +| Prop | Type | Default | Description | +| ------------ | --------------------- | ------- | ----------------------------------------------------------------------------- | +| `link` | `bool` | `False` | If set, renders an anchor (`rx.el.a`) instead of a button. | +| `aria_label` | `str \| None` | `None` | Accessibility label for screen readers. Required when no visible text exists. | +| `class_name` | `str` | `""` | Additional CSS classes applied to the trigger. | + + +## attachment.group + +Lays out attachments in a horizontally scrollable, snapping row. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group. | diff --git a/docs/components/bubble.md b/docs/components/bubble.md new file mode 100644 index 0000000..e9f83a7 --- /dev/null +++ b/docs/components/bubble.md @@ -0,0 +1,205 @@ +--- +title: Bubble +description: Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content. +order: 0 +--- + +# Bubble + +Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content. + +The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions. + +For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message). + +# Installation + +Copy the following code into your app directory. + +--INSTALL(bubble)-- + +# Usage + +--USAGE(bubble)-- + +# Anatomy +Use the following composition to build a `Bubble` component. + +--ANATOMY(bubble)-- + +# Features + +- Seven visual variants, from a strong primary bubble to unframed ghost content +- Start and end alignment for sender and receiver bubbles +- Reactions that anchor to the bubble edge with configurable side and alignment +- Bubbles size to their content, up to 80% of the container width +- Polymorphic content via `render` for link and button bubbles +- Customizable styling through the `class_name` prop on every part + +# Examples + +## Variants + +Use `variant` to change the visual treatment of the bubble. + +--DEMO(bubble_with_variants)-- + + +| Variant | Description | +| ------------- | ------------------------------------------------------ | +| `default` | A strong primary bubble, usually for the current user. | +| `secondary` | The standard neutral bubble for conversation content. | +| `muted` | A lower-emphasis bubble for quiet supporting content. | +| `tinted` | A subtle primary-tinted bubble. | +| `outline` | A bordered bubble for secondary or rich content. | +| `ghost` | Unframed content for assistant text or rich content. | +| `destructive` | A destructive bubble for error or failed actions. | + +A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row. + +## Alignment + +Use `align` on `bubble.root` to align the bubble to the start or end of the conversation. + +--DEMO(bubble_alignment_demo)-- + +| align | Description | +| ------- | -------------------------------------------------- | +| `start` | Align the bubble to the start of the conversation. | +| `end` | Align the bubble to the end of the conversation. | + +**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `message.root` component to automatically align the bubble to the start or end of the conversation. + +## Bubble Group + +Use `bubble.group` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `bubble.root` component itself, not the `bubble.group` component. + +```composition +bubble.group +├── bubble.root +│ └── bubble.content +└── bubble.root + └── bubble.content +``` + +--DEMO(bubble_group_demo)-- + +## Links and Buttons + +You can turn a bubble into a link or button by using the passing the interactive elements directly into the `bubble.content` slot. The `bubble.content` accepts `*children` so simply placing a button or link will render that component. + +--DEMO(bubble_link_button_demo)-- + +## Reactions + +Use `bubble.reactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason. + +--DEMO(bubble_reactions_demo)-- + + +## Show More / Collapsible + +Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `collapsible.trigger` component to trigger the collapsible content. + +--DEMO(bubble_collapsible_demo)-- + +## Tooltip + +Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read. + +--DEMO(bubble_tooltip_demo)-- + +## Popover + +Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action. + +--DEMO(bubble_popover_demo)-- + +# Accessibility + +`bubble.root` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below. + +## Labeling Reactions + +Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria_label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria_hidden` is needed. + +```python +bubble.reactions( + rx.el.span("👍"), + rx.el.span("🔥"), + rx.el.span("+8"), + role="img", + aria_label="Reactions: thumbs up, fire, and 8 more" +) +``` + +When reactions are interactive, render buttons instead and give icon-only buttons an `aria_label`. + +```python +bubble.reactions( + button( + ..., + aria_label="Thumbs up", + variant="secondary", + size="sm" + ) +) +``` + +## Interactive Bubbles + +When a bubble is clickable, render it as a real `` or ``. `bubble.-*` content accept `*children` so simply passing in the interactive component will get rendered. `bubble.content` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed. + +```python +bubble.root( + bubble.content( + "I forgot my password", + rx.el.button(type="button", on_click=on_reply) + ), + variant="muted", + align="end" +) +``` + +## Meaning Beyond Color + +Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment. + +# API Reference + +## bubble.root + +The root bubble wrapper. + +| Prop | Type | Default | Description | +| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ | +| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. | +| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. | +| `class_name` | `string` | - | Additional classes to apply to the root element. | + +## bubble.content + +The bubble content wrapper. + +| Prop | Type | Default | Description | +| ----------- | -------------------------- | ------- | --------------------------------------------------------- | +| `*children` | `rx.Component` | - | Render the content as a different element such as a link. | +| `class_name` | `string` | - | Additional classes to apply to the content element. | + +## bubble.reactions + +Displays overlapped reactions for a bubble. + +| Prop | Type | Default | Description | +| ----------- | ------------------- | ---------- | ------------------------------------------------ | +| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. | +| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. | +| `class_name` | `string` | - | Additional classes to apply to the reaction row. | + +## bubble.group + +Groups consecutive bubbles from the same sender. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ---------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group root. | diff --git a/docs/components/marker.md b/docs/components/marker.md index 9b50613..45b0696 100644 --- a/docs/components/marker.md +++ b/docs/components/marker.md @@ -18,6 +18,10 @@ Use the following composition to build a `Marker` component. --ANATOMY(marker)-- +# Usage + +--USAGE(marker)-- + # Examples ## Variants diff --git a/docs/components/message.md b/docs/components/message.md new file mode 100644 index 0000000..15fece3 --- /dev/null +++ b/docs/components/message.md @@ -0,0 +1,159 @@ +--- +title: "Message" +description: "Displays a message in a conversation, with optional avatar, header, footer, and alignment." +order: 13 +--- + +# Message + +Displays a message in a conversation, with optional avatar, header, footer, and alignment. + +> **Note:** The Message component is a fully custom implementation with no external dependencies. + +The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface. + +For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component. + +# Installation + +Copy the following code into your app directory. + +--INSTALL(message)-- + +# Usage + +--USAGE(message)-- + +**Note:** `Message` owns the row layout—avatar, alignment, header, and footer. +Render the visible message surface inside it with +[`Bubble`](/docs/components/bubble). For the scroll container around a +conversation, use [`MessageScroller`](/docs/components/message-scroller). + +# Anatomy +Use the following composition to build a `Message` component. + +--ANATOMY(message)-- + +# Examples + +## Avatar + +Use `message.avatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message. + +--DEMO(message_with_avatar)-- + +| align | Description | +| ------- | --------------------------------------------------- | +| `start` | Align the message to the start of the conversation. | +| `end` | Align the message to the end of the conversation. | + +## Group + +Use `message.group` to stack consecutive messages from the same sender. Render an empty `message.avatar` on the earlier messages to keep them aligned with the avatar on the last one. + +--DEMO(message_with_group)-- + +## Header and Footer + +Use `message.header` for a sender name and `message.footer` for metadata such as a delivery or read status. + +--DEMO(message_header_footer)-- + +## Actions + +Place message-level actions in `message.footer`, such as copy, retry, or feedback buttons. + +--DEMO(message_with_actions)-- + +## Attachment + +Use the [`Attachment`](/docs/components/attachment) with the messages to displays a file or image attachment with media, metadata, upload state, and actions. + +--DEMO(message_with_attachment)-- + +# Accessibility + +`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it. + +## Label icon-only actions + +Action buttons in `message.footer` are usually icon-only, so give each one an `aria-label`. + +```python +message.footer( + button( + hi("Refresh03Icon"), + variant="ghost", + size="sm", + title="Retry", + aria_label="Retry", + ), +) +``` + +## Status updates + +For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears. + +```python +message.root( + message.content( + marker.root( + marker.icon(spinner()), + marker.content("Compacting conversation"), + role="status", + ), + ), +) +``` + +# API Reference + +## message.root + +The message row wrapper. + +| Prop | Type | Default | Description | +| ----------- | ------------------ | --------- | ------------------------------------------------- | +| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. | +| `class_name` | `string` | - | Additional classes to apply to the row. | + +## message.group + +Groups consecutive messages from the same sender. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ---------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the group root. | + +## message.avatar + +The avatar slot, aligned to the bottom of the message. When the message has a `message.footer`, the avatar shifts up to stay aligned with the message surface instead of the footer. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ----------------------------------------------- | +| `class_name` | `string` | - | Additional classes to apply to the avatar slot. | + +### message.content + +Wraps the header, message surface, and footer. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the content slot. | + +### message.header + +Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the header. | + +### message.footer + +Displays content below the message, such as status or actions. Aligns to the message side. + +| Prop | Type | Default | Description | +| ----------- | -------- | ------- | ------------------------------------------ | +| `class_name` | `string` | - | Additional classes to apply to the footer. | diff --git a/reflex.lock/bun.lock b/reflex.lock/bun.lock index 5905c0f..f570231 100644 --- a/reflex.lock/bun.lock +++ b/reflex.lock/bun.lock @@ -19,6 +19,7 @@ "react-markdown": "10.1.0", "react-router": "7.15.0", "react-router-dom": "7.15.0", + "react-syntax-highlighter": "16.1.1", "recharts": "3.8.1", "rehype-katex": "7.0.1", "rehype-raw": "7.0.0", @@ -655,10 +656,14 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -701,6 +706,10 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -775,6 +784,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], @@ -919,6 +930,8 @@ "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], @@ -955,6 +968,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -965,6 +980,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], diff --git a/reflex.lock/package.json b/reflex.lock/package.json index 1455b88..6c912cf 100644 --- a/reflex.lock/package.json +++ b/reflex.lock/package.json @@ -21,6 +21,7 @@ "react-markdown": "10.1.0", "react-router": "7.15.0", "react-router-dom": "7.15.0", + "react-syntax-highlighter": "16.1.1", "recharts": "3.8.1", "rehype-katex": "7.0.1", "rehype-raw": "7.0.0", diff --git a/scripts/generate_sitemap.py b/scripts/generate_sitemap.py index a3029e5..388c026 100644 --- a/scripts/generate_sitemap.py +++ b/scripts/generate_sitemap.py @@ -8,6 +8,7 @@ CHARTS_URLS, GET_STARTED_URLS, RESOURCES_URLS, + UTILITIES, ) BASE_URL = "https://buridan.reflex.run" @@ -16,7 +17,9 @@ def generate_sitemap() -> str: - all_routes = GET_STARTED_URLS + RESOURCES_URLS + BASE_UI_COMPONENTS + CHARTS_URLS + all_routes = ( + GET_STARTED_URLS + RESOURCES_URLS + BASE_UI_COMPONENTS + CHARTS_URLS + UTILITIES + ) top = "\n".join(f" {BASE_URL}{path}" for path in TOP_LEVEL)