diff --git a/examples/iris/hello.py b/examples/iris/hello.py index ca3ed935c..c8d7e6bf9 100644 --- a/examples/iris/hello.py +++ b/examples/iris/hello.py @@ -9,6 +9,7 @@ get_df, plotly, sidebar, + tab, table, text, ) @@ -29,6 +30,14 @@ # Load the CSV df = get_df("iris_csv") +tab( + label="Data Views", + tabs=[ + {"title": "Intro", "components": [text("Welcome to the Iris app.")]}, + {"title": "Table", "components": [table(df)]}, + ], +) + # 1. Scatter plot - Sepal Length vs Sepal Width text( "## Sepal Length vs Sepal Width \n This scatter plot shows the relationship between sepal length and sepal width for different iris species. We can see that Setosa is well-separated from the other two species, while Versicolor and Virginica show some overlap." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0e277473..6790f7eb3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.2.2", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6", "@shadcn/ui": "^0.0.4", "@tailwindcss/typography": "^0.5.10", @@ -1971,6 +1972,104 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-scroll-area": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", @@ -2119,6 +2218,77 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index d83ace9d5..67f60a1aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.2.2", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6", "@shadcn/ui": "^0.0.4", "@tailwindcss/typography": "^0.5.10", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fae9a0950..0b858433f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -76,20 +76,61 @@ const App = () => { } try { - const updatedRows = components.rows.map((row) => - row.map((component) => { - if (!component || !component.id) return component; + const tabComponentRegistry = { + ids: new Set(), + content: new Set(), + }; + + components.rows.forEach((row) => { + row.forEach((component) => { + if (component?.type === 'tab' && component.tabs) { + component.tabs.forEach((tab) => { + (tab.components || []).forEach((tabComponent) => { + if (tabComponent?.id) { + tabComponentRegistry.ids.add(tabComponent.id); + } + if (typeof tabComponent === 'string') { + tabComponentRegistry.content.add(tabComponent.trim()); + } + const content = + tabComponent?.content?.trim() || + tabComponent?.markdown?.trim() || + (typeof tabComponent?.value === 'string' ? tabComponent.value.trim() : ''); + if (content) { + tabComponentRegistry.content.add(content); + } + }); + }); + } + }); + }); - const currentState = comm.getComponentState(component.id); - return { + const updatedRows = components.rows.map((row) => + row + .filter((component) => { + if (!component) return false; + if (component.type === 'tab') return true; + + const content = + component.content?.trim() || + component.markdown?.trim() || + (typeof component.value === 'string' ? component.value.trim() : ''); + + return !( + (component.id && tabComponentRegistry.ids.has(component.id)) || + (content && tabComponentRegistry.content.has(content)) + ); + }) + .map((component) => ({ ...component, - value: currentState !== undefined ? currentState : component.value, + value: component?.id + ? (comm.getComponentState(component.id) ?? component.value) + : component.value, error: null, - }; - }) + })) ); - console.log('[App] Updating components with:', { rows: updatedRows }); + console.log('[App] Final filtered components:', { rows: updatedRows }); setAreComponentsLoading(false); setComponents({ rows: updatedRows }); setError(null); diff --git a/frontend/src/components.css b/frontend/src/components.css index 91b65a38c..e4ee31f66 100644 --- a/frontend/src/components.css +++ b/frontend/src/components.css @@ -439,6 +439,94 @@ @apply transition-all duration-200 ease-in-out p-0 opacity-100; } +/* Tab Widget Styles */ +.tab-widget-container { + @apply w-full mb-6; +} + +.tab-widget-header { + @apply font-semibold text-lg mb-3; +} + +.tab-list { + @apply flex space-x-2 mb-3; +} + +.tab-trigger { + @apply px-4 py-2 text-sm font-medium rounded-md transition-colors; + @apply bg-muted text-muted-foreground hover:bg-muted/80; + @apply data-[state=active]:bg-primary data-[state=active]:text-primary-foreground; +} + +.tab-content { + @apply mt-4 outline-none; +} + +.tab-content-container { + @apply w-full; + contain: content; + overflow: hidden; +} + +.tab-content-inner { + @apply p-4 bg-background rounded-lg border border-border; +} + +/* Ensure tab content is properly contained */ +.tab-content > .dynamiccomponent-container { + @apply mt-0; +} + +/* Prevent double rendering of components meant for tabs */ +.dynamiccomponent-container:has(+ .tab-widget-container) { + @apply hidden; + contain: content; +} + +/* Special case for when tab is the only component */ +.dynamiccomponent-container:has( + > .dynamiccomponent-row > .dynamiccomponent-component > .tab-widget-container + ) { + @apply block; +} + +/* Loading state for tabs */ +.tab-loading { + @apply flex items-center justify-center h-32; +} + +.tab-loading-spinner { + @apply animate-spin rounded-full h-8 w-8 border-b-2 border-primary; +} + +.tab-loading-text { + @apply mt-2 text-sm text-muted-foreground; +} + +/* Empty tab state */ +.tab-empty { + @apply flex items-center justify-center h-32 text-sm text-muted-foreground; +} + +/* Error state for tabs */ +.tab-error { + @apply p-4 bg-destructive/10 text-destructive rounded-md; +} + +/* Tab content transitions */ +.tab-content[data-state='inactive'] { + @apply hidden; +} + +.tab-content[data-state='active'] { + @apply block; +} + +/* Ensure tab content doesn't affect layout when hidden */ +.tab-content-hidden { + @apply absolute opacity-0 pointer-events-none; +} + .spinner-container { @apply flex flex-col items-center justify-center gap-3 p-4; } diff --git a/frontend/src/components/DynamicComponents.jsx b/frontend/src/components/DynamicComponents.jsx index e7a2fef3c..213782ef3 100644 --- a/frontend/src/components/DynamicComponents.jsx +++ b/frontend/src/components/DynamicComponents.jsx @@ -27,28 +27,25 @@ import SeparatorWidget from './widgets/SeparatorWidget'; import SidebarWidget from './widgets/SidebarWidget'; import SliderWidget from './widgets/SliderWidget'; import SpinnerWidget from './widgets/SpinnerWidget'; +import TabWidget from './widgets/TabWidget'; import TableViewerWidget from './widgets/TableViewerWidget'; import TextInputWidget from './widgets/TextInputWidget'; import TopbarWidget from './widgets/TopbarWidget'; import UnknownWidget from './widgets/UnknownWidget'; const extractKeyProps = createExtractKeyProps(); - // Error boundary component class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } - static getDerivedStateFromError(error) { return { hasError: true, error }; } - componentDidCatch(error, errorInfo) { console.error('[DynamicComponents] Component Error:', error, errorInfo); } - render() { if (this.state.hasError) { return ( @@ -59,20 +56,16 @@ class ErrorBoundary extends React.Component { ); } - return this.props.children; } } - // Memoized component wrapper const MemoizedComponent = memo( ({ component, index, handleUpdate, extractKeyProps }) => { const [componentId, componentKey, props] = extractKeyProps(component, index); - switch (component.type) { case 'sidebar': return ; - case 'button': return ( ; - case 'slider': return ( ); - case 'text_input': return ( ); - case 'checkbox': return ( handleUpdate(componentId, value)} /> ); - case 'selectbox': return ( ); - case 'progress': return ( ); - case 'spinner': return ( ); - case 'alert': return ( ); - case 'image': return ( ); - case 'text': return ( ); - case 'chat': return ( ); + case 'tab': + return ( + { + console.log( + '[DynamicComponents] Tab component forwarding update:', + childComponentId, + value + ); + handleUpdate(childComponentId, value); + }} + /> + ); + case 'table': return ( ); - case 'plot': return ( ); - case 'dag': return ; - case 'fastplotlib_component': const { className, data, config, label, src } = component; return ( @@ -280,7 +278,6 @@ const MemoizedComponent = memo( clientId={comm.clientId} /> ); - case 'playground': return ( ); - case 'topbar': return ; - case 'separator': return ; - default: console.warn(`[DynamicComponents] Unknown component type: ${component.type}`); return ( @@ -323,19 +317,15 @@ const MemoizedComponent = memo( ); } ); - const DynamicComponents = ({ components, onComponentUpdate }) => { useEffect(() => { extractKeyProps.reset(); }, []); - console.log('[DynamicComponents] Rendering with components:', components); - if (!components?.rows) { console.warn('[DynamicComponents] No components or invalid structure received'); return null; } - const handleUpdate = (componentId, value) => { console.log(`[DynamicComponents] Component update triggered:`, { componentId, @@ -344,21 +334,17 @@ const DynamicComponents = ({ components, onComponentUpdate }) => { }); onComponentUpdate(componentId, value); }; - const renderRow = (row, rowIndex) => { if (!Array.isArray(row)) { console.warn(`[DynamicComponents] Invalid row at index ${rowIndex}`); return null; } - return (
{row.map((component, index) => { if (!component) return null; - const componentKey = component.id || `component-${index}`; const isSeparator = component.type === 'separator'; - return (
{
); }; - return (
{components.rows.map((row, index) => renderRow(row, index))}
); }; - export default memo(DynamicComponents); diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index c2ba4ca67..cf9fbc424 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -1,9 +1,10 @@ 'use client'; import { PanelLeft, PanelLeftClose } from 'lucide-react'; -import { Link, useLocation } from 'react-router-dom'; + import * as React from 'react'; import { useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; import TableOfContents from '@/components/TableOfContents'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 000000000..85d83beab --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/components/widgets/TabWidget.jsx b/frontend/src/components/widgets/TabWidget.jsx new file mode 100644 index 000000000..d066c64d0 --- /dev/null +++ b/frontend/src/components/widgets/TabWidget.jsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { Card } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +import DynamicComponents from '../DynamicComponents'; + +const TabWidget = ({ label, tabs = [], onComponentUpdate }) => { + const [activeTab, setActiveTab] = React.useState(tabs?.[0]?.title || ''); + + const normalizeComponents = (components, tabTitle) => { + if (!components) return []; + + return components.map((component, index) => { + if (typeof component === 'string') { + return { + id: `tab-${tabTitle}-text-${hashString(component)}-${index}`, + type: 'text', + content: component, + markdown: component, + value: component, + }; + } + + const baseId = component.id || `comp-${index}`; + return { + ...component, + id: `tab-${tabTitle}-${baseId}`, + type: component.type || 'text', + }; + }); + }; + + const hashString = (str) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); + }; + + return ( + +

{label}

+ + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + + {tabs.map((tab) => { + const normalizedComponents = normalizeComponents(tab.components || [], tab.title); + return ( + + + + ); + })} + +
+ ); +}; + +export default TabWidget; diff --git a/preswald/interfaces/__init__.py b/preswald/interfaces/__init__.py index 5d4cdb5c0..fc6ca8639 100644 --- a/preswald/interfaces/__init__.py +++ b/preswald/interfaces/__init__.py @@ -20,6 +20,7 @@ sidebar, slider, spinner, + tab, table, text, text_input, diff --git a/preswald/interfaces/components.py b/preswald/interfaces/components.py index 34673f0aa..71a4b0958 100644 --- a/preswald/interfaces/components.py +++ b/preswald/interfaces/components.py @@ -859,6 +859,38 @@ def sidebar(defaultopen: bool = False): return component +def tab( + label: str, + tabs: list[dict], + size: float = 1.0, +) -> None: + """ + tab() component that enables developers to + organize UI content into labeled tabs within their + Preswald apps—ideal for sectioning long dashboards, + multiple views, or split data insights. + + Args: + label: Text to show on the tab-bar + tabs: Sections to be placed under the tab (including their title and component) + size: Customizable, full-width by default (=1.0) + """ + + service = PreswaldService.get_instance() + component_id = generate_stable_id("tab", label) + + component = { + "type": "tab", + "id": component_id, + "label": label, + "size": size, + "tabs": tabs, + } + + service.append_component(component) + return component + + def table( data: pd.DataFrame, title: str | None = None, limit: int | None = None ) -> dict: