A log of all the places where we tried (and failed) to mimic the native HTML Element APIs.
Reference: https://storybooks.fluentui.dev/web-components/?path=/docs/concepts-introduction--docs
As a team we have a few culturally-enforced goals for our components.
- Single element compositions preferred over multi-element compositions (lower chance of mistakes)
- Low DOM complexity (e.g. no nested elements required, if avoidable) - partner teams are sensitive to node counts
- Prioritize accessibility and performance over everything else
- Need bundled template and style delivery
- Support size/appearance/shape variants on form controls
- Component names are inherited from React system, align to web whenever possible
Templates and adopted styles are composed into the element and we're supporting server-side rendering options as well. I'm picking out a set of elements where we've had to build workarounds to mimic native APIs. IIRC most of these support aria-* attributes and either delegate those attributes or handle those behaviors.
This often includes a lot of JavaScript (boo! no! angst!) API surface just to duplicate what the platform provides natively.
There are two "painpoints" we hope to solve through additional APIs like HTML*ElementBehaviors and/or referencetarget:
- Improve accessibility concerns like labelling
- Reduce script required to achieve native behaviors
For fluent-button we use ElementInternals.role = "button" to set a role and send a concise template.
<fluent-button>
<template srm=open>
<slot name="start"></slot>
<slot></slot>
<slot name="end"></slot>
</template>
</fluent-button>The major problem we encountered is that you can't mimic <button type="submit">
In order to support HTMLSubmitButton behaviors we mimic the an API. And even with all these type="submit"-centric attributes we cannot submit a form.
| Property | Solved by? |
|---|---|
disabled: boolean |
HTMLSubmitButtonBehavior |
formaction: string |
HTMLSubmitButtonBehavior |
form: string |
HTMLSubmitButtonBehavior |
formenctype: string |
HTMLSubmitButtonBehavior |
formmethod: string |
HTMLSubmitButtonBehavior |
formnovalidate: boolean |
HTMLSubmitButtonBehavior |
name: string |
HTMLSubmitButtonBehavior |
type: submit | reset | button |
HTMLSubmitButtonBehavior, HTMLResetButtonBehavior, HTMLButtonBehavior |
value: string |
HTMLSubmitButtonBehavior |
<fluent-button>
<template srm=open>
<button>
<slot name="start"></slot>
<slot></slot>
<slot name="end"></slot>
</button>
</template>
</fluent-button>We considered adding a nested shadowed <button> element, but that becomes just an extra DOM node since we can't pass/delegate attributes down to the control. If referencetarget existed and we could delegate attributes then we would maybe revist that decision and HTMLSubmitButtonBehaviors might not be necessary.
Other API options considered
<fluent-button>
<button>Light DOM Button</button>
</fluent-button>Requiring a Light DOM <button> as a slotted child would be a potential workaround, but wouldn't fully support our slotted icons and Light DOM styles would override shadowed styles. It also raises DOM complexity and increases chances of unaccepted elements (e.g. interactive elements inside button)
<button is="fluent-button>Extended Button</button>
- Not supported by Safari
- Shadow template limitations
- Binds to single element type
<fluent-checkbox>
<template srm=open>
<slot name="checked-indicator"><!-- default svg icon --></slot>
<slot name="indeterminate-indicator"><!-- default svg icon --></slot>
</template>
</fluent-checkbox>| Property | Solved by? |
|---|---|
disabled: boolean |
HTMLCheckboxBehavior? |
autofocus: boolean |
HTMLCheckboxBehavior? |
checked: boolean |
HTMLCheckboxBehavior? |
form: string |
HTMLCheckboxBehavior? |
indeterminate: boolean |
HTMLCheckboxBehavior? |
name: string |
HTMLCheckboxBehavior? |
required: boolean |
HTMLCheckboxBehavior? |
value: string |
HTMLCheckboxBehavior? |
Similar to button, nesting a native <input type="checkbox"> would just be an extra element (with styling limitations like height/width limits and no custom icon). Were something like referencetarget to exist, we could potentially revisit, but it wouldn't solve the style limitations (Updates to apperance: base fixes this?)
<fluent-label>
<template srm=open>
<slot><slot>
<span part="asterisk" ?hidden>*</span>
</template>
</fluent-label>role="label" in ARIA and we cannot set ElementInternals.role = "label". This does not actually label elements, so it ends up being an entirely decorative <span> (and potentially duplicative).
Actions like clicking <fluent-label> to enter the element don't work, so we had to change the API of Input and Textarea to include a native <label> element in the shadowroot.
HTMLLabelBehaviorswould help to get some of the actual label click/labelling behaviorsreferencetargetwould also be possible and we could use a native<label>but we have a couple "form-ish" attributes likerequiredfor showing/hiding the asterisk anddisabledfor a disabled appearance appearance (this could be redesigned).
<fluent-link>
<template srm=open>
<slot></slot>
<a inert><!-- Light DOM injected link --></a>
</template>
</fluent-link>To achieve Ctrl|Shift|Meta + Click behaviors we had to injet a native anchor into the Light DOM to intercept the behavior.
| Property | Solved By? |
|---|---|
href: string |
? |
hreflang: string |
? |
reffererpolicy: string |
? |
rel: string |
? |
type: string |
? |
target: _self | _blank | _parent | _top |
? |
Mimics native elements
<fluent-progress-bar>
<template srm=open>
<div part="indicator"></div>
</template>
</fluent-progress-bar>| Property | Solved By? |
|---|---|
max: number |
? |
min: number |
? |
value: number |
? |
<fluent-radio tabindex="0">
<template srm=open>
<slot name="checked-indicator">
<!-- default slotted indicator -->
<span part="checked-indicator" role="presentation"></span>
</slot>
</template>
</fluent-radio>tabindex="0" is injected by the client
| Property | Solved By? |
|---|---|
checked: boolean |
? |
disabled: boolean |
? |
Fieldset, basically
<fluent-radio-group>
<template srm=open>
<slot><!--fluent-field with label + radio go here --></slot>
</template>
</fluent-radio-group>| Property | Solved By? |
|---|---|
disabled: boolean |
? |
<fluent-slider tabindex="0">
<template srm=open>
<div part="track-container">
::before <!-- filled line -->
</div>
<div part="thumb-container">
<slot name="thumb">
<!-- default thumb -->
<div class="thumb"></div>
</slot>
</div>
</template>
</fluent-slider>tabindex="0" is injected
| Property | Solved By? |
|---|---|
disabled: boolean |
? |
min: number |
? |
value: number |
? |
step: number |
? |
orientation: horizontal | vertical |
? |
<fluent-switch tabindex="0">
<template srm=open>
<slot name="switch">
<!-- default switch indicator -->
<span part="checked-indicator"></span>
</slot>
</template>
</fluent-switch>tabindex="0" is injected
| Property | Solved By? |
|---|---|
checked: boolean |
? |
disabled: boolean |
? |
required: boolean |
? |
The design of this uses a native textarea in the shadowroot because it's was too risky to build a proper textarea with contenteditable. We had to include a label for the shadow textarea because we couldn't label from outside. referencetarget would clean that up and allow for an actual label.
<fluent-textarea>
<template srm=open>
<label for="control" part="label" ?hidden>
<slot="label"><!-- label text goes here --><slot>
</label>
<div part="root">
<textarea part="control"></textarea>
</div>
<div ?hidden>
<slot></slot>
</div>
</template>
</fluent-textarea>| Property | Solved By? |
|---|---|
autocomplete: string |
? |
autofocus: boolean |
? |
dirname: string |
? |
disabled: boolean |
? |
form: string |
? |
maxlength: number |
? |
minlength: number |
? |
name: string |
? |
placeholder: string |
? |
readOnly: boolean |
? |
required: boolean |
? |
resize: none | both | horizontal | vertical |
? |
spellcheck: boolean |
? |
The design of this uses a native input because it's was too risky to build a proper text input with contenteditable. We also feel the native input API is a bit overloaded and so we scope our "TextInput" to textual inputs (email, password, text, tel, url, etc) only.
<fluent-input>
<template srm=open>
<label part="label" for="control">
<slot><!-- text goes here --></slot>
</label>
<div class="root" part="root">
<slot name="start"></slot>
<input part="control" id="control" type="text">
<slot name="end"></slot>
</div>
</template>
</fluent-input>We had to include a label for the shadow input because we couldn't label from outside. referencetarget would clean that up and allow for an actual label.
This only covers a small subset of input attributes because we scope this to only textual inputs.
- Supported:
email | password | text | tel | url - NOT supported:
button | checkbox | color | date | datetime-local | file | hidden | image | month | number | radio | range | time | week | ...etc
Additionally we ignore attributes like autocapitalize, pattern, and others...
| Property | Specific input type? | Solved By? |
|---|---|---|
autocomplete: string |
all | ? |
autofocus: boolean |
all | ? |
dirname: string |
text, search, url, tel, email | ? |
disabled: boolean |
all | ? |
form: string |
all | ? |
list: string |
all, !password | ? |
maxlength: number |
all | ? |
minlength: number |
all | ? |
multiple: boolean |
? | |
name: string |
all | ? |
pattern: string |
all | ? |
placeholder: string |
all | ? |
readOnly: boolean |
all | ? |
required: boolean |
all | ? |
type: email | password | tel | text | url |
all | ? |
value: string |
all | ? |