diff --git a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs index cd3bba7..94b2c4e 100644 --- a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs +++ b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs @@ -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 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 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)); + } } diff --git a/HotKeys2.E2ETest/Internals/PlaywrightExtensions.cs b/HotKeys2.E2ETest/Internals/PlaywrightExtensions.cs index 7d38c47..d647fb3 100644 --- a/HotKeys2.E2ETest/Internals/PlaywrightExtensions.cs +++ b/HotKeys2.E2ETest/Internals/PlaywrightExtensions.cs @@ -87,6 +87,16 @@ public static async ValueTask FireOnKeyDown(this IPage page, await Task.Delay(100); } + /// + /// This is an alternative to the method, which does not work well in custom elements cases.
+ /// This method doesn't use Playwright's method, but uses JavaScript to focus on the element. + ///
+ 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}"; diff --git a/HotKeys2/script.js b/HotKeys2/script.js index 83b9b85..4431d7e 100644 --- a/HotKeys2/script.js +++ b/HotKeys2/script.js @@ -64,10 +64,10 @@ 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(); }; @@ -75,7 +75,7 @@ export var Toolbelt; 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) { @@ -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(); @@ -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); } diff --git a/HotKeys2/script.ts b/HotKeys2/script.ts index 8fca7d1..6f9d033 100644 --- a/HotKeys2/script.ts +++ b/HotKeys2/script.ts @@ -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) => { @@ -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(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(); } } @@ -112,7 +113,7 @@ let idSeq: number = 0; const hotKeyEntries = new Map(); - 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 => { @@ -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(); @@ -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 { diff --git a/HotKeys2/wwwroot/script.min.js b/HotKeys2/wwwroot/script.min.js index 1d1b06f..240ab3c 100644 --- a/HotKeys2/wwwroot/script.min.js +++ b/HotKeys2/wwwroot/script.min.js @@ -1 +1 @@ -export var Toolbelt;(function(n){var t;(function(n){var t;(function(n){const i=document,r="OnKeyDown",u=["button","checkbox","color","file","image","radio","range","reset","submit",],f="INPUT",e="keydown";class c{constructor(n,t,i,r,u,f,e){this.dotNetObj=n;this.mode=t;this.modifiers=i;this.keyEntry=r;this.exclude=u;this.excludeSelector=f;this.isDisabled=e}action(){this.dotNetObj.invokeMethodAsync("InvokeAction")}}const o=n=>i.addEventListener(e,n),s=n=>i.removeEventListener(e,n),l=n=>{return{OS:"Meta",Decimal:"Period"}[n.key]||n.key},t=(n,t)=>n.startsWith(t),a=(n,t,i,r)=>(n.exclude&1)!=0&&i===f&&u.every(n=>n!==r)?!0:(n.exclude&2)!=0&&i===f&&u.some(n=>n===r)?!0:(n.exclude&4)!=0&&i==="TEXTAREA"?!0:(n.exclude&8)!=0&&t.isContentEditable?!0:n.excludeSelector!==""&&t.matches(n.excludeSelector)?!0:!1,h=n=>t=>{if(typeof t.altKey!="undefined"){const r=(t.shiftKey?1:0)+(t.ctrlKey?2:0)+(t.altKey?4:0)+(t.metaKey?8:0),u=l(t),f=t.code,i=t.target,e=i.tagName,o=i.getAttribute("type"),s=n(r,u,f,i,e,o);s&&t.preventDefault()}};n.createContext=()=>{let r=0;const n=new Map,u=(i,r,u,f,e,o)=>{let s=!1;return n.forEach(n=>{if(!n.isDisabled){const l=n.mode===1,v=l?u:r,h=n.keyEntry;if(h!==v)return;const y=l?i:i&65534;let c=l?n.modifiers:n.modifiers&65534;if(t(h,"Shift")&&l&&(c|=1),t(h,"Control")&&(c|=2),t(h,"Alt")&&(c|=4),t(h,"Meta")&&(c|=8),y!==c)return;if(a(n,f,e,o))return;s=!0;n.action()}}),s},i=h(u);return o(i),{register:(t,i,u,f,e,o,s)=>{const h=r++,l=new c(t,i,u,f,e,o,s);return n.set(h,l),h},update:(t,i)=>{const r=n.get(t);r&&(r.isDisabled=i)},unregister:t=>{t!==-1&&n.delete(t)},dispose:()=>{s(i)}}};n.handleKeyEvent=(n,t)=>{const u=(i,u,f,e,o,s)=>t?n.invokeMethod(r,i,o,s,u,f):(n.invokeMethodAsync(r,i,o,s,u,f),!1),i=h(u);return o(i),{dispose:()=>{s(i)}}}})(t=n.HotKeys2||(n.HotKeys2={}))})(t=n.Blazor||(n.Blazor={}))})(Toolbelt||(Toolbelt={})); \ No newline at end of file +export var Toolbelt;(function(n){var t;(function(n){var t;(function(n){const i=document,r="OnKeyDown",u=["button","checkbox","color","file","image","radio","range","reset","submit",],f="INPUT",e="keydown";class c{constructor(n,t,i,r,u,f,e){this.dotNetObj=n;this.mode=t;this.modifiers=i;this.keyEntry=r;this.exclude=u;this.excludeSelector=f;this.isDisabled=e}action(){this.dotNetObj.invokeMethodAsync("InvokeAction")}}const o=n=>i.addEventListener(e,n),s=n=>i.removeEventListener(e,n),l=n=>{return{OS:"Meta",Decimal:"Period"}[n.key]||n.key},t=(n,t)=>n.startsWith(t),a=(n,t,i,r)=>(n.exclude&1)!=0&&i===f&&u.every(n=>n!==r)?!0:(n.exclude&2)!=0&&i===f&&u.some(n=>n===r)?!0:(n.exclude&4)!=0&&i==="TEXTAREA"?!0:(n.exclude&8)!=0&&t.isContentEditable?!0:n.excludeSelector!==""&&t.matches(n.excludeSelector)?!0:!1,h=n=>t=>{if(typeof t.altKey!="undefined"){const i=(t.shiftKey?1:0)+(t.ctrlKey?2:0)+(t.altKey?4:0)+(t.metaKey?8:0),r=l(t),u=t.code,f=[t.target,t.composedPath()[0]].filter(n=>n).map(n=>[n,n.tagName,n.getAttribute("type")]),e=n(i,r,u,f);e&&t.preventDefault()}};n.createContext=()=>{let r=0;const n=new Map,u=(i,r,u,f)=>{let e=!1;return n.forEach(n=>{if(!n.isDisabled){const h=n.mode===1,c=h?u:r,o=n.keyEntry;if(o!==c)return;const l=h?i:i&65534;let s=h?n.modifiers:n.modifiers&65534;if(t(o,"Shift")&&h&&(s|=1),t(o,"Control")&&(s|=2),t(o,"Alt")&&(s|=4),t(o,"Meta")&&(s|=8),l!==s)return;if(f.some(([i,t,r])=>a(n,i,t,r)))return;e=!0;n.action()}}),e},i=h(u);return o(i),{register:(t,i,u,f,e,o,s)=>{const h=r++,l=new c(t,i,u,f,e,o,s);return n.set(h,l),h},update:(t,i)=>{const r=n.get(t);r&&(r.isDisabled=i)},unregister:t=>{t!==-1&&n.delete(t)},dispose:()=>{s(i)}}};n.handleKeyEvent=(n,t)=>{const u=(i,u,f,e)=>{const[,o,s]=e[0];return t?n.invokeMethod(r,i,o,s,u,f):(n.invokeMethodAsync(r,i,o,s,u,f),!1)},i=h(u);return o(i),{dispose:()=>{s(i)}}}})(t=n.HotKeys2||(n.HotKeys2={}))})(t=n.Blazor||(n.Blazor={}))})(Toolbelt||(Toolbelt={})); \ No newline at end of file diff --git a/SampleSites/Client/wwwroot/index.html b/SampleSites/Client/wwwroot/index.html index a4dd0f1..d41315c 100644 --- a/SampleSites/Client/wwwroot/index.html +++ b/SampleSites/Client/wwwroot/index.html @@ -28,6 +28,7 @@ + diff --git a/SampleSites/Components/Pages/TestCustomElements.razor b/SampleSites/Components/Pages/TestCustomElements.razor new file mode 100644 index 0000000..ba3fc12 --- /dev/null +++ b/SampleSites/Components/Pages/TestCustomElements.razor @@ -0,0 +1,39 @@ +@page "/test/custom-elements" + +

Test - Custom Elements

+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + +
+ +
+ +
+ +
+ +
+ +
+ Click me +
+ +
+ Click me +
+ +
\ No newline at end of file diff --git a/SampleSites/Components/Shared/MainLayout.razor b/SampleSites/Components/Shared/MainLayout.razor index 0969902..f2546f0 100644 --- a/SampleSites/Components/Shared/MainLayout.razor +++ b/SampleSites/Components/Shared/MainLayout.razor @@ -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."); } } diff --git a/SampleSites/Components/Shared/NavMenu.razor b/SampleSites/Components/Shared/NavMenu.razor index 2771e2d..66b099d 100644 --- a/SampleSites/Components/Shared/NavMenu.razor +++ b/SampleSites/Components/Shared/NavMenu.razor @@ -52,6 +52,11 @@ Test Exclude Content Editable + @if (@RuntimeInformation.ProcessArchitecture.ToString() == "Wasm") {