fluentui-blazor icon indicating copy to clipboard operation
fluentui-blazor copied to clipboard

fix: FluentUI Razor web components do not work with Blazor CSS isolation

Open rajsite opened this issue 4 years ago • 13 comments

🐛 Bug Report

Because the terminology in the docs is a bit hard to keep track of, I'll use the following terms:

  • FAST Razor components: Refers to the Razor components exported in this repo wrapping the Fluent web components.
  • FAST web components: Refers to using the Fluent web components directly.

When using the FAST Razor components you cannot style them like you would expect with Blazor CSS isolation.

This seems to be related to https://github.com/dotnet/aspnetcore/issues/30213

TL;DR: I expect the following FAST web component usage and FAST Razor component usage to render the same but the Razor component renders without the margin:

Example.razor.css:

.awesome-margin { margin: 16px; }

Example.razor:

<!-- ✔ Using the FAST web component works and we see the margin, but I'd like to use the typing given by FAST Razor components -->
<fluent-button class="awesome-margin">FAST Web component</fluent-button>

<!-- ❌ Swapping and using the FAST Razor component does not work as the margin is missing 😢 -->
<FluentButton class="awesome-margin">FAST Razor component</FluentButton>

💻 Repro or Code Sample

Using the following Counter.razor.css file:

.button-container { display:flex; flex-direction: column; border: 1px solid red; width: 500px;}
.my-web-component-button-scoped-css { margin: 16px; }
.my-razor-button-scoped-css { margin: 16px; }
::deep .my-razor-button-deep { margin: 16px; }

For the following Counter.razor file:

<div class="button-container">
<!-- ✔ Using the FAST web component with CSS isolation works, but I'd like to use the typing given by FAST Razor components -->
    <fluent-button class="my-web-component-button-scoped-css" @onclick="IncrementCount">Click me - web component scoped css</fluent-button>

<!-- ❌ Swapping and using the FAST Razor component with CSS isolation does not work 😢 -->
    <FluentButton class="my-razor-button-scoped-css" @onclick="IncrementCount">Click me - scoped css</FluentButton>

<!-- The following are different workarounds -->

<!-- ⚠ Using the FAST Razor component by moving the css into a style tag "works" but is cheating. Those do not participate in CSS isolation and are effectively global styles -->
    <style>
        .my-razor-button-local-css {
            margin: 16px;
        }
    </style>
    <FluentButton class="my-razor-button-local-css" @onclick="IncrementCount">Click me - local css</FluentButton>

<!-- ⚠ Using a FAST Razor component with inline styles works but does not let us use CSS files as expected -->
    <FluentButton style="margin: 16px;" @onclick="IncrementCount">Click me - inline css</FluentButton>

<!-- ✔⚠ Using a FAST Razor component with deep style selector has the fewest tradeoffs -->
    <FluentButton class="my-razor-button-deep" @onclick="IncrementCount">Click me - deep css</FluentButton>
</div>

Gives the following output:

image

Notice how the FAST Razor component with the label "Click me - scoped css" does not receive the margin that was set in the corresponding CSS file.

Example repo: https://github.com/rajsite/fast-blazor-issue-template/tree/fast-blazor-scoped-css-margin

🤔 Expected Behavior

I expect to be able to target the FAST Razor components with classes from CSS files like I would the corresponding Fast web component. In the screenshot above I would expect all the buttons to have the same margin.

😯 Current Behavior

See screenshot above.

💁 Possible Solution

Wait for https://github.com/dotnet/aspnetcore/issues/30213 to be resolved?

Some workarounds:

  • Use the Blazor-specific ::deep selector as shown in the last example above. The Blazor docs explicitly describe this as Child component support. This allows you to place component styles in a stylesheet participating in Blazor CSS isolation. Probably the best workaround so far.
  • Modify CSS that does not participate in Blazor CSS isolation:
    • Add <style> tags in the Razor HTML
    • Add styles to wwwroot/css
    • Use inline style="" attributes

🔦 Context

Trying to switch from using the FAST web components to using the FAST Razor components.

🌍 Your Environment

$ dotnet --version
5.0.400
<script type="module" src="https://unpkg.com/@fluentui/[email protected]/dist/web-components.min.js"></script>
<PackageReference Include="Microsoft.Fast.Components.FluentUI" Version="0.4.0" />

