[New Control] Descriptions (#791)

* feat: add ColumnWrapPanel.

* feat: add Descriptions and DescriptionsItem controls

* WIP: extract a LabeledContentControl for this particular need.

* wip.

* feat: setup demo. fix various initial status issue.

* fix: fix a layout issue.

* feat: improve demo.

* feat: update demo.

* fix: remove a redundant calculation.

* feat: update per copilot feedback.

* feat: add tests, fix a calculation issue.

* feat: refactor to change the default label and value binding assignment logic.

* feat: introduce orientation.

* misc: format codes.

* feat: clarify LabelPosition property.

* feat: extract Font resources.

* revert AvatarDemo.

* feat: assign specific value to Font resources for testing.

* misc: resolve copilot comment.

---------

Co-authored-by: Zhang Dian <54255897+zdpcdt@users.noreply.github.com>
This commit is contained in:
Dong Bin
2025-10-22 20:49:02 +08:00
committed by GitHub
parent c6ac019a4e
commit dcaef1c8ed
22 changed files with 1076 additions and 32 deletions

View File

@@ -15,20 +15,28 @@
<vm:MainWindowViewModel />
</Design.DataContext>
<StackPanel>
<Button Content="???" Click="Button_OnClick"></Button>
<ContentControl Name="content">
<ContentControl.DataTemplates>
<DataTemplate DataType="x:Int32">
<u:Breadcrumb Separator="·" Classes="Margin">
<u:BreadcrumbItem Content="a" />
<u:BreadcrumbItem Content="b" />
<u:BreadcrumbItem Content="c" />
</u:Breadcrumb>
</DataTemplate>
<DataTemplate DataType="x:Double">
<TextBlock Text="Hello World"/>
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
<StackPanel.Styles>
<Style Selector="Button">
<Setter Property="Margin" Value="10" />
</Style>
</StackPanel.Styles>
<u:ColumnWrapPanel Column="4" Width="500" Background="AntiqueWhite">
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" Width="200" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" Width="200" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" Width="150" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" Width="200" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" Width="200" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
<Button Content="Hello" Theme="{DynamicResource SolidButton}" />
</u:ColumnWrapPanel>
</StackPanel>
</Window>

View File

@@ -10,16 +10,4 @@ public partial class MainWindow : Window
{
InitializeComponent();
}
private async void Button_OnClick(object? sender, RoutedEventArgs e)
{
if (content.Content is int s)
{
content.Content = 1.1;
}
else
{
content.Content = 1;
}
}
}

View File

