From e6d39fb0e9ce2bd733518892c420da0588381813 Mon Sep 17 00:00:00 2001 From: Roland Banks Date: Thu, 12 Nov 2026 11:01:58 +0700 Subject: [PATCH] Theme variables for the tree, combo box, context menu and form field editor colours The colours these components drew in fixed values now come from CSS variables a host can set on :root or a theme class. Each default is the colour the component drew before, so a host that sets nothing sees no change. - PDTree: --pd-tree-selected-bg and --pd-tree-selected-color for the selected node, and --pd-tree-node-radius and --pd-tree-node-inset-end for the shape the hover and the selection share. - PDComboBox: --pdcb-focus-border and --pdcb-focus-ring for the focused input. - PDContextMenu: --pd-contextmenu-bg, -color, -hover-bg, -border-color and -disabled-color; under Bootstrap 5.3's dark mode the same variables win, with the existing dark colours as defaults. - PDFormFieldEditor: --pd-form-editor-border-color for the frame around a field's editor. ThemeVariableStylesheetTests checks each variable and its default, and that no context menu colour is fixed. The four documentation pages gain a Colours section listing the variables. Co-Authored-By: Claude Opus 5.5 --- .../Pages/PDComboBoxDocumentation.razor | 14 +++++ .../Pages/PDContextMenuDocumentation.razor | 18 +++++++ .../Pages/PDFormDocumentation.razor | 13 +++++ .../Pages/PDTreeDocumentation.razor | 17 ++++++ .../ThemeVariableStylesheetTests.cs | 46 ++++++++++++++++ PanoramicData.Blazor/PDComboBox.razor.css | 4 +- PanoramicData.Blazor/PDContextMenu.razor.css | 52 ++++++++++--------- .../PDFormFieldEditor.razor.css | 7 +-- PanoramicData.Blazor/PDTreeNode.razor.css | 6 ++- 9 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 PanoramicData.Blazor.Test/ThemeVariableStylesheetTests.cs diff --git a/PanoramicData.Blazor.Demo/Pages/PDComboBoxDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDComboBoxDocumentation.razor index adb5ce97c..8db24dbe7 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDComboBoxDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDComboBoxDocumentation.razor @@ -49,4 +49,18 @@ +
+

Colours

+

+ The box's focus ring comes from CSS variables, beside the existing --pdcb-* colours, so a host + can match it to its theme on :root or on a theme class. +

+ + + + + + +
VariableDefaultDescription
--pdcb-focus-border#007bffThe input's border while it has focus
--pdcb-focus-ringrgba(0, 123, 255, 0.25)The glow around the input while it has focus
+
diff --git a/PanoramicData.Blazor.Demo/Pages/PDContextMenuDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDContextMenuDocumentation.razor index b6c0883c4..bb136333e 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDContextMenuDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDContextMenuDocumentation.razor @@ -59,4 +59,22 @@ +
+

Colours

+

+ The menu's colours come from CSS variables, so a host can set them on :root or on a theme class. + Without them the menu is light, or dark under Bootstrap 5.3's data-bs-theme="dark"; a host's + variables win in both. +

+ + + + + + + + + +
VariableDefaultDescription
--pd-contextmenu-bg#fffThe items' and separators' background
--pd-contextmenu-colorinheritThe items' text
--pd-contextmenu-hover-bg#eeeThe item under the pointer
--pd-contextmenu-border-colorrgba(0,0,0,.125)The menu's edges and the separator lines
--pd-contextmenu-disabled-color#6c757dA disabled item's text
+
diff --git a/PanoramicData.Blazor.Demo/Pages/PDFormDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDFormDocumentation.razor index c2d505c22..17273d56c 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDFormDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDFormDocumentation.razor @@ -211,4 +211,17 @@ +
+

Colours

+

+ The frame around each field's editor comes from a CSS variable, so a host can match it to its own control + borders on :root or on a theme class, for example on a dark theme. +

+ + + + + +
VariableDefaultDescription
--pd-form-editor-border-color#ced4daThe border around a field's editor, to the right of its label
+
diff --git a/PanoramicData.Blazor.Demo/Pages/PDTreeDocumentation.razor b/PanoramicData.Blazor.Demo/Pages/PDTreeDocumentation.razor index b7698552e..7f8720c0f 100644 --- a/PanoramicData.Blazor.Demo/Pages/PDTreeDocumentation.razor +++ b/PanoramicData.Blazor.Demo/Pages/PDTreeDocumentation.razor @@ -203,4 +203,21 @@ +
+

Colours

+

+ A node's selection and shape come from CSS variables, so a host can set them on :root or on a + theme class. The hover and the selection share the shape, so rounding one rounds both. For example, + body.theme-dark { --pd-tree-selected-bg: #2a3a5c; --pd-tree-node-radius: 0.25em; }. +