rajsite avatar Nov 09 '21 20:11 rajsite

@rajsite Thanks for putting this together. We're talking with Blazor engineers and will work towards a solution. This is obviously less than ideal.

EisenbergEffect avatar Nov 09 '21 21:11 EisenbergEffect

The solution here will likely involve https://github.com/dotnet/razor-tooling/issues/7606 since CSS isolation works at the file/component level. We can't allow you to pierce into the scope of a component unless that component explicitly allows it, which we don't really have a mechanism for.

One way to workaround this issue is to use the ::deep selector within the consuming component and make the style scoped to the callsite, for example, if your component is wrapped within a div do something like div::deep fluent-textfield on your scoped css file.

javiercn avatar Nov 09 '21 22:11 javiercn

Original workaround using wrapped ::deep hidden, See the top level issue description for the most recent workaround not requiring a wrapper > The solution here will likely involve [dotnet/razor-tooling#7606](https://github.com/dotnet/razor-tooling/issues/7606) since CSS isolation works at the file/component level. We can't allow you to pierce into the scope of a component unless that component explicitly allows it, which we don't really have a mechanism for. > > One way to workaround this issue is to use the `::deep` selector within the consuming component and make the style scoped to the callsite, for example, if your component is wrapped within a `div` do something like `div::deep fluent-textfield` on your scoped css file.

So we use Blazor's ::deep combinator which is a Blazor-specific feature that gets compiled out. The fundamental bit of the workaround is that we won't rely on the class set on the FAST Razor component and instead rely on an immediate wrapper and the ::deep selector to target the FAST web component child of the FAST Razor component.

So instead of the following (which does not work as expected):

Counter.razor.css:

.button-container { border: 1px solid red; }
.my-razor-button-scoped-css { margin: 16px; }

Counter.razor:

<div class="button-container">
    <FluentButton class="my-razor-button-scoped-css" @onclick="IncrementCount">Click me - scoped css</FluentButton>
</div>

The following workaround using ::deep and a wrapper element would accomplish a similar behavior:

Counter.razor.css:

.button-container { border: 1px solid red; }
.my-razor-button-scoped-css { display: contents; } /* Note: Set the wrapper to display contents so the child handles sizing behavior. We know this will target the immediate parent of the generated fluent-button. */
.my-razor-button-scoped-css ::deep fluent-button { margin: 16px; } /* Note: This selector is targeting the underlying FAST web component generated by the FAST Razor component. */

Counter.razor:

<div class="button-container">
    <div class="my-razor-button-scoped-css"> <!-- Note: This wrapper div was added and is used to help build the ::deep selector targeting the generated fluent-button FAST web component. -->
        <FluentButton @onclick="IncrementCount">Click me - scoped css</FluentButton>
    </div>
</div>

This seems workable. I'm on the fence if this is much better than some of the other workarounds like global CSS since it's a lot of munging in the DOM hierarchy.

If we had to assume one of these workarounds would be used long-term (maybe until .NET 7 or later @javiercn ?) then it might be better to have the encapsulation this option provides despite the maintenance burden of putting wrappers over every FAST Razor component usage.

I'll put a link in the workarounds options above.

rajsite avatar Nov 10 '21 00:11 rajsite

I may be mistaken, but I believe we missed the one configuration that will actually solve this perceived issue, which is taking the original CSS and simply changing it to:

.button-container { border: 1px solid red; }
.my-web-component-button-scoped-css { margin: 16px; }
::deep .my-razor-button-scoped-css { margin: 16px; }

I've verified that this yields the expected behavior with no changes to the .razor file (thus, no wrappers are necessary). I think this behavior jives with how we should expect Blazor isolated CSS to work (since the class we're applying to the Blazor component is actually applied to an element inside what is essentially a child component), and I believe we can close this issue out. I think it would be good to let @rajsite comment further before doing so however.

atmgrifter00 avatar Mar 04 '22 15:03 atmgrifter00

Unfortunately, ::deep was removed from the standards track several years ago and is only implemented in Chromium-based engines for backwards compat. It is deprecated.

EisenbergEffect avatar Mar 04 '22 16:03 EisenbergEffect

