Skip to content

Commit

Permalink
Merge branch 'improve/custom-elements-support'
Browse files Browse the repository at this point in the history
  • Loading branch information
jsakamoto committed Dec 19, 2024
2 parents 548dae9 + d738898 commit c1a5f74
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 16 deletions.
44 changes: 44 additions & 0 deletions HotKeys2.E2ETest/HotKeysOnBrowserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -521,4 +521,48 @@ public async Task DisposeAfterCreateContextImmediately_Test(HostingModel hosting
await Task.Delay(500);
await page.WaitForAsync(async _ => (await h1.TextContentAsync()) == "Hello, world!");
}

public record CustomElementTestCase(string TargetSelector, string ExcludedKey, string AvailableKey, string ExpectedURL);

private static readonly IEnumerable<CustomElementTestCase> CustomElementTestCases = [
new(TargetSelector: "#input-1", ExcludedKey: "f", AvailableKey: "h", ExpectedURL: "/" ),
new(TargetSelector: "#text-area-1", ExcludedKey: "f", AvailableKey: "t", ExpectedURL: "/test/bykeyname" ),
new(TargetSelector: "#select-1", ExcludedKey: "s", AvailableKey: "f", ExpectedURL: "/fetchdata" ),
new(TargetSelector: "#checkbox-1", ExcludedKey: "f", AvailableKey: "c", ExpectedURL: "/counter" ),
new(TargetSelector: "#radio-group-1", ExcludedKey: "t", AvailableKey: "h", ExpectedURL: "/" ),
new(TargetSelector: "#button-1", ExcludedKey: "s", AvailableKey: "f", ExpectedURL: "/fetchdata" ),
new(TargetSelector: "#input-button-1", ExcludedKey: "t", AvailableKey: "c", ExpectedURL: "/counter" ),
];

private static IEnumerable<object[]> AllCustomElementTestCases { get; } =
from testCase in CustomElementTestCases
from hostingModel in AllHostingModels
select new object[] { testCase, hostingModel };

[Test]
[TestCaseSource(typeof(HotKeysOnBrowserTest), nameof(AllCustomElementTestCases))]
public async Task ExcludeCustomElements_Test(CustomElementTestCase testCase, HostingModel hostingModel)
{
var context = TestContext.Instance;
var host = await context.StartHostAsync(hostingModel);

// Navigate to the "Test Custom Elements" page,
var page = await context.GetPageAsync();
await page.GotoAndWaitForReadyAsync(host.GetUrl("/test/custom-elements"));

// Set focus to test target element.
// (NOTE: Playwright's `FocusAsync` method does not work for custom elements, so we need to use custom implementation.)
await page.FocusByScriptAsync(testCase.TargetSelector);

// Enter the excluded hot key, but the excluded hokey should not be worked,
// so it stays on the current page.
await page.Keyboard.DownAsync(testCase.ExcludedKey);
await page.Keyboard.UpAsync(testCase.ExcludedKey);
await page.AssertUrlIsAsync(host.GetUrl("/test/custom-elements"));

// But, enter the available key, then the available hokey should be worked.(go to the expected page.)
await page.Keyboard.DownAsync(testCase.AvailableKey);
await page.Keyboard.UpAsync(testCase.AvailableKey);
await page.AssertUrlIsAsync(host.GetUrl(testCase.ExpectedURL));
}
}
10 changes: 10 additions & 0 deletions HotKeys2.E2ETest/Internals/PlaywrightExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ public static async ValueTask FireOnKeyDown(this IPage page,
await Task.Delay(100);
}

/// <summary>
/// This is an alternative to the <see cref="IPage.FocusAsync(string)"/> method, which does not work well in custom elements cases.<br/>
/// This method doesn't use Playwright's <see cref="IPage.FocusAsync(string)"/> method, but uses JavaScript to focus on the element.
/// </summary>
public static async ValueTask FocusByScriptAsync(this IPage page, string selector)
{
await page.WaitForSelectorAsync(selector);
await page.EvaluateAsync($"document.querySelector(\"{selector}\").focus()");
}

