From 815366b7c743bdd577718605ba8af4836f8798d3 Mon Sep 17 00:00:00 2001 From: Ahmad Hakim Date: Mon, 29 Jun 2026 16:39:32 +0300 Subject: [PATCH] new: message, bubble, attachment comps. --- app/registry/components.py | 12 + app/templates/docsidebar.py | 37 +- app/www/anatomy.py | 25 + app/www/library/examples/attachment_group.py | 48 ++ .../library/examples/attachment_with_image.py | 61 ++ .../library/examples/attachment_with_sizes.py | 33 + .../examples/attachment_with_states.py | 78 ++ .../examples/attachment_with_trigger.py | 39 + app/www/library/examples/bubble_group.py | 39 + .../library/examples/bubble_link_button.py | 50 ++ app/www/library/examples/bubble_reactions.py | 60 ++ app/www/library/examples/bubble_show_more.py | 44 ++ app/www/library/examples/bubble_with_align.py | 22 + .../library/examples/bubble_with_popover.py | 49 ++ .../library/examples/bubble_with_tooltip.py | 43 + .../library/examples/bubble_with_variants.py | 64 ++ .../library/examples/message_with_actions.py | 67 ++ .../examples/message_with_attachments.py | 67 ++ .../library/examples/message_with_avatar.py | 62 ++ .../library/examples/message_with_group.py | 38 + .../examples/message_with_header_footer.py | 35 + assets/avatars/01.png | Bin 0 -> 13920 bytes assets/avatars/02.png | Bin 0 -> 15326 bytes assets/avatars/03.png | Bin 0 -> 14526 bytes assets/avatars/04.png | Bin 0 -> 14077 bytes assets/docs/components/attachment.md | 696 +++++++++++++++++ assets/docs/components/bubble.md | 732 ++++++++++++++++++ assets/docs/components/button.md | 33 +- assets/docs/components/collapsible.md | 2 +- assets/docs/components/marker.md | 8 + assets/docs/components/message.md | 554 +++++++++++++ assets/docs/components/tooltip.md | 2 +- assets/fuse/searchList.json | 15 + assets/llms.txt | 3 + assets/sitemap.xml | 5 + assets/social/attachment.webp | Bin 0 -> 22646 bytes assets/social/bubble.webp | Bin 0 -> 26916 bytes assets/social/message.webp | Bin 0 -> 22316 bytes components/ui/attachment.py | 262 +++++++ components/ui/bubble.py | 167 ++++ components/ui/button.py | 33 +- components/ui/collapsible.py | 2 +- components/ui/message.py | 125 +++ components/ui/message_scroller.py | 384 +++++++++ components/ui/tooltip.py | 2 +- docs/components/attachment.md | 202 +++++ docs/components/bubble.md | 205 +++++ docs/components/marker.md | 4 + docs/components/message.md | 159 ++++ reflex.lock/bun.lock | 17 + reflex.lock/package.json | 1 + scripts/generate_sitemap.py | 5 +- 52 files changed, 4548 insertions(+), 43 deletions(-) create mode 100644 app/www/library/examples/attachment_group.py create mode 100644 app/www/library/examples/attachment_with_image.py create mode 100644 app/www/library/examples/attachment_with_sizes.py create mode 100644 app/www/library/examples/attachment_with_states.py create mode 100644 app/www/library/examples/attachment_with_trigger.py create mode 100644 app/www/library/examples/bubble_group.py create mode 100644 app/www/library/examples/bubble_link_button.py create mode 100644 app/www/library/examples/bubble_reactions.py create mode 100644 app/www/library/examples/bubble_show_more.py create mode 100644 app/www/library/examples/bubble_with_align.py create mode 100644 app/www/library/examples/bubble_with_popover.py create mode 100644 app/www/library/examples/bubble_with_tooltip.py create mode 100644 app/www/library/examples/bubble_with_variants.py create mode 100644 app/www/library/examples/message_with_actions.py create mode 100644 app/www/library/examples/message_with_attachments.py create mode 100644 app/www/library/examples/message_with_avatar.py create mode 100644 app/www/library/examples/message_with_group.py create mode 100644 app/www/library/examples/message_with_header_footer.py create mode 100644 assets/avatars/01.png create mode 100644 assets/avatars/02.png create mode 100644 assets/avatars/03.png create mode 100644 assets/avatars/04.png create mode 100644 assets/docs/components/attachment.md create mode 100644 assets/docs/components/bubble.md create mode 100644 assets/docs/components/message.md create mode 100644 assets/social/attachment.webp create mode 100644 assets/social/bubble.webp create mode 100644 assets/social/message.webp create mode 100644 components/ui/attachment.py create mode 100644 components/ui/bubble.py create mode 100644 components/ui/message.py create mode 100644 components/ui/message_scroller.py create mode 100644 docs/components/attachment.md create mode 100644 docs/components/bubble.md create mode 100644 docs/components/message.md 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 0000000000000000000000000000000000000000..c19010109235ce8258c1c95c1f4c3b216d3167a0 GIT binary patch literal 13920 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 literal 0 HcmV?d00001 diff --git a/assets/avatars/02.png b/assets/avatars/02.png new file mode 100644 index 0000000000000000000000000000000000000000..b2aae01dd3cefd574c5d07c1f4438068017512da GIT binary patch literal 15326 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 literal 0 HcmV?d00001 diff --git a/assets/avatars/03.png b/assets/avatars/03.png new file mode 100644 index 0000000000000000000000000000000000000000..f04b6b0b0610c62c64bc54b1890a9f62c09caff1 GIT binary patch literal 14526 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 literal 0 HcmV?d00001 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 ` + + ); + } + + // ── 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 `