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 ` + + ); + } + + // ── 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 `