Skip to content

Commit fbaebfb

Browse files
committed
feat: add findbyrole
1 parent 59eaade commit fbaebfb

File tree

9 files changed

+1321
-0
lines changed

9 files changed

+1321
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Web.AngleSharp;
3+
4+
namespace Bunit;
5+
6+
internal sealed class ByRoleElementFactory : IElementWrapperFactory
7+
{
8+
private readonly IRenderedComponent<IComponent> testTarget;
9+
private readonly AriaRole role;
10+
private readonly ByRoleOptions options;
11+
12+
public Action? OnElementReplaced { get; set; }
13+
14+
public ByRoleElementFactory(IRenderedComponent<IComponent> testTarget, AriaRole role, ByRoleOptions options)
15+
{
16+
this.testTarget = testTarget;
17+
this.role = role;
18+
this.options = options;
19+
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
20+
}
21+
22+
private void FragmentsMarkupUpdated(object? sender, EventArgs args)
23+
=> OnElementReplaced?.Invoke();
24+
25+
public TElement GetElement<TElement>() where TElement : class, IElement
26+
{
27+
var element = testTarget.FindByRoleInternal(role, options) as TElement;
28+
29+
return element ?? throw new ElementRemovedFromDomException(role.ToRoleString());
30+
}
31+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using AngleSharp.Dom;
2+
3+
namespace Bunit.Roles;
4+
5+
internal static class AccessibleNameCalculator
6+
{
7+
internal static string? GetAccessibleName(IElement element, INodeList rootNodes)
8+
{
9+
// 1. aria-labelledby — resolve each referenced ID, join with spaces
10+
var labelledBy = element.GetAttribute("aria-labelledby");
11+
if (!string.IsNullOrEmpty(labelledBy))
12+
{
13+
var ids = labelledBy.Split(' ', StringSplitOptions.RemoveEmptyEntries);
14+
var parts = new List<string>();
15+
foreach (var id in ids)
16+
{
17+
var referenced = rootNodes.TryQuerySelector($"#{id}");
18+
if (referenced is not null)
19+
{
20+
var text = referenced.TextContent.Trim();
21+
if (!string.IsNullOrEmpty(text))
22+
parts.Add(text);
23+
}
24+
}
25+
26+
if (parts.Count > 0)
27+
return string.Join(" ", parts);
28+
}
29+
30+
// 2. aria-label attribute
31+
var ariaLabel = element.GetAttribute("aria-label");
32+
if (!string.IsNullOrEmpty(ariaLabel))
33+
return ariaLabel.Trim();
34+
35+
// 3. For IMG, AREA, INPUT[type=image]: alt attribute
36+
var isImageInput = element.NodeName == "INPUT" &&
37+
string.Equals(element.GetAttribute("type"), "image", StringComparison.OrdinalIgnoreCase);
38+
if (element.NodeName is "IMG" or "AREA" || isImageInput)
39+
{
40+
var alt = element.GetAttribute("alt");
41+
if (!string.IsNullOrEmpty(alt))
42+
return alt.Trim();
43+
}
44+
45+
// 4. For form controls: associated <label>
46+
if (element.NodeName is "INPUT" or "SELECT" or "TEXTAREA" or "METER" or "OUTPUT" or "PROGRESS")
47+
{
48+
var labelName = GetLabelName(element, rootNodes);
49+
if (labelName is not null)
50+
return labelName;
51+
}
52+
53+
// 5. Text content (for buttons, links, headings, etc.)
54+
var textContent = element.TextContent.Trim();
55+
if (!string.IsNullOrEmpty(textContent))
56+
return textContent;
57+
58+
// 6. title attribute (fallback)
59+
var title = element.GetAttribute("title");
60+
if (!string.IsNullOrEmpty(title))
61+
return title.Trim();
62+
63+
// 7. placeholder attribute (fallback for inputs)
64+
if (element.NodeName is "INPUT" or "TEXTAREA")
65+
{
66+
var placeholder = element.GetAttribute("placeholder");
67+
if (!string.IsNullOrEmpty(placeholder))
68+
return placeholder.Trim();
69+
}
70+
71+
return null;
72+
}
73+
74+
private static string? GetLabelName(IElement element, INodeList rootNodes)
75+
{
76+
// Check for label via "for" attribute matching element's id
77+
var id = element.GetAttribute("id");
78+
if (!string.IsNullOrEmpty(id))
79+
{
80+
var labels = rootNodes.TryQuerySelectorAll($"label[for='{id}']");
81+
foreach (var label in labels)
82+
{
83+
var text = label.TextContent.Trim();
84+
if (!string.IsNullOrEmpty(text))
85+
return text;
86+
}
87+
}
88+
89+
// Check for wrapping <label>
90+
var parent = element.ParentElement;
91+
while (parent is not null)
92+
{
93+
if (parent.NodeName == "LABEL")
94+
{
95+
var text = GetTextContentExcluding(parent, element).Trim();
96+
if (!string.IsNullOrEmpty(text))
97+
return text;
98+
}
99+
100+
parent = parent.ParentElement;
101+
}
102+
103+
return null;
104+
}
105+
106+
private static string GetTextContentExcluding(IElement container, IElement excluded)
107+
{
108+
var parts = new List<string>();
109+
CollectTextNodes(container, excluded, parts);
110+
return string.Join("", parts);
111+
}
112+
113+
private static void CollectTextNodes(INode node, IElement excluded, List<string> parts)
114+
{
115+
foreach (var child in node.ChildNodes)
116+
{
117+
if (child == excluded)
118+
continue;
119+
120+
if (child.NodeType == NodeType.Text)
121+
{
122+
parts.Add(child.TextContent);
123+
}
124+
else if (child.NodeType == NodeType.Element)
125+
{
126+
CollectTextNodes(child, excluded, parts);
127+
}
128+
}
129+
}
130+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Represents WAI-ARIA roles used to identify the type of an element.
5+
/// Mirrors Playwright.NET's AriaRole enum for familiarity.
6+
/// </summary>
7+
public enum AriaRole
8+
{
9+
Alert,
10+
AlertDialog,
11+
Application,
12+
Article,
13+
Banner,
14+
Blockquote,
15+
Button,
16+
Caption,
17+
Cell,
18+
Checkbox,
19+
Code,
20+
ColumnHeader,
21+
Combobox,
22+
Complementary,
23+
ContentInfo,
24+
Definition,
25+
Deletion,
26+
Dialog,
27+
Directory,
28+
Document,
29+
Emphasis,
30+
Feed,
31+
Figure,
32+
Form,
33+
Generic,
34+
Grid,
35+
GridCell,
36+
Group,
37+
Heading,
38+
Img,
39+
Insertion,
40+
Link,
41+
List,
42+
Listbox,
43+
Listitem,
44+
Log,
45+
Main,
46+
Mark,
47+
Marquee,
48+
Math,
49+
Menu,
50+
Menubar,
51+
Menuitem,
52+
Menuitemcheckbox,
53+
Menuitemradio,
54+
Meter,
55+
Navigation,
56+
None,
57+
Note,
58+
Option,
59+
Paragraph,
60+
Presentation,
61+
Progressbar,
62+
Radio,
63+
Radiogroup,
64+
Region,
65+
Row,
66+
Rowgroup,
67+
Rowheader,
68+
Scrollbar,
69+
Search,
70+
Searchbox,
71+
Separator,
72+
Slider,
73+
Spinbutton,
74+
Status,
75+
Strong,
76+
Subscript,
77+
Superscript,
78+
Switch,
79+
Tab,
80+
Table,
81+
Tablist,
82+
Tabpanel,
83+
Term,
84+
Textbox,
85+
Time,
86+
Timer,
87+
Toolbar,
88+
Tooltip,
89+
Tree,
90+
Treegrid,
91+
Treeitem,
92+
}
93+
94+
internal static class AriaRoleExtensions
95+
{
96+
internal static string ToRoleString(this AriaRole role) => role switch
97+
{
98+
AriaRole.Alert => "alert",
99+
AriaRole.AlertDialog => "alertdialog",
100+
AriaRole.Application => "application",
101+
AriaRole.Article => "article",
102+
AriaRole.Banner => "banner",
103+
AriaRole.Blockquote => "blockquote",
104+
AriaRole.Button => "button",
105+
AriaRole.Caption => "caption",
106+
AriaRole.Cell => "cell",
107+
AriaRole.Checkbox => "checkbox",
108+
AriaRole.Code => "code",
109+
AriaRole.ColumnHeader => "columnheader",
110+
AriaRole.Combobox => "combobox",
111+
AriaRole.Complementary => "complementary",
112+
AriaRole.ContentInfo => "contentinfo",
113+
AriaRole.Definition => "definition",
114+
AriaRole.Deletion => "deletion",
115+
AriaRole.Dialog => "dialog",
116+
AriaRole.Directory => "directory",
117+
AriaRole.Document => "document",
118+
AriaRole.Emphasis => "emphasis",
119+
AriaRole.Feed => "feed",
120+
AriaRole.Figure => "figure",
121+
AriaRole.Form => "form",
122+
AriaRole.Generic => "generic",
123+
AriaRole.Grid => "grid",
124+
AriaRole.GridCell => "gridcell",
125+
AriaRole.Group => "group",
126+
AriaRole.Heading => "heading",
127+
AriaRole.Img => "img",
128+
AriaRole.Insertion => "insertion",
129+
AriaRole.Link => "link",
130+
AriaRole.List => "list",
131+
AriaRole.Listbox => "listbox",
132+
AriaRole.Listitem => "listitem",
133+
AriaRole.Log => "log",
134+
AriaRole.Main => "main",
135+
AriaRole.Mark => "mark",
136+
AriaRole.Marquee => "marquee",
137+
AriaRole.Math => "math",
138+
AriaRole.Menu => "menu",
139+
AriaRole.Menubar => "menubar",
140+
AriaRole.Menuitem => "menuitem",
141+
AriaRole.Menuitemcheckbox => "menuitemcheckbox",
142+
AriaRole.Menuitemradio => "menuitemradio",
143+
AriaRole.Meter => "meter",
144+
AriaRole.Navigation => "navigation",
145+
AriaRole.None => "none",
146+
AriaRole.Note => "note",
147+
AriaRole.Option => "option",
148+
AriaRole.Paragraph => "paragraph",
149+
AriaRole.Presentation => "presentation",
150+
AriaRole.Progressbar => "progressbar",
151+
AriaRole.Radio => "radio",
152+
AriaRole.Radiogroup => "radiogroup",
153+
AriaRole.Region => "region",
154+
AriaRole.Row => "row",
155+
AriaRole.Rowgroup => "rowgroup",
156+
AriaRole.Rowheader => "rowheader",
157+
AriaRole.Scrollbar => "scrollbar",
158+
AriaRole.Search => "search",
159+
AriaRole.Searchbox => "searchbox",
160+
AriaRole.Separator => "separator",
161+
AriaRole.Slider => "slider",
162+
AriaRole.Spinbutton => "spinbutton",
163+
AriaRole.Status => "status",
164+
AriaRole.Strong => "strong",
165+
AriaRole.Subscript => "subscript",
166+
AriaRole.Superscript => "superscript",
167+
AriaRole.Switch => "switch",
168+
AriaRole.Tab => "tab",
169+
AriaRole.Table => "table",
170+
AriaRole.Tablist => "tablist",
171+
AriaRole.Tabpanel => "tabpanel",
172+
AriaRole.Term => "term",
173+
AriaRole.Textbox => "textbox",
174+
AriaRole.Time => "time",
175+
AriaRole.Timer => "timer",
176+
AriaRole.Toolbar => "toolbar",
177+
AriaRole.Tooltip => "tooltip",
178+
AriaRole.Tree => "tree",
179+
AriaRole.Treegrid => "treegrid",
180+
AriaRole.Treeitem => "treeitem",
181+
_ => throw new ArgumentOutOfRangeException(nameof(role), role, $"Unknown AriaRole value: {role}"),
182+
};
183+
}

0 commit comments

Comments
 (0)