Skip to content

Commit 44ca571

Browse files
authored
feat(TreeView): support keyboard shortcut (#4345)
* refactor: 增加 EnableKeyboardArrowUpDown 参数 * refactor: 增加 js 回调 * feat: 增加客户端脚本逻辑 * feat: 实现上移下移逻辑 * feat: 增加 Enter 支持 * perf: 性能优化 * refactor: 精简代码提高可读性 * chore: bump version 8.9.4-beta02 * doc: 代码格式化 * refactor: 精简代码 * test: 更新单元测试 * doc: 增加示例 * doc: 更新本地化文档
1 parent e8d9bf1 commit 44ca571

File tree

11 files changed

+194
-23
lines changed

11 files changed

+194
-23
lines changed

src/BootstrapBlazor.Server/Components/Components/CustomerSelectDialog.razor.cs

+8-8
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ public partial class CustomerSelectDialog
1212
private IEnumerable<SelectedItem>? Items2;
1313
private readonly IEnumerable<SelectedItem> Items3 = new SelectedItem[]
1414
{
15-
new SelectedItem ("", "请选择 ..."),
16-
new SelectedItem ("Beijing", "北京"),
17-
new SelectedItem ("Shanghai", "上海")
15+
new("", "请选择 ..."),
16+
new("Beijing", "北京"),
17+
new("Shanghai", "上海")
1818
};
1919

2020
/// <summary>
@@ -29,21 +29,21 @@ private async Task OnCascadeBindSelectClick(SelectedItem item)
2929
{
3030
Items2 = new SelectedItem[]
3131
{
32-
new SelectedItem("1","朝阳区"),
33-
new SelectedItem("2","海淀区"),
32+
new("1","朝阳区"),
33+
new("2","海淀区"),
3434
};
3535
}
3636
else if (item.Value == "Shanghai")
3737
{
3838
Items2 = new SelectedItem[]
3939
{
40-
new SelectedItem("1","静安区"),
41-
new SelectedItem("2","黄浦区"),
40+
new("1","静安区"),
41+
new("2","黄浦区"),
4242
};
4343
}
4444
else
4545
{
46-
Items2 = Enumerable.Empty<SelectedItem>();
46+
Items2 = [];
4747
}
4848
StateHasChanged();
4949
}

src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor

+7
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@
190190
MaxSelectedCount="2" OnMaxSelectedCountExceed="OnMaxSelectedCountExceed"></TreeView>
191191
</DemoBlock>
192192

193+
<DemoBlock Title="@Localizer["TreeViewEnableKeyboardArrowUpDownTitle"]"
194+
Introduction="@Localizer["TreeViewEnableKeyboardArrowUpDownIntro"]"
195+
Name="Normal">
196+
<section ignore>@_selectedValue</section>
197+
<TreeView TItem="TreeFoo" Items="@KeyboardItems" OnTreeItemClick="@OnTreeItemKeyboardClick" EnableKeyboardArrowUpDown="true" />
198+
</DemoBlock>
199+
193200
<AttributeTable Items="@GetAttributes()"></AttributeTable>
194201

195202
<AttributeTable Items="@GetTreeItemAttributes()" Title="@Localizer["TreeViewsAttribute"]"></AttributeTable>

src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor.cs

+11
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public sealed partial class TreeViews
4444

4545
private List<TreeViewItem<TreeFoo>> CheckedItems2 { get; set; } = TreeFoo.GetTreeItems();
4646

47+
private List<TreeViewItem<TreeFoo>> KeyboardItems { get; set; } = TreeFoo.GetTreeItems();
48+
4749
private List<SelectedItem> SelectedItems { get; set; } = TreeFoo.GetItems().Select(x => new SelectedItem(x.Id, x.Text)).ToList();
4850

4951
private TreeView<TreeFoo>? SetActiveTreeView { get; set; }
@@ -54,12 +56,21 @@ public sealed partial class TreeViews
5456

5557
private Foo Model => Foo.Generate(LocalizerFoo);
5658

59+
private string? _selectedValue;
60+
5761
private Task OnTreeItemClick(TreeViewItem<TreeFoo> item)
5862
{
5963
Logger1.Log($"TreeItem: {item.Text} clicked");
6064
return Task.CompletedTask;
6165
}
6266