@@ -0,0 +1,124 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:u="https://irihi.tech/ursa"
xmlns:vm="clr-namespace:Ursa.Demo.ViewModels"
xmlns:common="clr-namespace:Ursa.Common;assembly=Ursa"
mc:Ignorable="d" d:DesignWidth="800"
x:DataType="vm:DescriptionsDemoViewModel"
x:Class="Ursa.Demo.Pages.DescriptionsDemo">
<Design.DataContext>
<vm:DescriptionsDemoViewModel />
</Design.DataContext>
<ScrollViewer>
<StackPanel Spacing="20">
<TextBlock Text="Descriptions support different orientations and item alignments. " />
<u:Form LabelPosition="Left">
<u:EnumSelector
Name="orientation"
u:FormItem.Label="Orientation"
EnumType="Orientation"
Value="{x:Static Orientation.Vertical}" />
<u:EnumSelector
Name="labelPosition"
u:FormItem.Label="Label Position"
EnumType="common:Position"
Value="{x:Static common:Position.Left}" />
<u:EnumSelector
Name="itemAlignment"
u:FormItem.Label="Item Alignment"
EnumType="common:ItemAlignment"
Value="{x:Static common:ItemAlignment.Center}" />
</u:Form>
<u:Descriptions
HorizontalAlignment="Left"
ItemsSource="{Binding Items}"
LabelMemberBinding="{Binding Label}"
DisplayMemberBinding="{Binding Description}"
Orientation="{Binding #orientation.Value}"
LabelPosition="{Binding #labelPosition.Value}"
ItemAlignment="{Binding #itemAlignment.Value}"
MaxWidth="400" />
<TextBlock Text="Descriptions support XAML inline declaration. " />
<u:Descriptions
MaxWidth="400"
HorizontalAlignment="Left"
Orientation="{Binding #orientation.Value}"
LabelPosition="{Binding #labelPosition.Value}"
ItemAlignment="{Binding #itemAlignment.Value}">
<u:DescriptionsItem Label="实际用户数量" Content="1,480,000" />
<u:DescriptionsItem Label="7天留存">
<StackPanel Orientation="Horizontal">
<TextBlock Text="98%" />
<PathIcon
Theme="{DynamicResource InnerPathIcon}"
Classes="Small"
Margin="2 0 0 0"
Data="{DynamicResource SemiIconArrowUp}"
Foreground="{DynamicResource SemiGreen5}" />
</StackPanel>
</u:DescriptionsItem>
<u:DescriptionsItem Label="安全等级" Content="3级" />
<u:DescriptionsItem Label="垂类标签">
<Label Theme="{DynamicResource TagLabel}" Content="电商" />
</u:DescriptionsItem>
<u:DescriptionsItem Label="认证状态" Content="未认证" />
</u:Descriptions>
<TextBlock Text="Use ColumnWrapPanel to display horizontally" />
<u:Descriptions
ItemsSource="{Binding Items2}"
LabelMemberBinding="{Binding Label}"
ItemAlignment="Plain">
<u:Descriptions.ItemsPanel>
<ItemsPanelTemplate>
<u:ColumnWrapPanel Column="5" />
</ItemsPanelTemplate>
</u:Descriptions.ItemsPanel>
<u:Descriptions.ItemTemplate>
<DataTemplate>
<TextBlock
Text="{Binding Description}"
Margin="0 0 8 0" />
</DataTemplate>
</u:Descriptions.ItemTemplate>
</u:Descriptions>
<TextBlock Text="Setting Orientation to Horizontal support Classes Small and Large. " />
<StackPanel>
<Border Theme="{DynamicResource CardBorder}">
<u:Descriptions
Classes="Small"
Orientation="Horizontal"
LabelPosition="Top">
<u:DescriptionsItem Label="实际用户数量" Content="1,480,000" />
<u:DescriptionsItem Label="7天留存" Content="98%" />
<u:DescriptionsItem Label="安全等级" Content="3级" />
</u:Descriptions>
</Border>
<Border Theme="{DynamicResource CardBorder}">
<u:Descriptions
Orientation="Horizontal"
LabelPosition="Top">
<u:DescriptionsItem Label="实际用户数量" Content="1,480,000" />
<u:DescriptionsItem Label="7天留存" Content="98%" />
<u:DescriptionsItem Label="安全等级" Content="3级" />
</u:Descriptions>
</Border>
<Border Theme="{DynamicResource CardBorder}">
<u:Descriptions
Classes="Large"
Orientation="Horizontal"
LabelPosition="Top">
<u:DescriptionsItem Label="实际用户数量" Content="1,480,000" />
<u:DescriptionsItem Label="7天留存" Content="98%" />
<u:DescriptionsItem Label="安全等级" Content="3级" />
</u:Descriptions>
</Border>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Ursa.Demo.Pages;
public partial class DescriptionsDemo : UserControl
{
public DescriptionsDemo()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Ursa.Demo.ViewModels;
public partial class DescriptionsDemoViewModel : ObservableObject
{
public ObservableCollection<DescriptionItemViewModel> Items { get; set; }
public ObservableCollection<DescriptionItemViewModel> Items2 { get; set; }
public DescriptionsDemoViewModel()
{
Items = new ObservableCollection<DescriptionItemViewModel>()
{
new() { Label = "Actual Users", Description = "1,480,000" },
new() { Label = "7-day Retention", Description = "98%" },
new() { Label = "Security Level", Description = "III" },
new() { Label = "Category Tag", Description = "E-commerce" },
new() { Label = "Authorized State", Description = "Unauthorized" },
};
Items2 = new ObservableCollection<DescriptionItemViewModel>()
{
new() { Label = "抖音号", Description = "SemiDesign" },
new() { Label = "主播类型", Description = "自由主播" },
new() { Label = "安全等级", Description = "3级" },
new() { Label = "垂类标签", Description = "编程" },
new() { Label = "作品数量", Description = "88888888" },
new() { Label = "认证状态", Description = "这是一个很长很长很长很长很长很长很长很长很长的值" },
new() { Label = "上次直播时间", Description = "2024-05-01 12:00:00" }
};
}
}
public partial class DescriptionItemViewModel : ObservableObject
{
[ObservableProperty] private string? _label;
[ObservableProperty] private object? _description;
}

View File

@@ -47,6 +47,7 @@ public partial class MainViewViewModel : ViewModelBase
MenuKeys.MenuKeyDatePicker => new DatePickerDemoViewModel(),
MenuKeys.MenuKeyDateRangePicker => new DateRangePickerDemoViewModel(),
MenuKeys.MenuKeyDateTimePicker => new DateTimePickerDemoViewModel(),
MenuKeys.MenuKeyDescriptions => new DescriptionsDemoViewModel(),
MenuKeys.MenuKeyDialog => new DialogDemoViewModel(),
MenuKeys.MenuKeyDisableContainer => new DisableContainerDemoViewModel(),
MenuKeys.MenuKeyDivider => new DividerDemoViewModel(),

View File

@@ -24,7 +24,10 @@ public class MenuViewModel : ViewModelBase
new() { MenuHeader = "KeyGestureInput", Key = MenuKeys.MenuKeyKeyGestureInput },
new() { MenuHeader = "IPv4Box", Key = MenuKeys.MenuKeyIpBox },
new() { MenuHeader = "MultiComboBox", Key = MenuKeys.MenuKeyMultiComboBox },
new() { MenuHeader = "Multi AutoCompleteBox", Key = MenuKeys.MenuKeyMultiAutoCompleteBox, Status = "New" },
new()
{
MenuHeader = "Multi AutoCompleteBox", Key = MenuKeys.MenuKeyMultiAutoCompleteBox, Status = "New"
},
new() { MenuHeader = "Numeric UpDown", Key = MenuKeys.MenuKeyNumericUpDown },
new() { MenuHeader = "NumPad", Key = MenuKeys.MenuKeyNumPad },
new() { MenuHeader = "PathPicker", Key = MenuKeys.MenuKeyPathPicker, Status = "New" },
@@ -56,11 +59,17 @@ public class MenuViewModel : ViewModelBase
MenuHeader = "Date & Time", Children = new ObservableCollection<MenuItemViewModel>
{
new() { MenuHeader = "Date Picker", Key = MenuKeys.MenuKeyDatePicker, Status = "Updated" },
new() { MenuHeader = "Date Range Picker", Key = MenuKeys.MenuKeyDateRangePicker, Status = "Updated" },
new()
{
MenuHeader = "Date Range Picker", Key = MenuKeys.MenuKeyDateRangePicker, Status = "Updated"
},
new() { MenuHeader = "Date Time Picker", Key = MenuKeys.MenuKeyDateTimePicker, Status = "Updated" },
new() { MenuHeader = "Time Box", Key = MenuKeys.MenuKeyTimeBox },
new() { MenuHeader = "Time Picker", Key = MenuKeys.MenuKeyTimePicker, Status = "Updated" },
new() { MenuHeader = "Time Range Picker", Key = MenuKeys.MenuKeyTimeRangePicker, Status = "Updated" },
new()
{
MenuHeader = "Time Range Picker", Key = MenuKeys.MenuKeyTimeRangePicker, Status = "Updated"
},
new() { MenuHeader = "Clock", Key = MenuKeys.MenuKeyClock }
}
},
@@ -83,13 +92,14 @@ public class MenuViewModel : ViewModelBase
new() { MenuHeader = "AspectRatioLayout", Key = MenuKeys.MenuKeyAspectRatioLayout },
new() { MenuHeader = "Avatar", Key = MenuKeys.MenuKeyAvatar, Status = "WIP" },
new() { MenuHeader = "Badge", Key = MenuKeys.MenuKeyBadge },
new() { MenuHeader = "Banner", Key = MenuKeys.MenuKeyBanner, Status = "Updated" },
new() { MenuHeader = "Banner", Key = MenuKeys.MenuKeyBanner },
new() { MenuHeader = "Descriptions", Key = MenuKeys.MenuKeyDescriptions, Status = "New" },
new() { MenuHeader = "Disable Container", Key = MenuKeys.MenuKeyDisableContainer },
new() { MenuHeader = "Divider", Key = MenuKeys.MenuKeyDivider },
new() { MenuHeader = "DualBadge", Key = MenuKeys.MenuKeyDualBadge },
new() { MenuHeader = "ImageViewer", Key = MenuKeys.MenuKeyImageViewer },
new() { MenuHeader = "ElasticWrapPanel", Key = MenuKeys.MenuKeyElasticWrapPanel },
new() { MenuHeader = "Marquee", Key = MenuKeys.MenuKeyMarquee, Status = "New" },
new() { MenuHeader = "Marquee", Key = MenuKeys.MenuKeyMarquee },
new() { MenuHeader = "Number Displayer", Key = MenuKeys.MenuKeyNumberDisplayer },
new() { MenuHeader = "Scroll To", Key = MenuKeys.MenuKeyScrollToButton },
new() { MenuHeader = "Timeline", Key = MenuKeys.MenuKeyTimeline },
@@ -117,6 +127,7 @@ public static class MenuKeys
public const string MenuKeyDatePicker = "DatePicker";
public const string MenuKeyDateRangePicker = "DateRangePicker";
public const string MenuKeyDateTimePicker = "DateTimePicker";
public const string MenuKeyDescriptions = "Descriptions";
public const string MenuKeyDialog = "Dialog";
public const string MenuKeyDisableContainer = "DisableContainer";
public const string MenuKeyDivider = "Divider";