//public static void Counter_Should_Be(this IWebDriver driver, int count)
//{
// var expectedCounterText = $"Current count: {count}";
Expand Down
15 changes: 8 additions & 7 deletions HotKeys2/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,18 @@ export var Toolbelt;
(ev.metaKey ? 8 : 0);
const key = convertToKeyName(ev);
const code = ev.code;
const targetElement = ev.target;
const tagName = targetElement.tagName;
const type = targetElement.getAttribute('type');
const preventDefault = callback(modifiers, key, code, targetElement, tagName, type);
const targets = [ev.target, ev.composedPath()[0]]
.filter(e => e)
.map(e => [e, e.tagName, e.getAttribute('type')]);
const preventDefault = callback(modifiers, key, code, targets);
if (preventDefault)
ev.preventDefault();
};
};
HotKeys2.createContext = () => {
let idSeq = 0;
const hotKeyEntries = new Map();
const onKeyDown = (modifiers, key, code, targetElement, tagName, type) => {
const onKeyDown = (modifiers, key, code, targets) => {
let preventDefault = false;
hotKeyEntries.forEach(entry => {
if (!entry.isDisabled) {
Expand All @@ -96,7 +96,7 @@ export var Toolbelt;
entryModKeys |= 8;
if (eventModkeys !== entryModKeys)
return;
if (isExcludeTarget(entry, targetElement, tagName, type))
if (targets.some(([targetElement, tagName, type]) => isExcludeTarget(entry, targetElement, tagName, type)))
return;
preventDefault = true;
entry.action();
Expand Down Expand Up @@ -128,7 +128,8 @@ export var Toolbelt;
};
};
HotKeys2.handleKeyEvent = (hotKeysWrapper, isWasm) => {
const onKeyDown = (modifiers, key, code, targetElement, tagName, type) => {
const onKeyDown = (modifiers, key, code, targets) => {
const [, tagName, type] = targets[0];
if (isWasm) {
return hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code);
}
Expand Down
18 changes: 10 additions & 8 deletions HotKeys2/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@
return false;
}

type KeyEventHandler = (modifiers: ModCodes, key: string, code: string, targetElement: HTMLElement, tagName: string, type: string | null) => boolean;
type KeyEventTarget = [HTMLElement, string, string | null];
type KeyEventHandler = (modifiers: ModCodes, key: string, code: string, targets: KeyEventTarget[]) => boolean;

const createKeydownHandler = (callback: KeyEventHandler) => {
return (ev: KeyboardEvent) => {
Expand All @@ -99,11 +100,11 @@
const key = convertToKeyName(ev);
const code = ev.code;

const targetElement = ev.target as HTMLElement;
const tagName = targetElement.tagName;
const type = targetElement.getAttribute('type');
const targets = [ev.target as HTMLElement, ev.composedPath()[0] as HTMLElement | undefined]
.filter(e => e)
.map<KeyEventTarget>(e => [e!, e!.tagName, e!.getAttribute('type')]);

const preventDefault = callback(modifiers, key, code, targetElement, tagName, type);
const preventDefault = callback(modifiers, key, code, targets);
if (preventDefault) ev.preventDefault();
}
}
Expand All @@ -112,7 +113,7 @@
let idSeq: number = 0;
const hotKeyEntries = new Map<number, HotkeyEntry>();

const onKeyDown = (modifiers: ModCodes, key: string, code: string, targetElement: HTMLElement, tagName: string, type: string | null): boolean => {
const onKeyDown: KeyEventHandler = (modifiers, key, code, targets) => {
let preventDefault = false;

hotKeyEntries.forEach(entry => {
Expand All @@ -132,7 +133,7 @@
if (startsWith(keyEntry, "Meta")) entryModKeys |= ModCodes.Meta;
if (eventModkeys !== entryModKeys) return;

if (isExcludeTarget(entry, targetElement, tagName, type)) return;
if (targets.some(([targetElement, tagName, type]) => isExcludeTarget(entry, targetElement, tagName, type))) return;

preventDefault = true;
entry.action();
Expand Down Expand Up @@ -171,7 +172,8 @@

export const handleKeyEvent = (hotKeysWrapper: any, isWasm: boolean) => {

const onKeyDown = (modifiers: ModCodes, key: string, code: string, targetElement: HTMLElement, tagName: string, type: string | null): boolean => {
const onKeyDown: KeyEventHandler = (modifiers, key, code, targets: KeyEventTarget[]) => {
const [, tagName, type] = targets[0];
if (isWasm) {
return hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code);
} else {
Expand Down
2 changes: 1 addition & 1 deletion HotKeys2/wwwroot/script.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions SampleSites/Client/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_content/SampleSite.Components/helper.js"></script>
<script src="_content/SampleSite.Components/custom-elements.js"></script>
</body>

</html>
39 changes: 39 additions & 0 deletions SampleSites/Components/Pages/TestCustomElements.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
@page "/test/custom-elements"

<h1>Test - Custom Elements</h1>

<div style="display: flex; flex-direction:column; gap:12px;">

<div>
<custom-input id="input-1"></custom-input>
</div>

<div>
<custom-textarea id="text-area-1"></custom-textarea>
</div>

<div>
<custom-select id="select-1">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</custom-select>
</div>

<div>
<custom-checkbox id="checkbox-1"></custom-checkbox>
</div>

<div>
<custom-radio-group id="radio-group-1"></custom-radio-group>
</div>

<div>
<custom-button id="button-1">Click me</custom-button>
</div>

<div>
<custom-input-button id="input-button-1">Click me</custom-input-button>
</div>

</div>
2 changes: 2 additions & 0 deletions SampleSites/Components/Shared/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
.Add(Code.H, () => GoTo("/"), "Go to Home page.", Exclude.TextArea | Exclude.ContentEditable)
.Add(Code.C, () => GoTo("/counter"), "Go to Counter page.", Exclude.InputText | Exclude.TextArea)
.Add(Code.F, () => GoTo("/fetchdata"), "Go to Fetch data page.")
.Add(Code.T, () => GoTo("/test/bykeyname"), "Go to \"Test by Key Name\" page.", Exclude.InputText | Exclude.InputNonText)
.Add(Code.S, () => GoTo("/save-text"), new HotKeyOptions { Description = "Go to \"Save Text\" page.", ExcludeSelector = "button,select,option", Exclude = Exclude.None })
.Add(Key.Control, OnCtrlKeyDown, "Double tap to go to Home.");
}
}
Expand Down
5 changes: 5 additions & 0 deletions SampleSites/Components/Shared/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
<span class="material-icons md-36" aria-hidden="true">keyboard</span> Test Exclude Content Editable
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="test/custom-elements">
<span class="material-icons md-36" aria-hidden="true">keyboard</span> Test Custom Elements
</NavLink>
</li>
@if (@RuntimeInformation.ProcessArchitecture.ToString() == "Wasm")
{
<li class="nav-item px-3">
Expand Down
129 changes: 129 additions & 0 deletions SampleSites/Components/wwwroot/custom-elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// define a custom element that includes a normal input element.
// The custom element will be used to demonstrate how to interact with the input element.

class CustomInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.input = document.createElement('input');
this.input.setAttribute('type', 'text');
this.shadowRoot.appendChild(this.input);
}

focus() { this.input.focus(); }

get value() { return this.input.value; }
}
customElements.define('custom-input', CustomInput);

class CustomTextArea extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.textArea = document.createElement('textarea');
this.shadowRoot.appendChild(this.textArea);
}

focus() { this.textArea.focus(); }

get value() { return this.textArea.value; }
}
customElements.define('custom-textarea', CustomTextArea);

class CustomCheckbox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.checkbox = document.createElement('input');
this.checkbox.setAttribute('type', 'checkbox');
this.shadowRoot.appendChild(this.checkbox);
}

focus() { this.checkbox.focus(); }

get checked() { return this.checkbox.checked; }
}
customElements.define('custom-checkbox', CustomCheckbox);

class CustomSelect extends HTMLElement {

constructor() {
super();
this.attachShadow({ mode: 'open' });
this.select = document.createElement('select');
this.shadowRoot.appendChild(this.select);
}

connectedCallback() {
const options = this.querySelectorAll('option');
options.forEach(option => {
const selectOption = document.createElement('option');
selectOption.textContent = option.textContent;
selectOption.value = option.value;
this.select.appendChild(selectOption);
});
}

focus() { this.select.focus(); }

get value() { return this.select.value; }
}
customElements.define('custom-select', CustomSelect);

class CustomRadioGroup extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const radioGroup = document.createElement('div');
this.shadowRoot.appendChild(radioGroup);
for (let i = 0; i < 3; i++) {
const radio = document.createElement('input');
radio.setAttribute('type', 'radio');
radio.setAttribute('name', 'custom-radio-group');
radio.setAttribute('value', i);
if (i == 0) radio.setAttribute('checked', true);
radioGroup.appendChild(radio);
}
}

focus() { this.shadowRoot.querySelector('input').focus(); }

get value() { return this.shadowRoot.querySelector('input:checked').value; }
}
customElements.define('custom-radio-group', CustomRadioGroup);

class CustomButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.button = document.createElement('button');
this.button.textContent = 'Click me';
this.shadowRoot.appendChild(this.button);
}

connectedCallback() {
const text = this.textContent;
this.button.textContent = text;
}

focus() { this.button.focus(); }
}
customElements.define('custom-button', CustomButton);

class CustomInputButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.input = document.createElement('input');
this.input.setAttribute('type', 'button');
this.shadowRoot.appendChild(this.input);
}

connectedCallback() {
const text = this.textContent;
this.input.value = text;
}

focus() { this.input.focus(); }
}
customElements.define('custom-input-button', CustomInputButton);
1 change: 1 addition & 0 deletions SampleSites/Server/Pages/_Host.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@

<script src="_framework/blazor.server.js"></script>
<script src="_content/SampleSite.Components/helper.js"></script>
<script src="_content/SampleSite.Components/custom-elements.js"></script>
</body>
</html>
1 change: 1 addition & 0 deletions SampleSites/Server8/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
<script src="_content/SampleSite.Components/custom-elements.js"></script>
</body>

</html>

0 comments on commit c1a5f74

Please sign in to comment.