+ + + + + + + + +
VariableDefaultDescription
--pd-tree-selected-bg#007bffThe selected node's background
--pd-tree-selected-colorinheritThe selected node's text
--pd-tree-node-radius0The corner radius of a node's hover and selection
--pd-tree-node-inset-end0The gap between a node's hover and selection and the end of the tree, so rounded corners stay clear of the panel's edge
+
diff --git a/PanoramicData.Blazor.Test/ThemeVariableStylesheetTests.cs b/PanoramicData.Blazor.Test/ThemeVariableStylesheetTests.cs new file mode 100644 index 000000000..ce35f73af --- /dev/null +++ b/PanoramicData.Blazor.Test/ThemeVariableStylesheetTests.cs @@ -0,0 +1,46 @@ +using AwesomeAssertions; + +namespace PanoramicData.Blazor.Test; + +/// +/// The tree, combo box and context menu take their colours from CSS variables a host can set, and each default is the +/// colour the component had before, so a host that sets nothing sees no change. +/// +public class ThemeVariableStylesheetTests +{ + /// Each themable colour reads its variable, falling back to the component's original colour. + /// The component stylesheet. + /// The declaration it must contain. + [Theory] + [InlineData("PDTreeNode.razor.css", "background-color: var(--pd-tree-selected-bg, #007bff);")] + [InlineData("PDTreeNode.razor.css", "color: var(--pd-tree-selected-color, inherit);")] + [InlineData("PDTreeNode.razor.css", "margin-right: var(--pd-tree-node-inset-end, 0);")] + [InlineData("PDTreeNode.razor.css", "border-radius: var(--pd-tree-node-radius, 0);")] + [InlineData("PDComboBox.razor.css", "border-color: var(--pdcb-focus-border, #007bff);")] + [InlineData("PDComboBox.razor.css", "box-shadow: 0 0 0 0.2rem var(--pdcb-focus-ring, rgba(0, 123, 255, 0.25));")] + [InlineData("PDContextMenu.razor.css", "background-color: var(--pd-contextmenu-bg, #fff);")] + [InlineData("PDContextMenu.razor.css", "background-color: var(--pd-contextmenu-hover-bg, #eee);")] + [InlineData("PDContextMenu.razor.css", "color: var(--pd-contextmenu-disabled-color, #6c757d);")] + [InlineData("PDContextMenu.razor.css", "border-bottom: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125));")] + [InlineData("PDFormFieldEditor.razor.css", "border-top: 1px solid var(--pd-form-editor-border-color, #ced4da);")] + public void A_colour_reads_its_variable_with_the_original_default(string stylesheet, string declaration) + => Read(stylesheet).Should().Contain(declaration); + + /// No colour in the context menu's stylesheet is fixed, so a host can theme the whole menu. + [Fact] + public void Every_context_menu_colour_reads_a_variable() + { + var colourLines = Read("PDContextMenu.razor.css") + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.StartsWith("background-color:", StringComparison.Ordinal) + || line.StartsWith("color:", StringComparison.Ordinal) + || line.StartsWith("border-color:", StringComparison.Ordinal) + || (line.StartsWith("border", StringComparison.Ordinal) && line.Contains("solid", StringComparison.Ordinal))); + + colourLines.Should().NotBeEmpty().And.AllSatisfy(line => line.Should().Contain("var(--pd-contextmenu-")); + } + + private static string Read(string stylesheet) + => File.ReadAllText(Path.Combine(PackagingGuardTests.RepositoryRoot, "PanoramicData.Blazor", stylesheet)); +} diff --git a/PanoramicData.Blazor/PDComboBox.razor.css b/PanoramicData.Blazor/PDComboBox.razor.css index 1c1cf8f0e..7a119eada 100644 --- a/PanoramicData.Blazor/PDComboBox.razor.css +++ b/PanoramicData.Blazor/PDComboBox.razor.css @@ -24,8 +24,8 @@ } .pd-combobox .combo-input:focus { - border-color: #007bff; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + border-color: var(--pdcb-focus-border, #007bff); + box-shadow: 0 0 0 0.2rem var(--pdcb-focus-ring, rgba(0, 123, 255, 0.25)); } .pd-combobox .combo-dropdown { diff --git a/PanoramicData.Blazor/PDContextMenu.razor.css b/PanoramicData.Blazor/PDContextMenu.razor.css index c5a51590d..2202ffefe 100644 --- a/PanoramicData.Blazor/PDContextMenu.razor.css +++ b/PanoramicData.Blazor/PDContextMenu.razor.css @@ -24,76 +24,78 @@ display: flex; } +/* Colours come from variables a host can set, for example on a theme class; the defaults are the original light ones */ .pdcontextmenuitem { - background-color: #fff; + background-color: var(--pd-contextmenu-bg, #fff); + color: var(--pd-contextmenu-color, inherit); padding: .25rem .50rem; - border-left: 1px solid rgba(0,0,0,.125); - border-right: 1px solid rgba(0,0,0,.125); + border-left: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); + border-right: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); cursor: pointer; } .pdcontextmenuitem.disabled { - color: #6c757d; + color: var(--pd-contextmenu-disabled-color, #6c757d); pointer-events: none; - background-color: #fff; + background-color: var(--pd-contextmenu-bg, #fff); } .pdcontextmenu.disabled:hover { - background-color: #fff; + background-color: var(--pd-contextmenu-bg, #fff); } .pdcontextmenuitem:first-child { - border-top: 1px solid rgba(0,0,0,.125); + border-top: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); border-top-left-radius: .25rem; border-top-right-radius: .25rem; } .pdcontextmenuitem:last-child { - border-bottom: 1px solid rgba(0,0,0,.125); + border-bottom: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); border-bottom-left-radius: .25rem; border-bottom-right-radius: .25rem; } .pdcontextmenuitem:hover { - background-color: #eee; + background-color: var(--pd-contextmenu-hover-bg, #eee); } .pdcontextmenuseparator { - background-color: #fff; - border-bottom: 1px solid rgba(0,0,0,.125); - border-left: 1px solid rgba(0,0,0,.125); - border-right: 1px solid rgba(0,0,0,.125); + background-color: var(--pd-contextmenu-bg, #fff); + border-bottom: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); + border-left: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); + border-right: 1px solid var(--pd-contextmenu-border-color, rgba(0,0,0,.125)); } -/* Dark mode support for Bootstrap 5.3+ */ +/* Dark mode support for Bootstrap 5.3+; the same variables still win when a host sets them */ [data-bs-theme="dark"] .pdcontextmenuitem { - background-color: #2b2f33; - border-color: rgba(255, 255, 255, 0.15); - color: #e9ecef; + background-color: var(--pd-contextmenu-bg, #2b2f33); + border-color: var(--pd-contextmenu-border-color, rgba(255, 255, 255, 0.15)); + color: var(--pd-contextmenu-color, #e9ecef); } [data-bs-theme="dark"] .pdcontextmenuitem.disabled { - color: #6c757d; - background-color: #2b2f33; + color: var(--pd-contextmenu-disabled-color, #6c757d); + background-color: var(--pd-contextmenu-bg, #2b2f33); } [data-bs-theme="dark"] .pdcontextmenu.disabled:hover { - background-color: #2b2f33; + background-color: var(--pd-contextmenu-bg, #2b2f33); } [data-bs-theme="dark"] .pdcontextmenuitem:first-child { - border-top-color: rgba(255, 255, 255, 0.15); + border-top-color: var(--pd-contextmenu-border-color, rgba(255, 255, 255, 0.15)); } [data-bs-theme="dark"] .pdcontextmenuitem:last-child { - border-bottom-color: rgba(255, 255, 255, 0.15); + border-bottom-color: var(--pd-contextmenu-border-color, rgba(255, 255, 255, 0.15)); } [data-bs-theme="dark"] .pdcontextmenuitem:hover { - background-color: #343a40; + background-color: var(--pd-contextmenu-hover-bg, #343a40); } [data-bs-theme="dark"] .pdcontextmenuseparator { - background-color: #2b2f33; - border-color: rgba(255, 255, 255, 0.15); + background-color: var(--pd-contextmenu-bg, #2b2f33); + border-color: var(--pd-contextmenu-border-color, rgba(255, 255, 255, 0.15)); } \ No newline at end of file diff --git a/PanoramicData.Blazor/PDFormFieldEditor.razor.css b/PanoramicData.Blazor/PDFormFieldEditor.razor.css index bf857ff8e..bc3dbf64f 100644 --- a/PanoramicData.Blazor/PDFormFieldEditor.razor.css +++ b/PanoramicData.Blazor/PDFormFieldEditor.razor.css @@ -1,8 +1,9 @@ +/* The frame around a field's editor; a host can match it to its own control borders */ .editor { align-items: center; - border-bottom: 1px solid #ced4da; - border-top: 1px solid #ced4da; - border-right: 1px solid #ced4da; + border-bottom: 1px solid var(--pd-form-editor-border-color, #ced4da); + border-top: 1px solid var(--pd-form-editor-border-color, #ced4da); + border-right: 1px solid var(--pd-form-editor-border-color, #ced4da); display: flex; flex: 1 1; padding: 0 2px; diff --git a/PanoramicData.Blazor/PDTreeNode.razor.css b/PanoramicData.Blazor/PDTreeNode.razor.css index 719ca863d..39184675e 100644 --- a/PanoramicData.Blazor/PDTreeNode.razor.css +++ b/PanoramicData.Blazor/PDTreeNode.razor.css @@ -4,7 +4,8 @@ } .pdtreenode .selected { - background-color: #007bff; + background-color: var(--pd-tree-selected-bg, #007bff); + color: var(--pd-tree-selected-color, inherit); } .pdtreenode .pdtreenode_child_content { @@ -18,10 +19,13 @@ white-space: nowrap; } +/* The hover and the selection share this shape, so a host can round both and keep them clear of the panel's edge */ .pdtreenode .pdtreenode_header .pdtreenode_content { width: 100%; padding-left: 3px; margin-left: 3px; + margin-right: var(--pd-tree-node-inset-end, 0); + border-radius: var(--pd-tree-node-radius, 0); } .pdtreenode .pdtreenode_content:not(.selected):hover {