From 17f64e8b3d49b25f13097d1f0d1a7269d84b7d9f Mon Sep 17 00:00:00 2001 From: NoobNotFound <82730163+NoobNotFound@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:33:33 +0530 Subject: [PATCH 1/3] fix(overlay): portal server dropdown flyouts Inline popups remain inside native labels and backdrop-filter boundaries. Portal ComboBox and AutoSuggestBox content through FluentOverlayHost and close state before delayed callbacks. --- FluentKit.slnx | 1 + docs/reference/api.json | 82 ++++- docs/reference/api.md | 34 ++- .../Components/App.razor | 27 ++ .../Components/Routes.razor | 9 + .../Components/_Imports.razor | 5 + .../FluentKit.Sample.Server.csproj | 12 + samples/FluentKit.Sample.Server/Program.cs | 30 ++ .../UnavailableRazorPlaygroundCompiler.cs | 15 + .../Pages/Composite/AutoSuggestBoxPage.razor | 12 + .../Pages/Composite/ComboBoxPage.razor | 11 + .../wwwroot/gallery.css | 17 ++ .../AutoSuggestBox/FluentAutoSuggestBox.razor | 60 ++-- .../FluentAutoSuggestBox.razor.cs | 282 ++++++++++-------- .../FluentAutoSuggestBox.razor.css | 164 +--------- .../Composite/ComboBox/FluentComboBox.razor | 69 ++--- .../ComboBox/FluentComboBox.razor.cs | 264 ++++++++-------- .../ComboBox/FluentComboBox.razor.css | 146 +-------- .../TeachingTip/FluentTeachingTip.razor.cs | 4 +- .../Effects/Mica/FluentMicaPanel.razor.cs | 26 +- src/FluentKit/Overlay/IOverlayService.cs | 15 + .../Overlay/OverlayAnchorAlignment.cs | 13 + src/FluentKit/Overlay/OverlayEntry.cs | 2 + .../Overlay/OverlayPositioningOptions.cs | 20 ++ src/FluentKit/Overlay/OverlayService.cs | 25 +- src/FluentKit/Overlay/OverlaySurface.razor.cs | 4 +- .../Overlay/OverlaySurface.razor.css | 1 + src/FluentKit/PublicAPI.Unshipped.txt | 20 ++ src/FluentKit/Theming/ThemeProvider.razor.cs | 17 +- .../wwwroot/Overlay/overlay-interop.js | 8 +- .../FluentKit.Tests/PortalledDropdownTests.cs | 101 +++++++ 31 files changed, 829 insertions(+), 667 deletions(-) create mode 100644 samples/FluentKit.Sample.Server/Components/App.razor create mode 100644 samples/FluentKit.Sample.Server/Components/Routes.razor create mode 100644 samples/FluentKit.Sample.Server/Components/_Imports.razor create mode 100644 samples/FluentKit.Sample.Server/FluentKit.Sample.Server.csproj create mode 100644 samples/FluentKit.Sample.Server/Program.cs create mode 100644 samples/FluentKit.Sample.Server/Services/UnavailableRazorPlaygroundCompiler.cs create mode 100644 src/FluentKit/Overlay/OverlayAnchorAlignment.cs create mode 100644 src/FluentKit/Overlay/OverlayPositioningOptions.cs create mode 100644 tests/FluentKit.Tests/PortalledDropdownTests.cs diff --git a/FluentKit.slnx b/FluentKit.slnx index 36bba58..0ef916c 100644 --- a/FluentKit.slnx +++ b/FluentKit.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/reference/api.json b/docs/reference/api.json index b916829..3b73f73 100644 --- a/docs/reference/api.json +++ b/docs/reference/api.json @@ -79,7 +79,7 @@ "genericParameters": [ "TValue" ], - "summary": "Mirrors WinUI\u0027s AutoSuggestBox / fluent-svelte\u0027s AutoSuggestBox.svelte \u2014 a free-typing TextBox with a live-filtered suggestion dropdown underneath, distinct from ComboBox\u0027s editable mode in that the typed text is NOT required to match any item: is always whatever the user typed, fires with that raw text on Enter with no suggestion highlighted, and picking a suggestion (click, or Enter while one IS highlighted) fires separately and just replaces Text with the suggestion\u0027s Name \u2014 there\u0027s no persistent \u0022selected value\u0022 the way ComboBox has Value. Composes FluentTextBox (same \u0022don\u0027t reimplement input chrome\u0022 rule NumberBox follows) and, like ComboBox/ContentDialog/NumberBox\u0027s Expanded popout, stays self-contained/absolutely positioned rather than going through IOverlayService \u2014 the dropdown needs to be exactly the trigger\u0027s width, same reasoning as ComboBox\u0027s own dropdown.", + "summary": "A free-typing text box with live suggestions. The suggestion list is rendered through so it is not constrained by labels or acrylic ancestors.", "parameters": [ { "name": "AdditionalAttributes", @@ -103,7 +103,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "When true, shows a search icon on the right of the box (via TextBox\u0027s Buttons slot). Once there\u0027s text in the box, that icon swaps to a \u0022clear\u0022 icon that empties Text on click \u2014 same idea as the search field on the demo TextBox above, just wired in as an opt-in here.", + "summary": "Whether to display the search/clear affordance.", "settable": true }, { @@ -112,7 +112,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Optional custom match predicate over (typed text, candidate item). Defaults to a case-insensitive substring match against \u2014 deliberately \u0022contains\u0022 rather than ComboBox\u0027s \u0022starts-with\u0022, since AutoSuggestBox is meant for search-as-you-type against arbitrary positions in the name, not prefix completion.", + "summary": "Optional predicate used to filter suggestions from the entered text.", "settable": true }, { @@ -129,7 +129,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Optional custom row content for each suggestion. Falls back to a plain \u003Cspan\u003E@item.Name\u003C/span\u003E, same default ComboBox\u0027s ItemTemplate uses.", + "summary": "Optional custom content for each suggestion row.", "settable": true }, { @@ -146,7 +146,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Upper bound on how many matches are shown at once.", + "summary": "Maximum number of matching suggestions displayed at once.", "settable": true }, { @@ -163,7 +163,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Fired on Enter when no suggestion is highlighted \u2014 the raw typed text is the query, same as WinUI\u0027s QuerySubmitted with a null ChosenSuggestion.", + "summary": "Raised when Enter submits text without a highlighted suggestion.", "settable": true }, { @@ -172,7 +172,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Fired when the user picks a suggestion \u2014 click, or Enter while one is highlighted via arrow keys. Text is set to the suggestion\u0027s Name as part of the same interaction.", + "summary": "Raised after a suggestion is selected.", "settable": true }, { @@ -181,7 +181,7 @@ "cascading": false, "renderFragment": false, "bindable": true, - "summary": "The raw typed text. Two-way bindable \u2014 always reflects what\u0027s in the box, never snapped back to a suggestion\u0027s Name unless the user actually picks one.", + "summary": "The raw typed text. Two-way bindable.", "settable": true }, { @@ -836,7 +836,7 @@ "genericParameters": [ "TValue" ], - "summary": "Mirrors fluent-svelte\u0027s ComboBox.svelte (dropdown list with an optional editable/searchable text-box mode). Two render modes off : - false (default): a FluentButton-shaped trigger showing the selected item\u0027s Name, opens a positioned \u003Cul\u003E of ComboBoxItem-equivalent \u003Cli\u003E rows on click. - true: a FluentTextBox-shaped trigger the user can type into, filtering/matching items by Name prefix (case-insensitive), same as fluent-svelte\u0027s handleInput/searchValue flow. Unlike FluentMenuFlyout, this does NOT go through IOverlayService/FluentOverlayHost \u2014 the dropdown list needs to be exactly the trigger\u0027s width and grow from a specific list index (the menuOffset/menuGrowDirection choreography below), which is easiest to keep as a plain absolutely-positioned child of the component\u0027s own root (same as ComboBox.scss\u0027s own \u0060position: relative\u0060 \u002B \u0060.combo-box-dropdown { position: absolute }\u0060). A portal would only add indirection for zero benefit here since there\u0027s no anchor-to-elsewhere requirement.", + "summary": "A WinUI-style selectable list with optional editable search. Its dropdown is rendered through so it can escape labels, clipping ancestors, and backdrop-filter roots while retaining the trigger\u0027s width and selected-row alignment.", "parameters": [ { "name": "AdditionalAttributes", @@ -860,7 +860,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Whether the ComboBox can be searched/typed into (fluent-svelte\u0027s \u0060editable\u0060 prop).", + "summary": "Whether the ComboBox permits typing and prefix matching.", "settable": true }, { @@ -869,7 +869,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Optional custom row content for each dropdown item. Falls back to a plain \u003Cspan\u003E@item.Name\u003C/span\u003E when not supplied \u2014 same \u0022text unless you say otherwise\u0022 default as fluent-svelte\u0027s own ComboBoxItem (which likewise only exposes a default slot, no separate templating concept). Wraps in the same .fluent-combo-box-item \u003Cli\u003E (selection bar, hover/active states, focus ring) regardless of what you render inside \u2014 only the row\u0027s inner content is replaced, not its interaction chrome.", + "summary": "Optional custom content for each dropdown row.", "settable": true }, { @@ -894,7 +894,7 @@ "cascading": false, "renderFragment": false, "bindable": true, - "summary": "Current text in the search box. Only meaningful when is true.", + "summary": "Current editable search text.", "settable": true }, { @@ -911,7 +911,7 @@ "cascading": false, "renderFragment": false, "bindable": false, - "summary": "Optional custom content for the closed/collapsed trigger label in non-editable ( = false) mode, e.g. to show an icon \u002B name instead of plain text once something is selected. Only used when there IS a selection \u2014 the placeholder text always renders plain regardless. Has no effect in Editable mode, since that trigger is a real \u003Cinput\u003E and can only ever hold plain text.", + "summary": "Optional custom content for the selected non-editable trigger label.", "settable": true }, { @@ -3385,7 +3385,7 @@ "category": "Composite", "sample": "/Composite/TeachingTip", "genericParameters": [], - "summary": "Mirrors WinUI\u0027s TeachingTip \u2014 a persistent, anchor-relative callout (title/subtitle, an optional row of action buttons, and an explicit close button) used for contextual \u0022here\u0027s what this does\u0022 UI, as opposed to Flyout\u0027s generic content or ToolTip\u0027s hover-only hint. Structurally close to Flyout: built directly on IOverlayService/FluentOverlayHost with the same programmatic--binding shape as , and \u2014 like every other flyout/menu/tooltip \u2014 relies entirely on OverlaySurface\u0027s own default chrome (background, backdrop-filter: blur(60px), border, shadow) for its blur; it does NOT pass bare. One real difference from Flyout: it adds its own pointer/beak toward the anchor in targeted mode (WinUI teaching tips always draw one there \u2014 see the beak\u0027s own matching background/border in FluentTeachingTip.razor.css), and it defaults to false \u2014 a teaching tip is meant to stay up until the person reads it and dismisses it via the explicit close button, not disappear the moment they click elsewhere. Two placement modes, chosen automatically by whether is supplied: Targeted ( set) \u2014 is the anchor/target element the tip points its beak at, positioned via (top/bottom/left/right of the anchor), same anchor-measurement path as Flyout/MenuFlyout. Unlike Flyout/MenuFlyout it is NOT wrapped in a click handler \u2014 teaching tips are shown/hidden programmatically (e.g. after a first-run check), never by clicking their own target.Detached ( omitted) \u2014 for a tip that is not pointing at any particular control (e.g. a general new-features announcement). No beak is drawn, and position is set by relative to the viewport (defaults to bottom-center) via instead of the anchor-based overload.", + "summary": "Mirrors WinUI\u0027s TeachingTip \u2014 a persistent, anchor-relative callout (title/subtitle, an optional row of action buttons, and an explicit close button) used for contextual \u0022here\u0027s what this does\u0022 UI, as opposed to Flyout\u0027s generic content or ToolTip\u0027s hover-only hint. Structurally close to Flyout: built directly on IOverlayService/FluentOverlayHost with the same programmatic--binding shape as , and \u2014 like every other flyout/menu/tooltip \u2014 relies entirely on OverlaySurface\u0027s own default chrome (background, backdrop-filter: blur(60px), border, shadow) for its blur; it does NOT pass bare. One real difference from Flyout: it adds its own pointer/beak toward the anchor in targeted mode (WinUI teaching tips always draw one there \u2014 see the beak\u0027s own matching background/border in FluentTeachingTip.razor.css), and it defaults to false \u2014 a teaching tip is meant to stay up until the person reads it and dismisses it via the explicit close button, not disappear the moment they click elsewhere. Two placement modes, chosen automatically by whether is supplied: Targeted ( set) \u2014 is the anchor/target element the tip points its beak at, positioned via (top/bottom/left/right of the anchor), same anchor-measurement path as Flyout/MenuFlyout. Unlike Flyout/MenuFlyout it is NOT wrapped in a click handler \u2014 teaching tips are shown/hidden programmatically (e.g. after a first-run check), never by clicking their own target.Detached ( omitted) \u2014 for a tip that is not pointing at any particular control (e.g. a general new-features announcement). No beak is drawn, and position is set by relative to the viewport (defaults to bottom-center) via instead of an anchor-based overlay.", "parameters": [ { "name": "ActionContent", @@ -4498,11 +4498,21 @@ "kind": "method", "type": "Guid" }, + { + "name": "Show", + "kind": "method", + "type": "Guid" + }, { "name": "ShowDetached", "kind": "method", "type": "Guid" }, + { + "name": "Update", + "kind": "method", + "type": "Boolean" + }, { "name": "Update", "kind": "method", @@ -4510,6 +4520,18 @@ } ] }, + { + "name": "OverlayAnchorAlignment", + "fullName": "FluentKit.Overlay.OverlayAnchorAlignment", + "kind": "enum", + "genericParameters": [], + "values": [ + "Adjacent", + "AnchorStart" + ], + "summary": "Determines where an anchored overlay starts on its placement axis.", + "members": [] + }, { "name": "OverlayEntry", "fullName": "FluentKit.Overlay.OverlayEntry", @@ -4600,6 +4622,28 @@ "summary": "Preferred placement relative to the anchor element. The JS interop layer (overlay-interop.js) flips this to the opposite side when there isn\u0027t room in the viewport \u2014 callers get the flipped result back, they don\u0027t need to reason about collision themselves.", "members": [] }, + { + "name": "OverlayPositioningOptions", + "fullName": "FluentKit.Overlay.OverlayPositioningOptions", + "kind": "class", + "genericParameters": [], + "values": [], + "summary": "Optional positioning refinements for an anchored overlay. Existing overlay calls use and a zero offset.", + "members": [ + { + "name": "Alignment", + "kind": "property", + "type": "FluentKit.Overlay.OverlayAnchorAlignment", + "summary": "How the overlay aligns to its anchor before is applied." + }, + { + "name": "MainAxisOffset", + "kind": "property", + "type": "Double", + "summary": "Additional pixel offset on the placement axis. For an overlay this is measured from the anchor\u0027s leading edge." + } + ] + }, { "name": "OverlayScreenPlacement", "fullName": "FluentKit.Overlay.OverlayScreenPlacement", @@ -4654,11 +4698,21 @@ "kind": "method", "type": "Guid" }, + { + "name": "Show", + "kind": "method", + "type": "Guid" + }, { "name": "ShowDetached", "kind": "method", "type": "Guid" }, + { + "name": "Update", + "kind": "method", + "type": "Boolean" + }, { "name": "Update", "kind": "method", diff --git a/docs/reference/api.md b/docs/reference/api.md index b32fc2a..ab2d0d1 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -22,7 +22,7 @@ Category: **Effects** · Sample: `/Effects/Acrylic` ### `FluentAutoSuggestBox` Category: **Composite** · Sample: `/Composite/AutoSuggestBox` -Mirrors WinUI's AutoSuggestBox / fluent-svelte's AutoSuggestBox.svelte — a free-typing TextBox with a live-filtered suggestion dropdown underneath, distinct from ComboBox's editable mode in that the typed text is NOT required to match any item: is always whatever the user typed, fires with that raw text on Enter with no suggestion highlighted, and picking a suggestion (click, or Enter while one IS highlighted) fires separately and just replaces Text with the suggestion's Name — there's no persistent "selected value" the way ComboBox has Value. Composes FluentTextBox (same "don't reimplement input chrome" rule NumberBox follows) and, like ComboBox/ContentDialog/NumberBox's Expanded popout, stays self-contained/absolutely positioned rather than going through IOverlayService — the dropdown needs to be exactly the trigger's width, same reasoning as ComboBox's own dropdown. +A free-typing text box with live suggestions. The suggestion list is rendered through so it is not constrained by labels or acrylic ancestors. Generic parameters: `TValue` @@ -30,16 +30,16 @@ Generic parameters: `TValue` | --- | --- | --- | --- | --- | --- | | `AdditionalAttributes` | `IReadOnlyDictionary` | no | no | no | | | `Disabled` | `Boolean` | no | no | no | | -| `EnableSearchIcon` | `Boolean` | no | no | no | When true, shows a search icon on the right of the box (via TextBox's Buttons slot). Once there's text in the box, that icon swaps to a "clear" icon that empties Text on click — same idea as the search field on the demo TextBox above, just wired in as an opt-in here. | -| `Filter` | `Func, Boolean>` | no | no | no | Optional custom match predicate over (typed text, candidate item). Defaults to a case-insensitive substring match against — deliberately "contains" rather than ComboBox's "starts-with", since AutoSuggestBox is meant for search-as-you-type against arbitrary positions in the name, not prefix completion. | +| `EnableSearchIcon` | `Boolean` | no | no | no | Whether to display the search/clear affordance. | +| `Filter` | `Func, Boolean>` | no | no | no | Optional predicate used to filter suggestions from the entered text. | | `Header` | `String` | no | no | no | | -| `ItemTemplate` | `RenderFragment>` | no | no | no | Optional custom row content for each suggestion. Falls back to a plain @item.Name, same default ComboBox's ItemTemplate uses. | +| `ItemTemplate` | `RenderFragment>` | no | no | no | Optional custom content for each suggestion row. | | `Items` | `IReadOnlyList>` | no | no | no | | -| `MaxSuggestions` | `Int32` | no | no | no | Upper bound on how many matches are shown at once. | +| `MaxSuggestions` | `Int32` | no | no | no | Maximum number of matching suggestions displayed at once. | | `Placeholder` | `String` | no | no | no | | -| `QuerySubmitted` | `EventCallback` | no | no | no | Fired on Enter when no suggestion is highlighted — the raw typed text is the query, same as WinUI's QuerySubmitted with a null ChosenSuggestion. | -| `SuggestionChosen` | `EventCallback>` | no | no | no | Fired when the user picks a suggestion — click, or Enter while one is highlighted via arrow keys. Text is set to the suggestion's Name as part of the same interaction. | -| `Text` | `String` | no | no | yes | The raw typed text. Two-way bindable — always reflects what's in the box, never snapped back to a suggestion's Name unless the user actually picks one. | +| `QuerySubmitted` | `EventCallback` | no | no | no | Raised when Enter submits text without a highlighted suggestion. | +| `SuggestionChosen` | `EventCallback>` | no | no | no | Raised after a suggestion is selected. | +| `Text` | `String` | no | no | yes | The raw typed text. Two-way bindable. | | `TextChanged` | `EventCallback` | no | no | no | | ### `FluentButton` @@ -168,7 +168,7 @@ Mirrors WinUI's CheckBox, including the three-state (indeterminate) mode used fo ### `FluentComboBox` Category: **Composite** · Sample: `/Composite/ComboBox` -Mirrors fluent-svelte's ComboBox.svelte (dropdown list with an optional editable/searchable text-box mode). Two render modes off : - false (default): a FluentButton-shaped trigger showing the selected item's Name, opens a positioned
    of ComboBoxItem-equivalent
  • rows on click. - true: a FluentTextBox-shaped trigger the user can type into, filtering/matching items by Name prefix (case-insensitive), same as fluent-svelte's handleInput/searchValue flow. Unlike FluentMenuFlyout, this does NOT go through IOverlayService/FluentOverlayHost — the dropdown list needs to be exactly the trigger's width and grow from a specific list index (the menuOffset/menuGrowDirection choreography below), which is easiest to keep as a plain absolutely-positioned child of the component's own root (same as ComboBox.scss's own `position: relative` + `.combo-box-dropdown { position: absolute }`). A portal would only add indirection for zero benefit here since there's no anchor-to-elsewhere requirement. +A WinUI-style selectable list with optional editable search. Its dropdown is rendered through so it can escape labels, clipping ancestors, and backdrop-filter roots while retaining the trigger's width and selected-row alignment. Generic parameters: `TValue` @@ -176,13 +176,13 @@ Generic parameters: `TValue` | --- | --- | --- | --- | --- | --- | | `AdditionalAttributes` | `IReadOnlyDictionary` | no | no | no | | | `Disabled` | `Boolean` | no | no | no | | -| `Editable` | `Boolean` | no | no | no | Whether the ComboBox can be searched/typed into (fluent-svelte's `editable` prop). | -| `ItemTemplate` | `RenderFragment>` | no | no | no | Optional custom row content for each dropdown item. Falls back to a plain @item.Name when not supplied — same "text unless you say otherwise" default as fluent-svelte's own ComboBoxItem (which likewise only exposes a default slot, no separate templating concept). Wraps in the same .fluent-combo-box-item
  • (selection bar, hover/active states, focus ring) regardless of what you render inside — only the row's inner content is replaced, not its interaction chrome. | +| `Editable` | `Boolean` | no | no | no | Whether the ComboBox permits typing and prefix matching. | +| `ItemTemplate` | `RenderFragment>` | no | no | no | Optional custom content for each dropdown row. | | `Items` | `IReadOnlyList>` | no | no | no | | | `Placeholder` | `String` | no | no | no | | -| `SearchValue` | `String` | no | no | yes | Current text in the search box. Only meaningful when is true. | +| `SearchValue` | `String` | no | no | yes | Current editable search text. | | `SearchValueChanged` | `EventCallback` | no | no | no | | -| `SelectedTemplate` | `RenderFragment>` | no | no | no | Optional custom content for the closed/collapsed trigger label in non-editable ( = false) mode, e.g. to show an icon + name instead of plain text once something is selected. Only used when there IS a selection — the placeholder text always renders plain regardless. Has no effect in Editable mode, since that trigger is a real and can only ever hold plain text. | +| `SelectedTemplate` | `RenderFragment>` | no | no | no | Optional custom content for the selected non-editable trigger label. | | `Value` | `TValue` | no | no | yes | Currently selected item's value. Two-way bindable. | | `ValueChanged` | `EventCallback` | no | no | no | | @@ -771,7 +771,7 @@ Mirrors WinUI's SplitButton — a primary action region (fires immediately, like ### `FluentTeachingTip` Category: **Composite** · Sample: `/Composite/TeachingTip` -Mirrors WinUI's TeachingTip — a persistent, anchor-relative callout (title/subtitle, an optional row of action buttons, and an explicit close button) used for contextual "here's what this does" UI, as opposed to Flyout's generic content or ToolTip's hover-only hint. Structurally close to Flyout: built directly on IOverlayService/FluentOverlayHost with the same programmatic--binding shape as , and — like every other flyout/menu/tooltip — relies entirely on OverlaySurface's own default chrome (background, backdrop-filter: blur(60px), border, shadow) for its blur; it does NOT pass bare. One real difference from Flyout: it adds its own pointer/beak toward the anchor in targeted mode (WinUI teaching tips always draw one there — see the beak's own matching background/border in FluentTeachingTip.razor.css), and it defaults to false — a teaching tip is meant to stay up until the person reads it and dismisses it via the explicit close button, not disappear the moment they click elsewhere. Two placement modes, chosen automatically by whether is supplied: Targeted ( set) — is the anchor/target element the tip points its beak at, positioned via (top/bottom/left/right of the anchor), same anchor-measurement path as Flyout/MenuFlyout. Unlike Flyout/MenuFlyout it is NOT wrapped in a click handler — teaching tips are shown/hidden programmatically (e.g. after a first-run check), never by clicking their own target.Detached ( omitted) — for a tip that is not pointing at any particular control (e.g. a general new-features announcement). No beak is drawn, and position is set by relative to the viewport (defaults to bottom-center) via instead of the anchor-based overload. +Mirrors WinUI's TeachingTip — a persistent, anchor-relative callout (title/subtitle, an optional row of action buttons, and an explicit close button) used for contextual "here's what this does" UI, as opposed to Flyout's generic content or ToolTip's hover-only hint. Structurally close to Flyout: built directly on IOverlayService/FluentOverlayHost with the same programmatic--binding shape as , and — like every other flyout/menu/tooltip — relies entirely on OverlaySurface's own default chrome (background, backdrop-filter: blur(60px), border, shadow) for its blur; it does NOT pass bare. One real difference from Flyout: it adds its own pointer/beak toward the anchor in targeted mode (WinUI teaching tips always draw one there — see the beak's own matching background/border in FluentTeachingTip.razor.css), and it defaults to false — a teaching tip is meant to stay up until the person reads it and dismisses it via the explicit close button, not disappear the moment they click elsewhere. Two placement modes, chosen automatically by whether is supplied: Targeted ( set) — is the anchor/target element the tip points its beak at, positioned via (top/bottom/left/right of the anchor), same anchor-measurement path as Flyout/MenuFlyout. Unlike Flyout/MenuFlyout it is NOT wrapped in a click handler — teaching tips are shown/hidden programmatically (e.g. after a first-run check), never by clicking their own target.Detached ( omitted) — for a tip that is not pointing at any particular control (e.g. a general new-features announcement). No beak is drawn, and position is set by relative to the viewport (defaults to bottom-center) via instead of an anchor-based overlay. | Parameter | Type | Cascading | RenderFragment | Bindable | Description | @@ -937,11 +937,13 @@ Category: **Infrastructure** · Sample: `/Design/Colors` | `TimeClockIdentifier` | enum | `TwelveHour`, `TwentyFourHour` | Mirrors WinUI TimePicker/TimePickerFlyout's ClockIdentifier property — which in WinUI is a loosely-typed string ("12HourClock" / "24HourClock") since it's really a Windows.Globalization clock system identifier. Re-exposed here as a proper enum (FluentKit convention — see , ) since a string parameter with exactly two legal values isn't worth the stringly-typed footgun for a net-new (non-ported) control that owes WinUI fidelity, not fluent-svelte source compatibility. | | `AcrylicKind` | enum | `Base`, `Thin` | Approximates WinUI's in-app Acrylic (AcrylicBrush / DesktopAcrylicBackdrop). This is a genuinely different material from Mica (see FluentMicaPanel) — Acrylic is TRANSLUCENT and reacts live to whatever's actually rendered behind it (in-app content scrolling underneath, other panels, etc.), not a static sample of the desktop wallpaper. On the web the correct primitive for that is CSS `backdrop-filter`, which live-samples the page content behind the element and blurs it in real time. This component used to be (incorrectly) named FluentMicaPanel — that name now belongs to the actual wallpaper-based static-blur material, which needed a from-scratch implementation instead. | | `MicaVariant` | enum | `Base`, `BaseAlt` | Approximates WinUI's Mica material — rebuilt from scratch against the actual recipe in microsoft-ui-xaml's SystemBackdropComponentInternal::BuildMicaEffectBrush (dev/dll/SystemBackdrop/SystemBackdropBrushFactory.cpp). This is NOT the same thing as Acrylic (see FluentAcrylicBrush) — Acrylic live-blurs whatever's actually behind it via backdrop-filter; Mica does not. PERFORMANCE NOTE: this used to run the blur (CSS `filter`) and the two blend passes (CSS `mix-blend-mode`) live, every repaint. That's expensive for a large/fixed-viewport element and was the source of noticeable jank. It's now baked ONCE per (image, variant, theme) into a static raster via an offscreen (see wwwroot/Effects/mica-interop.js) and cached — after that, rendering Mica is just displaying a static image, no different in cost from any other photo background. The recipe itself (blur -> luminosity blend -> color blend) is unchanged, just baked ahead of time instead of recomputed on every paint. 1. Start from a heavily blurred copy of the desktop wallpaper (ICompositorWithBlurredWallpaper- BackdropBrush — an OS compositor API that samples the wallpaper only, not other windows). Browsers can't reach the real wallpaper, so stands in for it — supply any image (ideally the user's own wallpaper file) and it's blurred the same way. 2. LUMINOSITY blend pass: blend TintColor (alpha = LuminosityOpacity) over the blurred wallpaper using true "luminosity" blend mode — keeps the wallpaper's hue/saturation but replaces its per-pixel luminosity with the tint's. Mica's defaults set LuminosityOpacity to 1.0, so this pass ends up governing almost the entire final look. 3. COLOR blend pass: blend TintColor (alpha = TintOpacity) over the result of step 2 using true "color" blend mode — keeps the TINT's hue/saturation but takes luminosity from step 2's result. This is what gives Mica its "muted, personalized" gradient-ish look: the wallpaper's light/dark shape survives, but its color is replaced by the theme tint. With no BackgroundImageUrl supplied (or a format the browser can't decode, e.g. HEIC outside Safari), this degrades to WinUI's own documented fallback: a flat SolidBackgroundFillColorBase (Alt) fill — the same state real Mica falls back to under RDP, low-power GPUs, or Battery Saver. | -| `IOverlayService` | interface | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `ShowDetached`, `Update` | Register as Scoped. Components never position themselves — they call Show() with their own ElementReference as the anchor and let FluentOverlayHost (mounted once, near the app root) render the content at a fixed top-level position. This is the Blazor answer to React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. | +| `IOverlayService` | interface | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | Register as Scoped. Components never position themselves — they call Show() with their own ElementReference as the anchor and let FluentOverlayHost (mounted once, near the app root) render the content at a fixed top-level position. This is the Blazor answer to React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. | +| `OverlayAnchorAlignment` | enum | `Adjacent`, `AnchorStart` | Determines where an anchored overlay starts on its placement axis. | | `OverlayEntry` | class | `Anchor`, `Bare`, `ComputedStyle`, `Content`, `Id`, `IsClosing`, `IsDetached`, `LightDismiss`, `MatchAnchorWidth`, `PreferredPlacement`, `ScrollAnchorIntoView`, `WatchAnchorRemoved` | | | `OverlayPlacement` | enum | `Bottom`, `Left`, `Right`, `Top` | Preferred placement relative to the anchor element. The JS interop layer (overlay-interop.js) flips this to the opposite side when there isn't room in the viewport — callers get the flipped result back, they don't need to reason about collision themselves. | +| `OverlayPositioningOptions` | class | `Alignment`, `MainAxisOffset` | Optional positioning refinements for an anchored overlay. Existing overlay calls use and a zero offset. | | `OverlayScreenPlacement` | enum | `BottomCenter`, `BottomLeft`, `BottomRight`, `Center`, `TopCenter`, `TopLeft`, `TopRight` | Placement for a "detached" overlay that has no anchor element to position relative to (see ) — positioned relative to the viewport instead, via a plain CSS `position: fixed` computed synchronously in (unlike the anchor-based path, this needs no JS measurement round-trip, since there's no anchor rect to measure — the overlay can be positioned correctly on its very first render instead of one frame later). | -| `OverlayService` | class | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `ShowDetached`, `Update` | | +| `OverlayService` | class | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | | | `ButtonVariant` | enum | `Accent`, `Hyperlink`, `Standard`, `Subtle` | Mirrors WinUI's Button / AccentButton (via the IsAccent-equivalent style) / HyperlinkButton split. "Subtle" matches what WinUI calls a SubtleButton-styled Button (no border, transparent by default). | | `FluentIconNames` | class | | Hand-picked, ergonomic aliases for the most commonly used Fluent System Icons glyphs (e.g. Name="@FluentIconNames.Home"). For the full, exhaustive set of ~9,600 glyphs (auto-generated from wwwroot/Icons/FluentSystemIcons-Regular.css by tools/generate-icon-names.py), see the other partial of this class in FluentIconNames.g.cs, e.g. FluentIconNames.Beaker20. | | `FluentSliderOrientation` | enum | `Horizontal`, `Vertical` | | diff --git a/samples/FluentKit.Sample.Server/Components/App.razor b/samples/FluentKit.Sample.Server/Components/App.razor new file mode 100644 index 0000000..338948a --- /dev/null +++ b/samples/FluentKit.Sample.Server/Components/App.razor @@ -0,0 +1,27 @@ + + + + + + FluentKit Gallery — Interactive Server + + + + + + + + + + + + + diff --git a/samples/FluentKit.Sample.Server/Components/Routes.razor b/samples/FluentKit.Sample.Server/Components/Routes.razor new file mode 100644 index 0000000..eef6289 --- /dev/null +++ b/samples/FluentKit.Sample.Server/Components/Routes.razor @@ -0,0 +1,9 @@ + + + + + + +

    Not found.

    +
    +
    diff --git a/samples/FluentKit.Sample.Server/Components/_Imports.razor b/samples/FluentKit.Sample.Server/Components/_Imports.razor new file mode 100644 index 0000000..1495e49 --- /dev/null +++ b/samples/FluentKit.Sample.Server/Components/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Routing +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using FluentKit.Sample.Shared +@using FluentKit.Sample.Shared.Shared diff --git a/samples/FluentKit.Sample.Server/FluentKit.Sample.Server.csproj b/samples/FluentKit.Sample.Server/FluentKit.Sample.Server.csproj new file mode 100644 index 0000000..48dd631 --- /dev/null +++ b/samples/FluentKit.Sample.Server/FluentKit.Sample.Server.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/samples/FluentKit.Sample.Server/Program.cs b/samples/FluentKit.Sample.Server/Program.cs new file mode 100644 index 0000000..4782191 --- /dev/null +++ b/samples/FluentKit.Sample.Server/Program.cs @@ -0,0 +1,30 @@ +using FluentKit.Overlay; +using FluentKit.Sample.Server.Components; +using FluentKit.Sample.Server.Services; +using FluentKit.Sample.Shared; +using FluentKit.Sample.Shared.Services; +using FluentKit.Sample.Shared.Shared; +using FluentKit.Theming; + +var builder = WebApplication.CreateBuilder(args); +// Ensure Rider/debug launches load this project's RCL static-web-asset manifest as well as +// published deployments, rather than returning empty matches for _content/* files. +builder.WebHost.UseStaticWebAssets(); + +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +app.UseAntiforgery(); +// MapStaticAssets supplies fingerprinted production endpoints. StaticFiles also serves the +// development static-web-asset file provider that Rider uses for project and RCL assets. +app.UseStaticFiles(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddAdditionalAssemblies(typeof(MainLayout).Assembly) + .AddInteractiveServerRenderMode(); +app.Run(); diff --git a/samples/FluentKit.Sample.Server/Services/UnavailableRazorPlaygroundCompiler.cs b/samples/FluentKit.Sample.Server/Services/UnavailableRazorPlaygroundCompiler.cs new file mode 100644 index 0000000..607248e --- /dev/null +++ b/samples/FluentKit.Sample.Server/Services/UnavailableRazorPlaygroundCompiler.cs @@ -0,0 +1,15 @@ +using FluentKit.Sample.Shared.Services; + +namespace FluentKit.Sample.Server.Services; + +/// The gallery's in-browser compiler is intentionally unavailable in the Server host. +/// The rest of the shared gallery, including its interactive overlay regressions, remains usable. +public sealed class UnavailableRazorPlaygroundCompiler : IRazorPlaygroundCompiler +{ + public bool IsWarm => true; + + public Task WarmUpAsync() => Task.CompletedTask; + + public Task CompileAsync(string razorSource) => Task.FromResult( + RazorPlaygroundResult.Fail(pipelineError: "The live Razor playground is available in the WebAssembly gallery only.")); +} diff --git a/samples/FluentKit.Sample.Shared/Pages/Composite/AutoSuggestBoxPage.razor b/samples/FluentKit.Sample.Shared/Pages/Composite/AutoSuggestBoxPage.razor index a981955..da43ef2 100644 --- a/samples/FluentKit.Sample.Shared/Pages/Composite/AutoSuggestBoxPage.razor +++ b/samples/FluentKit.Sample.Shared/Pages/Composite/AutoSuggestBoxPage.razor @@ -32,6 +32,18 @@ + + + +
    + +
    +
    +
    @code { diff --git a/samples/FluentKit.Sample.Shared/Pages/Composite/ComboBoxPage.razor b/samples/FluentKit.Sample.Shared/Pages/Composite/ComboBoxPage.razor index 05a8bf9..2b09fc3 100644 --- a/samples/FluentKit.Sample.Shared/Pages/Composite/ComboBoxPage.razor +++ b/samples/FluentKit.Sample.Shared/Pages/Composite/ComboBoxPage.razor @@ -46,6 +46,17 @@ + + + +
    + +
    +
    +
    @code { diff --git a/samples/FluentKit.Sample.Shared/wwwroot/gallery.css b/samples/FluentKit.Sample.Shared/wwwroot/gallery.css index 15e41fa..86873f9 100644 --- a/samples/FluentKit.Sample.Shared/wwwroot/gallery.css +++ b/samples/FluentKit.Sample.Shared/wwwroot/gallery.css @@ -650,3 +650,20 @@ html[data-theme="dark"] .fluent-nav-view-content { background: rgba(26, 26, 26, 0.3) !important; } +/* Regression fixture for portalled dropdowns. The filtered ancestor matches the compositing + boundary that breaks an inline acrylic popup; native labels exercise browser label activation. */ +.overlay-regression-surface { + max-width: 320px; + padding: var(--spacing-l); + border: 1px solid var(--card-stroke-default); + border-radius: var(--overlay-corner-radius); + background: color-mix(in srgb, var(--card-background-fill) 75%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.overlay-regression-label { + display: grid; + gap: var(--spacing-xs); + color: var(--text-fill-color-secondary); +} diff --git a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor index da435d8..b1c3e2e 100644 --- a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor +++ b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor @@ -2,7 +2,8 @@ @typeparam TValue @using FluentKit.Primitives -
    @@ -12,7 +13,7 @@ Disabled="@Disabled" Value="@Text" ValueChanged="OnTextChangedAsync" - @attributes="AdditionalAttributes">@if (EnableSearchIcon) + @attributes="TextBoxAttributes">@if (EnableSearchIcon) { @if (!string.IsNullOrEmpty(Text)) { @@ -27,33 +28,34 @@ } } +
    - @if (!Disabled && (_open || _closing) && DisplayMatches.Count > 0) - { -
    -
    - -
      - @for (var i = 0; i < DisplayMatches.Count; i++) +@code { + // Rendered by FluentOverlayHost; keeping it as a Razor fragment preserves component-scoped CSS. + private RenderFragment RenderDropdown => @
        + @for (var i = 0; i < DisplayMatches.Count; i++) + { + var item = DisplayMatches[i]; + var isHighlighted = i == _highlightedIndex; +
      • + @if (ItemTemplate is not null) { - var item = DisplayMatches[i]; - var isHighlighted = i == _highlightedIndex; -
      • - @if (ItemTemplate is not null) - { - @ItemTemplate(item) - } - else - { - @item.Name - } -
      • + @ItemTemplate(item) } -
      -
    - } - + else + { + @item.Name + } +
  • + } +
; +} diff --git a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs index 72921ce..5f8bd90 100644 --- a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs +++ b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs @@ -1,3 +1,4 @@ +using FluentKit.Overlay; using FluentKit.Interop; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -6,97 +7,74 @@ namespace FluentKit.Composite; /// -/// Mirrors WinUI's AutoSuggestBox / fluent-svelte's AutoSuggestBox.svelte — a free-typing TextBox -/// with a live-filtered suggestion dropdown underneath, distinct from ComboBox's editable mode in -/// that the typed text is NOT required to match any item: is always whatever -/// the user typed, fires with that raw text on Enter with no -/// suggestion highlighted, and picking a suggestion (click, or Enter while one IS highlighted) -/// fires separately and just replaces Text with the suggestion's -/// Name — there's no persistent "selected value" the way ComboBox has Value. -/// -/// Composes FluentTextBox (same "don't reimplement input chrome" rule NumberBox follows) and, -/// like ComboBox/ContentDialog/NumberBox's Expanded popout, stays self-contained/absolutely -/// positioned rather than going through IOverlayService — the dropdown needs to be exactly the -/// trigger's width, same reasoning as ComboBox's own dropdown. +/// A free-typing text box with live suggestions. The suggestion list is rendered through +/// so it is not constrained by labels or acrylic ancestors. /// public partial class FluentAutoSuggestBox : ComponentBase, IAsyncDisposable { + [Inject] private IOverlayService OverlayService { get; set; } = default!; [Inject] private IJSRuntime JS { get; set; } = default!; - /// The raw typed text. Two-way bindable — always reflects what's in the box, never - /// snapped back to a suggestion's Name unless the user actually picks one. + /// The raw typed text. Two-way bindable. [Parameter] public string? Text { get; set; } - [Parameter] public EventCallback TextChanged { get; set; } - [Parameter, EditorRequired] public IReadOnlyList> Items { get; set; } = []; - - /// - /// Optional custom match predicate over (typed text, candidate item). Defaults to a - /// case-insensitive substring match against — - /// deliberately "contains" rather than ComboBox's "starts-with", since AutoSuggestBox is meant - /// for search-as-you-type against arbitrary positions in the name, not prefix completion. - /// + /// Optional predicate used to filter suggestions from the entered text. [Parameter] public Func, bool>? Filter { get; set; } - - /// Upper bound on how many matches are shown at once. + /// Maximum number of matching suggestions displayed at once. [Parameter] public int MaxSuggestions { get; set; } = 8; - - /// Fired when the user picks a suggestion — click, or Enter while one is highlighted - /// via arrow keys. Text is set to the suggestion's Name as part of the same interaction. + /// Raised after a suggestion is selected. [Parameter] public EventCallback> SuggestionChosen { get; set; } - - /// Fired on Enter when no suggestion is highlighted — the raw typed text is the - /// query, same as WinUI's QuerySubmitted with a null ChosenSuggestion. + /// Raised when Enter submits text without a highlighted suggestion. [Parameter] public EventCallback QuerySubmitted { get; set; } - - /// Optional custom row content for each suggestion. Falls back to a plain - /// <span>@item.Name</span>, same default ComboBox's ItemTemplate uses. + /// Optional custom content for each suggestion row. [Parameter] public RenderFragment>? ItemTemplate { get; set; } - [Parameter] public string? Header { get; set; } - - /// - /// When true, shows a search icon on the right of the box (via TextBox's Buttons slot). Once - /// there's text in the box, that icon swaps to a "clear" icon that empties Text on click — - /// same idea as the search field on the demo TextBox above, just wired in as an opt-in here. - /// + /// Whether to display the search/clear affordance. [Parameter] public bool EnableSearchIcon { get; set; } - [Parameter] public string? Placeholder { get; set; } - [Parameter] public bool Disabled { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } + private readonly string _dropdownId = $"fluent-autosuggest-{Guid.NewGuid():N}"; private bool _open; private int _highlightedIndex = -1; - - // Same enter/exit animation shape as OverlaySurface (see its own comments for the full - // rationale), just self-contained here instead of going through IOverlayService — the dropdown - // is a plain `@if`-conditional
    , not an overlay entry, since (like ComboBox) it needs to be - // exactly the trigger's width rather than independently positioned/measured. - private bool _closing; - // Bumped on every open/close request; a pending FinishClosingAsync only applies its own result if - // the generation it captured is still current, so a rapid close-then-reopen (e.g. arrow-key-close - // then immediately typing again) can't have a stale exit-animation wait clobber a state the user - // has already moved past. - private int _closeGeneration; private List> _lastMatches = []; + private ElementReference _rootElement; private ElementReference _dropdownElement; + private Guid? _overlayId; private JsModuleLifetime? _interop; - - private List> DisplayMatches => _open ? Matches : _lastMatches; - - // Guards observeAutoHeight from being (re)started on every keystroke re-render while already - // open — it only needs wiring up once per mount, right after the freshly-created
      actually - // lands in the DOM (OnAfterRenderAsync), same as OverlaySurface's own _positioned guard. private bool _heightObserved; private int _disposed; - private JsModuleLifetime Interop => _interop ??= new( - JS, "./_content/FluentKit/Overlay/overlay-interop.js"); + private string DropdownId => _dropdownId; + private IReadOnlyDictionary TextBoxAttributes + { + get + { + var attributes = new Dictionary(StringComparer.Ordinal) + { + ["role"] = "combobox", + ["aria-autocomplete"] = "list", + ["aria-haspopup"] = "listbox", + ["aria-controls"] = DropdownId, + ["aria-expanded"] = _open.ToString().ToLowerInvariant() + }; + + if (AdditionalAttributes is not null) + { + foreach (var attribute in AdditionalAttributes) + { + attributes[attribute.Key] = attribute.Value; + } + } + + return attributes; + } + } + private List> DisplayMatches => _open ? Matches : _lastMatches; private List> Matches { @@ -108,21 +86,33 @@ private List> Matches } var predicate = Filter ?? DefaultFilter; - return Items.Where(i => predicate(Text, i)).Take(MaxSuggestions).ToList(); + return Items.Where(item => predicate(Text, item)).Take(MaxSuggestions).ToList(); } } private static bool DefaultFilter(string text, AutoSuggestBoxItem item) => item.Name.Contains(text, StringComparison.OrdinalIgnoreCase); - protected override async Task OnAfterRenderAsync(bool firstRender) + private JsModuleLifetime Interop => _interop ??= new(JS, "./_content/FluentKit/Overlay/overlay-interop.js"); + + protected override void OnInitialized() => OverlayService.Changed += OnOverlayServiceChanged; + + protected override void OnParametersSet() => SynchronizeDropdown(); + + protected override Task OnAfterRenderAsync(bool firstRender) { - if (Volatile.Read(ref _disposed) != 0 || !_open || _heightObserved) + if (_overlayId is { } id) { - return; + OverlayService.Update(id, _rootElement, OverlayPlacement.Bottom, lightDismiss: true, + matchAnchorWidth: true); } - if (!await Interop.EnsureModuleAsync()) + return ObserveHeightAsync(); + } + + private async Task ObserveHeightAsync() + { + if (Volatile.Read(ref _disposed) != 0 || !_open || _heightObserved || !await Interop.EnsureModuleAsync()) { return; } @@ -133,52 +123,99 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private void OnOverlayServiceChanged() + { + if (_overlayId is not { } id) + { + return; + } + + var entry = OverlayService.Active.FirstOrDefault(candidate => candidate.Id == id); + if (entry is null || entry.IsClosing) + { + _lastMatches = Matches; + _overlayId = null; + if (_open) + { + _open = false; + _ = InvokeAsync(StateHasChanged); + } + } + } + + private void SynchronizeDropdown() + { + if (Disabled || Matches.Count == 0) + { + CloseDropdown(); + return; + } + + if (_open) + { + RefreshOpenDropdown(); + } + } + private void OpenDropdown() { - _closeGeneration++; + if (Disabled || _open || Matches.Count == 0) + { + return; + } + _open = true; - _closing = false; + _overlayId = OverlayService.Show(RenderDropdown, _rootElement, OverlayPlacement.Bottom, + lightDismiss: true, matchAnchorWidth: true); + StateHasChanged(); } private void CloseDropdown() { - if (!_open) + if (!_open && _overlayId is null) { return; } - // Snapshot now, before Text/Items can change further — e.g. ClearAsync blanks Text in the - // same call that closes the dropdown, which would make the live Matches property go empty - // immediately and the list would vanish instead of fading out with its last contents. _lastMatches = Matches; + StopHeightObservation(); + var overlayId = _overlayId; + _overlayId = null; _open = false; - _closing = true; - var generation = ++_closeGeneration; - ObserveBackgroundTask(FinishClosingAsync(generation)); + if (overlayId is { } id) + { + OverlayService.Close(id); + } + + StateHasChanged(); } - private async Task FinishClosingAsync(int generation) + private void RefreshOpenDropdown() { - if (Volatile.Read(ref _disposed) != 0) + if (_overlayId is not { } id) { return; } - if (!await Interop.EnsureModuleAsync()) + OverlayService.RefreshContent(id); + } + + private void StopHeightObservation() + { + if (!_heightObserved) { return; } - await Interop.InvokeVoidAsync("waitForExitAnimation", _dropdownElement); - await Interop.InvokeVoidAsync("unobserveAutoHeight", _dropdownElement); + _heightObserved = false; + ObserveBackgroundTask(StopHeightObservationAsync()); + } - // Only the most recent close request gets to actually unmount the list — if the user reopened - // (or closed again) while this was waiting, that newer request already owns _closing/_open. - if (Volatile.Read(ref _disposed) == 0 && generation == _closeGeneration && _closing) + private async Task StopHeightObservationAsync() + { + if (_interop is not null && await _interop.EnsureModuleAsync()) { - _closing = false; - _heightObserved = false; - StateHasChanged(); + await _interop.InvokeVoidAsync("unobserveAutoHeight", _dropdownElement); } } @@ -202,58 +239,24 @@ exception is not JSDisconnectedException && TaskScheduler.Default); } - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _closeGeneration++; - _open = false; - _closing = false; - - if (_interop is null) - { - return; - } - - if (_heightObserved) - { - await _interop.DisposeAsync(("unobserveAutoHeight", new object?[] { _dropdownElement })); - } - else - { - await _interop.DisposeAsync(); - } - - _heightObserved = false; - } - private async Task OnTextChangedAsync(string? text) { Text = text; _highlightedIndex = -1; - await TextChanged.InvokeAsync(Text); - if (Matches.Count > 0) { OpenDropdown(); + RefreshOpenDropdown(); } else { CloseDropdown(); } - } - private void OnFocusIn() - { - if (!Disabled && Matches.Count > 0) - { - OpenDropdown(); - } + await TextChanged.InvokeAsync(Text); } + private void OnFocusIn() => OpenDropdown(); private void OnFocusLost() => CloseDropdown(); private async Task ClearAsync() @@ -297,6 +300,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) OpenDropdown(); _highlightedIndex = _highlightedIndex + 1 >= matches.Count ? 0 : _highlightedIndex + 1; + RefreshOpenDropdown(); return; case "ArrowUp": @@ -307,6 +311,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) OpenDropdown(); _highlightedIndex = _highlightedIndex - 1 < 0 ? matches.Count - 1 : _highlightedIndex - 1; + RefreshOpenDropdown(); return; case "Enter": @@ -322,4 +327,33 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) return; } } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (OverlayService is not null) + { + OverlayService.Changed -= OnOverlayServiceChanged; + if (_overlayId is { } id) + { + OverlayService.Close(id); + } + } + + if (_interop is not null) + { + if (_heightObserved) + { + await _interop.DisposeAsync(("unobserveAutoHeight", new object?[] { _dropdownElement })); + } + else + { + await _interop.DisposeAsync(); + } + } + } } diff --git a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css index ecafdb6..bdd1c75 100644 --- a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css +++ b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css @@ -1,165 +1,26 @@ -/* Ported from fluent-svelte AutoSuggestBox's flyout list, reusing the same dropdown/item recipe - ComboBox's editable dropdown already established (--flyout-*, --subtle-fill-color-*, --control-*), - with a "highlighted" state (keyboard arrow position) standing in for ComboBox's "selected". */ - .fluent-autosuggest-root { - position: relative; display: block; width: 100%; max-width: 300px; } -/* Outer box: absolutely positioned/sized/animated, exactly what overlay-interop.js's - observeAutoHeight/waitForExitAnimation target via @ref="_dropdownElement" (see - FluentAutoSuggestBox.razor). Holds two children (see markup): a material div painted first, - then the actual scrolling
        painted after it — content is guaranteed above the acrylic - material by DOM/paint order, not z-index arithmetic across siblings that a backdrop-filter - boundary doesn't always honor consistently. */ -.fluent-autosuggest-dropdown { - z-index: 100; - position: absolute; - box-sizing: border-box; - inset-inline-start: 0; - inset-block-start: 100%; - margin: 0; - margin-block-start: var(--spacing-xxs); - width: 100%; - max-height: 320px; - border-radius: var(--overlay-corner-radius, var(--control-corner-radius)); - /* Clips the material's square corners/noise tile to the rounded box, and clips the inner
          's - own scroll content the same way a plain overflow:hidden card would. */ - overflow: hidden; - - /* Explicit `height` is set/kept in sync by overlay-interop.js's observeAutoHeight (see - FluentAutoSuggestBox.razor.cs's OnAfterRenderAsync) rather than left as the browser's normal - auto/intrinsic sizing — CSS can only transition between two concrete values, not to/from - `auto`, so without a script pinning it to the content's actual scrollHeight on every list-size - change, this `transition` would have nothing to animate and item-count changes would keep - snapping instantly even with everything else here in place. `max-height` above still caps the - visible/rendered size the normal way regardless of what the JS-set `height` says, so long - lists still scroll instead of growing past 320px. */ - transition: height var(--duration-normal) var(--easing-standard); - - /* Same enter/exit approach as OverlaySurface (see that file's comments for the full rationale, - including why this animates the standalone `scale`/`translate` properties instead of writing - to `transform` directly — avoids clobbering any positioning transform, even though this - particular element doesn't currently have one). A small downward-into-place translate on top - of the fade/scale reads better here than a pure scale, since the dropdown always grows out of - a fixed edge (right under the TextBox) rather than from a point that could be anywhere - on-screen the way a generic overlay's anchor can be. Uses --duration-normal (250ms), not the - buttons/menus --duration-fast (150ms) — this is a noticeably larger, content-bearing surface - appearing right under something the person is actively looking at (their own typing), so it - reads better a touch slower/more deliberate than a quick hover-feedback fade. */ - transform-origin: top center; - animation: fluent-autosuggest-enter var(--duration-normal) var(--easing-decelerate) both; - - border: 1px solid var(--flyout-border); - background-clip: padding-box; - box-shadow: var(--flyout-shadow, 0 4px 16px rgba(0, 0, 0, 0.24)); - /* no backdrop-filter support / reduced-transparency */ - background-color: var(--acrylic-fallback-color-override, var(--acrylic-fallback-fill)); -} - -@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { - .fluent-autosuggest-dropdown { - background-color: transparent; - backdrop-filter: blur(var(--acrylic-thin-blur-amount)); - -webkit-backdrop-filter: blur(var(--acrylic-thin-blur-amount)); - } - - .fluent-autosuggest-dropdown::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - pointer-events: none; - background-color: var(--acrylic-tint-color-override, rgb(var(--acrylic-tint-color))); - opacity: var(--acrylic-tint-opacity-pct-override, var(--acrylic-thin-tint-opacity)); - } -} - -.fluent-autosuggest-dropdown::after { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - pointer-events: none; - background-image: var(--noise-asset); - background-repeat: repeat; - background-size: 256px 256px; - opacity: 0.02; -} - -@media (prefers-reduced-transparency: reduce) { - .fluent-autosuggest-dropdown { - background-color: var(--acrylic-fallback-color-override, var(--acrylic-fallback-fill)) !important; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - } - .fluent-autosuggest-dropdown::before { - background-color: transparent; - opacity: 0; - } -} - -.fluent-autosuggest-dropdown-material { - display: none; -} - -/* Inner scrolling list — plain, no position/background/animation of its own. Sized to fill the - outer box exactly (100%/100%), scrolls independently of the material sibling painted behind it. */ .fluent-autosuggest-dropdown-list { - position: relative; box-sizing: border-box; - width: 100%; - height: 100%; - margin: 0; + width: calc(100% + (var(--spacing-xs) * 2)); + max-height: 320px; + margin: calc(var(--spacing-xs) * -1); padding: var(--spacing-xxs); list-style: none; overflow: auto; -} - -/* Applied by FluentAutoSuggestBox.razor.cs's CloseDropdown/_closing the instant a close is - requested — see that file's FinishClosingAsync for the matching JS-animationend wait that keeps - the
            mounted long enough for this to actually play before it's removed from the DOM. */ -.fluent-autosuggest-dropdown--closing { - animation: fluent-autosuggest-exit var(--duration-normal) var(--easing-accelerate) both; -} - -@keyframes fluent-autosuggest-enter { - from { - opacity: 0; - scale: 0.94; - translate: 0 -8px; - } - to { - opacity: 1; - scale: 1; - translate: 0 0; - } -} - -@keyframes fluent-autosuggest-exit { - from { - opacity: 1; - scale: 1; - translate: 0 0; - } - to { - opacity: 0; - scale: 0.94; - translate: 0 -8px; - } + transition: height var(--duration-normal) var(--easing-standard); } .fluent-autosuggest-item { display: flex; align-items: center; gap: var(--spacing-xs); - position: relative; box-sizing: border-box; + height: 32px; margin: var(--spacing-xxs) 0; padding-inline: var(--spacing-s); border-radius: var(--control-corner-radius); @@ -167,8 +28,6 @@ color: var(--text-fill-color-primary); cursor: default; user-select: none; - height: 32px; - font-family: var(--font-family-base); font-size: var(--font-size-body); } @@ -189,17 +48,8 @@ .fluent-autosuggest-item span { flex: 1 1 auto; + max-width: 100%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - overflow: hidden; - max-width: 100%; -} - -.fluent-autosuggest-dismiss-backdrop { - position: fixed; - inset: 0; - z-index: 99; - background: transparent; - pointer-events: auto; - cursor: default; } diff --git a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor index e82df8a..8cec1b6 100644 --- a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor +++ b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor @@ -13,17 +13,19 @@ type="text" role="combobox" aria-autocomplete="both" - aria-expanded="@_open" + aria-controls="@DropdownId" + aria-expanded="@AriaExpanded" placeholder="@Placeholder" disabled="@Disabled" value="@SearchValue" @oninput="OnSearchInputAsync" - @onfocus="() => SetOpenAsync(true)" /> + @onfocus="OpenDropdown" /> } + - @if (!Disabled && Items.Count > 0 && _open) - { -
            -
            - -
              - @foreach (var item in Items) +@code { + // The fragment is invoked by FluentOverlayHost. Keeping it in Razor retains the component's + // CSS-isolation scope attributes after the render-tree portal hop. + private RenderFragment RenderDropdown => @
                + @foreach (var item in Items) + { + var isSelected = Selection is not null && EqualityComparer.Default.Equals(item.Value, Selection.Value); +
              • + @if (ItemTemplate is not null) { - var isSelected = Selection is not null && EqualityComparer.Default.Equals(item.Value, Selection.Value); -
              • - @if (ItemTemplate is not null) - { - @ItemTemplate(item) - } - else - { - @item.Name - } -
              • + @ItemTemplate(item) } -
              -
            - } - + else + { + @item.Name + } + + } +
          ; +} diff --git a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs index 41da035..467e43a 100644 --- a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs +++ b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs @@ -1,108 +1,51 @@ +using FluentKit.Overlay; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; namespace FluentKit.Composite; /// -/// Mirrors fluent-svelte's ComboBox.svelte (dropdown list with an optional editable/searchable -/// text-box mode). Two render modes off : -/// - false (default): a FluentButton-shaped trigger showing the selected item's Name, opens a -/// positioned <ul> of ComboBoxItem-equivalent <li> rows on click. -/// - true: a FluentTextBox-shaped trigger the user can type into, filtering/matching items by -/// Name prefix (case-insensitive), same as fluent-svelte's handleInput/searchValue flow. -/// -/// Unlike FluentMenuFlyout, this does NOT go through IOverlayService/FluentOverlayHost — the -/// dropdown list needs to be exactly the trigger's width and grow from a specific list index (the -/// menuOffset/menuGrowDirection choreography below), which is easiest to keep as a plain -/// absolutely-positioned child of the component's own root (same as ComboBox.scss's own -/// `position: relative` + `.combo-box-dropdown { position: absolute }`). A portal would only add -/// indirection for zero benefit here since there's no anchor-to-elsewhere requirement. +/// A WinUI-style selectable list with optional editable search. Its dropdown is rendered through +/// so it can escape labels, clipping ancestors, and backdrop-filter +/// roots while retaining the trigger's width and selected-row alignment. /// -public partial class FluentComboBox : ComponentBase +public partial class FluentComboBox : ComponentBase, IDisposable { + [Inject] private IOverlayService OverlayService { get; set; } = default!; + /// Currently selected item's value. Two-way bindable. [Parameter] public TValue? Value { get; set; } - [Parameter] public EventCallback ValueChanged { get; set; } - - /// Current text in the search box. Only meaningful when is true. + /// Current editable search text. [Parameter] public string? SearchValue { get; set; } - [Parameter] public EventCallback SearchValueChanged { get; set; } - [Parameter, EditorRequired] public IReadOnlyList> Items { get; set; } = []; - - /// - /// Optional custom row content for each dropdown item. Falls back to a plain - /// <span>@item.Name</span> when not supplied — same "text unless you say - /// otherwise" default as fluent-svelte's own ComboBoxItem (which likewise only exposes a - /// default slot, no separate templating concept). Wraps in the same .fluent-combo-box-item - /// <li> (selection bar, hover/active states, focus ring) regardless of what you render - /// inside — only the row's inner content is replaced, not its interaction chrome. - /// + /// Optional custom content for each dropdown row. [Parameter] public RenderFragment>? ItemTemplate { get; set; } - - /// - /// Optional custom content for the closed/collapsed trigger label in non-editable - /// ( = false) mode, e.g. to show an icon + name instead of plain text - /// once something is selected. Only used when there IS a selection — the placeholder text - /// always renders plain regardless. Has no effect in Editable mode, since that trigger is a - /// real <input> and can only ever hold plain text. - /// + /// Optional custom content for the selected non-editable trigger label. [Parameter] public RenderFragment>? SelectedTemplate { get; set; } - [Parameter] public string? Placeholder { get; set; } - - /// Whether the ComboBox can be searched/typed into (fluent-svelte's `editable` prop). + /// Whether the ComboBox permits typing and prefix matching. [Parameter] public bool Editable { get; set; } - [Parameter] public bool Disabled { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } - private bool _open; - private ElementReference _rootElement; - private const int ItemHeight = 36; - private const int MaxVisibleItems = 14; // 504px max-block-size / 36px itemHeight, per ComboBox.scss + private const int MaxVisibleItems = 14; - private IEnumerable> SelectableItems => Items.Where(i => !i.Disabled); - - private ComboBoxItem? Selection => - Items.FirstOrDefault(i => EqualityComparer.Default.Equals(i.Value, Value!)); - - /// - /// Which edge the dropdown visually grows from, purely cosmetic (drives the clip-path grow - /// animation direction in FluentComboBox.razor.css) — same three-way split as fluent-svelte's - /// menuGrowDirection: grows from the selected row if it's near an edge of the list, otherwise - /// from the center. - /// - private string GrowDirectionClass - { - get - { - if (Editable) - { - return "top"; - } - - var mid = Items.Count / 2; - var selectedIndex = Selection is null ? -1 : Items.ToList().IndexOf(Selection); - - if (selectedIndex < 0 || selectedIndex == mid) - { - return "center"; - } + private readonly string _dropdownId = $"fluent-combo-box-{Guid.NewGuid():N}"; + private bool _open; + private bool _searchValueInitialized; + private ElementReference _rootElement; + private Guid? _overlayId; - return selectedIndex < mid ? "top" : "bottom"; - } - } + private string DropdownId => _dropdownId; + private string AriaExpanded => _open.ToString().ToLowerInvariant(); + private IEnumerable> SelectableItems => Items.Where(item => !item.Disabled); + private ComboBoxItem? Selection => Items.FirstOrDefault(item => EqualityComparer.Default.Equals(item.Value, Value!)); - /// - /// Vertical offset (px) so the dropdown opens with the selected row aligned under the trigger, - /// rather than always starting from row 0 — same idea as fluent-svelte's `menuOffset`. - /// private int MenuOffsetPx { get @@ -113,15 +56,14 @@ private int MenuOffsetPx } } - private bool _searchValueInitialized; + private OverlayPositioningOptions Positioning => Editable + ? new() { MainAxisOffset = -4 } + : new() { Alignment = OverlayAnchorAlignment.AnchorStart, MainAxisOffset = MenuOffsetPx - 6 }; + + protected override void OnInitialized() => OverlayService.Changed += OnOverlayServiceChanged; protected override void OnParametersSet() { - // Mirrors the svelte onMount exactly: seed the search box from the current selection ONCE, - // on first render only. The old version re-checked IsNullOrEmpty(SearchValue) on every - // render, so backspacing the box to empty (a perfectly valid "no match" state) would get - // silently overwritten back to the selected item's name on the next render — this is the - // bug where clearing the editable box snaps back to the current selection. if (Editable && !_searchValueInitialized) { _searchValueInitialized = true; @@ -130,34 +72,94 @@ protected override void OnParametersSet() SearchValue = Selection.Name; } } + + RefreshOpenDropdown(); + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (_overlayId is { } id) + { + OverlayService.Update(id, _rootElement, Positioning, OverlayPlacement.Bottom, lightDismiss: true, + matchAnchorWidth: true); + } + + return Task.CompletedTask; } - private async Task ToggleOpenAsync() + private void OnOverlayServiceChanged() { - if (Disabled) + if (_overlayId is not { } id) { return; } - await SetOpenAsync(!_open); + var entry = OverlayService.Active.FirstOrDefault(candidate => candidate.Id == id); + if (entry is null || entry.IsClosing) + { + _overlayId = null; + if (_open) + { + _open = false; + _ = InvokeAsync(StateHasChanged); + } + } } - private async Task SetOpenAsync(bool value) + private Task ToggleOpenAsync() { - if (_open == value) + if (!Disabled) + { + if (_open) + { + CloseDropdown(); + } + else + { + OpenDropdown(); + } + } + + return Task.CompletedTask; + } + + private void OpenDropdown() + { + if (Disabled || Items.Count == 0 || _open) { return; } - _open = value; + _open = true; + _overlayId = OverlayService.Show(RenderDropdown, _rootElement, Positioning, OverlayPlacement.Bottom, + lightDismiss: true, bare: false, matchAnchorWidth: true, scrollAnchorIntoView: false, + watchAnchorRemoved: false); + StateHasChanged(); + } + + private void CloseDropdown() + { + var overlayId = _overlayId; + _overlayId = null; + _open = false; + if (overlayId is { } id) + { + OverlayService.Close(id); + } + StateHasChanged(); + } - if (_open && Editable) + private void RefreshOpenDropdown() + { + if (!_open || _overlayId is not { } id) { - // Let the dropdown render, then focus/select the search input the way svelte's - // `await tick(); searchInputElement.focus()` does. - await Task.Yield(); + return; } + + OverlayService.Update(id, _rootElement, Positioning, OverlayPlacement.Bottom, lightDismiss: true, + matchAnchorWidth: true); + OverlayService.RefreshContent(id); } private async Task SelectAsync(ComboBoxItem item) @@ -168,34 +170,31 @@ private async Task SelectAsync(ComboBoxItem item) } Value = item.Value; - await ValueChanged.InvokeAsync(Value); - if (Editable) { SearchValue = item.Name; - await SearchValueChanged.InvokeAsync(SearchValue); } - await SetOpenAsync(false); + CloseDropdown(); + await ValueChanged.InvokeAsync(Value); + if (Editable) + { + await SearchValueChanged.InvokeAsync(SearchValue); + } } private async Task OnSearchInputAsync(ChangeEventArgs e) { var text = e.Value?.ToString() ?? string.Empty; SearchValue = text; - await SearchValueChanged.InvokeAsync(SearchValue); - var match = SelectableItems.FirstOrDefault( - i => i.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase)); + var match = SelectableItems.FirstOrDefault(item => item.Name.StartsWith(text, StringComparison.OrdinalIgnoreCase)); + Value = match is not null ? match.Value : text.Length == 0 ? default : Value; + RefreshOpenDropdown(); - if (match is not null) - { - Value = match.Value; - await ValueChanged.InvokeAsync(Value); - } - else if (text.Length == 0) + await SearchValueChanged.InvokeAsync(SearchValue); + if (match is not null || text.Length == 0) { - Value = default; await ValueChanged.InvokeAsync(Value); } } @@ -208,7 +207,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) { case "Escape": case "Tab": - await SetOpenAsync(false); + CloseDropdown(); return; case "Enter": @@ -223,7 +222,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) await MoveSelectionAsync(+1); if (Editable && !_open) { - await SetOpenAsync(true); + OpenDropdown(); } return; @@ -240,7 +239,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) return; case "ArrowDown" or "ArrowUp" when Editable && !_open: - await SetOpenAsync(true); + OpenDropdown(); return; } } @@ -248,53 +247,48 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e) private async Task MoveSelectionAsync(int direction) { var selectable = SelectableItems.ToList(); - if (selectable.Count == 0) - { - return; - } - var currentIndex = Selection is null ? -1 : selectable.IndexOf(Selection); var nextIndex = currentIndex + direction; - - if (nextIndex < 0 || nextIndex >= selectable.Count) + if (nextIndex >= 0 && nextIndex < selectable.Count) { - return; + await SelectSilentlyAsync(selectable[nextIndex]); } - - await SelectSilentlyAsync(selectable[nextIndex]); } private async Task JumpSelectionAsync(bool toFirst) { var selectable = SelectableItems.ToList(); - if (selectable.Count == 0) + if (selectable.Count > 0) { - return; + await SelectSilentlyAsync(toFirst ? selectable[0] : selectable[^1]); } - - await SelectSilentlyAsync(toFirst ? selectable[0] : selectable[^1]); } - /// Updates Value/SearchValue for keyboard navigation without closing the dropdown or - /// treating it as a final "select" action (mirrors svelte's direct `value = ...` assignments - /// inside handleKeyboardNavigation, as distinct from the explicit `selectItem` call on Enter). private async Task SelectSilentlyAsync(ComboBoxItem item) { Value = item.Value; - await ValueChanged.InvokeAsync(Value); - if (Editable) { SearchValue = item.Name; + } + + RefreshOpenDropdown(); + await ValueChanged.InvokeAsync(Value); + if (Editable) + { await SearchValueChanged.InvokeAsync(SearchValue); } } - private async Task OnFocusLostAsync() + public void Dispose() { - // Closest Blazor equivalent of svelte's externalMouseEvents/outermousedown dismiss — - //
            loses focus when the user clicks elsewhere, since nothing inside the - // dropdown intercepts focus permanently. - await SetOpenAsync(false); + if (OverlayService is not null) + { + OverlayService.Changed -= OnOverlayServiceChanged; + if (_overlayId is { } id) + { + OverlayService.Close(id); + } + } } } diff --git a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css index 26d8912..4f70baf 100644 --- a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css +++ b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css @@ -2,24 +2,6 @@ (--subtle-fill-color-*, --textbox-*, --control-*, --flyout-*) instead of fluent-svelte's own --fds-* custom properties. Grow/shadow-in keyframes kept as-is since they're purely presentational. */ -@keyframes fluent-combo-box-menu-in { - 0% { - clip-path: var(--fluent-combo-box-grow-clip-path); - } - 100% { - clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); - } -} - -@keyframes fluent-combo-box-shadow-in { - 0% { - box-shadow: none; - } - 100% { - box-shadow: var(--flyout-shadow, 0 4px 16px rgba(0, 0, 0, 0.24)); - } -} - .fluent-combo-box { position: relative; display: inline-flex; @@ -38,7 +20,7 @@ display: flex; align-items: center; flex: 1 1 auto; - inline-size: 100%; + inline-size: calc(100% + (var(--spacing-xs) * 2)); min-block-size: 30px; padding-inline: var(--spacing-s); gap: var(--spacing-xs); @@ -212,122 +194,19 @@ cursor: not-allowed; } -/* ===== dropdown list ===== */ - -/* Outer box: positioned/sized/clip-path-grown/shadow-in animated. Holds two children (see markup): - a material div painted first, then the actual scrolling
              (.fluent-combo-box-dropdown-list) - painted after it — content sits above the acrylic material by DOM/paint order, not cross-sibling - z-index (a backdrop-filter element's compositing doesn't always honor z-index consistently - across engines the way same-context, no-filter siblings do). */ -.fluent-combo-box-dropdown { - z-index: 100; - position: absolute; - box-sizing: border-box; - margin: 0; - margin-block-start: -6px; - margin-inline-start: -1px; - - border: 1px solid var(--flyout-border); - border-radius: var(--overlay-corner-radius, var(--control-corner-radius)); - /* Clips the material's square corners/noise tile to the rounded box, and clips the inner
                's - own scroll content the same way the old single-element version's implicit clip did. */ - overflow: hidden; - - animation: - fluent-combo-box-menu-in var(--duration-normal, 250ms) var(--easing-standard), - fluent-combo-box-shadow-in var(--duration-normal, 250ms) var(--easing-standard) var(--duration-normal, 250ms); - - inline-size: 100%; - max-block-size: 504px; - inset-block-start: var(--fluent-combo-box-menu-offset, 0); - inset-inline-start: 0; - - box-shadow: var(--flyout-shadow, 0 4px 16px rgba(0, 0, 0, 0.24)); - /* no backdrop-filter support / reduced-transparency */ - background-color: var(--acrylic-fallback-color-override, var(--acrylic-fallback-fill)); -} - -@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { - .fluent-combo-box-dropdown { - background-color: transparent; - backdrop-filter: blur(var(--acrylic-thin-blur-amount)); - -webkit-backdrop-filter: blur(var(--acrylic-thin-blur-amount)); - } - - .fluent-combo-box-dropdown::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - pointer-events: none; - background-color: var(--acrylic-tint-color-override, rgb(var(--acrylic-tint-color))); - opacity: var(--acrylic-tint-opacity-pct-override, var(--acrylic-thin-tint-opacity)); - } -} - -.fluent-combo-box-dropdown::after { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - border-radius: inherit; - pointer-events: none; - background-image: var(--noise-asset); - background-repeat: repeat; - background-size: 256px 256px; - opacity: 0.02; -} - -@media (prefers-reduced-transparency: reduce) { - .fluent-combo-box-dropdown { - background-color: var(--acrylic-fallback-color-override, var(--acrylic-fallback-fill)) !important; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - } - .fluent-combo-box-dropdown::before { - background-color: transparent; - opacity: 0; - } -} - -.fluent-combo-box-dropdown-material { - display: none; -} - -/* Inner scrolling list — plain, no position/background/animation of its own, fills the outer box - exactly and scrolls independently of the material sibling painted behind it. */ +/* Rendered inside FluentOverlayHost. OverlaySurface supplies the acrylic/chrome so this list only + owns sizing and row layout. The negative margin consumes the shared surface padding, matching + the trigger width exactly while preserving the surface border. */ .fluent-combo-box-dropdown-list { - position: relative; box-sizing: border-box; - margin: 0; + display: flex; + flex-direction: column; + margin: calc(var(--spacing-xs) * -1); padding: var(--spacing-xxs); list-style: none; overflow: auto; inline-size: 100%; - block-size: 100%; -} - -.fluent-combo-box--editable .fluent-combo-box-dropdown { - margin: 0; - inset-inline-start: 0; - inset-block-start: 100%; - inline-size: 100%; - border-radius: var(--overlay-corner-radius, var(--control-corner-radius)); - border-start-start-radius: 0; - border-start-end-radius: 0; -} - -.fluent-combo-box-dropdown--top { - --fluent-combo-box-grow-clip-path: polygon(0 0, 100% 0, 100% 25%, 0 25%); -} - -.fluent-combo-box-dropdown--center { - --fluent-combo-box-grow-clip-path: polygon(0 25%, 100% 24%, 100% 75%, 0 75%); -} - -.fluent-combo-box-dropdown--bottom { - --fluent-combo-box-grow-clip-path: polygon(0 75%, 100% 75%, 100% 100%, 0 100%); + max-block-size: 504px; } /* ===== dropdown item ===== */ @@ -411,12 +290,3 @@ overflow: hidden; max-inline-size: 100%; } - -.fluent-combo-box-dismiss-backdrop { - position: fixed; - inset: 0; - z-index: 99; - background: transparent; - pointer-events: auto; - cursor: default; -} diff --git a/src/FluentKit/Composite/TeachingTip/FluentTeachingTip.razor.cs b/src/FluentKit/Composite/TeachingTip/FluentTeachingTip.razor.cs index 3012948..920c777 100644 --- a/src/FluentKit/Composite/TeachingTip/FluentTeachingTip.razor.cs +++ b/src/FluentKit/Composite/TeachingTip/FluentTeachingTip.razor.cs @@ -27,8 +27,8 @@ namespace FluentKit.Composite; /// Detached ( omitted) — for a tip that is not pointing at any /// particular control (e.g. a general new-features announcement). No beak is drawn, and /// position is set by relative to the viewport (defaults to -/// bottom-center) via instead of the anchor-based -/// overload. +/// bottom-center) via instead of an anchor-based +/// overlay. /// /// public partial class FluentTeachingTip : ComponentBase, IDisposable diff --git a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs index bc726f7..31670af 100644 --- a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs +++ b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs @@ -84,36 +84,42 @@ public partial class FluentMicaPanel : ComponentBase, IAsyncDisposable private int _disposed; private int _renderGeneration; private bool _themeHandlerSubscribed; + private bool _interactive; private JsModuleLifetime Interop => _interop ??= new( JS, "./_content/FluentKit/Effects/mica-interop.js"); private string VariantClass => Variant == MicaVariant.BaseAlt ? "fluent-mica--alt" : "fluent-mica--base"; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { if (Volatile.Read(ref _disposed) != 0) { - return; + return Task.CompletedTask; } ThemeService.ThemeChanged += OnThemeChanged; _themeHandlerSubscribed = true; - if (!await Interop.EnsureModuleAsync()) - { - return; - } + return Task.CompletedTask; + } - await RenderAsync(); + protected override Task OnParametersSetAsync() + { + return _interactive && Volatile.Read(ref _disposed) == 0 + ? RenderAsync() + : Task.CompletedTask; } - protected override async Task OnParametersSetAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (Volatile.Read(ref _disposed) != 0) + if (!firstRender || Volatile.Read(ref _disposed) != 0) { return; } + // Keep the normal fallback surface prerenderable in Interactive Server. The Mica raster + // requires a browser canvas, so defer that work until the circuit is interactive. + _interactive = true; await RenderAsync(); } @@ -121,7 +127,7 @@ protected override async Task OnParametersSetAsync() private async Task OnThemeChangedAsync() { - if (Volatile.Read(ref _disposed) != 0) + if (!_interactive || Volatile.Read(ref _disposed) != 0) { return; } diff --git a/src/FluentKit/Overlay/IOverlayService.cs b/src/FluentKit/Overlay/IOverlayService.cs index 9a04d7d..5925a19 100644 --- a/src/FluentKit/Overlay/IOverlayService.cs +++ b/src/FluentKit/Overlay/IOverlayService.cs @@ -8,6 +8,7 @@ namespace FluentKit.Overlay; /// render the content at a fixed top-level position. This is the Blazor answer to /// React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. /// +#pragma warning disable RS0027 // Existing optional-parameter overloads must remain source/binary compatible. public interface IOverlayService { IReadOnlyList Active { get; } @@ -18,6 +19,15 @@ Guid Show(RenderFragment content, ElementReference anchor, OverlayPlacement placement = OverlayPlacement.Bottom, bool lightDismiss = true, bool bare = false, bool matchAnchorWidth = false, bool scrollAnchorIntoView = false, bool watchAnchorRemoved = false); + /// + /// Shows an overlay with optional anchor-alignment refinement. This overload preserves the + /// existing + /// contract for callers that need only ordinary adjacent placement. + /// + Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOptions positioning, + OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, + bool scrollAnchorIntoView, bool watchAnchorRemoved); + /// Shows an overlay with no anchor element — positioned relative to the viewport (see /// ) instead of relative to a trigger control. For content /// that isn't "pointing at" anything on screen, e.g. a general-purpose announcement/teaching tip @@ -49,6 +59,10 @@ Guid ShowDetached(RenderFragment content, bool Update(Guid id, ElementReference anchor, OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth = false); + /// Updates an existing overlay including its anchor-alignment refinement. + bool Update(Guid id, ElementReference anchor, OverlayPositioningOptions positioning, + OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth); + /// Actually removes an entry from . Called by OverlaySurface once an /// entry's exit animation has finished (or immediately, for an entry that was never shown/never /// got as far as playing one). Not meant to be called by application code — call @@ -57,3 +71,4 @@ bool Update(Guid id, ElementReference anchor, OverlayPlacement placement, bool l void CloseAll(); } +#pragma warning restore RS0027 diff --git a/src/FluentKit/Overlay/OverlayAnchorAlignment.cs b/src/FluentKit/Overlay/OverlayAnchorAlignment.cs new file mode 100644 index 0000000..a02d50b --- /dev/null +++ b/src/FluentKit/Overlay/OverlayAnchorAlignment.cs @@ -0,0 +1,13 @@ +namespace FluentKit.Overlay; + +/// +/// Determines where an anchored overlay starts on its placement axis. +/// +public enum OverlayAnchorAlignment +{ + /// Place the overlay outside the anchor, using the normal placement gap and flip behavior. + Adjacent, + + /// Align the overlay's leading edge with the anchor's leading edge before applying an offset. + AnchorStart +} diff --git a/src/FluentKit/Overlay/OverlayEntry.cs b/src/FluentKit/Overlay/OverlayEntry.cs index 65a1497..f6ca240 100644 --- a/src/FluentKit/Overlay/OverlayEntry.cs +++ b/src/FluentKit/Overlay/OverlayEntry.cs @@ -23,6 +23,8 @@ public sealed class OverlayEntry /// it via getBoundingClientRect. public bool MatchAnchorWidth { get; internal set; } + internal OverlayPositioningOptions Positioning { get; set; } = new(); + /// True for overlays created via — no anchor /// element, positioned relative to the viewport instead. OverlaySurface uses this to skip the /// anchor-measurement JS round-trip entirely (ComputedStyle is already filled in by diff --git a/src/FluentKit/Overlay/OverlayPositioningOptions.cs b/src/FluentKit/Overlay/OverlayPositioningOptions.cs new file mode 100644 index 0000000..f66da4a --- /dev/null +++ b/src/FluentKit/Overlay/OverlayPositioningOptions.cs @@ -0,0 +1,20 @@ +namespace FluentKit.Overlay; + +/// +/// Optional positioning refinements for an anchored overlay. Existing overlay calls use +/// and a zero offset. +/// +public sealed class OverlayPositioningOptions +{ + /// How the overlay aligns to its anchor before is applied. + public OverlayAnchorAlignment Alignment { get; init; } = OverlayAnchorAlignment.Adjacent; + + /// + /// Additional pixel offset on the placement axis. For an + /// overlay this is measured from the anchor's leading edge. + /// + public double MainAxisOffset { get; init; } + + internal bool IsEquivalentTo(OverlayPositioningOptions other) + => Alignment == other.Alignment && MainAxisOffset.Equals(other.MainAxisOffset); +} diff --git a/src/FluentKit/Overlay/OverlayService.cs b/src/FluentKit/Overlay/OverlayService.cs index bd2b364..8631530 100644 --- a/src/FluentKit/Overlay/OverlayService.cs +++ b/src/FluentKit/Overlay/OverlayService.cs @@ -2,6 +2,7 @@ namespace FluentKit.Overlay; +#pragma warning disable RS0027 // IOverlayService preserves an established optional-parameter overload. public sealed class OverlayService : IOverlayService { private readonly List _active = []; @@ -13,6 +14,12 @@ public sealed class OverlayService : IOverlayService public Guid Show(RenderFragment content, ElementReference anchor, OverlayPlacement placement = OverlayPlacement.Bottom, bool lightDismiss = true, bool bare = false, bool matchAnchorWidth = false, bool scrollAnchorIntoView = false, bool watchAnchorRemoved = false) + => Show(content, anchor, new OverlayPositioningOptions(), placement, lightDismiss, bare, + matchAnchorWidth, scrollAnchorIntoView, watchAnchorRemoved); + + public Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOptions positioning, + OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, + bool scrollAnchorIntoView, bool watchAnchorRemoved) { var entry = new OverlayEntry { @@ -22,6 +29,7 @@ public Guid Show(RenderFragment content, ElementReference anchor, LightDismiss = lightDismiss, Bare = bare, MatchAnchorWidth = matchAnchorWidth, + Positioning = positioning, ScrollAnchorIntoView = scrollAnchorIntoView, WatchAnchorRemoved = watchAnchorRemoved }; @@ -120,6 +128,10 @@ public bool RefreshContent(Guid id) public bool Update(Guid id, ElementReference anchor, OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth = false) + => Update(id, anchor, new OverlayPositioningOptions(), placement, lightDismiss, matchAnchorWidth); + + public bool Update(Guid id, ElementReference anchor, OverlayPositioningOptions positioning, + OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth) { var entry = FindActive(id); if (entry is null || entry.IsClosing) @@ -130,7 +142,8 @@ public bool Update(Guid id, ElementReference anchor, OverlayPlacement placement, var anchorChanged = entry.Anchor.Id != anchor.Id; var positionChanged = anchorChanged || entry.PreferredPlacement != placement - || entry.MatchAnchorWidth != matchAnchorWidth; + || entry.MatchAnchorWidth != matchAnchorWidth + || !entry.Positioning.IsEquivalentTo(positioning); var dismissRegistrationChanged = anchorChanged || entry.LightDismiss != lightDismiss; if (!positionChanged && !dismissRegistrationChanged) @@ -142,11 +155,18 @@ public bool Update(Guid id, ElementReference anchor, OverlayPlacement placement, entry.PreferredPlacement = placement; entry.LightDismiss = lightDismiss; entry.MatchAnchorWidth = matchAnchorWidth; + entry.Positioning = positioning; entry.NeedsReposition |= positionChanged; entry.NeedsDismissRegistrationUpdate |= dismissRegistrationChanged; if (positionChanged) { - entry.ComputedStyle = "position: fixed; visibility: hidden;"; + // An already visible overlay can remain at its prior position until the new browser + // measurement returns. Hiding it here produces a conspicuous Server-rendered flicker + // for controls (such as ComboBox) that update their anchor offset while open. + if (entry.ComputedStyle.Contains("visibility: hidden;", StringComparison.Ordinal)) + { + entry.ComputedStyle = "position: fixed; visibility: hidden;"; + } } Changed?.Invoke(); return true; @@ -181,3 +201,4 @@ public void CloseAll() private OverlayEntry? FindActive(Guid id) => _active.FirstOrDefault(entry => entry.Id == id); } +#pragma warning restore RS0027 diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.cs b/src/FluentKit/Overlay/OverlaySurface.razor.cs index 0465e33..a2dc895 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.cs +++ b/src/FluentKit/Overlay/OverlaySurface.razor.cs @@ -33,7 +33,6 @@ protected override void OnParametersSet() { Entry.NeedsReposition = false; _needsPositioning = true; - Entry.ComputedStyle = "position: fixed; visibility: hidden;"; } if (Entry.NeedsDismissRegistrationUpdate) @@ -129,7 +128,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var placementArg = Entry.PreferredPlacement.ToString().ToLowerInvariant(); var positionResult = await Interop.InvokeAsync( - "computePosition", Entry.Anchor, _surfaceElement, placementArg, Entry.MatchAnchorWidth); + "computePosition", Entry.Anchor, _surfaceElement, placementArg, Entry.MatchAnchorWidth, + Entry.Positioning.Alignment.ToString(), Entry.Positioning.MainAxisOffset); if (!positionResult.Succeeded || positionResult.Value is null) { return; diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.css b/src/FluentKit/Overlay/OverlaySurface.razor.css index bda95a8..fc9e625 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.css +++ b/src/FluentKit/Overlay/OverlaySurface.razor.css @@ -9,6 +9,7 @@ renders (Flyout, MenuFlyout, Tooltip, TeachingTip). */ .fluent-overlay-surface { position: relative; + box-sizing: border-box; /* TeachingTip's beak (FluentTeachingTip.razor.css) intentionally renders outside this box's own rect via negative offsets — don't clip it. */ overflow: visible; diff --git a/src/FluentKit/PublicAPI.Unshipped.txt b/src/FluentKit/PublicAPI.Unshipped.txt index 5092763..362714b 100644 --- a/src/FluentKit/PublicAPI.Unshipped.txt +++ b/src/FluentKit/PublicAPI.Unshipped.txt @@ -1,2 +1,22 @@ #nullable enable FluentKit.Composite.FluentNavigationView.DisposeAsync() -> System.Threading.Tasks.ValueTask +override FluentKit.Composite.FluentAutoSuggestBox.OnInitialized() -> void +override FluentKit.Composite.FluentAutoSuggestBox.OnParametersSet() -> void +FluentKit.Composite.FluentComboBox.Dispose() -> void +override FluentKit.Composite.FluentComboBox.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! +override FluentKit.Composite.FluentComboBox.OnInitialized() -> void +FluentKit.Overlay.IOverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid +FluentKit.Overlay.IOverlayService.Update(System.Guid id, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth) -> bool +FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayAnchorAlignment.Adjacent = 0 -> FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayAnchorAlignment.AnchorStart = 1 -> FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayPositioningOptions +FluentKit.Overlay.OverlayPositioningOptions.Alignment.get -> FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayPositioningOptions.Alignment.init -> void +FluentKit.Overlay.OverlayPositioningOptions.MainAxisOffset.get -> double +FluentKit.Overlay.OverlayPositioningOptions.MainAxisOffset.init -> void +FluentKit.Overlay.OverlayPositioningOptions.OverlayPositioningOptions() -> void +FluentKit.Overlay.OverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid +FluentKit.Overlay.OverlayService.Update(System.Guid id, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth) -> bool +override FluentKit.Theming.ThemeProvider.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! +override FluentKit.Effects.FluentMicaPanel.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! diff --git a/src/FluentKit/Theming/ThemeProvider.razor.cs b/src/FluentKit/Theming/ThemeProvider.razor.cs index 4aa7d3f..fa6e890 100644 --- a/src/FluentKit/Theming/ThemeProvider.razor.cs +++ b/src/FluentKit/Theming/ThemeProvider.razor.cs @@ -19,16 +19,29 @@ public partial class ThemeProvider : ComponentBase, IAsyncDisposable private JsModuleLifetime Interop => _interop ??= new( JS, "./_content/FluentKit/Theming/theme-interop.js"); - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { if (Volatile.Read(ref _disposed) != 0) { - return; + return Task.CompletedTask; } ThemeService.ThemeChanged += OnThemeChanged; _themeHandlerSubscribed = true; + return Task.CompletedTask; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || Volatile.Read(ref _disposed) != 0) + { + return; + } + + // JS interop cannot run while an Interactive Server component is being prerendered. + // Defer browser preference tracking and the document-level theme attribute until the + // first interactive render; the cascading provider itself remains fully prerenderable. if (!await Interop.EnsureModuleAsync()) { return; diff --git a/src/FluentKit/wwwroot/Overlay/overlay-interop.js b/src/FluentKit/wwwroot/Overlay/overlay-interop.js index c9a1fb6..a8aab19 100644 --- a/src/FluentKit/wwwroot/Overlay/overlay-interop.js +++ b/src/FluentKit/wwwroot/Overlay/overlay-interop.js @@ -106,7 +106,7 @@ export function unwatchAnchorRemoved(overlayId) { } } -export function computePosition(anchorEl, popupEl, placement, matchAnchorWidth) { +export function computePosition(anchorEl, popupEl, placement, matchAnchorWidth, alignment = "Adjacent", mainAxisOffset = 0) { const anchorRect = anchorEl.getBoundingClientRect(); // Width has to be applied BEFORE popupRect is measured below — changing width can reflow the @@ -148,7 +148,11 @@ export function computePosition(anchorEl, popupEl, placement, matchAnchorWidth) return { top, left, placement: resolvedPlacement, width: matchAnchorWidth ? anchorRect.width : null }; } - if (placement === "top") { + if (alignment === "AnchorStart") { + top = anchorRect.top + mainAxisOffset; + const maxTop = window.innerHeight - popupRect.height - viewportPadding; + top = Math.min(Math.max(top, viewportPadding), Math.max(maxTop, viewportPadding)); + } else if (placement === "top") { top = anchorRect.top - popupRect.height - gap; if (top < viewportPadding) { top = anchorRect.bottom + gap; diff --git a/tests/FluentKit.Tests/PortalledDropdownTests.cs b/tests/FluentKit.Tests/PortalledDropdownTests.cs new file mode 100644 index 0000000..29813dc --- /dev/null +++ b/tests/FluentKit.Tests/PortalledDropdownTests.cs @@ -0,0 +1,101 @@ +using Bunit; +using FluentKit.Composite; +using FluentKit.Overlay; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentKit.Tests; + +public sealed class PortalledDropdownTests +{ + [Fact] + public void ComboBox_PortalsListboxOutsideNativeLabel_AndClosesBeforeCallbackReturns() + { + using var context = CreateContext(); + var host = context.Render(); + var selectionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var selectionMayFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var selectionCount = 0; + var selectedValue = 0; + + var label = context.Render(builder => + { + builder.OpenElement(0, "label"); + builder.OpenComponent>(1); + builder.AddAttribute(2, nameof(FluentComboBox.Items), ComboItems); + builder.AddAttribute(3, nameof(FluentComboBox.ValueChanged), + EventCallback.Factory.Create(this, async value => + { + selectedValue = value; + selectionCount++; + selectionStarted.TrySetResult(); + await selectionMayFinish.Task; + })); + builder.CloseComponent(); + builder.CloseElement(); + }); + + label.Find("button").Click(); + host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-combo-box-dropdown-list"))); + Assert.Empty(label.FindAll("ul.fluent-combo-box-dropdown-list")); + + host.FindAll("li.fluent-combo-box-item")[1].Click(); + + Assert.True(selectionStarted.Task.IsCompleted); + Assert.Equal(1, selectionCount); + Assert.Equal(2, selectedValue); + Assert.Equal("false", label.Find("button").GetAttribute("aria-expanded"), ignoreCase: true); + Assert.Empty(label.FindAll("ul.fluent-combo-box-dropdown-list")); + + selectionMayFinish.TrySetResult(); + } + + [Fact] + public void AutoSuggestBox_PortalsListbox_AndSynchronizesLightDismissState() + { + using var context = CreateContext(); + var overlays = context.Services.GetRequiredService(); + var host = context.Render(); + var label = context.Render(builder => + { + builder.OpenElement(0, "label"); + builder.OpenComponent>(1); + builder.AddAttribute(2, nameof(FluentAutoSuggestBox.Text), "app"); + builder.AddAttribute(3, nameof(FluentAutoSuggestBox.Items), SuggestionItems); + builder.CloseComponent(); + builder.CloseElement(); + }); + + label.Find(".fluent-autosuggest-root").TriggerEvent("onfocusin", new FocusEventArgs()); + + host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-autosuggest-dropdown-list"))); + Assert.Empty(label.FindAll("ul.fluent-autosuggest-dropdown-list")); + Assert.Equal("true", label.Find("input").GetAttribute("aria-expanded"), ignoreCase: true); + + overlays.Close(overlays.Active.Single().Id); + + label.WaitForAssertion(() => + Assert.Equal("false", label.Find("input").GetAttribute("aria-expanded"), ignoreCase: true)); + } + + private static BunitContext CreateContext() + { + var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddScoped(); + return context; + } + + private static readonly IReadOnlyList> ComboItems = + [ + new("One", 1), + new("Two", 2) + ]; + + private static readonly IReadOnlyList> SuggestionItems = + [ + new("Apple", 1), + new("Pineapple", 2) + ]; +} From b262f5381af30a1478492a1d6b776258320e978d Mon Sep 17 00:00:00 2001 From: NoobNotFound <82730163+NoobNotFound@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:09:44 +0530 Subject: [PATCH 2/3] fix(overlay): refine flyout motion and mica --- docs/integration/overlays.md | 9 +- docs/reference/api.json | 64 ++++++++++++ docs/reference/api.md | 9 +- .../FluentAutoSuggestBox.razor.cs | 6 +- .../FluentAutoSuggestBox.razor.css | 6 +- .../ComboBox/FluentComboBox.razor.cs | 37 ++++++- .../ComboBox/FluentComboBox.razor.css | 7 +- .../MenuFlyout/FluentMenuFlyout.razor.cs | 5 +- .../FluentMenuFlyoutSurface.razor.css | 3 +- .../Effects/Mica/FluentMicaPanel.razor | 4 +- .../Effects/Mica/FluentMicaPanel.razor.cs | 97 ++++++++++++------- src/FluentKit/Overlay/IOverlayService.cs | 5 + src/FluentKit/Overlay/OverlayContentLayout.cs | 11 +++ .../Overlay/OverlayEntranceOrigin.cs | 17 ++++ src/FluentKit/Overlay/OverlayEntry.cs | 6 ++ src/FluentKit/Overlay/OverlayService.cs | 8 ++ src/FluentKit/Overlay/OverlaySurface.razor | 2 +- src/FluentKit/Overlay/OverlaySurface.razor.cs | 2 + .../Overlay/OverlaySurface.razor.css | 84 ++++++++++++++-- .../Overlay/OverlaySurfaceOptions.cs | 19 ++++ src/FluentKit/PublicAPI.Unshipped.txt | 17 ++++ src/FluentKit/wwwroot/Effects/mica-interop.js | 15 ++- .../FluentKit.Tests/PortalledDropdownTests.cs | 66 +++++++++++++ tests/FluentKit.Tests/ThemeDisposalTests.cs | 29 ++++-- 24 files changed, 454 insertions(+), 74 deletions(-) create mode 100644 src/FluentKit/Overlay/OverlayContentLayout.cs create mode 100644 src/FluentKit/Overlay/OverlayEntranceOrigin.cs create mode 100644 src/FluentKit/Overlay/OverlaySurfaceOptions.cs diff --git a/docs/integration/overlays.md b/docs/integration/overlays.md index e321321..10d1dd1 100644 --- a/docs/integration/overlays.md +++ b/docs/integration/overlays.md @@ -2,7 +2,14 @@ Register `IOverlayService` and render one `FluentOverlayHost` inside the root `ThemeProvider`. The host is the portal target for `FluentTooltip`, `FluentFlyout`, menus, -`FluentContentDialog`, and `FluentTeachingTip`. +`FluentComboBox`, `FluentAutoSuggestBox`, `FluentContentDialog`, and `FluentTeachingTip`. + +ComboBox and AutoSuggestBox dropdowns are portalled so they can escape native labels, clipping +ancestors, and backdrop-filter boundaries. They use the shared `OverlaySurface` with +`OverlayContentLayout.EdgeToEdge`; regular flyouts keep the default padded surface. Applications +that create overlays directly can pass `OverlaySurfaceOptions` to select the content layout or +entrance origin, and `OverlayPositioningOptions` to align an anchored surface at the anchor start +with a main-axis offset. Keep an overlay host mounted for the lifetime of the application. Rendering a second host or placing it inside a transient page can make an overlay disappear when navigation occurs. diff --git a/docs/reference/api.json b/docs/reference/api.json index 3b73f73..2a9aaef 100644 --- a/docs/reference/api.json +++ b/docs/reference/api.json @@ -4503,6 +4503,11 @@ "kind": "method", "type": "Guid" }, + { + "name": "Show", + "kind": "method", + "type": "Guid" + }, { "name": "ShowDetached", "kind": "method", @@ -4532,6 +4537,32 @@ "summary": "Determines where an anchored overlay starts on its placement axis.", "members": [] }, + { + "name": "OverlayContentLayout", + "fullName": "FluentKit.Overlay.OverlayContentLayout", + "kind": "enum", + "genericParameters": [], + "values": [ + "EdgeToEdge", + "Padded" + ], + "summary": "Controls whether an overlay\u0027s content participates in the surface\u0027s standard inset.", + "members": [] + }, + { + "name": "OverlayEntranceOrigin", + "fullName": "FluentKit.Overlay.OverlayEntranceOrigin", + "kind": "enum", + "genericParameters": [], + "values": [ + "Bottom", + "Center", + "Default", + "Top" + ], + "summary": "Chooses the reveal origin for an overlay entrance animation.", + "members": [] + }, { "name": "OverlayEntry", "fullName": "FluentKit.Overlay.OverlayEntry", @@ -4600,6 +4631,12 @@ "type": "Boolean", "summary": "When true, the anchor is scrolled into view (centered, instant) before its position is measured \u2014 for overlays like TeachingTip where the anchor may not be the same element the person just clicked (see FluentTeachingTip\u0027s separate Target parameter), so there\u0027s no guarantee it\u0027s already on screen the way a Flyout/MenuFlyout\u0027s own trigger always is." }, + { + "name": "SurfaceOptions", + "kind": "property", + "type": "FluentKit.Overlay.OverlaySurfaceOptions", + "summary": "Presentation options used by the shared overlay surface." + }, { "name": "WatchAnchorRemoved", "kind": "property", @@ -4703,6 +4740,11 @@ "kind": "method", "type": "Guid" }, + { + "name": "Show", + "kind": "method", + "type": "Guid" + }, { "name": "ShowDetached", "kind": "method", @@ -4720,6 +4762,28 @@ } ] }, + { + "name": "OverlaySurfaceOptions", + "fullName": "FluentKit.Overlay.OverlaySurfaceOptions", + "kind": "class", + "genericParameters": [], + "values": [], + "summary": "Optional surface presentation refinements for an anchored overlay.", + "members": [ + { + "name": "ContentLayout", + "kind": "property", + "type": "FluentKit.Overlay.OverlayContentLayout", + "summary": "How the overlay content relates to the surface\u0027s standard padding." + }, + { + "name": "EntranceOrigin", + "kind": "property", + "type": "FluentKit.Overlay.OverlayEntranceOrigin", + "summary": "Entrance reveal origin. The default uses the standard fade/scale motion." + } + ] + }, { "name": "ButtonVariant", "fullName": "FluentKit.Primitives.ButtonVariant", diff --git a/docs/reference/api.md b/docs/reference/api.md index ab2d0d1..1c9935d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -937,13 +937,16 @@ Category: **Infrastructure** · Sample: `/Design/Colors` | `TimeClockIdentifier` | enum | `TwelveHour`, `TwentyFourHour` | Mirrors WinUI TimePicker/TimePickerFlyout's ClockIdentifier property — which in WinUI is a loosely-typed string ("12HourClock" / "24HourClock") since it's really a Windows.Globalization clock system identifier. Re-exposed here as a proper enum (FluentKit convention — see , ) since a string parameter with exactly two legal values isn't worth the stringly-typed footgun for a net-new (non-ported) control that owes WinUI fidelity, not fluent-svelte source compatibility. | | `AcrylicKind` | enum | `Base`, `Thin` | Approximates WinUI's in-app Acrylic (AcrylicBrush / DesktopAcrylicBackdrop). This is a genuinely different material from Mica (see FluentMicaPanel) — Acrylic is TRANSLUCENT and reacts live to whatever's actually rendered behind it (in-app content scrolling underneath, other panels, etc.), not a static sample of the desktop wallpaper. On the web the correct primitive for that is CSS `backdrop-filter`, which live-samples the page content behind the element and blurs it in real time. This component used to be (incorrectly) named FluentMicaPanel — that name now belongs to the actual wallpaper-based static-blur material, which needed a from-scratch implementation instead. | | `MicaVariant` | enum | `Base`, `BaseAlt` | Approximates WinUI's Mica material — rebuilt from scratch against the actual recipe in microsoft-ui-xaml's SystemBackdropComponentInternal::BuildMicaEffectBrush (dev/dll/SystemBackdrop/SystemBackdropBrushFactory.cpp). This is NOT the same thing as Acrylic (see FluentAcrylicBrush) — Acrylic live-blurs whatever's actually behind it via backdrop-filter; Mica does not. PERFORMANCE NOTE: this used to run the blur (CSS `filter`) and the two blend passes (CSS `mix-blend-mode`) live, every repaint. That's expensive for a large/fixed-viewport element and was the source of noticeable jank. It's now baked ONCE per (image, variant, theme) into a static raster via an offscreen (see wwwroot/Effects/mica-interop.js) and cached — after that, rendering Mica is just displaying a static image, no different in cost from any other photo background. The recipe itself (blur -> luminosity blend -> color blend) is unchanged, just baked ahead of time instead of recomputed on every paint. 1. Start from a heavily blurred copy of the desktop wallpaper (ICompositorWithBlurredWallpaper- BackdropBrush — an OS compositor API that samples the wallpaper only, not other windows). Browsers can't reach the real wallpaper, so stands in for it — supply any image (ideally the user's own wallpaper file) and it's blurred the same way. 2. LUMINOSITY blend pass: blend TintColor (alpha = LuminosityOpacity) over the blurred wallpaper using true "luminosity" blend mode — keeps the wallpaper's hue/saturation but replaces its per-pixel luminosity with the tint's. Mica's defaults set LuminosityOpacity to 1.0, so this pass ends up governing almost the entire final look. 3. COLOR blend pass: blend TintColor (alpha = TintOpacity) over the result of step 2 using true "color" blend mode — keeps the TINT's hue/saturation but takes luminosity from step 2's result. This is what gives Mica its "muted, personalized" gradient-ish look: the wallpaper's light/dark shape survives, but its color is replaced by the theme tint. With no BackgroundImageUrl supplied (or a format the browser can't decode, e.g. HEIC outside Safari), this degrades to WinUI's own documented fallback: a flat SolidBackgroundFillColorBase (Alt) fill — the same state real Mica falls back to under RDP, low-power GPUs, or Battery Saver. | -| `IOverlayService` | interface | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | Register as Scoped. Components never position themselves — they call Show() with their own ElementReference as the anchor and let FluentOverlayHost (mounted once, near the app root) render the content at a fixed top-level position. This is the Blazor answer to React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. | +| `IOverlayService` | interface | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | Register as Scoped. Components never position themselves — they call Show() with their own ElementReference as the anchor and let FluentOverlayHost (mounted once, near the app root) render the content at a fixed top-level position. This is the Blazor answer to React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. | | `OverlayAnchorAlignment` | enum | `Adjacent`, `AnchorStart` | Determines where an anchored overlay starts on its placement axis. | -| `OverlayEntry` | class | `Anchor`, `Bare`, `ComputedStyle`, `Content`, `Id`, `IsClosing`, `IsDetached`, `LightDismiss`, `MatchAnchorWidth`, `PreferredPlacement`, `ScrollAnchorIntoView`, `WatchAnchorRemoved` | | +| `OverlayContentLayout` | enum | `EdgeToEdge`, `Padded` | Controls whether an overlay's content participates in the surface's standard inset. | +| `OverlayEntranceOrigin` | enum | `Bottom`, `Center`, `Default`, `Top` | Chooses the reveal origin for an overlay entrance animation. | +| `OverlayEntry` | class | `Anchor`, `Bare`, `ComputedStyle`, `Content`, `Id`, `IsClosing`, `IsDetached`, `LightDismiss`, `MatchAnchorWidth`, `PreferredPlacement`, `ScrollAnchorIntoView`, `SurfaceOptions`, `WatchAnchorRemoved` | | | `OverlayPlacement` | enum | `Bottom`, `Left`, `Right`, `Top` | Preferred placement relative to the anchor element. The JS interop layer (overlay-interop.js) flips this to the opposite side when there isn't room in the viewport — callers get the flipped result back, they don't need to reason about collision themselves. | | `OverlayPositioningOptions` | class | `Alignment`, `MainAxisOffset` | Optional positioning refinements for an anchored overlay. Existing overlay calls use and a zero offset. | | `OverlayScreenPlacement` | enum | `BottomCenter`, `BottomLeft`, `BottomRight`, `Center`, `TopCenter`, `TopLeft`, `TopRight` | Placement for a "detached" overlay that has no anchor element to position relative to (see ) — positioned relative to the viewport instead, via a plain CSS `position: fixed` computed synchronously in (unlike the anchor-based path, this needs no JS measurement round-trip, since there's no anchor rect to measure — the overlay can be positioned correctly on its very first render instead of one frame later). | -| `OverlayService` | class | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | | +| `OverlayService` | class | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | | +| `OverlaySurfaceOptions` | class | `ContentLayout`, `EntranceOrigin` | Optional surface presentation refinements for an anchored overlay. | | `ButtonVariant` | enum | `Accent`, `Hyperlink`, `Standard`, `Subtle` | Mirrors WinUI's Button / AccentButton (via the IsAccent-equivalent style) / HyperlinkButton split. "Subtle" matches what WinUI calls a SubtleButton-styled Button (no border, transparent by default). | | `FluentIconNames` | class | | Hand-picked, ergonomic aliases for the most commonly used Fluent System Icons glyphs (e.g. Name="@FluentIconNames.Home"). For the full, exhaustive set of ~9,600 glyphs (auto-generated from wwwroot/Icons/FluentSystemIcons-Regular.css by tools/generate-icon-names.py), see the other partial of this class in FluentIconNames.g.cs, e.g. FluentIconNames.Beaker20. | | `FluentSliderOrientation` | enum | `Horizontal`, `Vertical` | | diff --git a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs index 5f8bd90..3d89f91 100644 --- a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs +++ b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.cs @@ -165,8 +165,10 @@ private void OpenDropdown() } _open = true; - _overlayId = OverlayService.Show(RenderDropdown, _rootElement, OverlayPlacement.Bottom, - lightDismiss: true, matchAnchorWidth: true); + _overlayId = OverlayService.Show(RenderDropdown, _rootElement, new OverlayPositioningOptions(), + new OverlaySurfaceOptions { ContentLayout = OverlayContentLayout.EdgeToEdge }, + OverlayPlacement.Bottom, lightDismiss: true, bare: false, matchAnchorWidth: true, + scrollAnchorIntoView: false, watchAnchorRemoved: false); StateHasChanged(); } diff --git a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css index bdd1c75..9fcb963 100644 --- a/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css +++ b/src/FluentKit/Composite/AutoSuggestBox/FluentAutoSuggestBox.razor.css @@ -6,9 +6,9 @@ .fluent-autosuggest-dropdown-list { box-sizing: border-box; - width: calc(100% + (var(--spacing-xs) * 2)); - max-height: 320px; - margin: calc(var(--spacing-xs) * -1); + inline-size: 100%; + max-block-size: 320px; + margin: 0; padding: var(--spacing-xxs); list-style: none; overflow: auto; diff --git a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs index 467e43a..c7a4acc 100644 --- a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs +++ b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.cs @@ -46,6 +46,26 @@ public partial class FluentComboBox : ComponentBase, IDisposable private IEnumerable> SelectableItems => Items.Where(item => !item.Disabled); private ComboBoxItem? Selection => Items.FirstOrDefault(item => EqualityComparer.Default.Equals(item.Value, Value!)); + private string GrowDirectionClass + { + get + { + if (Editable) + { + return "top"; + } + + var midpoint = Items.Count / 2; + var selectedIndex = Selection is null ? -1 : Items.ToList().IndexOf(Selection); + if (selectedIndex < 0 || selectedIndex == midpoint) + { + return "center"; + } + + return selectedIndex < midpoint ? "top" : "bottom"; + } + } + private int MenuOffsetPx { get @@ -60,6 +80,17 @@ private int MenuOffsetPx ? new() { MainAxisOffset = -4 } : new() { Alignment = OverlayAnchorAlignment.AnchorStart, MainAxisOffset = MenuOffsetPx - 6 }; + private OverlaySurfaceOptions SurfaceOptions => new() + { + ContentLayout = OverlayContentLayout.EdgeToEdge, + EntranceOrigin = Editable ? OverlayEntranceOrigin.Top : GrowDirectionClass switch + { + "top" => OverlayEntranceOrigin.Top, + "bottom" => OverlayEntranceOrigin.Bottom, + _ => OverlayEntranceOrigin.Center + } + }; + protected override void OnInitialized() => OverlayService.Changed += OnOverlayServiceChanged; protected override void OnParametersSet() @@ -131,9 +162,9 @@ private void OpenDropdown() } _open = true; - _overlayId = OverlayService.Show(RenderDropdown, _rootElement, Positioning, OverlayPlacement.Bottom, - lightDismiss: true, bare: false, matchAnchorWidth: true, scrollAnchorIntoView: false, - watchAnchorRemoved: false); + _overlayId = OverlayService.Show(RenderDropdown, _rootElement, Positioning, SurfaceOptions, + OverlayPlacement.Bottom, lightDismiss: true, bare: false, matchAnchorWidth: true, + scrollAnchorIntoView: false, watchAnchorRemoved: false); StateHasChanged(); } diff --git a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css index 4f70baf..2e7b459 100644 --- a/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css +++ b/src/FluentKit/Composite/ComboBox/FluentComboBox.razor.css @@ -194,14 +194,13 @@ cursor: not-allowed; } -/* Rendered inside FluentOverlayHost. OverlaySurface supplies the acrylic/chrome so this list only - owns sizing and row layout. The negative margin consumes the shared surface padding, matching - the trigger width exactly while preserving the surface border. */ +/* Rendered inside FluentOverlayHost. OverlaySurface supplies the acrylic/chrome and its edge-to-edge + mode cancels the surface inset on the content wrapper, so this list only owns sizing and row layout. */ .fluent-combo-box-dropdown-list { box-sizing: border-box; display: flex; flex-direction: column; - margin: calc(var(--spacing-xs) * -1); + margin: 0; padding: var(--spacing-xxs); list-style: none; overflow: auto; diff --git a/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyout.razor.cs b/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyout.razor.cs index 2a5a08f..5258153 100644 --- a/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyout.razor.cs +++ b/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyout.razor.cs @@ -106,7 +106,10 @@ private void ShowInternal() _context = new MenuFlyoutCloseContext { Closable = Closable, CloseOnSelect = CloseOnSelect }; _context.RequestCloseAll += () => _ = CloseAsync(); - _overlayId = OverlayService.Show(RenderMenuContent, _anchor, Placement, Closable, matchAnchorWidth: MatchAnchorWidth); + _overlayId = OverlayService.Show(RenderMenuContent, _anchor, new OverlayPositioningOptions(), + new OverlaySurfaceOptions { ContentLayout = OverlayContentLayout.EdgeToEdge }, Placement, + Closable, bare: false, matchAnchorWidth: MatchAnchorWidth, scrollAnchorIntoView: false, + watchAnchorRemoved: false); } private void HideInternal() diff --git a/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyoutSurface.razor.css b/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyoutSurface.razor.css index a3aa81a..0dc76d3 100644 --- a/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyoutSurface.razor.css +++ b/src/FluentKit/Composite/MenuFlyout/FluentMenuFlyoutSurface.razor.css @@ -5,10 +5,9 @@ display: flex; flex-direction: column; list-style: none; - margin: calc(var(--spacing-xs) * -1); + margin: 0; padding: var(--spacing-xxs) 0; min-inline-size: 120px; - max-inline-size: 100%; max-block-size: 100vh; overflow-y: auto; box-sizing: border-box; diff --git a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor index ef10d55..1a8ef3d 100644 --- a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor +++ b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor @@ -1,9 +1,9 @@ @namespace FluentKit.Effects
                - @if (_renderedImageUrl is not null) + @if (!string.IsNullOrEmpty(BackgroundImageUrl)) { - + }
                @ChildContent diff --git a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs index 31670af..77a46ce 100644 --- a/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs +++ b/src/FluentKit/Effects/Mica/FluentMicaPanel.razor.cs @@ -72,19 +72,15 @@ public partial class FluentMicaPanel : ComponentBase, IAsyncDisposable [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } - // Shared across every FluentMicaPanel instance in the app — two panels showing the same image - // + variant + theme (e.g. the fixed background plus a demo panel) reuse the same baked raster - // instead of each paying the render cost separately. - private static readonly Dictionary _cache = new(); - private JsModuleLifetime? _interop; - private string? _renderedImageUrl; private string? _lastKey; private string? _pendingKey; + private ElementReference _wallpaperElement; private int _disposed; private int _renderGeneration; private bool _themeHandlerSubscribed; private bool _interactive; + private bool _needsRender = true; private JsModuleLifetime Interop => _interop ??= new( JS, "./_content/FluentKit/Effects/mica-interop.js"); @@ -105,22 +101,43 @@ protected override Task OnInitializedAsync() protected override Task OnParametersSetAsync() { - return _interactive && Volatile.Read(ref _disposed) == 0 - ? RenderAsync() - : Task.CompletedTask; + if (_interactive && Volatile.Read(ref _disposed) == 0) + { + // The new parameters are not in the DOM until this lifecycle pass completes. Defer + // the browser-side paint to OnAfterRenderAsync so a newly-created wallpaper element is + // a valid target, especially when a Server app changes its image at runtime. + _needsRender = true; + } + + return Task.CompletedTask; } protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || Volatile.Read(ref _disposed) != 0) + if (Volatile.Read(ref _disposed) != 0) { return; } // Keep the normal fallback surface prerenderable in Interactive Server. The Mica raster // requires a browser canvas, so defer that work until the circuit is interactive. - _interactive = true; - await RenderAsync(); + if (firstRender) + { + _interactive = true; + } + + if (!_interactive) + { + return; + } + + if (await PaintPendingRasterAsync() && Volatile.Read(ref _disposed) == 0) + { + // OnAfterRenderAsync does not schedule a render after its awaited work completes. + // The raster paint is performed by JavaScript, but scheduling the follow-up render keeps + // the component lifecycle synchronized with the completed browser-side work. + await InvokeAsync(StateHasChanged); + } } private void OnThemeChanged() => ObserveBackgroundTask(OnThemeChangedAsync()); @@ -170,33 +187,34 @@ exception is not JSDisconnectedException && TaskScheduler.Default); } - private async Task RenderAsync() + private async Task RenderAsync() { if (Volatile.Read(ref _disposed) != 0) { - return; + return false; } if (string.IsNullOrEmpty(BackgroundImageUrl)) { + var changed = _lastKey is not null; Interlocked.Increment(ref _renderGeneration); _pendingKey = null; - _renderedImageUrl = null; _lastKey = null; - return; + return changed; } if (!await Interop.EnsureModuleAsync()) { if (Volatile.Read(ref _disposed) == 0) { + var changed = _lastKey is not null; Interlocked.Increment(ref _renderGeneration); _pendingKey = null; - _renderedImageUrl = null; _lastKey = null; + return changed; } - return; + return false; } var isBase = Variant == MicaVariant.Base; @@ -206,24 +224,16 @@ private async Task RenderAsync() // what's on screen. This is what stops a resize/re-render from re-baking Mica every time. if (key == _lastKey) { - return; + return false; } if (key == _pendingKey) { - return; + return false; } _pendingKey = key; var generation = Interlocked.Increment(ref _renderGeneration); - if (_cache.TryGetValue(key, out var cached)) - { - _renderedImageUrl = cached; - _lastKey = key; - _pendingKey = null; - return; - } - // If I didnt do, Mica doesnt get properly rendered on first load. try { @@ -231,27 +241,33 @@ private async Task RenderAsync() if (Volatile.Read(ref _disposed) != 0 || generation != Volatile.Read(ref _renderGeneration)) { - return; + return false; } - var result = await Interop.InvokeAsync("renderMica", key, BackgroundImageUrl, isBase); - if (!result.Succeeded || string.IsNullOrEmpty(result.Value)) + // Keep the potentially-large PNG data URL inside the browser. Returning it through + // IJSRuntime would send the whole raster over the Server circuit and can exceed the + // SignalR message budget; renderMicaInto only returns success after applying it to the + // already-rendered wallpaper element. + var result = await Interop.InvokeVoidAsync("renderMicaInto", _wallpaperElement, key, + BackgroundImageUrl, isBase); + if (!result) { - return; + return false; } - _cache[key] = result.Value; - // Guard against a stale response landing after a newer request already changed things. if (Volatile.Read(ref _disposed) == 0 && generation == Volatile.Read(ref _renderGeneration)) { _lastKey = key; - _renderedImageUrl = result.Value; + return true; } + + return false; } catch (OperationCanceledException) when (Volatile.Read(ref _disposed) != 0) { // The delayed render or JS call was canceled by component disposal. + return false; } finally { @@ -262,6 +278,17 @@ private async Task RenderAsync() } } + private async Task PaintPendingRasterAsync() + { + if (!_needsRender) + { + return false; + } + + _needsRender = false; + return await RenderAsync(); + } + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) diff --git a/src/FluentKit/Overlay/IOverlayService.cs b/src/FluentKit/Overlay/IOverlayService.cs index 5925a19..f53b7cd 100644 --- a/src/FluentKit/Overlay/IOverlayService.cs +++ b/src/FluentKit/Overlay/IOverlayService.cs @@ -28,6 +28,11 @@ Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOpt OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved); + /// Shows an overlay with positioning and surface presentation refinements. + Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOptions positioning, + OverlaySurfaceOptions surface, OverlayPlacement placement, bool lightDismiss, bool bare, + bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved); + /// Shows an overlay with no anchor element — positioned relative to the viewport (see /// ) instead of relative to a trigger control. For content /// that isn't "pointing at" anything on screen, e.g. a general-purpose announcement/teaching tip diff --git a/src/FluentKit/Overlay/OverlayContentLayout.cs b/src/FluentKit/Overlay/OverlayContentLayout.cs new file mode 100644 index 0000000..c9a4a1f --- /dev/null +++ b/src/FluentKit/Overlay/OverlayContentLayout.cs @@ -0,0 +1,11 @@ +namespace FluentKit.Overlay; + +/// Controls whether an overlay's content participates in the surface's standard inset. +public enum OverlayContentLayout +{ + /// Keep the surface's standard content inset. + Padded, + + /// Extend the content to the surface border while retaining the surface chrome. + EdgeToEdge +} diff --git a/src/FluentKit/Overlay/OverlayEntranceOrigin.cs b/src/FluentKit/Overlay/OverlayEntranceOrigin.cs new file mode 100644 index 0000000..6cc880f --- /dev/null +++ b/src/FluentKit/Overlay/OverlayEntranceOrigin.cs @@ -0,0 +1,17 @@ +namespace FluentKit.Overlay; + +/// Chooses the reveal origin for an overlay entrance animation. +public enum OverlayEntranceOrigin +{ + /// Use the standard overlay fade/scale entrance. + Default, + + /// Reveal from the top portion of the surface. + Top, + + /// Reveal from the center portion of the surface. + Center, + + /// Reveal from the bottom portion of the surface. + Bottom +} diff --git a/src/FluentKit/Overlay/OverlayEntry.cs b/src/FluentKit/Overlay/OverlayEntry.cs index f6ca240..1230796 100644 --- a/src/FluentKit/Overlay/OverlayEntry.cs +++ b/src/FluentKit/Overlay/OverlayEntry.cs @@ -23,6 +23,12 @@ public sealed class OverlayEntry /// it via getBoundingClientRect. public bool MatchAnchorWidth { get; internal set; } + /// Presentation options used by the shared overlay surface. + public OverlaySurfaceOptions SurfaceOptions { get; internal set; } = new(); + + /// True once the overlay has received its measured fixed position. + internal bool IsPositioned { get; set; } + internal OverlayPositioningOptions Positioning { get; set; } = new(); /// True for overlays created via — no anchor diff --git a/src/FluentKit/Overlay/OverlayService.cs b/src/FluentKit/Overlay/OverlayService.cs index 8631530..1f68275 100644 --- a/src/FluentKit/Overlay/OverlayService.cs +++ b/src/FluentKit/Overlay/OverlayService.cs @@ -20,6 +20,12 @@ public Guid Show(RenderFragment content, ElementReference anchor, public Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOptions positioning, OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) + => Show(content, anchor, positioning, new OverlaySurfaceOptions(), placement, lightDismiss, bare, + matchAnchorWidth, scrollAnchorIntoView, watchAnchorRemoved); + + public Guid Show(RenderFragment content, ElementReference anchor, OverlayPositioningOptions positioning, + OverlaySurfaceOptions surface, OverlayPlacement placement, bool lightDismiss, bool bare, + bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) { var entry = new OverlayEntry { @@ -30,6 +36,7 @@ public Guid Show(RenderFragment content, ElementReference anchor, OverlayPositio Bare = bare, MatchAnchorWidth = matchAnchorWidth, Positioning = positioning, + SurfaceOptions = surface, ScrollAnchorIntoView = scrollAnchorIntoView, WatchAnchorRemoved = watchAnchorRemoved }; @@ -47,6 +54,7 @@ public Guid ShowDetached(RenderFragment content, Content = content, Anchor = default, IsDetached = true, + IsPositioned = true, LightDismiss = lightDismiss, // Computed synchronously here, not by overlay-interop.js's computePosition — there's no // anchor rect to measure against, just a fixed spot relative to the viewport, so diff --git a/src/FluentKit/Overlay/OverlaySurface.razor b/src/FluentKit/Overlay/OverlaySurface.razor index 13f78ba..4a09b3a 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor +++ b/src/FluentKit/Overlay/OverlaySurface.razor @@ -1,7 +1,7 @@ @namespace FluentKit.Overlay @implements IAsyncDisposable -
                +
                @if (Entry.LightDismiss) {
                diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.cs b/src/FluentKit/Overlay/OverlaySurface.razor.cs index a2dc895..25cd01f 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.cs +++ b/src/FluentKit/Overlay/OverlaySurface.razor.cs @@ -102,6 +102,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // here except mark positioning done and, if requested, wire up light-dismiss without an // anchor exclusion zone. _needsPositioning = false; + Entry.IsPositioned = true; if (firstRender) { await RegisterDismissHandlersAsync(); @@ -141,6 +142,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) Entry.ComputedStyle = $"position: fixed; top: {position.Top}px; left: {position.Left}px; {widthStyle}z-index: 1000;"; _needsPositioning = false; + Entry.IsPositioned = true; if (firstRender) { await RegisterDismissHandlersAsync(); diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.css b/src/FluentKit/Overlay/OverlaySurface.razor.css index fc9e625..14bdd37 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.css +++ b/src/FluentKit/Overlay/OverlaySurface.razor.css @@ -10,21 +10,17 @@ .fluent-overlay-surface { position: relative; box-sizing: border-box; + --fluent-overlay-surface-padding: var(--spacing-xs); /* TeachingTip's beak (FluentTeachingTip.razor.css) intentionally renders outside this box's own rect via negative offsets — don't clip it. */ overflow: visible; border: var(--control-stroke-thickness) solid var(--flyout-border); border-radius: var(--overlay-corner-radius); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.14); - padding: var(--spacing-xs); + padding: var(--fluent-overlay-surface-padding); - /* `both` so the surface holds its from-state before the animation starts (no flash-in at full - opacity on the frame before this kicks off) and its to-state after it ends, rather than - snapping back to the un-animated CSS values. transform-origin biased toward the anchor edge - (top by default, since Bottom/top-flip are the overwhelmingly common placements) so the scale - reads as "growing out of the anchor" rather than growing from the surface's own center. - - Animates the standalone `scale` property, NOT `transform: scale(...)`. Detached overlays + /* The standard motion animates the standalone `scale` property, NOT `transform: scale(...)`. + Detached overlays (OverlayService.ShowDetached, e.g. TeachingTip's screen-centered mode) position themselves with an inline `transform: translateX(-50%)`/`translate(-50%, -50%)` — a keyframe that writes to `transform` would replace that positioning transform outright for the animation's whole @@ -33,7 +29,9 @@ `scale`/`translate`/`rotate` are independent CSS properties precisely so an animation can drive one of them without touching whatever else already occupies `transform`. */ transform-origin: top center; - animation: fluent-overlay-enter var(--duration-fast) var(--easing-decelerate) both; + /* The surface is rendered hidden for one frame while the browser measures its anchor. The + positioned modifier below deliberately owns the entrance animation so Server latency cannot + consume the animation while the surface is invisible. */ /* no backdrop-filter support / reduced-transparency */ background-color: var(--acrylic-fallback-color-override, var(--acrylic-fallback-fill)); @@ -94,12 +92,37 @@ position: relative; } +/* Dropdown/list overlays need their content to meet the surface border exactly. Keeping this on + the shared wrapper avoids each control having to compensate for the surface inset with a different + combination of width and negative margins (which was the source of the one-sided right gap). */ +.fluent-overlay-surface--edge-to-edge .fluent-overlay-surface-content { + margin: calc(-1 * var(--fluent-overlay-surface-padding)); +} + +.fluent-overlay-surface--positioned { + animation: fluent-overlay-enter var(--duration-fast) var(--easing-decelerate) both; +} + +/* ComboBox restores the WinUI/fluent-svelte reveal that grows from the selected-row region. The + clip-path is applied to the complete surface so its acrylic, border, and content reveal together. */ +.fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-top { + animation: fluent-overlay-reveal-top var(--duration-normal) var(--easing-standard) both; +} + +.fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-center { + animation: fluent-overlay-reveal-center var(--duration-normal) var(--easing-standard) both; +} + +.fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-bottom { + animation: fluent-overlay-reveal-bottom var(--duration-normal) var(--easing-standard) both; +} + /* Applied by OverlaySurface.razor.cs the instant Entry.IsClosing flips true (see OverlaySurface.razor.cs for the full close handoff). Overriding `animation` here — rather than adding a second animation-name — means there's no ambiguity about which keyframes are currently driving opacity and transform; the enter animation has already finished by the time this class can apply (it's only added post-open), so there's nothing to interrupt. */ -.fluent-overlay-surface--closing { +.fluent-overlay-surface.fluent-overlay-surface--closing { animation: fluent-overlay-exit var(--duration-fast) var(--easing-accelerate) both; } @@ -125,6 +148,46 @@ } } +@keyframes fluent-overlay-reveal-top { + from { + opacity: 0; + clip-path: polygon(0 0, 100% 0, 100% 25%, 0 25%); + } + to { + opacity: 1; + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); + } +} + +@keyframes fluent-overlay-reveal-center { + from { + opacity: 0; + clip-path: polygon(0 25%, 100% 24%, 100% 75%, 0 75%); + } + to { + opacity: 1; + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); + } +} + +@keyframes fluent-overlay-reveal-bottom { + from { + opacity: 0; + clip-path: polygon(0 75%, 100% 75%, 100% 100%, 0 100%); + } + to { + opacity: 1; + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); + } +} + +@media (prefers-reduced-motion: reduce) { + .fluent-overlay-surface--positioned, + .fluent-overlay-surface.fluent-overlay-surface--closing { + animation-duration: 1ms !important; + } +} + /* Bare variant: content supplies its own surface material (e.g. FluentAcrylicBrush wrapping NavigationView's Compact/Minimal overlay pane) — OverlaySurface.razor doesn't even render the .fluent-overlay-surface-material div for Bare entries, so there's no acrylic layer to strip here; @@ -133,6 +196,7 @@ .fluent-overlay-surface--bare { border: none; box-shadow: none; + --fluent-overlay-surface-padding: 0px; padding: 0; } diff --git a/src/FluentKit/Overlay/OverlaySurfaceOptions.cs b/src/FluentKit/Overlay/OverlaySurfaceOptions.cs new file mode 100644 index 0000000..2ee0257 --- /dev/null +++ b/src/FluentKit/Overlay/OverlaySurfaceOptions.cs @@ -0,0 +1,19 @@ +namespace FluentKit.Overlay; + +/// Optional surface presentation refinements for an anchored overlay. +public sealed class OverlaySurfaceOptions +{ + /// Creates the default padded surface with standard entrance motion. + public OverlaySurfaceOptions() + { + } + + /// How the overlay content relates to the surface's standard padding. + public OverlayContentLayout ContentLayout { get; init; } = OverlayContentLayout.Padded; + + /// Entrance reveal origin. The default uses the standard fade/scale motion. + public OverlayEntranceOrigin EntranceOrigin { get; init; } = OverlayEntranceOrigin.Default; + + internal bool IsEquivalentTo(OverlaySurfaceOptions other) + => ContentLayout == other.ContentLayout && EntranceOrigin == other.EntranceOrigin; +} diff --git a/src/FluentKit/PublicAPI.Unshipped.txt b/src/FluentKit/PublicAPI.Unshipped.txt index 362714b..4e94765 100644 --- a/src/FluentKit/PublicAPI.Unshipped.txt +++ b/src/FluentKit/PublicAPI.Unshipped.txt @@ -6,10 +6,19 @@ FluentKit.Composite.FluentComboBox.Dispose() -> void override FluentKit.Composite.FluentComboBox.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! override FluentKit.Composite.FluentComboBox.OnInitialized() -> void FluentKit.Overlay.IOverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid +FluentKit.Overlay.IOverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlaySurfaceOptions! surface, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid FluentKit.Overlay.IOverlayService.Update(System.Guid id, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth) -> bool FluentKit.Overlay.OverlayAnchorAlignment FluentKit.Overlay.OverlayAnchorAlignment.Adjacent = 0 -> FluentKit.Overlay.OverlayAnchorAlignment FluentKit.Overlay.OverlayAnchorAlignment.AnchorStart = 1 -> FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayContentLayout +FluentKit.Overlay.OverlayContentLayout.Padded = 0 -> FluentKit.Overlay.OverlayContentLayout +FluentKit.Overlay.OverlayContentLayout.EdgeToEdge = 1 -> FluentKit.Overlay.OverlayContentLayout +FluentKit.Overlay.OverlayEntranceOrigin +FluentKit.Overlay.OverlayEntranceOrigin.Default = 0 -> FluentKit.Overlay.OverlayEntranceOrigin +FluentKit.Overlay.OverlayEntranceOrigin.Top = 1 -> FluentKit.Overlay.OverlayEntranceOrigin +FluentKit.Overlay.OverlayEntranceOrigin.Center = 2 -> FluentKit.Overlay.OverlayEntranceOrigin +FluentKit.Overlay.OverlayEntranceOrigin.Bottom = 3 -> FluentKit.Overlay.OverlayEntranceOrigin FluentKit.Overlay.OverlayPositioningOptions FluentKit.Overlay.OverlayPositioningOptions.Alignment.get -> FluentKit.Overlay.OverlayAnchorAlignment FluentKit.Overlay.OverlayPositioningOptions.Alignment.init -> void @@ -17,6 +26,14 @@ FluentKit.Overlay.OverlayPositioningOptions.MainAxisOffset.get -> double FluentKit.Overlay.OverlayPositioningOptions.MainAxisOffset.init -> void FluentKit.Overlay.OverlayPositioningOptions.OverlayPositioningOptions() -> void FluentKit.Overlay.OverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid +FluentKit.Overlay.OverlayService.Show(Microsoft.AspNetCore.Components.RenderFragment! content, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlaySurfaceOptions! surface, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool bare, bool matchAnchorWidth, bool scrollAnchorIntoView, bool watchAnchorRemoved) -> System.Guid FluentKit.Overlay.OverlayService.Update(System.Guid id, Microsoft.AspNetCore.Components.ElementReference anchor, FluentKit.Overlay.OverlayPositioningOptions! positioning, FluentKit.Overlay.OverlayPlacement placement, bool lightDismiss, bool matchAnchorWidth) -> bool +FluentKit.Overlay.OverlayEntry.SurfaceOptions.get -> FluentKit.Overlay.OverlaySurfaceOptions! +FluentKit.Overlay.OverlaySurfaceOptions +FluentKit.Overlay.OverlaySurfaceOptions.OverlaySurfaceOptions() -> void +FluentKit.Overlay.OverlaySurfaceOptions.ContentLayout.get -> FluentKit.Overlay.OverlayContentLayout +FluentKit.Overlay.OverlaySurfaceOptions.ContentLayout.init -> void +FluentKit.Overlay.OverlaySurfaceOptions.EntranceOrigin.get -> FluentKit.Overlay.OverlayEntranceOrigin +FluentKit.Overlay.OverlaySurfaceOptions.EntranceOrigin.init -> void override FluentKit.Theming.ThemeProvider.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! override FluentKit.Effects.FluentMicaPanel.OnAfterRenderAsync(bool firstRender) -> System.Threading.Tasks.Task! diff --git a/src/FluentKit/wwwroot/Effects/mica-interop.js b/src/FluentKit/wwwroot/Effects/mica-interop.js index a829250..71afdb2 100644 --- a/src/FluentKit/wwwroot/Effects/mica-interop.js +++ b/src/FluentKit/wwwroot/Effects/mica-interop.js @@ -438,4 +438,17 @@ export async function renderMica(cacheKey, imageUrl, isBase) { const dataUrl = outCanvas.toDataURL("image/png"); finalCache.set(cacheKey, dataUrl); return dataUrl; -} \ No newline at end of file +} + +// Server-safe entry point. The baked PNG can be hundreds of kilobytes, so never return it through +// Blazor Server's SignalR circuit. Keep it in this browser-side cache and paint the already-mounted +// wallpaper element directly instead. +export async function renderMicaInto(element, cacheKey, imageUrl, isBase) { + if (!element) return false; + + const dataUrl = await renderMica(cacheKey, imageUrl, isBase); + if (!dataUrl) return false; + + element.style.backgroundImage = `url("${dataUrl}")`; + return true; +} diff --git a/tests/FluentKit.Tests/PortalledDropdownTests.cs b/tests/FluentKit.Tests/PortalledDropdownTests.cs index 29813dc..dff6ffd 100644 --- a/tests/FluentKit.Tests/PortalledDropdownTests.cs +++ b/tests/FluentKit.Tests/PortalledDropdownTests.cs @@ -9,6 +9,64 @@ namespace FluentKit.Tests; public sealed class PortalledDropdownTests { + [Fact] + public void AnchoredOverlaysWaitForPositioning_WhileDetachedOverlaysEnterImmediately() + { + using var context = CreateContext(); + var host = context.Render(); + var overlays = context.Services.GetRequiredService(); + + overlays.Show(builder => builder.AddContent(0, "anchored"), default, OverlayPlacement.Bottom, + lightDismiss: false); + host.WaitForAssertion(() => Assert.Contains("anchored", host.Markup)); + Assert.DoesNotContain("fluent-overlay-surface--positioned", host.Markup); + + overlays.ShowDetached(builder => builder.AddContent(0, "detached"), OverlayScreenPlacement.Center, + lightDismiss: false); + host.WaitForAssertion(() => Assert.Contains("detached", host.Markup)); + var surfaces = host.FindAll(".fluent-overlay-surface"); + Assert.Equal(2, surfaces.Count); + Assert.DoesNotContain("fluent-overlay-surface--positioned", surfaces[0].GetAttribute("class")); + Assert.Contains("fluent-overlay-surface--positioned", surfaces[1].GetAttribute("class")); + } + + [Theory] + [InlineData(1, OverlayEntranceOrigin.Top)] + [InlineData(3, OverlayEntranceOrigin.Center)] + [InlineData(5, OverlayEntranceOrigin.Bottom)] + public void ComboBoxUsesSelectedRowRevealOrigin(int value, OverlayEntranceOrigin expectedOrigin) + { + using var context = CreateContext(); + var host = context.Render(); + var cut = context.Render>(parameters => parameters + .Add(combo => combo.Items, Enumerable.Range(1, 5) + .Select(number => new ComboBoxItem($"Item {number}", number)) + .ToArray()) + .Add(combo => combo.Value, value)); + + cut.Find("button").Click(); + host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-combo-box-dropdown-list"))); + + Assert.Equal(expectedOrigin, context.Services.GetRequiredService() + .Active.Single().SurfaceOptions.EntranceOrigin); + } + + [Fact] + public void EditableComboBoxUsesTopRevealOrigin() + { + using var context = CreateContext(); + var host = context.Render(); + var cut = context.Render>(parameters => parameters + .Add(combo => combo.Items, ComboItems) + .Add(combo => combo.Editable, true)); + + cut.Find("input").Focus(); + host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-combo-box-dropdown-list"))); + + Assert.Equal(OverlayEntranceOrigin.Top, context.Services.GetRequiredService() + .Active.Single().SurfaceOptions.EntranceOrigin); + } + [Fact] public void ComboBox_PortalsListboxOutsideNativeLabel_AndClosesBeforeCallbackReturns() { @@ -39,6 +97,10 @@ public void ComboBox_PortalsListboxOutsideNativeLabel_AndClosesBeforeCallbackRet label.Find("button").Click(); host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-combo-box-dropdown-list"))); Assert.Empty(label.FindAll("ul.fluent-combo-box-dropdown-list")); + Assert.Equal(OverlayContentLayout.EdgeToEdge, + GetOverlays(context).Active.Single().SurfaceOptions.ContentLayout); + Assert.Equal(OverlayEntranceOrigin.Center, + GetOverlays(context).Active.Single().SurfaceOptions.EntranceOrigin); host.FindAll("li.fluent-combo-box-item")[1].Click(); @@ -72,6 +134,7 @@ public void AutoSuggestBox_PortalsListbox_AndSynchronizesLightDismissState() host.WaitForAssertion(() => Assert.Single(host.FindAll("ul.fluent-autosuggest-dropdown-list"))); Assert.Empty(label.FindAll("ul.fluent-autosuggest-dropdown-list")); Assert.Equal("true", label.Find("input").GetAttribute("aria-expanded"), ignoreCase: true); + Assert.Equal(OverlayContentLayout.EdgeToEdge, overlays.Active.Single().SurfaceOptions.ContentLayout); overlays.Close(overlays.Active.Single().Id); @@ -87,6 +150,9 @@ private static BunitContext CreateContext() return context; } + private static IOverlayService GetOverlays(BunitContext context) + => context.Services.GetRequiredService(); + private static readonly IReadOnlyList> ComboItems = [ new("One", 1), diff --git a/tests/FluentKit.Tests/ThemeDisposalTests.cs b/tests/FluentKit.Tests/ThemeDisposalTests.cs index f323bb6..4074584 100644 --- a/tests/FluentKit.Tests/ThemeDisposalTests.cs +++ b/tests/FluentKit.Tests/ThemeDisposalTests.cs @@ -46,6 +46,24 @@ public async Task MicaPanelUnsubscribesBeforeHandlingLaterThemeChanges() Assert.Equal(0, theme.SubscriberCount); } + [Fact] + public void MicaPanelAddsTheInitialInteractiveRasterToTheMarkup() + { + var theme = new TestThemeService(); + var module = new TestJsObjectReference(); + Services.AddSingleton(theme); + Services.AddSingleton(new TestJsRuntime(module)); + + var cut = Render(parameters => parameters + .Add(panel => panel.BackgroundImageUrl, "wallpaper.png")); + + cut.WaitForAssertion(() => + { + Assert.Contains("fluent-mica__wallpaper", cut.Markup); + Assert.Contains("renderMicaInto", module.Calls); + }); + } + [Fact] public async Task MicaThemeRenderCancellationDoesNotCompleteTheRenderKey() { @@ -62,7 +80,7 @@ public async Task MicaThemeRenderCancellationDoesNotCompleteTheRenderKey() cut.Instance.BackgroundImageUrl = "wallpaper.png"; theme.RaiseChanged(); - await WaitUntilAsync(() => module.Calls.Contains("renderMica")); + await WaitUntilAsync(() => module.Calls.Contains("renderMicaInto")); await cut.Instance.DisposeAsync(); var lastKey = typeof(FluentKit.Effects.FluentMicaPanel) @@ -78,23 +96,22 @@ public async Task MicaThemeRenderDisconnectCanRetryTheSameKey() { var theme = new TestThemeService(); var module = new TestJsObjectReference(); - module.ThrowOnInvoke("renderMica", new JSDisconnectedException("circuit disconnected")); + module.ThrowOnInvoke("renderMicaInto", new JSDisconnectedException("circuit disconnected")); Services.AddSingleton(theme); Services.AddSingleton(new TestJsRuntime(module)); var cut = Render(); cut.Instance.BackgroundImageUrl = "retry-wallpaper.png"; theme.RaiseChanged(); - await WaitUntilAsync(() => module.Calls.Count(call => call == "renderMica") == 1); + await WaitUntilAsync(() => module.Calls.Count(call => call == "renderMicaInto") == 1); var lastKey = typeof(FluentKit.Effects.FluentMicaPanel) .GetField("_lastKey", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; Assert.Null(lastKey.GetValue(cut.Instance)); - module.ClearInvokeException("renderMica"); - module.Results["renderMica"] = "data:image/png;base64,retry"; + module.ClearInvokeException("renderMicaInto"); theme.RaiseChanged(); - await WaitUntilAsync(() => module.Calls.Count(call => call == "renderMica") == 2); + await WaitUntilAsync(() => module.Calls.Count(call => call == "renderMicaInto") == 2); Assert.Equal("retry-wallpaper.png|True|dark", lastKey.GetValue(cut.Instance)); await cut.Instance.DisposeAsync(); From f880795058d3cda1297fa5019b322037e9c47abd Mon Sep 17 00:00:00 2001 From: NoobNotFound <82730163+NoobNotFound@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:31:51 +0530 Subject: [PATCH 3/3] fix(overlay): tune entrance animation motion --- docs/integration/overlays.md | 4 +- docs/reference/api.json | 54 +++++++++++++++++++ docs/reference/api.md | 4 +- .../Overlay/OverlayAnimationEasing.cs | 17 ++++++ .../Overlay/OverlayAnimationOptions.cs | 23 ++++++++ src/FluentKit/Overlay/OverlaySurface.razor | 2 +- src/FluentKit/Overlay/OverlaySurface.razor.cs | 38 +++++++++++++ .../Overlay/OverlaySurface.razor.css | 49 +++++++++++++++-- .../Overlay/OverlaySurfaceOptions.cs | 10 +++- src/FluentKit/PublicAPI.Unshipped.txt | 17 ++++++ .../wwwroot/Overlay/overlay-interop.js | 20 +++++-- .../FluentKit.Tests/PortalledDropdownTests.cs | 31 +++++++++++ 12 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 src/FluentKit/Overlay/OverlayAnimationEasing.cs create mode 100644 src/FluentKit/Overlay/OverlayAnimationOptions.cs diff --git a/docs/integration/overlays.md b/docs/integration/overlays.md index 10d1dd1..9697b0f 100644 --- a/docs/integration/overlays.md +++ b/docs/integration/overlays.md @@ -9,7 +9,9 @@ ancestors, and backdrop-filter boundaries. They use the shared `OverlaySurface` `OverlayContentLayout.EdgeToEdge`; regular flyouts keep the default padded surface. Applications that create overlays directly can pass `OverlaySurfaceOptions` to select the content layout or entrance origin, and `OverlayPositioningOptions` to align an anchored surface at the anchor start -with a main-axis offset. +with a main-axis offset. `OverlayAnimationOptions` can override entrance or exit duration and +easing; entrances default to WinUI's fast-out, slow-in motion, so they begin at maximum velocity +and gradually decelerate to rest. Keep an overlay host mounted for the lifetime of the application. Rendering a second host or placing it inside a transient page can make an overlay disappear when navigation occurs. diff --git a/docs/reference/api.json b/docs/reference/api.json index 2a9aaef..b89ff9b 100644 --- a/docs/reference/api.json +++ b/docs/reference/api.json @@ -4537,6 +4537,54 @@ "summary": "Determines where an anchored overlay starts on its placement axis.", "members": [] }, + { + "name": "OverlayAnimationEasing", + "fullName": "FluentKit.Overlay.OverlayAnimationEasing", + "kind": "enum", + "genericParameters": [], + "values": [ + "Accelerate", + "Decelerate", + "Linear", + "Standard" + ], + "summary": "Controls how an overlay\u0027s velocity changes during its entrance or exit.", + "members": [] + }, + { + "name": "OverlayAnimationOptions", + "fullName": "FluentKit.Overlay.OverlayAnimationOptions", + "kind": "class", + "genericParameters": [], + "values": [], + "summary": "Optional timing controls for an overlay surface.", + "members": [ + { + "name": "EntranceDuration", + "kind": "property", + "type": "Nullable\u003CTimeSpan\u003E", + "summary": "Overrides the entrance duration. When omitted, standard surfaces use the fast duration and selected-row reveals use the normal duration." + }, + { + "name": "EntranceEasing", + "kind": "property", + "type": "FluentKit.Overlay.OverlayAnimationEasing", + "summary": "Controls the entrance velocity. The default is WinUI\u0027s fast-out, slow-in curve: maximum speed at the start that progressively decreases as the surface reaches rest." + }, + { + "name": "ExitDuration", + "kind": "property", + "type": "Nullable\u003CTimeSpan\u003E", + "summary": "Overrides the exit duration. When omitted, the standard fast duration is used." + }, + { + "name": "ExitEasing", + "kind": "property", + "type": "FluentKit.Overlay.OverlayAnimationEasing", + "summary": "Controls the exit velocity. The default accelerates out of the scene." + } + ] + }, { "name": "OverlayContentLayout", "fullName": "FluentKit.Overlay.OverlayContentLayout", @@ -4770,6 +4818,12 @@ "values": [], "summary": "Optional surface presentation refinements for an anchored overlay.", "members": [ + { + "name": "Animation", + "kind": "property", + "type": "FluentKit.Overlay.OverlayAnimationOptions", + "summary": "Optional timing and velocity controls for the surface motion." + }, { "name": "ContentLayout", "kind": "property", diff --git a/docs/reference/api.md b/docs/reference/api.md index 1c9935d..66f9437 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -939,6 +939,8 @@ Category: **Infrastructure** · Sample: `/Design/Colors` | `MicaVariant` | enum | `Base`, `BaseAlt` | Approximates WinUI's Mica material — rebuilt from scratch against the actual recipe in microsoft-ui-xaml's SystemBackdropComponentInternal::BuildMicaEffectBrush (dev/dll/SystemBackdrop/SystemBackdropBrushFactory.cpp). This is NOT the same thing as Acrylic (see FluentAcrylicBrush) — Acrylic live-blurs whatever's actually behind it via backdrop-filter; Mica does not. PERFORMANCE NOTE: this used to run the blur (CSS `filter`) and the two blend passes (CSS `mix-blend-mode`) live, every repaint. That's expensive for a large/fixed-viewport element and was the source of noticeable jank. It's now baked ONCE per (image, variant, theme) into a static raster via an offscreen (see wwwroot/Effects/mica-interop.js) and cached — after that, rendering Mica is just displaying a static image, no different in cost from any other photo background. The recipe itself (blur -> luminosity blend -> color blend) is unchanged, just baked ahead of time instead of recomputed on every paint. 1. Start from a heavily blurred copy of the desktop wallpaper (ICompositorWithBlurredWallpaper- BackdropBrush — an OS compositor API that samples the wallpaper only, not other windows). Browsers can't reach the real wallpaper, so stands in for it — supply any image (ideally the user's own wallpaper file) and it's blurred the same way. 2. LUMINOSITY blend pass: blend TintColor (alpha = LuminosityOpacity) over the blurred wallpaper using true "luminosity" blend mode — keeps the wallpaper's hue/saturation but replaces its per-pixel luminosity with the tint's. Mica's defaults set LuminosityOpacity to 1.0, so this pass ends up governing almost the entire final look. 3. COLOR blend pass: blend TintColor (alpha = TintOpacity) over the result of step 2 using true "color" blend mode — keeps the TINT's hue/saturation but takes luminosity from step 2's result. This is what gives Mica its "muted, personalized" gradient-ish look: the wallpaper's light/dark shape survives, but its color is replaced by the theme tint. With no BackgroundImageUrl supplied (or a format the browser can't decode, e.g. HEIC outside Safari), this degrades to WinUI's own documented fallback: a flat SolidBackgroundFillColorBase (Alt) fill — the same state real Mica falls back to under RDP, low-power GPUs, or Battery Saver. | | `IOverlayService` | interface | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | Register as Scoped. Components never position themselves — they call Show() with their own ElementReference as the anchor and let FluentOverlayHost (mounted once, near the app root) render the content at a fixed top-level position. This is the Blazor answer to React's createPortal: render-tree teleportation via a cascading service, not DOM manipulation. | | `OverlayAnchorAlignment` | enum | `Adjacent`, `AnchorStart` | Determines where an anchored overlay starts on its placement axis. | +| `OverlayAnimationEasing` | enum | `Accelerate`, `Decelerate`, `Linear`, `Standard` | Controls how an overlay's velocity changes during its entrance or exit. | +| `OverlayAnimationOptions` | class | `EntranceDuration`, `EntranceEasing`, `ExitDuration`, `ExitEasing` | Optional timing controls for an overlay surface. | | `OverlayContentLayout` | enum | `EdgeToEdge`, `Padded` | Controls whether an overlay's content participates in the surface's standard inset. | | `OverlayEntranceOrigin` | enum | `Bottom`, `Center`, `Default`, `Top` | Chooses the reveal origin for an overlay entrance animation. | | `OverlayEntry` | class | `Anchor`, `Bare`, `ComputedStyle`, `Content`, `Id`, `IsClosing`, `IsDetached`, `LightDismiss`, `MatchAnchorWidth`, `PreferredPlacement`, `ScrollAnchorIntoView`, `SurfaceOptions`, `WatchAnchorRemoved` | | @@ -946,7 +948,7 @@ Category: **Infrastructure** · Sample: `/Design/Colors` | `OverlayPositioningOptions` | class | `Alignment`, `MainAxisOffset` | Optional positioning refinements for an anchored overlay. Existing overlay calls use and a zero offset. | | `OverlayScreenPlacement` | enum | `BottomCenter`, `BottomLeft`, `BottomRight`, `Center`, `TopCenter`, `TopLeft`, `TopRight` | Placement for a "detached" overlay that has no anchor element to position relative to (see ) — positioned relative to the viewport instead, via a plain CSS `position: fixed` computed synchronously in (unlike the anchor-based path, this needs no JS measurement round-trip, since there's no anchor rect to measure — the overlay can be positioned correctly on its very first render instead of one frame later). | | `OverlayService` | class | `Active`, `Close`, `CloseAll`, `CompleteClose`, `RefreshContent`, `Show`, `Show`, `Show`, `ShowDetached`, `Update`, `Update` | | -| `OverlaySurfaceOptions` | class | `ContentLayout`, `EntranceOrigin` | Optional surface presentation refinements for an anchored overlay. | +| `OverlaySurfaceOptions` | class | `Animation`, `ContentLayout`, `EntranceOrigin` | Optional surface presentation refinements for an anchored overlay. | | `ButtonVariant` | enum | `Accent`, `Hyperlink`, `Standard`, `Subtle` | Mirrors WinUI's Button / AccentButton (via the IsAccent-equivalent style) / HyperlinkButton split. "Subtle" matches what WinUI calls a SubtleButton-styled Button (no border, transparent by default). | | `FluentIconNames` | class | | Hand-picked, ergonomic aliases for the most commonly used Fluent System Icons glyphs (e.g. Name="@FluentIconNames.Home"). For the full, exhaustive set of ~9,600 glyphs (auto-generated from wwwroot/Icons/FluentSystemIcons-Regular.css by tools/generate-icon-names.py), see the other partial of this class in FluentIconNames.g.cs, e.g. FluentIconNames.Beaker20. | | `FluentSliderOrientation` | enum | `Horizontal`, `Vertical` | | diff --git a/src/FluentKit/Overlay/OverlayAnimationEasing.cs b/src/FluentKit/Overlay/OverlayAnimationEasing.cs new file mode 100644 index 0000000..4593547 --- /dev/null +++ b/src/FluentKit/Overlay/OverlayAnimationEasing.cs @@ -0,0 +1,17 @@ +namespace FluentKit.Overlay; + +/// Controls how an overlay's velocity changes during its entrance or exit. +public enum OverlayAnimationEasing +{ + /// Starts at maximum velocity and gradually slows to rest. Use for entrances. + Decelerate, + + /// Uses the balanced Fluent standard curve. + Standard, + + /// Starts slowly and gains velocity. Use for exits. + Accelerate, + + /// Maintains a constant velocity. + Linear +} diff --git a/src/FluentKit/Overlay/OverlayAnimationOptions.cs b/src/FluentKit/Overlay/OverlayAnimationOptions.cs new file mode 100644 index 0000000..ff40f54 --- /dev/null +++ b/src/FluentKit/Overlay/OverlayAnimationOptions.cs @@ -0,0 +1,23 @@ +namespace FluentKit.Overlay; + +/// Optional timing controls for an overlay surface. +public sealed class OverlayAnimationOptions +{ + /// + /// Overrides the entrance duration. When omitted, standard surfaces use the fast duration and + /// selected-row reveals use the normal duration. + /// + public TimeSpan? EntranceDuration { get; init; } + + /// + /// Controls the entrance velocity. The default is WinUI's fast-out, slow-in curve: maximum + /// speed at the start that progressively decreases as the surface reaches rest. + /// + public OverlayAnimationEasing EntranceEasing { get; init; } = OverlayAnimationEasing.Decelerate; + + /// Overrides the exit duration. When omitted, the standard fast duration is used. + public TimeSpan? ExitDuration { get; init; } + + /// Controls the exit velocity. The default accelerates out of the scene. + public OverlayAnimationEasing ExitEasing { get; init; } = OverlayAnimationEasing.Accelerate; +} diff --git a/src/FluentKit/Overlay/OverlaySurface.razor b/src/FluentKit/Overlay/OverlaySurface.razor index 4a09b3a..7d50e98 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor +++ b/src/FluentKit/Overlay/OverlaySurface.razor @@ -1,7 +1,7 @@ @namespace FluentKit.Overlay @implements IAsyncDisposable -
                +
                @if (Entry.LightDismiss) {
                diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.cs b/src/FluentKit/Overlay/OverlaySurface.razor.cs index 25cd01f..0033eac 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.cs +++ b/src/FluentKit/Overlay/OverlaySurface.razor.cs @@ -1,6 +1,7 @@ using FluentKit.Interop; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using System.Globalization; namespace FluentKit.Overlay; @@ -27,6 +28,43 @@ public partial class OverlaySurface : ComponentBase, IAsyncDisposable private JsModuleLifetime Interop => _interop ??= new( JS, "./_content/FluentKit/Overlay/overlay-interop.js"); + private string SurfaceClass => string.Join(' ', + new[] + { + "fluent-overlay-surface", + Entry.Bare ? "fluent-overlay-surface--bare" : null, + Entry.IsClosing ? "fluent-overlay-surface--closing" : null, + Entry.IsPositioned ? "fluent-overlay-surface--positioned" : null, + Entry.SurfaceOptions.ContentLayout == OverlayContentLayout.EdgeToEdge + ? "fluent-overlay-surface--edge-to-edge" + : null, + GetEntranceOriginClass(), + $"fluent-overlay-surface--entrance-easing-{Entry.SurfaceOptions.Animation.EntranceEasing.ToString().ToLowerInvariant()}", + $"fluent-overlay-surface--exit-easing-{Entry.SurfaceOptions.Animation.ExitEasing.ToString().ToLowerInvariant()}" + }.Where(static className => className is not null)); + + private string SurfaceStyle + { + get + { + var entranceDuration = FormatDuration(Entry.SurfaceOptions.Animation.EntranceDuration); + var exitDuration = FormatDuration(Entry.SurfaceOptions.Animation.ExitDuration); + return $"{Entry.ComputedStyle}{(entranceDuration is null ? null : $" --fluent-overlay-entrance-duration: {entranceDuration};")}{(exitDuration is null ? null : $" --fluent-overlay-exit-duration: {exitDuration};")}"; + } + } + + private string? GetEntranceOriginClass() => Entry.SurfaceOptions.EntranceOrigin switch + { + OverlayEntranceOrigin.Top => "fluent-overlay-surface--entrance-top", + OverlayEntranceOrigin.Center => "fluent-overlay-surface--entrance-center", + OverlayEntranceOrigin.Bottom => "fluent-overlay-surface--entrance-bottom", + _ => null + }; + + private static string? FormatDuration(TimeSpan? duration) => duration is { } value && value >= TimeSpan.Zero + ? $"{value.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture)}ms" + : null; + protected override void OnParametersSet() { if (Entry.NeedsReposition) diff --git a/src/FluentKit/Overlay/OverlaySurface.razor.css b/src/FluentKit/Overlay/OverlaySurface.razor.css index 14bdd37..3fb7a11 100644 --- a/src/FluentKit/Overlay/OverlaySurface.razor.css +++ b/src/FluentKit/Overlay/OverlaySurface.razor.css @@ -11,6 +11,10 @@ position: relative; box-sizing: border-box; --fluent-overlay-surface-padding: var(--spacing-xs); + --fluent-overlay-entrance-duration: var(--duration-fast); + --fluent-overlay-entrance-easing: cubic-bezier(0, 0, 0, 1); + --fluent-overlay-exit-duration: var(--duration-fast); + --fluent-overlay-exit-easing: cubic-bezier(1, 0, 1, 1); /* TeachingTip's beak (FluentTeachingTip.razor.css) intentionally renders outside this box's own rect via negative offsets — don't clip it. */ overflow: visible; @@ -100,21 +104,56 @@ } .fluent-overlay-surface--positioned { - animation: fluent-overlay-enter var(--duration-fast) var(--easing-decelerate) both; + animation: fluent-overlay-enter var(--fluent-overlay-entrance-duration) var(--fluent-overlay-entrance-easing) both; } /* ComboBox restores the WinUI/fluent-svelte reveal that grows from the selected-row region. The clip-path is applied to the complete surface so its acrylic, border, and content reveal together. */ .fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-top { - animation: fluent-overlay-reveal-top var(--duration-normal) var(--easing-standard) both; + --fluent-overlay-entrance-duration: var(--duration-normal); + animation: fluent-overlay-reveal-top var(--fluent-overlay-entrance-duration) var(--fluent-overlay-entrance-easing) both; } .fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-center { - animation: fluent-overlay-reveal-center var(--duration-normal) var(--easing-standard) both; + --fluent-overlay-entrance-duration: var(--duration-normal); + animation: fluent-overlay-reveal-center var(--fluent-overlay-entrance-duration) var(--fluent-overlay-entrance-easing) both; } .fluent-overlay-surface--positioned.fluent-overlay-surface--entrance-bottom { - animation: fluent-overlay-reveal-bottom var(--duration-normal) var(--easing-standard) both; + --fluent-overlay-entrance-duration: var(--duration-normal); + animation: fluent-overlay-reveal-bottom var(--fluent-overlay-entrance-duration) var(--fluent-overlay-entrance-easing) both; +} + +.fluent-overlay-surface--entrance-easing-decelerate { + --fluent-overlay-entrance-easing: cubic-bezier(0, 0, 0, 1); +} + +.fluent-overlay-surface--entrance-easing-standard { + --fluent-overlay-entrance-easing: var(--easing-standard); +} + +.fluent-overlay-surface--entrance-easing-accelerate { + --fluent-overlay-entrance-easing: var(--easing-accelerate); +} + +.fluent-overlay-surface--entrance-easing-linear { + --fluent-overlay-entrance-easing: linear; +} + +.fluent-overlay-surface--exit-easing-decelerate { + --fluent-overlay-exit-easing: cubic-bezier(0, 0, 0, 1); +} + +.fluent-overlay-surface--exit-easing-standard { + --fluent-overlay-exit-easing: var(--easing-standard); +} + +.fluent-overlay-surface--exit-easing-linear { + --fluent-overlay-exit-easing: linear; +} + +.fluent-overlay-surface--exit-easing-accelerate { + --fluent-overlay-exit-easing: cubic-bezier(1, 0, 1, 1); } /* Applied by OverlaySurface.razor.cs the instant Entry.IsClosing flips true (see OverlaySurface.razor.cs @@ -123,7 +162,7 @@ and transform; the enter animation has already finished by the time this class can apply (it's only added post-open), so there's nothing to interrupt. */ .fluent-overlay-surface.fluent-overlay-surface--closing { - animation: fluent-overlay-exit var(--duration-fast) var(--easing-accelerate) both; + animation: fluent-overlay-exit var(--fluent-overlay-exit-duration) var(--fluent-overlay-exit-easing) both; } @keyframes fluent-overlay-enter { diff --git a/src/FluentKit/Overlay/OverlaySurfaceOptions.cs b/src/FluentKit/Overlay/OverlaySurfaceOptions.cs index 2ee0257..ca8cb86 100644 --- a/src/FluentKit/Overlay/OverlaySurfaceOptions.cs +++ b/src/FluentKit/Overlay/OverlaySurfaceOptions.cs @@ -14,6 +14,14 @@ public OverlaySurfaceOptions() /// Entrance reveal origin. The default uses the standard fade/scale motion. public OverlayEntranceOrigin EntranceOrigin { get; init; } = OverlayEntranceOrigin.Default; + /// Optional timing and velocity controls for the surface motion. + public OverlayAnimationOptions Animation { get; init; } = new(); + internal bool IsEquivalentTo(OverlaySurfaceOptions other) - => ContentLayout == other.ContentLayout && EntranceOrigin == other.EntranceOrigin; + => ContentLayout == other.ContentLayout + && EntranceOrigin == other.EntranceOrigin + && Animation.EntranceDuration == other.Animation.EntranceDuration + && Animation.EntranceEasing == other.Animation.EntranceEasing + && Animation.ExitDuration == other.Animation.ExitDuration + && Animation.ExitEasing == other.Animation.ExitEasing; } diff --git a/src/FluentKit/PublicAPI.Unshipped.txt b/src/FluentKit/PublicAPI.Unshipped.txt index 4e94765..dd35a45 100644 --- a/src/FluentKit/PublicAPI.Unshipped.txt +++ b/src/FluentKit/PublicAPI.Unshipped.txt @@ -11,6 +11,21 @@ FluentKit.Overlay.IOverlayService.Update(System.Guid id, Microsoft.AspNetCore.Co FluentKit.Overlay.OverlayAnchorAlignment FluentKit.Overlay.OverlayAnchorAlignment.Adjacent = 0 -> FluentKit.Overlay.OverlayAnchorAlignment FluentKit.Overlay.OverlayAnchorAlignment.AnchorStart = 1 -> FluentKit.Overlay.OverlayAnchorAlignment +FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationEasing.Decelerate = 0 -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationEasing.Standard = 1 -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationEasing.Accelerate = 2 -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationEasing.Linear = 3 -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationOptions +FluentKit.Overlay.OverlayAnimationOptions.OverlayAnimationOptions() -> void +FluentKit.Overlay.OverlayAnimationOptions.EntranceDuration.get -> System.TimeSpan? +FluentKit.Overlay.OverlayAnimationOptions.EntranceDuration.init -> void +FluentKit.Overlay.OverlayAnimationOptions.EntranceEasing.get -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationOptions.EntranceEasing.init -> void +FluentKit.Overlay.OverlayAnimationOptions.ExitDuration.get -> System.TimeSpan? +FluentKit.Overlay.OverlayAnimationOptions.ExitDuration.init -> void +FluentKit.Overlay.OverlayAnimationOptions.ExitEasing.get -> FluentKit.Overlay.OverlayAnimationEasing +FluentKit.Overlay.OverlayAnimationOptions.ExitEasing.init -> void FluentKit.Overlay.OverlayContentLayout FluentKit.Overlay.OverlayContentLayout.Padded = 0 -> FluentKit.Overlay.OverlayContentLayout FluentKit.Overlay.OverlayContentLayout.EdgeToEdge = 1 -> FluentKit.Overlay.OverlayContentLayout @@ -31,6 +46,8 @@ FluentKit.Overlay.OverlayService.Update(System.Guid id, Microsoft.AspNetCore.Com FluentKit.Overlay.OverlayEntry.SurfaceOptions.get -> FluentKit.Overlay.OverlaySurfaceOptions! FluentKit.Overlay.OverlaySurfaceOptions FluentKit.Overlay.OverlaySurfaceOptions.OverlaySurfaceOptions() -> void +FluentKit.Overlay.OverlaySurfaceOptions.Animation.get -> FluentKit.Overlay.OverlayAnimationOptions! +FluentKit.Overlay.OverlaySurfaceOptions.Animation.init -> void FluentKit.Overlay.OverlaySurfaceOptions.ContentLayout.get -> FluentKit.Overlay.OverlayContentLayout FluentKit.Overlay.OverlaySurfaceOptions.ContentLayout.init -> void FluentKit.Overlay.OverlaySurfaceOptions.EntranceOrigin.get -> FluentKit.Overlay.OverlayEntranceOrigin diff --git a/src/FluentKit/wwwroot/Overlay/overlay-interop.js b/src/FluentKit/wwwroot/Overlay/overlay-interop.js index a8aab19..0d52df9 100644 --- a/src/FluentKit/wwwroot/Overlay/overlay-interop.js +++ b/src/FluentKit/wwwroot/Overlay/overlay-interop.js @@ -236,11 +236,21 @@ export function waitForExitAnimation(popupEl) { }; popupEl.addEventListener("animationend", onAnimationEnd); - // Safety net: duration-fast is 150ms today but this reads the *class* being applied, not a - // hardcoded number, so it stays correct if that token ever changes — 400ms is just a - // generous ceiling above any realistic exit-animation duration, not a value that needs to - // track it. - setTimeout(finish, 400); + // Safety net based on the computed duration, so a caller that opts into a longer custom exit + // animation is not unmounted at the old 400ms ceiling before its motion can finish. Keep a + // 400ms minimum for malformed or unsupported computed values and a small event-time buffer. + const animationDuration = getComputedStyle(popupEl).animationDuration; + const longestDuration = Math.max(0, ...animationDuration.split(",").map((value) => { + const duration = value.trim(); + if (duration.endsWith("ms")) { + return Number.parseFloat(duration); + } + if (duration.endsWith("s")) { + return Number.parseFloat(duration) * 1000; + } + return 0; + }).filter(Number.isFinite)); + setTimeout(finish, Math.max(400, longestDuration + 100)); }); } diff --git a/tests/FluentKit.Tests/PortalledDropdownTests.cs b/tests/FluentKit.Tests/PortalledDropdownTests.cs index dff6ffd..23919e8 100644 --- a/tests/FluentKit.Tests/PortalledDropdownTests.cs +++ b/tests/FluentKit.Tests/PortalledDropdownTests.cs @@ -9,6 +9,37 @@ namespace FluentKit.Tests; public sealed class PortalledDropdownTests { + [Fact] + public void OverlayAnimationOptionsUseDeceleratingEntrancesAndAllowCustomTiming() + { + using var context = CreateContext(); + var host = context.Render(); + var overlays = context.Services.GetRequiredService(); + var surface = new OverlaySurfaceOptions + { + Animation = new OverlayAnimationOptions + { + EntranceDuration = TimeSpan.FromMilliseconds(320), + EntranceEasing = OverlayAnimationEasing.Standard, + ExitDuration = TimeSpan.FromMilliseconds(180), + ExitEasing = OverlayAnimationEasing.Linear + } + }; + + Assert.Equal(OverlayAnimationEasing.Decelerate, new OverlaySurfaceOptions().Animation.EntranceEasing); + + overlays.Show(builder => builder.AddContent(0, "animated"), default, new OverlayPositioningOptions(), + surface, OverlayPlacement.Bottom, lightDismiss: false, bare: false, matchAnchorWidth: false, + scrollAnchorIntoView: false, watchAnchorRemoved: false); + + host.WaitForAssertion(() => Assert.Contains("animated", host.Markup)); + var overlay = host.Find(".fluent-overlay-surface"); + Assert.Contains("fluent-overlay-surface--entrance-easing-standard", overlay.GetAttribute("class")); + Assert.Contains("fluent-overlay-surface--exit-easing-linear", overlay.GetAttribute("class")); + Assert.Contains("--fluent-overlay-entrance-duration: 320ms", overlay.GetAttribute("style")); + Assert.Contains("--fluent-overlay-exit-duration: 180ms", overlay.GetAttribute("style")); + } + [Fact] public void AnchoredOverlaysWaitForPositioning_WhileDetachedOverlaysEnterImmediately() {