feat(Semantic): 增加 Drawer 语义部件并修复作用域层跟踪错位与预览裁剪

This commit is contained in:
chinboy
2026-09-10 16:57:18 +08:00
parent babbbd4c72
commit e969f2b7ed
25 changed files with 1706 additions and 42 deletions
@@ -70,5 +70,19 @@ public enum DrawerShowCaseLangResourceKind
P2ContentOpenLargeSizeN736px,
P2ContentOpenCustomSizeN400px,
P2ContentOpenCustomSizeN50,
P2ContentOpen
P2ContentOpen,
SemanticRootDescription,
SemanticMaskDescription,
SemanticSectionDescription,
SemanticHeaderDescription,
SemanticTitleDescription,
SemanticExtraDescription,
SemanticBodyDescription,
SemanticFooterDescription,
SemanticCloseDescription,
SemanticStageHint,
SemanticPreviewTitleText,
SemanticPreviewFooterLink,
SemanticStylesTitle,
SemanticStylesDescription
}
@@ -331,5 +331,75 @@
<source>Examples</source>
</segment>
</unit>
<unit id="SemanticBodyDescription">
<segment>
<source>Content element with flex ratio, padding and overflow scrolling styles.</source>
</segment>
</unit>
<unit id="SemanticCloseDescription">
<segment>
<source>Close button element with basic button styles.</source>
</segment>
</unit>
<unit id="SemanticExtraDescription">
<segment>
<source>Extra action element at the header trailing edge.</source>
</segment>
</unit>
<unit id="SemanticFooterDescription">
<segment>
<source>Footer element with padding and top border styles.</source>
</segment>
</unit>
<unit id="SemanticHeaderDescription">
<segment>
<source>Header element with layout, alignment, padding and typography styles.</source>
</segment>
</unit>
<unit id="SemanticMaskDescription">
<segment>
<source>Mask element with positioning, z-index, background and pointer-event controls.</source>
</segment>
</unit>
<unit id="SemanticPreviewFooterLink">
<segment>
<source>Footer</source>
</segment>
</unit>
<unit id="SemanticPreviewTitleText">
<segment>
<source>Title</source>
</segment>
</unit>
<unit id="SemanticRootDescription">
<segment>
<source>Root element that carries the semantic identity and the owner-level style customization entry.</source>
</segment>
</unit>
<unit id="SemanticSectionDescription">
<segment>
<source>Drawer panel container with layout, size, overflow and background styles.</source>
</segment>
</unit>
<unit id="SemanticStageHint">
<segment>
<source>The drawer renders inline and stays open in this area.</source>
</segment>
</unit>
<unit id="SemanticStylesDescription">
<segment>
<source>Customize every semantic part with the generated part style classes.</source>
</segment>
</unit>
<unit id="SemanticStylesTitle">
<segment>
<source>Semantic Styles</source>
</segment>
</unit>
<unit id="SemanticTitleDescription">
<segment>
<source>Title element with font weight, size and line-height styles.</source>
</segment>
</unit>
</file>
</xliff>
@@ -397,5 +397,89 @@
<target state="translated">Exemplos</target>
</segment>
</unit>
<unit id="SemanticBodyDescription">
<segment>
<source>Content element with flex ratio, padding and overflow scrolling styles.</source>
<target state="translated">Elemento de conteúdo com estilos de proporção flex, preenchimento e rolagem de overflow.</target>
</segment>
</unit>
<unit id="SemanticCloseDescription">
<segment>
<source>Close button element with basic button styles.</source>
<target state="translated">Elemento do botão de fechar com estilos básicos de botão.</target>
</segment>
</unit>
<unit id="SemanticExtraDescription">
<segment>
<source>Extra action element at the header trailing edge.</source>
<target state="translated">Elemento de ação extra na borda final do cabeçalho.</target>
</segment>
</unit>
<unit id="SemanticFooterDescription">
<segment>
<source>Footer element with padding and top border styles.</source>
<target state="translated">Elemento inferior com estilos de preenchimento e borda superior.</target>
</segment>
</unit>
<unit id="SemanticHeaderDescription">
<segment>
<source>Header element with layout, alignment, padding and typography styles.</source>
<target state="translated">Elemento de cabeçalho com estilos de layout, alinhamento, preenchimento e tipografia.</target>
</segment>
</unit>
<unit id="SemanticMaskDescription">
<segment>
<source>Mask element with positioning, z-index, background and pointer-event controls.</source>
<target state="translated">Elemento de máscara com posicionamento, z-index, cor de fundo e controles de eventos de ponteiro.</target>
</segment>
</unit>
<unit id="SemanticPreviewFooterLink">
<segment>
<source>Footer</source>
<target state="translated">Rodapé</target>
</segment>
</unit>
<unit id="SemanticPreviewTitleText">
<segment>
<source>Title</source>
<target state="translated">Título</target>
</segment>
</unit>
<unit id="SemanticRootDescription">
<segment>
<source>Root element that carries the semantic identity and the owner-level style customization entry.</source>
<target state="translated">Elemento raiz que carrega a identidade semântica e a entrada de personalização de estilo no nível do owner.</target>
</segment>
</unit>
<unit id="SemanticSectionDescription">
<segment>
<source>Drawer panel container with layout, size, overflow and background styles.</source>
<target state="translated">Contêiner do painel do drawer com estilos de layout, tamanho, overflow e fundo.</target>
</segment>
</unit>
<unit id="SemanticStageHint">
<segment>
<source>The drawer renders inline and stays open in this area.</source>
<target state="translated">O drawer é renderizado embutido e permanece aberto nesta área.</target>
</segment>
</unit>
<unit id="SemanticStylesDescription">
<segment>
<source>Customize every semantic part with the generated part style classes.</source>
<target state="translated">Personalize cada parte semântica com as classes de estilo de parte geradas.</target>
</segment>
</unit>
<unit id="SemanticStylesTitle">
<segment>
<source>Semantic Styles</source>
<target state="translated">Estilos semânticos</target>
</segment>
</unit>
<unit id="SemanticTitleDescription">
<segment>
<source>Title element with font weight, size and line-height styles.</source>
<target state="translated">Elemento de título com estilos de peso, tamanho e altura de linha da fonte.</target>
</segment>
</unit>
</file>
</xliff>
@@ -397,5 +397,89 @@
<target state="translated">示例</target>
</segment>
</unit>
<unit id="SemanticBodyDescription">
<segment>
<source>Content element with flex ratio, padding and overflow scrolling styles.</source>
<target state="translated">内容元素,包含占比、内边距、溢出滚动等内容区域的展示和布局样式。</target>
</segment>
</unit>
<unit id="SemanticCloseDescription">
<segment>
<source>Close button element with basic button styles.</source>
<target state="translated">关闭按钮元素,包含按钮的基础样式。</target>
</segment>
</unit>
<unit id="SemanticExtraDescription">
<segment>
<source>Extra action element at the header trailing edge.</source>
<target state="translated">额外操作元素,位于头部尾缘,控制额外操作内容的布局与样式。</target>
</segment>
</unit>
<unit id="SemanticFooterDescription">
<segment>
<source>Footer element with padding and top border styles.</source>
<target state="translated">底部元素,包含内边距、上边框等底部操作区域的样式。</target>
</segment>
</unit>
<unit id="SemanticHeaderDescription">
<segment>
<source>Header element with layout, alignment, padding and typography styles.</source>
<target state="translated">头部元素,包含布局、对齐方式、内边距、字体、下边框等头部区域的样式。</target>
</segment>
</unit>
<unit id="SemanticMaskDescription">
<segment>
<source>Mask element with positioning, z-index, background and pointer-event controls.</source>
<target state="translated">遮罩层元素,包含定位、层级、背景色、指针事件等遮罩层的样式与交互控制。</target>
</segment>
</unit>
<unit id="SemanticPreviewFooterLink">
<segment>
<source>Footer</source>
<target state="translated">页脚</target>
</segment>
</unit>
<unit id="SemanticPreviewTitleText">
<segment>
<source>Title</source>
<target state="translated">标题</target>
</segment>
</unit>
<unit id="SemanticRootDescription">
<segment>
<source>Root element that carries the semantic identity and the owner-level style customization entry.</source>
<target state="translated">根元素,承载语义身份与 owner 级样式定制入口。</target>
</segment>
</unit>
<unit id="SemanticSectionDescription">
<segment>
<source>Drawer panel container with layout, size, overflow and background styles.</source>
<target state="translated">抽屉面板容器元素,包含布局、宽高、溢出控制、背景色等抽屉主体的样式。</target>
</segment>
</unit>
<unit id="SemanticStageHint">
<segment>
<source>The drawer renders inline and stays open in this area.</source>
<target state="translated">抽屉在此区域内以内联方式常开呈现。</target>
</segment>
</unit>
<unit id="SemanticStylesDescription">
<segment>
<source>Customize every semantic part with the generated part style classes.</source>
<target state="translated">使用生成的部件 Style 类定制全部语义部件。</target>
</segment>
</unit>
<unit id="SemanticStylesTitle">
<segment>
<source>Semantic Styles</source>
<target state="translated">语义样式</target>
</segment>
</unit>
<unit id="SemanticTitleDescription">
<segment>
<source>Title element with font weight, size and line-height styles.</source>
<target state="translated">标题元素,包含字体权重、字体大小、行高等标题文字的样式。</target>
</segment>
</unit>
</file>
</xliff>
@@ -397,5 +397,89 @@
<target state="translated">示例</target>
</segment>
</unit>
<unit id="SemanticBodyDescription">
<segment>
<source>Content element with flex ratio, padding and overflow scrolling styles.</source>
<target state="translated">內容元素,包含佔比、內邊距、溢出滾動等內容區域的展示和佈局樣式。</target>
</segment>
</unit>
<unit id="SemanticCloseDescription">
<segment>
<source>Close button element with basic button styles.</source>
<target state="translated">關閉按鈕元素,包含按鈕的基礎樣式。</target>
</segment>
</unit>
<unit id="SemanticExtraDescription">
<segment>
<source>Extra action element at the header trailing edge.</source>
<target state="translated">額外操作元素,位於頭部尾緣,控制額外操作內容的佈局與樣式。</target>
</segment>
</unit>
<unit id="SemanticFooterDescription">
<segment>
<source>Footer element with padding and top border styles.</source>
<target state="translated">底部元素,包含內邊距、上邊框等底部操作區域的樣式。</target>
</segment>
</unit>
<unit id="SemanticHeaderDescription">
<segment>
<source>Header element with layout, alignment, padding and typography styles.</source>
<target state="translated">頭部元素,包含佈局、對齊方式、內邊距、字體、下邊框等頭部區域的樣式。</target>
</segment>
</unit>
<unit id="SemanticMaskDescription">
<segment>
<source>Mask element with positioning, z-index, background and pointer-event controls.</source>
<target state="translated">遮罩層元素,包含定位、層級、背景色、指針事件等遮罩層的樣式與互動控制。</target>
</segment>
</unit>
<unit id="SemanticPreviewFooterLink">
<segment>
<source>Footer</source>
<target state="translated">頁腳</target>
</segment>
</unit>
<unit id="SemanticPreviewTitleText">
<segment>
<source>Title</source>
<target state="translated">標題</target>
</segment>
</unit>
<unit id="SemanticRootDescription">
<segment>
<source>Root element that carries the semantic identity and the owner-level style customization entry.</source>
<target state="translated">根元素,承載語義身份與 owner 級樣式定製入口。</target>
</segment>
</unit>
<unit id="SemanticSectionDescription">
<segment>
<source>Drawer panel container with layout, size, overflow and background styles.</source>
<target state="translated">抽屜面板容器元素,包含佈局、寬高、溢出控制、背景色等抽屜主體的樣式。</target>
</segment>
</unit>
<unit id="SemanticStageHint">
<segment>
<source>The drawer renders inline and stays open in this area.</source>
<target state="translated">抽屜在此區域內以內聯方式常開呈現。</target>
</segment>
</unit>
<unit id="SemanticStylesDescription">
<segment>
<source>Customize every semantic part with the generated part style classes.</source>
<target state="translated">使用生成的部件 Style 類定製全部語義部件。</target>
</segment>
</unit>
<unit id="SemanticStylesTitle">
<segment>
<source>Semantic Styles</source>
<target state="translated">語義樣式</target>
</segment>
</unit>
<unit id="SemanticTitleDescription">
<segment>
<source>Title element with font weight, size and line-height styles.</source>
<target state="translated">標題元素,包含字體權重、字體大小、行高等標題文字的樣式。</target>
</segment>
</unit>
</file>
</xliff>
@@ -14,8 +14,8 @@
</UserControl.Styles>
<Grid Background="{atom:SharedTokenResource ColorBgLayout}">
<gallery:GalleryStickyTabsHost StickyContentPadding="28,0,28,0">
<gallery:GalleryStickyTabsHost.Header>
<gallery:GalleryShowCaseHost StickyContentPadding="28,0,28,0">
<gallery:GalleryShowCaseHost.Header>
<gallery:GalleryShowCaseHeader
Title="Drawer"
Category="{gallery:DrawerShowCaseLangResource ComponentCategory}"
@@ -25,7 +25,78 @@
Namespace="AtomUI.Desktop.Controls"
Package="AtomUI.Desktop.Controls"
BaseClass="Control" />
</gallery:GalleryStickyTabsHost.Header>
</gallery:GalleryShowCaseHost.Header>
<gallery:GalleryShowCaseHost.SemanticPartsContentTemplate>
<DataTemplate x:DataType="vm:DrawerViewModel">
<gallery:SemanticPartPreview Name="DrawerSemanticPreview"
Title="Drawer"
SemanticOwner="{Binding #DrawerSemanticOwner}"
SemanticOwnerType="{x:Type atom:Drawer}"
PreviewStageMinHeight="434">
<gallery:SemanticPartPreview.PreviewContent>
<!-- 对齐 antd getContainer={false} 的内联容器:ScrollContentPresenter 是
ScopeAwareAdornerLayer 的最近层宿主,层注入只包裹舞台自身,
不会逃逸到页面级滚动容器重挂整页内容;舞台限高裁剪,抽屉常开
渲染于舞台局部 layer。舞台用 MinHeight + 拉伸:空间充裕时自然长高,宿主钳制时随画布收缩,不与视口相抵被裁。 -->
<ScrollContentPresenter>
<Border Name="DrawerSemanticStage"
MinHeight="320"
Margin="24,24,24,24"
Background="{atom:SharedTokenResource ColorFillAlter}"
BorderBrush="{atom:SharedTokenResource ColorBorderSecondary}"
BorderThickness="1"
CornerRadius="{atom:SharedTokenResource BorderRadiusLG}"
ClipToBounds="True">
<!-- Grid 让 owner 拉伸铺满舞台:root 部件高亮 = 整个内联容器(对齐 antd root 语义)。 -->
<Grid>
<atom:TextBlock Margin="24,16,24,0"
VerticalAlignment="Top"
Foreground="{atom:SharedTokenResource ColorTextSecondary}"
Text="{gallery:DrawerShowCaseLangResource SemanticStageHint}" />
<atom:Drawer Name="DrawerSemanticOwner"
Title="{gallery:DrawerShowCaseLangResource SemanticPreviewTitleText}"
IsOpen="True"
IsPinnedOpen="True"
IsMotionEnabled="False"
DialogSize="300"
OpenOn="{Binding #DrawerSemanticStage}">
<atom:Drawer.Extra>
<atom:Button Content="{gallery:DrawerShowCaseLangResource P2ContentCancel}" />
</atom:Drawer.Extra>
<atom:Drawer.Footer>
<atom:Button ButtonType="Link"
Content="{gallery:DrawerShowCaseLangResource SemanticPreviewFooterLink}" />
</atom:Drawer.Footer>
<atom:TextBlock Text="{gallery:DrawerShowCaseLangResource P2TextSomeContents}" />
</atom:Drawer>
</Grid>
</Border>
</ScrollContentPresenter>
</gallery:SemanticPartPreview.PreviewContent>
<gallery:SemanticPartPreview.PartDescriptions>
<gallery:SemanticPartDescription Path="root"
Description="{gallery:DrawerShowCaseLangResource SemanticRootDescription}" />
<gallery:SemanticPartDescription Path="mask"
Description="{gallery:DrawerShowCaseLangResource SemanticMaskDescription}" />
<gallery:SemanticPartDescription Path="section"
Description="{gallery:DrawerShowCaseLangResource SemanticSectionDescription}" />
<gallery:SemanticPartDescription Path="header"
Description="{gallery:DrawerShowCaseLangResource SemanticHeaderDescription}" />
<gallery:SemanticPartDescription Path="title"
Description="{gallery:DrawerShowCaseLangResource SemanticTitleDescription}" />
<gallery:SemanticPartDescription Path="extra"
Description="{gallery:DrawerShowCaseLangResource SemanticExtraDescription}" />
<gallery:SemanticPartDescription Path="body"
Description="{gallery:DrawerShowCaseLangResource SemanticBodyDescription}" />
<gallery:SemanticPartDescription Path="footer"
Description="{gallery:DrawerShowCaseLangResource SemanticFooterDescription}" />
<gallery:SemanticPartDescription Path="close"
Description="{gallery:DrawerShowCaseLangResource SemanticCloseDescription}" />
</gallery:SemanticPartPreview.PartDescriptions>
</gallery:SemanticPartPreview>
</DataTemplate>
</gallery:GalleryShowCaseHost.SemanticPartsContentTemplate>
<gallery:ShowCasePanel Name="ExamplesContent"
@@ -357,7 +428,64 @@
</DataTemplate>
</gallery:ShowCaseItem.DeferredContentTemplate>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem Title="{gallery:DrawerShowCaseLangResource SemanticStylesTitle}"
Description="{gallery:DrawerShowCaseLangResource SemanticStylesDescription}"
Height="300" IsDeferredContentEnabled="True">
<gallery:ShowCaseItem.DeferredContentTemplate>
<DataTemplate x:DataType="vm:DrawerViewModel">
<Panel>
<StackPanel Orientation="Vertical" Spacing="10" VerticalAlignment="Top">
<atom:TextBlock Text="{gallery:DrawerShowCaseLangResource P2TextRenderInThis}" />
<atom:ToggleSwitch Content="{gallery:DrawerShowCaseLangResource P2ContentOpen}" />
</StackPanel>
<atom:Drawer Classes="semantic-object-style-demo"
IsOpen="{Binding $parent[Panel].((Panel)Children[0]).((atom:ToggleSwitch)Children[1]).IsChecked}"
Title="{gallery:DrawerShowCaseLangResource P2TitleBasicDrawer}"
OpenOn="{Binding $parent[gallery:ShowCaseItem]}">
<atom:Drawer.Styles>
<Style Selector="atom|Drawer.semantic-object-style-demo">
<atom:DrawerMaskStyle x:SetterTargetType="Border">
<Setter Property="Background" Value="#661677ff" />
</atom:DrawerMaskStyle>
<atom:DrawerSectionStyle x:SetterTargetType="Border">
<Setter Property="CornerRadius" Value="8,0,0,8" />
</atom:DrawerSectionStyle>
<atom:DrawerHeaderStyle x:SetterTargetType="Grid">
<Setter Property="Margin" Value="24,12" />
</atom:DrawerHeaderStyle>
<atom:DrawerTitleStyle x:SetterTargetType="atom:TextBlock">
<Setter Property="Foreground" Value="#1677ff" />
</atom:DrawerTitleStyle>
<atom:DrawerExtraStyle x:SetterTargetType="ContentPresenter">
<Setter Property="Margin" Value="0,0,8,0" />
</atom:DrawerExtraStyle>
<atom:DrawerBodyStyle x:SetterTargetType="ContentPresenter">
<Setter Property="Padding" Value="24" />
</atom:DrawerBodyStyle>
<atom:DrawerFooterStyle x:SetterTargetType="ContentPresenter">
<Setter Property="Background" Value="{atom:SharedTokenResource ColorFillAlter}" />
</atom:DrawerFooterStyle>
<atom:DrawerCloseStyle x:SetterTargetType="atom:IconButton">
<Setter Property="Foreground" Value="#1677ff" />
</atom:DrawerCloseStyle>
</Style>
</atom:Drawer.Styles>
<atom:Drawer.Footer>
<atom:Button ButtonType="Link"
Content="{gallery:DrawerShowCaseLangResource SemanticPreviewFooterLink}" />
</atom:Drawer.Footer>
<StackPanel Orientation="Vertical" Spacing="5">
<atom:TextBlock Text="{gallery:DrawerShowCaseLangResource P2TextSomeContents}" />
<atom:TextBlock Text="{gallery:DrawerShowCaseLangResource P2TextSomeContents}" />
<atom:TextBlock Text="{gallery:DrawerShowCaseLangResource P2TextSomeContents}" />
</StackPanel>
</atom:Drawer>
</Panel>
</DataTemplate>
</gallery:ShowCaseItem.DeferredContentTemplate>
</gallery:ShowCaseItem>
</gallery:ShowCasePanel>
</gallery:GalleryStickyTabsHost>
</gallery:GalleryShowCaseHost>
</Grid>
</UserControl>
@@ -2,6 +2,26 @@
本文档记录 Drawer 控件级设计、API、主题契约、Token 和实现结构的变化。它不替代仓库根目录 `CHANGELOG.md`,也不作为正式版本发布说明。
## 2026-09-10
- API
- Add the public `IsPinnedOpen` styled property: a pinned drawer ignores mask clicks and the close button so `IsOpen` stays true (semantic preview parity with the upstream controlled-open demo); external `IsOpen` assignments keep closing normally.
- Semantic Parts
- Publish eight antd-aligned semantic parts (`mask`, `section`, `header`, `title`, `extra`, `body`, `footer`, `close`) plus the implicit `root`; markers are declared statically on the two internal container themes with `CrossVisualRoot` + `RuntimeCreated`.
- Implement `ISemanticPartCrossRootProvider` on `Drawer`: the live `DrawerContainer` is reported as the cross root and `CrossRootsChanged` fires on container attach, detach and release.
- Infrastructure
- Parent the `DrawerContainer` logically to the owning `Drawer` while attached to its scope layer (`ISetLogicalParent`, cleared on detach) so owner-scoped generated semantic styles reach container targets; relay `ThemeVariantScope.ActualThemeVariant` from the owner.
- Gallery
- Replace the sticky host with `GalleryShowCaseHost` and add the Semantic Parts tab: an inline always-open stage (local `ScrollContentPresenter` layer host, the `getContainer={false}` equivalent) with `IsOpen`/`IsPinnedOpen` set declaratively, plus a Semantic Styles example exercising all eight generated part styles.
- Bugfix
- Fix semantic-stage clipping under the height-bounded preview mode: the stage used a fixed `Height` while the declared `PreviewStageMinHeight` floor (360) did not cover the stage content's real desired height (320 + 48 margins), so the stage — and the mask/panel inside its scope layer — were cut by the stage viewport while the highlight adorner kept drawing at the unclipped bounds. The stage now uses `MinHeight` + stretch (grows with room, never fights the viewport) and the floor is raised to 434 (viewport = floor − 62 panel overhead must cover 368).
- Fix a pre-existing tracking-size defect: the adorned-element snapshot took `Math.Max(Bounds, DesiredSize)` while `DesiredSize` includes margin, inflating the container to the host's margin box so the mask and panel bled past the visible host bounds (the semantic preview stage footer was clipped below the stage border). The snapshot now takes the arranged `Bounds` and only falls back to `DesiredSize - Margin` before the first arrange.
- Fix a pre-existing first-open defect surfaced by scrolled local hosts: `ScopeAwareAdornerLayer` captured the adorned-element tracking snapshot during the layer-injection transient (host `ScrollContentPresenter` content rewrap, extent collapse, offset coerced to zero before recovery), leaving the container translated without scroll compensation until the next open/close cycle. The snapshot now translates directly into layer space (scroll-invariant on the shared content subtree) and re-syncs when the layer itself gets arranged after injection.
- Validation
- Add `DrawerSemanticPartTests` (descriptor contract, static markers, materialization, logical-parent invariant, generated style hits, pinning, cross-root reporting) and `DrawerSemanticPartHighlightTests` (page-level end-to-end highlighting for all nine cards).
- Add `DrawerScopeLayerTrackingTests`: first open must track the scrolled host bounds immediately and keep tracking across subsequent scrolling.
- Add `DrawerSemanticPreviewStageGeometryTests` (theory across window heights): the container, mask and panel must stay inside the visible stage box (margin must not inflate the container; the stage content must not overflow its viewport under the height-bounded mode).
## 2026-08-20
- Architecture
@@ -11,6 +11,7 @@
主要源码文件:
- `src/AtomUI.Desktop.Controls/Drawer/Drawer.cs`
- `src/AtomUI.Desktop.Controls/Drawer/Drawer.SemanticParts.cs`
- `src/AtomUI.Desktop.Controls/Drawer/DrawerContainer.cs`
- `src/AtomUI.Desktop.Controls/Drawer/DrawerInfoContainer.cs`
- `src/AtomUI.Desktop.Controls/Drawer/DrawerPlacement.cs`
@@ -85,6 +86,14 @@ Public API / ItemsSource / Command / Event
- `PART_InfoContainerMotionActor`:稳定模板协作入口,重命名前必须同步主题和实现。
- `PART_Mask`:稳定模板协作入口,重命名前必须同步主题和实现。
### 语义部件基础设施
- 全部非 root 语义部件位于运行时创建的 `DrawerContainer` 内,统一 `CrossVisualRoot + RuntimeCreated`,marker 静态声明在 `DrawerContainerTheme.axaml`(`PART_Mask`)与 `DrawerInfoContainerTheme.axaml`(`Frame`、`InfoHeader`、`HeaderText`、`ExtraContentPresenter`、`InfoContainer`、`InfoFooter`、`PART_CloseButton`)。
- 容器可达性不变量:`AttachToLayer` 在 `layer.Children.Add` 之前调用 `((ISetLogicalParent)this).SetParent(drawer)`(Panel 不会覆盖已显式设置的逻辑父),`DetachFromLayer` 置空;owner 嵌套生成 Semantic Style 的命中依赖此不变量。
- `BindToDrawer` 显式中继 `ThemeVariantScope.ActualThemeVariant`(ImagePreviewer 先例),保证容器跟随 owner 主题变体。
- `Drawer : ISemanticPartCrossRootProvider`:容器挂层(`Open` 末尾)、`NotifyClosed`、`ReleaseDrawerContainer` 三处触发 `CrossRootsChanged`;`GetCrossRoots()` 仅在容器有视觉父时返回 `[container]`。
- `IsPinnedOpen`:`DrawerContainer.OnPointerReleased`(遮罩点击)与 `HandleCloseRequested`(关闭按钮)两处拦截,钉住时不落 `IsOpen=false`。
## 6. 交互与事件处理
Drawer 的交互事件应从输入源收敛到控件级语义事件:
@@ -40,7 +40,7 @@ Drawer 的公共契约由 public/protected 类型成员、Avalonia 属性、事
| 契约组 | 代表成员 | 维护含义 |
| --- | --- | --- |
| 内容与数据 | `Content`、`ContentTemplate`、`ExtraTemplate`、`FooterTemplate`、`Title` | 定义控件展示内容、输入数据、模板或业务对象入口。 |
| 交互与状态 | `IsCloseOnMaskClick`、`IsMotionEnabled`、`IsOpen`、`IsShowCloseButton`、`IsShowMask` | 表达用户可观察状态、可用性、清除、加载或反馈语义。 |
| 交互与状态 | `IsCloseOnMaskClick`、`IsMotionEnabled`、`IsOpen`、`IsPinnedOpen`、`IsShowCloseButton`、`IsShowMask` | 表达用户可观察状态、可用性、清除、加载或反馈语义;`IsPinnedOpen` 钉住常开(语义预览用),拦截遮罩点击与关闭按钮,不拦截外部 `IsOpen` 赋值。 |
| 视觉与布局 | `DialogSize`、`Placement`、`PushOffsetPercent`、`SizeType` | 影响尺寸、位置、颜色、形状、密度和模板视觉变量。 |
| 其他稳定入口 | `Extra`、`Footer`、`OpenOn` | 保留为 public surface,变更前需确认 Gallery 和用户 XAML 依赖。 |
@@ -161,18 +161,23 @@ Drawer 的视觉选项通过 public API 归一为 theme variables、伪类或模
关联文档:
- [Drawer 桌面版实现原理](implementation.md)
- [Drawer Semantic Part 契约](semantic-part.md)
- [Drawer Token 设计](token.md)
- [Drawer Changelog](changelog.md)
LLMS 语义区域:
LLMS 语义区域(完整契约见 [semantic-part.md](semantic-part.md)):
| Part | AtomUI 节点 | 职责 | 相关 API | 相关 Token | 稳定性 |
| --- | --- | --- | --- | --- | --- |
| `root` | `Drawer` | 反馈控件根语义区域,承载 public API、反馈状态和主题入口。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `host` | `宿主或弹层区域` | 承载 overlay、popup、portal、message host、drawer 或 modal 容器。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `surface` | `反馈表面` | 承载背景、边框、阴影、尺寸、placement 和视觉状态。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `content` | `内容区域` | 承载标题、正文、图标、进度、结果、操作或关闭入口。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `motion` | `动效区域` | 表达进入退出、loading、progress、skeleton 或水印刷新反馈。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `root` | `Drawer`(owner,零尺寸标记控件) | 语义身份与 owner 级样式定制入口;antd fixed root 对应内部 `DrawerContainer`,不暴露。 | 见 API 与契约模型 | 见视觉与主题模型 | stable |
| `mask` | `PART_Mask`(Border) | 遮罩层:定位、层级、背景、指针事件。 | `IsShowMask`、`IsCloseOnMaskClick` | `ColorBgMask` | stable |
| `section` | `Frame`(Border) | 面板容器:flex 布局、宽高、背景、边缘阴影。 | `DialogSize`、`Placement` | `ColorBgElevated`、`BoxShadowDrawer*` | stable |
| `header` | `InfoHeader`(Grid) | 头部排布与内边距。 | `Title`、`Extra`、`IsShowCloseButton` | `HeaderMargin` | stable |
| `title` | `HeaderText`(TextBlock) | 标题文字排版。 | `Title` | `FontSizeLG`、`FontWeightStrong` | stable |
| `extra` | `ExtraContentPresenter` | 头部尾缘额外操作内容。 | `Extra`、`ExtraTemplate` | - | stable |
| `body` | `InfoContainer`(ContentPresenter) | 主内容区:占位、内边距、滚动。 | `Content`、`ContentPadding` | `ContentPadding` | stable |
| `footer` | `InfoFooter`(ContentPresenter) | 底部操作区,仅设置 Footer 时可见。 | `Footer`、`FooterTemplate` | `FooterPadding` | stable |
| `close` | `PART_CloseButton`(IconButton) | 关闭按钮;钉住预览忽略其关闭请求。 | `IsShowCloseButton`、`IsPinnedOpen` | `CloseIconPadding`、`CloseIconMargin` | stable |
LLMS 导出来源:
@@ -0,0 +1,213 @@
# Drawer Semantic Part 契约
本文定义 Drawer 的 Semantic Part 契约:部件清单、marker 落点、跨根可达性与定制方式。控件定位与公共 API 见 [overview.md](overview.md),实现结构见 [implementation.md](implementation.md),系统级设计见 [Semantic Parts 系统设计](../../../../architecture/systems/theming/semantic-parts.md),Gallery 预览模式见 [Semantic Part Preview](../../../../gallery/authoring/semantic-part-preview.md)。
## 1. Semantic Parts
Drawer 的语义 owner 是 `AtomUI.Desktop.Controls.Drawer` 本身(无模板的零尺寸标记控件)。全部非 root 部件位于运行时创建的 `DrawerContainer`(注入 `ScopeAwareAdornerLayer`,在 owner 可视子树之外、同一 TopLevel 之内),因此统一声明 `CrossVisualRoot=true` + `RuntimeCreated=true`;marker 静态声明在两个内部容器主题上(`DrawerContainerTheme.axaml`、`DrawerInfoContainerTheme.axaml`),运行时无需代码注入。
与上游 antd Drawer Semantic DOM(v6 `_semantic.tsx`,10 部件)的映射:
| antd 部件 | AtomUI 部件 | 说明 |
| --- | --- | --- |
| `root` | `root`(隐式,owner 契约) | antd 的 root 是 fixed 定位容器;AtomUI 遵循系统契约 root=owner 控件,antd root 对应内部 `DrawerContainer` 基础设施,不作为部件暴露。内联语义预览中 owner 拉伸铺满舞台,root 高亮即整个内联容器,视觉语义对齐上游。 |
| `mask` | `mask` | 一一对应;`IsShowMask=false` 时不呈现(Optional)。 |
| `section` | `section` | antd v6 由 `content` 改名而来;AtomUI 由 `DrawerInfoContainer` 模板中的 `Frame` Border 承载(背景/阴影层)。 |
| `header` | `header` | 一一对应(`InfoHeader` Grid)。 |
| `title` | `title` | 一一对应(`HeaderText`)。 |
| `extra` | `extra` | 一一对应(`ExtraContentPresenter`)。 |
| `body` | `body` | 一一对应(`InfoContainer` presenter,`ContentPadding` 落点)。 |
| `footer` | `footer` | 一一对应(`InfoFooter` presenter,仅设置 Footer 时可见)。 |
| `close` | `close` | 一一对应(`PART_CloseButton` IconButton)。 |
| `dragger` | 不暴露 | antd v6 的 resizable 拖拽手柄;AtomUI Drawer 暂无 resizable 能力,待能力落地后按上游补齐。 |
| `wrapper` | 不暴露 | antd 动效包装容器,上游语义预览清单同样不包含它;对应 AtomUI 的 `PART_InfoContainerMotionActor` 动效基础设施。 |
部件明细:
#### `mask`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `mask` |
| Selector | `.semantic-mask` |
| SelectorRoute | `>> .semantic-mask` |
| Style Type | `AtomUI.Theme.Styling.DrawerMaskStyle` |
| ContractType | `Avalonia.Controls.Border` |
| Cardinality | Optional(`IsShowMask=false` 时不呈现) |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerContainerTheme.axaml` 的 `PART_Mask`(静态 marker) |
| 职责 | 遮罩层:绝对定位、层级、`ColorBgMask` 背景、指针事件命中 |
| 相关 API | `IsShowMask`、`IsCloseOnMaskClick` |
| 相关 Token | `ColorBgMask` |
#### `section`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `section` |
| Selector | `.semantic-section` |
| SelectorRoute | `>> .semantic-section` |
| Style Type | `AtomUI.Theme.Styling.DrawerSectionStyle` |
| ContractType | `Avalonia.Controls.Border` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `Frame`(静态 marker) |
| 职责 | 抽屉面板容器:flex 布局、宽高、背景、按 Placement 的边缘阴影 |
| 相关 API | `DialogSize`、`Placement`、`SizeType` |
| 相关 Token | `ColorBgElevated`、`BoxShadowDrawer{Left,Right,Up,Down}` |
#### `header`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `header` |
| Selector | `.semantic-header` |
| SelectorRoute | `>> .semantic-header` |
| Style Type | `AtomUI.Theme.Styling.DrawerHeaderStyle` |
| ContractType | `Avalonia.Controls.Grid` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `InfoHeader`(静态 marker) |
| 职责 | 头部区域:标题/关闭按钮/额外操作的排布、内边距 |
| 相关 API | `Title`、`Extra`、`IsShowCloseButton` |
| 相关 Token | `HeaderMargin` |
#### `title`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `title` |
| Selector | `.semantic-title` |
| SelectorRoute | `>> .semantic-title` |
| Style Type | `AtomUI.Theme.Styling.DrawerTitleStyle` |
| ContractType | `AtomUI.Desktop.Controls.TextBlock` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `HeaderText`(静态 marker) |
| 职责 | 标题文字排版 |
| 相关 API | `Title` |
| 相关 Token | `FontSizeLG`、`FontHeightLG`、`FontWeightStrong` |
#### `extra`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `extra` |
| Selector | `.semantic-extra` |
| SelectorRoute | `>> .semantic-extra` |
| Style Type | `AtomUI.Theme.Styling.DrawerExtraStyle` |
| ContractType | `Avalonia.Controls.Presenters.ContentPresenter` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `ExtraContentPresenter`(静态 marker) |
| 职责 | 头部尾缘的额外操作内容呈现 |
| 相关 API | `Extra`、`ExtraTemplate` |
#### `body`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `body` |
| Selector | `.semantic-body` |
| SelectorRoute | `>> .semantic-body` |
| Style Type | `AtomUI.Theme.Styling.DrawerBodyStyle` |
| ContractType | `Avalonia.Controls.Presenters.ContentPresenter` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `InfoContainer`(静态 marker) |
| 职责 | 主内容区:flex 占比、内边距、滚动 |
| 相关 API | `Content`、`ContentTemplate`、`ContentPadding` |
| 相关 Token | `ContentPadding` |
#### `footer`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `footer` |
| Selector | `.semantic-footer` |
| SelectorRoute | `>> .semantic-footer` |
| Style Type | `AtomUI.Theme.Styling.DrawerFooterStyle` |
| ContractType | `Avalonia.Controls.Presenters.ContentPresenter` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `InfoFooter`(静态 marker) |
| 职责 | 底部操作区:上分隔线、内边距;仅设置 Footer 时可见 |
| 相关 API | `Footer`、`FooterTemplate` |
| 相关 Token | `FooterPadding` |
#### `close`
| 字段 | 值 |
| --- | --- |
| Owner / Part | `Drawer` / `close` |
| Selector | `.semantic-close` |
| SelectorRoute | `>> .semantic-close` |
| Style Type | `AtomUI.Theme.Styling.DrawerCloseStyle` |
| ContractType | `AtomUI.Controls.IconButton` |
| Cardinality | Single |
| CrossVisualRoot / RuntimeCreated | true / true |
| AtomUI 节点 | `DrawerInfoContainerTheme.axaml` 的 `PART_CloseButton`(静态 marker) |
| 职责 | 关闭按钮:图标、hover/pressed 反馈;钉住预览时忽略其关闭请求 |
| 相关 API | `IsShowCloseButton`、`IsPinnedOpen` |
| 相关 Token | `CloseIconPadding`、`CloseIconMargin` |
隐式 `root`:由生成器无条件加入,`StyleType=null`,定制走 owner 级普通 Style(见系统文档)。
## 2. 职责与存在条件
- 部件仅在 `IsOpen=true` 且 `DrawerContainer` 挂到 `ScopeAwareAdornerLayer` 时物化;关闭后容器脱离 layer,所有部件目标消失。
- 容器挂层时通过 `ISetLogicalParent.SetParent(drawer)` 挂到 owner 逻辑树下(先于 `layer.Children.Add`,脱离时置空),owner 嵌套的生成 Semantic Style 因此可达;同时按 ImagePreviewer 先例显式中继 `ThemeVariantScope.ActualThemeVariant`。
- `Drawer : ISemanticPartCrossRootProvider` 在容器挂层/脱离/释放时触发 `CrossRootsChanged`,`GetCrossRoots()` 返回存活容器;Gallery 语义预览据此自动收集跨根,无需页面 code-behind 注册 `AdditionalRoots`(区别于 DropdownButton 的 Popup 根注册模式)。
- `IsPinnedOpen=true` 时遮罩点击与关闭按钮不再把 `IsOpen` 置 false(对齐 antd 语义演示的受控常开);外部代码直接设置 `IsOpen` 仍正常关闭。
## 3. 数量语义
全部部件为 Single(每个 Drawer 至多一个实例),仅 `mask` 为 Optional(`IsShowMask=false` 时不呈现)。多抽屉场景中各 Drawer 的部件由各自的 `GetCrossRoots()` 容器承载,互不串扰;嵌套子 Drawer 复用父级同一 scope layer,但部件解析以各自容器为根。
## 4. Selector 用法
生成 Style 命名规则为 `Drawer{PascalCase(part)}Style`,位于 `AtomUI.Theme.Styling`(AXAML 前缀 `atom:`)。推荐写法(与 Gallery「语义样式」示例一致):
```xml
<atom:Drawer.Styles>
<Style Selector="atom|Drawer.semantic-object-style-demo">
<atom:DrawerMaskStyle x:SetterTargetType="Border">
<Setter Property="Background" Value="#661677ff" />
</atom:DrawerMaskStyle>
<atom:DrawerSectionStyle x:SetterTargetType="Border">
<Setter Property="CornerRadius" Value="8,0,0,8" />
</atom:DrawerSectionStyle>
<atom:DrawerTitleStyle x:SetterTargetType="atom:TextBlock">
<Setter Property="Foreground" Value="#1677ff" />
</atom:DrawerTitleStyle>
<atom:DrawerCloseStyle x:SetterTargetType="atom:IconButton">
<Setter Property="Foreground" Value="#1677ff" />
</atom:DrawerCloseStyle>
</Style>
</atom:Drawer.Styles>
```
规则要点:
- 生成 Style 必须作为 owner(或其业务 class)的嵌套 Style 子节点使用;`x:SetterTargetType` 必须写部件 ContractType(AtomUI 类型带 `atom:` 前缀,Avalonia 类型不带)。
- `root` 不生成 Style,owner 级 Setter 直接写在外层普通 Style 上。
- 禁止对 `DrawerContainer`/`DrawerInfoContainer` 直接写业务 Selector(internal 类型,不属于公共契约);禁止 code-behind 找节点设属性替代生成 Style。
## 5. 定制边界
以下模板节点**不属于**语义部件,不承载稳定定制契约:`PART_RootClip`(宿主圆角裁剪)、`RootLayout`(两个容器各自的布局 Panel)、`InfoLayout`(行布局 Grid)、两条 `Separator`(分割线,随 header/footer 存在性显示)、`PART_InfoContainerMotionActor`(动效宿主)。antd 的 `wrapper`/`dragger` 不暴露(见 §1 映射表)。
## 6. 兼容性与验证
契约变更定义:增删部件、修改 SelectorClass/SelectorRoute/ContractType/Cardinality 均为破坏性契约变更,需同步本文、Gallery 演示与 LLMS 重生成。
与上游差异(设计决定,非缺陷):
- `root` 指向 owner 控件(系统级契约),antd root(fixed 容器)对应内部 `DrawerContainer`;内联语义预览通过让 owner 铺满舞台保持视觉等价。
- `dragger` 未实现(AtomUI Drawer 暂无 resizable);`wrapper` 为动效基础设施,上游预览亦不暴露。
验证要求(均为可失败契约测试):
- `tests/AtomUI.Desktop.Controls.Tests/Drawer/DrawerSemanticPartTests.cs`:descriptor 契约、两个容器主题的静态 marker 清单、打开后 marker 物化、容器逻辑父不变量、生成 Style 跨根命中全部 8 个目标、`IsPinnedOpen` 钉住语义、`GetCrossRoots` 上报。
- `tests/AtomUIGallery.Tests/ShowCases/DrawerSemanticPartHighlightTests.cs`:页面级端到端——语义页签内 9 张部件卡 hover 逐一高亮(各 1 个描边)、钉住忽略遮罩点击、页签切换清理高亮会话。
- `tests/AtomUIGallery.Tests/ShowCases/DrawerShowCasePageTests.cs`:AXAML 契约断言(内联钉住模式、PartDescriptions 顺序、生成 Style 唯一定制入口、无 code-behind 回退)。
@@ -414,6 +414,24 @@ public interface ISemanticPartCrossRootProvider
Popup 首次打开竞态、直接 Child wrapper 契约和 pinned light-dismiss 的完整排查记录见
[Semantic Part Popup 首次打开生命周期竞态案例](../../engineering/case-studies/semantic-part-popup-first-open-lifecycle-case-study.md)。
非 Popup 宿主的遮罩类弹层控件(Drawer 先例)演示语义部件时,不走 Popup 钉住模式,改用**内联常开舞台**,
即上游 antd `getContainer={false}` 的等价物:
1. 预览内容根是一个 `ScrollContentPresenter`,内含舞台 Border(限高、裁剪、浅色填充)。`ScrollContentPresenter`
是 `ScopeAwareAdornerLayer` 层解析的最近宿主:层注入只包裹舞台自身。若缺少该局部宿主,层解析会逃逸到页面级
滚动容器,`InjectLayer` 重挂整页内容导致 `GalleryShowCaseHost` 脱离视觉树并释放语义预览——这是已踩过的坑,
不得回退。
2. 演示控件声明式设置 `IsOpen="True"` + `IsPinnedOpen="True"`(钉住后忽略遮罩点击与关闭按钮)+
`IsMotionEnabled="False"`,`OpenOn` 绑定舞台 Border,遮罩与面板渲染于舞台局部 layer 内。
3. 舞台内容用 `Grid` 让零尺寸 owner 拉伸铺满舞台:`root` 卡片高亮 = 整个内联容器,对齐上游 root 语义
(resolver 对零尺寸目标不建 Adorner,此布局同时解决该问题)。
4. 跨根根集合由控件自身的 `ISemanticPartCrossRootProvider` 上报,页面无需 code-behind 注册
`AdditionalRoots`(区别于 DropdownButton 的 Popup 根注册模式)。
5. 舞台 Border 用 `MinHeight` + 默认拉伸,禁止固定 `Height`:限高钳制模式下画布会随宿主收缩,
固定高度会与视口相抵被裁(遮罩/面板连同高亮框越出可见区)。`PreviewStageMinHeight`
地板必须覆盖舞台内容的真实期望:视口 = 地板 − 62(面板开销),需 ≥ 舞台 MinHeight + 边距
(Drawer 先例:MinHeight 320 + 边距 48 → 地板 434)。
## 11. 高密度预算
`Multiple` Part 只处理已实例化且可见的节点。单次 `SemanticPartHighlightSession` 默认最多创建 32 个 Adorner:
@@ -1,5 +1,6 @@
using System.Collections.Specialized;
using System.Diagnostics;
using System.Reactive.Disposables;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
@@ -239,30 +240,59 @@ public class ScopeAwareAdornerLayer : Canvas
adorner.SetValue(AdornedElementInfoProperty, info);
}
info.Subscription = adorned.GetObservable(BoundsProperty).Subscribe(x =>
void TakeBoundsSnapshot()
{
Debug.Assert(LayerHost != null);
double offsetX = 0d;
double offsetY = 0d;
var relateToHostPoint = adorned.TranslatePoint(new Point(0, 0), LayerHost) ?? new Point(0, 0);
if (LayerHost is ScrollContentPresenter scrollContentPresenter)
// 层与被装饰元素位于同一内容子树时(层注入路径:层是被装饰元素
// 所在内容根的兄弟节点),直接换算到层坐标:滚动平移在链路上抵消,
// 结果对滚动不变。回退路径按 LayerHost 换算并补偿宿主
// ScrollContentPresenter 的滚动偏移。
var translation = adorned.TranslatePoint(new Point(0, 0), this);
if (translation is null)
{
offsetX = scrollContentPresenter.Offset.X;
offsetY = scrollContentPresenter.Offset.Y;
double offsetX = 0d;
double offsetY = 0d;
var relateToHostPoint = adorned.TranslatePoint(new Point(0, 0), LayerHost) ?? new Point(0, 0);
if (LayerHost is ScrollContentPresenter scrollContentPresenter)
{
offsetX = scrollContentPresenter.Offset.X;
offsetY = scrollContentPresenter.Offset.Y;
}
offsetX += relateToHostPoint.X;
offsetY += relateToHostPoint.Y;
translation = new Point(offsetX, offsetY);
}
offsetX += relateToHostPoint.X;
offsetY += relateToHostPoint.Y;
var translationMatrix = LayerHost != null
? Matrix.CreateTranslation(offsetX, offsetY)
: Matrix.Identity;
var adornedWidth = Math.Max(adorned.Bounds.Width, adorned.DesiredSize.Width);
var adornedHeight = Math.Max(adorned.Bounds.Height, adorned.DesiredSize.Height);
var translationMatrix = Matrix.CreateTranslation(translation.Value.X, translation.Value.Y);
// 尺寸必须取被装饰元素的排列盒(Bounds,不含 Margin):DesiredSize
// 包含 Margin,直接取大会把容器放大成 margin box,遮罩与面板会
// 越出宿主的可见边界(带边距的宿主元素尤甚)。仅在尚未排列
// (Bounds 为空)时回退到「DesiredSize - Margin」的测量值。
var adornedWidth = adorned.Bounds.Width > 0
? adorned.Bounds.Width
: Math.Max(0, adorned.DesiredSize.Width - adorned.Margin.Left - adorned.Margin.Right);
var adornedHeight = adorned.Bounds.Height > 0
? adorned.Bounds.Height
: Math.Max(0, adorned.DesiredSize.Height - adorned.Margin.Top - adorned.Margin.Bottom);
info.Bounds = new TransformedBounds(new Rect(new Size(adornedWidth, adornedHeight)),
new Rect(new Size(adornedWidth, adornedHeight)),
translationMatrix);
InvalidateMeasure();
}
// 层注入把层挂进全新的包裹面板,首次快照发生在面板/层尚未 arrange 的
// 瞬态,换算链不可用(得到未补偿滚动的错误位置,且被装饰元素的 Bounds
// 不会随之再变,错误会一直保持到下一次开合)。订阅层自身 Bounds:注入
// 后层被 arrange(0 → 实际值)时换算链就绪、快照自愈;正常滚动不改变
// 层的 Bounds,不会引入「旧位置 + 新偏移」的混态重算。
var adornedSubscription = adorned.GetObservable(BoundsProperty).Subscribe(_ => TakeBoundsSnapshot());
var layerSubscription = this.GetObservable(BoundsProperty).Subscribe(_ => TakeBoundsSnapshot());
info.Subscription = Disposable.Create(() =>
{
adornedSubscription.Dispose();
layerSubscription.Dispose();
});
}
}
@@ -0,0 +1,78 @@
using AtomUI.Theme;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
namespace AtomUI.Desktop.Controls;
// 与上游 antd Drawer Semantic DOM(v6 _semantic.tsx)对齐的平级命名;
// wrapper(动效容器)与 dragger(AtomUI 暂无 resizable)不暴露,见 semantic-part.md。
// 部件全部位于运行时创建的 DrawerContainer(ScopeAwareAdornerLayer,owner 子树之外),
// 因此统一声明 CrossVisualRoot + RuntimeCreated;marker 静态声明在两个内部容器主题上。
[SemanticPart("mask",
SelectorClass = "semantic-mask",
SelectorRoute = ">> .semantic-mask",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
Cardinality = SemanticPartCardinality.Optional,
ContractType = typeof(Border),
Since = "6.0")]
[SemanticPart("section",
SelectorClass = "semantic-section",
SelectorRoute = ">> .semantic-section",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(Border),
Since = "6.0")]
[SemanticPart("header",
SelectorClass = "semantic-header",
SelectorRoute = ">> .semantic-header",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(Avalonia.Controls.Grid),
Since = "6.0")]
[SemanticPart("title",
SelectorClass = "semantic-title",
SelectorRoute = ">> .semantic-title",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(TextBlock),
Since = "6.0")]
[SemanticPart("extra",
SelectorClass = "semantic-extra",
SelectorRoute = ">> .semantic-extra",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(ContentPresenter),
Since = "6.0")]
[SemanticPart("body",
SelectorClass = "semantic-body",
SelectorRoute = ">> .semantic-body",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(ContentPresenter),
Since = "6.0")]
[SemanticPart("footer",
SelectorClass = "semantic-footer",
SelectorRoute = ">> .semantic-footer",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(ContentPresenter),
Since = "6.0")]
[SemanticPart("close",
SelectorClass = "semantic-close",
SelectorRoute = ">> .semantic-close",
CrossVisualRoot = true,
CrossNestedOwners = true,
RuntimeCreated = true,
ContractType = typeof(IconButton),
Since = "6.0")]
public partial class Drawer
{
}
+43 -4
View File
@@ -4,6 +4,7 @@ using AtomUI.Controls;
using AtomUI.Controls.Primitives;
using AtomUI.Data;
using AtomUI.Desktop.Controls.DesignTokens;
using AtomUI.Theme.SemanticParts;
using AtomUI.Utils;
using Avalonia;
using Avalonia.Controls;
@@ -14,9 +15,10 @@ using Avalonia.VisualTree;
namespace AtomUI.Desktop.Controls;
public class Drawer : Control,
IMotionAwareControl,
ICustomizableSizeTypeAware
public partial class Drawer : Control,
IMotionAwareControl,
ICustomizableSizeTypeAware,
ISemanticPartCrossRootProvider
{
#region 公共属性定义
@@ -44,9 +46,12 @@ public class Drawer : Control,
public static readonly StyledProperty<bool> IsShowCloseButtonProperty =
AvaloniaProperty.Register<Drawer, bool>(nameof(IsShowCloseButton), true);
public static readonly StyledProperty<bool> IsCloseOnMaskClickProperty =
public static readonly StyledProperty<bool> IsCloseOnMaskClickProperty =
AvaloniaProperty.Register<Drawer, bool>(nameof(IsCloseOnMaskClick), true);
public static readonly StyledProperty<bool> IsPinnedOpenProperty =
AvaloniaProperty.Register<Drawer, bool>(nameof(IsPinnedOpen));
public static readonly StyledProperty<string> TitleProperty =
AvaloniaProperty.Register<Drawer, string>(nameof(Title));
@@ -130,6 +135,16 @@ public class Drawer : Control,
set => SetValue(IsCloseOnMaskClickProperty, value);
}
/// <summary>
/// 钉住常开(语义预览用):为 true 时遮罩点击与关闭按钮不再将 <see cref="IsOpen"/> 置 false;
/// 外部代码直接设置 <see cref="IsOpen"/> 仍正常关闭。对齐上游 antd 语义演示的受控常开行为。
/// </summary>
public bool IsPinnedOpen
{
get => GetValue(IsPinnedOpenProperty);
set => SetValue(IsPinnedOpenProperty, value);
}
public string Title
{
get => GetValue(TitleProperty);
@@ -195,6 +210,27 @@ public class Drawer : Control,
#endregion
#region ISemanticPartCrossRootProvider 实现
public event EventHandler? CrossRootsChanged;
public IReadOnlyList<Visual> GetCrossRoots()
{
if (_container is not null && _container.GetVisualParent() is not null)
{
return [_container];
}
return Array.Empty<Visual>();
}
private void RaiseCrossRootsChanged()
{
CrossRootsChanged?.Invoke(this, EventArgs.Empty);
}
#endregion
#region 内部属性定义
internal static readonly DirectProperty<Drawer, double> EffectiveDialogSizeProperty =
@@ -390,6 +426,7 @@ public class Drawer : Control,
CreateDrawerContainer();
Debug.Assert(_container != null);
_container.Open(layer);
RaiseCrossRootsChanged();
}
private void Close()
@@ -417,6 +454,7 @@ public class Drawer : Control,
private void ReleaseDrawerContainer()
{
RaiseCrossRootsChanged();
_container?.Release();
_container = null;
}
@@ -530,6 +568,7 @@ public class Drawer : Control,
protected internal virtual void NotifyClosed()
{
Closed?.Invoke(this, EventArgs.Empty);
RaiseCrossRootsChanged();
}
private void ApplyDialogSizeTokenBinding()
@@ -14,7 +14,6 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Transformation;
using Avalonia.VisualTree;
using Avalonia.Threading;
namespace AtomUI.Desktop.Controls;
@@ -247,7 +246,9 @@ internal class DrawerContainer : ContentControl
Bind(IsShowCloseButtonProperty, drawer[!AtomUI.Desktop.Controls.Drawer.IsShowCloseButtonProperty]),
Bind(IsMotionEnabledProperty, drawer[!AtomUI.Desktop.Controls.Drawer.IsMotionEnabledProperty]),
Bind(IsCloseOnMaskClickProperty, drawer[!AtomUI.Desktop.Controls.Drawer.IsCloseOnMaskClickProperty]),
Bind(PushOffsetPercentProperty, drawer[!AtomUI.Desktop.Controls.Drawer.PushOffsetPercentProperty])
Bind(PushOffsetPercentProperty, drawer[!AtomUI.Desktop.Controls.Drawer.PushOffsetPercentProperty]),
Bind(ThemeVariantScope.ActualThemeVariantProperty,
drawer.GetBindingObservable(ThemeVariantScope.ActualThemeVariantProperty))
};
}
@@ -445,6 +446,14 @@ internal class DrawerContainer : ContentControl
currentParent.Children.Remove(this);
}
// 语义部件契约:容器必须逻辑挂到 Drawer owner 下,owner 嵌套的生成
// Semantic Style 才能命中容器子树。必须在 Children.Add 之前设置:
// Panel 收集子项不会覆盖已显式设置的逻辑父(先例:ImagePreviewer 的 OverlayHost)。
if (Drawer is not null && Drawer.TryGetTarget(out var drawer))
{
((ISetLogicalParent)this).SetParent(drawer);
}
if (!layer.Children.Contains(this))
{
layer.Children.Add(this);
@@ -462,6 +471,7 @@ internal class DrawerContainer : ContentControl
layer?.Children.Remove(this);
}
((ISetLogicalParent)this).SetParent(null);
ScopeAwareAdornerLayer.SetAdornedElement(this, null);
ClearHostMargin();
}
@@ -557,6 +567,11 @@ internal class DrawerContainer : ContentControl
{
if (Drawer != null && Drawer.TryGetTarget(out var drawer))
{
if (drawer.IsPinnedOpen)
{
return;
}
drawer.IsOpen = false;
}
}
@@ -584,6 +599,11 @@ internal class DrawerContainer : ContentControl
{
if (Drawer != null && Drawer.TryGetTarget(out var drawer))
{
if (drawer.IsPinnedOpen)
{
return;
}
drawer.IsOpen = false;
}
}
@@ -11,6 +11,7 @@
CornerRadius="{TemplateBinding CornerRadius}">
<Panel Name="RootLayout">
<Border Name="PART_Mask"
Classes.semantic-mask="True"
IsVisible="{TemplateBinding IsShowMask}"
Background="{TemplateBinding Background}" />
<atom:MotionActor
@@ -9,25 +9,30 @@
<ControlTemplate>
<Panel
Name="RootLayout">
<Border Name="Frame" />
<Border Name="Frame"
Classes.semantic-section="True" />
<Grid Name="InfoLayout"
RowDefinitions="Auto, Auto, *, Auto, Auto">
<Grid Name="InfoHeader"
Classes.semantic-header="True"
Grid.Row="0"
ColumnDefinitions="Auto, *, Auto">
<atom:IconButton Name="PART_CloseButton"
Classes.semantic-close="True"
Grid.Column="0"
Icon="{antdicons:AntDesignIconProvider CloseOutlined}"
VerticalAlignment="Center"
IsMotionEnabled="{TemplateBinding IsMotionEnabled}"
IsVisible="{TemplateBinding IsShowCloseButton}" />
<atom:TextBlock Name="HeaderText"
Classes.semantic-title="True"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Grid.Column="1"
Text="{TemplateBinding Title}" />
<ContentPresenter
Name="ExtraContentPresenter"
Classes.semantic-extra="True"
Grid.Column="2"
HorizontalAlignment="Left"
VerticalAlignment="Center"
@@ -38,6 +43,7 @@
<atom:Separator Grid.Row="1" SizeType="Custom" />
<ContentPresenter
Name="InfoContainer"
Classes.semantic-body="True"
Grid.Row="2"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
@@ -46,6 +52,7 @@
IsVisible="{TemplateBinding HasFooter}" />
<ContentPresenter
Name="InfoFooter"
Classes.semantic-footer="True"
Grid.Row="4"
IsVisible="{TemplateBinding HasFooter}"
Content="{TemplateBinding Footer}"
@@ -0,0 +1,114 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AtomUIDrawer = AtomUI.Desktop.Controls.Drawer;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.Drawer;
public class DrawerScopeLayerTrackingTests
{
[Fact]
public void First_Open_Tracks_The_Scrolled_Host_Bounds_Immediately()
{
// 回归:首次打开局部宿主抽屉时,层注入会让宿主 ScrollContentPresenter 的
// 偏移经历「Content 置空 → Offset 钳 0 → 恢复要等下一次布局」的瞬态。
// 追踪快照若在瞬态里取到 0 偏移,容器会以未补偿滚动的位置落位且不会
// 自愈(被装饰元素的 Bounds 不随滚动变化),直到下一次开合才纠正——
// 表现为首次弹出遮罩与面板整体错位。
AvaloniaTestApp.EnsureInitialized();
var drawer = new AtomUIDrawer
{
Title = "Basic Drawer",
Content = new TextBlock { Text = "Some contents..." },
Footer = new Button { ButtonType = ButtonType.Link, Content = "Footer" },
IsMotionEnabled = false,
};
var hostItem = new Border
{
Height = 300,
Child = new StackPanel
{
Children =
{
new TextBlock { Text = "Render in this" },
drawer
}
}
};
drawer.OpenOn = hostItem;
var panel = new StackPanel { Spacing = 20 };
for (var i = 0; i < 6; i++)
{
panel.Children.Add(new Border
{
Height = 120,
Background = Avalonia.Media.Brushes.White,
Child = new TextBlock { Text = $"Card {i}" }
});
}
panel.Children.Add(hostItem);
panel.Children.Add(new Border
{
Height = 200,
Background = Avalonia.Media.Brushes.White,
Child = new TextBlock { Text = "Tail" }
});
var window = new AvaloniaWindow
{
Width = 1000,
Height = 700,
Content = new ScrollViewer { Content = panel }
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
hostItem.BringIntoView();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
var scrollPresenter = window.GetVisualDescendants().OfType<ScrollContentPresenter>().First();
scrollPresenter.Offset.Y.ShouldBeGreaterThan(0, "用例前提:宿主条目必须在滚动偏移之下");
var itemTopLeftInWindow = hostItem.TranslatePoint(new Point(0, 0), window).ShouldNotBeNull();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
var container = window.GetVisualDescendants()
.Single(v => v.GetType().Name == "DrawerContainer");
container.Bounds.Width.ShouldBe(hostItem.Bounds.Width, 1,
"首次打开后容器宽度必须等于宿主条目宽度(不允许整层回退排布)");
container.Bounds.Height.ShouldBe(hostItem.Bounds.Height, 1,
"首次打开后容器高度必须等于宿主条目高度(不允许整层回退排布)");
var containerTopLeft = container.TranslatePoint(new Point(0, 0), window).ShouldNotBeNull();
containerTopLeft.X.ShouldBe(itemTopLeftInWindow.X, 1);
containerTopLeft.Y.ShouldBe(itemTopLeftInWindow.Y, 1,
"首次打开后容器必须即刻贴合宿主条目位置(滚动偏移必须被补偿)");
// 滚动后容器继续贴合宿主条目(层随内容滚动 + 偏移订阅自愈)。
scrollPresenter.Offset = new Vector(scrollPresenter.Offset.X, scrollPresenter.Offset.Y - 100);
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
var scrolledItemTopLeft = hostItem.TranslatePoint(new Point(0, 0), window).ShouldNotBeNull();
var scrolledContainerTopLeft = container.TranslatePoint(new Point(0, 0), window).ShouldNotBeNull();
scrolledContainerTopLeft.Y.ShouldBe(scrolledItemTopLeft.Y, 1,
"滚动后容器必须继续贴合宿主条目位置");
}
finally
{
window.Close();
}
}
}
@@ -0,0 +1,392 @@
using System.Xml.Linq;
using AtomUI.Controls;
using AtomUI.Theme;
using AtomUI.Theme.Schema;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Input;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AtomUIDrawer = AtomUI.Desktop.Controls.Drawer;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.Drawer;
public class DrawerSemanticPartTests
{
private const string DrawerContainerThemePath =
"src/AtomUI.Desktop.Controls/Drawer/Themes/DrawerContainerTheme.axaml";
private const string DrawerInfoContainerThemePath =
"src/AtomUI.Desktop.Controls/Drawer/Themes/DrawerInfoContainerTheme.axaml";
// manifest 顺序:隐式 root 在前,其余按 path 字典序。
private static readonly string[] ApprovedPartNames =
[
"root", "body", "close", "extra", "footer", "header", "mask", "section", "title"
];
static DrawerSemanticPartTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Registered_Descriptor_Exposes_Only_The_Approved_Drawer_Parts()
{
var registry = Application.Current.ShouldNotBeNull()
.GetThemeManager().ShouldNotBeNull()
.SemanticParts;
registry.TryGetControl(typeof(AtomUIDrawer), out var descriptor).ShouldBeTrue();
descriptor.ShouldNotBeNull();
descriptor.Parts.Select(static part => part.Name).ShouldBe(ApprovedPartNames);
AssertRoot(descriptor, typeof(AtomUIDrawer));
AssertPart(descriptor, "mask", "semantic-mask", typeof(Border), ">> .semantic-mask",
SemanticPartCardinality.Optional);
AssertPart(descriptor, "section", "semantic-section", typeof(Border), ">> .semantic-section");
AssertPart(descriptor, "header", "semantic-header", typeof(Avalonia.Controls.Grid), ">> .semantic-header");
AssertPart(descriptor, "title", "semantic-title", typeof(AtomUI.Desktop.Controls.TextBlock), ">> .semantic-title");
AssertPart(descriptor, "extra", "semantic-extra", typeof(ContentPresenter), ">> .semantic-extra");
AssertPart(descriptor, "body", "semantic-body", typeof(ContentPresenter), ">> .semantic-body");
AssertPart(descriptor, "footer", "semantic-footer", typeof(ContentPresenter), ">> .semantic-footer");
AssertPart(descriptor, "close", "semantic-close", typeof(IconButton), ">> .semantic-close");
}
[Fact]
public void Container_Themes_Carry_The_Approved_Static_Markers()
{
// Drawer 的 owner 模板为空,全部 marker 静态声明在两个内部容器主题上
//(RuntimeCreated=true 豁免 owner 模板校验,因此用显式 XDocument 断言锁定)。
var containerDoc = XDocument.Load(GetRepoFile(DrawerContainerThemePath), LoadOptions.SetLineInfo);
CollectMarkers(containerDoc).ShouldBe(["semantic-mask:Border"]);
var infoDoc = XDocument.Load(GetRepoFile(DrawerInfoContainerThemePath), LoadOptions.SetLineInfo);
CollectMarkers(infoDoc).ShouldBe(
[
"semantic-body:ContentPresenter",
"semantic-close:IconButton",
"semantic-extra:ContentPresenter",
"semantic-footer:ContentPresenter",
"semantic-header:Grid",
"semantic-section:Border",
"semantic-title:TextBlock"
]);
}
[Fact]
public void Drawer_Parts_Expose_Markers_When_Opened()
{
var drawer = new AtomUIDrawer
{
Title = "Title",
Content = new TextBlock { Text = "Body" },
Extra = new TextBlock { Text = "Extra" },
Footer = new TextBlock { Text = "Footer" },
IsMotionEnabled = false,
Width = 1,
Height = 1
};
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = drawer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
var descendants = window.GetVisualDescendants().ToArray();
descendants.OfType<Border>().Single(b => b.Name == "PART_Mask")
.Classes.Contains("semantic-mask").ShouldBeTrue();
descendants.OfType<Border>().Single(b => b.Name == "Frame")
.Classes.Contains("semantic-section").ShouldBeTrue();
descendants.OfType<Avalonia.Controls.Grid>().Single(g => g.Name == "InfoHeader")
.Classes.Contains("semantic-header").ShouldBeTrue();
descendants.OfType<AtomUI.Desktop.Controls.TextBlock>().Single(t => t.Name == "HeaderText")
.Classes.Contains("semantic-title").ShouldBeTrue();
descendants.OfType<ContentPresenter>().Single(p => p.Name == "ExtraContentPresenter")
.Classes.Contains("semantic-extra").ShouldBeTrue();
descendants.OfType<ContentPresenter>().Single(p => p.Name == "InfoContainer")
.Classes.Contains("semantic-body").ShouldBeTrue();
descendants.OfType<ContentPresenter>().Single(p => p.Name == "InfoFooter")
.Classes.Contains("semantic-footer").ShouldBeTrue();
descendants.OfType<IconButton>().Single(b => b.Name == "PART_CloseButton")
.Classes.Contains("semantic-close").ShouldBeTrue();
}
finally
{
window.Close();
}
}
[Fact]
public void DrawerContainer_Is_Logically_Parented_To_The_Owner_While_Open()
{
var drawer = new AtomUIDrawer
{
Content = new TextBlock { Text = "Body" },
IsMotionEnabled = false,
Width = 1,
Height = 1
};
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = drawer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
var container = window.GetVisualDescendants().OfType<DrawerContainer>().Single();
container.Parent.ShouldBeSameAs(drawer,
"打开期间容器必须逻辑挂到 Drawer owner 下,owner 嵌套语义 Style 才可达(系统文档 §9.2)");
drawer.IsOpen = false;
Dispatcher.UIThread.RunJobs();
container.GetVisualParent().ShouldBeNull();
container.Parent.ShouldBeNull("脱离 layer 后必须解除逻辑父,避免样式宿主残留");
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
window.GetVisualDescendants().OfType<DrawerContainer>().Single()
.Parent.ShouldBeSameAs(drawer, "复用容器重开时必须重新挂回逻辑父");
}
finally
{
window.Close();
}
}
[Fact]
public void Generated_Semantic_Styles_Apply_To_The_Container_Targets()
{
var registry = Application.Current.ShouldNotBeNull()
.GetThemeManager().ShouldNotBeNull()
.SemanticParts;
registry.TryGetControl(typeof(AtomUIDrawer), out var descriptor).ShouldBeTrue();
descriptor.ShouldNotBeNull();
var drawer = new AtomUIDrawer
{
Title = "Title",
Content = new TextBlock { Text = "Body" },
Extra = new TextBlock { Text = "Extra" },
Footer = new TextBlock { Text = "Footer" },
IsMotionEnabled = false,
Width = 1,
Height = 1
};
drawer.Classes.Add("semantic-owner");
var ownerStyle = new Avalonia.Styling.Style(
selector => selector.OfType<AtomUIDrawer>().Class("semantic-owner"));
foreach (var part in descriptor.Parts.Where(static part => part.StyleType != null))
{
var partStyle = (Avalonia.Styling.Style)Activator
.CreateInstance(part.StyleType.ShouldNotBeNull()).ShouldNotBeNull();
partStyle.Setters.Add(new Avalonia.Styling.Setter(
Avalonia.Controls.Control.TagProperty, part.Name));
ownerStyle.Children.Add(partStyle);
}
drawer.Styles.Add(ownerStyle);
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = drawer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
var descendants = window.GetVisualDescendants().ToArray();
descendants.OfType<Border>().Single(b => b.Name == "PART_Mask").Tag.ShouldBe("mask");
descendants.OfType<Border>().Single(b => b.Name == "Frame").Tag.ShouldBe("section");
descendants.OfType<Avalonia.Controls.Grid>().Single(g => g.Name == "InfoHeader").Tag.ShouldBe("header");
descendants.OfType<AtomUI.Desktop.Controls.TextBlock>().Single(t => t.Name == "HeaderText").Tag.ShouldBe("title");
descendants.OfType<ContentPresenter>().Single(p => p.Name == "ExtraContentPresenter").Tag.ShouldBe("extra");
descendants.OfType<ContentPresenter>().Single(p => p.Name == "InfoContainer").Tag.ShouldBe("body");
descendants.OfType<ContentPresenter>().Single(p => p.Name == "InfoFooter").Tag.ShouldBe("footer");
descendants.OfType<IconButton>().Single(b => b.Name == "PART_CloseButton").Tag.ShouldBe("close");
}
finally
{
window.Close();
}
}
[Fact]
public void Pinned_Drawer_Ignores_Mask_Click_And_Close_Button()
{
var drawer = new AtomUIDrawer
{
Content = new TextBlock { Text = "Body" },
IsCloseOnMaskClick = true,
IsPinnedOpen = true,
IsMotionEnabled = false,
Width = 1,
Height = 1
};
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = drawer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
var mask = window.GetVisualDescendants().OfType<Border>()
.Single(b => b.Name == "PART_Mask");
RaisePointerReleased(mask, MouseButton.Left, PointerUpdateKind.LeftButtonReleased);
Dispatcher.UIThread.RunJobs();
drawer.IsOpen.ShouldBeTrue("钉住时遮罩点击不得关闭(语义预览常开)");
var closeButton = window.GetVisualDescendants().OfType<IconButton>()
.Single(b => b.Name == "PART_CloseButton");
closeButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(
AtomUI.Desktop.Controls.Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
drawer.IsOpen.ShouldBeTrue("钉住时关闭按钮不得关闭(语义预览常开)");
// 外部代码直接置 IsOpen=false 仍可关闭(受控常开只拦交互路径)。
drawer.IsOpen = false;
Dispatcher.UIThread.RunJobs();
drawer.IsOpen.ShouldBeFalse();
// 取消钉住后恢复常规交互关闭。
drawer.IsPinnedOpen = false;
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
RaisePointerReleased(mask, MouseButton.Left, PointerUpdateKind.LeftButtonReleased);
Dispatcher.UIThread.RunJobs();
drawer.IsOpen.ShouldBeFalse("未钉住时遮罩点击应保持关闭行为");
}
finally
{
window.Close();
}
}
[Fact]
public void Drawer_Reports_The_Container_As_The_Live_Cross_Root()
{
var drawer = new AtomUIDrawer
{
Content = new TextBlock { Text = "Body" },
IsMotionEnabled = false,
Width = 1,
Height = 1
};
drawer.GetCrossRoots().ShouldBeEmpty();
var raised = 0;
drawer.CrossRootsChanged += (_, _) => raised++;
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = drawer };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
drawer.IsOpen = true;
Dispatcher.UIThread.RunJobs();
var container = window.GetVisualDescendants().OfType<DrawerContainer>().Single();
drawer.GetCrossRoots().ShouldHaveSingleItem().ShouldBeSameAs(container);
drawer.IsOpen = false;
Dispatcher.UIThread.RunJobs();
drawer.GetCrossRoots().ShouldBeEmpty();
raised.ShouldBeGreaterThanOrEqualTo(2);
}
finally
{
window.Close();
}
}
private static void RaisePointerReleased(Control source, MouseButton button, PointerUpdateKind updateKind)
{
source.RaiseEvent(new PointerReleasedEventArgs(
source,
new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true),
source,
default,
0,
new PointerPointProperties(RawInputModifiers.None, updateKind),
KeyModifiers.None,
button));
}
private static void AssertRoot(ControlSemanticDescriptor descriptor, Type controlType)
{
var root = descriptor.Parts.Single(static part => part.Name == "root");
root.Path.ShouldBe("root");
root.SelectorClass.ShouldBeNull();
root.SelectorRoute.ShouldBeNull();
root.ContractType.ShouldBe(controlType);
root.Cardinality.ShouldBe(SemanticPartCardinality.Single);
root.Customization.ShouldBe(SemanticPartCustomization.Root);
root.StyleType.ShouldBeNull();
}
private static void AssertPart(
ControlSemanticDescriptor descriptor,
string name,
string selectorClass,
Type contractType,
string selectorRoute,
SemanticPartCardinality cardinality = SemanticPartCardinality.Single)
{
var part = descriptor.Parts.Single(candidate => candidate.Name == name);
part.Path.ShouldBe(name);
part.SelectorClass.ShouldBe(selectorClass);
part.SelectorRoute.ShouldBe(selectorRoute);
part.ContractType.ShouldBe(contractType);
part.Cardinality.ShouldBe(cardinality);
part.Customization.ShouldBe(SemanticPartCustomization.Selector);
part.Since.ShouldBe("6.0");
part.StyleType.ShouldNotBeNull();
part.StyleType.Name.ShouldBe($"Drawer{Pascal(name)}Style");
}
private static string Pascal(string partName)
{
return char.ToUpperInvariant(partName[0]) + partName[1..];
}
private static string[] CollectMarkers(XDocument document)
{
return document.Descendants()
.SelectMany(static element => element.Attributes()
.Where(static attribute => attribute.Name.LocalName.StartsWith(
"Classes.semantic-", StringComparison.Ordinal)))
.Select(static attribute =>
$"{attribute.Name.LocalName["Classes.".Length..]}:{attribute.Parent!.Name.LocalName}")
.OrderBy(static marker => marker, StringComparer.Ordinal)
.ToArray();
}
private static string GetRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -35,7 +35,7 @@ AtomUIGallery.Localization.CustomizeThemeShowCaseLangResourceKind|ScenarioExampl
AtomUIGallery.Localization.DataGridShowCaseLangResourceKind|BasicTitle,BasicDescription,SelectionTitle,SelectionDescription,DragResizeColumnTitle,DragResizeColumnDescription,SizeTitle,SizeDescription,TableBorderTitleAndFooterTitle,TableBorderTitleAndFooterDescription,FilterAndSorterTitle,FilterAndSorterDescription,FilterInTreeTitle,FilterInTreeDescription,MultipleSorterTitle,MultipleSorterDescription,ResetFiltersAndSortersTitle,ResetFiltersAndSortersDescription,ExpandableRowTitle,ExpandableRowDescription,OrderSpecificColumnTitle,OrderSpecificColumnDescription,RowHeaderTitle,RowHeaderDescription,GroupingTableHeadTitle,GroupingTableHeadDescription,HiddenColumnsTitle,HiddenColumnsDescription,FixedHeaderTitle,FixedHeaderDescription,FixedColumnsTitle,FixedColumnsDescription,FixedColumnsAndHeadersTitle,FixedColumnsAndHeadersDescription,DragColumnSortingTitle,DragColumnSortingDescription,DragSortingWithHandlerTitle,DragSortingWithHandlerDescription,CustomEmptyAndLoadingTitle,CustomEmptyAndLoadingDescription,EditableCellsTitle,EditableCellsDescription,BasicPagingTitle,BasicPagingDescription,RemoteRangeTitle,RemoteRangeDescription,RemoteRangeReload,RemoteRangeFailNext,RemoteRangeExpireSnapshot,ScenarioBasic,ScenarioInteraction,ScenarioFiltering,ScenarioStructure,ScenarioFixed,ScenarioDrag,ScenarioEditing,ScenarioPaging,P2HeaderName,P2HeaderAge,P2HeaderAddress,P2HeaderTags,P2HeaderAction,P2TextInvite,P2TextModify,P2TextDelete,P2TitleNormal,P2HeaderGender,P2HeaderEmail,P2TitleManyColumns,P2HeaderColumn1,P2HeaderColumn2,P2HeaderColumn3,P2HeaderColumn4,P2HeaderColumn5,P2HeaderColumn6,P2HeaderColumn7,P2HeaderColumn8,P2HeaderColumn9,P2TitleDragReorderWithFrozenColumns,P2TitleSureToDelete,P2TextJoe,P2TextJim,P2TextSubmenu,P2TextGreen,P2TextBlack,P2TextLondon,P2TextNewYork,P2HeaderChineseScore,P2HeaderMathScore,P2HeaderEnglishScore,P2HeaderColumnN1,P2HeaderColumnN2,P2HeaderColumnN3,P2HeaderColumnN4,P2HeaderColumnN5,P2HeaderColumnN6,P2HeaderColumnN7,P2HeaderColumnN8,P2HeaderColumnN9,P2HeaderColumnN10,P2HeaderColumnN11,P2HeaderColumnN12,P2HeaderColumnN13,P2HeaderColumnN14,P2HeaderColumnN15,P2HeaderColumnN16,P2HeaderColumnN17,P2HeaderColumnN18,P2HeaderColumnN19,P2HeaderColumnN20,P2HeaderCashAssets,P2HeaderOther,P2HeaderStreet,P2HeaderBlock,P2HeaderBuilding,P2HeaderDoorNo,P2HeaderCompany,P2HeaderCompanyAddress,P2HeaderCompanyName,P2TitleHeader,P2TitleFooter,P2ContentToggleData,P2ContentToggleLoading,P2ContentAddARow,P2ContentSortAge,P2ContentClearFilters,P2ContentClearFiltersAndSorters,P2ContentExtended,P2ContentSingle,P2TextPaginationvisibility,P2ContentTopPagination,P2ContentBottomPagination,P2TextTopPaginationAlign,P2ContentStart,P2ContentCenter,P2ContentEnd,P2TextBottomPaginationAlign,P2ContentColumnN1,P2ContentColumnN2,P2ContentColumnN3,P2ContentColumnN4,P2ContentColumnN5,P2ContentColumnN6,ComponentCategory,ComponentStatusStable,PageSubtitle,PageDescription,ScenarioExamples
AtomUIGallery.Localization.DatePickerShowCaseLangResourceKind|BasicTitle,BasicDescription,SwitchableTitle,SwitchableDescription,BindingTitle,BindingDescription,PickerDisplayDateTitle,PickerDisplayDateDescription,MinMaxTitle,MinMaxDescription,RangePickerTitle,RangePickerDescription,NeedConfirmTitle,NeedConfirmDescription,ChooseTimeTitle,ChooseTimeDescription,DisabledTitle,DisabledDescription,ThreeSizesTitle,ThreeSizesDescription,StatusTitle,StatusDescription,VariantsTitle,VariantsDescription,PlacementTitle,PlacementDescription,P2PlaceholderTextSelectDate,P2PlaceholderTextSelectWeek,P2PlaceholderTextSelectMonth,P2PlaceholderTextSelectQuarter,P2PlaceholderTextSelectYear,P2SecondaryPlaceholderTextEndDate,P2PlaceholderTextSelectTime,P2PlaceholderTextStartDate,P2PlaceholderTextStartWeek,P2SecondaryPlaceholderTextEndWeek,P2PlaceholderTextStartMonth,P2SecondaryPlaceholderTextEndMonth,P2PlaceholderTextStartQuarter,P2SecondaryPlaceholderTextEndQuarter,P2PlaceholderTextStartYear,P2SecondaryPlaceholderTextEndYear,P2PlaceholderTextOutline,P2PlaceholderTextFilled,P2PlaceholderTextBorderless,P2TextExpandDirection,P2ContentLarge,P2ContentDefault,P2ContentSmall,P2ContentCustom,P2ContentTime,P2ContentDate,P2ContentWeek,P2ContentMonth,P2ContentQuarter,P2ContentYear,P2TextPlacement,P2TextSelectedDateTime,P2TextSelectedDateRange,P2ContentSetTomorrow,P2ContentSetThisWeek,P2ContentClear,P2ContentTopleft,P2ContentTopright,P2ContentBottomleft,P2ContentBottomright,PageSubtitle,PageDescription,ComponentCategory,ComponentStatusStable,ScenarioExamples,StyleClassTitle,StyleClassDescription,SemanticClearDescription,SemanticInputDescription,SemanticPopupBodyDescription,SemanticPopupCellDescription,SemanticPopupContainerDescription,SemanticPopupContentDescription,SemanticPopupFooterDescription,SemanticPopupHeaderDescription,SemanticPopupRootDescription,SemanticPrefixDescription,SemanticRootDescription,SemanticSecondaryInputDescription,SemanticSuffixDescription
AtomUIGallery.Localization.DescriptionsShowCaseLangResourceKind|PageSubtitle,PageDescription,ComponentCategory,ComponentStatusStable,ScenarioExamples,BasicTitle,BasicDescription,BorderTitle,BorderDescription,CustomSizeTitle,CustomSizeDescription,ResponsiveTitle,ResponsiveDescription,VerticalTitle,VerticalDescription,VerticalBorderTitle,VerticalBorderDescription,RowTitle,RowDescription,SemanticPartStyleTitle,SemanticPartStyleDescription,SemanticRootDescription,SemanticHeaderDescription,SemanticTitleDescription,SemanticExtraDescription,SemanticLabelDescription,SemanticContentDescription,P2HeaderUserInfo,P2HeaderCustomSize,P2HeaderResponsiveDescriptions,P2LabelUserName,P2LabelTelephone,P2LabelLive,P2LabelRemark,P2LabelAddress,P2LabelProduct,P2LabelBillingMode,P2LabelBilling,P2LabelAutomaticRenewal,P2LabelOrderTime,P2LabelUsageTime,P2LabelStatus,P2LabelNegotiatedAmount,P2LabelDiscount,P2LabelOfficialReceipts,P2LabelOfficial,P2LabelConfigInfo,P2LabelTime,P2LabelAmount,P2LabelHardwareInfo,P2ContentZhouMaomao,P2ContentHangzhouZhejiang,P2ContentEmpty,P2ContentAddress,P2ContentCloudDatabase,P2ContentPrepaid,P2ContentYes,P2ContentRunning,P2TextDataDiskTypeMongodb,P2TextDatabaseVersionN3N4,P2TextPackageDdsMongoMid,P2TextStorageSpaceN10Gb,P2TextReplicationFactorN3,P2TextRegionEastChinaN1,P2ContentLarge,P2ContentMiddle,P2ContentSmall,P2ContentEdit,P2TextCpuN6CoreN3N5Ghz
AtomUIGallery.Localization.DrawerShowCaseLangResourceKind|PageSubtitle,PageDescription,ComponentCategory,ComponentStatusStable,ScenarioExamples,BasicTitle,BasicDescription,MultiLevelTitle,MultiLevelDescription,ExtraAndFooterTitle,ExtraAndFooterDescription,FormInDrawerTitle,FormInDrawerDescription,NoMaskTitle,NoMaskDescription,CustomPlacementTitle,CustomPlacementDescription,RenderInCurrentAreaTitle,RenderInCurrentAreaDescription,PresetSizeTitle,PresetSizeDescription,P2TitleBasicDrawer,P2TextSomeContents,P2TitleFirstLevelDrawer,P2TitleTwoLevelDrawer,P2TextPlacement,P2ContentLeft,P2ContentTop,P2ContentRight,P2ContentBottom,P2ContentTwoLevelDrawer,P2ContentCancel,P2ContentOk,P2ContentSubmit,P2ContentNewAccount,P2ContentEdit,P2ContentUpload,P2ContentDelete,P2TitleCreateNewAccount,P2LabelTextName,P2LabelTextUrl,P2LabelTextOwner,P2LabelTextType,P2LabelTextApprover,P2LabelTextDateTime,P2LabelTextDescription,P2PlaceholderTextPleaseEnterUserName,P2PlaceholderTextPleaseEnterUrl,P2PlaceholderTextPleaseSelectOwner,P2PlaceholderTextPleaseChooseType,P2PlaceholderTextPleaseChooseApprover,P2PlaceholderTextStartDate,P2SecondaryPlaceholderTextEndDate,P2PlaceholderTextPleaseEnterUrlDescription,P2HeaderXiaoxiaoFu,P2HeaderMaomaoZhou,P2HeaderPrivate,P2HeaderPublic,P2HeaderJackMa,P2HeaderTomLiu,P2TextRenderInThis,P2ContentOpenDefaultSizeN378px,P2ContentOpenLargeSizeN736px,P2ContentOpenCustomSizeN400px,P2ContentOpenCustomSizeN50,P2ContentOpen
AtomUIGallery.Localization.DrawerShowCaseLangResourceKind|PageSubtitle,PageDescription,ComponentCategory,ComponentStatusStable,ScenarioExamples,BasicTitle,BasicDescription,MultiLevelTitle,MultiLevelDescription,ExtraAndFooterTitle,ExtraAndFooterDescription,FormInDrawerTitle,FormInDrawerDescription,NoMaskTitle,NoMaskDescription,CustomPlacementTitle,CustomPlacementDescription,RenderInCurrentAreaTitle,RenderInCurrentAreaDescription,PresetSizeTitle,PresetSizeDescription,P2TitleBasicDrawer,P2TextSomeContents,P2TitleFirstLevelDrawer,P2TitleTwoLevelDrawer,P2TextPlacement,P2ContentLeft,P2ContentTop,P2ContentRight,P2ContentBottom,P2ContentTwoLevelDrawer,P2ContentCancel,P2ContentOk,P2ContentSubmit,P2ContentNewAccount,P2ContentEdit,P2ContentUpload,P2ContentDelete,P2TitleCreateNewAccount,P2LabelTextName,P2LabelTextUrl,P2LabelTextOwner,P2LabelTextType,P2LabelTextApprover,P2LabelTextDateTime,P2LabelTextDescription,P2PlaceholderTextPleaseEnterUserName,P2PlaceholderTextPleaseEnterUrl,P2PlaceholderTextPleaseSelectOwner,P2PlaceholderTextPleaseChooseType,P2PlaceholderTextPleaseChooseApprover,P2PlaceholderTextStartDate,P2SecondaryPlaceholderTextEndDate,P2PlaceholderTextPleaseEnterUrlDescription,P2HeaderXiaoxiaoFu,P2HeaderMaomaoZhou,P2HeaderPrivate,P2HeaderPublic,P2HeaderJackMa,P2HeaderTomLiu,P2TextRenderInThis,P2ContentOpenDefaultSizeN378px,P2ContentOpenLargeSizeN736px,P2ContentOpenCustomSizeN400px,P2ContentOpenCustomSizeN50,P2ContentOpen,SemanticRootDescription,SemanticMaskDescription,SemanticSectionDescription,SemanticHeaderDescription,SemanticTitleDescription,SemanticExtraDescription,SemanticBodyDescription,SemanticFooterDescription,SemanticCloseDescription,SemanticStageHint,SemanticPreviewTitleText,SemanticPreviewFooterLink,SemanticStylesTitle,SemanticStylesDescription
AtomUIGallery.Localization.DropdownButtonShowCaseLangResourceKind|ComponentCategory,ComponentStatusStable,PageSubtitle,PageDescription,ScenarioExamples,BasicTitle,BasicDescription,ButtonTypesTitle,ButtonTypesDescription,SizeTypeTitle,SizeTypeDescription,ArrowTitle,ArrowDescription,PlacementTitle,PlacementDescription,P2HeaderCut,P2HeaderCopy,P2HeaderDelete,P2HeaderPaste,P2HeaderPasteFromHistory,P2ContentHoverMe,P2ContentEditFile,P2ContentLarge,P2ContentMiddle,P2ContentSmall,P2ContentCustom,P2ContentBottomLeft,P2ContentBottom,P2ContentBottomRight,P2ContentTopLeft,P2ContentTop,P2ContentTopRight,SemanticItemContentDescription,SemanticItemDescription,SemanticItemIconDescription,SemanticItemTitleDescription,SemanticPopupRootDescription,SemanticPreviewGroupTitle,SemanticPreviewFirstMenuItem,SemanticPreviewSecondMenuItem,SemanticPreviewSubMenu,SemanticPreviewItem1,SemanticPreviewOption1,SemanticPreviewOption2,SemanticStylesDescription,SemanticStylesTitle,SemanticStylesObjectButton,SemanticStylesFunctionButton,SemanticStylesItemProfile,SemanticStylesItemSettings,SemanticStylesItemLogout
AtomUIGallery.Localization.EmptyShowCaseLangResourceKind|BasicTitle,BasicDescription,SizeTitle,SizeDescription,CustomizeTitle,CustomizeDescription,NoDescriptionTitle,NoDescriptionDescription,SemanticPartStyleTitle,SemanticPartStyleDescription,SemanticRootDescription,SemanticImageDescription,SemanticDescriptionDescription,SemanticFooterDescription,P2DescriptionCustomizeDescription,P2DescriptionObjectStyles,P2DescriptionFunctionStyles,P2ContentCreateNow,ScenarioExamples,PageSubtitle,PageDescription,ComponentCategory,ComponentStatusStable
AtomUIGallery.Localization.ExpanderShowCaseLangResourceKind|ScenarioExamples,ComponentCategory,ComponentStatusStable,PageSubtitle,PageDescription,ScenarioBasic,ScenarioAppearance,ScenarioBehavior,ExpanderTitle,ExpanderDescription,SizeTitle,SizeDescription,ExpandingDirectionTitle,ExpandingDirectionDescription,NestedPanelTitle,NestedPanelDescription,BorderlessTitle,BorderlessDescription,NoArrowTitle,NoArrowDescription,ExpandIconLocationTitle,ExpandIconLocationDescription,GhostCollapseTitle,GhostCollapseDescription,CollapsibleTitle,CollapsibleDescription,CustomPaddingTitle,CustomPaddingDescription,P2HeaderThisIsPanelHeaderN1,P2TitleDefaultSize,P2HeaderThisIsDefaultSizePanelHeader,P2TitleSmallSize,P2HeaderThisIsSmallSizePanelHeader,P2TitleLargeSize,P2HeaderThisIsLargeSizePanelHeader,P2HeaderThisIsPanelHeader,P2HeaderThisIsNestedPanelHeader,P2HeaderThisPanelCanOnlyBeCollapsedByClicking,P2HeaderThisPanelCanOnlyBeCollapsedByClicking2,P2HeaderThisPanelCanTBeCollapsed,P2TextADogIsATypeOfDomesticatedAnimal,P2TextExpandDirection,P2ContentDown,P2ContentUp,P2ContentLeft,P2ContentRight,P2TextExpandIconPosition,P2ContentStart,P2ContentEnd
@@ -120,7 +120,7 @@ public class GalleryCatalogCoverageTests
.ToArray();
sourceFiles.Length.ShouldBe(81);
sourceFiles.Sum(CountUnits).ShouldBe(4547);
sourceFiles.Sum(CountUnits).ShouldBe(4561);
foreach (var sourcePath in sourceFiles)
{
var targetPath = Path.Combine(
@@ -0,0 +1,118 @@
using AtomUI.Toolkits.GalleryBase.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ReactiveUI;
using Shouldly;
using Xunit;
using DrawerShowCase = AtomUIGallery.ShowCases.Drawer.DrawerShowCase;
using DrawerViewModel = AtomUIGallery.ShowCases.Drawer.DrawerViewModel;
using AtomUIWindow = AtomUI.Desktop.Controls.Window;
namespace AtomUIGallery.Tests.ShowCases;
public class DrawerSemanticPartHighlightTests
{
[Fact]
public void Drawer_Semantic_Preview_Highlights_All_Parts_Inside_The_Inline_Stage()
{
AvaloniaTestApp.EnsureInitialized();
var page = new DrawerShowCase { DataContext = new DrawerViewModel(new DrawerTestScreen()) };
var window = new AtomUIWindow { Width = 1280, Height = 900, Content = page };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var host = page.GetVisualDescendants().OfType<GalleryShowCaseHost>().Single();
host.SelectedTab = GalleryShowCaseTab.SemanticParts;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
var preview = page.GetVisualDescendants().OfType<SemanticPartPreview>()
.Single(p => p.Name == "DrawerSemanticPreview");
var owner = preview.GetVisualDescendants()
.OfType<AtomUI.Desktop.Controls.Drawer>()
.Single(d => d.Name == "DrawerSemanticOwner");
owner.IsOpen.ShouldBeTrue("进入语义页签后,舞台内抽屉必须保持常开");
owner.GetCrossRoots().ShouldHaveSingleItem();
var partsPane = preview.GetVisualDescendants().OfType<Border>()
.First(b => b.Name == "PART_PartsPane");
var cards = partsPane.GetVisualDescendants().OfType<UserControl>()
.Where(c => c.GetType().Name.Contains("SemanticPartPreviewItem"))
.ToArray();
cards.Length.ShouldBe(9);
foreach (var path in new[]
{
"root", "mask", "section", "header",
"title", "extra", "body", "footer", "close"
})
{
HoverCard(cards, window, path);
GetHighlightCount(window).ShouldBe(1);
}
// 钉住常开:语义页签内点击遮罩不关闭。
var mask = window.GetVisualDescendants().OfType<Border>()
.Single(b => b.Name == "PART_Mask");
RaisePointerReleased(mask);
Dispatcher.UIThread.RunJobs();
owner.IsOpen.ShouldBeTrue("钉住的语义预览抽屉必须忽略遮罩点击");
host.SelectedTab = GalleryShowCaseTab.Examples;
Dispatcher.UIThread.RunJobs();
GetHighlightCount(window).ShouldBe(0);
}
finally
{
window.Close();
Dispatcher.UIThread.RunJobs();
}
}
private static int GetHighlightCount(AtomUIWindow window)
{
return window.GetVisualDescendants()
.Count(v => v.GetType().Name == "SemanticPartAdorner");
}
private static void HoverCard(UserControl[] cards, AtomUIWindow window, string path)
{
var card = cards.Single(c =>
(string?)c.DataContext?.GetType().GetProperty("Path")!.GetValue(c.DataContext) == path);
card.BringIntoView();
Dispatcher.UIThread.RunJobs();
var center = card.TransformToVisual(window)!.Value
.Transform(new Point(card.Bounds.Width / 2, card.Bounds.Height / 2));
window.MouseMove(new Point(center.X, center.Y));
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
}
private static void RaisePointerReleased(Control source)
{
source.RaiseEvent(new PointerReleasedEventArgs(
source,
new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true),
source,
default,
0,
new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonReleased),
KeyModifiers.None,
MouseButton.Left));
}
}
internal sealed class DrawerTestScreen : IScreen
{
public RoutingState Router { get; } = new();
}
@@ -0,0 +1,94 @@
using AtomUI.Toolkits.GalleryBase.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ReactiveUI;
using Shouldly;
using Xunit;
using DrawerShowCase = AtomUIGallery.ShowCases.Drawer.DrawerShowCase;
using DrawerViewModel = AtomUIGallery.ShowCases.Drawer.DrawerViewModel;
using AtomUIWindow = AtomUI.Desktop.Controls.Window;
namespace AtomUIGallery.Tests.ShowCases;
public class DrawerSemanticPreviewStageGeometryTests
{
// 回归一:ScopeAwareAdornerLayer 的追踪快照尺寸曾取 Math.Max(Bounds, DesiredSize),
// 而 DesiredSize 包含 Margin,会把容器放大成 margin box——遮罩与面板越出
// 舞台可见边界(Footer 在舞台底边之下被裁)。容器尺寸必须贴合宿主的排列盒。
//
// 回归二:预览页签处于限高钳制模式时舞台画布随宿主收缩;舞台曾用固定
// Height=320 且声明的 PreviewStageMinHeight 地板(360)没有覆盖舞台内容
// 的真实期望(320+48 边距),舞台连同层内的遮罩/面板被视口裁掉,而高亮
// adorner 按未裁剪 bounds 画框伸出可见区。舞台必须用 MinHeight+拉伸自适应,
// 且 PreviewStageMinHeight(434)必须覆盖舞台内容期望(视口 = 地板 - 62 开销)。
[Theory]
[InlineData(960)]
[InlineData(820)]
[InlineData(700)]
public void Semantic_Stage_Drawer_Stays_Inside_The_Visible_Stage_Box(int windowHeight)
{
AvaloniaTestApp.EnsureInitialized();
var page = new DrawerShowCase { DataContext = new DrawerViewModel(new DrawerStageGeometryTestScreen()) };
var window = new AtomUIWindow { Width = 1300, Height = windowHeight, Content = page };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var host = page.GetVisualDescendants().OfType<GalleryShowCaseHost>().Single();
host.SelectedTab = GalleryShowCaseTab.SemanticParts;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Dispatcher.UIThread.RunJobs();
Rect InWindow(Visual v)
{
var topLeft = v.TranslatePoint(new Point(0, 0), window).ShouldNotBeNull();
return new Rect(topLeft, v.Bounds.Size);
}
var stage = page.GetVisualDescendants().OfType<Border>()
.Single(b => b.Name == "DrawerSemanticStage");
var stageBox = InWindow(stage);
var container = window.GetVisualDescendants()
.Single(v => v.GetType().Name == "DrawerContainer");
var mask = container.GetVisualDescendants().OfType<Border>()
.Single(b => b.Name == "PART_Mask");
var frame = container.GetVisualDescendants().OfType<Border>()
.Single(b => b.Name == "Frame");
// 舞台内容不得超出舞台视口(限高钳制下也不允许裁剪)。
var stageScp = (ScrollContentPresenter)stage.GetVisualAncestors()
.First(a => a is ScrollContentPresenter);
(stageScp.Extent.Height - stageScp.Bounds.Height).ShouldBe(0, 0.5,
"舞台内容(含层内遮罩/面板)不得超出舞台视口,否则高亮框会伸出可见区外");
var containerBox = InWindow(container);
containerBox.X.ShouldBe(stageBox.X, 1, "容器左缘必须贴合舞台可见盒(Margin 不得放大容器)");
containerBox.Y.ShouldBe(stageBox.Y, 1, "容器上缘必须贴合舞台可见盒(Margin 不得放大容器)");
containerBox.Width.ShouldBe(stageBox.Width, 1);
containerBox.Height.ShouldBe(stageBox.Height, 1);
InWindow(mask).ShouldBe(containerBox);
var frameBox = InWindow(frame);
frameBox.Width.ShouldBe(300, 1, "面板宽度 = DialogSize");
frameBox.Right.ShouldBe(stageBox.Right, 1, "右置面板右缘必须贴合舞台右缘");
frameBox.Bottom.ShouldBe(stageBox.Bottom, 1, "面板底缘(Footer 所在)必须贴合舞台底缘,不得越出被裁");
}
finally
{
window.Close();
Dispatcher.UIThread.RunJobs();
}
}
}
internal sealed class DrawerStageGeometryTestScreen : IScreen
{
public RoutingState Router { get; } = new();
}
@@ -1,2 +1,2 @@
count: 8
sha256: 8694053edfe9de6832ce28731e809fb92ef1fe92f7adcf60295b16afef12c201
count: 9
sha256: 07d694b6535af8084d4bbd36d6045a680346962c29e02ac27c206b9230b4e11f
@@ -26,7 +26,7 @@ public class DrawerShowCasePageTests
source.ShouldNotContain("Tag=\"Examples\"");
source.ShouldNotContain("Tag=\"Api\"");
source.ShouldNotContain("Tag=\"DesignToken\"");
source.ShouldContain("<gallery:GalleryStickyTabsHost");
source.ShouldContain("<gallery:GalleryShowCaseHost");
source.ShouldContain("StickyContentPadding=\"28,0,28,0\"");
source.ShouldNotContain("<atom:TabStrip Name=\"ScenarioTabs\"");
source.ShouldNotContain("<ContentControl Name=\"ScenarioContentHost\">");
@@ -42,20 +42,62 @@ public class DrawerShowCasePageTests
CountOccurrences(source, "Classes=\"info-value\"").ShouldBe(0);
source.ShouldNotContain("LineHeight=\"22\"");
source.ShouldContain("Description=\"{gallery:DrawerShowCaseLangResource PageDescription}\"");
CountShowCaseItemElements(source).ShouldBe(8);
CountOccurrences(source, "IsDeferredContentEnabled=\"True\"").ShouldBe(8);
CountOccurrences(source, "<gallery:ShowCaseItem.DeferredContentTemplate>").ShouldBe(8);
CountOccurrences(source, "DataTemplate x:DataType=\"vm:DrawerViewModel\"").ShouldBe(8);
CountShowCaseItemElements(source).ShouldBe(9);
CountOccurrences(source, "IsDeferredContentEnabled=\"True\"").ShouldBe(9);
CountOccurrences(source, "<gallery:ShowCaseItem.DeferredContentTemplate>").ShouldBe(9);
CountOccurrences(source, "DataTemplate x:DataType=\"vm:DrawerViewModel\"").ShouldBe(10);
source.ShouldContain("DrawerShowCaseLangResource BasicTitle");
source.ShouldContain("DrawerShowCaseLangResource MultiLevelTitle");
source.ShouldContain("DrawerShowCaseLangResource FormInDrawerTitle");
source.ShouldContain("DrawerShowCaseLangResource PresetSizeTitle");
source.ShouldContain("DrawerShowCaseLangResource SemanticStylesTitle");
source.ShouldNotContain("<atom:TabControl");
source.ShouldNotContain("<atom:TabItem");
source.ShouldNotContain("<atom:DataGrid");
source.ShouldNotContain(">Gallery<");
}
[Fact]
public void Drawer_ShowCase_Semantic_Preview_Follows_The_Inline_Pinned_Pattern()
{
var source = ReadRepoFile("controlgallery/AtomUIGallery/ShowCases/Feedback/Drawer/Views/DrawerShowCase.axaml");
source.ShouldContain("GalleryShowCaseHost.SemanticPartsContentTemplate");
source.ShouldContain("<gallery:SemanticPartPreview Name=\"DrawerSemanticPreview\"");
source.ShouldContain("SemanticOwnerType=\"{x:Type atom:Drawer}\"");
source.ShouldContain("SemanticOwner=\"{Binding #DrawerSemanticOwner}\"");
source.ShouldContain("Name=\"DrawerSemanticStage\"");
source.ShouldContain("Name=\"DrawerSemanticOwner\"");
source.ShouldContain("IsOpen=\"True\"");
source.ShouldContain("IsPinnedOpen=\"True\"");
source.ShouldContain("IsMotionEnabled=\"False\"");
source.ShouldContain("DialogSize=\"300\"");
source.ShouldContain("OpenOn=\"{Binding #DrawerSemanticStage}\"");
// PartDescriptions 顺序对齐 antd _semantic.tsx(去掉 dragger)。
var paths = Regex.Matches(source, "SemanticPartDescription Path=\"([^\"]+)\"")
.Select(static m => m.Groups[1].Value).ToArray();
paths.ShouldBe(["root", "mask", "section", "header", "title", "extra", "body", "footer", "close"]);
// 跨根经 ISemanticPartCrossRootProvider 上报,不允许出现 DropdownButton 式的 code-behind 根注册。
source.ShouldNotContain("HandleSemanticPreviewLoaded");
source.ShouldNotContain("HandleSemanticPreviewUnloaded");
// SemanticStyles 示例必须用生成 Style 类定制(专用 Style 唯一定制入口)。
var semanticStylesItem = ExtractShowCaseItem(source, "SemanticStylesTitle");
semanticStylesItem.ShouldContain("atom:DrawerMaskStyle");
semanticStylesItem.ShouldContain("atom:DrawerSectionStyle");
semanticStylesItem.ShouldContain("atom:DrawerHeaderStyle");
semanticStylesItem.ShouldContain("atom:DrawerTitleStyle");
semanticStylesItem.ShouldContain("atom:DrawerExtraStyle");
semanticStylesItem.ShouldContain("atom:DrawerBodyStyle");
semanticStylesItem.ShouldContain("atom:DrawerFooterStyle");
semanticStylesItem.ShouldContain("atom:DrawerCloseStyle");
// 禁止以事件处理器为特征的代码回退。
semanticStylesItem.ShouldNotContain(".Loaded=");
semanticStylesItem.ShouldNotContain(".Unloaded=");
}
[Fact]
public void Drawer_ShowCase_Form_In_Drawer_Maps_AntDesign_Form_Demo()
{