View File

@@ -0,0 +1,158 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:u="https://irihi.tech/ursa"
xmlns:iri="https://irihi.tech/shared">
<Design.PreviewWith>
<StackPanel Margin="20" Spacing="20">
<Border Theme="{DynamicResource CardBorder}">
<u:Descriptions Orientation="Horizontal" LabelPosition="Top" ItemAlignment="Center">
<u:DescriptionsItem Label="实际用户数量" Content="1,480,000" />
<u:DescriptionsItem Label="7天留存" Content="98%" />
<u:DescriptionsItem Label="安全等级" Content="3级" />
</u:Descriptions>
</Border>
</StackPanel>
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type u:Descriptions}" TargetType="u:Descriptions">
<Setter Property="Grid.IsSharedSizeScope" Value="False" />
<Setter Property="Template">
<ControlTemplate TargetType="u:Descriptions">
<ItemsPresenter ItemsPanel="{TemplateBinding ItemsPanel}" />
</ControlTemplate>
</Setter>
<Style Selector="^:fixed-width">
<Setter Property="Grid.IsSharedSizeScope" Value="True" />
</Style>
<Style Selector="^[Orientation=Horizontal]">
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<u:ColumnWrapPanel />
</ItemsPanelTemplate>
</Setter>
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type u:DescriptionsItem}" TargetType="u:DescriptionsItem">
<Style Selector="^:horizontal">
<Setter Property="Template">
<ControlTemplate TargetType="u:DescriptionsItem">
<Grid Margin="{DynamicResource DescriptionsItemMargin}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="Label" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label
Name="{x:Static u:LabeledContentControl.PART_Label}"
Grid.Column="0"
Foreground="{DynamicResource DescriptionsKeyTextForeground}"
TextBlock.LineHeight="{DynamicResource DescriptionsLineHeight}"
Content="{TemplateBinding Label}"
ContentTemplate="{TemplateBinding LabelTemplate}"
Width="{TemplateBinding LabelWidth}" />
<TextBlock
Name="PART_Colon"
Grid.Column="1"
Text=":"
Foreground="{DynamicResource DescriptionsKeyTextForeground}"
Margin="{DynamicResource DescriptionsKeyPlainMargin}" />
<ContentPresenter
Name="{x:Static iri:PartNames.PART_ContentPresenter}"
Grid.Column="2"
TextBlock.LineHeight="{DynamicResource DescriptionsLineHeight}"
Foreground="{DynamicResource DescriptionsValueTextForeground}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^[ItemAlignment=Center]">
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Padding" Value="{DynamicResource DescriptionsKeyPadding}" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Colon">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
</Style>
<Style Selector="^[ItemAlignment=Left]">
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Padding" Value="{DynamicResource DescriptionsKeyPadding}" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Colon">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
</Style>
<Style Selector="^[ItemAlignment=Justify]">
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Padding" Value="{DynamicResource DescriptionsKeyPadding}" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Colon">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
</Style>
</Style>
<Style Selector="^:vertical">
<Setter Property="iri:ClassHelper.ClassSource" Value="{Binding $parent[u:Descriptions]}" />
<Setter Property="Template">
<ControlTemplate TargetType="u:DescriptionsItem">
<StackPanel>
<Label
Name="{x:Static u:LabeledContentControl.PART_Label}"
DataContext="{TemplateBinding Label}"
Foreground="{DynamicResource DescriptionsKeyTextForeground}"
Content="{TemplateBinding Label}"
ContentTemplate="{TemplateBinding LabelTemplate}"
Width="{TemplateBinding LabelWidth}" />
<ContentPresenter
Name="{x:Static iri:PartNames.PART_ContentPresenter}"
Foreground="{DynamicResource DescriptionsValueTextForeground}"
FontWeight="{DynamicResource DescriptionsValueFontWeight}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="Margin" Value="{DynamicResource DescriptionsKeyMediumMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsKeyMediumFontSize}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="{DynamicResource DescriptionsValueMediumMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsValueMediumFontSize}" />
</Style>
<Style Selector="^.Small">
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="Margin" Value="{DynamicResource DescriptionsKeySmallMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsKeySmallFontSize}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="{DynamicResource DescriptionsValueSmallMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsValueSmallFontSize}" />
</Style>
</Style>
<Style Selector="^.Large">
<Style Selector="^ /template/ Label#PART_Label">
<Setter Property="Margin" Value="{DynamicResource DescriptionsKeyLargeMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsKeyLargeFontSize}" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Margin" Value="{DynamicResource DescriptionsValueLargeMargin}" />
<Setter Property="FontSize" Value="{DynamicResource DescriptionsValueLargeFontSize}" />
</Style>
</Style>
</Style>
</ControlTheme>
</ResourceDictionary>

