When we define base component classes in 'fast-foundation' we typically include a default template which is then used in libraries that actually declare a component implementation. For example we export a 'Badge' class and 'BadgeTemplate' from 'fast-foundation' which are used to declare the
import { Badge, BadgeTemplate as template } from "@microsoft/fast-foundation";
import { BadgeStyles as styles } from "./badge.styles";
/**
* The FAST Badge Element. Implements {@link @microsoft/fast-foundation#Badge},
* {@link @microsoft/fast-foundation#BadgeTemplate}
*
*
* @public
* @remarks
* HTML Element: \<fast-badge\>
*/
@customElement({
name: "fast-badge",
template,
styles,
})
export class FASTBadge extends Badge {}
This works well for simple components like Badge that only use basic html elements in their templates. However when a component wants to include other custom elements within its own template things become problematic as the template will need to be customized every time it is used in a differently named library. Examples of this include 'data-grid' which needs to create child elements at runtime (ie. 'fast-data-grid' needs to create 'fast-data-grid-row' elements while a fluent version would want to create 'fluent-data-grid-row' elements), or a 'tooltip' component which includes a 'fast-anchored-region' in its template when it is in the 'fast' library and presumably a 'fluent-anchored-region' in the fluent library.
The current path available to address this issue involves creating custom templates for these components rather than reuse the ones in 'fast-foundation' which is not ideal.
This is a tricky issue. I have thoughts on a long, medium, and short-term solutions.
In the long-term, we'll have a new browser feature which will enable us to instantiate custom element registries and associate those with shadow dom. The registry will allow us to map tag names to element definitions, scoped to the shadow root. By combining this with our new provider system, we could have elements declare their dependencies in terms of the tag name used internal to the template, along with the base component class. Then, we can turn that into an element registry that maps the element name used in the template to the actual component class defined by the design system. If we're able to eliminate the need for class inheritance for different design systems, then this is trivially easy based on the proposed API. If we can't eliminate the inheritance, we can still achieve the same end result, just with a bit more code.
This isn't something we can do today because the standard hasn't been finalized or implemented in any browser. But, it's something we can bake into fast-element down the road and update our code to take advantage of when available.
A very simple short-term solution occurred to me last week. For the special composed elements like fast-data-grid-row, we can register the element with two names in the fluent package. So, when we define fluent-data-grid-row, we also manually call the imperative FASTElement.define API to define it a second time with the name fast-data-grid-row. We can pick and choose the elements we do this with. This will guarantee that any templates defined in foundation in terms of fast-prefixed elements will have those tag names defined with the fluent implementation.
Alternatively, if we are uncomfortable with using the fast- prefix for this case, we can create an API in fast element that takes a base tag name and generates a consistent (but random per run) prefixed element name. This helper could be used inside the foundation templates and could be used for the secondary registration in fluent. Something like this:
// in fast-foundation
const template = html`
<${helper('data-grid-row')}>blah, blah, blah</${helper('data-grid-row')}>
`;
// in fluent web-components
@customElement({
name: 'fluent-data-grid-row'
...
})
class FluentDataGridRow extends DataGridRow { ... }
FASTElement.define(FluentDataGridRow, {
name: helper('data-grid-row'),
...
});
UPDATE: It turns out that we can't use the same class with different tag names in the define API. However, if we inherit the class before re-defining it, it will work just fine.
Once we have the new provider system in place and the short-term solution mentioned above, we could add a new API to the provider system to do the double registration, thus eliminating some boilerplate code. We might be able to build the public API for the long-term solution, but leverage the provider to translate that API into the short term implementation. Then, later on down the road, we can swap it for actual custom registries.
So, for example, we might define templates like this:
// in fast-foundation
const template = html`
<fast-data-grid-row>blah, blah, blah</fast-data-grid-row>
`.withRegistry({
'fast-data-grid-row': DataGridRow
});
Inside withRegistry we could have a fake implementation that sets up metadata for the provider so that when DataGridRow gets defined, it defines it a second time using the fast-data-grid-row tag (if it's not already defined under that tag name).
Seems like a reasonable plan to me. So unless there are other alternatives we could get more specific about what needs to happen to implement a short term solution so we can unblock?
The first decision we need to make is whether we're ok just using the fast- prefix or wether we feel we need some helper that generates a random prefix and obscures that. I'd prefer to go the simple route of using the fast- prefix, at least at first. If we do that, all we need to do is call FASTElement.define with the fast- prefix based name for each of the child components and use some inheritance to satisfy the platform. So, in our @fluent/webcomponents package, we need something like this for each embedded component:
export class FluentDataGridRow extends DataGridRow {
// any extra code here
}
FASTElement.define(FluentDataGridRow, {
name: 'fluent-data-grid-row',
template,
styles
});
FASTElement.define(class extends FluentDataGridRow, {
name: 'fast-data-grid-row',
template,
styles
});
Basically, remove the customElement decorator and instead manually call the define API. Call it twice though, registering it with the fluent- prefix and then a second time with the fast- prefix (and a bit of inheritance).
Does that have consequences for scenarios where people mix libraries in the same project? (ie. tries to use both fluent and fast elements)
It definitely would. If they used fast-components and the fluent components together the library would try to register the same tag name. It's possible we could add a new API in foundation for aliasing elements, which could prevent dupes. However, if the actual element is different because the fluent version adds some additional properties or behavior, then things would break in another way as the wrong version of the element would be registered even in that case.
I'm not sure there's a perfect solution. Even with custom element registries this particular scenario could be tricky because you would have to look at the prefix of the parent element in order to determine which concrete elements to associate with the registry in the shadow dom. Once some of our new provider work lands...if we're able to eliminate all the scenarios where inheritance is needed for different elements...and it can all come from the design system, then that makes some of this easier.
There's another option I just though of. Instead of writing templates, we can write template factories, that take a prefix as an input.
export const createDataGridTemplate = (prefix: string) => html<DataGrid>`
<${prefix}-data-grid-row></${prefix}-data-grid-row>
`;
@customElement({
name: 'fast-data-grid',
template: createDataGridTemplate('fast')
})
@customElement({
name: 'fluent-data-grid',
template: createDataGridTemplate('fluent')
})
That seems interesting, fewer cases where implementers need to make custom templates, and we could remove the "fast" references from the foundation library as well.
Yes, I'm a little embarrassed this wasn't my first thought actually 馃ぃ
We only need to do this for templates that embed other elements. It's just a minor change, isn't a hack, and is going to have the best memory/perf characteristics of the options I believe.
There's another option I just though of. Instead of writing templates, we can write template factories, that take a prefix as an input.
export const createDataGridTemplate = (prefix: string) => html<DataGrid>` <${prefix}-data-grid-row></${prefix}-data-grid-row> `;@customElement({ name: 'fast-data-grid', template: createDataGridTemplate('fast') })@customElement({ name: 'fluent-data-grid', template: createDataGridTemplate('fluent') })
I think this is more along the lines of what I was envisioning. I think this will really help and provide a solid solution and path forward for these!
Actually, wouldn't we want a generic 'createComponentTemplate(component.template.template, myPrefix)' function rather than component specific ones? And if so, do we need to differentiate template templates from regular templates?
I'm not sure what a generic function would do. I worry that this might add complexity when we can just use a simple function for this case.
I'd agree with the above - I think specific functions will help avoid confusion as to when to leverage, etc.
How would we differentiate between a component like data-grid which has a template factory and one that does not? ie. how does an implementor know to call the function in a custom component declaration?
I don't think the types will match up if they are in TS. I'd rely on that plus documenting the exported function for now.
I'll see about rigging data-grid as a sample then, I think I have all I need to give it try.
I have added a draft PR for tooltip with a proposed implementation as well as updated the existing Data Grid pr.. The data grid one is interesting because it also customizes the templates used to generate items as well as the component's base template.
The DataGrid PR is interesting, the way you composed those together. Seems like it will work.
Most helpful comment
There's another option I just though of. Instead of writing templates, we can write template factories, that take a prefix as an input.