From 43e34f40cbf40232751f7ec2a13227a818c701d0 Mon Sep 17 00:00:00 2001 From: HuJean <7037477+HuJean@users.noreply.github.com> Date: Thu, 14 May 2026 17:41:49 +0800 Subject: [PATCH] feat: A2UI column template rendering --- .changeset/tame-bears-happen.md | 5 + .../genui/a2ui/src/catalog/Button/index.tsx | 16 ++- .../genui/a2ui/src/catalog/Column/index.tsx | 98 +++++++++++++++---- .../genui/a2ui/src/react/A2UIRenderer.tsx | 24 +++-- packages/genui/a2ui/styles/catalog/Button.css | 28 ++++-- packages/genui/a2ui/styles/catalog/Image.css | 1 - packages/genui/a2ui/styles/theme.css | 11 +++ 7 files changed, 145 insertions(+), 38 deletions(-) create mode 100644 .changeset/tame-bears-happen.md diff --git a/.changeset/tame-bears-happen.md b/.changeset/tame-bears-happen.md new file mode 100644 index 0000000000..d74a8315f1 --- /dev/null +++ b/.changeset/tame-bears-happen.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/a2ui-reactlynx": patch +--- + +Fix `Column` template rendering so nested bindings keep the correct `dataContextPath`, and preserve the caller-provided context in `NodeRenderer` instead of overwriting it from the stored component snapshot. diff --git a/packages/genui/a2ui/src/catalog/Button/index.tsx b/packages/genui/a2ui/src/catalog/Button/index.tsx index a424292f64..7a3a9d1d59 100644 --- a/packages/genui/a2ui/src/catalog/Button/index.tsx +++ b/packages/genui/a2ui/src/catalog/Button/index.tsx @@ -12,6 +12,7 @@ import '../../../styles/catalog/Button.css'; export interface ButtonProps extends GenericComponentProps { child: string; variant?: 'primary' | 'borderless'; + isValid?: boolean; /** v0.9 actions should use the `event` wrapper for server-dispatched clicks. */ action: { event: { @@ -25,10 +26,17 @@ export interface ButtonProps extends GenericComponentProps { export function Button( props: ButtonProps, ): import('@lynx-js/react').ReactNode { - const { action, child, surface, sendAction, variant = 'primary' } = props; + const { + action, + child, + isValid = true, + surface, + sendAction, + variant = 'primary', + } = props; const handleClick = () => { - if (action) { + if (isValid && action) { void sendAction?.(action as Record); } }; @@ -39,8 +47,8 @@ export function Button( return ( {childResource ? diff --git a/packages/genui/a2ui/src/catalog/Column/index.tsx b/packages/genui/a2ui/src/catalog/Column/index.tsx index 58bd723690..90b8a3efcf 100644 --- a/packages/genui/a2ui/src/catalog/Column/index.tsx +++ b/packages/genui/a2ui/src/catalog/Column/index.tsx @@ -1,11 +1,42 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { useMemo } from '@lynx-js/react'; + import { NodeRenderer } from '../../react/A2UIRenderer.jsx'; -import type { GenericComponentProps } from '../../store/types.js'; +import { useDataBinding } from '../../react/useDataBinding.js'; +import type { + ComponentInstance, + GenericComponentProps, + Surface, +} from '../../store/types.js'; import '../../../styles/catalog/Column.css'; +const buildChild = ( + surface: Surface, + childId: string, + dataContextPath: string | undefined, + childPath?: string, + key = childId, +): { + key: string; + component: ComponentInstance; +} | null => { + const child = surface.components.get(childId); + if (!child) return null; + let childWithContext = child; + if (childPath) { + childWithContext = { ...child, dataContextPath: childPath }; + } else if (dataContextPath) { + childWithContext = { ...child, dataContextPath }; + } + return { + key, + component: childWithContext, + }; +}; + /** * @a2uiCatalog Column */ @@ -26,33 +57,64 @@ export interface ColumnProps extends GenericComponentProps { export function Column( props: ColumnProps, ): import('@lynx-js/react').ReactNode { - const children = props.children; - const surface = props.surface; - const dataContextPath = props.dataContextPath; - const justify = props.justify as string | undefined ?? 'start'; - const align = props.align as string | undefined ?? 'stretch'; - const explicitChildren = Array.isArray(children) ? children : []; + const { + children, + surface, + dataContextPath, + justify = 'start', + align = 'stretch', + } = props; + + const isDynamic = children && !Array.isArray(children) + && typeof children === 'object'; + const template = isDynamic + ? children + : undefined; + + const [columnData, , fullPath] = useDataBinding[]>( + template ? { path: template.path } : undefined, + surface, + dataContextPath, + [], + ); + + const childList = useMemo(() => { + if (Array.isArray(children)) { + return children.map((childId: string) => + buildChild(surface, childId, dataContextPath) + ); + } + return (Array.isArray(columnData) ? columnData : []).map((item, index) => { + const key = item && typeof item === 'object' && 'key' in item + ? String(item['key']) + : `${index}`; + const itemPath = `${fullPath}/${index}`; + return buildChild( + surface, + template?.componentId ?? '', + dataContextPath, + itemPath, + key, + ); + }); + }, [children, surface, dataContextPath, columnData, fullPath, template]); return ( - {explicitChildren.map((childId: string) => { - const child = surface.components.get(childId); - if (!child) return null; - const childWithContext = dataContextPath - ? { ...child, dataContextPath: dataContextPath } - : child; - const weight = (child as unknown as { weight?: number }).weight; + {childList.map((item) => { + if (!item) return null; + const weight = item.component.weight; if (typeof weight === 'number' && weight > 0) { return ( @@ -60,8 +122,8 @@ export function Column( } return ( ); diff --git a/packages/genui/a2ui/src/react/A2UIRenderer.tsx b/packages/genui/a2ui/src/react/A2UIRenderer.tsx index c3c5182ef0..2832bc39ec 100644 --- a/packages/genui/a2ui/src/react/A2UIRenderer.tsx +++ b/packages/genui/a2ui/src/react/A2UIRenderer.tsx @@ -182,35 +182,43 @@ function NodeRendererImpl( const component = (latest.value as { component?: ComponentInstance } | undefined)?.component ?? initialComponent; + const effectiveComponent = initialComponent.dataContextPath !== undefined + && component.dataContextPath !== initialComponent.dataContextPath + ? { ...component, dataContextPath: initialComponent.dataContextPath } + : component; useEffect(() => { - const tag = component.component; + const tag = effectiveComponent.component; if (!catalog.has(tag) && !warnedTags.has(tag)) { warnedTags.add(tag); console.warn(`[a2ui] Component "${tag}" is not in the active catalog.`); } - }, [component.component, catalog]); + }, [effectiveComponent.component, catalog]); const [resolvedProps, setValue] = useResolvedProps( - component, + effectiveComponent, surface, - component.dataContextPath, + effectiveComponent.dataContextPath, ); const actionProps = useMemo( () => ({ - id: component.id!, + id: effectiveComponent.id!, surfaceId: surface.surfaceId, - dataContext: component.dataContextPath, + dataContext: effectiveComponent.dataContextPath, }), - [component.id, surface.surfaceId, component.dataContextPath], + [ + effectiveComponent.id, + surface.surfaceId, + effectiveComponent.dataContextPath, + ], ); const { sendAction } = useAction(actionProps); return ( <> {buildNodeRecursive( - component, + effectiveComponent, surface, catalog as ReadonlyMap< string, diff --git a/packages/genui/a2ui/styles/catalog/Button.css b/packages/genui/a2ui/styles/catalog/Button.css index d666bfc13c..4d1ffed4eb 100644 --- a/packages/genui/a2ui/styles/catalog/Button.css +++ b/packages/genui/a2ui/styles/catalog/Button.css @@ -8,30 +8,44 @@ gap: var(--a2ui-spacing-m); padding: var(--a2ui-spacing-m) calc(var(--a2ui-spacing-m) * 1.75); margin: calc(var(--a2ui-spacing-xs) * 2) 0; - border-radius: var(--a2ui-border-radius); - background-color: var(--a2ui-color-primary); - color: var(--a2ui-color-on-primary); + border: var(--a2ui-border-width) solid var(--a2ui-color-border); + border-radius: var(--a2ui-button-border-radius, var(--a2ui-border-radius)); + background: var(--a2ui-color-surface); + box-shadow: none; + color: var(--a2ui-color-on-secondary); + font-weight: normal; transition: + background-color 0.2s ease, transform 0.12s ease, - opacity 0.12s ease, - background-color 0.12s ease; + opacity 0.12s ease; } .button.ui-active { - background-color: var(--a2ui-color-primary-hover); + background-color: var(--a2ui-color-secondary-hover); } .button-primary { background-color: var(--a2ui-color-primary); + border: none; + color: var(--a2ui-color-on-primary); } .button-borderless { - background-color: transparent; + background: none; + border: none; padding-left: 0; padding-right: 0; color: var(--a2ui-color-primary); } +.button-disabled { + opacity: 0.55; +} + +.button-disabled.ui-active { + background-color: inherit; +} + .button .text-body, .button .text-caption, .button .text-h1, diff --git a/packages/genui/a2ui/styles/catalog/Image.css b/packages/genui/a2ui/styles/catalog/Image.css index c2e11c0281..0337c8b139 100644 --- a/packages/genui/a2ui/styles/catalog/Image.css +++ b/packages/genui/a2ui/styles/catalog/Image.css @@ -40,7 +40,6 @@ .a2ui-image { flex-shrink: 0; - padding: 8px; overflow: hidden; } diff --git a/packages/genui/a2ui/styles/theme.css b/packages/genui/a2ui/styles/theme.css index 066a53d2fe..3fbd9f3fa1 100644 --- a/packages/genui/a2ui/styles/theme.css +++ b/packages/genui/a2ui/styles/theme.css @@ -51,6 +51,15 @@ --a2ui-color-border: light-dark(#ccc, #444); --a2ui-border-width: 1px; --a2ui-border: 1px solid var(--a2ui-color-border, #ccc); + --a2ui-button-padding: var(--a2ui-spacing-m) calc( + var(--a2ui-spacing-m) * 1.75 + ); + --a2ui-button-margin: calc(var(--a2ui-spacing-xs) * 2) 0; + --a2ui-button-background: var(--a2ui-color-surface); + --a2ui-button-box-shadow: none; + --a2ui-button-font-weight: normal; + --a2ui-button-border: var(--a2ui-border-width) solid var(--a2ui-color-border); + --a2ui-button-border-radius: var(--a2ui-border-radius); --a2ui-font-family-title: inherit; --a2ui-font-family-monospace: monospace; @@ -99,4 +108,6 @@ --a2ui-icon-size-sm: var(--a2ui-font-size-m); --a2ui-icon-size-md: 24px; --a2ui-icon-size-lg: 32px; + --a2ui-image-small-feature-size: 120px; + --a2ui-image-large-feature-size: 400px; }