feat(ui/templates): adopt TreeView design guide; split editor to /design/templates/{id}
Templates page is now a tree-only browser; editing happens on a dedicated TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder. TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per the new Visual Design Guide (V1-V7) in Component-TreeView.md.
This commit is contained in:
@@ -4,23 +4,21 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">New Template</h6>
|
||||
<h6 class="modal-title">Move '@FolderName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="_name" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Description</label>
|
||||
<input class="form-control form-control-sm" @bind="_description" />
|
||||
</div>
|
||||
<select class="form-select form-select-sm" @bind="_targetParentId">
|
||||
@foreach (var opt in FolderOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,21 +28,20 @@
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int? FolderId { get; set; }
|
||||
[Parameter] public int FolderId { get; set; }
|
||||
[Parameter] public string FolderName { get; set; } = string.Empty;
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int? FolderId, string Name, string? Description)> OnSubmit { get; set; }
|
||||
[Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private string _name = string.Empty;
|
||||
private string? _description;
|
||||
private int? _targetParentId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset internal state on transition from hidden -> visible.
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_name = string.Empty;
|
||||
_description = null;
|
||||
_targetParentId = null;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
@@ -56,6 +53,6 @@
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
await OnSubmit.InvokeAsync((FolderId, _name.Trim(), _description?.Trim()));
|
||||
await OnSubmit.InvokeAsync((FolderId, _targetParentId));
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")" style="list-style:none;padding-left:0;margin:0;">
|
||||
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
RenderNode(item, 0);
|
||||
@@ -22,7 +22,8 @@ else
|
||||
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
|
||||
{
|
||||
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
|
||||
<div class="dropdown-menu show" style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;">
|
||||
<div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
|
||||
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
|
||||
@ContextMenu(_contextMenuItem)
|
||||
</div>
|
||||
}
|
||||
@@ -33,19 +34,21 @@ else
|
||||
var children = ChildrenSelector(item);
|
||||
var isBranch = HasChildrenSelector(item);
|
||||
var isExpanded = _expandedKeys.Contains(KeyStr(key));
|
||||
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
||||
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
||||
|
||||
<li role="treeitem" @key="key"
|
||||
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
||||
aria-selected="@(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? "true" : null)">
|
||||
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px"
|
||||
aria-selected="@(isSelected ? "true" : null)">
|
||||
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
|
||||
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
|
||||
@if (isBranch)
|
||||
{
|
||||
<span class="tv-toggle" style="display:inline-block;width:1.2em;text-align:center;cursor:pointer;" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")</span>
|
||||
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tv-spacer" style="display:inline-block;width:1.2em;"></span>
|
||||
<span class="tv-spacer"></span>
|
||||
}
|
||||
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
|
||||
@NodeContent(item)
|
||||
@@ -53,7 +56,7 @@ else
|
||||
</div>
|
||||
@if (isBranch && isExpanded && children is { Count: > 0 })
|
||||
{
|
||||
<ul role="group" style="list-style:none;padding-left:0;margin:0;">
|
||||
<ul role="group">
|
||||
@foreach (var child in children)
|
||||
{
|
||||
RenderNode(child, depth + 1);
|
||||
@@ -76,6 +79,8 @@ else
|
||||
private double _contextMenuX;
|
||||
private double _contextMenuY;
|
||||
private bool _showContextMenu;
|
||||
private bool _contextMenuNeedsFocus;
|
||||
private ElementReference _contextMenuRef;
|
||||
|
||||
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
|
||||
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
||||
@@ -117,6 +122,12 @@ else
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_contextMenuNeedsFocus && _showContextMenu)
|
||||
{
|
||||
_contextMenuNeedsFocus = false;
|
||||
try { await _contextMenuRef.FocusAsync(); } catch { /* element may have been disposed if dismissed during render */ }
|
||||
}
|
||||
|
||||
if (firstRender && StorageKey != null)
|
||||
{
|
||||
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
|
||||
@@ -212,6 +223,7 @@ else
|
||||
_contextMenuX = e.ClientX;
|
||||
_contextMenuY = e.ClientY;
|
||||
_showContextMenu = true;
|
||||
_contextMenuNeedsFocus = true;
|
||||
}
|
||||
|
||||
private void DismissContextMenu()
|
||||
@@ -220,6 +232,17 @@ else
|
||||
_contextMenuItem = default;
|
||||
}
|
||||
|
||||
private void OnContextMenuKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
DismissContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether the node with the given key is currently expanded.</summary>
|
||||
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
|
||||
|
||||
/// <summary>Expand every branch node in the tree.</summary>
|
||||
public void ExpandAll()
|
||||
{
|
||||
|
||||
135
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css
Normal file
135
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css
Normal file
@@ -0,0 +1,135 @@
|
||||
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1–V7). */
|
||||
|
||||
/* Root list — no list styling. */
|
||||
.tv-root,
|
||||
.tv-root ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* V1 — Row anatomy. Flex container; full-width hit surface; ~32px row. */
|
||||
.tv-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: default;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.08s linear;
|
||||
}
|
||||
|
||||
/* V1 — slot widths. Toggle and glyph are always present so labels align across siblings. */
|
||||
.tv-row .tv-toggle,
|
||||
.tv-row .tv-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem; /* 20px */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--bs-secondary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tv-row .tv-spacer {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tv-row .tv-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* required so child label can ellipsis-truncate inside a flex item */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* V5 — primary label truncation. Consumers add their own bold class for branches. */
|
||||
.tv-row .tv-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* V1 — meta slot right-aligns trailing badges/text against row edge. */
|
||||
.tv-row .tv-meta {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* V4 — glyph slot. Bootstrap Icons render at 1em, inherit text color. */
|
||||
.tv-row .tv-glyph {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem; /* 20px, same slot size as toggle */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* V3 — hover. Subtle gray wash; suppressed when row is selected. */
|
||||
.tv-row:hover:not(.tv-selected) {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
/* V3 — selected. Bootstrap utility `bg-primary bg-opacity-10` is the SelectedCssClass default;
|
||||
the .tv-selected hook is provided for consumers that prefer scoped styling. */
|
||||
.tv-row.tv-selected {
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* V3 — keyboard focus. Inset ring composes with hover/selected without layout shift. */
|
||||
.tv-row:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--bs-primary);
|
||||
}
|
||||
|
||||
/* V3 — drop-target (valid). Overrides hover/selected. */
|
||||
.tv-row.tv-drop-target {
|
||||
background-color: rgba(var(--bs-info-rgb), 0.25);
|
||||
}
|
||||
|
||||
/* V3 — dimmed (e.g. non-droppable while a drag is in progress; reserved for future use). */
|
||||
.tv-row.tv-dimmed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* V2 — guide lines. A pseudo-element overlays the row's depth gutter and draws
|
||||
one vertical line per ancestor depth at 24px intervals. The `--tv-depth` variable
|
||||
is set inline per row; lines never extend into the content area. */
|
||||
.tv-guides .tv-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tv-guides .tv-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: calc(var(--tv-depth, 0) * 1.5rem);
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent calc(0.625rem - 0.5px),
|
||||
var(--bs-border-color) calc(0.625rem - 0.5px),
|
||||
var(--bs-border-color) calc(0.625rem + 0.5px),
|
||||
transparent calc(0.625rem + 0.5px)
|
||||
);
|
||||
background-size: 1.5rem 100%;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
|
||||
/* Branch chevron rotates on expand via the aria-expanded attribute on the parent treeitem.
|
||||
Consumers using `bi-chevron-right` get the down-rotation for free. */
|
||||
.tv-row .tv-toggle .bi-chevron-right {
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
Reference in New Issue
Block a user