View File

@@ -13,6 +13,7 @@
<ResourceInclude Source="DatePicker.axaml" />
<ResourceInclude Source="DateRangePicker.axaml" />
<ResourceInclude Source="DateTimePicker.axaml" />
<ResourceInclude Source="Descriptions.axaml" />
<ResourceInclude Source="Dialog.axaml" />
<ResourceInclude Source="DialogShared.axaml" />
<ResourceInclude Source="DisableContainer.axaml" />

View File

@@ -0,0 +1,4 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StaticResource x:Key="DescriptionsKeyTextForeground" ResourceKey="SemiColorText2" />
<StaticResource x:Key="DescriptionsValueTextForeground" ResourceKey="SemiColorText0" />
</ResourceDictionary>

View File

@@ -8,6 +8,7 @@
<ResourceInclude Source="ButtonGroup.axaml" />
<ResourceInclude Source="Clock.axaml" />
<ResourceInclude Source="DatePicker.axaml" />
<ResourceInclude Source="Descriptions.axaml" />
<ResourceInclude Source="Dialog.axaml" />
<ResourceInclude Source="Divider.axaml" />
<ResourceInclude Source="DualBadge.axaml" />

View File

@@ -0,0 +1,4 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StaticResource x:Key="DescriptionsKeyTextForeground" ResourceKey="SemiColorText2" />
<StaticResource x:Key="DescriptionsValueTextForeground" ResourceKey="SemiColorText0" />
</ResourceDictionary>

View File

@@ -8,6 +8,7 @@
<ResourceInclude Source="ButtonGroup.axaml" />
<ResourceInclude Source="Clock.axaml" />
<ResourceInclude Source="DatePicker.axaml" />
<ResourceInclude Source="Descriptions.axaml" />
<ResourceInclude Source="Dialog.axaml" />
<ResourceInclude Source="Divider.axaml" />
<ResourceInclude Source="DualBadge.axaml" />

View File

