diff --git a/apps/public-docsite-v9/src/Concepts/Migration/FromV8/ComponentMapping.mdx b/apps/public-docsite-v9/src/Concepts/Migration/FromV8/ComponentMapping.mdx index 78e947bab0ca61..0a99ce4d1a218a 100644 --- a/apps/public-docsite-v9/src/Concepts/Migration/FromV8/ComponentMapping.mdx +++ b/apps/public-docsite-v9/src/Concepts/Migration/FromV8/ComponentMapping.mdx @@ -37,82 +37,82 @@ Here is the mapping of v8 components to their v9 component replacement or equiva - A (🔁) means the v9 component is currently being developed. It may be in preview or not exported from react-components yet. Check out the latest schedule information on the [v9 Component Roadmap](https://github.com/microsoft/fluentui/wiki/Fluent-UI-React-v9-Component-Roadmap) -| \*\*React v8 Component\*\* | \*\*React v9 Component\*\* | \*\*Notes\*\* | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| \`ActionButton\` | \[\`Button\`\](/docs/components-button-button--docs) | Use \`Button\` with \`iconPosition="before"\` for similar functionality. | -| \`ActivityItem\` | Not available | No direct equivalent in v9; custom implementation needed. | -| \`Announced\` | Not available | No direct equivalent in v9. | -| \`Breadcrumb\` | \[\`Breadcrumb\`\](/docs/components-breadcrumb--docs) | No major changes; available in v9. | -| \`Button\` | \[\`Button\`\](/docs/components-button-button--docs) | Unified button API; use \`appearance\` prop for variants like \`PrimaryButton\`. | -| \`Callout\` | \[\`Popover\` \](/docs/components-popover--docs) | Replaced by \`Popover\`, with enhanced accessibility and customization. | -| \`Calendar\` | \[\`CalendarCompat\`\](/docs/compat-components-calendar--docs) | Use the compact variant in v9. | -| \`CommandBar\` | \[\`Toolbar\`\](/docs/components-toolbar--docs) | Similar functionality in v9. | -| \`CommandBarButton\` | \[\`Toolbar\`\](/docs/components-toolbar--docs) | Similar functionality in v9. | -| \`CommandButton\` | \[\`MenuButton\`\](/docs/components-button-docs--docs) | Similar functionality in v9. | -| \`CompoundButton\` | \[\`CompoundButton\`\](/docs/components-button-compoundbutton--docs) | Similar functionality in v9. | -| \`Charts\` | \[\`Migration Guide\`\](/docs/concepts-migration-from-v8-components-charts-migration--docs) | No direct equivalent in v9. | -| \`Checkbox\` | \[\`Checkbox\`\](/docs/components-checkbox--docs) | API remains mostly the same in v9. | -| \`ChoiceGroup\` | \[\`RadioGroup\`\](/docs/components-radiogroup--docs) | Renamed to \`RadioGroup\` with similar functionality. | -| \`Coachmark\` | \[\`TeachingPopover\`\](/docs/components-teachingpopover--docs) | Use \`TeachingPopover\` in v9 for the same functionality. | -| \`ColorPicker\` | \[\`ColorPicker\`\](/docs/components-colorpicker--docs) | No major changes in v9. | -| \`ComboBox\` | \[\`Combobox\`\](/docs/components-combobox--docs) | Renamed to \`Combobox\` with API improvements. | -| \`CommandBar\` | \[\`Toolbar\`\](/docs/components-toolbar--docs) | CommandBar replaced by \`Toolbar\` for managing actions. | -| \`ContextualMenu\` | \[\`Menu\`\](/docs/components-menu-menu--docs) with Button as the \`MenuTrigger\` | Use \`Menu\` in v9 with \`MenuTrigger\`. | -| \`DefaultButton (anchor)\` | \[\`Button\`\](/docs/components-button-button--docs) | Use \`Button\` in v9. | -| \`DefaultButton (menu)\` | \[\`MenuButton\`\](/docs/components-button-menubutton--docs) | Use \`Button\` in v9. | -| \`DatePicker\` | \[\`DatePickerCompat\`\](/docs/compat-components-datepicker--docs) | API remains similar with enhanced theming options. | -| \`DetailsList\` | \[\`DataGrid\`\](/docs/components-datagrid--docs) | Experimental \`DataGrid\` in v9 could be a substitute. | -| \`Dialog\` | \[\`Dialog\`\](/docs/components-dialog--docs) | Refined API, using \`open\` and \`onOpenChange\` props. | -| \`DocumentCard\` | \[\`Card\`\](/docs/components-card-card--docs) | Use \`Card\` in v9. | -| \`Dropdown\` | \[\`Dropdown\`\](/docs/components-dropdown--docs) | API and performance enhancements. | -| \`Fabric\` | \`FluentProvider\` | Use \`FluentProvider\` for managing themes and global context. | -| \`Facepile\` | \[\`AvatarGroup\`\](/docs/components-avatargroup--docs) | Replaced by \`AvatarGroup\` in v9. | -| \`FocusTrapZone\` | \[\`Tabster\`\](https://tabster.io/) | Use \`Tabster\`. | -| \`FocusZone\` | \[\`Tabster\`\](https://tabster.io/) | Use \`Tabster\`. | -| \`GroupedList\` | \[\`Tree\`\](/docs/components-tree--docs) | Use \`Tree\` in v9. | -| \`HoverCard\` | Not available | No direct equivalent; could use \`Popover\` with custom behavior. | -| \`Icon\` | \[\`@fluentui/react-icons package\`\](/docs/icons-overview--docs) | v9 provides more customization options. | -| \`IconButton\` | \[\`Button\`\](/docs/components-button-button--docs) | v9 provides more customization options. | -| \`Image\` | \[\`Image\`\](/docs/components-image--docs) | No major changes in v9. | -| \`Keytips\` | \[\`Keytips\`\](/docs/contrib_packages-react-keytips--docs) | No major changes in v9. | -| \`Label\` | \[\`Label\`\](/docs/components-label--docs) | Similar functionality in v9. | -| \`Layer\` | \[\`Portal\`\](/docs/components-portal--docs) | Use \`Portal\` for similar layering behavior. | -| \`Link\` | \[\`Link\`\](/docs/components-link--docs) | Available in v9, with better accessibility handling. | -| \`List\` | \[\`List\`\](/docs/components-list--docs) | Enhanced performance in v9. | -| \`MarqueeSelection\` | Not available | No direct equivalent in v9. | -| \`MessageBar\` | \[\`MessageBar\`\](/docs/components-messagebar--docs) | Available in v9, with better accessibility handling. | -| \`Modal\` | \[\`Dialog\`\](/docs/components-dialog--docs) | Dialog in v9 serves the modal role with updated API. | -| \`Nav\` | \[\`Nav\`\](/docs/components-nav--docs) | No major changes; available in v9 with improvements. | -| \`OverflowSet\` | \[\`Dialog\`\](/docs/components-overflow--docs) | No direct equivalent; consider \`Dialog\` for overflow menus. | -| \`Overlay\` | \[\`Portal\`\](/docs/components-portal--docs) | Replaced with \`Dialog\` in v9. | -| \`Panel\` | \[\`Drawer\`\](/docs/components-drawer--docs) | Replaced by \`Drawer\` for side panel navigation. | -| \`Popup\` | \[\`Dialog\`\](/docs/components-dialog--default) | Replaced by \`Dialog\` | -| \`PeoplePicker\` | \[\`TagPicker\`\](/docs/components-tagpicker--docs) | Replaced by \`TagPicker\` for improved functionality. | -| \`Persona\` | \[\`Persona\`\](/docs/components-persona--docs) | No major changes; available in v9 with improvements. | -| \`Pickers\` | Not available | No direct equivalent in v9. | -| \`Pivot\`, \`PivotItem\` | \[\`TabList\`, \`Tab\`\](/docs/components-tablist--docs) | Replaced by \`TabList\` in v9. | -| \`ProgressIndicator\` | \[\`ProgressBar\`\](/docs/components-progressbar--docs) | Renamed to \`ProgressBar\` in v9; similar functionality. | -| \`Rating\` | \[\`Rating\`\](/docs/components-rating--docs) | Available in v9 with slight API refinements. | -| \`ResizeGroup\` | Not available | No direct equivalent in v9. | -| \`ScrollablePane\` | Not available | No direct equivalent in v9. | -| \`SearchBox\` | \[\`SearchBox\`\](/docs/components-searchbox--docs) | Available in v9 with updated API and better accessibility. | -| \`Separator\` | \[\`Divider\`\](/docs/components-divider--docs) | Replaced by \`Divider\` in v9. | -| \`Shimmer\` | \[\`Skeleton\`\](/docs/components-skeleton--docs) | Updated to \`Skeleton\` in v9 for loading states. | -| \`Slider\` | \[\`Slider\`\](/docs/components-slider--docs) | API enhancements for better customization. | -| \`SplitButton\` | \[\`Menu with SplitButton as the Menu Trigger\`\](/docs/components-button-splitbutton--docs) | Various updates done in v9. | -| \`SpinButton\` | \[\`SpinButton\`\](/docs/components-spinbutton--docs) | Available in v9 with improved API. | -| \`Spinner\` | \[\`Spinner\`\](/docs/components-spinner--docs) | Available in v9 with better control over animations. | -| \`Stack\` | \[\`Migration Guide\`\](/docs/concepts-migration-from-v8-components-flex-stack--docs), \[\`StackShim\`\](/docs/migration-shims-v8-stackshim--playground) | Various changes in v9. | -| \`SwatchColorPicker\` | \[\`SwatchPicker\`\](/docs/components-swatchpicker--docs) | Replaced by \`SwatchPicker\` in v9 for color selection. | -| \`TagPicker\` | \[\`TagPicker\`\](/docs/components-tagpicker--docs) | No major changes; retained as \`TagPicker\`. | -| \`TeachingBubble\` | \[\`TeachingPopover\`\](/docs/components-teachingpopover--docs) | Replaced by \`TeachingPopover\` in v9. | -| \`Text\` | \[\`Text\`\](/docs/components-text--docs) | Similar functionality in v9. | -| \`TextField\` | \[\`Input\`\](/docs/components-input--docs) | Replaced by \`Input\` in v9 for text input functionality. | -| \`ThemeProvider\` | \[\`FluentProvider\`\](/docs/components-fluentprovider--docs) | Replaced by \`FluentProvider\` in v9; similar behavior. | -| \`TimePicker\` | \[\`TimePickerCompat\`\](/docs/compat-components-timepicker--docs) | Replaced by \`TimePickerCompat\` in v9; similar behavior. | -| \`ToggleButton\` | \[\`ToggleButton\`\](/docs/components-button-togglebutton--docs) | No major changes in v9. | -| \`Toggle\` | \[\`Switch\`\](/docs/components-switch--docs) | Renamed to \`Switch\` in v9; similar behavior. | -| \`Tooltip\` | \[\`Tooltip\`\](/docs/components-tooltip--docs) | No major changes in v9. | +| **React v8 Component** | **React v9 Component** | **Notes** | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| ActionButton | [Button](/docs/components-button-button--docs) | Use `Button` with `iconPosition="before"` for similar functionality. | +| ActivityItem | Not available | No direct equivalent in v9; custom implementation needed. | +| Announced | Not available | No direct equivalent in v9. | +| Breadcrumb | [Breadcrumb](/docs/components-breadcrumb--docs) | No major changes; available in v9. | +| Button | [Button](/docs/components-button-button--docs) | Unified button API; use `appearance` prop for variants like `PrimaryButton`. | +| Callout | [Popover](/docs/components-popover--docs) | Replaced by `Popover`, with enhanced accessibility and customization. | +| Calendar | [CalendarCompat](/docs/compat-components-calendar--docs) | Use the compact variant in v9. | +| CommandBar | [Toolbar](/docs/components-toolbar--docs) | Similar functionality in v9. | +| CommandBarButton | [Toolbar](/docs/components-toolbar--docs) | Similar functionality in v9. | +| CommandButton | [MenuButton](/docs/components-button-docs--docs) | Similar functionality in v9. | +| CompoundButton | [CompoundButton](/docs/components-button-compoundbutton--docs) | Similar functionality in v9. | +| Charts | [Migration Guide](/docs/concepts-migration-from-v8-components-charts-migration--docs) | No direct equivalent in v9. | +| Checkbox | [Checkbox](/docs/components-checkbox--docs) | API remains mostly the same in v9. | +| ChoiceGroup | [RadioGroup](/docs/components-radiogroup--docs) | Renamed to `RadioGroup` with similar functionality. | +| Coachmark | [TeachingPopover](/docs/components-teachingpopover--docs) | Use `TeachingPopover` in v9 for the same functionality. | +| ColorPicker | [ColorPicker](/docs/components-colorpicker--docs) | No major changes in v9. | +| ComboBox | [Combobox](/docs/components-combobox--docs) | Renamed to `Combobox` with API improvements. | +| CommandBar | [Toolbar](/docs/components-toolbar--docs) | CommandBar replaced by `Toolbar` for managing actions. | +| ContextualMenu | [Menu](/docs/components-menu-menu--docs) with Button as the `MenuTrigger` | Use `Menu` in v9 with `MenuTrigger`. | +| DefaultButton (anchor) | [Button](/docs/components-button-button--docs) | Use `Button` in v9. | +| DefaultButton (menu) | [MenuButton](/docs/components-button-menubutton--docs) | Use `Button` in v9. | +| DatePicker | [DatePickerCompat](/docs/compat-components-datepicker--docs) | API remains similar with enhanced theming options. | +| DetailsList | [DataGrid](/docs/components-datagrid--docs) | Experimental `DataGrid` in v9 could be a substitute. | +| Dialog | [Dialog](/docs/components-dialog--docs) | Refined API, using `open` and `onOpenChange` props. | +| DocumentCard | [Card](/docs/components-card-card--docs) | Use `Card` in v9. | +| Dropdown | [Dropdown](/docs/components-dropdown--docs) | API and performance enhancements. | +| Fabric | FluentProvider | Use `FluentProvider` for managing themes and global context. | +| Facepile | [AvatarGroup](/docs/components-avatargroup--docs) | Replaced by `AvatarGroup` in v9. | +| FocusTrapZone | [Tabster](https://tabster.io/) | Use `Tabster`. | +| FocusZone | [Tabster](https://tabster.io/) | Use `Tabster`. | +| GroupedList | [Tree](/docs/components-tree--docs) | Use `Tree` in v9. | +| HoverCard | Not available | No direct equivalent; could use `Popover` with custom behavior. | +| Icon | [@fluentui/react-icons package](/docs/icons-overview--docs) | v9 provides more customization options. | +| IconButton | [Button](/docs/components-button-button--docs) | v9 provides more customization options. | +| Image | [Image](/docs/components-image--docs) | No major changes in v9. | +| Keytips | [Keytips](/docs/contrib_packages-react-keytips--docs) | No major changes in v9. | +| Label | [Label](/docs/components-label--docs) | Similar functionality in v9. | +| Layer | [Portal](/docs/components-portal--docs) | Use `Portal` for similar layering behavior. | +| Link | [Link](/docs/components-link--docs) | Available in v9, with better accessibility handling. | +| List | [List](/docs/components-list--docs) | Enhanced performance in v9. | +| MarqueeSelection | Not available | No direct equivalent in v9. | +| MessageBar | [MessageBar](/docs/components-messagebar--docs) | Available in v9, with better accessibility handling. | +| Modal | [Dialog](/docs/components-dialog--docs) | Dialog in v9 serves the modal role with updated API. | +| Nav | [Nav](/docs/components-nav--docs) | No major changes; available in v9 with improvements. | +| OverflowSet | [Dialog](/docs/components-overflow--docs) | No direct equivalent; consider `Dialog` for overflow menus. | +| Overlay | [Portal](/docs/components-portal--docs) | Replaced with `Dialog` in v9. | +| Panel | [Drawer](/docs/components-drawer--docs) | Replaced by `Drawer` for side panel navigation. | +| Popup | [Dialog](/docs/components-dialog--default) | Replaced by `Dialog` | +| PeoplePicker | [TagPicker](/docs/components-tagpicker--docs) | Replaced by `TagPicker` for improved functionality. | +| Persona | [Persona](/docs/components-persona--docs) | No major changes; available in v9 with improvements. | +| Pickers | Not available | No direct equivalent in v9. | +| Pivot, PivotItem | [TabList, Tab](/docs/components-tablist--docs) | Replaced by `TabList` in v9. | +| ProgressIndicator | [ProgressBar](/docs/components-progressbar--docs) | Renamed to `ProgressBar` in v9; similar functionality. | +| Rating | [Rating](/docs/components-rating--docs) | Available in v9 with slight API refinements. | +| ResizeGroup | Not available | No direct equivalent in v9. | +| ScrollablePane | Not available | No direct equivalent in v9. | +| SearchBox | [SearchBox](/docs/components-searchbox--docs) | Available in v9 with updated API and better accessibility. | +| Separator | [Divider](/docs/components-divider--docs) | Replaced by `Divider` in v9. | +| Shimmer | [Skeleton](/docs/components-skeleton--docs) | Updated to `Skeleton` in v9 for loading states. | +| Slider | [Slider](/docs/components-slider--docs) | API enhancements for better customization. | +| SplitButton | [Menu with SplitButton as the Menu Trigger](/docs/components-button-splitbutton--docs) | Various updates done in v9. | +| SpinButton | [SpinButton](/docs/components-spinbutton--docs) | Available in v9 with improved API. | +| Spinner | [Spinner](/docs/components-spinner--docs) | Available in v9 with better control over animations. | +| Stack | [Migration Guide](/docs/concepts-migration-from-v8-components-flex-stack--docs), [StackShim](/docs/migration-shims-v8-stackshim--playground) | Various changes in v9. | +| SwatchColorPicker | [SwatchPicker](/docs/components-swatchpicker--docs) | Replaced by `SwatchPicker` in v9 for color selection. | +| TagPicker | [TagPicker](/docs/components-tagpicker--docs) | No major changes; retained as `TagPicker`. | +| TeachingBubble | [TeachingPopover](/docs/components-teachingpopover--docs) | Replaced by `TeachingPopover` in v9. | +| Text | [Text](/docs/components-text--docs) | Similar functionality in v9. | +| TextField | [Input](/docs/components-input--docs) | Replaced by `Input` in v9 for text input functionality. | +| ThemeProvider | [FluentProvider](/docs/components-fluentprovider--docs) | Replaced by `FluentProvider` in v9; similar behavior. | +| TimePicker | [TimePickerCompat](/docs/compat-components-timepicker--docs) | Replaced by `TimePickerCompat` in v9; similar behavior. | +| ToggleButton | [ToggleButton](/docs/components-button-togglebutton--docs) | No major changes in v9. | +| Toggle | [Switch](/docs/components-switch--docs) | Renamed to `Switch` in v9; similar behavior. | +| Tooltip | [Tooltip](/docs/components-tooltip--docs) | No major changes in v9. | ## New Components in v9 diff --git a/change/@fluentui-eslint-plugin-react-components-29e79141-9ead-403f-90e9-acf2e988b420.json b/change/@fluentui-eslint-plugin-react-components-29e79141-9ead-403f-90e9-acf2e988b420.json new file mode 100644 index 00000000000000..0251ae430de85c --- /dev/null +++ b/change/@fluentui-eslint-plugin-react-components-29e79141-9ead-403f-90e9-acf2e988b420.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expand component coverage", + "packageName": "@fluentui/eslint-plugin-react-components", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-message-bar-4a6b8620-f010-4128-b5a0-4c68e73bc7de.json b/change/@fluentui-react-message-bar-4a6b8620-f010-4128-b5a0-4c68e73bc7de.json new file mode 100644 index 00000000000000..af4299b40e09a3 --- /dev/null +++ b/change/@fluentui-react-message-bar-4a6b8620-f010-4128-b5a0-4c68e73bc7de.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-message-bar): add useMessageBarBase_unstable hook", + "packageName": "@fluentui/react-message-bar", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-toolbar-c4d2e81f-6a3b-4c90-d5e7-1f8b2a3c4d5e.json b/change/@fluentui-react-toolbar-c4d2e81f-6a3b-4c90-d5e7-1f8b2a3c4d5e.json new file mode 100644 index 00000000000000..077ec44d063a8a --- /dev/null +++ b/change/@fluentui-react-toolbar-c4d2e81f-6a3b-4c90-d5e7-1f8b2a3c4d5e.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expose base hooks for Toolbar components", + "packageName": "@fluentui/react-toolbar", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts index 5c8a37ad689002..43284a6bf3beba 100644 --- a/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts +++ b/packages/react-components/eslint-plugin-react-components/src/rules/prefer-fluentui-v9.ts @@ -84,22 +84,25 @@ const MIGRATIONS = { Announced: { import: 'useAnnounce', package: '@fluentui/react-components' }, Breadcrumb: { import: 'Breadcrumb', package: '@fluentui/react-components' }, Button: { import: 'Button', package: '@fluentui/react-components' }, - Callout: { import: 'Popover', package: '@fluentui/react-components' }, Calendar: { import: 'Calendar', package: '@fluentui/react-calendar-compat' }, - CommandBar: { import: 'Toolbar', package: '@fluentui/react-components' }, - CommandBarButton: { import: 'Toolbar', package: '@fluentui/react-components' }, - CommandButton: { import: 'MenuButton', package: '@fluentui/react-components' }, - CompoundButton: { import: 'CompoundButton', package: '@fluentui/react-components' }, + Callout: { import: 'Popover', package: '@fluentui/react-components' }, Checkbox: { import: 'Checkbox', package: '@fluentui/react-components' }, ChoiceGroup: { import: 'RadioGroup', package: '@fluentui/react-components' }, Coachmark: { import: 'TeachingPopover', package: '@fluentui/react-components' }, ComboBox: { import: 'Combobox', package: '@fluentui/react-components' }, + CommandBar: { import: 'Toolbar', package: '@fluentui/react-components' }, + CommandBarButton: { import: 'Toolbar', package: '@fluentui/react-components' }, + CommandButton: { import: 'MenuButton', package: '@fluentui/react-components' }, + CompoundButton: { import: 'CompoundButton', package: '@fluentui/react-components' }, ContextualMenu: { import: 'Menu', package: '@fluentui/react-components' }, - DefaultButton: { import: 'Button', package: '@fluentui/react-components' }, + ContextualMenuItem: { import: 'MenuItem', package: '@fluentui/react-components' }, DatePicker: { import: 'DatePicker', package: '@fluentui/react-datepicker-compat' }, + DefaultButton: { import: 'Button', package: '@fluentui/react-components' }, DetailsList: { import: 'DataGrid', package: '@fluentui/react-components' }, Dialog: { import: 'Dialog', package: '@fluentui/react-components' }, DocumentCard: { import: 'Card', package: '@fluentui/react-components' }, + DocumentCardTitle: { import: 'CardHeader', package: '@fluentui/react-components' }, + DocumentCardPreview: { import: 'CardPreview', package: '@fluentui/react-components' }, Dropdown: { import: 'Dropdown', package: '@fluentui/react-components' }, Fabric: { import: 'FluentProvider', package: '@fluentui/react-components' }, Facepile: { import: 'AvatarGroup', package: '@fluentui/react-components' }, @@ -113,13 +116,16 @@ const MIGRATIONS = { Label: { import: 'Label', package: '@fluentui/react-components' }, Layer: { import: 'Portal', package: '@fluentui/react-components' }, Link: { import: 'Link', package: '@fluentui/react-components' }, + List: { import: 'List', package: '@fluentui/react-components' }, MessageBar: { import: 'MessageBar', package: '@fluentui/react-components' }, Modal: { import: 'Dialog', package: '@fluentui/react-components' }, + Nav: { import: 'Nav', package: '@fluentui/react-components' }, OverflowSet: { import: 'Overflow', package: '@fluentui/react-components' }, Overlay: { import: 'Portal', package: '@fluentui/react-components' }, Panel: { import: 'Drawer', package: '@fluentui/react-components' }, PeoplePicker: { import: 'TagPicker', package: '@fluentui/react-components' }, Persona: { import: 'Persona', package: '@fluentui/react-components' }, + PersonaPresence: { import: 'Persona', package: '@fluentui/react-components' }, Pivot: { import: 'TabList', package: '@fluentui/react-components' }, PivotItem: { import: 'Tab', package: '@fluentui/react-components' }, ProgressIndicator: { import: 'ProgressBar', package: '@fluentui/react-components' }, @@ -128,9 +134,9 @@ const MIGRATIONS = { Separator: { import: 'Divider', package: '@fluentui/react-components' }, Shimmer: { import: 'Skeleton', package: '@fluentui/react-components' }, Slider: { import: 'Slider', package: '@fluentui/react-components' }, - SplitButton: { import: 'SplitButton', package: '@fluentui/react-components' }, SpinButton: { import: 'SpinButton', package: '@fluentui/react-components' }, Spinner: { import: 'Spinner', package: '@fluentui/react-components' }, + SplitButton: { import: 'SplitButton', package: '@fluentui/react-components' }, Stack: { import: 'StackShim', package: '@fluentui/react-components' }, SwatchColorPicker: { import: 'SwatchPicker', package: '@fluentui/react-components' }, TagPicker: { import: 'TagPicker', package: '@fluentui/react-components' }, @@ -138,9 +144,10 @@ const MIGRATIONS = { Text: { import: 'Text', package: '@fluentui/react-components' }, TextField: { import: 'Input', package: '@fluentui/react-components' }, TimePicker: { import: 'TimePicker', package: '@fluentui/react-timepicker-compat' }, - ToggleButton: { import: 'ToggleButton', package: '@fluentui/react-components' }, Toggle: { import: 'Switch', package: '@fluentui/react-components' }, + ToggleButton: { import: 'ToggleButton', package: '@fluentui/react-components' }, Tooltip: { import: 'Tooltip', package: '@fluentui/react-components' }, + TooltipHost: { import: 'Tooltip', package: '@fluentui/react-components' }, }; /** diff --git a/packages/react-components/react-message-bar/library/etc/react-message-bar.api.md b/packages/react-components/react-message-bar/library/etc/react-message-bar.api.md index db2ebad4061521..b551006eddc15f 100644 --- a/packages/react-components/react-message-bar/library/etc/react-message-bar.api.md +++ b/packages/react-components/react-message-bar/library/etc/react-message-bar.api.md @@ -7,6 +7,7 @@ import type { ButtonContextValue } from '@fluentui/react-button'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import type { DistributiveOmit } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; @@ -41,12 +42,25 @@ export type MessageBarActionsState = ComponentState & Pi hasActions: boolean; }; +// @public (undocumented) +export type MessageBarBaseProps = DistributiveOmit; + +// @public (undocumented) +export type MessageBarBaseState = DistributiveOmit; + // @public export const MessageBarBody: ForwardRefComponent; // @public (undocumented) export const messageBarBodyClassNames: SlotClassNames; +// @public (undocumented) +export type MessageBarBodyContextValues = { + link: { + inline?: boolean; + }; +}; + // @public export type MessageBarBodyProps = ComponentProps; @@ -156,7 +170,7 @@ export type MessageBarTransitionContextValue = { }; // @public -export const renderMessageBar_unstable: (state: MessageBarState, contexts: MessageBarContextValues) => JSXElement; +export const renderMessageBar_unstable: (state: MessageBarBaseState, contexts: MessageBarContextValues) => JSXElement; // @public export const renderMessageBarActions_unstable: (state: MessageBarActionsState, contexts: MessageBarActionsContextValues) => JSXElement; @@ -182,9 +196,15 @@ export function useMessageBarActionsContextValue_unstable(): MessageBarActionsCo // @public export const useMessageBarActionsStyles_unstable: (state: MessageBarActionsState) => MessageBarActionsState; +// @public +export const useMessageBarBase_unstable: (props: MessageBarBaseProps, ref: React_2.Ref) => MessageBarBaseState; + // @public export const useMessageBarBody_unstable: (props: MessageBarBodyProps, ref: React_2.Ref) => MessageBarBodyState; +// @public (undocumented) +export function useMessageBarBodyContextValues_unstable(state: MessageBarBodyState): MessageBarBodyContextValues; + // @public export const useMessageBarBodyStyles_unstable: (state: MessageBarBodyState) => MessageBarBodyState; diff --git a/packages/react-components/react-message-bar/library/src/MessageBar.ts b/packages/react-components/react-message-bar/library/src/MessageBar.ts index ffb91018f6786c..3a82d86b4182b7 100644 --- a/packages/react-components/react-message-bar/library/src/MessageBar.ts +++ b/packages/react-components/react-message-bar/library/src/MessageBar.ts @@ -1,4 +1,6 @@ export type { + MessageBarBaseProps, + MessageBarBaseState, MessageBarContextValues, MessageBarIntent, MessageBarProps, @@ -11,5 +13,6 @@ export { renderMessageBar_unstable, useMessageBarContextValue_unstable, useMessageBarStyles_unstable, + useMessageBarBase_unstable, useMessageBar_unstable, } from './components/MessageBar/index'; diff --git a/packages/react-components/react-message-bar/library/src/MessageBarBody.ts b/packages/react-components/react-message-bar/library/src/MessageBarBody.ts index 6b36a72d2517bb..8955ba3b1027d4 100644 --- a/packages/react-components/react-message-bar/library/src/MessageBarBody.ts +++ b/packages/react-components/react-message-bar/library/src/MessageBarBody.ts @@ -10,4 +10,5 @@ export { renderMessageBarBody_unstable, useMessageBarBodyStyles_unstable, useMessageBarBody_unstable, + useMessageBarBodyContextValues_unstable, } from './components/MessageBarBody/index'; diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBar/MessageBar.types.ts b/packages/react-components/react-message-bar/library/src/components/MessageBar/MessageBar.types.ts index 95b0ee35ccf3db..7aad7eb809e947 100644 --- a/packages/react-components/react-message-bar/library/src/components/MessageBar/MessageBar.types.ts +++ b/packages/react-components/react-message-bar/library/src/components/MessageBar/MessageBar.types.ts @@ -1,4 +1,4 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ComponentProps, ComponentState, DistributiveOmit, Slot } from '@fluentui/react-utilities'; import type { MessageBarContextValue } from '../../contexts/messageBarContext'; export type MessageBarSlots = { @@ -53,3 +53,6 @@ export type MessageBarState = ComponentState & */ transitionClassName: string; }; + +export type MessageBarBaseProps = DistributiveOmit; +export type MessageBarBaseState = DistributiveOmit; diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBar/index.ts b/packages/react-components/react-message-bar/library/src/components/MessageBar/index.ts index 5cb0b03985cb3e..6da118dcb122b1 100644 --- a/packages/react-components/react-message-bar/library/src/components/MessageBar/index.ts +++ b/packages/react-components/react-message-bar/library/src/components/MessageBar/index.ts @@ -1,5 +1,7 @@ export { MessageBar } from './MessageBar'; export type { + MessageBarBaseProps, + MessageBarBaseState, MessageBarContextValues, MessageBarIntent, MessageBarProps, @@ -7,6 +9,6 @@ export type { MessageBarState, } from './MessageBar.types'; export { renderMessageBar_unstable } from './renderMessageBar'; -export { useMessageBar_unstable } from './useMessageBar'; +export { useMessageBarBase_unstable, useMessageBar_unstable } from './useMessageBar'; export { messageBarClassNames, useMessageBarStyles_unstable } from './useMessageBarStyles.styles'; export { useMessageBarContextValue_unstable } from './useMessageBarContextValues'; diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBar/renderMessageBar.tsx b/packages/react-components/react-message-bar/library/src/components/MessageBar/renderMessageBar.tsx index 2d81b61e342271..e3470ff1b12e05 100644 --- a/packages/react-components/react-message-bar/library/src/components/MessageBar/renderMessageBar.tsx +++ b/packages/react-components/react-message-bar/library/src/components/MessageBar/renderMessageBar.tsx @@ -3,13 +3,16 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { MessageBarState, MessageBarSlots, MessageBarContextValues } from './MessageBar.types'; +import type { MessageBarBaseState, MessageBarSlots, MessageBarContextValues } from './MessageBar.types'; import { MessageBarContextProvider } from '../../contexts/messageBarContext'; /** * Render the final JSX of MessageBar */ -export const renderMessageBar_unstable = (state: MessageBarState, contexts: MessageBarContextValues): JSXElement => { +export const renderMessageBar_unstable = ( + state: MessageBarBaseState, + contexts: MessageBarContextValues, +): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.test.tsx b/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.test.tsx new file mode 100644 index 00000000000000..57236241bc8057 --- /dev/null +++ b/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.test.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useMessageBar_unstable } from './useMessageBar'; + +describe('useMessageBar', () => { + it('should return state for default props', () => { + const { result } = renderHook(() => useMessageBar_unstable({}, React.createRef())); + + expect(result.current).toMatchObject({ + intent: 'info', + layout: 'singleline', + shape: 'rounded', + }); + + expect(result.current.icon?.children).toBeDefined(); + }); + + it('should return state for custom props', () => { + const { result } = renderHook(() => + useMessageBar_unstable( + { + intent: 'error', + layout: 'multiline', + shape: 'square', + icon: { children: null }, + }, + React.createRef(), + ), + ); + + expect(result.current).toMatchObject({ + intent: 'error', + layout: 'multiline', + shape: 'square', + }); + + expect(result.current.icon?.children).toBeNull(); + }); +}); diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.ts b/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.ts index b32153dd1a2b54..4d590ac4b81421 100644 --- a/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.ts +++ b/packages/react-components/react-message-bar/library/src/components/MessageBar/useMessageBar.ts @@ -3,23 +3,23 @@ import * as React from 'react'; import { getIntrinsicElementProps, slot, useId, useMergedRefs } from '@fluentui/react-utilities'; import { useAnnounce } from '@fluentui/react-shared-contexts'; -import type { MessageBarProps, MessageBarState } from './MessageBar.types'; +import type { MessageBarBaseProps, MessageBarBaseState, MessageBarProps, MessageBarState } from './MessageBar.types'; import { getIntentIcon } from './getIntentIcon'; import { useMessageBarReflow } from './useMessageBarReflow'; import { useMessageBarTransitionContext } from '../../contexts/messageBarTransitionContext'; import { useMotionForwardedRef } from '@fluentui/react-motion'; /** - * Create the state required to render MessageBar. - * - * The returned state can be modified with hooks such as useMessageBarStyles_unstable, - * before being passed to renderMessageBar_unstable. + * Create the base state required to render MessageBar without design-specific props. * - * @param props - props from this instance of MessageBar + * @param props - props from this instance of MessageBar (without shape) * @param ref - reference to root HTMLElement of MessageBar */ -export const useMessageBar_unstable = (props: MessageBarProps, ref: React.Ref): MessageBarState => { - const { layout = 'auto', intent = 'info', politeness, shape = 'rounded' } = props; +export const useMessageBarBase_unstable = ( + props: MessageBarBaseProps, + ref: React.Ref, +): MessageBarBaseState => { + const { layout = 'auto', intent = 'info', politeness } = props; const computedPoliteness = politeness ?? intent === 'info' ? 'polite' : 'assertive'; const autoReflow = layout === 'auto'; const { ref: reflowRef, reflowing } = useMessageBarReflow(autoReflow); @@ -57,11 +57,9 @@ export const useMessageBar_unstable = (props: MessageBarProps, ref: React.Ref): MessageBarState => { + const { shape = 'rounded', ...baseProps } = props; + + const state = useMessageBarBase_unstable(baseProps, ref); + + return { + ...state, shape, + icon: slot.optional(props.icon, { + defaultProps: { + children: getIntentIcon(state.intent), + }, + renderByDefault: true, + elementType: 'div', + }), }; }; diff --git a/packages/react-components/react-message-bar/library/src/components/MessageBarBody/index.ts b/packages/react-components/react-message-bar/library/src/components/MessageBarBody/index.ts index 70cdf388b93951..b4e71a2dbb5eaf 100644 --- a/packages/react-components/react-message-bar/library/src/components/MessageBarBody/index.ts +++ b/packages/react-components/react-message-bar/library/src/components/MessageBarBody/index.ts @@ -7,4 +7,5 @@ export type { } from './MessageBarBody.types'; export { renderMessageBarBody_unstable } from './renderMessageBarBody'; export { useMessageBarBody_unstable } from './useMessageBarBody'; +export { useMessageBarBodyContextValues_unstable } from './useMessageBarBodyContextValues'; export { messageBarBodyClassNames, useMessageBarBodyStyles_unstable } from './useMessageBarBodyStyles.styles'; diff --git a/packages/react-components/react-message-bar/library/src/index.ts b/packages/react-components/react-message-bar/library/src/index.ts index 0bc4ab549edc84..ce5a8ce2db6df4 100644 --- a/packages/react-components/react-message-bar/library/src/index.ts +++ b/packages/react-components/react-message-bar/library/src/index.ts @@ -1,6 +1,7 @@ export { MessageBar, useMessageBarStyles_unstable, + useMessageBarBase_unstable, useMessageBar_unstable, useMessageBarContextValue_unstable, renderMessageBar_unstable, @@ -8,6 +9,8 @@ export { } from './MessageBar'; export type { + MessageBarBaseProps, + MessageBarBaseState, MessageBarProps, MessageBarSlots, MessageBarState, @@ -45,11 +48,17 @@ export { MessageBarBody, useMessageBarBodyStyles_unstable, useMessageBarBody_unstable, + useMessageBarBodyContextValues_unstable, renderMessageBarBody_unstable, messageBarBodyClassNames, } from './MessageBarBody'; -export type { MessageBarBodyProps, MessageBarBodySlots, MessageBarBodyState } from './MessageBarBody'; +export type { + MessageBarBodyProps, + MessageBarBodySlots, + MessageBarBodyState, + MessageBarBodyContextValues, +} from './MessageBarBody'; export { MessageBarContextProvider, diff --git a/packages/react-components/react-toolbar/library/etc/react-toolbar.api.md b/packages/react-components/react-toolbar/library/etc/react-toolbar.api.md index 6642011e9621a5..85b9915cd7377c 100644 --- a/packages/react-components/react-toolbar/library/etc/react-toolbar.api.md +++ b/packages/react-components/react-toolbar/library/etc/react-toolbar.api.md @@ -10,6 +10,8 @@ import type { ButtonState } from '@fluentui/react-button'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ContextSelector } from '@fluentui/react-context-selector'; +import type { DistributiveOmit } from '@fluentui/react-utilities'; +import type { DividerBaseState } from '@fluentui/react-divider'; import type { DividerSlots } from '@fluentui/react-divider'; import type { DividerState } from '@fluentui/react-divider'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; @@ -21,7 +23,7 @@ import type { ToggleButtonProps } from '@fluentui/react-button'; import type { ToggleButtonState } from '@fluentui/react-button'; // @public -export const renderToolbar_unstable: (state: ToolbarState, contextValues: ToolbarContextValues) => JSXElement; +export const renderToolbar_unstable: (state: ToolbarBaseState, contextValues: ToolbarContextValues) => JSXElement; // @public export const renderToolbarGroup_unstable: (state: ToolbarGroupState) => JSXElement; @@ -29,9 +31,21 @@ export const renderToolbarGroup_unstable: (state: ToolbarGroupState) => JSXEleme // @public export const Toolbar: ForwardRefComponent; +// @public (undocumented) +export type ToolbarBaseProps = Omit; + +// @public (undocumented) +export type ToolbarBaseState = Omit; + // @public export const ToolbarButton: ForwardRefComponent; +// @public (undocumented) +export type ToolbarButtonBaseProps = DistributiveOmit; + +// @public (undocumented) +export type ToolbarButtonBaseState = DistributiveOmit; + // @public export type ToolbarButtonProps = ComponentProps & Partial> & { appearance?: 'primary' | 'subtle' | 'transparent'; @@ -59,6 +73,12 @@ export type ToolbarContextValues = { // @public export const ToolbarDivider: ForwardRefComponent; +// @public (undocumented) +export type ToolbarDividerBaseProps = ToolbarDividerProps; + +// @public (undocumented) +export type ToolbarDividerBaseState = ComponentState> & DividerBaseState; + // @public export type ToolbarDividerProps = ComponentProps> & { vertical?: boolean; @@ -93,6 +113,12 @@ export type ToolbarProps = ComponentProps & { // @public export const ToolbarRadioButton: ForwardRefComponent; +// @public (undocumented) +export type ToolbarRadioButtonBaseProps = DistributiveOmit; + +// @public (undocumented) +export type ToolbarRadioButtonBaseState = DistributiveOmit; + // @public export type ToolbarRadioButtonProps = ComponentProps & Partial> & { appearance?: 'primary' | 'subtle' | 'transparent'; @@ -126,6 +152,12 @@ export type ToolbarState = ComponentState & Required; +// @public (undocumented) +export type ToolbarToggleButtonBaseProps = DistributiveOmit; + +// @public (undocumented) +export type ToolbarToggleButtonBaseState = DistributiveOmit; + // @public export type ToolbarToggleButtonProps = ComponentProps & Partial> & { appearance?: 'primary' | 'subtle' | 'transparent'; @@ -139,9 +171,15 @@ export type ToolbarToggleButtonState = ComponentState> & To // @public export const useToolbar_unstable: (props: ToolbarProps, ref: React_2.Ref) => ToolbarState; +// @internal +export const useToolbarBase_unstable: (props: ToolbarBaseProps, ref: React_2.Ref) => ToolbarBaseState; + // @public export const useToolbarButton_unstable: (props: ToolbarButtonProps, ref: React_2.Ref) => ToolbarButtonState; +// @internal +export const useToolbarButtonBase_unstable: (props: ToolbarButtonBaseProps, ref: React_2.Ref) => ToolbarButtonBaseState; + // @public export const useToolbarButtonStyles_unstable: (state: ToolbarButtonState) => void; @@ -154,6 +192,9 @@ export function useToolbarContextValues_unstable(state: ToolbarState): ToolbarCo // @public export const useToolbarDivider_unstable: (props: ToolbarDividerProps, ref: React_2.Ref) => ToolbarDividerState; +// @internal +export const useToolbarDividerBase_unstable: (props: ToolbarDividerBaseProps, ref: React_2.Ref) => ToolbarDividerBaseState; + // @public export const useToolbarDividerStyles_unstable: (state: ToolbarDividerState) => ToolbarDividerState; @@ -166,6 +207,9 @@ export const useToolbarGroupStyles_unstable: (state: ToolbarGroupState) => Toolb // @public export const useToolbarRadioButton_unstable: (props: ToolbarRadioButtonProps, ref: React_2.Ref) => ToolbarRadioButtonState; +// @internal +export const useToolbarRadioButtonBase_unstable: (props: ToolbarRadioButtonBaseProps, ref: React_2.Ref) => ToolbarRadioButtonBaseState; + // @public export const useToolbarRadioButtonStyles_unstable: (state: ToolbarRadioButtonState) => ToolbarRadioButtonState; @@ -175,6 +219,9 @@ export const useToolbarStyles_unstable: (state: ToolbarState) => ToolbarState; // @public export const useToolbarToggleButton_unstable: (props: ToolbarToggleButtonProps, ref: React_2.Ref) => ToolbarToggleButtonState; +// @internal +export const useToolbarToggleButtonBase_unstable: (props: ToolbarToggleButtonBaseProps, ref: React_2.Ref) => ToolbarToggleButtonBaseState; + // @public export const useToolbarToggleButtonStyles_unstable: (state: ToolbarToggleButtonState) => ToolbarToggleButtonState; diff --git a/packages/react-components/react-toolbar/library/src/components/Toolbar/renderToolbar.tsx b/packages/react-components/react-toolbar/library/src/components/Toolbar/renderToolbar.tsx index 669a6407c4221a..c8d1783dd7ce16 100644 --- a/packages/react-components/react-toolbar/library/src/components/Toolbar/renderToolbar.tsx +++ b/packages/react-components/react-toolbar/library/src/components/Toolbar/renderToolbar.tsx @@ -2,13 +2,13 @@ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ToolbarState, ToolbarSlots, ToolbarContextValues } from './Toolbar.types'; +import type { ToolbarBaseState, ToolbarSlots, ToolbarContextValues } from './Toolbar.types'; import { ToolbarContext } from './ToolbarContext'; /** * Render the final JSX of Toolbar */ -export const renderToolbar_unstable = (state: ToolbarState, contextValues: ToolbarContextValues): JSXElement => { +export const renderToolbar_unstable = (state: ToolbarBaseState, contextValues: ToolbarContextValues): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-toolbar/library/src/components/Toolbar/useToolbar.test.tsx b/packages/react-components/react-toolbar/library/src/components/Toolbar/useToolbar.test.tsx new file mode 100644 index 00000000000000..ec35112e1dd9ba --- /dev/null +++ b/packages/react-components/react-toolbar/library/src/components/Toolbar/useToolbar.test.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import type { ToolbarProps } from './Toolbar.types'; +import { useToolbar_unstable } from './useToolbar'; + +describe('useToolbar_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('should return state with default props', () => { + const { result } = renderHook(() => useToolbar_unstable({}, ref)); + + expect(result.current).toMatchObject({ + vertical: false, + size: 'medium', + checkedValues: {}, + components: { + root: 'div', + }, + root: expect.objectContaining({ + role: 'toolbar', + ref, + }), + }); + }); + + it('should reflect custom props in state', () => { + const props = { + checkedValues: { group1: ['item1', 'item2'] }, + size: 'small', + vertical: true, + } satisfies ToolbarProps; + + const { result } = renderHook(() => useToolbar_unstable(props, ref)); + + expect(result.current).toMatchObject({ + checkedValues: props.checkedValues, + size: props.size, + vertical: props.vertical, + root: expect.objectContaining({ + role: 'toolbar', + 'aria-orientation': 'vertical', + ref, + }), + }); + }); + + it('should omit aria-orientation from root when horizontal', () => { + const { result } = renderHook(() => useToolbar_unstable({ size: 'large', vertical: false }, ref)); + + expect(result.current).toMatchObject({ + size: 'large', + vertical: false, + }); + expect(result.current.root).not.toHaveProperty('aria-orientation'); + }); + + it('should render root with Tabster arrow-navigation attributes', () => { + const { result } = renderHook(() => useToolbar_unstable({ size: 'large', vertical: true }, ref)); + + const tabsterAttrOnRoot = result.current.root['data-tabster' as keyof typeof result.current.root]; + + expect(tabsterAttrOnRoot).toMatchInlineSnapshot( + `"{\\"mover\\":{\\"cyclic\\":true,\\"direction\\":0,\\"memorizeCurrent\\":true}}"`, + ); + }); +}); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.test.tsx b/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.test.tsx new file mode 100644 index 00000000000000..f92cef0764a169 --- /dev/null +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useToolbarButton_unstable } from './useToolbarButton'; + +describe('useToolbarButton_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('should return state with default props', () => { + const { result } = renderHook(() => useToolbarButton_unstable({}, ref)); + + expect(result.current).toMatchObject({ + appearance: 'subtle', + size: 'medium', + shape: 'rounded', + vertical: false, + root: expect.objectContaining({ + type: 'button', + ref, + }), + }); + }); + + it('should reflect custom props in state', () => { + const props = { appearance: 'primary', disabled: true, vertical: true } as const; + + const { result } = renderHook(() => useToolbarButton_unstable(props, ref)); + + expect(result.current).toMatchObject({ + appearance: props.appearance, + size: 'medium', + shape: 'rounded', + vertical: props.vertical, + disabled: props.disabled, + }); + }); +}); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.ts index e9eb101f7a212b..bb8ada6f10983c 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarButton/useToolbarButton.ts @@ -1,7 +1,7 @@ 'use client'; import type * as React from 'react'; -import { useButton_unstable } from '@fluentui/react-button'; +import { useButtonBase_unstable } from '@fluentui/react-button'; import type { ToolbarButtonBaseProps, ToolbarButtonBaseState, @@ -19,10 +19,11 @@ export const useToolbarButton_unstable = ( props: ToolbarButtonProps, ref: React.Ref, ): ToolbarButtonState => { - const state = useToolbarButtonBase_unstable(props, ref); + const { appearance = 'subtle', ...baseProps } = props; + const state = useToolbarButtonBase_unstable(baseProps, ref); return { - appearance: 'subtle', + appearance, size: 'medium', shape: 'rounded', ...state, @@ -42,14 +43,7 @@ export const useToolbarButtonBase_unstable = ( ref: React.Ref, ): ToolbarButtonBaseState => { const { vertical = false, ...buttonProps } = props; - const state = useButton_unstable( - { - appearance: 'subtle', - ...buttonProps, - size: 'medium', - }, - ref, - ); + const state = useButtonBase_unstable(buttonProps, ref); return { vertical, diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/ToolbarDivider.types.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/ToolbarDivider.types.ts index 5156d89373100f..68709b1ea0256c 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/ToolbarDivider.types.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/ToolbarDivider.types.ts @@ -1,5 +1,5 @@ import type { ComponentProps, ComponentState } from '@fluentui/react-utilities'; -import type { DividerSlots, DividerState } from '@fluentui/react-divider'; +import type { DividerSlots, DividerState, DividerBaseState } from '@fluentui/react-divider'; /** * ToolbarDivider Props @@ -20,4 +20,4 @@ export type ToolbarDividerBaseProps = ToolbarDividerProps; */ export type ToolbarDividerState = ComponentState> & DividerState; -export type ToolbarDividerBaseState = Omit; +export type ToolbarDividerBaseState = ComponentState> & DividerBaseState; diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.test.tsx b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.test.tsx new file mode 100644 index 00000000000000..353bafb5e5e0e5 --- /dev/null +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.test.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { ToolbarContext } from '../Toolbar/ToolbarContext'; +import { useToolbarDivider_unstable } from './useToolbarDivider'; +import type { ToolbarContextValue } from '../Toolbar/Toolbar.types'; + +const renderWithToolbarContext = (contextValue: Partial) => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + null, + handleRadio: () => null, + ...contextValue, + }} + > + {children} + + ); + + return wrapper; +}; + +describe('useToolbarDivider_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('should return state with default props', () => { + const { result } = renderHook(() => useToolbarDivider_unstable({}, ref)); + + expect(result.current).toMatchObject({ + appearance: 'default', + alignContent: 'center', + inset: false, + vertical: true, + }); + }); + + it('should reflect custom props and toolbar context in state', () => { + const { result } = renderHook(() => useToolbarDivider_unstable({}, ref), { + wrapper: renderWithToolbarContext({ vertical: true }), + }); + + expect(result.current).toMatchObject({ + appearance: 'default', + alignContent: 'center', + inset: false, + vertical: false, + }); + }); +}); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.ts index 0eb9bdf02512f4..38d04e98b4a4cd 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarDivider/useToolbarDivider.ts @@ -7,7 +7,7 @@ import type { ToolbarDividerProps, ToolbarDividerState, } from './ToolbarDivider.types'; -import { useDivider_unstable } from '@fluentui/react-divider'; +import { useDividerBase_unstable } from '@fluentui/react-divider'; import { useToolbarContext_unstable } from '../Toolbar/ToolbarContext'; /** @@ -25,8 +25,10 @@ export const useToolbarDivider_unstable = ( ): ToolbarDividerState => { const state = useToolbarDividerBase_unstable(props, ref); return { - ...state, + alignContent: 'center', appearance: 'default', + inset: false, + ...state, }; }; @@ -43,5 +45,5 @@ export const useToolbarDividerBase_unstable = ( ref: React.Ref, ): ToolbarDividerBaseState => { const vertical = useToolbarContext_unstable(ctx => ctx.vertical); - return useDivider_unstable({ vertical: !vertical, ...props }, ref); + return useDividerBase_unstable({ vertical: !vertical, ...props }, ref); }; diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/ToolbarRadioButton.types.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/ToolbarRadioButton.types.ts index 1a3988de5bf55f..5d12c124b65639 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/ToolbarRadioButton.types.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/ToolbarRadioButton.types.ts @@ -21,4 +21,4 @@ export type ToolbarRadioButtonState = ComponentState> & Required> & Pick; -export type ToolbarRadioButtonBaseState = DistributiveOmit; +export type ToolbarRadioButtonBaseState = DistributiveOmit; diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.test.tsx b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.test.tsx index 7ed2902921dd38..32a9b9dc7ca35d 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.test.tsx +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.test.tsx @@ -1,46 +1,93 @@ -import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; import { ToolbarContext } from '../Toolbar/ToolbarContext'; import { useToolbarRadioButton_unstable } from './useToolbarRadioButton'; +import type { ToolbarContextValue } from '../Toolbar/Toolbar.types'; import type { ToolbarRadioButtonProps } from './ToolbarRadioButton.types'; -const refMock = React.createRef(); const propsMock: ToolbarRadioButtonProps = { name: 'test-radio', value: 'test-value', }; -const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - return ( - +const renderWithToolbarContext = (contextValue: Partial) => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + null, + handleRadio: () => null, + ...contextValue, + }} + > {children} ); + + return wrapper; }; -describe('useToolbarRadioButton', () => { - describe('size', () => { - it('applies the medium size by default', () => { - const { result } = renderHook(() => useToolbarRadioButton_unstable(propsMock, refMock)); +describe('useToolbarRadioButton_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('should return state with default props', () => { + const { result } = renderHook(() => useToolbarRadioButton_unstable(propsMock, ref)); - expect(result.current.size).toBe('medium'); + expect(result.current).toMatchObject({ + appearance: 'secondary', + checked: false, + name: 'test-radio', + value: 'test-value', + size: 'medium', + root: expect.objectContaining({ + role: 'radio', + 'aria-checked': false, + }), }); + expect(result.current.root['aria-pressed']).toBeUndefined(); + }); - it('applies the size from a context', () => { - const { result } = renderHook(() => useToolbarRadioButton_unstable(propsMock, refMock), { - wrapper: Wrapper, - }); + it('should reflect custom props and toolbar context in state', () => { + const { result } = renderHook( + () => useToolbarRadioButton_unstable({ ...propsMock, appearance: 'primary', size: 'small' }, ref), + { + wrapper: renderWithToolbarContext({ checkedValues: { 'test-radio': ['test-value'] }, size: 'large' }), + }, + ); - expect(result.current.size).toBe('large'); + expect(result.current).toMatchObject({ + appearance: 'primary', + checked: true, + name: 'test-radio', + value: 'test-value', + size: 'small', + root: expect.objectContaining({ + role: 'radio', + 'aria-checked': true, + }), }); + }); - it('applies the size from props over context', () => { - const { result } = renderHook(() => useToolbarRadioButton_unstable({ ...propsMock, size: 'small' }, refMock), { - wrapper: Wrapper, - }); + it('should call onClick and toolbar radio handlers', () => { + const handleRadio = jest.fn(); + const onClick = jest.fn(); + const { result } = renderHook(() => useToolbarRadioButton_unstable({ ...propsMock, onClick }, ref), { + wrapper: renderWithToolbarContext({ handleRadio }), + }); + const event = {} as React.MouseEvent; - expect(result.current.size).toBe('small'); + act(() => { + result.current.root.onClick?.(event); }); + + expect(handleRadio).toHaveBeenCalledWith(event, 'test-radio', 'test-value', false); + expect(onClick).toHaveBeenCalledWith(event); }); }); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.ts index 6834c906dc1cf8..e9b8b38225ad3f 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarRadioButton/useToolbarRadioButton.ts @@ -2,7 +2,7 @@ import type * as React from 'react'; import { useEventCallback } from '@fluentui/react-utilities'; -import { useToggleButton_unstable } from '@fluentui/react-button'; +import { useToggleButtonBase_unstable } from '@fluentui/react-button'; import { useToolbarContext_unstable } from '../Toolbar/ToolbarContext'; import type { ToolbarRadioButtonProps, @@ -21,15 +21,15 @@ export const useToolbarRadioButton_unstable = ( props: ToolbarRadioButtonProps, ref: React.Ref, ): ToolbarRadioButtonState => { - const { appearance = 'secondary' } = props; - const size = useToolbarContext_unstable(ctx => ctx.size); - const state = useToolbarRadioButtonBase_unstable(props, ref); + const contextSize = useToolbarContext_unstable(ctx => ctx.size); + const { appearance = 'secondary', size = contextSize, ...baseProps } = props; + const state = useToolbarRadioButtonBase_unstable(baseProps, ref); return { ...state, - appearance, - size: props.size || size, + size, + shape: 'rounded', }; }; @@ -49,7 +49,7 @@ export const useToolbarRadioButtonBase_unstable = ( const checked = useToolbarContext_unstable(ctx => !!ctx.checkedValues[props.name]?.includes(props.value)); const { onClick: onClickOriginal } = props; - const toggleButtonState = useToggleButton_unstable( + const toggleButtonState = useToggleButtonBase_unstable( { checked, role: 'radio', 'aria-checked': checked, ...props }, ref, ); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/ToolbarToggleButton.types.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/ToolbarToggleButton.types.ts index 995a4bd62d8ace..cd2838f64f8bf9 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/ToolbarToggleButton.types.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/ToolbarToggleButton.types.ts @@ -21,4 +21,4 @@ export type ToolbarToggleButtonState = ComponentState> & Required> & Pick; -export type ToolbarToggleButtonBaseState = DistributiveOmit; +export type ToolbarToggleButtonBaseState = DistributiveOmit; diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.test.tsx b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.test.tsx new file mode 100644 index 00000000000000..1dc18953d1e39e --- /dev/null +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { ToolbarContext } from '../Toolbar/ToolbarContext'; +import { useToolbarToggleButton_unstable } from './useToolbarToggleButton'; +import type { ToolbarContextValue } from '../Toolbar/Toolbar.types'; +import type { ToolbarToggleButtonProps } from './ToolbarToggleButton.types'; + +const propsMock: ToolbarToggleButtonProps = { + name: 'text', + value: 'bold', +}; + +const renderWithToolbarContext = (contextValue: Partial) => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + null, + handleRadio: () => null, + ...contextValue, + }} + > + {children} + + ); + + return wrapper; +}; + +describe('useToolbarToggleButton_unstable', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + }); + + it('should return state with default props', () => { + const { result } = renderHook(() => useToolbarToggleButton_unstable(propsMock, ref)); + + expect(result.current).toMatchObject({ + appearance: 'subtle', + checked: false, + name: 'text', + size: 'medium', + shape: 'rounded', + value: 'bold', + }); + }); + + it('should reflect custom props and toolbar context in state', () => { + const { result } = renderHook( + () => useToolbarToggleButton_unstable({ ...propsMock, appearance: 'primary', size: 'small' }, ref), + { + wrapper: renderWithToolbarContext({ checkedValues: { text: ['bold'] } }), + }, + ); + + expect(result.current).toMatchObject({ + appearance: 'primary', + checked: true, + name: 'text', + size: 'small', + shape: 'rounded', + value: 'bold', + }); + }); + + it('should call onClick and toolbar toggle handlers', () => { + const handleToggleButton = jest.fn(); + const onClick = jest.fn(); + const { result } = renderHook(() => useToolbarToggleButton_unstable({ ...propsMock, onClick }, ref), { + wrapper: renderWithToolbarContext({ handleToggleButton }), + }); + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as React.MouseEvent; + + act(() => { + result.current.root.onClick?.(event); + }); + + expect(handleToggleButton).toHaveBeenCalledWith(event, 'text', 'bold', false); + expect(onClick).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.ts b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.ts index 9e7db7de4bc52e..c8d7dc450601fe 100644 --- a/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.ts +++ b/packages/react-components/react-toolbar/library/src/components/ToolbarToggleButton/useToolbarToggleButton.ts @@ -1,7 +1,7 @@ 'use client'; import type * as React from 'react'; -import { useToggleButton_unstable } from '@fluentui/react-button'; +import { useToggleButtonBase_unstable } from '@fluentui/react-button'; import { useToolbarContext_unstable } from '../Toolbar/ToolbarContext'; import type { ToolbarToggleButtonProps, @@ -20,11 +20,15 @@ export const useToolbarToggleButton_unstable = ( props: ToolbarToggleButtonProps, ref: React.Ref, ): ToolbarToggleButtonState => { - const { appearance = 'subtle', ...baseProps } = props; + const contextSize = useToolbarContext_unstable(ctx => ctx.size); + const { appearance = 'subtle', size = contextSize ?? 'medium', ...baseProps } = props; const state = useToolbarToggleButtonBase_unstable(baseProps, ref); + return { - appearance, ...state, + appearance, + size, + shape: 'rounded', }; }; @@ -44,7 +48,7 @@ export const useToolbarToggleButtonBase_unstable = ( const checked = useToolbarContext_unstable(ctx => !!ctx.checkedValues[props.name]?.includes(props.value)); const { onClick: onClickOriginal } = props; - const toggleButtonState = useToggleButton_unstable({ checked, ...props }, ref); + const toggleButtonState = useToggleButtonBase_unstable({ checked, ...props }, ref); const state: ToolbarToggleButtonBaseState = { ...toggleButtonState, name: props.name, diff --git a/packages/react-components/react-toolbar/library/src/index.ts b/packages/react-components/react-toolbar/library/src/index.ts index 43df8ac1872439..11def5adf85cb3 100644 --- a/packages/react-components/react-toolbar/library/src/index.ts +++ b/packages/react-components/react-toolbar/library/src/index.ts @@ -6,24 +6,65 @@ export { useToolbar_unstable, useToolbarContextValues_unstable, useToolbarContext_unstable, + useToolbarBase_unstable, } from './Toolbar'; -export type { ToolbarContextValue, ToolbarContextValues, ToolbarProps, ToolbarSlots, ToolbarState } from './Toolbar'; -export { ToolbarButton, useToolbarButtonStyles_unstable, useToolbarButton_unstable } from './ToolbarButton'; -export type { ToolbarButtonProps, ToolbarButtonState } from './ToolbarButton'; -export { ToolbarDivider, useToolbarDividerStyles_unstable, useToolbarDivider_unstable } from './ToolbarDivider'; -export type { ToolbarDividerProps, ToolbarDividerState } from './ToolbarDivider'; +export type { + ToolbarContextValue, + ToolbarContextValues, + ToolbarProps, + ToolbarSlots, + ToolbarState, + ToolbarBaseState, + ToolbarBaseProps, +} from './Toolbar'; +export { + ToolbarButton, + useToolbarButtonStyles_unstable, + useToolbarButton_unstable, + useToolbarButtonBase_unstable, +} from './ToolbarButton'; +export type { + ToolbarButtonProps, + ToolbarButtonState, + ToolbarButtonBaseState, + ToolbarButtonBaseProps, +} from './ToolbarButton'; +export { + ToolbarDivider, + useToolbarDividerStyles_unstable, + useToolbarDivider_unstable, + useToolbarDividerBase_unstable, +} from './ToolbarDivider'; +export type { + ToolbarDividerProps, + ToolbarDividerState, + ToolbarDividerBaseState, + ToolbarDividerBaseProps, +} from './ToolbarDivider'; export { ToolbarToggleButton, useToolbarToggleButtonStyles_unstable, useToolbarToggleButton_unstable, + useToolbarToggleButtonBase_unstable, +} from './ToolbarToggleButton'; +export type { + ToolbarToggleButtonProps, + ToolbarToggleButtonState, + ToolbarToggleButtonBaseState, + ToolbarToggleButtonBaseProps, } from './ToolbarToggleButton'; -export type { ToolbarToggleButtonProps, ToolbarToggleButtonState } from './ToolbarToggleButton'; export { ToolbarRadioButton, useToolbarRadioButtonStyles_unstable, useToolbarRadioButton_unstable, + useToolbarRadioButtonBase_unstable, +} from './ToolbarRadioButton'; +export type { + ToolbarRadioButtonProps, + ToolbarRadioButtonState, + ToolbarRadioButtonBaseState, + ToolbarRadioButtonBaseProps, } from './ToolbarRadioButton'; -export type { ToolbarRadioButtonProps, ToolbarRadioButtonState } from './ToolbarRadioButton'; export { ToolbarGroup, useToolbarGroupStyles_unstable, @@ -34,15 +75,3 @@ export { export type { ToolbarGroupProps, ToolbarGroupState } from './ToolbarGroup'; export { ToolbarRadioGroup } from './ToolbarRadioGroup'; export type { ToolbarRadioGroupProps, ToolbarRadioGroupState } from './ToolbarRadioGroup'; - -// Experimental APIs -// export type { ToolbarBaseState, ToolbarBaseProps } from './Toolbar'; -// export { useToolbarBase_unstable } from './Toolbar'; -// export type { ToolbarButtonBaseState, ToolbarButtonBaseProps } from './ToolbarButton'; -// export { useToolbarButtonBase_unstable } from './ToolbarButton'; -// export type { ToolbarDividerBaseState, ToolbarDividerBaseProps } from './ToolbarDivider'; -// export { useToolbarDividerBase_unstable } from './ToolbarDivider'; -// export type { ToolbarRadioButtonBaseState, ToolbarRadioButtonBaseProps } from './ToolbarRadioButton'; -// export { useToolbarRadioButtonBase_unstable } from './ToolbarRadioButton'; -// export type { ToolbarToggleButtonBaseState, ToolbarToggleButtonBaseProps } from './ToolbarToggleButton'; -// export { useToolbarToggleButtonBase_unstable } from './ToolbarToggleButton'; diff --git a/tools/workspace-plugin/src/executors/build/executor.ts b/tools/workspace-plugin/src/executors/build/executor.ts index 668a93c3300dcd..8c14640b6a3f0a 100644 --- a/tools/workspace-plugin/src/executors/build/executor.ts +++ b/tools/workspace-plugin/src/executors/build/executor.ts @@ -8,6 +8,7 @@ import { NormalizedOptions, normalizeOptions, processAsyncQueue, runInParallel, import { measureEnd, measureStart } from '../../utils'; import generateApiExecutor from '../generate-api/executor'; +import { type GenerateApiExecutorSchema } from '../generate-api/schema'; import { type BuildExecutorSchema } from './schema'; @@ -22,7 +23,14 @@ const runExecutor: PromiseExecutor = async (schema, context () => runInParallel( () => runBuild(options, context), - () => (options.generateApi ? generateApiExecutor({}, context).then(res => res.success) : Promise.resolve(true)), + () => { + if (!options.generateApi) { + return Promise.resolve(true); + } + const generateApiSchema: GenerateApiExecutorSchema = + typeof options.generateApi === 'object' ? options.generateApi : {}; + return generateApiExecutor(generateApiSchema, context).then(res => res.success); + }, ), () => copyAssets(assetFiles), ); diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index 6fd6ea036aebdd..5316c9a4054f76 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -33,7 +33,7 @@ export interface BuildExecutorSchema { /** * Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API */ - generateApi?: boolean; + generateApi?: boolean | { exportSubpaths?: boolean | { apiReport?: boolean } }; /** * Enable Griffel raw styles output. * This will generate additional files with '.styles.raw.js' extension that contain Griffel raw styles diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index 07738deab70c83..b327a9df840088 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -29,8 +29,32 @@ } }, "generateApi": { - "type": "boolean", - "description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API", + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "exportSubpaths": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "apiReport": { + "type": "boolean", + "description": "Whether to generate api.md reports for each resolved sub-path entry." + } + }, + "additionalProperties": false + } + ], + "description": "Whether to read non-root export map entries from package.json and run api-extractor for each resolved sub-path." + } + }, + "additionalProperties": false + } + ], + "description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API. Pass an object to configure generate-api executor options.", "default": true }, "enableGriffelRawStyles": { diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 729a0ace1645ca..64c701c30ea576 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -232,3 +232,264 @@ describe('GenerateApi Executor', () => { expect(output.success).toBe(true); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Export subpath resolution +// ───────────────────────────────────────────────────────────────────────────── + +describe('GenerateApi Executor – export subpath resolution', () => { + afterEach(() => { + cleanup(); + }); + + /** + * Creates a fixture with configurable export map entries. + * The primary api-extractor.json uses a relative path from config/ to dts/src/. + */ + function prepareExportFixture(config: { wildcardSubDirs?: string[]; namedExports?: string[] }) { + const { wildcardSubDirs = [], namedExports = [] } = config; + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + const exports: Record = { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + }; + if (namedExports.length > 0) { + for (const name of namedExports) { + exports[`./${name}`] = { types: `./dist/${name}/index.d.ts`, import: `./lib/${name}/index.js` }; + } + } + if (wildcardSubDirs.length > 0) { + exports['./*'] = { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }; + } + exports['./package.json'] = './package.json'; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ name: '@proj/proj', types: 'dist/index.d.ts', exports }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), + serializeJson({ + mainEntryPointFilePath: '../dts/src/index.d.ts', + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: true }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); + for (const name of namedExports) { + mkdirSync(join(projRoot, 'dts', 'src', name), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', name, 'index.d.ts'), `export const ${name}: string;`, 'utf-8'); + } + for (const name of wildcardSubDirs) { + mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); + writeFileSync( + join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'), + `export const value: string;`, + 'utf-8', + ); + } + }); + + return { paths, context }; + } + + // ── Wildcard exports ────────────────────────────────────────────────────── + + it('generates correct configs for each wildcard sub-directory', async () => { + const subDirs = ['alpha', 'beta', 'gamma']; + const { paths, context } = prepareExportFixture({ wildcardSubDirs: subDirs }); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + const output = await executor({ ...options, exportSubpaths: true }, context); + + // primary (1) + one per sub-directory + expect(capturedConfigs).toHaveLength(1 + subDirs.length); + expect(output.success).toBe(true); + + const wildcardConfigs = capturedConfigs.slice(1); + for (const name of subDirs) { + const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; + expect(cfg.mainEntryPointFilePath).toContain(`items/${name}/index.d.ts`); + expect(cfg.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', name, 'index.d.ts')); + expect(cfg.apiReportEnabled).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`)); + } + }); + + it('skips wildcard exports with no types field', async () => { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { import: './lib/index.js' }, + './*': { import: './lib/items/*/index.js' }, // no types field + './package.json': './package.json', + }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts')); + writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const x: 1;', 'utf-8'); + }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + await executor(options, context); + + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only + }); + + it('skips wildcard expansion when the resolved declaration directory does not exist', async () => { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, + }, + }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), + serializeJson({ + mainEntryPointFilePath: '../dts/src/index.d.ts', + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: true }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const x: 1;', 'utf-8'); + // dts/src/items/ intentionally NOT created + }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor(options, context); + + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only + expect(output.success).toBe(true); + }); + + it.each([{ exportSubpaths: false } as const, {} as const])( + 'skips export subpath expansion when exportSubpaths=%j', + async overrides => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareExportFixture({ wildcardSubDirs: subDirs }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor({ ...options, ...overrides }, context); + + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); + }, + ); + + // ── Named exports ──────────────────────────────────────────────────────── + + it('generates correct config for named export ./utils', async () => { + const { paths, context } = prepareExportFixture({ namedExports: ['utils'] }); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + // primary + utils — "." and "./package.json" are skipped + expect(capturedConfigs).toHaveLength(2); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.mainEntryPointFilePath).toContain('utils/index.d.ts'); + expect(utilsConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'utils', 'index.d.ts')); + expect(utilsConfig.apiReportEnabled).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(utilsConfig.reportFilePath).toContain('utils.api.md'); + }); + + it('disables apiReport for named exports when exportSubpaths: { apiReport: false }', async () => { + const { context } = prepareExportFixture({ namedExports: ['utils'] }); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: { apiReport: false } }, context); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.apiReportEnabled).toBe(false); + }); + + it('processes both named and wildcard exports in a single package', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareExportFixture({ wildcardSubDirs: subDirs, namedExports: ['utils'] }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor({ ...options, exportSubpaths: true }, context); + + // primary (1) + utils (1) + wildcard sub-dirs (2) + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length); + expect(output.success).toBe(true); + }); +}); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 1f9b9b822c5dc1..bf0961e8f62cd4 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -1,14 +1,14 @@ -import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { execSync } from 'node:child_process'; - +import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; import { measureEnd, measureStart } from '../../utils'; -import { isCI } from './lib/shared'; +import { isCI, verboseLog } from './lib/shared'; +import { getExportSubpathConfigs } from './lib/utils'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('GenerateApiExecutor'); @@ -26,14 +26,30 @@ export default runExecutor; // =========== -interface NormalizedOptions extends ReturnType {} +export interface NormalizedOptions extends ReturnType {} async function runGenerateApi(options: NormalizedOptions, context: ExecutorContext): Promise { - if (generateTypeDeclarations(options)) { - return apiExtractor(options, context); + if (!generateTypeDeclarations(options)) { + return false; } - return false; + // Run primary api-extractor config + if (!apiExtractor({ configPath: options.config }, options, context)) { + return false; + } + + // Expand export subpaths and run api-extractor for each resolved entry + if (options.exportSubpaths.enabled) { + const subpathConfigs = getExportSubpathConfigs(options); + for (const configObject of subpathConfigs) { + verboseLog(`Running api-extractor for export subpath entry: ${configObject.mainEntryPointFilePath}`); + if (!apiExtractor({ configObject }, options, context)) { + return false; + } + } + } + + return true; } function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorContext) { @@ -44,6 +60,13 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo }; const resolvedSchema = { ...defaults, ...schema }; + // Normalize exportSubpaths into { enabled, apiReport } + const rawExportSubpaths = resolvedSchema.exportSubpaths; + const exportSubpaths = + typeof rawExportSubpaths === 'object' && rawExportSubpaths !== null + ? { enabled: true, apiReport: rawExportSubpaths.apiReport !== false } + : { enabled: rawExportSubpaths === true, apiReport: true }; + const project = context.projectsConfigurations!.projects[context.projectName!]; const resolveLocalFlag = Boolean(process.env.__FORCE_API_MD_UPDATE__) || (isCI() ? false : resolvedSchema.local); @@ -62,6 +85,7 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo return { ...resolvedSchema, + exportSubpaths, local: resolveLocalFlag, config: resolveConfig.result!, project, @@ -92,15 +116,18 @@ function generateTypeDeclarations(options: NormalizedOptions) { } } -function apiExtractor(options: NormalizedOptions, context: ExecutorContext) { - const extractorConfigPath = options.config; +function apiExtractor( + configSource: { configPath: string } | { configObject: IConfigFile }, + options: NormalizedOptions, + context: ExecutorContext, +) { + const { rawConfig, fullPath } = resolveConfigSource(); // Load,parse,customize and prepare the api-extractor.json file for API Extractor API - const rawExtractorConfig = ExtractorConfig.loadFile(extractorConfigPath); - customizeExtractorConfig(rawExtractorConfig); + customizeExtractorConfig(rawConfig); const extractorConfig = ExtractorConfig.prepare({ - configObject: rawExtractorConfig, - configObjectFullPath: extractorConfigPath, + configObject: rawConfig, + configObjectFullPath: fullPath, packageJsonFullPath: options.packageJsonPath, }); @@ -125,6 +152,25 @@ function apiExtractor(options: NormalizedOptions, context: ExecutorContext) { ); return false; + /** + * Resolves the config source into a raw IConfigFile and the full path used for token resolution. + * File-based sources are loaded from disk; programmatic configs reuse the primary config path. + */ + function resolveConfigSource(): { rawConfig: IConfigFile; fullPath: string } { + if ('configPath' in configSource) { + return { + rawConfig: ExtractorConfig.loadFile(configSource.configPath), + fullPath: configSource.configPath, + }; + } + + return { + rawConfig: configSource.configObject, + // Reuse the primary config path so that token resolution matches file-based configs. + fullPath: options.config, + }; + } + function customizeExtractorConfig(apiExtractorConfig: IConfigFile) { apiExtractorConfig.compiler = getTsConfigForApiExtractor({ packageJson: parseJson(readFileSync(options.packageJsonPath, 'utf-8')), @@ -214,7 +260,7 @@ function enableAllowSyntheticDefaultImports(options: { pkgJson: PackageJson }) { return shouldEnable ? { allowSyntheticDefaultImports: true } : null; } -function getApiExtractorConfigPath(schema: Required, projectRoot: string) { +function getApiExtractorConfigPath(schema: Required>, projectRoot: string) { const configPath = schema.config.replace('{projectRoot}', projectRoot); if (!existsSync(configPath)) { @@ -243,9 +289,3 @@ function getTsConfigPathUsedForProduction(projectRoot: string) { return { error: null, result: tsConfigFileForCompilation }; } - -function verboseLog(message: string, kind: keyof typeof logger = 'info') { - if (process.env.NX_VERBOSE_LOGGING === 'true') { - logger[kind](message); - } -} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts b/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts index 90c09255805ff8..3f1393fb3eab73 100644 --- a/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts +++ b/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts @@ -1,3 +1,5 @@ +import { logger } from '@nx/devkit'; + export function isCI() { return ( (process.env.CI && process.env.CI !== 'false') || @@ -5,3 +7,9 @@ export function isCI() { process.env.GITHUB_ACTIONS === 'true' ); } + +export function verboseLog(message: string, kind: keyof typeof logger = 'info') { + if (process.env.NX_VERBOSE_LOGGING === 'true') { + logger[kind](message); + } +} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts new file mode 100644 index 00000000000000..6ec2d4dc32f13a --- /dev/null +++ b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts @@ -0,0 +1,265 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { parseJson } from '@nx/devkit'; +import { type IConfigFile } from '@microsoft/api-extractor'; + +import type { PackageJson } from '../../../types'; + +import type { NormalizedOptions } from '../executor'; +import { verboseLog } from './shared'; + +function isTypedEntry(exportValue: unknown): exportValue is { types: string } & Record { + return typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; +} + +/** + * Checks whether a single export map entry is a wildcard entry with a `types` field. + */ +function isWildcardTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + return exportKey.includes('*') && isTypedEntry(exportValue); +} + +/** + * Checks whether a single export map entry is a named (non-wildcard, non-root) entry with a `types` field. + * Skips `"."` and `"./package.json"`. + */ +function isNamedTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) { + return false; + } + return isTypedEntry(exportValue); +} + +/** + * Reads the package.json exports map and resolves both wildcard entries (e.g. "./*") and named + * entries (e.g. "./utils") into individual api-extractor config objects. + * + * - Wildcard entries are expanded into one config per sub-directory found under the resolved source path. + * - Named entries produce a single config each, derived directly from their types field. + * - The root export (".") and "./package.json" are always skipped. + */ +export function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile[] { + const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); + + const exports = packageJson.exports ?? {}; + + const declarationBase = resolveDeclarationBase(options, packageJson); + if (!declarationBase) { + return []; + } + + const apiReportEnabled = options.exportSubpaths.apiReport; + + const configs: IConfigFile[] = []; + + for (const [exportKey, exportValue] of Object.entries(exports)) { + // Wildcard entries: expand into sub-directories + if (isWildcardTypedEntry(exportKey, exportValue)) { + const pathPrefixes = parseWildcardTypesPattern(exportValue.types); + if (!pathPrefixes) { + continue; + } + + const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); + const subDirs = listSubDirectories(declarationScanDir); + if (!subDirs) { + continue; + } + + for (const dirName of subDirs) { + configs.push( + createSubpathEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + subPath: pathPrefixes.wildcardSubPath + dirName, + distRelativePath: pathPrefixes.distRelativePrefix + dirName, + reportFileName: dirName, + apiReportEnabled, + }), + ); + } + continue; + } + + // Named entries: create config directly from types field + if (isNamedTypedEntry(exportKey, exportValue)) { + const parsed = parseNamedTypesPattern(exportValue.types); + if (!parsed) { + continue; + } + + const subpathName = exportKey.replace(/^\.\//, ''); + + configs.push( + createSubpathEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + subPath: parsed.declarationSubPath, + distRelativePath: parsed.distRelativePath, + reportFileName: subpathName, + apiReportEnabled, + }), + ); + } + } + + return configs; + + /** + * Resolves the declaration base path from the primary api-extractor config's mainEntryPointFilePath. + * The primary config uses tokens (, , ) which we + * resolve here so that programmatic configs for wildcard entries land in the same output tree. + * + * @returns The absolute path to the declaration base, or `null` if it cannot be resolved. + */ + function resolveDeclarationBase(opts: NormalizedOptions, pkgJson: PackageJson): string | null { + const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(opts.config, 'utf-8')); + const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; + if (!primaryMainEntryTemplate) { + return null; + } + + const unscopedPackageName = (pkgJson.name ?? '').replace(/^@[^/]+\//, ''); + // and in api-extractor.json are NOT replaced before path.resolve — + // they act as literal path segments that the subsequent "../" chain traverses through. + // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. + const configDir = dirname(opts.config); + const resolvedPrimaryEntry = resolve( + configDir, + primaryMainEntryTemplate.replace(//g, unscopedPackageName), + ); + + const indexDtsSuffix = '/index.d.ts'; + if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { + verboseLog( + `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${indexDtsSuffix}". ` + + `Skipping export subpath expansion.`, + 'warn', + ); + return null; + } + + return resolvedPrimaryEntry.slice(0, -indexDtsSuffix.length); + } + + /** + * Parses a wildcard types pattern and derives the dist-relative prefix and the + * wildcard sub-path (the portion after the first path segment). + * + * Example: "./dist/items/STAR/index.d.ts" + * → distRelativePrefix: "dist/items/" + * → wildcardSubPath: "items/" + * + * @returns The path prefixes, or `null` if the pattern cannot be parsed. + */ + function parseWildcardTypesPattern(typesPattern: string): { + distRelativePrefix: string; + wildcardSubPath: string; + } | null { + const starIdx = typesPattern.indexOf('*'); + if (starIdx === -1) { + return null; + } + + const typesPrefix = typesPattern.slice(0, starIdx); + const distRelativePrefix = typesPrefix.replace(/^\.\//, ''); + + // Extract the sub-path by stripping the first path segment (the dist directory name). + // e.g. "dist/items/" → "items/" + const firstSlashIdx = distRelativePrefix.indexOf('/'); + const wildcardSubPath = firstSlashIdx === -1 ? '' : distRelativePrefix.slice(firstSlashIdx + 1); + + return { distRelativePrefix, wildcardSubPath }; + } + + /** + * Parses a named (non-wildcard) types pattern and derives the dist-relative path and + * the declaration sub-path (the portion after the first path segment, minus index.d.ts). + * + * Example: "./dist/utils/index.d.ts" + * → distRelativePath: "dist/utils" + * → declarationSubPath: "utils" + * + * @returns The path components, or `null` if the pattern cannot be parsed. + */ + function parseNamedTypesPattern(typesPattern: string): { + distRelativePath: string; + declarationSubPath: string; + } | null { + const indexDtsSuffix = '/index.d.ts'; + if (!typesPattern.endsWith(indexDtsSuffix)) { + return null; + } + + // Strip "./" prefix and trailing "/index.d.ts" + const distRelativePath = typesPattern.replace(/^\.\//, '').slice(0, -indexDtsSuffix.length); + + // Strip the first path segment (the dist directory name) + const firstSlashIdx = distRelativePath.indexOf('/'); + const declarationSubPath = firstSlashIdx === -1 ? '' : distRelativePath.slice(firstSlashIdx + 1); + + if (!declarationSubPath) { + return null; + } + + return { distRelativePath, declarationSubPath }; + } + + /** + * Lists immediate sub-directories of the given path. + * + * @returns Array of directory names, or `null` if the path does not exist or cannot be read. + */ + function listSubDirectories(dirPath: string): string[] | null { + if (!existsSync(dirPath)) { + verboseLog(`Export subpath source dir not found, skipping: ${dirPath}`, 'warn'); + return null; + } + + try { + return readdirSync(dirPath, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + return null; + } + } + + /** + * Creates an api-extractor IConfigFile for a single export sub-path entry. + */ + function createSubpathEntryConfig(params: { + projectAbsolutePath: string; + declarationBase: string; + subPath: string; + distRelativePath: string; + reportFileName: string; + apiReportEnabled: boolean; + }): IConfigFile { + const mainEntryPointFilePath = join(params.declarationBase, params.subPath, 'index.d.ts'); + const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePath, 'index.d.ts'); + + return { + projectFolder: params.projectAbsolutePath, + mainEntryPointFilePath, + apiReport: { + enabled: params.apiReportEnabled, + reportFileName: params.reportFileName, + reportFolder: '/etc/', + reportTempFolder: '/temp/', + }, + docModel: { enabled: false }, + dtsRollup: { + enabled: true, + untrimmedFilePath: dtsRollupPath, + }, + tsdocMetadata: { enabled: false }, + }; + } +} diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts index a4c1b28b945894..8b8020569728ea 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts +++ b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts @@ -2,4 +2,5 @@ export interface GenerateApiExecutorSchema { config?: string; local?: boolean; diagnostics?: boolean; + exportSubpaths?: boolean | { apiReport?: boolean }; } diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.json b/tools/workspace-plugin/src/executors/generate-api/schema.json index 0fa20bb5ba3c19..8d2ac506305a65 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.json +++ b/tools/workspace-plugin/src/executors/generate-api/schema.json @@ -19,6 +19,24 @@ "type": "boolean", "description": "Show diagnostic messages used for troubleshooting problems with API Extractor", "default": false + }, + "exportSubpaths": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "apiReport": { + "type": "boolean", + "description": "Whether to generate api.md reports for each resolved sub-path entry.", + "default": true + } + }, + "additionalProperties": false + } + ], + "description": "Whether to read non-root export map entries from package.json and run api-extractor for each resolved sub-path. When false or omitted, only the primary config is processed.", + "default": false } }, "required": [] diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index c6adde7aaa4b5f..b1e8cc100dd547 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -591,7 +591,6 @@ describe(`workspace-plugin`, () => { "{projectRoot}/lib", "{projectRoot}/lib-commonjs", "{projectRoot}/dist", - "{projectRoot}/dist/index.d.ts", "{projectRoot}/etc/proj.api.md", ], }, @@ -821,6 +820,74 @@ describe(`workspace-plugin`, () => { `); }); + describe('generate-api target', () => { + const v9LibFiles = { + 'proj/project.json': serializeJson({ + root: 'proj', + name: 'proj', + projectType: 'library', + tags: ['vNext'], + } satisfies ProjectConfiguration), + 'proj/package.json': serializeJson({ + name: '@proj/proj', + private: true, + } satisfies Partial), + }; + + async function setupAndGetGenerateApiTarget(extraFiles: Record = {}) { + await tempFs.createFiles({ ...v9LibFiles, ...extraFiles }); + const results = await createNodesFunction(['proj/project.json'], options, context); + return getTargets(results)?.['generate-api']; + } + + it('should use default inputs and outputs for a standard v9 library', async () => { + const target = await setupAndGetGenerateApiTarget(); + + expect(target?.inputs).toEqual([ + '{projectRoot}/config/api-extractor.json', + '{projectRoot}/tsconfig.json', + '{projectRoot}/tsconfig.lib.json', + '{projectRoot}/src/**/*.tsx?', + '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', + { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, + ]); + expect(target?.outputs).toEqual(['{projectRoot}/dist/index.d.ts', '{projectRoot}/etc/proj.api.md']); + }); + + it('should add glob outputs when user configures exportSubpaths on generate-api target', async () => { + const extraFiles = { + 'proj/project.json': serializeJson({ + root: 'proj', + name: 'proj', + projectType: 'library', + tags: ['vNext'], + targets: { + 'generate-api': { + options: { + exportSubpaths: true, + }, + }, + }, + } satisfies ProjectConfiguration), + }; + await tempFs.createFiles({ ...v9LibFiles, ...extraFiles }); + const results = await createNodesFunction(['proj/project.json'], options, context); + const targets = getTargets(results); + + const generateApiTarget = targets?.['generate-api']; + expect(generateApiTarget?.outputs).toEqual(['{projectRoot}/dist/**/*.d.ts', '{projectRoot}/etc/*.api.md']); + + const buildTarget = targets?.build; + expect(buildTarget?.options?.generateApi).toEqual({ exportSubpaths: true }); + expect(buildTarget?.outputs).toEqual([ + '{projectRoot}/lib', + '{projectRoot}/lib-commonjs', + '{projectRoot}/dist', + '{projectRoot}/etc/*.api.md', + ]); + }); + }); + it('should add verify-packaging task only if package is not private', async () => { await tempFs.createFiles({ 'proj/project.json': serializeJson({ diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index d6208797e27394..6e1b444fd4ba0a 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -201,27 +201,9 @@ function buildWorkspaceProjectConfiguration( // library - targets['generate-api'] = { - cache: true, - executor: '@fluentui/workspace-plugin:generate-api', - inputs: [ - '{projectRoot}/config/api-extractor.json', - '{projectRoot}/tsconfig.json', - '{projectRoot}/tsconfig.lib.json', - '{projectRoot}/src/**/*.tsx?', - // trigger affected or cache invalidation on generate-api target if scripts-api-extractor changed - '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', - { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, - ], - outputs: [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`], - metadata: { - technologies: ['typescript', 'api-extractor'], - help: { - command: `${config.pmc.exec} nx run ${config.projectJSON.name}:generate-api --help`, - example: {}, - }, - }, - }; + targets['generate-api'] = buildGenerateApiTarget(projectRoot, config); + + const { value: userExportSubpaths, enabled: userEnabledExportSubpaths } = resolveExportSubpathsOption(config); targets.build = { cache: true, @@ -235,6 +217,7 @@ function buildWorkspaceProjectConfiguration( config.tags.includes('ships-amd') ? { module: 'amd', outputPath: 'lib-amd' } : null, ].filter(Boolean) as BuildExecutorSchema['moduleOutput'], enableGriffelRawStyles: true, + ...(userEnabledExportSubpaths ? { generateApi: { exportSubpaths: userExportSubpaths } } : null), // NOTE: assets should be set per project needs // assets: [], } satisfies BuildExecutorSchema, @@ -253,7 +236,8 @@ function buildWorkspaceProjectConfiguration( `{projectRoot}/lib-commonjs`, config.tags.includes('ships-amd') ? `{projectRoot}/lib-amd` : null, `{projectRoot}/dist`, - ...targets['generate-api'].outputs!, + // only spread etc/ outputs from generate-api (dist/ is already covered by {projectRoot}/dist above) + ...targets['generate-api'].outputs!.filter(outputPath => !outputPath.startsWith('{projectRoot}/dist')), ].filter(Boolean) as string[], metadata: { technologies: ['swc', 'typescript', 'api-extractor'], @@ -287,6 +271,45 @@ function buildWorkspaceProjectConfiguration( return { targets }; } +function resolveExportSubpathsOption(config: TaskBuilderConfig): { + value: boolean | { apiReport?: boolean }; + enabled: boolean; +} { + const value = config.projectJSON.targets?.['generate-api']?.options?.exportSubpaths; + const enabled = value === true || (typeof value === 'object' && value !== null); + return { value, enabled }; +} + +function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration { + const { enabled: hasExportSubpaths } = resolveExportSubpathsOption(config); + + return { + cache: true, + executor: '@fluentui/workspace-plugin:generate-api', + inputs: [ + '{projectRoot}/config/api-extractor.json', + '{projectRoot}/tsconfig.json', + '{projectRoot}/tsconfig.lib.json', + '{projectRoot}/src/**/*.tsx?', + // trigger affected or cache invalidation on generate-api target if scripts-api-extractor changed + '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', + { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, + ], + // When exportSubpaths is enabled, use broad globs for outputs + // — the executor resolves exact paths at runtime. + outputs: hasExportSubpaths + ? ['{projectRoot}/dist/**/*.d.ts', '{projectRoot}/etc/*.api.md'] + : [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`], + metadata: { + technologies: ['typescript', 'api-extractor'], + help: { + command: `${config.pmc.exec} nx run ${config.projectJSON.name}:generate-api --help`, + example: {}, + }, + }, + }; +} + function buildTestTarget( projectRoot: string, options: Required,