diff --git a/.gitignore b/.gitignore index 758faab0dd1e6..c12f69f059139 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ $RECYCLE.BIN/ dist.stats.json bower_components node_modules +!packages/react-components/react-storybook-addon/src/preset/__fixtures__/**/node_modules +!packages/react-components/react-storybook-addon/src/preset/__fixtures__/**/node_modules/** # Yarn (ignore everything except patches, plugins, and releases) .yarn/* diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index d48c59b15dd70..073782313276f 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -5,10 +5,58 @@ "implicitDependencies": ["react-headless-components-preview-stories"], "tags": ["platform:web", "vNext"], "targets": { + "storybook": { + "dependsOn": [ + { + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox" + ], + "target": "build" + } + ] + }, + "storybook:docs": { + "dependsOn": [ + { + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox" + ], + "target": "build" + } + ] + }, + "start": { + "dependsOn": [ + { + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox" + ], + "target": "build" + } + ] + }, "build-storybook": { + "inputs": [ + "default", + "^production", + "{workspaceRoot}/.storybook/**", + "{projectRoot}/.storybook/**", + "{workspaceRoot}/packages/react-components/react-headless-components-preview/stories/.storybook/**" + ], "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "storybook-llms-extractor" + ], "target": "build" } ] @@ -16,11 +64,22 @@ "build-storybook:docsite": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-headless-components-preview", + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "storybook-llms-extractor" + ], "target": "build" } ], - "inputs": ["default", "{workspaceRoot}/.storybook/**", "{projectRoot}/.storybook/**"] + "inputs": [ + "default", + "^production", + "{workspaceRoot}/.storybook/**", + "{projectRoot}/.storybook/**", + "{workspaceRoot}/packages/react-components/react-headless-components-preview/stories/.storybook/**" + ] } } } diff --git a/change/@fluentui-react-headless-components-preview-c6f83c30-ba64-4639-a5e8-1d6d1560e9bc.json b/change/@fluentui-react-headless-components-preview-c6f83c30-ba64-4639-a5e8-1d6d1560e9bc.json new file mode 100644 index 0000000000000..82b098cb826f4 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-c6f83c30-ba64-4639-a5e8-1d6d1560e9bc.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "docs: clarify presence-only data attribute semantics", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-storybook-addon-7438d397-3d01-4a9f-9e4d-879e2ca08aed.json b/change/@fluentui-react-storybook-addon-7438d397-3d01-4a9f-9e4d-879e2ca08aed.json new file mode 100644 index 0000000000000..2c700f8c1e9fb --- /dev/null +++ b/change/@fluentui-react-storybook-addon-7438d397-3d01-4a9f-9e4d-879e2ca08aed.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add opt-in state data attribute documentation for Storybook ArgTypes.", + "packageName": "@fluentui/react-storybook-addon", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts index d2ec75c3fb670..1417d25e28eaa 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/Accordion.types.ts @@ -7,11 +7,11 @@ export type AccordionProps = AccordionBaseProps; export type AccordionState = AccordionBaseState & { root: { /** - * Data attribute set to indicate whether the accordion allows multiple items to be expanded at once. + * Present when the accordion allows all items to be collapsed; omitted when one item must remain open. */ 'data-collapsible'?: string; /** - * Data attribute set to indicate whether the accordion allows multiple items to be expanded at once. + * Present when the accordion allows multiple items to be expanded at once; omitted when only one item may be expanded. */ 'data-multiple'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionHeader/AccordionHeader.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionHeader/AccordionHeader.types.ts index 29efa306f60ac..b76f2a9c5b00d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionHeader/AccordionHeader.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionHeader/AccordionHeader.types.ts @@ -9,12 +9,12 @@ export type { export type AccordionHeaderState = AccordionHeaderBaseState & { root: { /** - * Data attribute set when the accordion item is open. + * Present when the accordion item is open; omitted when the accordion item is closed. */ 'data-open'?: string; /** - * Data attribute set when the accordion header is disabled. + * Present when the accordion header is disabled; omitted when enabled. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionItem/AccordionItem.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionItem/AccordionItem.types.ts index 599a501961a14..cdd39ae43ecdf 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionItem/AccordionItem.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionItem/AccordionItem.types.ts @@ -9,11 +9,11 @@ export type { export type AccordionItemState = AccordionItemBaseState & { root: { /** - * Data attribute set to indicate whether the accordion item is disabled. + * Present when the accordion item is disabled; omitted when enabled. */ 'data-disabled'?: string; /** - * Data attribute set to indicate whether the accordion item is open. + * Present when the accordion item is open; omitted when the accordion item is closed. */ 'data-open'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionPanel/AccordionPanel.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionPanel/AccordionPanel.types.ts index a035c14f68aa8..c07ea0abe20ed 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionPanel/AccordionPanel.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Accordion/AccordionPanel/AccordionPanel.types.ts @@ -5,7 +5,7 @@ export type { AccordionPanelSlots, AccordionPanelBaseProps as AccordionPanelProp export type AccordionPanelState = AccordionPanelBaseState & { root: { /** - * Data attribute set when the accordion panel is open. + * Present when the accordion panel is open; omitted when the accordion panel is closed. */ 'data-open'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Breadcrumb/BreadcrumbButton/BreadcrumbButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Breadcrumb/BreadcrumbButton/BreadcrumbButton.types.ts index b41af4beeea86..5f3e4a661efe5 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Breadcrumb/BreadcrumbButton/BreadcrumbButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Breadcrumb/BreadcrumbButton/BreadcrumbButton.types.ts @@ -11,7 +11,7 @@ export type { export type BreadcrumbButtonState = BreadcrumbButtonBaseState & { root: { /** - * Data attribute set to indicate that this button represents the current page in the breadcrumb. + * Present when this button represents the current page in the breadcrumb; omitted otherwise. */ 'data-current'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Button/Button.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Button/Button.types.ts index 45def2015b720..9dfaa53399fef 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Button/Button.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Button/Button.types.ts @@ -8,17 +8,17 @@ export type { ButtonSlots, ButtonBaseProps as ButtonProps } from '@fluentui/reac export type ButtonState = ButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts index b609c0fd43709..6f19cb8c21c8f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts @@ -18,6 +18,9 @@ export type CardProps = Omit; */ export type CardState = CardBaseState & { root: { + /** + * Present when selected; omitted otherwise. + */ 'data-selected'?: string; 'data-disabled'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Checkbox/Checkbox.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Checkbox/Checkbox.types.ts index 30571d427f42a..d4e871b0daddb 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Checkbox/Checkbox.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Checkbox/Checkbox.types.ts @@ -8,12 +8,12 @@ export type { CheckboxSlots, CheckboxBaseProps as CheckboxProps } from '@fluentu export type CheckboxState = CheckboxBaseState & { root: { /** - * Data attribute set when the checkbox is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the checkbox is checked. Value is 'mixed' when in the indeterminate state. + * Present when checked; omitted when unchecked. The value is "mixed" when indeterminate. */ 'data-checked'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts index 9c80757beb825..e83ebda599b65 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts @@ -16,11 +16,11 @@ export type ComboboxState = BaseComboboxState & { */ 'data-open'?: string; /** - * Whether the trigger element is currently disabled. + * Present when the input is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Whether the trigger element is currently displaying a placeholder. + * Present when a placeholder is displayed; omitted when a value is present. */ 'data-placeholder'?: string; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Drawer/InlineDrawer/InlineDrawer.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Drawer/InlineDrawer/InlineDrawer.types.ts index 5c89ab979ce07..caf8e90ddeb30 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Drawer/InlineDrawer/InlineDrawer.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Drawer/InlineDrawer/InlineDrawer.types.ts @@ -5,7 +5,7 @@ export type { InlineDrawerSlots, InlineDrawerBaseProps as InlineDrawerProps } fr export type InlineDrawerState = InlineDrawerBaseState & { root: { /** - * Indicates whether the drawer is open, used for styling purposes. + * Present when the drawer is open; omitted when it is closed. */ 'data-open'?: string; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts index f05547444ee94..17b2a48c98e51 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts @@ -16,11 +16,11 @@ export type DropdownState = DropdownBaseHookState & { */ 'data-open'?: string; /** - * Whether the trigger element is currently disabled. + * Present when the trigger is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Whether the trigger element is currently displaying a placeholder. + * Present when a placeholder is displayed; omitted when a value is present. */ 'data-placeholder'?: string; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/Option.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/Option.types.ts index 88303ce0ec9c1..7497bd0e0e16a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/Option.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/Option.types.ts @@ -8,11 +8,11 @@ export type { OptionSlots, OptionProps } from '@fluentui/react-combobox'; export type OptionState = OptionBaseState & { root: { /** - * Whether the option is currently disabled. + * Present when the option is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Whether the option is currently selected. + * Present when the option is selected; omitted otherwise. */ 'data-selected'?: string; /** diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Input/Input.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Input/Input.types.ts index 8b24edb964bea..b22dbe9cd084d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Input/Input.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Input/Input.types.ts @@ -8,7 +8,7 @@ export type { InputSlots, InputBaseProps as InputProps } from '@fluentui/react-i export type InputState = InputBaseState & { root: { /** - * Data attribute set when the input is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts index c4e7f2e046ec9..8441ee4719e09 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts @@ -5,12 +5,12 @@ export type { InteractionTagSlots, InteractionTagBaseProps as InteractionTagProp export type InteractionTagState = InteractionTagBaseState & { root: { /** - * Data attribute set when the interaction tag is disabled. + * Present when the interaction tag is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the interaction tag is selected. + * Present when the interaction tag is selected; omitted otherwise. */ 'data-selected'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagPrimary/InteractionTagPrimary.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagPrimary/InteractionTagPrimary.types.ts index 0a15ac0749555..1fd94758acaad 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagPrimary/InteractionTagPrimary.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagPrimary/InteractionTagPrimary.types.ts @@ -9,17 +9,17 @@ export type { export type InteractionTagPrimaryState = InteractionTagPrimaryBaseState & { root: { /** - * Data attribute set when the primary action is disabled. + * Present when the primary action is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the interaction tag is selected. + * Present when the interaction tag is selected; omitted otherwise. */ 'data-selected'?: string; /** - * Data attribute set when the interaction tag has a secondary action. + * Present when the interaction tag has a secondary action; omitted otherwise. */ 'data-has-secondary-action'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagSecondary/InteractionTagSecondary.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagSecondary/InteractionTagSecondary.types.ts index 715110cfdf01a..9600eff518069 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagSecondary/InteractionTagSecondary.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTagSecondary/InteractionTagSecondary.types.ts @@ -8,12 +8,12 @@ export type { export type InteractionTagSecondaryState = InteractionTagSecondaryBaseState & { root: { /** - * Data attribute set when the secondary action is disabled. + * Present when the secondary action is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the interaction tag is selected. + * Present when the interaction tag is selected; omitted otherwise. */ 'data-selected'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Label/Label.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Label/Label.types.ts index 5e8832e03dc7a..e4484cd92e37a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Label/Label.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Label/Label.types.ts @@ -8,7 +8,7 @@ export type { LabelSlots, LabelBaseProps as LabelProps } from '@fluentui/react-l export type LabelState = LabelBaseState & { root: { /** - * Data attribute set when the label is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Link/Link.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Link/Link.types.ts index 856eb5c95c2e1..daf7fb9ff4a95 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Link/Link.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Link/Link.types.ts @@ -8,12 +8,12 @@ export type { LinkSlots, LinkBaseProps as LinkProps } from '@fluentui/react-link export type LinkState = LinkBaseState & { root: { /** - * Data attribute set when the link is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the link is disabled but still focusable. + * Present when disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts index cf6ac90fc2edd..4471b44d949ba 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/MenuButton/MenuButton.types.ts @@ -8,17 +8,17 @@ export type { MenuButtonBaseProps as MenuButtonProps, MenuButtonSlots } from '@f export type MenuButtonState = MenuButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when the button is disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBarActions/MessageBarActions.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBarActions/MessageBarActions.types.ts index b6e81efbf8054..d880e711ea44c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBarActions/MessageBarActions.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/MessageBar/MessageBarActions/MessageBarActions.types.ts @@ -14,7 +14,7 @@ export type MessageBarActionsState = MessageBarActionsBaseState & { 'data-layout'?: string; /** - * Data attribute set when actions content is present. + * Present when actions content is present; omitted otherwise. */ 'data-has-actions'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/RadioGroup/Radio/Radio.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/RadioGroup/Radio/Radio.types.ts index 6aa2be03383c7..ab2f5b0c7fee1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/RadioGroup/Radio/Radio.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/RadioGroup/Radio/Radio.types.ts @@ -8,7 +8,7 @@ export type { RadioSlots, RadioBaseProps as RadioProps } from '@fluentui/react-r export type RadioState = RadioBaseState & { root: { /** - * Data attribute set when the radio is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SearchBox/Search.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SearchBox/Search.types.ts index 6e1e4805a3d54..3ebb679183619 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SearchBox/Search.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SearchBox/Search.types.ts @@ -8,12 +8,12 @@ export type { SearchBoxSlots, SearchBoxBaseProps as SearchBoxProps } from '@flue export type SearchBoxState = SearchBoxBaseState & { root: { /** - * Data attribute set when the search box is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the search box has focus within. + * Present when the search box has focus within; omitted otherwise. */ 'data-focused'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Select/Select.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Select/Select.types.ts index 40f2848a286e0..0e540f3d72aec 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Select/Select.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Select/Select.types.ts @@ -8,7 +8,7 @@ export type { SelectSlots, SelectBaseProps as SelectProps } from '@fluentui/reac export type SelectState = SelectBaseState & { root: { /** - * Data attribute set when the select is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Slider/Slider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Slider/Slider.types.ts index a6eadf54b5169..d4d3a71336fff 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Slider/Slider.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Slider/Slider.types.ts @@ -8,12 +8,12 @@ export type { SliderSlots, SliderBaseProps as SliderProps } from '@fluentui/reac export type SliderState = SliderBaseState & { root: { /** - * Data attribute set when the slider is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the slider is oriented vertically. + * Present when oriented vertically; omitted when oriented horizontally. */ 'data-vertical'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SpinButton/SpinButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SpinButton/SpinButton.types.ts index 0e93887b997ee..d0e29fe37c7ce 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SpinButton/SpinButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SpinButton/SpinButton.types.ts @@ -8,7 +8,7 @@ export type { SpinButtonSlots, SpinButtonBaseProps as SpinButtonProps } from '@f export type SpinButtonState = SpinButtonBaseState & { root: { /** - * Data attribute set when the spin button is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Switch/Switch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Switch/Switch.types.ts index cf7a597de3720..dfdf21229ca62 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Switch/Switch.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Switch/Switch.types.ts @@ -8,17 +8,17 @@ export type { SwitchSlots, SwitchBaseProps as SwitchProps } from '@fluentui/reac export type SwitchState = SwitchBaseState & { root: { /** - * Data attribute set when the switch is disabled. + * Present when disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the switch is disabled but still focusable. + * Present when disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the switch is checked (controlled mode only). + * Present when checked in controlled mode; omitted when unchecked or uncontrolled. */ 'data-checked'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TabList/Tab/Tab.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TabList/Tab/Tab.types.ts index ba1e87eef5df6..91a7054fa0f15 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TabList/Tab/Tab.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TabList/Tab/Tab.types.ts @@ -4,7 +4,13 @@ import type { TabBaseState } from '@fluentui/react-tabs'; export type TabState = TabBaseState & { root: { + /** + * Present when the tab renders only an icon; omitted otherwise. + */ 'data-icon-only'?: string; + /** + * Present when the tab is selected; omitted otherwise. + */ 'data-selected'?: string; 'data-disabled'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts index cfa79f3c1906f..192b96ba13508 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts @@ -5,17 +5,17 @@ export type { TagBaseProps as TagProps, TagSlots, TagContextValues } from '@flue export type TagState = TagBaseState & { root: { /** - * Data attribute set when the tag is disabled. + * Present when the tag is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the tag renders as a dismissible button. + * Present when the tag renders as a dismissible button; omitted otherwise. */ 'data-dismissible'?: string; /** - * Data attribute set when the tag is selected. + * Present when the tag is selected; omitted otherwise. */ 'data-selected'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts index 8d9f91de1db26..935cf0c523230 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts @@ -5,12 +5,12 @@ export type { TagGroupBaseProps as TagGroupProps, TagGroupSlots, TagGroupContext export type TagGroupState = TagGroupBaseState & { root: { /** - * Data attribute set when the group is disabled. + * Present when the group is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the group is dismissible. + * Present when the group is dismissible; omitted otherwise. */ 'data-dismissible'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts index 0df4a700d6c6c..81a53bd9141b4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts @@ -11,7 +11,7 @@ export type { export type TagPickerButtonState = TagPickerButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts index 3ac9f24b2a482..177be445fb128 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts @@ -12,11 +12,11 @@ export type { export type TagPickerControlState = TagPickerControlBaseState & { root: { /** - * Data attribute set when the control is disabled. + * Present when the control is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the control is in an invalid (error) field state. + * Present when the control is in an invalid field state; omitted otherwise. */ 'data-invalid'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts index 17f3ffdfa6018..9ad833df0031b 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts @@ -19,7 +19,7 @@ export type TagPickerGroupProps = ComponentProps & export type TagPickerGroupState = TagPickerGroupBaseState & { root: { /** - * Data attribute set when the group is disabled. + * Present when the group is disabled; omitted otherwise. */ 'data-disabled'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts index e38c86533d8cf..fd49667b1ba9f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts @@ -8,7 +8,7 @@ export type { TagPickerInputBaseProps as TagPickerInputProps, TagPickerInputSlot export type TagPickerInputState = TagPickerInputBaseState & { root: { /** - * Data attribute set when the input is disabled. + * Present when the input is disabled; omitted otherwise. */ 'data-disabled'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ToggleButton/ToggleButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/ToggleButton/ToggleButton.types.ts index 601f6fe9bbc41..97332d8a26374 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/ToggleButton/ToggleButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/ToggleButton/ToggleButton.types.ts @@ -9,22 +9,22 @@ export type { ToggleButtonBaseProps as ToggleButtonProps } from '@fluentui/react export type ToggleButtonState = ToggleButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when the button is disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; /** - * Data attribute set when the button is in a checked (pressed) state. + * Present when the button is checked; omitted otherwise. */ 'data-checked'?: string; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/Toolbar.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/Toolbar.types.ts index 5cd8c1f0b170c..715d1257e982a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/Toolbar.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/Toolbar.types.ts @@ -5,7 +5,7 @@ export type { ToolbarSlots, ToolbarBaseProps as ToolbarProps, ToolbarContextValu export type ToolbarState = ToolbarBaseState & { root: { /** - * Data attribute set when the toolbar is vertically oriented. + * Present when the toolbar is vertically oriented; omitted when it is horizontally oriented. */ 'data-vertical'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarButton/ToolbarButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarButton/ToolbarButton.types.ts index 7012eabd02b3f..47ebcb61b00b9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarButton/ToolbarButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarButton/ToolbarButton.types.ts @@ -5,22 +5,22 @@ export type { ToolbarButtonBaseProps as ToolbarButtonProps } from '@fluentui/rea export type ToolbarButtonState = ToolbarButtonBaseState & { root: { /** - * Data attribute set when the button is in a vertically oriented toolbar. + * Present when the button is in a vertically oriented toolbar; omitted when it is in a horizontally oriented toolbar. */ 'data-vertical'?: string; /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when the button is disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarDivider/ToolbarDivider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarDivider/ToolbarDivider.types.ts index 8a4bcf288685b..ceb8a88d00921 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarDivider/ToolbarDivider.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarDivider/ToolbarDivider.types.ts @@ -5,8 +5,8 @@ export type { ToolbarDividerBaseProps as ToolbarDividerProps } from '@fluentui/r export type ToolbarDividerState = ToolbarDividerBaseState & { root: { /** - * Data attribute reflecting the actual orientation of the divider element. - * Note: the toolbar divider's orientation is inverted relative to the toolbar's orientation. + * Present when the divider is vertically oriented (in a horizontally oriented toolbar); + * omitted when the divider is horizontally oriented (in a vertically oriented toolbar). */ 'data-vertical'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarGroup/ToolbarGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarGroup/ToolbarGroup.types.ts index 49d90f42fbca7..0de69aaa51005 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarGroup/ToolbarGroup.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarGroup/ToolbarGroup.types.ts @@ -5,7 +5,8 @@ export type { ToolbarGroupProps } from '@fluentui/react-toolbar'; export type ToolbarGroupState = ToolbarGroupBaseState & { root: { /** - * Data attribute set when the toolbar group is in a vertically oriented toolbar. + * Present when the toolbar group is in a vertically oriented toolbar; + * omitted when it is in a horizontally oriented toolbar. */ 'data-vertical'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioButton/ToolbarRadioButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioButton/ToolbarRadioButton.types.ts index a31cbf9b34326..7b935384aec18 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioButton/ToolbarRadioButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioButton/ToolbarRadioButton.types.ts @@ -5,22 +5,22 @@ export type { ToolbarRadioButtonBaseProps as ToolbarRadioButtonProps } from '@fl export type ToolbarRadioButtonState = ToolbarRadioButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when the button is disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; /** - * Data attribute set when the button is in a checked (selected) state. + * Present when the button is checked (selected); omitted when it is unchecked. */ 'data-checked'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioGroup/ToolbarRadioGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioGroup/ToolbarRadioGroup.types.ts index 92d8f3f0fe53b..5900b3b5d4ebf 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioGroup/ToolbarRadioGroup.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarRadioGroup/ToolbarRadioGroup.types.ts @@ -9,7 +9,8 @@ export type ToolbarRadioGroupState = ToolbarRadioGroupBaseState & { vertical?: boolean; root: { /** - * Data attribute set when the toolbar radio group is in a vertically oriented toolbar. + * Present when the toolbar radio group is in a vertically oriented toolbar; + * omitted when it is in a horizontally oriented toolbar. */ 'data-vertical'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarToggleButton/ToolbarToggleButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarToggleButton/ToolbarToggleButton.types.ts index 4b49fd9f180fc..03e06458cd9b3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarToggleButton/ToolbarToggleButton.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Toolbar/ToolbarToggleButton/ToolbarToggleButton.types.ts @@ -5,22 +5,22 @@ export type { ToolbarToggleButtonBaseProps as ToolbarToggleButtonProps } from '@ export type ToolbarToggleButtonState = ToolbarToggleButtonBaseState & { root: { /** - * Data attribute set when the button is disabled. + * Present when the button is disabled; omitted otherwise. */ 'data-disabled'?: string; /** - * Data attribute set when the button is disabled but still focusable. + * Present when the button is disabled but still focusable; omitted otherwise. */ 'data-disabled-focusable'?: string; /** - * Data attribute set when the button renders only an icon. + * Present when the button renders only an icon; omitted otherwise. */ 'data-icon-only'?: string; /** - * Data attribute set when the button is in a checked (pressed) state. + * Present when the button is checked (pressed); omitted when it is unchecked. */ 'data-checked'?: string; }; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css b/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css index 39c3039bf5913..1127f65f6e7ba 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/headless-docs-page.css @@ -46,3 +46,20 @@ box-shadow: #9b1f5a 0 -3px 0 0 inset !important; color: #9b1f5a !important; } + +#storybook-docs .headless-docs-page .docblock-argstable tr[title^='Hide Data attributes'] > td > button + span, +#storybook-docs .headless-docs-page .docblock-argstable tr[title^='Show Data attributes'] > td > button + span { + font-size: 12px; + font-weight: 700; +} + +#storybook-docs .headless-docs-page .docblock-argstable tr[title^='Hide Data attributes'] > td { + min-width: 125px; +} + +#storybook-docs .headless-docs-page .docblock-argstable tr[title^='Hide Data attributes'] > td > button + span > svg, +#storybook-docs .headless-docs-page .docblock-argstable tr[title^='Show Data attributes'] > td > button + span > svg { + width: 10px; + height: 10px; + flex: 0 0 10px; +} diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js index 8d95fb49d632f..307c55674d26b 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/main.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/main.js @@ -1,43 +1,52 @@ const path = require('path'); const rootMain = require('../../../../../.storybook/main'); -const { - loadWorkspaceAddon, - getImportMappingsForExportToSandboxAddon, - processBabelLoaderOptions, -} = require('@fluentui/scripts-storybook'); const { registerCssModuleRules } = require('./css-modules-webpack'); const repoRoot = path.resolve(__dirname, '../../../../..'); -const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); +const packageRoot = path.resolve(repoRoot, 'packages/react-components/react-headless-components-preview/library'); +const addonPresetPattern = /(?:^|\/)react-storybook-addon\/(?:temp\/)?preset\.[jt]s$/; +const exportToSandboxPresetPattern = /(?:^|\/)react-storybook-addon-export-to-sandbox\/(?:temp\/)?preset\.[jt]s$/; /** - * @param {string | { name?: string }} addon + * @param {string | { name?: string; options?: { stateDataAttributes?: object; cssModules?: object; [key: string]: unknown }; [key: string]: unknown }} addon */ -function isNotExportToSandboxAddon(addon) { +function configureStateDataAttributes(addon) { const name = typeof addon === 'string' ? addon : addon?.name ?? ''; - return !name.includes('react-storybook-addon-export-to-sandbox'); + const normalizedName = name.replace(/\\/g, '/'); + const options = typeof addon === 'string' ? undefined : addon.options; + const configuredAddon = typeof addon === 'string' ? { name: addon } : addon; + + if (addonPresetPattern.test(normalizedName)) { + return { + ...configuredAddon, + options: { + ...options, + stateDataAttributes: { ...options?.stateDataAttributes, packageRoot }, + }, + }; + } + + if (exportToSandboxPresetPattern.test(normalizedName)) { + return { + ...configuredAddon, + options: { + ...options, + cssModules: { + ...options?.cssModules, + tokensFilePath: path.resolve(__dirname, 'tokens.css'), + }, + }, + }; + } + + return addon; } module.exports = /** @type {Omit} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], - addons: [ - ...rootMain.addons.filter(isNotExportToSandboxAddon), - loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { - tsConfigPath, - /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ - options: { - importMappings: getImportMappingsForExportToSandboxAddon(), - babelLoaderOptionsUpdater: processBabelLoaderOptions, - cssModules: { tokensFilePath: path.resolve(__dirname, 'tokens.css') }, - webpackRule: { - test: /\.stories\.tsx$/, - include: /stories/, - }, - }, - }), - ], + addons: rootMain.addons.map(configureStateDataAttributes), webpackFinal: (config, options) => { const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); diff --git a/packages/react-components/react-headless-components-preview/stories/project.json b/packages/react-components/react-headless-components-preview/stories/project.json index 0effce2bfe286..a81ad27d44569 100644 --- a/packages/react-components/react-headless-components-preview/stories/project.json +++ b/packages/react-components/react-headless-components-preview/stories/project.json @@ -4,5 +4,33 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-headless-components-preview/stories/src", "tags": ["vNext", "platform:web", "type:stories", "react-headless"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "storybook": { + "inputs": [ + "default", + "^production", + "{workspaceRoot}/.storybook/**", + "{projectRoot}/.storybook/**", + { + "externalDependencies": ["storybook"] + } + ], + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon"], + "target": "build" + } + ] + }, + "build-storybook": { + "inputs": ["default", "^production", "{workspaceRoot}/.storybook/**", "{projectRoot}/.storybook/**"], + "dependsOn": [ + { + "projects": ["react-headless-components-preview", "react-storybook-addon"], + "target": "build" + } + ] + } + } } diff --git a/packages/react-components/react-storybook-addon/.swcrc b/packages/react-components/react-storybook-addon/.swcrc index a5d9d62d2f9c8..fb58c167d3755 100644 --- a/packages/react-components/react-storybook-addon/.swcrc +++ b/packages/react-components/react-storybook-addon/.swcrc @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/swcrc", "exclude": [ "/testing", + "/**/__fixtures__/**", "/**/*.cy.ts", "/**/*.cy.tsx", "/**/*.spec.ts", diff --git a/packages/react-components/react-storybook-addon/README.md b/packages/react-components/react-storybook-addon/README.md index 1ce937b7db812..7ef64c607becf 100644 --- a/packages/react-components/react-storybook-addon/README.md +++ b/packages/react-components/react-storybook-addon/README.md @@ -78,6 +78,56 @@ module.exports = { }; ``` +### Advanced Configuration (opt-in `stateDataAttributes`) + +The addon can be registered with an options object (instead of a bare string) to opt into extracting +all `data-*` members declared on the immediate properties of exported component `*State` types and surfacing them as +read-only Storybook ArgTypes rows, alongside the framework's native (e.g. react-docgen-typescript) ArgTypes: + +```js +// .storybook/main.js +const path = require('node:path'); + +module.exports = { + addons: [ + { + name: '@fluentui/react-storybook-addon', + options: { + stateDataAttributes: { + packageRoot: path.resolve(__dirname, '../../my-package'), + }, + }, + }, + ], +}; +``` + +Notes: + +- **Disabled by default.** Omitting `stateDataAttributes` entirely leaves native Storybook behavior untouched. +- **`packageRoot` must be an absolute path** (e.g. derived from `__dirname` as above). A relative path throws at + build time. +- **`packageRoot` must already be built.** Extraction reads the package's on-disk `.d.ts` rollups; it never falls + back to scanning TypeScript source, so the package must be built (its `exports` map targets must exist on disk) + before Storybook is started or built. +- Extraction discovers entry points exclusively from `packageRoot`'s `package.json` `exports` map: only export + conditions with a direct string `types` target (e.g. `"./button": { "types": "./dist/button.d.ts" }`) are scanned. + Nested `import.types`/`require.types` forms, the `"./package.json"` self-reference, and any non-object export + values are ignored. +- Every immediate state property is inspected, including optional slots. +- Rows for `root` keep raw keys and use the `Data attributes` category. For non-root properties, internal keys are + `.`, visible names remain raw `data-*`, and the category is `Data attributes · `. +- Only names that are genuinely exported from a scanned `.d.ts` entry point are surfaced; declarations that merely + happen to appear in the file (e.g. a non-exported helper type used only to compose an exported `*State` type) are + ignored. +- When two entry points declare the same component key with identical extracted metadata, the duplicate is silently + deduped. When they declare the same key with **different** metadata, extraction throws a conflicting-metadata + error identifying both declaration files. +- Native React prop extraction remains responsible for normal props; this feature only adds state data attribute rows. +- When a generated row and a native row have an exact key collision, **the native row wins**. +- The optional TypeScript, react-docgen, and Storybook React peers are loaded only when `stateDataAttributes` is enabled. +- Restart Storybook after rebuilding `packageRoot` so the extracted rows are refreshed. + ## Development 1. Run the inner loop from the monorepo root with `yarn workspace @fluentui/react-storybook-addon storybook`. diff --git a/packages/react-components/react-storybook-addon/eslint.config.js b/packages/react-components/react-storybook-addon/eslint.config.js index d21eda3d690b2..45f05aeb505e7 100644 --- a/packages/react-components/react-storybook-addon/eslint.config.js +++ b/packages/react-components/react-storybook-addon/eslint.config.js @@ -11,4 +11,7 @@ module.exports = [ '@fluentui/react-components/enforce-use-client': 'off', }, }, + { + ignores: ['src/preset/__fixtures__/**'], + }, ]; diff --git a/packages/react-components/react-storybook-addon/etc/react-storybook-addon.api.md b/packages/react-components/react-storybook-addon/etc/react-storybook-addon.api.md index 2aefadc8791a9..44e768ac4a59d 100644 --- a/packages/react-components/react-storybook-addon/etc/react-storybook-addon.api.md +++ b/packages/react-components/react-storybook-addon/etc/react-storybook-addon.api.md @@ -71,6 +71,16 @@ export interface FluentStoryContext extends StoryContext { // @public (undocumented) export function parameters(options?: FluentParameters): FluentParameters; +// @public +export interface PresetConfig { + stateDataAttributes?: StateDataAttributesConfig; +} + +// @public +export type StateDataAttributesConfig = { + packageRoot: string; +}; + // @public (undocumented) export type Theme = (typeof themes)[number]; diff --git a/packages/react-components/react-storybook-addon/jest.config.js b/packages/react-components/react-storybook-addon/jest.config.js index b574ad1c80134..c5fbb20cd7b81 100644 --- a/packages/react-components/react-storybook-addon/jest.config.js +++ b/packages/react-components/react-storybook-addon/jest.config.js @@ -30,4 +30,8 @@ module.exports = { }, coverageDirectory: './coverage', setupFilesAfterEnv: ['./config/tests.js'], + testPathIgnorePatterns: ['/src/preset/__fixtures__/'], + // Fixture package.json/types files are test data (including intentionally malformed JSON) consumed + // directly by the tests via fs/TypeScript APIs — they must not be crawled by Jest's haste map. + modulePathIgnorePatterns: ['/src/preset/__fixtures__/'], }; diff --git a/packages/react-components/react-storybook-addon/package.json b/packages/react-components/react-storybook-addon/package.json index 6eb489b882b34..5ec6f8a48ee75 100644 --- a/packages/react-components/react-storybook-addon/package.json +++ b/packages/react-components/react-storybook-addon/package.json @@ -31,12 +31,27 @@ "peerDependencies": { "@storybook/addon-docs": "^9.1.17", "@storybook/icons": "^1.3.2", + "@storybook/react": "^9.1.17", "@storybook/react-webpack5": "^9.1.17", "@types/react": ">=16.14.0 <20.0.0", "@types/react-dom": ">=16.9.0 <20.0.0", "react": ">=16.14.0 <20.0.0", + "react-docgen-typescript": "^2.2.2", "react-dom": ">=16.14.0 <20.0.0", - "storybook": "^9.1.17" + "storybook": "^9.1.17", + "typescript": ">=5.7.0 <6.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@storybook/react": { + "optional": true + }, + "react-docgen-typescript": { + "optional": true + }, + "typescript": { + "optional": true + } }, "beachball": { "disallowedChangeTypes": [ diff --git a/packages/react-components/react-storybook-addon/preset.js b/packages/react-components/react-storybook-addon/preset.js index 405bde4673742..c5d54e52fb307 100644 --- a/packages/react-components/react-storybook-addon/preset.js +++ b/packages/react-components/react-storybook-addon/preset.js @@ -1,9 +1,21 @@ -function previewAnnotations(entry = []) { - return [...entry, require.resolve('./lib/preset/preview')]; +/** + * CommonJS bridge for Storybook's preset API. Preview and manager entries point to browser-resolved paths. + */ +const preset = require('./lib-commonjs/preset/preset'); +const { isStateDataAttributesConfigured } = require('./lib-commonjs/preset/options'); + +function previewAnnotations(entry = [], options = {}) { + const annotations = [...entry, require.resolve('./lib/preset/preview')]; + + if (isStateDataAttributesConfigured(options)) { + annotations.push(require.resolve('./lib/preset/stateDataAttributesPreview')); + } + + return annotations; } function managerEntries(entry = []) { return [...entry, require.resolve('./lib/preset/manager')]; } -module.exports = { managerEntries, previewAnnotations }; +module.exports = { managerEntries, previewAnnotations, ...preset }; diff --git a/packages/react-components/react-storybook-addon/src/index.ts b/packages/react-components/react-storybook-addon/src/index.ts index 41d4145596b52..badf9f677c7f6 100644 --- a/packages/react-components/react-storybook-addon/src/index.ts +++ b/packages/react-components/react-storybook-addon/src/index.ts @@ -5,3 +5,4 @@ export { DIR_ID, THEME_ID, THEMES } from './constants'; export { parameters } from './hooks'; export { FluentCanvas, FluentDocsPage, FluentStory } from './docs'; export type { FluentDocsPageProps } from './docs'; +export type { PresetConfig, StateDataAttributesConfig } from './public-types'; diff --git a/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/invalid/package.json b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/invalid/package.json new file mode 100644 index 0000000000000..d8d3404ccd2e2 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/invalid/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fixtures/state-data-attributes-invalid", + "version": "0.0.0" +} diff --git a/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/package.json b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/package.json new file mode 100644 index 0000000000000..1ef2cd5e42c50 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixtures/state-data-attributes-valid", + "version": "0.0.0", + "exports": { + "./button": { + "types": "./types/button.d.ts" + } + } +} diff --git a/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/types/button.d.ts b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/types/button.d.ts new file mode 100644 index 0000000000000..41001f8201440 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/__fixtures__/state-data-attributes/valid/types/button.d.ts @@ -0,0 +1,6 @@ +export type ButtonState = { + root: { + 'data-disabled'?: boolean; + className?: string; + }; +}; diff --git a/packages/react-components/react-storybook-addon/src/preset/getExportedTypeEntryPoints.ts b/packages/react-components/react-storybook-addon/src/preset/getExportedTypeEntryPoints.ts new file mode 100644 index 0000000000000..5e8401dba47e3 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/getExportedTypeEntryPoints.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readPackageJson(packageJsonPath: string): unknown { + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`getExportedTypeEntryPoints: package.json not found at "${packageJsonPath}"`); + } + + const raw = fs.readFileSync(packageJsonPath, 'utf-8'); + + try { + return JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`getExportedTypeEntryPoints: failed to parse "${packageJsonPath}" as JSON: ${message}`); + } +} + +/** + * Safely resolves package-contained `.d.ts` entry points declared as direct string `types` targets in an + * `exports` map, such as API Extractor rollups. + * + * Nested conditions, package self-references, and non-object export values are ignored. + */ +export function getExportedTypeEntryPoints(packageRoot: string): string[] { + if (!path.isAbsolute(packageRoot)) { + throw new Error(`getExportedTypeEntryPoints: packageRoot must be an absolute path, received "${packageRoot}"`); + } + + const packageJsonPath = path.join(packageRoot, 'package.json'); + const packageJson = readPackageJson(packageJsonPath); + + if (!isRecord(packageJson)) { + throw new Error(`getExportedTypeEntryPoints: "${packageJsonPath}" does not contain a JSON object`); + } + + const exportsMap = packageJson.exports; + + if (!isRecord(exportsMap)) { + throw new Error(`getExportedTypeEntryPoints: "${packageJsonPath}" is missing an "exports" object`); + } + + const seen = new Set(); + const entryPoints: string[] = []; + + for (const [exportKey, exportValue] of Object.entries(exportsMap)) { + if (exportKey === './package.json' || !isRecord(exportValue)) { + continue; + } + + if (!('types' in exportValue)) { + continue; + } + + const typesValue = exportValue.types; + + if (typeof typesValue !== 'string') { + throw new Error( + `getExportedTypeEntryPoints: exports["${exportKey}"].types in "${packageJsonPath}" must be a string, received ${typeof typesValue}`, + ); + } + + const resolved = path.resolve(packageRoot, typesValue); + const relativeToRoot = path.relative(packageRoot, resolved); + + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + throw new Error( + `getExportedTypeEntryPoints: exports["${exportKey}"].types ("${typesValue}") escapes packageRoot "${packageRoot}"`, + ); + } + + if (!resolved.endsWith('.d.ts')) { + throw new Error( + `getExportedTypeEntryPoints: exports["${exportKey}"].types ("${typesValue}") must point to a ".d.ts" file`, + ); + } + + if (!fs.existsSync(resolved)) { + throw new Error( + `getExportedTypeEntryPoints: exports["${exportKey}"].types resolves to "${resolved}", which does not exist`, + ); + } + + if (!seen.has(resolved)) { + seen.add(resolved); + entryPoints.push(resolved); + } + } + + if (entryPoints.length === 0) { + throw new Error( + `getExportedTypeEntryPoints: no direct export map "types" entry points found in "${packageJsonPath}"`, + ); + } + + return entryPoints; +} diff --git a/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.spec.ts b/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.spec.ts new file mode 100644 index 0000000000000..044955364a232 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.spec.ts @@ -0,0 +1,27 @@ +import * as path from 'path'; + +import { getStateDataAttributes } from './getStateDataAttributes'; + +const FIXTURES_ROOT = path.join(__dirname, '__fixtures__/state-data-attributes'); + +describe('getStateDataAttributes', () => { + it('extracts data attributes from an exported component state', () => { + const result = getStateDataAttributes({ packageRoot: path.join(FIXTURES_ROOT, 'valid') }); + + expect(Object.keys(result)).toEqual(['Button']); + expect(Object.keys(result.Button)).toEqual(['data-disabled']); + expect(result.Button['data-disabled']).toMatchObject({ + table: { + category: 'Data attributes', + type: { summary: 'boolean' }, + }, + control: false, + }); + }); + + it('throws an actionable error when package exports are missing', () => { + expect(() => getStateDataAttributes({ packageRoot: path.join(FIXTURES_ROOT, 'invalid') })).toThrow( + /missing an "exports" object/, + ); + }); +}); diff --git a/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.ts b/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.ts new file mode 100644 index 0000000000000..0ad26d454607b --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/getStateDataAttributes.ts @@ -0,0 +1,535 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import { Parser } from 'react-docgen-typescript'; +import type { StrictArgTypes } from 'storybook/internal/types'; + +import type { StateDataAttributesConfig } from '../public-types'; +import { getExportedTypeEntryPoints } from './getExportedTypeEntryPoints'; + +/** Options for extracting `data-*` ArgTypes from a package's exported component state declarations. */ +export type GetStateDataAttributesOptions = StateDataAttributesConfig; + +const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2019, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + skipLibCheck: false, + types: [], + noEmit: true, +}; + +/** + * Upper bound on state property nesting depth walked by {@link collectStateDataAttributeRows}. Generous + * enough for any real component state shape (deepest known real-world states nest a handful of levels), + * while still catching pathological/mistaken type shapes (e.g. an accidentally-recursive alias that + * escapes cycle detection due to differing type arguments at every level) with an actionable error + * instead of silently truncating output or hanging. + */ +const MAX_STATE_TRAVERSAL_DEPTH = 10; + +/** + * Upper bound on the number of `data-*` rows a single state may emit. Generous enough for any real + * component (the largest known real states emit a few dozen rows), while still catching combinatorial + * blow-ups from fan-out type shapes with an actionable error instead of silently truncating output. + */ +const MAX_STATE_DATA_ATTRIBUTE_ROWS = 1000; + +/** + * Upper bound on the number of traversal-node visits performed while walking a single exported state + * (every recursive {@link collectStateDataAttributeRows} visit call counts, including zero-row types). + * Depth and emitted-row limits alone do not bound a same-package type graph with wide fan-out and zero + * `data-*` leaves: such a graph can visit exponentially many nodes without ever exceeding the depth limit + * (its levels stay shallow) or the row limit (it emits nothing). This generous, independent budget is + * scoped per exported state traversal and catches that case with an actionable error instead of silently + * truncating output or running away. + */ +const MAX_STATE_TRAVERSAL_NODES = 50_000; + +function getPreservedCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions { + const preserved: ts.CompilerOptions = {}; + + ['target', 'module', 'moduleResolution', 'lib'].forEach(option => { + if (options[option] !== undefined) { + preserved[option] = options[option]; + } + }); + + return preserved; +} + +function hasSymbolFlag(symbol: ts.Symbol, flag: ts.SymbolFlags) { + // eslint-disable-next-line no-bitwise + return (symbol.flags & flag) !== 0; +} + +function getLocallyExportedNames(sourceFile: ts.SourceFile): { exported: Set; nonExported: Set } { + const exported = new Set(); + const nonExported = new Set(); + + for (const statement of sourceFile.statements) { + if (ts.isExportDeclaration(statement)) { + if (statement.exportClause && ts.isNamedExports(statement.exportClause)) { + for (const specifier of statement.exportClause.elements) { + exported.add(specifier.name.text); + } + } + continue; + } + + if ( + !ts.isTypeAliasDeclaration(statement) && + !ts.isInterfaceDeclaration(statement) && + !ts.isClassDeclaration(statement) && + !ts.isFunctionDeclaration(statement) && + !ts.isEnumDeclaration(statement) && + !ts.isModuleDeclaration(statement) + ) { + continue; + } + + if (!statement.name || !ts.isIdentifier(statement.name)) { + continue; + } + + const isExported = ts.canHaveModifiers(statement) && hasExportModifier(statement); + (isExported ? exported : nonExported).add(statement.name.text); + } + + return { exported, nonExported }; +} + +function hasExportModifier(node: ts.HasModifiers): boolean { + return ts.getModifiers(node)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false; +} + +function resolveCompilerOptions(packageRoot: string): ts.CompilerOptions { + const candidateConfigPaths = [path.join(packageRoot, 'tsconfig.lib.json'), path.join(packageRoot, 'tsconfig.json')]; + const configPath = candidateConfigPaths.find(candidate => fs.existsSync(candidate)); + + if (!configPath) { + return { ...DEFAULT_COMPILER_OPTIONS }; + } + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + throw new Error( + `getStateDataAttributes: failed to read tsconfig "${configPath}": ${ts.flattenDiagnosticMessageText( + configFile.error.messageText, + '\n', + )}`, + ); + } + + const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)); + if (parsedConfig.errors.length > 0) { + const messages = parsedConfig.errors + .map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')) + .join('\n'); + throw new Error(`getStateDataAttributes: failed to parse tsconfig "${configPath}": ${messages}`); + } + + return { ...DEFAULT_COMPILER_OPTIONS, ...getPreservedCompilerOptions(parsedConfig.options) }; +} + +function formatDiagnostic(diagnostic: ts.Diagnostic): string { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + + if (diagnostic.file && diagnostic.start !== undefined) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}`; + } + + return message; +} + +function serializeArgTypes(argTypes: StrictArgTypes): string { + return JSON.stringify(Object.fromEntries(Object.entries(argTypes).sort(([a], [b]) => a.localeCompare(b)))); +} + +/** Absolute directory containing the nearest `package.json` walking up from a given file, if any. */ +function findPackageRootForFile(fileName: string, cache: Map): string | undefined { + const resolvedFileName = path.resolve(fileName); + if (cache.has(resolvedFileName)) { + return cache.get(resolvedFileName); + } + + let dir = path.dirname(resolvedFileName); + for (;;) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + cache.set(resolvedFileName, dir); + return dir; + } + + const parentDir = path.dirname(dir); + if (parentDir === dir) { + cache.set(resolvedFileName, undefined); + return undefined; + } + + dir = parentDir; + } +} + +/** + * Finds the package that owns a resolved `*State` declaration: traversal of that state's own type is + * bounded to this package so dependency-declared properties can't leak in, while dependency-reexported + * states (whose resolved declaration legitimately lives in a dependency) still work end-to-end. + */ +function findStatePackageRoot( + resolvedSymbol: ts.Symbol, + fallbackPackageRoot: string, + cache: Map, +): string { + const declaration = resolvedSymbol.declarations?.[0]; + if (!declaration) { + return fallbackPackageRoot; + } + + return findPackageRootForFile(declaration.getSourceFile().fileName, cache) ?? fallbackPackageRoot; +} + +/** True if at least one declaration of `symbol` lives inside `statePackageRoot`. */ +function isOwnedByStatePackageRoot( + symbol: ts.Symbol, + statePackageRoot: string, + cache: Map, +): boolean { + const declarations = + symbol.declarations && symbol.declarations.length > 0 + ? symbol.declarations + : symbol.valueDeclaration + ? [symbol.valueDeclaration] + : []; + + return declarations.some( + declaration => findPackageRootForFile(declaration.getSourceFile().fileName, cache) === statePackageRoot, + ); +} + +/** + * Identifies a `ts.Type` for path-local cycle detection: prefers the alias declaration (so, e.g., + * `Node` and `Node<[T]>` are recognized as the same recursive alias despite differing type + * arguments), then falls back to the type's own symbol declaration, then the `ts.Type` instance itself + * for anonymous/intersection types without a stable declaration. + */ +function getTypeIdentity(type: ts.Type): unknown { + const aliasDeclaration = type.aliasSymbol?.declarations?.[0]; + if (aliasDeclaration) { + return aliasDeclaration; + } + + const symbolDeclaration = type.getSymbol()?.declarations?.[0]; + if (symbolDeclaration) { + return symbolDeclaration; + } + + return type; +} + +type StorybookDataAttributeRow = StrictArgTypes[string]; + +type PendingRow = { + priority: number; + row: StorybookDataAttributeRow; +}; + +type DataAttributeWalkContext = { + checker: ts.TypeChecker; + parser: Parser; + statePackageRoot: string; + packageRootCache: Map; + fallbackLocation: ts.Node; + /** Name of the exported `*State` declaration being walked, used only for actionable error messages. */ + stateName: string; +}; + +/** Builds the Storybook ArgTypes row for a single `data-*` property. */ +function buildDataAttributeRow( + property: ts.Symbol, + propertyType: ts.Type, + category: string, + ctx: DataAttributeWalkContext, +): StorybookDataAttributeRow { + const isRequired = !hasSymbolFlag(property, ts.SymbolFlags.Optional); + const typeName = ctx.parser.getDocgenType(propertyType, isRequired).name; + + return { + name: property.name, + description: ctx.parser.findDocComment(property).description.trim(), + type: { name: 'other', value: typeName, required: false }, + table: { + category, + type: { summary: typeName }, + }, + control: false, + }; +} + +/** + * Registers a `data-*` row under its normalized key/category, resolving root-vs-non-root key collisions + * by explicit priority (root wins) rather than by traversal/declaration order. + */ +function addDataAttributeRow( + propertyPath: string[], + property: ts.Symbol, + propertyType: ts.Type, + ctx: DataAttributeWalkContext, + rows: Map, +): void { + const isRootLeading = propertyPath[0] === 'root'; + const effectivePath = isRootLeading ? propertyPath.slice(1) : propertyPath; + const priority = isRootLeading ? 2 : 1; + const key = effectivePath.length > 0 ? `${effectivePath.join('.')}.${property.name}` : property.name; + const category = effectivePath.length > 0 ? `Data attributes · ${effectivePath.join('.')}` : 'Data attributes'; + + const existing = rows.get(key); + if (existing && existing.priority >= priority) { + return; + } + + rows.set(key, { priority, row: buildDataAttributeRow(property, propertyType, category, ctx) }); +} + +/** + * True if any *direct* (non-recursive) property of `containerType` is owned by `statePackageRoot`. Used + * to detect an immediate state property (e.g. `root`) whose type is wholly declared in a dependency, so + * its own direct `data-*` properties can still be emitted (see {@link collectStateDataAttributeRows}). + */ +function containerHasAnyOwnedProperty( + containerType: ts.Type, + statePackageRoot: string, + cache: Map, +): boolean { + return containerType.getProperties().some(property => isOwnedByStatePackageRoot(property, statePackageRoot, cache)); +} + +/** + * Recursively walks a `*State` type, collecting `data-*` properties from every reachable slot/nested + * object. Traversal is bounded to the package owning the resolved `*State` declaration: every immediate + * (depth 0) property may be entered to preserve existing slot extraction, and its own type (depth 1) may + * emit direct `data-*` rows unfiltered by ownership when that type has no state-package-owned properties + * at all (e.g. a `root: SharedSlot` immediate slot whose type is wholly declared in a dependency) — this + * preserves pre-ownership-filtering slot extraction. As soon as a depth-1 type mixes in even one + * state-package-owned property, it is treated as local and every property (data or not) is filtered by + * ownership as usual. Beyond depth 1, only property symbols declared within the state's package root are + * recursed into or read as `data-*` rows — this keeps dependency-declared properties (including ones + * merged in via intersection) from leaking in, while still supporting states that are wholly re-exported + * from a dependency. Path-local cycle detection (keyed by alias declaration, then symbol declaration, then + * `ts.Type`) prevents infinite recursion from self-referencing types while still allowing the same + * reusable type under separate sibling branches. Traversal depth, emitted row count, and total visited + * node count (including zero-row visits) are all bounded (see {@link MAX_STATE_TRAVERSAL_DEPTH}, + * {@link MAX_STATE_DATA_ATTRIBUTE_ROWS}, {@link MAX_STATE_TRAVERSAL_NODES}) so a pathological or mistaken + * type shape fails loudly instead of hanging or silently truncating output — the node-count budget in + * particular is what stops a same-package type graph with wide fan-out and zero `data-*` leaves, which + * the depth and row limits alone do not bound. + */ +function collectStateDataAttributeRows(stateType: ts.Type, ctx: DataAttributeWalkContext): Map { + const rows = new Map(); + const visiting = new Set(); + let visitedNodeCount = 0; + + function visit(containerType: ts.Type, propertyPath: string[], depth: number): void { + if (depth > MAX_STATE_TRAVERSAL_DEPTH) { + throw new Error( + `getStateDataAttributes: state "${ctx.stateName}" exceeded the maximum traversal depth ` + + `(${MAX_STATE_TRAVERSAL_DEPTH}) at path "${propertyPath.join('.') || ''}"`, + ); + } + + visitedNodeCount++; + if (visitedNodeCount > MAX_STATE_TRAVERSAL_NODES) { + const declarationPath = containerType.getSymbol()?.declarations?.[0]?.getSourceFile().fileName; + const declarationSuffix = declarationPath ? ` (declared at "${declarationPath}")` : ''; + throw new Error( + `getStateDataAttributes: state "${ctx.stateName}" exceeded the maximum number of visited traversal ` + + `nodes (${MAX_STATE_TRAVERSAL_NODES}) at path "${propertyPath.join('.') || ''}"${declarationSuffix}`, + ); + } + + const allowUnownedDataAtThisLevel = + depth === 1 && !containerHasAnyOwnedProperty(containerType, ctx.statePackageRoot, ctx.packageRootCache); + + for (const property of containerType.getProperties()) { + const isDataProperty = property.name.startsWith('data-'); + + if (isDataProperty) { + if ( + !allowUnownedDataAtThisLevel && + !isOwnedByStatePackageRoot(property, ctx.statePackageRoot, ctx.packageRootCache) + ) { + continue; + } + + const propertyLocation = property.valueDeclaration ?? property.declarations?.[0] ?? ctx.fallbackLocation; + const rawPropertyType = ctx.checker.getTypeOfSymbolAtLocation(property, propertyLocation); + addDataAttributeRow(propertyPath, property, rawPropertyType, ctx, rows); + + if (rows.size > MAX_STATE_DATA_ATTRIBUTE_ROWS) { + throw new Error( + `getStateDataAttributes: state "${ctx.stateName}" exceeded the maximum number of data attribute ` + + `rows (${MAX_STATE_DATA_ATTRIBUTE_ROWS}) at path "${propertyPath.join('.') || ''}"`, + ); + } + continue; + } + + if (depth > 0 && !isOwnedByStatePackageRoot(property, ctx.statePackageRoot, ctx.packageRootCache)) { + continue; + } + + const propertyLocation = property.valueDeclaration ?? property.declarations?.[0] ?? ctx.fallbackLocation; + const rawPropertyType = ctx.checker.getTypeOfSymbolAtLocation(property, propertyLocation); + const nonNullablePropertyType = ctx.checker.getNonNullableType(rawPropertyType); + const identity = getTypeIdentity(nonNullablePropertyType); + + if (visiting.has(identity)) { + continue; + } + + visiting.add(identity); + visit(nonNullablePropertyType, [...propertyPath, property.name], depth + 1); + visiting.delete(identity); + } + } + + visit(stateType, [], 0); + + return rows; +} + +/** + * Compiles exported declarations and converts slot `data-*` properties from exported `*State` types into + * Storybook ArgTypes. + */ +export function getStateDataAttributes(options: GetStateDataAttributesOptions): Record { + const rawPackageRoot = options.packageRoot; + + if (!path.isAbsolute(rawPackageRoot)) { + throw new Error(`getStateDataAttributes: packageRoot must be an absolute path, received "${rawPackageRoot}"`); + } + + if (!fs.existsSync(rawPackageRoot)) { + throw new Error(`getStateDataAttributes: packageRoot does not exist: "${rawPackageRoot}"`); + } + + // Normalized so trailing slashes/`.`/`..` segments compare consistently against paths produced by + // `path.resolve` elsewhere (e.g. `findPackageRootForFile`'s fallback-ownership comparisons). + const packageRoot = path.resolve(rawPackageRoot); + + const rootFiles = getExportedTypeEntryPoints(packageRoot); + const compilerOptions = resolveCompilerOptions(packageRoot); + const program = ts.createProgram(rootFiles, compilerOptions); + const checker = program.getTypeChecker(); + const parser = new Parser(program, { shouldRemoveUndefinedFromOptional: true }); + const rootFileSet = new Set(rootFiles.map(fileName => path.resolve(fileName))); + + const programDiagnostics = [...program.getOptionsDiagnostics(), ...program.getGlobalDiagnostics()].filter( + diagnostic => diagnostic.category === ts.DiagnosticCategory.Error, + ); + + if (programDiagnostics.length > 0) { + throw new Error( + `getStateDataAttributes: invalid compiler options for "${packageRoot}":\n${programDiagnostics + .map(formatDiagnostic) + .join('\n')}`, + ); + } + + const rootDiagnostics: string[] = []; + for (const rootFile of rootFiles) { + const sourceFile = program.getSourceFile(rootFile); + if (!sourceFile) { + rootDiagnostics.push(`${rootFile} - source file could not be loaded into the program`); + continue; + } + + const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)]; + for (const diagnostic of diagnostics) { + if (diagnostic.category === ts.DiagnosticCategory.Error) { + rootDiagnostics.push(formatDiagnostic(diagnostic)); + } + } + } + + if (rootDiagnostics.length > 0) { + throw new Error(`getStateDataAttributes: failed to compile exported declarations:\n${rootDiagnostics.join('\n')}`); + } + + const results = new Map(); + const packageRootCache = new Map(); + + for (const sourceFile of program.getSourceFiles()) { + const resolvedFileName = path.resolve(sourceFile.fileName); + if (!rootFileSet.has(resolvedFileName)) { + continue; + } + + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) { + continue; + } + + const { exported: locallyExportedNames, nonExported: locallyNonExportedNames } = + getLocallyExportedNames(sourceFile); + + for (const exportSymbol of checker.getExportsOfModule(moduleSymbol)) { + const exportName = exportSymbol.getName(); + if (!exportName.endsWith('State')) { + continue; + } + + if (locallyNonExportedNames.has(exportName) && !locallyExportedNames.has(exportName)) { + continue; + } + + const componentKey = exportName.slice(0, -'State'.length); + const resolvedSymbol = hasSymbolFlag(exportSymbol, ts.SymbolFlags.Alias) + ? checker.getAliasedSymbol(exportSymbol) + : exportSymbol; + + const stateType = checker.getDeclaredTypeOfSymbol(resolvedSymbol); + const statePackageRoot = findStatePackageRoot(resolvedSymbol, packageRoot, packageRootCache); + const rows = collectStateDataAttributeRows(stateType, { + checker, + parser, + statePackageRoot, + packageRootCache, + fallbackLocation: sourceFile, + stateName: exportName, + }); + + if (rows.size === 0) { + continue; + } + + const argTypes: StrictArgTypes = {}; + for (const [key, { row }] of rows) { + argTypes[key] = row; + } + + const sortedArgTypes = Object.fromEntries(Object.entries(argTypes).sort(([a], [b]) => a.localeCompare(b))); + const existing = results.get(componentKey); + + if (existing) { + if (serializeArgTypes(existing.argTypes) === serializeArgTypes(sortedArgTypes)) { + continue; + } + + throw new Error( + `getStateDataAttributes: conflicting component metadata for key "${componentKey}" between "${existing.declarationPath}" and "${resolvedFileName}"`, + ); + } + + results.set(componentKey, { argTypes: sortedArgTypes, declarationPath: resolvedFileName }); + } + } + + const output: Record = {}; + for (const [componentKey, { argTypes }] of [...results.entries()].sort(([a], [b]) => a.localeCompare(b))) { + output[componentKey] = argTypes; + } + + return output; +} diff --git a/packages/react-components/react-storybook-addon/src/preset/manager.ts b/packages/react-components/react-storybook-addon/src/preset/manager.ts index caacbc158d77b..5edda566c09b2 100644 --- a/packages/react-components/react-storybook-addon/src/preset/manager.ts +++ b/packages/react-components/react-storybook-addon/src/preset/manager.ts @@ -8,7 +8,6 @@ import { DirectionSwitch } from '../components/DirectionSwitch'; addons.register(ADDON_ID, () => { addons.add(THEME_ID, { title: 'Fluent Theme Picker', - type: types.TOOL, match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)), render: ThemePicker, diff --git a/packages/react-components/react-storybook-addon/src/preset/options.ts b/packages/react-components/react-storybook-addon/src/preset/options.ts new file mode 100644 index 0000000000000..2e824366ca41b --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/options.ts @@ -0,0 +1,31 @@ +import type { PresetConfig } from '../public-types'; + +const ADDON_PRESET_PATTERN = /(?:^|\/)react-storybook-addon\/(?:temp\/)?preset\.[jt]s$/; + +type PresetRegistration = { + name?: unknown; + options?: PresetConfig; +}; + +type StateDataAttributesOptions = { + stateDataAttributes?: PresetConfig['stateDataAttributes']; + presetsList?: PresetRegistration[]; +}; + +export function getStateDataAttributesConfig( + options: StateDataAttributesOptions | undefined, +): PresetConfig['stateDataAttributes'] | undefined { + if (options?.stateDataAttributes) { + return options.stateDataAttributes; + } + + const preset = options?.presetsList?.find(registration => + ADDON_PRESET_PATTERN.test(String(registration.name ?? '').replace(/\\/g, '/')), + ); + + return preset?.options?.stateDataAttributes; +} + +export function isStateDataAttributesConfigured(options: StateDataAttributesOptions | undefined): boolean { + return Boolean(getStateDataAttributesConfig(options)); +} diff --git a/packages/react-components/react-storybook-addon/src/preset/preset.ts b/packages/react-components/react-storybook-addon/src/preset/preset.ts new file mode 100644 index 0000000000000..4d1915b15a6b3 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/preset.ts @@ -0,0 +1,6 @@ +import { webpack } from './webpack'; +import type { WebpackFinalConfig, WebpackFinalOptions } from './webpack'; + +export function webpackFinal(config: WebpackFinalConfig, options: WebpackFinalOptions): WebpackFinalConfig { + return webpack(config, options); +} diff --git a/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesArgTypes.ts b/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesArgTypes.ts new file mode 100644 index 0000000000000..56fff48684bd6 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesArgTypes.ts @@ -0,0 +1,17 @@ +import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import type { StrictArgTypes } from 'storybook/internal/types'; + +type NamedComponentLike = { displayName?: string; name?: string }; + +export function createStateDataAttributesExtractor( + nativeExtractArgTypes: ArgTypesExtractor, + metadata: Record, +): ArgTypesExtractor { + return component => { + const nativeRows = nativeExtractArgTypes(component); + const namedComponent = component as NamedComponentLike | undefined; + const generatedRows = metadata[namedComponent?.displayName || namedComponent?.name || '']; + + return generatedRows ? { ...generatedRows, ...nativeRows } : nativeRows; + }; +} diff --git a/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesPreview.ts b/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesPreview.ts new file mode 100644 index 0000000000000..e6733c2c9aea2 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/stateDataAttributesPreview.ts @@ -0,0 +1,21 @@ +import type { StrictArgTypes } from 'storybook/internal/types'; + +/** + * React's native extractor must be wrapped because Storybook calls docs.extractArgTypes directly for primary + * components and subcomponents. argTypesEnhancers are not equivalent, and this path is an explicit export of the + * supported Storybook peer version. + */ +import { parameters as nativeParameters } from '@storybook/react/dist/entry-preview-argtypes.mjs'; + +import { createStateDataAttributesExtractor } from './stateDataAttributesArgTypes'; + +declare const FLUENT_STATE_DATA_ATTRIBUTE_ARG_TYPES: Record; + +export const parameters = { + docs: { + extractArgTypes: createStateDataAttributesExtractor( + nativeParameters.docs.extractArgTypes, + FLUENT_STATE_DATA_ATTRIBUTE_ARG_TYPES, + ), + }, +}; diff --git a/packages/react-components/react-storybook-addon/src/preset/webpack.ts b/packages/react-components/react-storybook-addon/src/preset/webpack.ts new file mode 100644 index 0000000000000..03f4b68737b47 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/preset/webpack.ts @@ -0,0 +1,35 @@ +import * as path from 'path'; + +import { DefinePlugin } from 'webpack'; + +import { getStateDataAttributesConfig } from './options'; + +type WebpackFinalFn = NonNullable; +export type WebpackFinalConfig = Parameters[0]; +export type WebpackFinalOptions = Parameters[1]; + +/** + * Generates state data-attribute metadata when configured and injects it through webpack's DefinePlugin. + */ +export function webpack(config: WebpackFinalConfig, options: WebpackFinalOptions): WebpackFinalConfig { + const stateDataAttributesConfig = getStateDataAttributesConfig(options); + + if (!stateDataAttributesConfig) { + return config; + } + + if (!path.isAbsolute(stateDataAttributesConfig.packageRoot)) { + throw new Error('stateDataAttributes.packageRoot must be an absolute path'); + } + + const { getStateDataAttributes } = require('./getStateDataAttributes') as typeof import('./getStateDataAttributes'); + + config.plugins ??= []; + config.plugins.push( + new DefinePlugin({ + FLUENT_STATE_DATA_ATTRIBUTE_ARG_TYPES: JSON.stringify(getStateDataAttributes(stateDataAttributesConfig)), + }), + ); + + return config; +} diff --git a/packages/react-components/react-storybook-addon/src/public-types.ts b/packages/react-components/react-storybook-addon/src/public-types.ts new file mode 100644 index 0000000000000..61b94fe37f092 --- /dev/null +++ b/packages/react-components/react-storybook-addon/src/public-types.ts @@ -0,0 +1,17 @@ +/** Configuration for documenting `data-*` members from exported component state types. */ +export type StateDataAttributesConfig = { + /** + * Absolute root of the built package whose `package.json` `exports` map declares the direct `types` + * entry points (API Extractor-style `.d.ts` rollups) to scan for exported `*State` declarations. + * + * The package must already be built: extraction reads the on-disk `.d.ts` rollups resolved from the + * export map, it never falls back to scanning TypeScript source. + */ + packageRoot: string; +}; + +/** Options for the `@fluentui/react-storybook-addon` Storybook preset. */ +export interface PresetConfig { + /** Adds all `data-*` members. */ + stateDataAttributes?: StateDataAttributesConfig; +} diff --git a/packages/react-components/react-storybook-addon/storybook-react-entry-preview-argtypes.d.ts b/packages/react-components/react-storybook-addon/storybook-react-entry-preview-argtypes.d.ts new file mode 100644 index 0000000000000..5169039ece831 --- /dev/null +++ b/packages/react-components/react-storybook-addon/storybook-react-entry-preview-argtypes.d.ts @@ -0,0 +1,9 @@ +import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; + +declare module '@storybook/react/dist/entry-preview-argtypes.mjs' { + export const parameters: { + docs: { + extractArgTypes: ArgTypesExtractor; + }; + }; +} diff --git a/packages/react-components/react-storybook-addon/tsconfig.lib.json b/packages/react-components/react-storybook-addon/tsconfig.lib.json index 5f092c43892b8..f87436b4d1535 100644 --- a/packages/react-components/react-storybook-addon/tsconfig.lib.json +++ b/packages/react-components/react-storybook-addon/tsconfig.lib.json @@ -9,6 +9,14 @@ "inlineSources": true, "types": ["static-assets", "environment", "node"] }, - "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.stories.ts", "**/*.stories.tsx"], - "include": ["./src/**/*.ts", "./src/**/*.tsx"] + "exclude": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx", + "src/preset/__fixtures__/**" + ], + "include": ["./src/**/*.ts", "./src/**/*.tsx", "./storybook-react-entry-preview-argtypes.d.ts"] } diff --git a/packages/react-components/react-storybook-addon/tsconfig.spec.json b/packages/react-components/react-storybook-addon/tsconfig.spec.json index 469fcba4d7ba7..a1ce1e87ca204 100644 --- a/packages/react-components/react-storybook-addon/tsconfig.spec.json +++ b/packages/react-components/react-storybook-addon/tsconfig.spec.json @@ -5,5 +5,6 @@ "outDir": "dist", "types": ["jest", "node"] }, - "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"], + "exclude": ["src/preset/__fixtures__/**"] } diff --git a/yarn.lock b/yarn.lock index 29a79676267e6..7dc7e9ef6f12e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5494,12 +5494,23 @@ __metadata: peerDependencies: "@storybook/addon-docs": ^9.1.17 "@storybook/icons": ^1.3.2 + "@storybook/react": ^9.1.17 "@storybook/react-webpack5": ^9.1.17 "@types/react": ">=16.14.0 <20.0.0" "@types/react-dom": ">=16.9.0 <20.0.0" react: ">=16.14.0 <20.0.0" + react-docgen-typescript: ^2.2.2 react-dom: ">=16.14.0 <20.0.0" storybook: ^9.1.17 + typescript: ">=5.7.0 <6.0.0" + webpack: ^5.0.0 + peerDependenciesMeta: + "@storybook/react": + optional: true + react-docgen-typescript: + optional: true + typescript: + optional: true languageName: unknown linkType: soft