@@ -0,0 +1,27 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<x:Double x:Key="DescriptionsLineHeight">20</x:Double>
<FontWeight x:Key="DescriptionsValueFontWeight">600</FontWeight>
<x:Double x:Key="DescriptionsKeySmallFontSize">12</x:Double>
<x:Double x:Key="DescriptionsValueSmallFontSize">16</x:Double>
<x:Double x:Key="DescriptionsKeyMediumFontSize">14</x:Double>
<x:Double x:Key="DescriptionsValueMediumFontSize">20</x:Double>
<x:Double x:Key="DescriptionsKeyLargeFontSize">14</x:Double>
<x:Double x:Key="DescriptionsValueLargeFontSize">28</x:Double>
<!-- <StaticResource x:Key="DescriptionsValueFontWeight" ResourceKey="SemiFontWeightBold" /> -->
<!-- <StaticResource x:Key="DescriptionsKeySmallFontSize" ResourceKey="SemiFontSizeSmall" /> -->
<!-- <StaticResource x:Key="DescriptionsValueSmallFontSize" ResourceKey="SemiFontSizeHeader6" /> -->
<!-- <StaticResource x:Key="DescriptionsKeyMediumFontSize" ResourceKey="SemiFontSizeRegular" /> -->
<!-- <StaticResource x:Key="DescriptionsValueMediumFontSize" ResourceKey="SemiFontSizeHeader4" /> -->
<!-- <StaticResource x:Key="DescriptionsKeyLargeFontSize" ResourceKey="SemiFontSizeRegular" /> -->
<!-- <StaticResource x:Key="DescriptionsValueLargeFontSize" ResourceKey="SemiFontSizeHeader2" /> -->
<Thickness x:Key="DescriptionsKeyPadding">0 0 24 0</Thickness>
<Thickness x:Key="DescriptionsItemMargin">0 0 0 12</Thickness>
<Thickness x:Key="DescriptionsValueSmallMargin">0 0 48 0</Thickness>
<Thickness x:Key="DescriptionsValueMediumMargin">0 0 60 0</Thickness>
<Thickness x:Key="DescriptionsValueLargeMargin">0 0 80 0</Thickness>
<Thickness x:Key="DescriptionsKeySmallMargin">0</Thickness>
<Thickness x:Key="DescriptionsKeyMediumMargin">0 0 0 4</Thickness>
<Thickness x:Key="DescriptionsKeyLargeMargin">0 0 0 4</Thickness>
<Thickness x:Key="DescriptionsKeyPlainMargin">0 0 8 0</Thickness>
</ResourceDictionary>

View File

@@ -7,6 +7,7 @@
<ResourceInclude Source="ButtonGroup.axaml" />
<ResourceInclude Source="DatePicker.axaml" />
<ResourceInclude Source="DateTimePicker.axaml" />
<ResourceInclude Source="Descriptions.axaml" />
<ResourceInclude Source="Dialog.axaml" />
<ResourceInclude Source="DialogShared.axaml" />
<ResourceInclude Source="Divider.axaml" />

View File

@@ -0,0 +1,27 @@
namespace Ursa.Common;
/// <summary>
/// Describes the alignment of items with header and content in a collection control.
/// </summary>
public enum ItemAlignment
{
/// <summary>
/// The separate line of header and content is aligned. Header right aligned and Content left aligned.
/// </summary>
Center,
/// <summary>
/// The separate line of header and content is aligned. Header left aligned and Content right aligned.
/// </summary>
Justify,
/// <summary>
/// The separate line of header and content is aligned. Header left aligned and Content left aligned.
/// </summary>
Left,
/// <summary>
/// Content docks to the right of Header.
/// </summary>
Plain,
}

View File

@@ -0,0 +1,98 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
namespace Ursa.Controls;
/// <summary>
/// LabeledContentControl is almost identical to HeaderedContentControl, but uses "Label" terminology instead of "Header".
/// This is to provide better semantic meaning of paired label and content.
/// Label part is recommended to be Label control for accessibility.
/// </summary>
[TemplatePart(PART_Label, typeof(Label))]
public abstract class LabeledContentControl: ContentControl
{
public const string PART_Label = "PART_Label";
public static readonly StyledProperty<object?> LabelProperty = AvaloniaProperty.Register<LabeledContentControl, object?>(
nameof(Label));
public object? Label
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public static readonly StyledProperty<IDataTemplate?> LabelTemplateProperty = AvaloniaProperty.Register<LabeledContentControl, IDataTemplate?>(
nameof(LabelTemplate));
public IDataTemplate? LabelTemplate
{
get => GetValue(LabelTemplateProperty);
set => SetValue(LabelTemplateProperty, value);
}
public Label? LabelHost
{
get;
private set;
}
static LabeledContentControl()
{
LabelProperty.Changed.AddClassHandler<LabeledContentControl>((x, e) => x.LabelChanged(e));
}
private void LabelChanged(AvaloniaPropertyChangedEventArgs e)
{
if (e.OldValue is ILogical oldChild)
{
LogicalChildren.Remove(oldChild);
}
if (e.NewValue is ILogical newChild)
{
LogicalChildren.Add(newChild);
}
HookLabelToContent();
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
LabelHost = e.NameScope.Find<Label>(PART_Label);
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
HookLabelToContent();
}
protected virtual void HookLabelToContent()
{
if (LabelHost is null) return;
// Set it directly if content is a control, this is faster than looking up logical tree.
if (Content is InputElement input)
{
LabelHost.Target = input;
}
else
{
var focusable =
Presenter?.GetSelfAndLogicalDescendants()
.OfType<InputElement>()
.FirstOrDefault(a => a.Focusable);
if (focusable is not null)
{
LabelHost.Target = focusable;
}
}
}
}

