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.
+
+
+ | Variable | Default | Description |
+
+ --pdcb-focus-border | #007bff | The input's border while it has focus |
+ --pdcb-focus-ring | rgba(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.
+
+
+ | Variable | Default | Description |
+
+ --pd-contextmenu-bg | #fff | The items' and separators' background |
+ --pd-contextmenu-color | inherit | The items' text |
+ --pd-contextmenu-hover-bg | #eee | The item under the pointer |
+ --pd-contextmenu-border-color | rgba(0,0,0,.125) | The menu's edges and the separator lines |
+ --pd-contextmenu-disabled-color | #6c757d | A 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.
+
+
+ | Variable | Default | Description |
+
+ --pd-form-editor-border-color | #ced4da | The 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; }.
+
+
+ | Variable | Default | Description |
+
+ --pd-tree-selected-bg | #007bff | The selected node's background |
+ --pd-tree-selected-color | inherit | The selected node's text |
+ --pd-tree-node-radius | 0 | The corner radius of a node's hover and selection |
+ --pd-tree-node-inset-end | 0 | The 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 {