From 57e1e3848968c48dfa9769848e0c94edb975bec9 Mon Sep 17 00:00:00 2001 From: Zacky Ma Date: Fri, 24 Jul 2026 10:25:43 -0700 Subject: [PATCH 1/2] make native disabled state support SSR better (#36441) --- ...-a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c.json | 7 ++ .../src/button/button.styles.css | 60 ++++++++++++--- .../src/button/button.styles.ts | 33 ++++++--- .../src/checkbox/checkbox.styles.css | 26 +++---- .../src/checkbox/checkbox.styles.ts | 28 +++---- .../compound-button.styles.css | 73 +++++++++++++++---- .../compound-button/compound-button.styles.ts | 13 +++- .../src/dropdown/dropdown.styles.css | 16 ++-- .../src/dropdown/dropdown.styles.ts | 18 ++--- .../src/menu-button/menu-button.styles.css | 60 ++++++++++++--- .../src/option/option.styles.css | 8 +- .../src/option/option.styles.ts | 16 ++-- .../src/slider/slider.styles.css | 4 +- .../src/slider/slider.styles.ts | 5 +- .../web-components/src/styles/states/index.ts | 10 +++ .../src/switch/switch.styles.css | 12 +-- .../src/switch/switch.styles.ts | 14 ++-- .../src/text-input/text-input.styles.css | 10 +-- .../src/text-input/text-input.styles.ts | 11 +-- .../src/textarea/textarea.styles.css | 12 +-- .../src/textarea/textarea.styles.ts | 14 ++-- .../toggle-button/toggle-button.styles.css | 60 ++++++++++++--- 22 files changed, 353 insertions(+), 157 deletions(-) create mode 100644 change/@fluentui-web-components-a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c.json diff --git a/change/@fluentui-web-components-a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c.json b/change/@fluentui-web-components-a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c.json new file mode 100644 index 00000000000000..f0959817887eb7 --- /dev/null +++ b/change/@fluentui-web-components-a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "make native disabled state support SSR better", + "packageName": "@fluentui/web-components", + "email": "machi@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/src/button/button.styles.css b/packages/web-components/src/button/button.styles.css index 15bddfefdbf8a4..72d0e7377f1cf0 100644 --- a/packages/web-components/src/button/button.styles.css +++ b/packages/web-components/src/button/button.styles.css @@ -213,33 +213,60 @@ border-color: transparent; } -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) { +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover:active + ) { background-color: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); color: var(--colorNeutralForegroundDisabled); cursor: not-allowed; } -:host([appearance='primary']:is(:disabled, [disabled-focusable])), -:host([appearance='primary']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; } -:host([appearance='outline']:is(:disabled, [disabled-focusable])), -:host([appearance='outline']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); } -:host([appearance='subtle']:is(:disabled, [disabled-focusable])), -:host([appearance='subtle']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); border-color: transparent; } -:host([appearance='transparent']:is(:disabled, [disabled-focusable])), -:host([appearance='transparent']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; background-color: var(--colorTransparentBackground); } @@ -260,7 +287,16 @@ forced-color-adjust: none; } - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])) { + :host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ) { background-color: ButtonFace; color: GrayText; border-color: ButtonText; diff --git a/packages/web-components/src/button/button.styles.ts b/packages/web-components/src/button/button.styles.ts index 2aeca76b3c60ed..3a220df4f58ff2 100644 --- a/packages/web-components/src/button/button.styles.ts +++ b/packages/web-components/src/button/button.styles.ts @@ -280,33 +280,33 @@ export const baseButtonStyles = css` export const styles = css` ${baseButtonStyles} - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])), - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover), - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) { + :host(:is(:disabled, [disabled], [disabled-focusable], [appearance]:disabled, [appearance][disabled], [appearance][disabled-focusable])), + :host(:is(:disabled, [disabled], [disabled-focusable], [appearance]:disabled, [appearance][disabled], [appearance][disabled-focusable]):hover), + :host(:is(:disabled, [disabled], [disabled-focusable], [appearance]:disabled, [appearance][disabled], [appearance][disabled-focusable]):hover:active) { background-color: ${colorNeutralBackgroundDisabled}; border-color: ${colorNeutralStrokeDisabled}; color: ${colorNeutralForegroundDisabled}; cursor: not-allowed; } - :host([appearance='primary']:is(:disabled, [disabled-focusable])), - :host([appearance='primary']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { + :host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable])), + :host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; } - :host([appearance='outline']:is(:disabled, [disabled-focusable])), - :host([appearance='outline']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { + :host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable])), + :host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: ${colorTransparentBackground}; } - :host([appearance='subtle']:is(:disabled, [disabled-focusable])), - :host([appearance='subtle']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { + :host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable])), + :host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: ${colorTransparentBackground}; border-color: transparent; } - :host([appearance='transparent']:is(:disabled, [disabled-focusable])), - :host([appearance='transparent']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { + :host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable])), + :host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; background-color: ${colorTransparentBackground}; } @@ -327,7 +327,16 @@ export const styles = css` forced-color-adjust: none; } - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])) { + :host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ) { background-color: ButtonFace; color: GrayText; border-color: ButtonText; diff --git a/packages/web-components/src/checkbox/checkbox.styles.css b/packages/web-components/src/checkbox/checkbox.styles.css index 8ebe81e8520071..0800defa4d9d94 100644 --- a/packages/web-components/src/checkbox/checkbox.styles.css +++ b/packages/web-components/src/checkbox/checkbox.styles.css @@ -109,21 +109,21 @@ border-radius: var(--borderRadiusCircular); } -:host([disabled]), -:host([disabled]:state(checked)) { +:host(:is([disabled], :disabled)), +:host(:is([disabled], :disabled):state(checked)) { background-color: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); } -:host([disabled]) { +:host(:is([disabled], :disabled)) { cursor: unset; } -:host([disabled]:state(indeterminate)) .indeterminate-indicator { +:host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator { background-color: var(--colorNeutralStrokeDisabled); } -:host([disabled]:state(checked)) .checked-indicator { +:host(:is([disabled], :disabled):state(checked)) .checked-indicator { color: var(--colorNeutralStrokeDisabled); } @@ -136,8 +136,8 @@ border-color: Canvas; } - :host(:not([disabled]):hover), - :host(:state(checked):not([disabled]):hover), + :host(:not(:is([disabled], :disabled)):hover), + :host(:state(checked):not(:is([disabled], :disabled)):hover), :host(:not([slot='input']):focus-visible)::after { border-color: Highlight; } @@ -152,21 +152,21 @@ background-color: FieldText; } - :host(:state(checked):not([disabled]):hover), - :host(:state(indeterminate):not([disabled]):hover) .indeterminate-indicator { + :host(:state(checked):not(:is([disabled], :disabled)):hover), + :host(:state(indeterminate):not(:is([disabled], :disabled)):hover) .indeterminate-indicator { background-color: Highlight; } - :host([disabled]) { + :host(:is([disabled], :disabled)) { border-color: GrayText; } - :host([disabled]:state(indeterminate)) .indeterminate-indicator { + :host(:is([disabled], :disabled):state(indeterminate)) .indeterminate-indicator { background-color: GrayText; } - :host([disabled]), - :host([disabled]:state(checked)) .checked-indicator { + :host(:is([disabled], :disabled)), + :host(:is([disabled], :disabled):state(checked)) .checked-indicator { color: GrayText; } } diff --git a/packages/web-components/src/checkbox/checkbox.styles.ts b/packages/web-components/src/checkbox/checkbox.styles.ts index d3d07c3ab1bb2d..4ef59bb5236b54 100644 --- a/packages/web-components/src/checkbox/checkbox.styles.ts +++ b/packages/web-components/src/checkbox/checkbox.styles.ts @@ -1,5 +1,5 @@ import { css } from '@microsoft/fast-element'; -import { checkedState, indeterminateState } from '../styles/states/index.js'; +import { checkedState, indeterminateState, nativeDisabledState } from '../styles/states/index.js'; import { borderRadiusCircular, borderRadiusMedium, @@ -135,21 +135,21 @@ export const styles = css` border-radius: ${borderRadiusCircular}; } - :host([disabled]), - :host([disabled]${checkedState}) { + :host(${nativeDisabledState}), + :host(${nativeDisabledState}${checkedState}) { background-color: ${colorNeutralBackgroundDisabled}; border-color: ${colorNeutralStrokeDisabled}; } - :host([disabled]) { + :host(${nativeDisabledState}) { cursor: unset; } - :host([disabled]${indeterminateState}) .indeterminate-indicator { + :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator { background-color: ${colorNeutralStrokeDisabled}; } - :host([disabled]${checkedState}) .checked-indicator { + :host(${nativeDisabledState}${checkedState}) .checked-indicator { color: ${colorNeutralStrokeDisabled}; } @@ -162,8 +162,8 @@ export const styles = css` border-color: Canvas; } - :host(:not([disabled]):hover), - :host(${checkedState}:not([disabled]):hover), + :host(:not(${nativeDisabledState}):hover), + :host(${checkedState}:not(${nativeDisabledState}):hover), :host(:not([slot='input']):focus-visible)::after { border-color: Highlight; } @@ -178,21 +178,21 @@ export const styles = css` background-color: FieldText; } - :host(${checkedState}:not([disabled]):hover), - :host(${indeterminateState}:not([disabled]):hover) .indeterminate-indicator { + :host(${checkedState}:not(${nativeDisabledState}):hover), + :host(${indeterminateState}:not(${nativeDisabledState}):hover) .indeterminate-indicator { background-color: Highlight; } - :host([disabled]) { + :host(${nativeDisabledState}) { border-color: GrayText; } - :host([disabled]${indeterminateState}) .indeterminate-indicator { + :host(${nativeDisabledState}${indeterminateState}) .indeterminate-indicator { background-color: GrayText; } - :host([disabled]), - :host([disabled]${checkedState}) .checked-indicator { + :host(${nativeDisabledState}), + :host(${nativeDisabledState}${checkedState}) .checked-indicator { color: GrayText; } } diff --git a/packages/web-components/src/compound-button/compound-button.styles.css b/packages/web-components/src/compound-button/compound-button.styles.css index 5efe49e318540a..548d2aac35b344 100644 --- a/packages/web-components/src/compound-button/compound-button.styles.css +++ b/packages/web-components/src/compound-button/compound-button.styles.css @@ -213,33 +213,60 @@ border-color: transparent; } -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) { +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover:active + ) { background-color: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); color: var(--colorNeutralForegroundDisabled); cursor: not-allowed; } -:host([appearance='primary']:is(:disabled, [disabled-focusable])), -:host([appearance='primary']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; } -:host([appearance='outline']:is(:disabled, [disabled-focusable])), -:host([appearance='outline']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); } -:host([appearance='subtle']:is(:disabled, [disabled-focusable])), -:host([appearance='subtle']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); border-color: transparent; } -:host([appearance='transparent']:is(:disabled, [disabled-focusable])), -:host([appearance='transparent']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; background-color: var(--colorTransparentBackground); } @@ -260,7 +287,16 @@ forced-color-adjust: none; } - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])) { + :host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ) { background-color: ButtonFace; color: GrayText; border-color: ButtonText; @@ -323,7 +359,16 @@ color: var(--colorNeutralForeground2BrandPressed); } -:host(:is(:disabled, :disabled[appearance], [disabled-focusable], [disabled-focusable][appearance])) +:host( + :is( + :disabled, + :disabled[appearance], + [disabled], + [disabled][appearance], + [disabled-focusable], + [disabled-focusable][appearance] + ) + ) ::slotted([slot='description']) { color: var(--colorNeutralForegroundDisabled); } @@ -363,7 +408,7 @@ } @media (forced-colors: active) { - :host([appearance='primary']:not(:hover, :focus-visible, :disabled, [disabled-focusable])) + :host([appearance='primary']:not(:hover, :focus-visible, :disabled, [disabled], [disabled-focusable])) ::slotted([slot='description']) { color: HighlightText; } diff --git a/packages/web-components/src/compound-button/compound-button.styles.ts b/packages/web-components/src/compound-button/compound-button.styles.ts index f506f4fdc2be64..ec1738ee71be49 100644 --- a/packages/web-components/src/compound-button/compound-button.styles.ts +++ b/packages/web-components/src/compound-button/compound-button.styles.ts @@ -79,7 +79,16 @@ export const styles = css` color: ${colorNeutralForeground2BrandPressed}; } - :host(:is(:disabled, :disabled[appearance], [disabled-focusable], [disabled-focusable][appearance])) + :host( + :is( + :disabled, + :disabled[appearance], + [disabled], + [disabled][appearance], + [disabled-focusable], + [disabled-focusable][appearance] + ) + ) ::slotted([slot='description']) { color: ${colorNeutralForegroundDisabled}; } @@ -119,7 +128,7 @@ export const styles = css` } @media (forced-colors: active) { - :host([appearance='primary']:not(:hover, :focus-visible, :disabled, [disabled-focusable])) + :host([appearance='primary']:not(:hover, :focus-visible, :disabled, [disabled], [disabled-focusable])) ::slotted([slot='description']) { color: HighlightText; } diff --git a/packages/web-components/src/dropdown/dropdown.styles.css b/packages/web-components/src/dropdown/dropdown.styles.css index 12bd0156cac306..cdbbf2ba944329 100644 --- a/packages/web-components/src/dropdown/dropdown.styles.css +++ b/packages/web-components/src/dropdown/dropdown.styles.css @@ -175,18 +175,18 @@ --control-border-color: var(--colorTransparentStroke); } -:host(:disabled), -:host(:disabled) ::slotted(:where(button, input)) { +:host(:is([disabled], :disabled)), +:host(:is([disabled], :disabled)) ::slotted(:where(button, input)) { cursor: not-allowed; } -:host(:disabled) .control::before, -:host(:disabled) .control::after { +:host(:is([disabled], :disabled)) .control::before, +:host(:is([disabled], :disabled)) .control::after { content: none; } -:host(:disabled) .control:is(*, :active, :hover), -:host(:disabled) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { +:host(:is([disabled], :disabled)) .control:is(*, :active, :hover), +:host(:is([disabled], :disabled)) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { --control-border-color: var(--colorNeutralStrokeDisabled); background-color: var(--colorNeutralBackgroundDisabled); color: var(--colorNeutralForegroundDisabled); @@ -213,10 +213,10 @@ } @media (forced-colors: active) { - :host(:disabled) .control { + :host(:is([disabled], :disabled)) .control { border-color: GrayText; } - :host(:disabled) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { + :host(:is([disabled], :disabled)) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { color: GrayText; } } diff --git a/packages/web-components/src/dropdown/dropdown.styles.ts b/packages/web-components/src/dropdown/dropdown.styles.ts index 0e52c6e03a7fee..610c9e79140d99 100644 --- a/packages/web-components/src/dropdown/dropdown.styles.ts +++ b/packages/web-components/src/dropdown/dropdown.styles.ts @@ -4,7 +4,7 @@ import { typographyBody2Styles, typographyCaption1Styles, } from '../styles/partials/typography.partials.js'; -import { openState, placeholderShownState } from '../styles/states/index.js'; +import { nativeDisabledState, openState, placeholderShownState } from '../styles/states/index.js'; import { borderRadiusMedium, borderRadiusNone, @@ -216,18 +216,18 @@ export const styles = css` --control-border-color: ${colorTransparentStroke}; } - :host(:disabled), - :host(:disabled) ::slotted(:where(button, input)) { + :host(${nativeDisabledState}), + :host(${nativeDisabledState}) ::slotted(:where(button, input)) { cursor: not-allowed; } - :host(:disabled) .control::before, - :host(:disabled) .control::after { + :host(${nativeDisabledState}) .control::before, + :host(${nativeDisabledState}) .control::after { content: none; } - :host(:disabled) .control:is(*, :active, :hover), - :host(:disabled) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { + :host(${nativeDisabledState}) .control:is(*, :active, :hover), + :host(${nativeDisabledState}) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { --control-border-color: ${colorNeutralStrokeDisabled}; background-color: ${colorNeutralBackgroundDisabled}; color: ${colorNeutralForegroundDisabled}; @@ -254,10 +254,10 @@ export const styles = css` } @media (forced-colors: active) { - :host(:disabled) .control { + :host(${nativeDisabledState}) .control { border-color: GrayText; } - :host(:disabled) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { + :host(${nativeDisabledState}) :where(slot[name='indicator'] > *, ::slotted([slot='indicator'])) { color: GrayText; } } diff --git a/packages/web-components/src/menu-button/menu-button.styles.css b/packages/web-components/src/menu-button/menu-button.styles.css index 15bddfefdbf8a4..72d0e7377f1cf0 100644 --- a/packages/web-components/src/menu-button/menu-button.styles.css +++ b/packages/web-components/src/menu-button/menu-button.styles.css @@ -213,33 +213,60 @@ border-color: transparent; } -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) { +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover:active + ) { background-color: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); color: var(--colorNeutralForegroundDisabled); cursor: not-allowed; } -:host([appearance='primary']:is(:disabled, [disabled-focusable])), -:host([appearance='primary']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; } -:host([appearance='outline']:is(:disabled, [disabled-focusable])), -:host([appearance='outline']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); } -:host([appearance='subtle']:is(:disabled, [disabled-focusable])), -:host([appearance='subtle']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); border-color: transparent; } -:host([appearance='transparent']:is(:disabled, [disabled-focusable])), -:host([appearance='transparent']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; background-color: var(--colorTransparentBackground); } @@ -260,7 +287,16 @@ forced-color-adjust: none; } - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])) { + :host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ) { background-color: ButtonFace; color: GrayText; border-color: ButtonText; diff --git a/packages/web-components/src/option/option.styles.css b/packages/web-components/src/option/option.styles.css index cedee7d592ca3b..015abfab1f9d32 100644 --- a/packages/web-components/src/option/option.styles.css +++ b/packages/web-components/src/option/option.styles.css @@ -46,7 +46,7 @@ color: var(--colorNeutralForeground2Pressed); } -:host(:disabled) { +:host(:is([disabled], :disabled)) { background-color: var(--colorNeutralBackground1); color: var(--colorNeutralForegroundDisabled); cursor: default; @@ -92,11 +92,11 @@ slot[name='checked-indicator'] > *, fill: var(--colorNeutralForegroundInverted); } -:host(:disabled:state(multiple)) .checkmark-12-regular { +:host(:is([disabled], :disabled):state(multiple)) .checkmark-12-regular { border-color: var(--colorNeutralStrokeDisabled); } -:host(:disabled:state(multiple):state(selected)) .checkmark-12-regular { +:host(:is([disabled], :disabled):state(multiple):state(selected)) .checkmark-12-regular { background-color: var(--colorNeutralBackgroundDisabled); } @@ -128,7 +128,7 @@ slot[name='checked-indicator'] > *, } @media (forced-colors: active) { - :host(:disabled) { + :host(:is([disabled], :disabled)) { color: GrayText; } } diff --git a/packages/web-components/src/option/option.styles.ts b/packages/web-components/src/option/option.styles.ts index 6da6bbc1ff5807..55ce349c495c2a 100644 --- a/packages/web-components/src/option/option.styles.ts +++ b/packages/web-components/src/option/option.styles.ts @@ -1,6 +1,12 @@ import { css } from '@microsoft/fast-element'; import { typographyBody1Styles, typographyCaption1Styles } from '../styles/partials/typography.partials.js'; -import { activeState, descriptionState, multipleState, selectedState } from '../styles/states/index.js'; +import { + activeState, + descriptionState, + multipleState, + nativeDisabledState, + selectedState, +} from '../styles/states/index.js'; import { borderRadiusMedium, borderRadiusSmall, @@ -71,7 +77,7 @@ export const styles = css` color: ${colorNeutralForeground2Pressed}; } - :host(:disabled) { + :host(${nativeDisabledState}) { background-color: ${colorNeutralBackground1}; color: ${colorNeutralForegroundDisabled}; cursor: default; @@ -117,11 +123,11 @@ export const styles = css` fill: ${colorNeutralForegroundInverted}; } - :host(:disabled${multipleState}) .checkmark-12-regular { + :host(${nativeDisabledState}${multipleState}) .checkmark-12-regular { border-color: ${colorNeutralStrokeDisabled}; } - :host(:disabled${multipleState}${selectedState}) .checkmark-12-regular { + :host(${nativeDisabledState}${multipleState}${selectedState}) .checkmark-12-regular { background-color: ${colorNeutralBackgroundDisabled}; } @@ -149,7 +155,7 @@ export const styles = css` } @media (forced-colors: active) { - :host(:disabled) { + :host(${nativeDisabledState}) { color: GrayText; } } diff --git a/packages/web-components/src/slider/slider.styles.css b/packages/web-components/src/slider/slider.styles.css index 2bbf60a4e9abf0..9e517e55e2dc69 100644 --- a/packages/web-components/src/slider/slider.styles.css +++ b/packages/web-components/src/slider/slider.styles.css @@ -37,12 +37,12 @@ --rail-color: var(--colorCompoundBrandBackgroundPressed); } -:host(:disabled) { +:host(:is([disabled], :disabled)) { --rail-color: var(--colorNeutralForegroundDisabled); --track-color: var(--colorNeutralBackgroundDisabled); } -:host(:not(:disabled)) { +:host(:not(:is([disabled], :disabled))) { cursor: pointer; } diff --git a/packages/web-components/src/slider/slider.styles.ts b/packages/web-components/src/slider/slider.styles.ts index 826ac979278516..c772d6498531da 100644 --- a/packages/web-components/src/slider/slider.styles.ts +++ b/packages/web-components/src/slider/slider.styles.ts @@ -1,5 +1,6 @@ import { css } from '@microsoft/fast-element'; import { display } from '../utils/display.js'; +import { nativeDisabledState } from '../styles/states/index.js'; import { borderRadiusCircular, borderRadiusMedium, @@ -54,12 +55,12 @@ export const styles = css` --rail-color: ${colorCompoundBrandBackgroundPressed}; } - :host(:disabled) { + :host(${nativeDisabledState}) { --rail-color: ${colorNeutralForegroundDisabled}; --track-color: ${colorNeutralBackgroundDisabled}; } - :host(:not(:disabled)) { + :host(:not(${nativeDisabledState})) { cursor: pointer; } diff --git a/packages/web-components/src/styles/states/index.ts b/packages/web-components/src/styles/states/index.ts index 0d366084428bba..fe06c0764bf492 100644 --- a/packages/web-components/src/styles/states/index.ts +++ b/packages/web-components/src/styles/states/index.ts @@ -35,6 +35,16 @@ export const descriptionState = stateSelector('description'); */ export const disabledState = stateSelector('disabled'); +/** + * Selector for the native `disabled` state for elements that are + * form-associated, which supports the native `:disabled` pseudo class when + * the `disabled` attribute is present. The `disabled` attribute selector is + * for SSR support because `:disabled` is not supported before the element is + * defined (as a form-associated custom element). + * @public + */ +export const nativeDisabledState = ':is([disabled], :disabled)'; + /** * Selector for the `error` state. * @public diff --git a/packages/web-components/src/switch/switch.styles.css b/packages/web-components/src/switch/switch.styles.css index 822b4a1d2908d1..2f94c49e3212fa 100644 --- a/packages/web-components/src/switch/switch.styles.css +++ b/packages/web-components/src/switch/switch.styles.css @@ -31,7 +31,7 @@ :host(:active) { border-color: var(--colorNeutralStrokeAccessiblePressed); } -:host(:disabled), +:host(:is([disabled], :disabled)), :host([readonly]) { border: 1px solid var(--colorNeutralStrokeDisabled); background-color: none; @@ -49,7 +49,7 @@ background: var(--colorCompoundBrandBackgroundPressed); border-color: var(--colorCompoundBrandBackgroundPressed); } -:host(:state(checked):disabled) { +:host(:state(checked):is([disabled], :disabled)) { background: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); } @@ -79,11 +79,11 @@ :host(:active) .checked-indicator { background-color: var(--colorNeutralForeground3Pressed); } -:host(:disabled) .checked-indicator, +:host(:is([disabled], :disabled)) .checked-indicator, :host([readonly]) .checked-indicator { background: var(--colorNeutralForegroundDisabled); } -:host(:state(checked):disabled) .checked-indicator { +:host(:state(checked):is([disabled], :disabled)) .checked-indicator { background: var(--colorNeutralForegroundDisabled); } @@ -118,8 +118,8 @@ :host(:state(checked):active) .checked-indicator { background-color: ButtonFace; } - :host(:disabled) .checked-indicator, - :host(:state(checked):disabled) .checked-indicator { + :host(:is([disabled], :disabled)) .checked-indicator, + :host(:state(checked):is([disabled], :disabled)) .checked-indicator { background-color: GrayText; } } diff --git a/packages/web-components/src/switch/switch.styles.ts b/packages/web-components/src/switch/switch.styles.ts index d92bcbc38cf57b..a6db79a77b51e8 100644 --- a/packages/web-components/src/switch/switch.styles.ts +++ b/packages/web-components/src/switch/switch.styles.ts @@ -1,5 +1,5 @@ import { css } from '@microsoft/fast-element'; -import { checkedState } from '../styles/states/index.js'; +import { checkedState, nativeDisabledState } from '../styles/states/index.js'; import { borderRadiusCircular, colorCompoundBrandBackground, @@ -57,7 +57,7 @@ export const styles = css` :host(:active) { border-color: ${colorNeutralStrokeAccessiblePressed}; } - :host(:disabled), + :host(${nativeDisabledState}), :host([readonly]) { border: 1px solid ${colorNeutralStrokeDisabled}; background-color: none; @@ -75,7 +75,7 @@ export const styles = css` background: ${colorCompoundBrandBackgroundPressed}; border-color: ${colorCompoundBrandBackgroundPressed}; } - :host(${checkedState}:disabled) { + :host(${checkedState}${nativeDisabledState}) { background: ${colorNeutralBackgroundDisabled}; border-color: ${colorNeutralStrokeDisabled}; } @@ -105,11 +105,11 @@ export const styles = css` :host(:active) .checked-indicator { background-color: ${colorNeutralForeground3Pressed}; } - :host(:disabled) .checked-indicator, + :host(${nativeDisabledState}) .checked-indicator, :host([readonly]) .checked-indicator { background: ${colorNeutralForegroundDisabled}; } - :host(${checkedState}:disabled) .checked-indicator { + :host(${checkedState}${nativeDisabledState}) .checked-indicator { background: ${colorNeutralForegroundDisabled}; } @@ -144,8 +144,8 @@ export const styles = css` :host(${checkedState}:active) .checked-indicator { background-color: ButtonFace; } - :host(:disabled) .checked-indicator, - :host(${checkedState}:disabled) .checked-indicator { + :host(${nativeDisabledState}) .checked-indicator, + :host(${checkedState}${nativeDisabledState}) .checked-indicator { background-color: GrayText; } } diff --git a/packages/web-components/src/text-input/text-input.styles.css b/packages/web-components/src/text-input/text-input.styles.css index 9a6e4d09ee3b83..fc65612d8018c8 100644 --- a/packages/web-components/src/text-input/text-input.styles.css +++ b/packages/web-components/src/text-input/text-input.styles.css @@ -122,13 +122,13 @@ :host(:focus-within) .control { color: var(--colorNeutralForeground1); } -:host([disabled]) .root { +:host(:is([disabled], :disabled)) .root { background: var(--colorTransparentBackground); border: var(--strokeWidthThin) solid var(--colorNeutralStrokeDisabled); } -:host([disabled]) .control::placeholder, -:host([disabled]) ::slotted([slot='start']), -:host([disabled]) ::slotted([slot='end']) { +:host(:is([disabled], :disabled)) .control::placeholder, +:host(:is([disabled], :disabled)) ::slotted([slot='start']), +:host(:is([disabled], :disabled)) ::slotted([slot='end']) { color: var(--colorNeutralForegroundDisabled); } ::selection { @@ -179,7 +179,7 @@ border: 0; border-bottom-color: var(--colorNeutralStrokeAccessiblePressed); } -:host([appearance='underline'][disabled]) .root { +:host([appearance='underline']:is([disabled], :disabled)) .root { border-bottom-color: var(--colorNeutralStrokeDisabled); } :host([appearance='filled-lighter']) .root, diff --git a/packages/web-components/src/text-input/text-input.styles.ts b/packages/web-components/src/text-input/text-input.styles.ts index dcaf7882ab95aa..6630afa18603ad 100644 --- a/packages/web-components/src/text-input/text-input.styles.ts +++ b/packages/web-components/src/text-input/text-input.styles.ts @@ -1,5 +1,6 @@ import type { ElementStyles } from '@microsoft/fast-element'; import { css } from '@microsoft/fast-element'; +import { nativeDisabledState } from '../styles/states/index.js'; import { borderRadiusMedium, colorCompoundBrandStroke, @@ -174,13 +175,13 @@ export const styles: ElementStyles = css` :host(:focus-within) .control { color: ${colorNeutralForeground1}; } - :host([disabled]) .root { + :host(${nativeDisabledState}) .root { background: ${colorTransparentBackground}; border: ${strokeWidthThin} solid ${colorNeutralStrokeDisabled}; } - :host([disabled]) .control::placeholder, - :host([disabled]) ::slotted([slot='start']), - :host([disabled]) ::slotted([slot='end']) { + :host(${nativeDisabledState}) .control::placeholder, + :host(${nativeDisabledState}) ::slotted([slot='start']), + :host(${nativeDisabledState}) ::slotted([slot='end']) { color: ${colorNeutralForegroundDisabled}; } ::selection { @@ -231,7 +232,7 @@ export const styles: ElementStyles = css` border: 0; border-bottom-color: ${colorNeutralStrokeAccessiblePressed}; } - :host([appearance='underline'][disabled]) .root { + :host([appearance='underline']${nativeDisabledState}) .root { border-bottom-color: ${colorNeutralStrokeDisabled}; } :host([appearance='filled-lighter']) .root, diff --git a/packages/web-components/src/textarea/textarea.styles.css b/packages/web-components/src/textarea/textarea.styles.css index dd49fb6e1b0655..f13f63c929989d 100644 --- a/packages/web-components/src/textarea/textarea.styles.css +++ b/packages/web-components/src/textarea/textarea.styles.css @@ -78,15 +78,15 @@ --control-padding-inline: var(--spacingHorizontalSNudge); } -:host([resize='both']:not(:disabled)) { +:host([resize='both']:not(:is([disabled], :disabled))) { --resize: both; } -:host([resize='horizontal']:not(:disabled)) { +:host([resize='horizontal']:not(:is([disabled], :disabled))) { --resize: horizontal; } -:host([resize='vertical']:not(:disabled)) { +:host([resize='vertical']:not(:is([disabled], :disabled))) { --resize: vertical; } @@ -116,7 +116,7 @@ --border-block-end-color: var(--colorPaletteRedBorder2); } -:host(:disabled) { +:host(:is([disabled], :disabled)) { --color: var(--colorNeutralForegroundDisabled); --background-color: var(--colorTransparentBackground); --border-color: var(--colorNeutralStrokeDisabled); @@ -174,7 +174,7 @@ } :host([readonly]) .root::after, -:host(:disabled) .root::after { +:host(:is([disabled], :disabled)) .root::after { content: none; } @@ -253,7 +253,7 @@ label[hidden] { --border-block-end-color: Highlight; } - :host(:disabled) { + :host(:is([disabled], :disabled)) { --color: GrayText; --border-color: GrayText; --border-block-end-color: GrayText; diff --git a/packages/web-components/src/textarea/textarea.styles.ts b/packages/web-components/src/textarea/textarea.styles.ts index 07ea67b0caa8fe..1ed11d74c8fcb5 100644 --- a/packages/web-components/src/textarea/textarea.styles.ts +++ b/packages/web-components/src/textarea/textarea.styles.ts @@ -43,7 +43,7 @@ import { strokeWidthThin, } from '../theme/design-tokens.js'; import { display } from '../utils/display.js'; -import { userInvalidState } from '../styles/states/index.js'; +import { nativeDisabledState, userInvalidState } from '../styles/states/index.js'; /** * Styles for the TextArea component. @@ -126,15 +126,15 @@ export const styles: ElementStyles = css` --control-padding-inline: ${spacingHorizontalSNudge}; } - :host([resize='both']:not(:disabled)) { + :host([resize='both']:not(${nativeDisabledState})) { --resize: both; } - :host([resize='horizontal']:not(:disabled)) { + :host([resize='horizontal']:not(${nativeDisabledState})) { --resize: horizontal; } - :host([resize='vertical']:not(:disabled)) { + :host([resize='vertical']:not(${nativeDisabledState})) { --resize: vertical; } @@ -164,7 +164,7 @@ export const styles: ElementStyles = css` --border-block-end-color: ${colorPaletteRedBorder2}; } - :host(:disabled) { + :host(${nativeDisabledState}) { --color: ${colorNeutralForegroundDisabled}; --background-color: ${colorTransparentBackground}; --border-color: ${colorNeutralStrokeDisabled}; @@ -222,7 +222,7 @@ export const styles: ElementStyles = css` } :host([readonly]) .root::after, - :host(:disabled) .root::after { + :host(${nativeDisabledState}) .root::after { content: none; } @@ -301,7 +301,7 @@ export const styles: ElementStyles = css` --border-block-end-color: Highlight; } - :host(:disabled) { + :host(${nativeDisabledState}) { --color: GrayText; --border-color: GrayText; --border-block-end-color: GrayText; diff --git a/packages/web-components/src/toggle-button/toggle-button.styles.css b/packages/web-components/src/toggle-button/toggle-button.styles.css index eb3ac91189922c..4ad86aad9be35c 100644 --- a/packages/web-components/src/toggle-button/toggle-button.styles.css +++ b/packages/web-components/src/toggle-button/toggle-button.styles.css @@ -213,33 +213,60 @@ border-color: transparent; } -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover), -:host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable]):hover:active) { +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover + ), +:host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ):hover:active + ) { background-color: var(--colorNeutralBackgroundDisabled); border-color: var(--colorNeutralStrokeDisabled); color: var(--colorNeutralForegroundDisabled); cursor: not-allowed; } -:host([appearance='primary']:is(:disabled, [disabled-focusable])), -:host([appearance='primary']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='primary']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; } -:host([appearance='outline']:is(:disabled, [disabled-focusable])), -:host([appearance='outline']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='outline']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); } -:host([appearance='subtle']:is(:disabled, [disabled-focusable])), -:host([appearance='subtle']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='subtle']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { background-color: var(--colorTransparentBackground); border-color: transparent; } -:host([appearance='transparent']:is(:disabled, [disabled-focusable])), -:host([appearance='transparent']:is(:disabled, [disabled-focusable]):is(:hover, :hover:active)) { +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable])), +:host([appearance='transparent']:is(:disabled, [disabled], [disabled-focusable]):is(:hover, :hover:active)) { border-color: transparent; background-color: var(--colorTransparentBackground); } @@ -260,7 +287,16 @@ forced-color-adjust: none; } - :host(:is(:disabled, [disabled-focusable], [appearance]:disabled, [appearance][disabled-focusable])) { + :host( + :is( + :disabled, + [disabled], + [disabled-focusable], + [appearance]:disabled, + [appearance][disabled], + [appearance][disabled-focusable] + ) + ) { background-color: ButtonFace; color: GrayText; border-color: ButtonText; From d712b3c8fb9a22c8fd3d85183deff8a6167b5265 Mon Sep 17 00:00:00 2001 From: Victor Genaev Date: Fri, 24 Jul 2026 20:45:26 +0200 Subject: [PATCH 2/2] docs: add browser support information to headless components docs (#36322) --- .prettierignore | 2 + .../.storybook/preview.js | 8 +- apps/public-docsite-v9-headless/package.json | 1 + .../src/BrowserSupport.mdx | 46 ++++ .../BrowserSupport/BrowserSupportMatrix.tsx | 144 +++++++++++++ .../src/BrowserSupport/MinimumVersions.tsx | 31 +++ .../browser-support-data.generated.json | 139 ++++++++++++ .../BrowserSupport/browserSupport.module.css | 193 +++++++++++++++++ .../src/BrowserSupport/constants.ts | 104 +++++++++ .../src/BrowserSupport/getMinimumVersions.ts | 40 ++++ .../src/BrowserSupport/index.ts | 15 ++ .../src/BrowserSupport/types.ts | 39 ++++ .../src/BrowserSupport/utils.ts | 89 ++++++++ .../src/PolyfillsAndFallbacks.mdx | 64 ++++++ .../tsconfig.app.json | 2 + package.json | 1 + .../stories/src/Combobox/index.stories.tsx | 4 +- .../stories/src/Dialog/index.stories.tsx | 5 +- .../stories/src/Drawer/index.stories.tsx | 5 +- .../stories/src/Dropdown/index.stories.tsx | 4 +- .../stories/src/InfoLabel/index.stories.tsx | 4 +- .../stories/src/Menu/index.stories.tsx | 4 +- .../stories/src/Nav/index.stories.tsx | 4 +- .../stories/src/Popover/index.stories.tsx | 5 +- .../stories/src/TabList/index.stories.tsx | 5 +- .../src/Tags/TagGroup/index.stories.tsx | 5 +- .../stories/src/Toast/index.stories.tsx | 4 +- .../stories/src/Toolbar/index.stories.tsx | 5 +- .../stories/src/Tooltip/index.stories.tsx | 4 +- .../src/shared/browserSupportNotice.ts | 59 ++++++ tools/web-features/eslint.config.js | 25 +++ tools/web-features/jest.config.js | 14 ++ tools/web-features/package.json | 9 + tools/web-features/project.json | 7 + tools/web-features/src/generate.spec.ts | 172 +++++++++++++++ tools/web-features/src/generate.ts | 200 ++++++++++++++++++ tools/web-features/tsconfig.json | 22 ++ tools/web-features/tsconfig.lib.json | 13 ++ tools/web-features/tsconfig.spec.json | 10 + yarn.lock | 18 +- 40 files changed, 1510 insertions(+), 15 deletions(-) create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport.mdx create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/BrowserSupportMatrix.tsx create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/MinimumVersions.tsx create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/browser-support-data.generated.json create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/browserSupport.module.css create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/constants.ts create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/getMinimumVersions.ts create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/index.ts create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/types.ts create mode 100644 apps/public-docsite-v9-headless/src/BrowserSupport/utils.ts create mode 100644 apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts create mode 100644 tools/web-features/eslint.config.js create mode 100644 tools/web-features/jest.config.js create mode 100644 tools/web-features/package.json create mode 100644 tools/web-features/project.json create mode 100644 tools/web-features/src/generate.spec.ts create mode 100644 tools/web-features/src/generate.ts create mode 100644 tools/web-features/tsconfig.json create mode 100644 tools/web-features/tsconfig.lib.json create mode 100644 tools/web-features/tsconfig.spec.json diff --git a/.prettierignore b/.prettierignore index 6aecefc0421cd7..36acacc7b5f173 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ # generated or imported files package-deps.json CHANGELOG.* +# generated from `web-features` via `yarn generate-browser-support` +**/browser-support-data.generated.json # generated version files (@fluentui/react v8 specific) packages/**/version.ts apps/**/version.ts diff --git a/apps/public-docsite-v9-headless/.storybook/preview.js b/apps/public-docsite-v9-headless/.storybook/preview.js index 5185ce7b232f73..5bc609742b0d42 100644 --- a/apps/public-docsite-v9-headless/.storybook/preview.js +++ b/apps/public-docsite-v9-headless/.storybook/preview.js @@ -8,7 +8,13 @@ export const parameters = { options: { storySort: { method: 'alphabetical', - order: ['Overview', ['Introduction', 'Getting Started', 'Accessibility'], 'Guides', 'Components', 'Concepts'], + order: [ + 'Overview', + ['Introduction', 'Getting Started', 'Accessibility', 'Browser support', 'Polyfills & fallbacks'], + 'Guides', + 'Components', + 'Concepts', + ], }, }, reactStorybookAddon: { diff --git a/apps/public-docsite-v9-headless/package.json b/apps/public-docsite-v9-headless/package.json index 986d391f4c8b0e..191770af19294e 100644 --- a/apps/public-docsite-v9-headless/package.json +++ b/apps/public-docsite-v9-headless/package.json @@ -9,6 +9,7 @@ "postbuild-storybook": "yarn generate-llms-docs", "postbuild-storybook:docsite": "yarn postbuild-storybook", "generate-llms-docs": "yarn run -T storybook-llms-extractor --distPath ./dist/storybook --summaryBaseUrl \"https://storybooks.fluentui.dev/headless/\" --summaryTitle \"Fluent UI React Headless Components\" --summaryDescription \"Fluent UI React headless components provide unstyled, accessible component primitives that can be styled with any CSS approach.\"", + "generate-browser-support": "node -r ../../scripts/ts-node/src/register ../../tools/web-features/src/generate.ts --output src/BrowserSupport/browser-support-data.generated.json --features popover,dialog,focusgroup,anchor-positioning=css.properties.anchor-name,anchor-name=anchor-positioning::css.properties.anchor-name,position-area=anchor-positioning::css.properties.position-area,position-try-fallbacks=anchor-positioning::css.properties.position-try-fallbacks,anchor-center=anchor-positioning::css.properties.place-self.anchor-center", "start": "yarn storybook:docs", "storybook": "yarn run -T storybook dev --port 3000", "storybook:docs": "yarn storybook --docs" diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport.mdx b/apps/public-docsite-v9-headless/src/BrowserSupport.mdx new file mode 100644 index 00000000000000..24aff43cc352d9 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport.mdx @@ -0,0 +1,46 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import { BrowserSupportMatrix } from './BrowserSupport/BrowserSupportMatrix'; +import { MinimumVersions } from './BrowserSupport/MinimumVersions'; + + + +# Browser support + +Fluent UI Headless components build their overlays on modern web-platform features — the **Popover +API**, the native **`` element**, and **CSS anchor positioning** — instead of JavaScript +portals and z-index stacks. This page tells you which browsers support those features out of the box. + +Fluent UI Headless does not own the browser traffic of the products that use it. An internal tool and +a public website may need different compatibility targets. Use the generated data below with your +product's browser usage to decide which fallbacks are necessary. + +## Minimum supported versions + +The generated table below shows the minimum browser versions where the native overlay features +work **without a polyfill**: + + + +The `focusgroup` attribute is excluded from this native browser floor because no browser supports it +yet. Components that use arrow-key navigation require the consumer-provided +[`@microsoft/focusgroup-polyfill`](https://github.com/microsoft/polyfills/tree/main/packages/focusgroup). + +## Detailed feature support + +Use this generated matrix to see the Baseline status and minimum browser version for each capability, +plus which components depend on it. + + + +## Baseline, briefly + +Browser availability is described using [Baseline](https://web.dev/baseline) stages: + +- **Limited availability** — the feature has shipped in some browsers but is not yet in all of them. +- **Newly available** — the feature works in the current version of every major browser. +- **Widely available** — newly available, plus 2.5 years of support across browsers; safe to rely on. + +For more about the availability stages, see the [Baseline](https://web.dev/baseline) overview. For +ways to support browsers that lack a required capability, see +[Polyfills & fallbacks](?path=/docs/overview-polyfills-fallbacks--docs). diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/BrowserSupportMatrix.tsx b/apps/public-docsite-v9-headless/src/BrowserSupport/BrowserSupportMatrix.tsx new file mode 100644 index 00000000000000..dd81088b753785 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/BrowserSupportMatrix.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; + +import { + browsers, + browserLabel, + features, + featureLabel, + MATRIX_ORDER, + CONCEPT_ORDER, + COMPONENT_FEATURES, + FEATURE_DETAILS, + REFERENCE_LINKS, + getBaselineStatus, + generatedFrom, + WEB_FEATURES_URL, +} from '.'; +import styles from './browserSupport.module.css'; + +/** All tracked components, sorted, for the component → feature matrix rows. */ +const ALL_COMPONENTS = Object.keys(COMPONENT_FEATURES).sort(); + +/** Render `backtick`-delimited segments as inline ``, linking known CSS properties to MDN. */ +function renderRichText(text: string): React.ReactNode { + return text.split('`').map((part, index) => { + if (index % 2 === 0) { + return part; + } + + const href = REFERENCE_LINKS[part]; + if (href) { + return ( + + {part} + + ); + } + + return ( + + {part} + + ); + }); +} + +export const BrowserSupportMatrix = (): React.ReactNode => { + return ( +
+ + + + + + + {browsers.map(browser => ( + + ))} + + + + {MATRIX_ORDER.map(key => { + const feature = features[key]; + const status = getBaselineStatus(key); + return ( + + + + {browsers.map(browser => { + const version = feature.support[browser]; + return ( + + ); + })} + + ); + })} + +
Baseline status and minimum supporting browser versions
FeatureAvailability + {browserLabel(browser)} +
{featureLabel(key)} + {status.availabilityLabel} +
{status.detailLabel}
+
+ {version ?? 'No'} +
+ +

How each feature is used

+
+ {CONCEPT_ORDER.map(key => { + const details = FEATURE_DETAILS[key]; + return ( +
+

{featureLabel(key)}

+
{renderRichText(details.usage)}
+
+ Fallback: + {renderRichText(details.fallback)} +
+ + Reference ↗ + +
+ ); + })} +
+ +

Feature usage by component

+ + + + + {CONCEPT_ORDER.map(key => ( + + ))} + + + + {ALL_COMPONENTS.map(component => ( + + + {CONCEPT_ORDER.map(key => { + const uses = COMPONENT_FEATURES[component].includes(key); + return ( + + ); + })} + + ))} + +
Component + {featureLabel(key)} +
{component} + {uses ? '✓' : ''} +
+ +
+ Generated with{' '} + + {generatedFrom} + +
+
+ ); +}; diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/MinimumVersions.tsx b/apps/public-docsite-v9-headless/src/BrowserSupport/MinimumVersions.tsx new file mode 100644 index 00000000000000..f956258a733e22 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/MinimumVersions.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { browsers, browserLabel, getMinimumVersions } from '.'; +import styles from './browserSupport.module.css'; + +/** + * Single minimum browser version (per browser) where the headless overlay features work without a + * polyfill. Derived from the generated Baseline data (max across natively-shipping features). + * focusgroup is excluded here and called out separately — it always needs a polyfill. + */ +export const MinimumVersions = (): React.ReactNode => { + const minimums = getMinimumVersions(); + return ( + + + + + + + + + {browsers.map(browser => ( + + + + + ))} + +
BrowserMinimum version
{browserLabel(browser)}{minimums[browser] ?? 'Not supported'}
+ ); +}; diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/browser-support-data.generated.json b/apps/public-docsite-v9-headless/src/BrowserSupport/browser-support-data.generated.json new file mode 100644 index 00000000000000..a1fe28cd6d07e1 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/browser-support-data.generated.json @@ -0,0 +1,139 @@ +{ + "generatedFrom": "web-features@3.30.0", + "browsers": [ + "chrome", + "edge", + "firefox", + "safari" + ], + "features": { + "popover": { + "key": "popover", + "name": "Popover", + "baseline": "low", + "baselineLowDate": "2025-01-27", + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "116", + "edge": "116", + "firefox": "125", + "safari": "17" + } + }, + "dialog": { + "key": "dialog", + "name": "", + "baseline": "high", + "baselineLowDate": "2022-03-14", + "baselineHighDate": "2024-09-14", + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "37", + "edge": "79", + "firefox": "98", + "safari": "15.4" + } + }, + "focusgroup": { + "key": "focusgroup", + "name": "focusgroup", + "baseline": false, + "baselineLowDate": null, + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": null, + "edge": null, + "firefox": null, + "safari": null + } + }, + "anchor-positioning": { + "key": "anchor-positioning", + "name": "Anchor positioning", + "baseline": false, + "baselineLowDate": null, + "baselineHighDate": null, + "partial": true, + "representativeBaseline": "low", + "representativeBaselineLowDate": "2026-01-13", + "support": { + "chrome": "125", + "edge": "125", + "firefox": "147", + "safari": "26" + } + }, + "anchor-name": { + "key": "anchor-name", + "name": "anchor-name", + "baseline": "low", + "baselineLowDate": "2026-01-13", + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "125", + "edge": "125", + "firefox": "147", + "safari": "26" + } + }, + "position-area": { + "key": "position-area", + "name": "position-area", + "baseline": "low", + "baselineLowDate": "2026-01-13", + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "129", + "edge": "129", + "firefox": "147", + "safari": "26" + } + }, + "position-try-fallbacks": { + "key": "position-try-fallbacks", + "name": "position-try-fallbacks", + "baseline": "low", + "baselineLowDate": "2026-01-13", + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "128", + "edge": "128", + "firefox": "147", + "safari": "26" + } + }, + "anchor-center": { + "key": "anchor-center", + "name": "anchor-center", + "baseline": "low", + "baselineLowDate": "2026-01-13", + "baselineHighDate": null, + "partial": false, + "representativeBaseline": null, + "representativeBaselineLowDate": null, + "support": { + "chrome": "125", + "edge": "125", + "firefox": "147", + "safari": "26" + } + } + } +} diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/browserSupport.module.css b/apps/public-docsite-v9-headless/src/BrowserSupport/browserSupport.module.css new file mode 100644 index 00000000000000..80bdf37033dc63 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/browserSupport.module.css @@ -0,0 +1,193 @@ +.root { + color: var(--text); + font-family: var(--font-sans); +} + +.table { + width: 100%; + border-collapse: collapse; + margin: var(--space-4) 0 var(--space-8); + font-size: 14px; +} + +.table caption { + text-align: left; + font-weight: 600; + margin-bottom: var(--space-2); +} + +.table th, +.table td { + text-align: left; + padding: var(--space-2) var(--space-3); + border-bottom: var(--stroke-thin) solid var(--border); + vertical-align: top; +} + +.table thead th { + color: var(--text-muted); + font-weight: 600; + font-size: 12.5px; + text-transform: uppercase; + letter-spacing: var(--tracking-tight); +} + +.table tbody th { + font-weight: 600; + white-space: nowrap; +} + +.version { + font-family: var(--font-mono); + color: var(--text-soft); +} + +.unsupported { + color: var(--text-faint); +} + +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.widely { + background: var(--success-soft); + color: var(--success); +} + +.newly { + background: var(--warning-soft); + color: var(--warning); +} + +.limited { + background: var(--danger-soft); + color: var(--danger); +} + +.since { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); +} + +.usage { + margin: var(--space-4) 0 var(--space-8); + display: grid; + gap: var(--space-4); +} + +.usageItem { + padding: var(--space-4); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-soft); +} + +.usageTitle { + margin: 0 0 var(--space-2); + font-size: 16px; + font-weight: 600; +} + +.usageText { + margin: 0 0 var(--space-2); + color: var(--text-soft); + font-size: 14px; + line-height: 1.5; +} + +.fallback { + margin: 0; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} + +.code { + font-family: var(--font-mono); + font-size: 0.9em; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--surface-sunken); + border: var(--stroke-thin) solid var(--border); +} + +.codeLink { + text-decoration: none; +} + +.codeLink .code { + color: var(--info); +} + +.codeLink:hover .code { + text-decoration: underline; +} + +.check { + text-align: center; + color: var(--success); + font-weight: 600; +} + +.mdnLink { + display: inline-block; + margin-top: var(--space-3); + font-size: 13px; + font-weight: 600; + color: var(--info); + text-decoration: none; +} + +.mdnLink:hover { + text-decoration: underline; +} + +.fallbackLabel { + font-weight: 600; + color: var(--text-soft); +} + +.provenance { + margin-top: var(--space-6); + font-size: 11px; + color: var(--text-muted); +} + +.provenance .provenanceLink { + color: inherit; + text-decoration: underline; +} + +.minVersions { + width: auto; + border-collapse: collapse; + margin: var(--space-4) 0 var(--space-8); + font-size: 14px; +} + +.minVersions th, +.minVersions td { + text-align: left; + padding: var(--space-2) var(--space-4); + border-bottom: var(--stroke-thin) solid var(--border); +} + +.minVersions thead th { + color: var(--text-muted); + font-weight: 600; + font-size: 12.5px; + text-transform: uppercase; + letter-spacing: var(--tracking-tight); +} + +.minVersionValue { + font-family: var(--font-mono); + color: var(--text-soft); +} diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/constants.ts b/apps/public-docsite-v9-headless/src/BrowserSupport/constants.ts new file mode 100644 index 00000000000000..159ce346d1bda6 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/constants.ts @@ -0,0 +1,104 @@ +import data from './browser-support-data.generated.json'; +import type { BrowserSupportFile, ConceptKey, FeatureDetail, FeatureKey, FeatureSupport } from './types'; + +const file = data as BrowserSupportFile; + +/** Provenance string, e.g. `web-features@3.30.0`. */ +export const generatedFrom: string = file.generatedFrom; + +/** Browser ids shown in the support matrix, in column order. */ +export const browsers: string[] = file.browsers; + +/** Per-feature Baseline + support data (generated from `web-features`). */ +export const features: Record = file.features; + +/** npm page for the `web-features` package the data is generated from. */ +export const WEB_FEATURES_URL = 'https://www.npmjs.com/package/web-features'; + +/** Rows shown in the overview support matrix — CSS anchor positioning broken into the properties we use. */ +export const MATRIX_ORDER: FeatureKey[] = [ + 'popover', + 'dialog', + 'anchor-name', + 'position-area', + 'position-try-fallbacks', + 'anchor-center', + 'focusgroup', +]; + +/** Concept-level features for the components table and per-component notices. */ +export const CONCEPT_ORDER: ConceptKey[] = ['popover', 'dialog', 'anchor-positioning', 'focusgroup']; + +/** Hand-maintained display label for every feature/property (matrix + notices). */ +export const FEATURE_LABELS: Record = { + popover: 'Popover API', + dialog: 'Native element', + 'anchor-positioning': 'CSS anchor positioning', + 'anchor-name': 'anchor-name', + 'position-area': 'position-area', + 'position-try-fallbacks': 'position-try-fallbacks', + 'anchor-center': 'anchor-center', + focusgroup: 'Focus group', +}; + +export const FEATURE_DETAILS: Record = { + popover: { + referenceUrl: 'https://developer.mozilla.org/en-US/docs/Web/API/Popover_API', + usage: + 'Renders overlays in the top layer with native light-dismiss and stacking, avoiding portals and manual z-index management.', + fallback: + 'Only non-modal `Dialog` surfaces fall back to `dialog.show()` when the Popover API is missing. `Popover` and `Menu` surfaces render in place, while `Tooltip` logs a development warning.', + }, + dialog: { + referenceUrl: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog', + usage: + 'Provides accessible modal (`showModal()`) and non-modal overlays with native focus handling and a top-layer backdrop.', + fallback: + 'Non-modal surfaces fall back from `showPopover()` to the basic `dialog.show()` when the Popover API is missing.', + }, + 'anchor-positioning': { + referenceUrl: 'https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning', + usage: + 'Positions overlays relative to their trigger via `anchor-name` / `position-anchor` / `position-area`, with `position-try-fallbacks` for flipping. The properties land in browsers individually — see the matrix above.', + fallback: + 'The headless components do not include a JavaScript positioning fallback, so overlays render at their static position on browsers without CSS anchor positioning. Add a polyfill to keep them anchored.', + }, + focusgroup: { + referenceUrl: 'https://open-ui.org/components/scoped-focusgroup.explainer/', + usage: + 'Provides roving arrow-key focus navigation via the `focusgroup` attribute, replacing manual keyboard event handlers.', + fallback: + 'No browser supports it natively yet, and the published headless library does not apply a polyfill on your behalf. Add `@microsoft/focusgroup-polyfill` so keyboard navigation works today.', + }, +}; + +export const COMPONENT_FEATURES: Record = { + Popover: ['popover', 'dialog', 'anchor-positioning'], + InfoLabel: ['popover', 'dialog', 'anchor-positioning'], + Tooltip: ['popover', 'anchor-positioning'], + Menu: ['popover', 'anchor-positioning', 'focusgroup'], + Dropdown: ['popover', 'anchor-positioning'], + Combobox: ['popover', 'anchor-positioning'], + Dialog: ['dialog', 'popover'], + Drawer: ['dialog', 'popover'], + Toast: ['popover'], + TagGroup: ['focusgroup'], + Toolbar: ['focusgroup'], + TabList: ['focusgroup'], + Nav: ['focusgroup'], +}; + +export const BROWSER_LABELS: Record = { + chrome: 'Chrome', + edge: 'Edge', + firefox: 'Firefox', + safari: 'Safari', +}; + +export const REFERENCE_LINKS: Record = { + '@microsoft/focusgroup-polyfill': 'https://github.com/microsoft/polyfills/tree/main/packages/focusgroup', + 'anchor-name': 'https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-name', + 'position-anchor': 'https://developer.mozilla.org/en-US/docs/Web/CSS/position-anchor', + 'position-area': 'https://developer.mozilla.org/en-US/docs/Web/CSS/position-area', + 'position-try-fallbacks': 'https://developer.mozilla.org/en-US/docs/Web/CSS/position-try-fallbacks', +}; diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/getMinimumVersions.ts b/apps/public-docsite-v9-headless/src/BrowserSupport/getMinimumVersions.ts new file mode 100644 index 00000000000000..6926c50a174a9e --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/getMinimumVersions.ts @@ -0,0 +1,40 @@ +import { browsers, features, MATRIX_ORDER } from './constants'; +import type { FeatureKey } from './types'; + +const NATIVE_FEATURES: FeatureKey[] = MATRIX_ORDER; + +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +export function getMinimumVersions(): Record { + const result: Record = {}; + + for (const browser of browsers) { + let max: string | null = null; + + for (const key of NATIVE_FEATURES) { + const version = features[key].support[browser]; + if (version === null || version === undefined) { + // Feature never shipped in this browser — exclude it from the "by default" floor. + continue; + } + if (max === null || compareVersions(version, max) > 0) { + max = version; + } + } + + result[browser] = max; + } + + return result; +} diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/index.ts b/apps/public-docsite-v9-headless/src/BrowserSupport/index.ts new file mode 100644 index 00000000000000..f8b9a7a58b2f5b --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/index.ts @@ -0,0 +1,15 @@ +export type { AvailabilityLevel, Baseline, ConceptKey, FeatureDetail, FeatureKey, FeatureSupport } from './types'; +export { + browsers, + COMPONENT_FEATURES, + CONCEPT_ORDER, + FEATURE_DETAILS, + FEATURE_LABELS, + features, + generatedFrom, + MATRIX_ORDER, + REFERENCE_LINKS, + WEB_FEATURES_URL, +} from './constants'; +export { getMinimumVersions } from './getMinimumVersions'; +export { browserLabel, featureLabel, getBaselineStatus } from './utils'; diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/types.ts b/apps/public-docsite-v9-headless/src/BrowserSupport/types.ts new file mode 100644 index 00000000000000..27e854ebe9bbbd --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/types.ts @@ -0,0 +1,39 @@ +export type FeatureKey = + | 'popover' + | 'dialog' + | 'anchor-positioning' + | 'anchor-name' + | 'position-area' + | 'position-try-fallbacks' + | 'anchor-center' + | 'focusgroup'; + +export type ConceptKey = 'popover' | 'dialog' | 'anchor-positioning' | 'focusgroup'; + +export type Baseline = 'high' | 'low' | false; + +export interface FeatureSupport { + key: FeatureKey; + name: string; + baseline: Baseline; + baselineLowDate: string | null; + baselineHighDate: string | null; + partial: boolean; + representativeBaseline: Baseline | null; + representativeBaselineLowDate: string | null; + support: Record; +} + +export interface BrowserSupportFile { + generatedFrom: string; + browsers: string[]; + features: Record; +} + +export interface FeatureDetail { + referenceUrl: string; + usage: string; + fallback: string; +} + +export type AvailabilityLevel = 'widely' | 'newly' | 'limited'; diff --git a/apps/public-docsite-v9-headless/src/BrowserSupport/utils.ts b/apps/public-docsite-v9-headless/src/BrowserSupport/utils.ts new file mode 100644 index 00000000000000..881d822b2921b7 --- /dev/null +++ b/apps/public-docsite-v9-headless/src/BrowserSupport/utils.ts @@ -0,0 +1,89 @@ +import { BROWSER_LABELS, FEATURE_LABELS, features } from './constants'; +import type { AvailabilityLevel, FeatureKey } from './types'; + +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const AVAILABILITY_LABELS: Record = { + widely: 'Widely available', + newly: 'Newly available', + limited: 'Limited availability', +}; + +export interface BaselineStatus { + level: AvailabilityLevel; + availabilityLabel: string; + detailLabel: string; +} + +export function browserLabel(id: string): string { + return BROWSER_LABELS[id] ?? id; +} + +export function featureLabel(key: FeatureKey): string { + return FEATURE_LABELS[key]; +} + +export function getBaselineStatus(key: FeatureKey): BaselineStatus { + const feature = features[key]; + + if (key === 'focusgroup') { + return { + level: 'limited', + availabilityLabel: AVAILABILITY_LABELS.limited, + detailLabel: 'Polyfilled (Microsoft focusgroup polyfill)', + }; + } + + if (feature.baseline === 'high') { + return { + level: 'widely', + availabilityLabel: AVAILABILITY_LABELS.widely, + detailLabel: `Baseline · Widely available (since ${formatYear(feature.baselineHighDate)})`, + }; + } + + if (feature.baseline === 'low') { + return { + level: 'newly', + availabilityLabel: AVAILABILITY_LABELS.newly, + detailLabel: `Baseline · Newly available (since ${formatMonthYear(feature.baselineLowDate)})`, + }; + } + + const detailLabel = + feature.partial && feature.representativeBaselineLowDate + ? `Limited availability (since ${formatMonthYear(feature.representativeBaselineLowDate)})` + : 'Limited availability · not yet Baseline'; + + return { + level: 'limited', + availabilityLabel: AVAILABILITY_LABELS.limited, + detailLabel, + }; +} + +function formatMonthYear(date: string | null): string { + if (!date) { + return ''; + } + + const [year, month] = date.split('-'); + return `${MONTHS[Number(month) - 1]} ${year}`; +} + +function formatYear(date: string | null): string { + return date ? date.split('-')[0] : ''; +} diff --git a/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx new file mode 100644 index 00000000000000..5b14381d246f1c --- /dev/null +++ b/apps/public-docsite-v9-headless/src/PolyfillsAndFallbacks.mdx @@ -0,0 +1,64 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Polyfills & fallbacks + +To support browsers that lack the underlying web-platform features (see +[Browser support](?path=/docs/overview-browser-support--docs)), add a polyfill for the feature you +need. Load polyfills before Fluent UI Headless renders so the required APIs are available to the +components. The browser support matrix shows where each feature is available; the sections below +explain what to do when a capability is missing. + +## Supporting Older Browsers + +Products that support browsers without native CSS anchor positioning need to decide what placement +experience to provide. Fluent UI Headless leaves that tradeoff to the consuming product: + +1. **Load a polyfill only where needed.** Check for native support before downloading an + anchor-positioning polyfill. This keeps the common path lean and requires little application code, + although a polyfill may implement only part of the positioning behavior listed in the + [browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support). +2. **Use product-owned positioning logic.** When `CSS.supports('anchor-name', '--x')` returns `false`, + hand the layer to a measurement-based positioner. This adds implementation work, but lets the + product control placement, flipping, and collision handling. +3. **Allow static placement.** Keep the native fallback behavior: the surface opens, but it may appear + at its document position rather than beside the trigger. Products aimed primarily at browsers with + native support may find this sufficient. + +Base the decision on the browser capabilities present at runtime rather than a browser identity list. + +## focusgroup + +Roving arrow-key navigation uses the +[`focusgroup` attribute](https://open-ui.org/components/scoped-focusgroup.explainer/), which no +browser supports natively yet, and the published library does not apply a polyfill for you. Add the Microsoft +[`@microsoft/focusgroup-polyfill`](https://github.com/microsoft/polyfills/tree/main/packages/focusgroup) — +it is progressive and no-ops once browsers ship native support. Without it, arrow-key navigation does +not work. + +## Popover API + +Overlays render in the top layer using the +[Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API). On browsers without it, +only non-modal `Dialog` surfaces fall back to `dialog.show()`; `Popover` and `Menu` surfaces render in +place and `Tooltip` logs a development warning. For full coverage on older browsers, add a polyfill. +Detect support and load it before Fluent UI, for example: + +```js +const hasPopover = typeof HTMLElement !== 'undefined' && typeof HTMLElement.prototype.showPopover === 'function'; + +if (!hasPopover) { + await import('your-popover-polyfill'); +} +``` + +## CSS anchor positioning + +Overlays are positioned relative to their trigger with +[CSS anchor positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning). +The headless library does **not** include a JavaScript positioning fallback, so without support these +overlays render at their static position. Use the feature detection and fallback options above, and +load any polyfill before Fluent UI Headless renders. Consult the +[browser support matrix](?path=/docs/overview-browser-support--docs#detailed-feature-support) for the +individual CSS properties Fluent UI Headless uses. diff --git a/apps/public-docsite-v9-headless/tsconfig.app.json b/apps/public-docsite-v9-headless/tsconfig.app.json index 3114b73ab654d7..66722d6139354d 100644 --- a/apps/public-docsite-v9-headless/tsconfig.app.json +++ b/apps/public-docsite-v9-headless/tsconfig.app.json @@ -7,6 +7,8 @@ "outDir": "lib", "jsx": "react", "sourceMap": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "types": ["static-assets", "webpack-env"] }, "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"], diff --git a/package.json b/package.json index 1f89dbe823ab94..4b7e797a6cd0e4 100644 --- a/package.json +++ b/package.json @@ -321,6 +321,7 @@ "typescript": "5.7.3", "typescript-eslint": "8.46.2", "vite": "6.4.2", + "web-features": "3.30.0", "webpack": "5.108.4", "webpack-bundle-analyzer": "4.10.1", "webpack-cli": "5.1.4", diff --git a/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx index 0fdeb172a6132c..d8d4a178e01cb2 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx @@ -2,6 +2,8 @@ import { Combobox, Listbox, Option, OptionGroup } from '@fluentui/react-headless import descriptionMd from './ComboboxDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './ComboboxDefault.stories'; export { Controlled } from './ComboboxControlled.stories'; export { Freeform } from './ComboboxFreeform.stories'; @@ -17,7 +19,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Combobox')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx index d56ff90f682b4d..2f4a5b11779618 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dialog/index.stories.tsx @@ -8,6 +8,9 @@ import { } from '@fluentui/react-headless-components-preview/dialog'; import descriptionMd from './DialogDescription.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './DialogDefault.stories'; export { NonModal } from './DialogNonModal.stories'; export { Alert } from './DialogAlert.stories'; @@ -24,7 +27,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Dialog')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx index 764a795148bb54..b3a698410a64f0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Drawer/index.stories.tsx @@ -10,6 +10,9 @@ import { } from '@fluentui/react-headless-components-preview/drawer'; import descriptionMd from './DrawerDescription.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './DefaultDrawer.stories'; export { Inline } from './InlineDrawer.stories'; @@ -28,7 +31,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Drawer')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dropdown/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Dropdown/index.stories.tsx index 8fa9c9bbd69805..8182b8c3e8154f 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dropdown/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Dropdown/index.stories.tsx @@ -2,6 +2,8 @@ import { Dropdown, Listbox, Option, OptionGroup } from '@fluentui/react-headless import descriptionMd from './DropdownDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './DropdownDefault.stories'; export { Multiselect } from './DropdownMultiselect.stories'; export { Controlled } from './DropdownControlled.stories'; @@ -16,7 +18,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Dropdown')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/index.stories.tsx index 1118ce23c0b317..1872037f53a668 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/InfoLabel/index.stories.tsx @@ -2,6 +2,8 @@ import { InfoLabel } from '@fluentui/react-headless-components-preview/info-labe import descriptionMd from './InfoLabelDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './InfoLabelDefault.stories'; export { Required } from './InfoLabelRequired.stories'; export { InField } from './InfoLabelInField.stories'; @@ -12,7 +14,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('InfoLabel')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx index e03dc16bb42e44..a8314933d75831 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Menu/index.stories.tsx @@ -17,6 +17,8 @@ import { import descriptionMd from './MenuDescription.md'; import bestPracticesMd from './MenuBestPractices.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './MenuDefault.stories'; export { Controlled } from './MenuControlled.stories'; export { WithDivider } from './MenuWithDivider.stories'; @@ -52,7 +54,7 @@ export default { parameters: { docs: { description: { - component: [descriptionMd, bestPracticesMd].join('\n'), + component: [descriptionMd, bestPracticesMd, getBrowserSupportNotice('Menu')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Nav/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Nav/index.stories.tsx index 79e4e01d80a90c..a823b6ec8a6905 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Nav/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Nav/index.stories.tsx @@ -15,6 +15,8 @@ import { import descriptionMd from './NavDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './NavDefault.stories'; export { WithCategories } from './NavWithCategories.stories'; export { Controlled } from './NavControlled.stories'; @@ -40,7 +42,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Nav')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx index f29c8b72892160..99951f418b29d8 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Popover/index.stories.tsx @@ -2,6 +2,9 @@ import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headles import descriptionMd from './PopoverDescription.md'; import bestPracticesMd from './PopoverBestPractices.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './PopoverDefault.stories'; export { WithArrow } from './PopoverWithArrow.stories'; export { Controlled } from './PopoverControlled.stories'; @@ -20,7 +23,7 @@ export default { parameters: { docs: { description: { - component: [descriptionMd, bestPracticesMd].join('\n'), + component: [descriptionMd, bestPracticesMd, getBrowserSupportNotice('Popover')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx index 3ff4d754c065a0..0a8b2404017cab 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/TabList/index.stories.tsx @@ -1,6 +1,9 @@ import { TabList } from '@fluentui/react-headless-components-preview/tab-list'; import descriptionMd from './TabListDescription.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './TabListDefault.stories'; export default { @@ -9,7 +12,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('TabList')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx index 44ae930fe618c8..377e7e9cf0f1c3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx @@ -1,6 +1,9 @@ import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; import descriptionMd from './TagGroupDescription.md'; + +import { getBrowserSupportNotice } from '../../shared/browserSupportNotice'; + export { Default } from './TagGroupDefault.stories'; export { Dismiss } from './TagGroupDismiss.stories'; export { Disabled } from './TagGroupDisabled.stories'; @@ -12,7 +15,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('TagGroup')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx index f9150431b63363..86fb0b4f7241c1 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toast/index.stories.tsx @@ -2,6 +2,8 @@ import { Toast, ToastTitle, ToastBody, ToastFooter, Toaster } from '@fluentui/re import descriptionMd from './ToastDescription.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './ToastDefault.stories'; export { Intent } from './ToastIntent.stories'; export { Positions } from './ToastPositions.stories'; @@ -29,7 +31,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Toast')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx index 2e6891a6c4982f..98960eaff8c1d1 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Toolbar/index.stories.tsx @@ -9,6 +9,9 @@ import { } from '@fluentui/react-headless-components-preview/toolbar'; import descriptionMd from './ToolbarDescription.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './ToolbarDefault.stories'; export { Vertical } from './ToolbarVertical.stories'; export { Toggle } from './ToolbarToggleButton.stories'; @@ -28,7 +31,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: [descriptionMd, getBrowserSupportNotice('Toolbar')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx index 28a0961d476ad3..ca81b5086fbfa3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tooltip/index.stories.tsx @@ -3,6 +3,8 @@ import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; import descriptionMd from './TooltipDescription.md'; import bestPracticesMd from './TooltipBestPractices.md'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + export { Default } from './TooltipDefault.stories'; export { WithArrow } from './TooltipWithArrow.stories'; export { Positions } from './TooltipPositions.stories'; @@ -16,7 +18,7 @@ export default { parameters: { docs: { description: { - component: [descriptionMd, bestPracticesMd].join('\n'), + component: [descriptionMd, bestPracticesMd, getBrowserSupportNotice('Tooltip')].join('\n'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts new file mode 100644 index 00000000000000..e23b5b845834bf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -0,0 +1,59 @@ +type ConceptKey = 'popover' | 'dialog' | 'anchor-positioning' | 'focusgroup'; + +const FEATURE_LABELS: Record = { + popover: 'Popover API', + dialog: 'Native element', + 'anchor-positioning': 'CSS anchor positioning', + focusgroup: 'Focus group', +}; + +const COMPONENT_FEATURES: Record = { + Popover: ['popover', 'dialog', 'anchor-positioning'], + InfoLabel: ['popover', 'dialog', 'anchor-positioning'], + Tooltip: ['popover', 'anchor-positioning'], + Menu: ['popover', 'anchor-positioning', 'focusgroup'], + Dropdown: ['popover', 'anchor-positioning'], + Combobox: ['popover', 'anchor-positioning'], + Dialog: ['dialog', 'popover'], + Drawer: ['dialog', 'popover'], + Toast: ['popover'], + TagGroup: ['focusgroup'], + Toolbar: ['focusgroup'], + TabList: ['focusgroup'], + Nav: ['focusgroup'], +}; + +const BROWSER_SUPPORT_DOCS_PATH = '?path=/docs/overview-browser-support--docs'; + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +// Fluent `LightbulbRegular` icon, inlined because the notice is a raw-HTML string (markdown) +const LIGHTBULB_ICON = + ''; + +export function getBrowserSupportNotice(component: string): string { + const keys = COMPONENT_FEATURES[component]; + + if (!keys || keys.length === 0) { + return ''; + } + + const featureList = keys.map(key => `${escapeHtml(FEATURE_LABELS[key])}`).join(', '); + + return ( + '' + ); +} diff --git a/tools/web-features/eslint.config.js b/tools/web-features/eslint.config.js new file mode 100644 index 00000000000000..dd0ce544a8d391 --- /dev/null +++ b/tools/web-features/eslint.config.js @@ -0,0 +1,25 @@ +// @ts-check +const fluentPlugin = require('@fluentui/eslint-plugin'); + +/** @type {import("eslint").Linter.Config[]} */ +module.exports = [ + ...fluentPlugin.configs['flat/node'], + ...fluentPlugin.configs['flat/imports'], + { + rules: { + '@fluentui/max-len': 'off', + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: ['.', '../../'], + }, + ], + }, + }, + { + files: ['index.d.ts'], + rules: { + 'import/no-self-import': 'off', + }, + }, +]; diff --git a/tools/web-features/jest.config.js b/tools/web-features/jest.config.js new file mode 100644 index 00000000000000..b13150bb7152de --- /dev/null +++ b/tools/web-features/jest.config.js @@ -0,0 +1,14 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'web-features-generator', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', {}], + }, + coverageDirectory: './coverage', + testEnvironment: 'node', +}; diff --git a/tools/web-features/package.json b/tools/web-features/package.json new file mode 100644 index 00000000000000..0293c4f9a8ebe5 --- /dev/null +++ b/tools/web-features/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fluentui/web-features-generator", + "version": "0.0.1", + "private": true, + "main": "src/generate.js", + "devDependencies": { + "@fluentui/eslint-plugin": "*" + } +} diff --git a/tools/web-features/project.json b/tools/web-features/project.json new file mode 100644 index 00000000000000..5dc2537d841f65 --- /dev/null +++ b/tools/web-features/project.json @@ -0,0 +1,7 @@ +{ + "name": "web-features-generator", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/web-features/src", + "projectType": "library", + "tags": ["tools"] +} diff --git a/tools/web-features/src/generate.spec.ts b/tools/web-features/src/generate.spec.ts new file mode 100644 index 00000000000000..e13fd68322c824 --- /dev/null +++ b/tools/web-features/src/generate.spec.ts @@ -0,0 +1,172 @@ +import { normalizeFeature, parseFeatureToken, pickBrowsers, splitCsv } from './generate'; + +type AllFeatures = Parameters[2]; + +jest.mock('web-features', () => ({ features: {} })); + +describe('parseFeatureToken', () => { + it('parses a plain top-level key', () => { + expect(parseFeatureToken('popover')).toEqual({ + id: 'popover', + groupKey: 'popover', + compatKey: null, + representativeCompatKey: null, + }); + }); + + it('parses a key with a representative compat key', () => { + expect(parseFeatureToken('anchor-positioning=css.properties.anchor-name')).toEqual({ + id: 'anchor-positioning', + groupKey: 'anchor-positioning', + compatKey: null, + representativeCompatKey: 'css.properties.anchor-name', + }); + }); + + it('parses a sub-feature (id=group::compatKey)', () => { + expect(parseFeatureToken('anchor-name=anchor-positioning::css.properties.anchor-name')).toEqual({ + id: 'anchor-name', + groupKey: 'anchor-positioning', + compatKey: 'css.properties.anchor-name', + representativeCompatKey: null, + }); + }); +}); + +describe('splitCsv', () => { + it('splits, trims, and drops empty items', () => { + expect(splitCsv(' a, b ,, c ')).toEqual(['a', 'b', 'c']); + expect(splitCsv('')).toEqual([]); + }); +}); + +describe('pickBrowsers', () => { + it('maps requested browsers and uses null for missing ones', () => { + expect(pickBrowsers({ chrome: '116', firefox: '125' }, ['chrome', 'edge', 'firefox', 'safari'])).toEqual({ + chrome: '116', + edge: null, + firefox: '125', + safari: null, + }); + }); + + it('returns all null when support is undefined', () => { + expect(pickBrowsers(undefined, ['chrome', 'safari'])).toEqual({ chrome: null, safari: null }); + }); +}); + +describe('normalizeFeature', () => { + const browsers = ['chrome', 'edge', 'firefox', 'safari']; + + /* eslint-disable @typescript-eslint/naming-convention -- snake_case mirrors the web-features JSON shape */ + const allFeatures = { + popover: { + name: 'Popover', + status: { + baseline: 'low', + baseline_low_date: '2025-01-27', + support: { chrome: '116', edge: '116', firefox: '125', safari: '17' }, + }, + }, + dialog: { + name: '', + status: { + baseline: 'high', + baseline_low_date: '2022-03-14', + baseline_high_date: '2024-09-14', + support: { chrome: '37', edge: '79', firefox: '98', safari: '15.4' }, + }, + }, + 'anchor-positioning': { + name: 'Anchor positioning', + status: { + baseline: false, + support: {}, + by_compat_key: { + 'css.properties.anchor-name': { + baseline: 'low', + baseline_low_date: '2026-01-13', + support: { chrome: '125', firefox: '147' }, + }, + }, + }, + }, + focusgroup: { name: 'focusgroup', status: { baseline: false, support: {} } }, + } as unknown as AllFeatures; + /* eslint-enable @typescript-eslint/naming-convention */ + + it('normalizes a top-level feature with aggregate support', () => { + expect(normalizeFeature(parseFeatureToken('popover'), browsers, allFeatures)).toMatchObject({ + key: 'popover', + name: 'Popover', + baseline: 'low', + baselineLowDate: '2025-01-27', + baselineHighDate: null, + partial: false, + representativeBaseline: null, + representativeBaselineLowDate: null, + support: { chrome: '116', edge: '116', firefox: '125', safari: '17' }, + }); + }); + + it('carries both baseline dates for a widely available feature', () => { + expect(normalizeFeature(parseFeatureToken('dialog'), browsers, allFeatures)).toMatchObject({ + baseline: 'high', + baselineLowDate: '2022-03-14', + baselineHighDate: '2024-09-14', + }); + }); + + it('falls back to the representative compat key when the group is not Baseline', () => { + expect( + normalizeFeature(parseFeatureToken('anchor-positioning=css.properties.anchor-name'), browsers, allFeatures), + ).toMatchObject({ + key: 'anchor-positioning', + baseline: false, + partial: true, + representativeBaseline: 'low', + representativeBaselineLowDate: '2026-01-13', + support: { chrome: '125', edge: null, firefox: '147', safari: null }, + }); + }); + + it('emits a sub-feature compat key as its own entry', () => { + expect( + normalizeFeature( + parseFeatureToken('anchor-name=anchor-positioning::css.properties.anchor-name'), + browsers, + allFeatures, + ), + ).toMatchObject({ + key: 'anchor-name', + name: 'anchor-name', + baseline: 'low', + baselineLowDate: '2026-01-13', + partial: false, + support: { chrome: '125', edge: null, firefox: '147', safari: null }, + }); + }); + + it('keeps an empty support map for a feature with no native support', () => { + const result = normalizeFeature(parseFeatureToken('focusgroup'), browsers, allFeatures); + expect(result.baseline).toBe(false); + expect(result.partial).toBe(false); + expect(result.support).toEqual({ chrome: null, edge: null, firefox: null, safari: null }); + }); + + it('throws for an unknown feature key', () => { + expect(() => normalizeFeature(parseFeatureToken('nope'), browsers, allFeatures)).toThrow(/no feature "nope"/); + }); + + it('throws for a missing representative compat key', () => { + expect(() => + normalizeFeature(parseFeatureToken('anchor-positioning=css.properties.missing'), browsers, allFeatures), + ).toThrow(/Representative compat key/); + }); + + it('throws for a missing sub-feature compat key', () => { + expect(() => + normalizeFeature(parseFeatureToken('x=anchor-positioning::css.properties.missing'), browsers, allFeatures), + ).toThrow(/Compat key "css.properties.missing"/); + }); +}); diff --git a/tools/web-features/src/generate.ts b/tools/web-features/src/generate.ts new file mode 100644 index 00000000000000..d49e0dca21d24e --- /dev/null +++ b/tools/web-features/src/generate.ts @@ -0,0 +1,200 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { features as webFeaturesData } from 'web-features'; +import type { FeatureData, Support } from 'web-features/types'; +import yargs from 'yargs'; + +const DEFAULT_BROWSERS = ['chrome', 'edge', 'firefox', 'safari']; + +type Baseline = 'high' | 'low' | false; + +interface FeatureRequest { + id: string; + groupKey: string; + compatKey: string | null; + representativeCompatKey: string | null; +} + +interface CliArgs { + output: string[]; + browsers: string[]; + features: FeatureRequest[]; +} + +type WebFeature = FeatureData; +type WebFeatureStatus = FeatureData['status']; +type WebSupport = Support; + +export interface NormalizedFeature { + key: string; + name: string; + baseline: Baseline; + baselineLowDate: string | null; + baselineHighDate: string | null; + partial: boolean; + representativeBaseline: Baseline | null; + representativeBaselineLowDate: string | null; + support: Record; +} + +export function parseFeatureToken(token: string): FeatureRequest { + const [id, source] = token.split('='); + + if (!source) { + return { id, groupKey: id, compatKey: null, representativeCompatKey: null }; + } + + if (source.includes('::')) { + const [groupKey, compatKey] = source.split('::'); + return { id, groupKey, compatKey, representativeCompatKey: null }; + } + + return { id, groupKey: id, compatKey: null, representativeCompatKey: source }; +} + +export function splitCsv(value: string): string[] { + return value + .split(',') + .map(item => item.trim()) + .filter(Boolean); +} + +function parseArgs(argv: string[]): CliArgs { + const parsed = yargs(argv) + .option('output', { + type: 'string', + array: true, + demandOption: true, + describe: 'Output JSON path(s), resolved relative to the cwd. Repeat to write the same data to several files.', + }) + .option('browsers', { + type: 'string', + default: DEFAULT_BROWSERS.join(','), + describe: 'Comma-separated browser ids for the support columns', + }) + .option('features', { + type: 'string', + demandOption: true, + describe: 'Comma-separated list of , =, or =::', + }) + .strict().argv; + + return { + output: parsed.output, + browsers: splitCsv(parsed.browsers), + features: splitCsv(parsed.features).map(parseFeatureToken), + }; +} + +export function pickBrowsers(support: WebSupport | undefined, browsers: string[]): Record { + const versions = (support ?? {}) as Record; + const result: Record = {}; + + for (const browser of browsers) { + result[browser] = versions[browser] ?? null; + } + + return result; +} + +export function normalizeFeature( + request: FeatureRequest, + browsers: string[], + allFeatures: Record, +): NormalizedFeature { + const feature = allFeatures[request.groupKey]; + + if (!feature) { + throw new Error(`web-features has no feature "${request.groupKey}" — verify the key against the installed data.`); + } + + const status = feature.status || ({} as WebFeatureStatus); + + if (request.compatKey) { + const compat = status.by_compat_key && status.by_compat_key[request.compatKey]; + + if (!compat) { + throw new Error(`Compat key "${request.compatKey}" not found for "${request.groupKey}".`); + } + + return { + key: request.id, + name: request.id, + baseline: compat.baseline as Baseline, + baselineLowDate: compat.baseline_low_date || null, + baselineHighDate: compat.baseline_high_date || null, + partial: false, + representativeBaseline: null, + representativeBaselineLowDate: null, + support: pickBrowsers(compat.support, browsers), + }; + } + + const hasAggregateSupport = Boolean(status.support && Object.keys(status.support).length > 0); + + let support = status.support; + let representativeBaseline: Baseline | null = null; + let representativeBaselineLowDate: string | null = null; + + if (!hasAggregateSupport && request.representativeCompatKey) { + const compat = status.by_compat_key && status.by_compat_key[request.representativeCompatKey]; + + if (!compat) { + throw new Error( + `Representative compat key "${request.representativeCompatKey}" not found for "${request.groupKey}".`, + ); + } + + support = compat.support; + representativeBaseline = compat.baseline as Baseline; + representativeBaselineLowDate = compat.baseline_low_date || null; + } + + return { + key: request.id, + name: feature.name, + baseline: status.baseline as Baseline, + baselineLowDate: status.baseline_low_date || null, + baselineHighDate: status.baseline_high_date || null, + partial: !hasAggregateSupport && Boolean(request.representativeCompatKey), + representativeBaseline, + representativeBaselineLowDate, + support: pickBrowsers(support, browsers), + }; +} + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + + const allFeatures = webFeaturesData as unknown as Record; + + const version = JSON.parse( + fs.readFileSync(path.join(path.dirname(require.resolve('web-features')), 'package.json'), 'utf8'), + ).version; + + const features: Record> = {}; + + for (const request of args.features) { + features[request.id] = normalizeFeature(request, args.browsers, allFeatures); + } + + const result = { + generatedFrom: `web-features@${version}`, + browsers: args.browsers, + features, + }; + + const json = JSON.stringify(result, null, 2) + '\n'; + + for (const output of args.output) { + const outPath = path.resolve(process.cwd(), output); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, json); + console.log(`Wrote ${path.relative(process.cwd(), outPath)} from web-features@${version}`); + } +} + +if (require.main === module) { + main(); +} diff --git a/tools/web-features/tsconfig.json b/tools/web-features/tsconfig.json new file mode 100644 index 00000000000000..a95e1d6f4a43cc --- /dev/null +++ b/tools/web-features/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "target": "ES2019", + "pretty": true, + "noEmit": true, + "allowJs": true, + "checkJs": true, + "sourceMap": true, + "noUnusedLocals": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/web-features/tsconfig.lib.json b/tools/web-features/tsconfig.lib.json new file mode 100644 index 00000000000000..ca1067058aa639 --- /dev/null +++ b/tools/web-features/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019"], + "outDir": "../../dist/out-tsc", + "types": ["node"], + "module": "CommonJS", + "moduleResolution": "Node10" + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "include": ["./src/**/*.ts", "./src/**/*.js"] +} diff --git a/tools/web-features/tsconfig.spec.json b/tools/web-features/tsconfig.spec.json new file mode 100644 index 00000000000000..a0a0008c224b9f --- /dev/null +++ b/tools/web-features/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node10", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/yarn.lock b/yarn.lock index e47568548beee9..c7baf78da9dacd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2682,7 +2682,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/eslint-plugin@workspace:packages/eslint-plugin": +"@fluentui/eslint-plugin@npm:*, @fluentui/eslint-plugin@workspace:packages/eslint-plugin": version: 0.0.0-use.local resolution: "@fluentui/eslint-plugin@workspace:packages/eslint-plugin" dependencies: @@ -3030,6 +3030,7 @@ __metadata: typescript: "npm:5.7.3" typescript-eslint: "npm:8.46.2" vite: "npm:6.4.2" + web-features: "npm:3.30.0" webpack: "npm:5.108.4" webpack-bundle-analyzer: "npm:4.10.1" webpack-cli: "npm:5.1.4" @@ -6594,6 +6595,14 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/web-features-generator@workspace:tools/web-features": + version: 0.0.0-use.local + resolution: "@fluentui/web-features-generator@workspace:tools/web-features" + dependencies: + "@fluentui/eslint-plugin": "npm:*" + languageName: unknown + linkType: soft + "@fluentui/webpack-utilities@workspace:packages/webpack-utilities": version: 0.0.0-use.local resolution: "@fluentui/webpack-utilities@workspace:packages/webpack-utilities" @@ -31908,6 +31917,13 @@ __metadata: languageName: node linkType: hard +"web-features@npm:3.30.0": + version: 3.30.0 + resolution: "web-features@npm:3.30.0" + checksum: 10c0/fa4f193af8a55aebc5ca7192abf809994084a51cbb4c608465efb7a4a532f739e046fe6fa902e14e847aee1022462620c820abd8db14c2296e71bcde31647fcc + languageName: node + linkType: hard + "web-streams-polyfill@npm:^3.0.3": version: 3.2.1 resolution: "web-streams-polyfill@npm:3.2.1"