View File

@@ -0,0 +1,208 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Metadata;
using Ursa.Common;
namespace Ursa.Controls;
[PseudoClasses(PC_FixedWidth)]
public class Descriptions: ItemsControl
{
public const string PC_FixedWidth = ":fixed-width";
public static readonly StyledProperty<IDataTemplate?> LabelTemplateProperty =
LabeledContentControl.LabelTemplateProperty.AddOwner<Descriptions>();
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IDataTemplate? LabelTemplate
{
get => GetValue(LabelTemplateProperty);
set => SetValue(LabelTemplateProperty, value);
}
public static readonly StyledProperty<IBinding?> LabelMemberBindingProperty = AvaloniaProperty.Register<Descriptions, IBinding?>(
nameof(LabelMemberBinding));
[AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? LabelMemberBinding
{
get => GetValue(LabelMemberBindingProperty);
set => SetValue(LabelMemberBindingProperty, value);
}
public static readonly StyledProperty<Position> LabelPositionProperty =
DescriptionsItem.LabelPositionProperty.AddOwner<Descriptions>();
/// <summary>
/// The position of the header relative to the content. Only Top and Left are supported.
/// </summary>
public Position LabelPosition
{
get => GetValue(LabelPositionProperty);
set => SetValue(LabelPositionProperty, value);
}
public static readonly StyledProperty<GridLength> LabelWidthProperty =
AvaloniaProperty.Register<Descriptions, GridLength>(nameof(LabelWidth));
public GridLength LabelWidth
{
get => GetValue(LabelWidthProperty);
set => SetValue(LabelWidthProperty, value);
}
public static readonly StyledProperty<ItemAlignment> ItemAlignmentProperty =
DescriptionsItem.ItemAlignmentProperty.AddOwner<Descriptions>();
public ItemAlignment ItemAlignment
{
get => GetValue(ItemAlignmentProperty);
set => SetValue(ItemAlignmentProperty, value);
}
public static readonly StyledProperty<Orientation> OrientationProperty =
AvaloniaProperty.Register<Descriptions, Orientation>(nameof(Orientation), Orientation.Vertical);
public Orientation Orientation
{
get => GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
static Descriptions()
{
LabelWidthProperty.Changed.AddClassHandler<Descriptions>((x, args) => x.OnLabelWidthChanged(args));
ItemAlignmentProperty.Changed.AddClassHandler<Descriptions, ItemAlignment>((x, args) =>
x.OnItemAlignmentChanged(args));
}
private void OnItemAlignmentChanged(AvaloniaPropertyChangedEventArgs<ItemAlignment> args)
{
PseudoClasses.Set(PC_FixedWidth, args.GetNewValue<ItemAlignment>() != ItemAlignment.Plain);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
PseudoClasses.Set(PC_FixedWidth, this.ItemAlignment != ItemAlignment.Plain);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == LabelMemberBindingProperty)
{
if (change.NewValue != null && LabelTemplate != null)
throw new InvalidOperationException("Cannot set both LabelMemberBinding and LabelTemplate.");
_labelDisplayMemberItemTemplate = null;
RefreshContainers();
}
if (change.Property == LabelTemplateProperty)
{
if (change.NewValue != null && LabelMemberBinding != null)
{
throw new InvalidOperationException("Cannot set both LabelMemberBinding and LabelTemplate.");
}
RefreshContainers();
}
}
private void OnLabelWidthChanged(AvaloniaPropertyChangedEventArgs e)
{
foreach (var item in this.GetLogicalDescendants().OfType<DescriptionsItem>())
{
item.LabelWidth = LabelWidth.IsAbsolute ? LabelWidth.Value : double.NaN;
}
}
protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
{
return new DescriptionsItem();
}
protected override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
if (container is not DescriptionsItem descriptionItem) return;
if (container == item) return;
SetupBindings(descriptionItem, item);
}
protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
{
recycleKey = null;
if (item is not DescriptionsItem descriptionItem) return true;
SetupBindings(descriptionItem, null);
return false;
}
private void SetupBindings(DescriptionsItem container, object? item)
{
var effectiveLabelTemplate = GetLabelTemplate();
if (effectiveLabelTemplate is not null && !container.IsSet(LabeledContentControl.LabelTemplateProperty))
{
container.LabelTemplate = effectiveLabelTemplate;
}
var effectiveContentTemplate = GetContentTemplate();
if (effectiveContentTemplate is not null && !container.IsSet(ContentControl.ContentTemplateProperty))
{
container.ContentTemplate = effectiveContentTemplate;
}
if (!container.IsSet(LabeledContentControl.LabelProperty))
{
container.Label = item;
}
if (!container.IsSet(LabelPositionProperty))
{
container[!LabelPositionProperty] = this[!LabelPositionProperty];
}
if (!container.IsSet(DescriptionsItem.ItemAlignmentProperty))
{
container[!DescriptionsItem.ItemAlignmentProperty] = this[!ItemAlignmentProperty];
}
if (!container.IsSet(DescriptionsItem.LabelWidthProperty))
{
container.LabelWidth = LabelWidth.IsAbsolute ? LabelWidth.Value : double.NaN;
}
}
private IDataTemplate? _valueDisplayMemberItemTemplate;
private IDataTemplate? _labelDisplayMemberItemTemplate;
private IDataTemplate? GetContentTemplate()
{
IDataTemplate? itemTemplate = this.ItemTemplate;
if (itemTemplate != null)
return itemTemplate;
if (this._valueDisplayMemberItemTemplate == null)
{
IBinding? binding = this.DisplayMemberBinding;
if (binding != null)
_valueDisplayMemberItemTemplate =
new FuncDataTemplate<object>((o, s) => new TextBlock { [!TextBlock.TextProperty] = binding });
}
return _valueDisplayMemberItemTemplate;
}
private IDataTemplate? GetLabelTemplate()
{
IDataTemplate? itemTemplate = this.LabelTemplate;
if (itemTemplate != null)
return itemTemplate;
if (this._labelDisplayMemberItemTemplate == null)
{
IBinding? binding = this.LabelMemberBinding;
if (binding != null)
_labelDisplayMemberItemTemplate =
new FuncDataTemplate<object>((o, s) => new TextBlock { [!TextBlock.TextProperty] = binding });
}
return _labelDisplayMemberItemTemplate;
}
}