67+
private Task OnTreeItemKeyboardClick(TreeViewItem<TreeFoo> item)
68+
{
69+
_selectedValue = item.Value.Text;
70+
StateHasChanged();
71+
return Task.CompletedTask;
72+
}
73+
6374
private void OnRefresh()
6475
{
6576
CheckedItems = GetCheckedItems();

src/BootstrapBlazor.Server/Locales/en-US.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,9 @@
707707
"OnMaxSelectedCountExceedContent": "You can select at most {0} items",
708708
"TreeViewMaxSelectedCountTitle": "MaxSelectedCount",
709709
"TreeViewMaxSelectedCountIntro": "Control the maximum number of selectable items by setting the <code>MaxSelectedCount</code> property, and handle the logic through the <code>OnMaxSelectedCountExceed</code> callback",
710-
"TreeViewMaxSelectedCountDesc": "When more than 2 nodes are selected, a <code>Toast</code> prompt bar will pop up"
710+
"TreeViewMaxSelectedCountDesc": "When more than 2 nodes are selected, a <code>Toast</code> prompt bar will pop up",
711+
"TreeViewEnableKeyboardArrowUpDownTitle": "Keyboard",
712+
"TreeViewEnableKeyboardArrowUpDownIntro": "Support keyboard up and down arrow operations by setting <code>EnableKeyboardArrowUpDown=\"true\"</code>"
711713
},
712714
"BootstrapBlazor.Server.Components.Samples.Trees": {
713715
"TreeIntro": "<p>Obsolete,The <a href=\"treeviews\" alt=\"treeview\">TreeView</a> provides more functions",

src/BootstrapBlazor.Server/Locales/zh-CN.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,9 @@
707707
"OnMaxSelectedCountExceedContent": "最多只能选择 {0} 项",
708708
"TreeViewMaxSelectedCountTitle": "最大选择数量",
709709
"TreeViewMaxSelectedCountIntro": "通过设置 <code>MaxSelectedCount</code> 属性控制最大可选数量,通过 <code>OnMaxSelectedCountExceed</code> 回调处理逻辑",
710-
"TreeViewMaxSelectedCountDesc": "选中节点超过 2 个时,弹出 <code>Toast</code> 提示栏"
710+
"TreeViewMaxSelectedCountDesc": "选中节点超过 2 个时,弹出 <code>Toast</code> 提示栏",
711+
"TreeViewEnableKeyboardArrowUpDownTitle": "键盘支持",
712+
"TreeViewEnableKeyboardArrowUpDownIntro": "通过设置 <code>EnableKeyboardArrowUpDown=\"true\"</code> 支持键盘上下箭头操作"
711713
},
712714
"BootstrapBlazor.Server.Components.Samples.Trees": {
713715
"TreeIntro": "<p>本组件已弃用,请使用新组件 <a href=\"treeviews\" alt=\"treeview\">TreeView</a> 提供更多功能",

src/BootstrapBlazor/BootstrapBlazor.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk.Razor">
22

33
<PropertyGroup>
4-
<Version>8.9.4-beta01</Version>
4+
<Version>8.9.4-beta02</Version>
55
</PropertyGroup>
66

77
<ItemGroup Condition="'$(TargetFramework)' == 'net5.0'">

src/BootstrapBlazor/Components/TreeView/TreeView.razor

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@namespace BootstrapBlazor.Components
22
@typeparam TItem
33
@inherits BootstrapModuleComponentBase
4-
@attribute [BootstrapModuleAutoLoader]
4+
@attribute [BootstrapModuleAutoLoader(JSObjectReference = true)]
55

66
@if (Items == null)
77
{
@@ -18,7 +18,7 @@
1818
}
1919
else
2020
{
21-
<div @attributes="AdditionalAttributes" id="@Id" tabindex="0" class="@ClassString">
21+
<div @attributes="AdditionalAttributes" id="@Id" tabindex="0" class="@ClassString" data-bb-keyboard-arrow-up-down="@EnableKeyboardArrowUpDownString">
2222
@if (ShowSearch)
2323
{
2424
@if (SearchTemplate == null)
@@ -37,7 +37,7 @@ else
3737
@SearchTemplate
3838
}
3939
}
40-
<ul class="tree-root scroll">
40+
<ul class="tree-root scroll" tabindex="0">
4141
@foreach (var item in Items)
4242
{
4343
@RenderTreeItem(item)
@@ -58,7 +58,7 @@ else
5858
private RenderFragment<TreeViewItem<TItem>> RenderTreeItem => item =>
5959
@<li class="@GetItemClassString(item)">
6060
<div class="tree-content" @oncontextmenu="e => OnContextMenu(e, item)" @oncontextmenu:preventDefault="IsPreventDefault" @ontouchstart="e => OnTouchStart(e, item)" @ontouchend="OnTouchEnd">
61-
<DynamicElement TagName="i" class="@GetCaretClassString(item)" TriggerClick="TriggerNodeArrow(item)" OnClick="() => OnToggleNodeAsync(item, true)"></DynamicElement>
61+
<DynamicElement TagName="i" class="@GetCaretClassString(item)" TriggerClick="TriggerNodeArrow(item)" OnClick="() => OnToggleNodeAsync(item, true)"></DynamicElement>
6262
@if (ShowCheckbox)
6363
{
6464
<Checkbox Value="@item.CheckedState" IsDisabled="GetItemDisabledState(item)" SkipValidate="true"

src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs

+94-1
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ public partial class TreeView<TItem> : IModelEqualityComparer<TItem>
245245
[Parameter]
246246
public string? ExpandNodeIcon { get; set; }
247247

248+
/// <summary>
249+
/// 获得/设置 是否开启键盘上下键操作 默认 false
250+
/// </summary>
251+
[Parameter]
252+
public bool EnableKeyboardArrowUpDown { get; set; }
253+
248254
[CascadingParameter]
249255
private ContextMenuZone? ContextMenuZone { get; set; }
250256

@@ -283,6 +289,8 @@ public partial class TreeView<TItem> : IModelEqualityComparer<TItem>
283289

284290
private string? _searchText;
285291

292+
private string? EnableKeyboardArrowUpDownString => EnableKeyboardArrowUpDown ? "true" : null;
293+
286294
/// <summary>
287295
/// <inheritdoc/>
288296
/// </summary>
@@ -346,6 +354,91 @@ protected override async Task OnParametersSetAsync()
346354
}
347355
}
348356

357+
/// <summary>
358+
/// <inheritdoc/>
359+
/// </summary>
360+
/// <returns></returns>
361+
protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, Interop, nameof(TriggerKeyDown));
362+
363+
/// <summary>
364+
/// 客户端用户键盘操作处理方法 由 JavaScript 调用
365+
/// </summary>
366+
/// <param name="key"></param>
367+
/// <returns></returns>
368+
[JSInvokable]
369+
public async ValueTask TriggerKeyDown(string key)
370+
{
371+
// 通过 ActiveItem 找到兄弟节点
372+
// 如果兄弟节点没有时,找到父亲节点
373+
if (ActiveItem != null)
374+
{
375+
await ActiveTreeViewItem(key, ActiveItem);
376+
StateHasChanged();
377+
}
378+
}
379+
380+
private static bool IsExpand(TreeViewItem<TItem> item) => item.IsExpand && item.Items.Count > 0;
381+
382+
private List<TreeViewItem<TItem>> GetItems(TreeViewItem<TItem> item) => item.Parent?.Items ?? Items;
383+
384+
private async Task ActiveTreeViewItem(string key, TreeViewItem<TItem> item)
385+
{
386+
var items = GetItems(item);
387+
var index = items.IndexOf(item);
388+
389+
if (key == "ArrowUp")
390+
{
391+
index--;
392+
if (index >= 0)
393+
{
394+
var currentItem = items[index];
395+
if (IsExpand(currentItem))
396+
{
397+
await OnClick(currentItem.Items[^1]);
398+
}
399+
else
400+
{
401+
await OnClick(currentItem);
402+
}
403+
}
404+
else if (item.Parent != null)
405+
{
406+
await OnClick(item.Parent);
407+
}
408+
}
409+
else if (key == "ArrowDown")
410+
{
411+
if (IsExpand(item))
412+
{
413+
await OnClick(item.Items[0]);
414+
}
415+
else
416+
{
417+
index++;
418+
if (index < items.Count)
419+
{
420+
await OnClick(items[index]);
421+
}
422+
else if (item.Parent != null)
423+
{
424+
await ActiveParentTreeViewItem(item.Parent);
425+
}
426+
}
427+
}
428+
}
429+
430+
private async Task ActiveParentTreeViewItem(TreeViewItem<TItem> item)
431+
{
432+
var items = GetItems(item);
433+
var index = items.IndexOf(item);
434+
435+
index++;
436+
if (index < items.Count)
437+
{
438+
await OnClick(items[index]);
439+
}
440+
}
441+
349442
private async Task<bool> OnBeforeStateChangedCallback(TreeViewItem<TItem> item, CheckboxState state)
350443
{
351444
var ret = true;
@@ -416,7 +509,7 @@ private async Task OnClick(TreeViewItem<TItem> item)
416509
if (ShowCheckbox && ClickToggleCheck)
417510
{
418511
item.CheckedState = ToggleCheckState(item.CheckedState);
419-
await OnCheckStateChanged(item);
512+
await OnCheckStateChanged(item, false);
420513
}
421514

422515
StateHasChanged();
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import Data from "../../modules/data.js"
22
import EventHandler from "../../modules/event-handler.js"
33

4-
export function init(id) {
4+
export function init(id, invoke, method) {
55
const el = document.getElementById(id)
66
if (el === null) {
77
return
88
}
99

10-
const tree = {el}
10+
const tree = { el };
1111
Data.set(id, tree)
1212

1313
EventHandler.on(el, 'mouseenter', '.tree-content', e => {
@@ -20,7 +20,6 @@ export function init(id) {
2020
ele.classList.remove('hover')
2121
})
2222

23-
// 支持 Radio
2423
EventHandler.on(el, 'click', '.tree-node', e => {
2524
const node = e.delegateTarget
2625
const prev = node.previousElementSibling;
@@ -29,13 +28,28 @@ export function init(id) {
2928
radio.click();
3029
}
3130
})
31+
32+
EventHandler.on(el, 'keydown', '.tree-root', e => {
33+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
34+
const v = el.getAttribute('data-bb-keyboard-arrow-up-down');
35+
if (v === "true") {
36+
e.preventDefault();
37+
38+
invoke.invokeMethodAsync(method, e.key);
39+
}
40+
}
41+
});
3242
}
3343

3444
export function dispose(id) {
3545
const tree = Data.get(id)
46+
Data.remove(id);
47+
3648
if (tree) {
37-
EventHandler.off(tree.el, 'mouseenter')
38-
EventHandler.off(tree.el, 'mouseleave')
39-
EventHandler.off(tree.el, 'click', '.tree-node')
49+
const { el } = tree;
50+
EventHandler.off(el, 'mouseenter');
51+
EventHandler.off(el, 'mouseleave');
52+
EventHandler.off(el, 'click', '.tree-node');
53+
EventHandler.off(el, 'keyup', '.tree-root');
4054
}
4155
}

src/BootstrapBlazor/Extensions/TreeItemExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ public static class TreeItemExtensions
4141
/// <typeparam name="TItem"></typeparam>
4242
/// <param name="source"></param>
4343
/// <returns></returns>
44-
public static IEnumerable<TreeViewItem<TItem>> GetAllSubItems<TItem>(this IEnumerable<TreeViewItem<TItem>> source) => source.SelectMany(i => i.Items.Any() ? i.Items.Concat(GetAllSubItems(i.Items)) : i.Items);
44+
public static IEnumerable<TreeViewItem<TItem>> GetAllSubItems<TItem>(this IEnumerable<TreeViewItem<TItem>> source) => source.SelectMany(i => i.Items.Count > 0 ? i.Items.Concat(GetAllSubItems(i.Items)) : i.Items);
4545
}

test/UnitTest/Components/TreeViewTest.cs

+42
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,48 @@ public async Task Esc_Ok()
873873
Assert.Null(key);
874874
}
875875

876+
[Fact]
877+
public async Task KeyBoard_Ok()
878+
{
879+
var items = TreeFoo.GetTreeItems();
880+
items[0].IsActive = true;
881+
items[1].IsExpand = true;
882+
items[1].Items[1].IsExpand = true;
883+
items[1].Items[1].Items[1].IsExpand = true;
884+
var cut = Context.RenderComponent<TreeView<TreeFoo>>(pb =>
885+
{
886+
pb.Add(a => a.EnableKeyboardArrowUpDown, true);
887+
pb.Add(a => a.Items, items);
888+
});
889+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
890+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
891+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
892+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
893+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
894+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
895+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
896+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
897+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
898+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
899+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
900+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowDown"));
901+
902+
903+
904+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
905+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
906+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
907+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
908+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
909+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
910+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
911+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
912+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
913+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
914+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
915+
await cut.InvokeAsync(() => cut.Instance.TriggerKeyDown("ArrowUp"));
916+
}
917+
876918
class MockTree<TItem> : TreeView<TItem> where TItem : class
877919
{
878920
public bool TestComparerItem(TItem? a, TItem? b) => base.Equals(a, b);

0 commit comments

Comments
 (0)