@rajsite Is a fix for this issue planned for the next Blazor release?

EisenbergEffect avatar Mar 04 '22 16:03 EisenbergEffect

@EisenbergEffect Blazor has a ::deep pseudo-selector in its implementation of scoped CSS, which is that this is talking about I think.

javiercn avatar Mar 04 '22 16:03 javiercn

@javiercn @EisenbergEffect, correct. As far as I know, the ::deep pseudo-selector is a necessary, well-supported construct for Blazor apps, otherwise the official docs are in dire need of updating.

atmgrifter00 avatar Mar 04 '22 16:03 atmgrifter00

Oh. Interesting. That is confusing since W3C had a selector with the same name in early versions of web components, which was intentionally removed...

EisenbergEffect avatar Mar 04 '22 17:03 EisenbergEffect

Yes...it's all horribly confusing, particularly since we've now been conditioned to not use that pseudo-selector in Angular apps, which was once their recommendation for crossing component boundaries.

atmgrifter00 avatar Mar 04 '22 17:03 atmgrifter00

Well, if this fixes the issues, that's great. I'd like to keep this open until we maybe add some documentation around this scenario. Anyone here interested in adding a small section to the readme?

EisenbergEffect avatar Mar 04 '22 17:03 EisenbergEffect

@atmgrifter00 That does look like a cleaner way to use the ::deep workaround (ie not requiring a wrapping element) and is described in the Blazor docs as Child component support. Good find!

I updated my top-level issue description and my example branch to use that workaround.

@EisenbergEffect @javiercn it still very much feels like a workaround but it is probably the workaround worth documenting. 👍 (as a community member using FAST, I'm not at Microsoft / representative of the Blazor team)

Hopefully there is some pattern that blazor / asp.net can adopt to make this use-case more first class. There is issue https://github.com/dotnet/razor-tooling/issues/7606, however another idea could be inspired from Angular.

In Angular the corresponding concept is having components vs directives. A component is a class with a template associated with it and a directive is a class without a template associated with it but instead with a selector associated with it. For our Angular fast web component wrappers we have defined Angular directives that match to the web components and can do Angulary behaviors (Angular Form participation, defining property mappings, etc).

Notice in the following example that the directive has a selector that it matches but does not instantiate a template:

/**
 * Directive to provide Angular integration for the button.
 */
@Directive({
    selector: 'nimble-button'
})
export class NimbleButtonDirective {
    public get appearance(): ButtonAppearance {
        return this.elementRef.nativeElement.appearance;
    }

    @Input() public set appearance(value: ButtonAppearance | ButtonAppearanceAttribute) {
        this.renderer.setProperty(this.elementRef.nativeElement, 'appearance', value);
    }

    public get disabled(): boolean {
        return this.elementRef.nativeElement.disabled;
    }

    @Input() public set disabled(value: BooleanValueOrAttribute) {
        this.renderer.setProperty(this.elementRef.nativeElement, 'disabled', toBooleanProperty(value));
    }

    // contentHidden property intentionally maps to the content-hidden attribute
    // eslint-disable-next-line @angular-eslint/no-input-rename
    @Input('content-hidden') public set contentHidden(value: BooleanValueOrAttribute) {
        this.renderer.setProperty(this.elementRef.nativeElement, 'contentHidden', toBooleanProperty(value));
    }

   // ...

    public constructor(private readonly renderer: Renderer2, private readonly elementRef: ElementRef<Button>) {}
}

I could imagine a similar concept like that in Blazor / ASP.net that could apply for web components. @javiercn Does something like that already exist to support the built-in elements, ie div, span, etc that could be extended for web components (particularly as https://github.com/dotnet/razor-tooling/issues/7606 was de-prioritized and doesn't seem to be making much progress)?

rajsite avatar Mar 12 '22 01:03 rajsite

I could imagine a similar concept like that in Blazor / ASP.net that could apply for web components. @javiercn Does something like that already exist to support the built-in elements, ie div, span, etc that could be extended for web components (particularly as dotnet/razor-tooling#7606 was de-prioritized and doesn't seem to be making much progress)?

I guess that concept is tagHelpers which aren't supported in Blazor.

rajsite avatar Mar 12 '22 23:03 rajsite