View File

@@ -0,0 +1,63 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Irihi.Avalonia.Shared.Common;
using Ursa.Common;
namespace Ursa.Controls;
[PseudoClasses(PseudoClassName.PC_Horizontal, PseudoClassName.PC_Vertical)]
public class DescriptionsItem: LabeledContentControl
{
public static readonly StyledProperty<Position> LabelPositionProperty = AvaloniaProperty.Register<DescriptionsItem, Position>(
nameof(LabelPosition));
public Position LabelPosition
{
get => GetValue(LabelPositionProperty);
set => SetValue(LabelPositionProperty, value);
}
public static readonly StyledProperty<ItemAlignment> ItemAlignmentProperty = AvaloniaProperty.Register<DescriptionsItem, ItemAlignment>(
nameof(ItemAlignment));
public ItemAlignment ItemAlignment
{
get => GetValue(ItemAlignmentProperty);
set => SetValue(ItemAlignmentProperty, value);
}
public static readonly StyledProperty<double> LabelWidthProperty = AvaloniaProperty.Register<DescriptionsItem, double>(
nameof(LabelWidth));
public double LabelWidth
{
get => GetValue(LabelWidthProperty);
set => SetValue(LabelWidthProperty, value);
}
static DescriptionsItem()
{
LabelPositionProperty.Changed.AddClassHandler<DescriptionsItem, Position>((item, args)=> item.OnLabelPositionChanged(args));
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
UpdatePositionPseudoClass(LabelPosition);
}
private void OnLabelPositionChanged(AvaloniaPropertyChangedEventArgs<Position> args)
{
UpdatePositionPseudoClass(args.GetNewValue<Position>());
}
private void UpdatePositionPseudoClass(Position newPosition)
{
PseudoClasses.Set(PseudoClassName.PC_Horizontal, newPosition is Position.Left or Position.Right);
PseudoClasses.Set(PseudoClassName.PC_Vertical, newPosition is Position.Top or Position.Bottom);
}
}

View File

@@ -0,0 +1,86 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Irihi.Avalonia.Shared.Helpers;
namespace Ursa.Controls;
public class ColumnWrapPanel : Panel, INavigableContainer
{
public static readonly StyledProperty<int> ColumnProperty = AvaloniaProperty.Register<ColumnWrapPanel, int>(
nameof(Column), int.MaxValue, validate: a => a > 0);
public int Column
{
get => GetValue(ColumnProperty);
set => SetValue(ColumnProperty, value);
}
static ColumnWrapPanel()
{
AffectsMeasure<ColumnWrapPanel>(ColumnProperty);
AffectsArrange<ColumnWrapPanel>(ColumnProperty);
}
protected override Size MeasureOverride(Size availableSize)
{
double unit = availableSize.Width / Column;
double x = 0;
double y = 0;
double rowHeight = 0;
for (var i = 0; i < Children.Count; i++)
{
var child = Children[i];
child.Measure(availableSize);
var desiredSize = child.DesiredSize;
// calculate how many columns the child will take
int colSpan = (int)Math.Ceiling(desiredSize.Width / unit);
if (colSpan > Column) colSpan = Column; // limit to max columns
double childWidth = colSpan * unit;
if (MathHelpers.GreaterThan(x + childWidth, availableSize.Width)) // wrap to next row
{
x = 0;
y += rowHeight;
rowHeight = 0;
}
x += childWidth;
rowHeight = Math.Max(rowHeight, desiredSize.Height);
}
return new Size(availableSize.Width, y + rowHeight);
}
protected override Size ArrangeOverride(Size finalSize)
{
double unit = finalSize.Width / Column;
double x = 0;
double y = 0;
double rowHeight = 0;
for (var i = 0; i < Children.Count; i++)
{
var child = Children[i];
var desiredSize = child.DesiredSize;
var remainingWidth = finalSize.Width - x;
if (MathHelpers.GreaterThan(desiredSize.Width, remainingWidth))
{
x = 0;
y += rowHeight;
rowHeight = 0;
}
child.Arrange(new Rect(x, y, desiredSize.Width, desiredSize.Height));
int colSpan = (int)Math.Ceiling(desiredSize.Width / unit);
if (colSpan > Column) colSpan = Column; // limit to max columns
x += colSpan * unit;
rowHeight = Math.Max(rowHeight, desiredSize.Height);
}
return new Size(finalSize.Width, y + rowHeight);
}
public IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
return null;
}
}

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Utilities;
using Irihi.Avalonia.Shared.Helpers;

