diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50a3df2..e7752e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: - run: dotnet test -c Release -f ${{ matrix.target-framework }} --no-build working-directory: tests/HerePlatform.Blazor.Tests + js-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: node --version + - run: node --test tests/HerePlatform.Blazor.Tests.Js/*.test.mjs + restclient-tests: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/release-blazor.yml b/.github/workflows/release-blazor.yml index 146be1c..843ea2e 100644 --- a/.github/workflows/release-blazor.yml +++ b/.github/workflows/release-blazor.yml @@ -14,6 +14,7 @@ jobs: package: blazor csproj-path: HerePlatform.Blazor/HerePlatform.Blazor.csproj test-directory: tests/HerePlatform.Blazor.Tests + js-test-directory: tests/HerePlatform.Blazor.Tests.Js title-prefix: HerePlatform.NET.Blazor secrets: nuget-api-key: ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index af4a10a..1ca9afa 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -18,6 +18,11 @@ on: description: 'Display name for GitHub Release title' required: true type: string + js-test-directory: + description: 'Path to the node:test directory (*.test.mjs); empty to skip' + required: false + type: string + default: '' secrets: nuget-api-key: required: true @@ -42,6 +47,9 @@ jobs: - run: dotnet build -c Release --no-restore - run: dotnet test -c Release --no-build working-directory: ${{ inputs.test-directory }} + - name: Run JS tests + if: inputs.js-test-directory != '' + run: node --test ${{ inputs.js-test-directory }}/*.test.mjs - name: Extract version from tag run: | if [[ ! "$GITHUB_REF_NAME" =~ ^${{ inputs.package }}/v ]]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index bd62bdc..ee5a78c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### [Unreleased] +### [1.4.1] - 2026-09-24 + +#### Changed + +- `AutosuggestInputContext.PreventDefaultKeyDown`: the `[Obsolete]` message now states why the property is no + longer needed and what to do instead — remove `@onkeydown:preventDefault` from custom templates and only splat + `@attributes="context.InputAttributes"` +- Changelog and component docs now mark 1.3.0 as a known regression and point 1.3.0 users to 1.3.1 or later + ### [1.4.0] - 2026-09-20 #### Added @@ -30,7 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 per-element flag and cannot be limited to single keys. The browser default is now suppressed key-selectively by a `keydown` listener on the input, and only for `Enter` (implicit form submit) and `ArrowUp`/`ArrowDown` (caret jump), and only while the dropdown is open. Enter semantics are unchanged: - it still picks the active suggestion, or the first one when none is active + it still picks the active suggestion, or the first one when none is active. This fixes the known + regression in 1.3.0 — upgrade from 1.3.0 is strongly recommended. A narrower form of the same defect + existed in 1.0.0–1.2.0: after navigating the list with the arrow keys, the default template swallowed + every keystroke until the dropdown closed; this is fixed as well #### Deprecated @@ -44,8 +56,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Known broken — do not use.** With the default input template — and with every custom `InputTemplate` > that followed the 1.3.0 guidance to apply `@onkeydown:preventDefault` — an open suggestion dropdown -> suppressed every keystroke, leaving the input uneditable after the first suggestions appeared. -> Fixed in the following release. +> suppressed every keystroke (typing, Backspace/Delete, Tab), leaving the input uneditable after the first +> suggestions appeared. This affects every `HereAutosuggest` without a custom `InputTemplate`. Fixed in +> 1.3.1: upgrade, and remove any `@onkeydown:preventDefault` binding from custom input templates. The +> `PreventDefaultKeyDown` guidance below is withdrawn. #### Added @@ -65,7 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `HereAutosuggest` with a custom `InputTemplate`: pressing Enter while the dropdown was open selected a suggestion AND submitted a surrounding `
`/`EditForm`, because `InputAttributes` splatting could not carry the default template's `@onkeydown:preventDefault` modifier. Consumers must now also bind - `@onkeydown:preventDefault="@context.PreventDefaultKeyDown"` on their custom input + `@onkeydown:preventDefault="@context.PreventDefaultKeyDown"` on their custom input (withdrawn in 1.3.1 — + this binding blocks every keystroke) ### [1.2.0] - 2026-06-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58edcf1..ff84261 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,8 +27,11 @@ dotnet build ```bash dotnet test tests/HerePlatform.Blazor.Tests/ +node --test tests/HerePlatform.Blazor.Tests.Js/*.test.mjs ``` +The JS tests use the built-in `node:test` runner and need Node.js 20 or later. + ## Pull Request Guidelines 1. **Branch from `main`** — create a feature or fix branch (e.g. `feat/my-feature` or `fix/issue-42`). diff --git a/HerePlatform.Blazor/Maps/Search/AutosuggestInputContext.cs b/HerePlatform.Blazor/Maps/Search/AutosuggestInputContext.cs index eb1ede7..19264ac 100644 --- a/HerePlatform.Blazor/Maps/Search/AutosuggestInputContext.cs +++ b/HerePlatform.Blazor/Maps/Search/AutosuggestInputContext.cs @@ -42,9 +42,10 @@ public class AutosuggestInputContext /// suppresses EVERY keystroke — characters, Backspace, Tab — once the dropdown is open. /// Splat instead; it carries the marker the component needs. /// - [Obsolete("Key-selective preventDefault is handled by the component since 1.3.1; do not apply " + - "@onkeydown:preventDefault in custom templates. Always false; scheduled for removal in " + - "the next major version.")] + [Obsolete("No longer needed since 1.3.1: the component suppresses the browser default for Enter and " + + "ArrowUp/ArrowDown itself. Remove @onkeydown:preventDefault from custom templates (it blocks " + + "every keystroke) and only splat @attributes=\"context.InputAttributes\". Always false; " + + "scheduled for removal in the next major version.", error: false)] public bool PreventDefaultKeyDown { get => false; diff --git a/docs/HerePlatform.Docs.Generator/ApiDocGenerator.cs b/docs/HerePlatform.Docs.Generator/ApiDocGenerator.cs index aca4c7f..69bd691 100644 --- a/docs/HerePlatform.Docs.Generator/ApiDocGenerator.cs +++ b/docs/HerePlatform.Docs.Generator/ApiDocGenerator.cs @@ -295,7 +295,7 @@ private static Dictionary LoadXmlDocs(string xmlPath) foreach (var member in doc.Descendants("member")) { var name = member.Attribute("name")?.Value; - var summary = member.Element("summary")?.Value.Trim(); + var summary = XmlDocText.Render(member.Element("summary")); if (name is not null && !string.IsNullOrEmpty(summary)) { // Normalize whitespace diff --git a/docs/HerePlatform.Docs.Generator/RestApiDocGenerator.cs b/docs/HerePlatform.Docs.Generator/RestApiDocGenerator.cs index ec2afca..be23b61 100644 --- a/docs/HerePlatform.Docs.Generator/RestApiDocGenerator.cs +++ b/docs/HerePlatform.Docs.Generator/RestApiDocGenerator.cs @@ -499,7 +499,7 @@ private static (Dictionary summaries, Dictionary summaries, Dictionary +/// Renders an XML documentation element (summary, param) as plain text. +/// drops self-closing tags such as <see cref="..."/>, which leaves gaps like +/// "The provides" in the generated docs. +/// +internal static class XmlDocText +{ + public static string? Render(XElement? element) + { + if (element is null) return null; + + var sb = new StringBuilder(); + Append(element, sb); + + // Normalize whitespace + return string.Join(" ", sb.ToString().Split(default(char[]), StringSplitOptions.RemoveEmptyEntries)); + } + + private static void Append(XElement element, StringBuilder sb) + { + foreach (var node in element.Nodes()) + { + if (node is XText text) + { + sb.Append(text.Value); + } + else if (node is XElement child) + { + if (child.Name == "para") sb.Append(' '); + + if (child.Nodes().Any()) + Append(child, sb); + else + sb.Append(RenderEmpty(child)); + + if (child.Name == "para") sb.Append(' '); + } + } + } + + // , , + private static string RenderEmpty(XElement element) + { + if (element.Attribute("cref")?.Value is { } cref) return ShortCref(cref); + if (element.Attribute("langword")?.Value is { } langword) return langword; + if (element.Attribute("name")?.Value is { } name) return name; + if (element.Attribute("href")?.Value is { } href) return href; + return ""; + } + + // "T:Ns.HereAutosuggest" -> "HereAutosuggest", "P:Ns.HereAutosuggest.InputTemplate" -> + // "HereAutosuggest.InputTemplate", "M:Ns.Type.#ctor(System.String)" -> "Type" + private static string ShortCref(string cref) + { + var kind = cref.Length > 2 && cref[1] == ':' ? cref[0] : 'T'; + var path = cref.Length > 2 && cref[1] == ':' ? cref[2..] : cref; + + var paren = path.IndexOf('('); + if (paren >= 0) path = path[..paren]; + path = Regex.Replace(path, @"`+\d+", ""); + + var isConstructor = path.EndsWith(".#ctor", StringComparison.Ordinal); + if (isConstructor) path = path[..^".#ctor".Length]; + + var parts = path.Split('.'); + if (kind is 'T' or 'N' || isConstructor || parts.Length < 2) + return parts[^1]; + return $"{parts[^2]}.{parts[^1]}"; + } +} diff --git a/docs/HerePlatform.Docs/wwwroot/content/components/autosuggest.md b/docs/HerePlatform.Docs/wwwroot/content/components/autosuggest.md index ef73583..129b925 100644 --- a/docs/HerePlatform.Docs/wwwroot/content/components/autosuggest.md +++ b/docs/HerePlatform.Docs/wwwroot/content/components/autosuggest.md @@ -128,7 +128,9 @@ static per-event/per-element flag, so it would swallow every keystroke, includin ``` `AutosuggestInputContext.PreventDefaultKeyDown` is deprecated since 1.3.1, always returns `false`, and will -be removed in the next major version. +be removed in the next major version. Version 1.3.0 is known broken: once suggestions appeared, the input +swallowed every keystroke. If you are on 1.3.0, upgrade to 1.3.1 or later and remove any +`@onkeydown:preventDefault` binding from your custom input template. ## Events diff --git a/docs/HerePlatform.Docs/wwwroot/data/api-docs.json b/docs/HerePlatform.Docs/wwwroot/data/api-docs.json index e10b891..119734e 100644 --- a/docs/HerePlatform.Docs/wwwroot/data/api-docs.json +++ b/docs/HerePlatform.Docs/wwwroot/data/api-docs.json @@ -98,13 +98,13 @@ "name": "CenterChanged", "kind": "Event", "type": "EventCallback\u003CLatLngLiteral\u003E", - "description": "Callback for two-way binding of . Fired when the user finishes panning (on mapviewchangeend)." + "description": "Callback for two-way binding of AdvancedHereMap.Center. Fired when the user finishes panning (on mapviewchangeend)." }, { "name": "HeadingChanged", "kind": "Event", "type": "EventCallback\u003Cdouble\u003E", - "description": "Callback for two-way binding of . Fired when the user finishes rotating (on mapviewchangeend)." + "description": "Callback for two-way binding of AdvancedHereMap.Heading. Fired when the user finishes rotating (on mapviewchangeend)." }, { "name": "OnAfterInit", @@ -284,13 +284,13 @@ "name": "TiltChanged", "kind": "Event", "type": "EventCallback\u003Cdouble\u003E", - "description": "Callback for two-way binding of . Fired when the user finishes tilting (on mapviewchangeend)." + "description": "Callback for two-way binding of AdvancedHereMap.Tilt. Fired when the user finishes tilting (on mapviewchangeend)." }, { "name": "ZoomChanged", "kind": "Event", "type": "EventCallback\u003Cdouble\u003E", - "description": "Callback for two-way binding of . Fired when the user finishes zooming (on mapviewchangeend)." + "description": "Callback for two-way binding of AdvancedHereMap.Zoom. Fired when the user finishes zooming (on mapviewchangeend)." } ] }, @@ -326,7 +326,7 @@ "name": "PreventDefaultKeyDown", "kind": "Property", "type": "bool", - "description": "Always false. Deprecated since 1.3.1: the component suppresses the browser default itself, key-selectively for Enter and ArrowUp/ArrowDown while the dropdown is open. A custom must NOT apply @onkeydown:preventDefault: Blazor\u0027s modifier is a static per-event/per-element flag, so it suppresses EVERY keystroke \u2014 characters, Backspace, Tab \u2014 once the dropdown is open. Splat instead; it carries the marker the component needs." + "description": "Always false. Deprecated since 1.3.1: the component suppresses the browser default itself, key-selectively for Enter and ArrowUp/ArrowDown while the dropdown is open. A custom HereAutosuggest.InputTemplate must NOT apply @onkeydown:preventDefault: Blazor\u0027s modifier is a static per-event/per-element flag, so it suppresses EVERY keystroke \u2014 characters, Backspace, Tab \u2014 once the dropdown is open. Splat AutosuggestInputContext.InputAttributes instead; it carries the marker the component needs." } ] }, @@ -1547,7 +1547,7 @@ "kind": "Property", "type": "AutosuggestDesign", "default": "Default", - "description": "Predefined design variant for the component. Default: ." + "description": "Predefined design variant for the component. Default: AutosuggestDesign.Default." }, { "name": "Disabled", @@ -1561,7 +1561,7 @@ "kind": "Property", "type": "RenderFragment\u003CAutosuggestInputContext\u003E", "default": "null", - "description": "Custom template for rendering the input area. The provides an InputAttributes dictionary that must be splatted onto the custom \u003Cinput\u003E element (@attributes=\u0022context.InputAttributes\u0022) \u2014 nothing else is required. Keyboard defaults are handled by the component: splatting also applies the data-here-autosuggest-input marker that lets the component suppress the browser default for Enter and ArrowUp/ArrowDown \u2014 and only those \u2014 while the dropdown is open. Do NOT apply @onkeydown:preventDefault in a custom template." + "description": "Custom template for rendering the input area. The AutosuggestInputContext provides an InputAttributes dictionary that must be splatted onto the custom \u003Cinput\u003E element (@attributes=\u0022context.InputAttributes\u0022) \u2014 nothing else is required. Keyboard defaults are handled by the component: splatting also applies the data-here-autosuggest-input marker that lets the component suppress the browser default for Enter and ArrowUp/ArrowDown \u2014 and only those \u2014 while the dropdown is open. Do NOT apply @onkeydown:preventDefault in a custom template." }, { "name": "MinChars", @@ -1589,14 +1589,14 @@ "kind": "Property", "type": "RenderFragment\u003CAutosuggestItem\u003E", "default": "null", - "description": "Custom template for rendering each suggestion item. When set, replaces the default item content inside the \u003Cli\u003E shell (click handler and active class are preserved). Ignored when is set." + "description": "Custom template for rendering each suggestion item. When set, replaces the default item content inside the \u003Cli\u003E shell (click handler and active class are preserved). Ignored when HereAutosuggest.SuggestionListTemplate is set." }, { "name": "SuggestionListTemplate", "kind": "Property", "type": "RenderFragment\u003CAutosuggestListContext\u003E", "default": "null", - "description": "Custom template for rendering the entire suggestion dropdown. When set, replaces the default \u003Cul\u003E list completely. Use to wire up item selection." + "description": "Custom template for rendering the entire suggestion dropdown. When set, replaces the default \u003Cul\u003E list completely. Use AutosuggestListContext.SelectItem to wire up item selection." }, { "name": "UserAttributes", @@ -1933,7 +1933,7 @@ "name": "IsOpenChanged", "kind": "Event", "type": "EventCallback\u003Cbool\u003E", - "description": "Callback for two-way binding of . Fired when the InfoBubble is closed by clicking the X button." + "description": "Callback for two-way binding of InfoBubbleComponent.IsOpen. Fired when the InfoBubble is closed by clicking the X button." } ] }, @@ -2958,13 +2958,13 @@ "name": "LatChanged", "kind": "Event", "type": "EventCallback\u003Cdouble\u003E", - "description": "Callback for two-way binding of . Fired when the marker is dragged to a new position." + "description": "Callback for two-way binding of MarkerComponent.Lat. Fired when the marker is dragged to a new position." }, { "name": "LngChanged", "kind": "Event", "type": "EventCallback\u003Cdouble\u003E", - "description": "Callback for two-way binding of . Fired when the marker is dragged to a new position." + "description": "Callback for two-way binding of MarkerComponent.Lng. Fired when the marker is dragged to a new position." }, { "name": "OnClick", diff --git a/docs/HerePlatform.Docs/wwwroot/data/rest-api-docs.json b/docs/HerePlatform.Docs/wwwroot/data/rest-api-docs.json index bd56e2d..ef6faa1 100644 --- a/docs/HerePlatform.Docs/wwwroot/data/rest-api-docs.json +++ b/docs/HerePlatform.Docs/wwwroot/data/rest-api-docs.json @@ -48,7 +48,7 @@ "name": "Show", "type": "string?", "required": false, - "description": "Additional response fields to request via the HERE show parameter. Default: \u0022details\u0022 \u2014 without it the Autosuggest API returns only address.label and none of the structured address fields (street, postal code, city, \u2026) on . Set to null to omit the parameter (label-only responses). Only sent for Autosuggest requests; Autocomplete requests never include it (the /autocomplete endpoint does not support show=details and returns structured addresses natively).", + "description": "Additional response fields to request via the HERE show parameter. Default: \u0022details\u0022 \u2014 without it the Autosuggest API returns only address.label and none of the structured address fields (street, postal code, city, \u2026) on AutosuggestAddress. Set to null to omit the parameter (label-only responses). Only sent for Autosuggest requests; Autocomplete requests never include it (the /autocomplete endpoint does not support show=details and returns structured addresses natively).", "default": "\u0022details\u0022" }, { @@ -111,7 +111,7 @@ "name": "Show", "type": "string?", "required": false, - "description": "Additional response fields to request via the HERE show parameter. Default: \u0022details\u0022 \u2014 without it the Autosuggest API returns only address.label and none of the structured address fields (street, postal code, city, \u2026) on . Set to null to omit the parameter (label-only responses). Only sent for Autosuggest requests; Autocomplete requests never include it (the /autocomplete endpoint does not support show=details and returns structured addresses natively).", + "description": "Additional response fields to request via the HERE show parameter. Default: \u0022details\u0022 \u2014 without it the Autosuggest API returns only address.label and none of the structured address fields (street, postal code, city, \u2026) on AutosuggestAddress. Set to null to omit the parameter (label-only responses). Only sent for Autosuggest requests; Autocomplete requests never include it (the /autocomplete endpoint does not support show=details and returns structured addresses natively).", "default": "\u0022details\u0022" }, { @@ -1909,7 +1909,7 @@ { "name": "Address", "type": "AutosuggestAddress?", - "description": "Structured address details. For Autosuggest results the structured fields (street, postal code, city, \u2026) are only populated when the request includes show=details (, on by default) \u2014 without it the API returns only . Autocomplete results carry structured addresses natively, independent of .", + "description": "Structured address details. For Autosuggest results the structured fields (street, postal code, city, \u2026) are only populated when the request includes show=details (AutosuggestOptions.Show, on by default) \u2014 without it the API returns only AutosuggestAddress.Label. Autocomplete results carry structured addresses natively, independent of AutosuggestOptions.Show.", "nestedType": "AutosuggestAddress" }, { @@ -3050,7 +3050,7 @@ { "name": "Observations", "type": "List\u003CWeatherObservation\u003E?", - "description": "Current weather observations (when was requested).", + "description": "Current weather observations (when WeatherProduct.Observation was requested).", "nestedType": "WeatherObservation" }, { diff --git a/docs/HerePlatform.Docs/wwwroot/docs/components/autosuggest/index.html b/docs/HerePlatform.Docs/wwwroot/docs/components/autosuggest/index.html index dc2fd85..e25e4e6 100644 --- a/docs/HerePlatform.Docs/wwwroot/docs/components/autosuggest/index.html +++ b/docs/HerePlatform.Docs/wwwroot/docs/components/autosuggest/index.html @@ -200,7 +200,9 @@

Templates

</HereAutosuggest>

AutosuggestInputContext.PreventDefaultKeyDown is deprecated since 1.3.1, always returns false, and will -be removed in the next major version.

+be removed in the next major version. Version 1.3.0 is known broken: once suggestions appeared, the input +swallowed every keystroke. If you are on 1.3.0, upgrade to 1.3.1 or later and remove any +@onkeydown:preventDefault binding from your custom input template.

Events

diff --git a/tests/HerePlatform.Blazor.Tests/Search/HereAutosuggestInteractionTests.cs b/tests/HerePlatform.Blazor.Tests/Search/HereAutosuggestInteractionTests.cs index 61f77ff..71bd3c5 100644 --- a/tests/HerePlatform.Blazor.Tests/Search/HereAutosuggestInteractionTests.cs +++ b/tests/HerePlatform.Blazor.Tests/Search/HereAutosuggestInteractionTests.cs @@ -106,6 +106,19 @@ public void Default_Input_Does_Not_Apply_PreventDefault_KeyDown_Modifier() Assert.That(input.OuterHtml, Does.Not.Contain("onkeydown:preventdefault")); } + [Test] + public void Default_Input_Does_Not_Apply_PreventDefault_KeyDown_Modifier_After_Arrow_Navigation() + { + var cut = Render(); + InjectResults(cut); + + // Up to 1.2.0 the modifier was bound to an active item, so typing died after ArrowDown. + cut.Find("input").KeyDown(Key.Down); + + Assert.That(cut.FindAll("li.here-autosuggest-item")[0].ClassList, Does.Contain("active")); + Assert.That(cut.Find("input").OuterHtml, Does.Not.Contain("onkeydown:preventdefault")); + } + [Test] public void PreventDefaultKeyDown_Is_Always_False() { @@ -396,8 +409,22 @@ private static void WaitUntil(Func condition, int timeoutMs = 5000) Assert.That(condition(), Is.True, "Condition was not met within the timeout."); } - private List GetAutosuggestInvocations() => - JSInterop.Invocations - .Where(i => i.Identifier == "herePlatform.objectManager.autosuggest") - .ToList(); + // bUnit records invocations in an unsynchronized dictionary, and the debounce timer thread may + // add one while WaitUntil enumerates it ("Collection was modified") — retry until a clean read. + private List GetAutosuggestInvocations() + { + while (true) + { + try + { + return JSInterop.Invocations + .Where(i => i.Identifier == "herePlatform.objectManager.autosuggest") + .ToList(); + } + catch (InvalidOperationException) + { + Thread.Sleep(1); + } + } + } }