View File

@@ -0,0 +1,183 @@
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Ursa.Common;
using Ursa.Controls;
namespace HeadlessTest.Ursa.Controls.DescriptionTests;
public class Test
{
[AvaloniaFact]
public void Descriptions_LabelPosition_Propagates_To_DescriptionItems()
{
var descriptions = new Descriptions
{
LabelPosition = Position.Left,
ItemsSource = new[]
{
new { Label = "Name", Content = "John Doe" },
new { Label = "Age", Content = "30" }
},
LabelMemberBinding = new Binding("Label"),
};
var window = new Window
{
Content = descriptions
};
window.Show();
var items = descriptions.GetLogicalChildren().OfType<DescriptionsItem>().ToList();
foreach (var item in items)
{
Assert.Equal(Position.Left, item.LabelPosition);
}
descriptions.LabelPosition = Position.Top;
foreach (var item in items)
{
Assert.Equal(Position.Top, item.LabelPosition);
}
}
[AvaloniaFact]
public void Inline_Descriptions_LabelPosition_Overrides_Parent_Descriptions()
{
var descriptions = new Descriptions
{
LabelPosition = Position.Left,
Items =
{
new DescriptionsItem()
{
Label = "Name",
Content = "John Doe",
LabelPosition = Position.Top
},
new DescriptionsItem()
{
Label = "Age",
Content = "30",
}
}
};
var window = new Window
{
Content = descriptions
};
window.Show();
var items = descriptions.GetLogicalChildren().OfType<DescriptionsItem>().ToList();
Assert.Equal(Position.Top, items[0].LabelPosition);
Assert.Equal(Position.Left, items[1].LabelPosition);
descriptions.LabelPosition = Position.Top;
Assert.Equal(Position.Top, items[0].LabelPosition);
Assert.Equal(Position.Top, items[1].LabelPosition);
}
[AvaloniaFact]
public void Descriptions_ItemAlignment_Propagates_To_DescriptionItems()
{
var descriptions = new Descriptions
{
LabelPosition = Position.Left,
ItemsSource = new[]
{
new { Label = "Name", Content = "John Doe" },
new { Label = "Age", Content = "30" }
},
ItemAlignment = ItemAlignment.Center,
LabelMemberBinding = new Binding("Label"),
};
var window = new Window
{
Content = descriptions
};
window.Show();
var items = descriptions.GetLogicalChildren().OfType<DescriptionsItem>().ToList();
foreach (var item in items)
{
Assert.Equal(ItemAlignment.Center, item.ItemAlignment);
}
descriptions.ItemAlignment = ItemAlignment.Justify;
foreach (var item in items)
{
Assert.Equal(ItemAlignment.Justify, item.ItemAlignment);
}
}
[AvaloniaFact]
public void Inline_Descriptions_ItemAlignment_Overrides_Parent_Descriptions()
{
var descriptions = new Descriptions
{
ItemAlignment = ItemAlignment.Center,
Items =
{
new DescriptionsItem()
{
Label = "Name",
Content = "John Doe",
ItemAlignment = ItemAlignment.Justify
},
new DescriptionsItem()
{
Label = "Age",
Content = "30",
}
}
};
var window = new Window
{
Content = descriptions
};
window.Show();
var items = descriptions.GetLogicalChildren().OfType<DescriptionsItem>().ToList();
Assert.Equal(ItemAlignment.Justify, items[0].ItemAlignment);
Assert.Equal(ItemAlignment.Center, items[1].ItemAlignment);
descriptions.ItemAlignment = ItemAlignment.Left;
Assert.Equal(ItemAlignment.Justify, items[0].ItemAlignment);
Assert.Equal(ItemAlignment.Left, items[1].ItemAlignment);
}
[AvaloniaFact]
public void Descriptions_LabelWidth_Propagates_To_DescriptionItems()
{
var descriptions = new Descriptions
{
LabelWidth = new GridLength(100),
ItemsSource = new[]
{
new { Label = "Name", Content = "John Doe" },
new { Label = "Age", Content = "30" }
},
LabelMemberBinding = new Binding("Label"),
};
var window = new Window
{
Content = descriptions
};
window.Show();
var items = descriptions.GetLogicalChildren().OfType<DescriptionsItem>().ToList();
foreach (var item in items)
{
Assert.Equal(100, item.LabelWidth);
}
descriptions.LabelWidth = new GridLength(150);
foreach (var item in items)
{
Assert.Equal(150, item.LabelWidth);
}
}
}